手把手教你实现手绘风格图形
大家好,我是 漫步,今天分享一篇高難度的圖形繪制文章。
Rough.js[1]是一個手繪風(fēng)格的圖形庫,提供了一些基本圖形的繪制能力,比如:雖然筆者是個糙漢子,但是對這種可愛的東西都沒啥抵抗力,這個庫的使用本身很簡單,沒什么好說的,但是它只有繪制能力,沒有交互能力,所以使用場景有限,先來用它畫個示例圖形:
import?rough?from?'roughjs/bundled/rough.esm.js'this.rc?=?rough.canvas(this.$refs.canvas) this.rc.rectangle(100,?150,?300,?200,?{fillweight:?0,roughness:?3 }) this.rc.circle(195,?220,?40,?{fill:?'red' }) this.rc.circle(325,?220,?40,?{fill:?'red' }) this.rc.rectangle(225,?270,?80,?30,?{fill:?'red',fillweight:?5 }) this.rc.line(200,?150,?150,?80,?{?roughness:?5?}) this.rc.line(300,?150,?350,?80,?{?roughness:?2?})效果如下:是不是有點(diǎn)蠢萌,本文的主要內(nèi)容是帶大家手動實(shí)現(xiàn)上面的圖形,最終效果預(yù)覽:lxqnsys.com/#/demo/hand…[2]。話不多說,代碼見。
線段
萬物基于線段,所以先來看線段怎么畫,仔細(xì)看上圖會發(fā)現(xiàn)手繪版線段其實(shí)是用兩根彎曲的線段組成的,曲線可以使用貝塞爾曲線來畫,這里使用三次貝塞爾曲線,那么剩下的問題就是求起點(diǎn)、終點(diǎn)、兩個控制點(diǎn)的坐標(biāo)了。貝塞爾曲線可以在這個網(wǎng)站上嘗試:cubic-bezier.com/[3]。首先一條線段的起點(diǎn)和終點(diǎn)我們都給它加一點(diǎn)隨機(jī)值,隨機(jī)值比如就在[-2,2]之間,也可以把這個范圍和線段的長度關(guān)聯(lián)起來,比如線段越長,隨機(jī)值就越大。
//?直線變曲線 _line?(x1,?y1,?x2,?y2)?{let?result?=?[]//?起始點(diǎn)result[0]?=?x1?+?this.random(-this.offset,?this.offset)result[1]?=?y1?+?this.random(-this.offset,?this.offset)//?終點(diǎn)result[2]?=?x2?+?this.random(-this.offset,?this.offset)result[3]?=?y2?+?this.random(-this.offset,?this.offset) }接下來就是兩個控制點(diǎn),我們把控制點(diǎn)限定在線段所在的矩形內(nèi):
_line?(x1,?y1,?x2,?y2)?{let?result?=?[]//?起始點(diǎn)//?...//?終點(diǎn)//?...//?兩個控制點(diǎn)let?xo?=?x2?-?x1let?yo?=?y2?-?y1let?randomFn?=?(x)?=>?{return?x?>?0???this.random(0,?x)?:?this.random(x,?0)}result[4]?=?x1?+?randomFn(xo)result[5]?=?y1?+?randomFn(yo)result[6]?=?x1?+?randomFn(xo)result[7]?=?y1?+?randomFn(yo)return?result }然后把上面生成的曲線繪制出來:
//?繪制手繪線段 line?(x1,?y1,?x2,?y2)?{this.drawDoubleLine(x1,?y1,?x2,?y2) }//?繪制兩條曲線 drawDoubleLine?(x1,?y1,?x2,?y2)?{//?繪制生成的兩條曲線let?line1?=?this._line(x1,?y1,?x2,?y2)let?line2?=?this._line(x1,?y1,?x2,?y2)this.drawLine(line1)this.drawLine(line2) }//?繪制單條曲線 drawLine?(line)?{this.ctx.beginPath()this.ctx.moveTo(line[0],?line[1])//?bezierCurveTo方法前兩個點(diǎn)為控制點(diǎn),第三個點(diǎn)為結(jié)束點(diǎn)this.ctx.bezierCurveTo(line[4],?line[5],?line[6],?line[7],?line[2],?line[3])this.ctx.strokeStyle?=?'#000'this.ctx.stroke() }效果如下:但是多試幾次就會發(fā)現(xiàn)偏離太遠(yuǎn)、彎曲程度過大:完全不像一個手正常的人能畫出來的,去上面的貝塞爾曲線網(wǎng)站上試幾次會發(fā)現(xiàn)兩個控制點(diǎn)離線段越近,曲線彎曲程度越小:所以我們要找線段附近的點(diǎn)作為控制點(diǎn),首先隨機(jī)一個橫坐標(biāo)點(diǎn),然后可以計算出線段上該橫坐標(biāo)對應(yīng)的縱坐標(biāo)點(diǎn),把該縱坐標(biāo)點(diǎn)加減一點(diǎn)隨機(jī)值即可。
_line?(x1,?y1,?x2,?y2)?{let?result?=?[]//?...//?兩個控制點(diǎn)let?c1?=?this.getNearRandomPoint(x1,?y1,?x2,?y2)let?c2?=?this.getNearRandomPoint(x1,?y1,?x2,?y2)result[4]?=?c1[0]result[5]?=?c1[1]result[6]?=?c2[0]result[7]?=?c2[1]return?result }//?計算兩個點(diǎn)連成的線段上附近的一個隨機(jī)點(diǎn) getNearRandomPoint?(x1,?y1,?x2,?y2)?{let?xo,?yo,?rx,?ry//?垂直x軸的線段特殊處理if?(x1?===?x2)?{yo?=?y2?-?y1rx?=?x1?+?this.random(-2,?2)//?在橫坐標(biāo)附近找一個隨機(jī)點(diǎn)ry?=?y1?+?yo?*?this.random(0,?1)//?在線段上找一個隨機(jī)點(diǎn)return?[rx,?ry]}xo?=?x2?-?x1rx?=?x1?+?xo?*?this.random(0,?1)//?找一個隨機(jī)的橫坐標(biāo)ry?=?((rx?-?x1)?*?(y2?-?y1))?/?(x2?-?x1)?+?y1//?通過兩點(diǎn)式求出直線方程ry?+=?this.random(-2,?2)//?縱坐標(biāo)加一點(diǎn)隨機(jī)值return?[rx,?ry] }看一下效果:當(dāng)然和Rough.js比起來還是不夠好,有興趣的可以自行去看一下源碼,反正筆者是看不懂,控制變量太多,還沒有注釋。
多邊形&矩形
多邊形就是把多個點(diǎn)首尾相連起來,遍歷頂點(diǎn)調(diào)用繪制線段的方法即可:
//?繪制手繪多邊形 polygon?(points?=?[],?opt?=?{})?{if?(points.length?<?3)?{return}let?len?=?points.lengthfor?(let?i?=?0;?i?<?len?-?1;?i++)?{this.line(points[i][0],?points[i][1],?points[i?+?1][0],?points[i?+?1][1])}//?首尾相連this.line(points[len?-?1][0],?points[len?-?1][1],?points[0][0],?points[0][1]) }矩形是多邊形的一種特殊情況,四個角都是直角,一般傳參為左上角頂點(diǎn)的x坐標(biāo)、y坐標(biāo)、矩形的寬、矩形的高:
//?繪制手繪矩形 rectangle?(x,?y,?width,?height,?opt?=?{})?{let?points?=?[[x,?y],[x?+?width,?y],[x?+?width,?y?+?height],[x,?y?+?height]]this.polygon(points,?opt) }image-20210207161756507.png圓
圓要怎么處理呢,首先大家都知道圓是可以使用多邊形來近似得到的,只要多邊形的邊足夠多,那么看起來就足夠圓,既然不想要太圓,那就把它恢復(fù)成多邊形好了,多邊形上面已經(jīng)講過了。恢復(fù)成多邊形很簡單,比如我們要把一個圓變成十邊形(具體還原成幾邊形你也可以和圓的周長關(guān)聯(lián)起來),那么每個邊對應(yīng)的弧度就是2*Math.PI/10,然后使用Math.cos和Math.sin來計算頂點(diǎn)的位置,最后再調(diào)用繪制多邊形的方法進(jìn)行繪制:
//?繪制手繪圓 circle?(x,?y,?r)?{let?stepCount?=?10let?step?=?(2?*?Math.PI)?/?stepCountlet?points?=?[]for?(let?angle?=?0;?angle?<?2?*?Math.PI;?angle?+=?step)?{let?p?=?[x?+?r?*?Math.cos(angle),y?+?r?*?Math.sin(angle)]points.push(p)}this.polygon(points) }效果如下:可以看到效果很一般,就算邊的數(shù)量再多一點(diǎn)看起來也不像:如果直接用正常的線段連起來,那完全就是個正經(jīng)多邊形了,肯定也不行,所以核心是把線段變成隨機(jī)弧形,首先為了增加隨機(jī)性,我們把圓的半徑和各個頂點(diǎn)都加一點(diǎn)隨機(jī)增量:
circle?(x,?y,?r)?{let?stepCount?=?10let?step?=?(2?*?Math.PI)?/?stepCountlet?points?=?[]let?rx?=?r?+?this.random(-r?*?0.05,?r?*?0.05)let?ry?=?r?+?this.random(-r?*?0.05,?r?*?0.05)for?(let?angle?=?0;?angle?<?2?*?Math.PI;?angle?+=?step)?{let?p?=?[x?+?rx?*?Math.cos(angle)?+?this.random(-2,?2),y?+?ry?*?Math.sin(angle)?+?this.random(-2,?2)]points.push(p)} }接下來的問題又變成了計算貝塞爾曲線的兩個控制點(diǎn),首先因?yàn)榛【€肯定是要往多邊形外凸的,根據(jù)貝塞爾曲線的性質(zhì),兩個控制點(diǎn)一定是在線段的外面,直接用線段本身的兩個端點(diǎn)來計算的話我試了一下,比較難處理,不同的角度可能都需要特殊處理,所以我們參考Rough.js間隔一個點(diǎn):比如上圖的多邊形我們隨便找一個線段bc,對于點(diǎn)b來說上一個點(diǎn)是a,下一個點(diǎn)是c,b點(diǎn)分別加上c減a的橫坐標(biāo)縱坐標(biāo)之差,得到了控制點(diǎn)c1,其他點(diǎn)也是一樣,最后算出來的控制點(diǎn)都會在外面,現(xiàn)在還差一個控制點(diǎn),我們不要讓點(diǎn)c閑著,也給它加上前后兩點(diǎn)之差:可以看到點(diǎn)c的控制點(diǎn)c2和c1都在同一側(cè),這樣畫出來的曲線顯然是朝一個方向的:我們讓它對稱一下,讓點(diǎn)c的前一個點(diǎn)減后一個點(diǎn):這樣畫出來的曲線仍然不行:原因很簡單,控制點(diǎn)離的太遠(yuǎn)了,所以我們少加一點(diǎn)差值,最后代碼如下:
circle?(x,?y,?r)?{//?...let?len?=?points.lengththis.ctx.beginPath()//?路徑的起點(diǎn)移到第一個點(diǎn)this.ctx.moveTo(points[0][0],?points[0][1])this.ctx.strokeStyle?=?'#000'for?(let?i?=?1;?i?+?2?<?len;?i++)?{let?c1,?c2,?c3let?point?=?points[i]//?控制點(diǎn)1c1?=?[point[0]?+?(points[i?+?1][0]?-?points[i?-?1][0])?/?5,point[1]?+?(points[i?+?1][1]?-?points[i?-?1][1])?/?5]//?控制點(diǎn)2c2?=?[points[i?+?1][0]?+?(point[0]?-?points[i?+?2][0])?/?5,points[i?+?1][1]?+?(point[1]?-?points[i?+?2][1])?/?5]c3?=?[points[i?+?1][0],?points[i?+?1][1]]this.ctx.bezierCurveTo(c1[0],c1[1],c2[0],c2[1],c3[0],c3[1])}this.ctx.stroke() }我們只加差值的五分之一,我試了一下,5-7之間最自然,Rough.js加的是六分之一。事情到這里并沒有結(jié)束,首先這個圓還有個缺口,原因很簡單,i + 2 < len的循環(huán)條件導(dǎo)致最后一個點(diǎn)沒連上,另外首尾也沒有相連,此外開頭一段很不自然,太直了,原因是我們路徑的起點(diǎn)是從第一個點(diǎn)開始的,但是我們的第一段曲線的結(jié)束點(diǎn)已經(jīng)是第三個點(diǎn)了,所以先把路徑的起點(diǎn)移到第二個點(diǎn):
this.ctx.moveTo(points[1][0],?points[1][1])這樣缺口就更大了:紅色的代表前兩個點(diǎn),藍(lán)色的是最后一個點(diǎn),為了要連到第二個點(diǎn)我們需要把頂點(diǎn)列表里的前三個點(diǎn)追加到列表最后:
//?把前三個點(diǎn)追加到列表最后 points.push([points[0][0],?points[0][1]],?[points[1][0],?points[1][1]],?[points[2][0],?points[2][1]]) let?len?=?points.length this.ctx.beginPath() //?...效果如下:問題又來了,應(yīng)該沒有人能徒手把圓的首尾完美無缺的連上,所以加的第二個點(diǎn)我們不能讓它和原來的點(diǎn)一模一樣,得加點(diǎn)偏移:
let?end?=?[]?//?處理最后一個連線點(diǎn),讓它和原本的點(diǎn)來點(diǎn)隨機(jī)偏移 let?radRandom?=?step?*?this.random(0.1,?0.5)//?讓該點(diǎn)超前一點(diǎn),代表畫過頭了,也可以來點(diǎn)負(fù)數(shù),代表差一點(diǎn)才連上,但是比較丑 end[0]?=?x?+?rx?*?Math.cos(step?+?radRandom)//?要連的最后一個點(diǎn)實(shí)際上是列表里的第二個點(diǎn),所以角度是step而不是0 end[1]?=?y?+?ry?*?Math.sin(step?+?radRandom) points.push([points[0][0],?points[0][1]],[end[0],?end[1]],[points[2][0],?points[2][1]] ) let?len?=?points.length this.ctx.beginPath() //...最后一個要優(yōu)化的點(diǎn)是起點(diǎn)或者說終點(diǎn)位置,一般來說我們徒手畫圓都是從上面開始畫,因?yàn)?度是在x軸正軸方向,所以我們減去Math.PI/2左右就能把起點(diǎn)移到上方,最后完整的代碼如下:
drawCircle?(x,?y,?r)?{//?圓變多邊形let?stepCount?=?10let?step?=?(2?*?Math.PI)?/?stepCount//?多邊形的一條邊對應(yīng)的角度let?startOffset?=?-Math.PI?/?2?+?this.random(-Math.PI?/?4,?Math.PI?/?4)//?起點(diǎn)偏移角度let?points?=?[]let?rx?=?r?+?this.random(-r?*?0.05,?r?*?0.05)let?ry?=?r?+?this.random(-r?*?0.05,?r?*?0.05)for?(let?angle?=?startOffset;?angle?<?(2?*?Math.PI?+?startOffset);?angle?+=?step)?{let?p?=?[x?+?rx?*?Math.cos(angle)?+?this.random(-2,?2),y?+?ry?*?Math.sin(angle)?+?this.random(-2,?2)]points.push(p)}//?線段變曲線let?end?=?[]?//?處理最后一個連線點(diǎn),讓它和原本的點(diǎn)來點(diǎn)隨機(jī)偏移let?radRandom?=?step?*?this.random(0.1,?0.5)end[0]?=?x?+?rx?*?Math.cos(startOffset?+?step?+?radRandom)end[1]?=?y?+?ry?*?Math.sin(startOffset?+?step?+?radRandom)points.push([points[0][0],?points[0][1]],[end[0],?end[1]],[points[2][0],?points[2][1]])let?len?=?points.lengththis.ctx.beginPath()this.ctx.moveTo(points[1][0],?points[1][1])this.ctx.strokeStyle?=?'#000'for?(let?i?=?1;?i?+?2?<?len;?i++)?{let?c1,?c2,?c3let?point?=?points[i]let?num?=?6c1?=?[point[0]?+?(points[i?+?1][0]?-?points[i?-?1][0])?/?num,point[1]?+?(points[i?+?1][1]?-?points[i?-?1][1])?/?num]c2?=?[points[i?+?1][0]?+?(point[0]?-?points[i?+?2][0])?/?num,points[i?+?1][1]?+?(point[1]?-?points[i?+?2][1])?/?num]c3?=?[points[i?+?1][0],?points[i?+?1][1]]this.ctx.bezierCurveTo(c1[0],?c1[1],?c2[0],?c2[1],?c3[0],?c3[1])}this.ctx.stroke() }最后的最后,也可以和上面的線段一樣畫兩次,綜合效果如下:圓搞定了,橢圓也類似,畢竟圓是橢圓的一種特殊情況,順帶提一下,橢圓的近似周長公式如下:
填充
樣式1
先來看一種比較簡單的填充:上面我們繪制的矩形四條邊是斷開的,路徑不閉合不能直接調(diào)用canvas的fill方法,所以需要把這四段曲線首尾連起來:
//?繪制手繪多邊形 polygon?(points?=?[],?opt?=?{})?{if?(points.length?<?3)?{return}//?加上填充方法let?lines?=?this.closeLines(points)this.fillLines(lines,?opt)//?描邊let?len?=?points.length//?... }closeLines方法用來把頂點(diǎn)閉合成曲線:
//?把多邊形的頂點(diǎn)轉(zhuǎn)換成首尾相連的閉合線段 closeLines?(points)?{let?len?=?points.lengthlet?lines?=?[]let?lastPoint?=?nullfor?(let?i?=?0;?i?<?len?-?1;?i++)?{//?_line方法上文已經(jīng)實(shí)現(xiàn)了,把直線段轉(zhuǎn)換成曲線let?arr?=?this._line(points[i][0],points[i][1],points[i?+?1][0],points[i?+?1][1])lines.push([lastPoint???lastPoint[2]?:?arr[0],?//?上一個點(diǎn)存在則使用上一個點(diǎn)的終點(diǎn)來作為該點(diǎn)的起點(diǎn)lastPoint???lastPoint[3]?:?arr[1],arr[2],arr[3],arr[4],arr[5],arr[6],arr[7]])lastPoint?=?arr}//?首尾閉合let?arr?=?this._line(points[len?-?1][0],points[len?-?1][1],points[0][0],points[0][1])lines.push([lastPoint???lastPoint[2]?:?arr[0],lastPoint???lastPoint[3]?:?arr[1],lines[0][0],?//?終點(diǎn)是第一條線段的起點(diǎn)lines[0][1],arr[4],arr[5],arr[6],arr[7]])return?lines }線段有了,只要遍歷線段繪制出來最后調(diào)用fill方法即可:
//?填充多邊形 fillLines?(lines,?opt)?{this.ctx.beginPath()this.ctx.fillStyle?=?opt.fillStylefor?(let?i?=?0;?i?+?1?<?lines.length;?i++)?{let?line?=?lines[i]if?(i?===?0)?{this.ctx.moveTo(line[0],?line[1])}this.ctx.bezierCurveTo(line[4],line[5],line[6],line[7],line[2],line[3])}this.ctx.fill() }效果如下:圓就更簡單了,本身差不多就是閉合的,只要我們把最后一個點(diǎn)的特殊處理邏輯給去掉就行了:
//?下面幾行代碼都給去掉,使用原本的點(diǎn)即可 let?end?=?[] let?radRandom?=?step?*?this.random(0.1,?0.5) end[0]?=?x?+?rx?*?Math.cos(startOffset?+?step?+?radRandom) end[1]?=?y?+?ry?*?Math.sin(startOffset?+?step?+?radRandom)2021-03-19-14-54-42.gif樣式2
第二種填充會稍微復(fù)雜一點(diǎn),比如下面這種最簡單的填充,其實(shí)就是一些傾斜的線段,但問題是這些線段的端點(diǎn)怎么確定,矩形當(dāng)然可以暴力的算出來,但是不規(guī)則的多邊形怎么辦,所以需要找到一個通用的方法。填充最暴力的方法就是判斷每個點(diǎn)是否在多邊形內(nèi)部,但是這樣的計算量太大,我查了一下多邊形填充的思路,大概有兩種算法:掃描線填充和種子填充,掃描線填充更流行,Rough.js用的也是這種方法,所以接下來介紹一下這個算法。掃描線填充很簡單,就是一條掃描線(水平線)從多邊形的底部開始往上掃描,那么每條掃描線都會和多邊形有交點(diǎn),同一條掃描線和多邊形的各個交點(diǎn)之間的區(qū)域就是我們要填充的,那么問題來了,怎么確定交點(diǎn),以及怎么判斷兩個交點(diǎn)之間屬于多邊形內(nèi)部。關(guān)于交點(diǎn)的計算,首先我們交點(diǎn)的y坐標(biāo)是已知的,就是掃描線的y坐標(biāo),那么只要求出x,知道線段的兩個端點(diǎn)坐標(biāo),那么可以求出直線方程,然后再計算,但是有一種更簡單的方法,就是利用邊的相關(guān)性,也就是知道了線段上的某一點(diǎn),其相鄰的點(diǎn)可以輕松的根據(jù)該點(diǎn)求出,下面是推導(dǎo)過程:
//?設(shè)直線方程 y?=?kx?+?b //?設(shè)兩點(diǎn):c(x3, y3),d點(diǎn)的y坐標(biāo)為c點(diǎn)y坐標(biāo)+1,d(x4, y3 + 1),那么要求出x4 y3?=?kx3?+?b//?1 y3?+?1?=?kX4?+?b//?2 //?1式代入2式 kx3?+?b?+?1?=?kX4?+?b kx3?+?1?=?kX4//?約去b X4?=?x3?+?1?/?k//?兩邊同時除k //?所以y坐標(biāo)+1,x坐標(biāo)為上一個點(diǎn)的x坐標(biāo)加上直線斜率的倒數(shù) //?多邊形的線段是已知兩個點(diǎn)的,假設(shè)為a(x1, y1)、b(x2, y2),那么斜率k如下: k?=?(y2?-?y1)?/? //?斜率的倒數(shù)也就是 1/k?=?(x2?-?x1)?/?(y2?-?y1)這樣我們從線段的一個端點(diǎn)開始,可以挨個計算出線段上的所有點(diǎn)。詳細(xì)的算法介紹和推導(dǎo)過程可以看一下這個PPT:wenku.baidu.com/view/4ee141…[4],接下來直接來看算法的實(shí)現(xiàn)過程。先簡單介紹一下幾個名詞:1.邊表ET邊表ET,一個數(shù)組,里面保存了多邊形所有邊的信息,每條邊保存的信息有:該邊y的最大值ymax和最小值ymin、該邊最低點(diǎn)的x值xi、該邊斜率的倒數(shù)dx。邊按ymin遞增排序,ymin相同則按xi遞增,xi也相同則只能看ymax,如果ymax還相同,說明兩條邊重合了,如果不重合,則按yamx遞增排序。2.活動邊表AET也是一個數(shù)組,里面保存著與當(dāng)前掃描線相交的邊信息,隨著掃描線的掃描會發(fā)生變化,刪除不相交的,添加新相交的。該表里的邊按xi遞增排序。比如下面的多邊形ET表順序?yàn)?#xff1a;
//?ET [p1p5,?p1p2,?p5p4,?p2p3,?p4p3]下面是具體的算法步驟:1.根據(jù)多邊形的頂點(diǎn)數(shù)據(jù)創(chuàng)建ET表edgeTable,按上述順序排序;2.創(chuàng)建一個空的AET表activeEdgeTable;3.開始掃描,掃描線的y=多邊形的最低點(diǎn)的y值,也就是activeEdgeTable[0].ymin;4.重復(fù)下面步驟,直到ET表和AET表都為空:(1)從ET表里取出與當(dāng)前掃描線相交的邊,添加到AET表里,同樣按上面提到的順序排序 (2)成對取出AET表里的邊信息的xi值,在每對之間進(jìn)行填充 (3)從AET表里刪除當(dāng)前已經(jīng)掃描到最后的邊,即y >= ymax (4)更新AET表里剩下的邊信息的xi,即xi = xi + dx (5)更新掃描線的y,即y = y + 1看著并不難,接下來轉(zhuǎn)化成代碼,先創(chuàng)建一下邊表ET:
//?創(chuàng)建排序邊表ET createEdgeTable?(points)?{//?邊表ETlet?edgeTable?=?[]//?將第一個點(diǎn)復(fù)制一份到隊(duì)尾,用來閉合多邊形let?_points?=?points.concat([[points[0][0],?points[0][1]]])let?len?=?_points.lengthfor?(let?i?=?0;?i?<?len?-?1;?i++)?{let?p1?=?_points[i]let?p2?=?_points[i?+?1]//?過濾掉平行于x軸的線段,詳見上述PPT鏈接if?(p1[1]?!==?p2[1])?{let?ymin?=?Math.min(p1[1],?p2[1])edgeTable.push({ymin,ymax:?Math.max(p1[1],?p2[1]),xi:?ymin?===?p1[1]???p1[0]?:?p2[0],?//?最低頂點(diǎn)的x值dx:?(p2[0]?-?p1[0])?/?(p2[1]?-?p1[1])?//?線段的斜率的倒數(shù)})}}//?對邊表進(jìn)行排序edgeTable.sort((e1,?e2)?=>?{//?按ymin遞增排序if?(e1.ymin?<?e2.ymin)?{return?-1}if?(e1.ymin?>?e2.ymin)?{return?1}//?ymin相同則按xi遞增if?(e1.xi?<?e2.xi)?{return?-1}if?(e1.xi?>?e2.xi)?{return?1}//?xi也相同則只能看ymax//?ymax還相同,說明兩條邊重合if?(e1.ymax?===?e2.ymax)?{return?0}//?如果不重合,則按yamx遞增排序if?(e1.ymax?<?e2.ymax)?{return?-1}if?(e1.ymax?>?e2.ymax)?{return?1}})return?edgeTable }接下來進(jìn)行掃描操作:
scanLines?(points)?{if?(points.length?<?3)?{return?[]}let?lines?=?[]//?創(chuàng)建排序邊表ETlet?edgeTable?=?this.createEdgeTable(points)//?活動邊表AETlet?activeEdgeTable?=?[]//?開始掃描,從多邊形的最低點(diǎn)開始let?y?=?edgeTable[0].ymin//?循環(huán)的終點(diǎn)是兩個表都為空while?(edgeTable.length?>?0?||?activeEdgeTable.length?>?0)?{//?從ET表里把當(dāng)前掃描線的邊添加到AET表里if?(edgeTable.length?>?0)?{//?將當(dāng)前ET表里和掃描線相交的邊添加到AET表里for?(let?i?=?0;?i?<?edgeTable.length;?i++)?{//?如果掃描線的間隔加大,可能高低差比較小的線段會被整個直接跳過,導(dǎo)致死循環(huán),需要考慮到這種情況if?(edgeTable[i].ymin?<=?y?&&?edgeTable[i].ymax?>=?y?||?edgeTable[i].ymax?<?y)?{let?removed?=?edgeTable.splice(i,?1)activeEdgeTable.push(...removed)i--}}}//?從AET表里刪除y=ymax的記錄activeEdgeTable?=?activeEdgeTable.filter((item)?=>?{return?y?<?item.ymax})//?按xi從小到大排序activeEdgeTable.sort((e1,?e2)?=>?{if?(e1.xi?<?e2.xi)?{return?-1}?else?if?(e1.xi?>?e2.xi)?{return?1}?else?{return?0}})//?如果存在活動邊,則填充活動邊之間的區(qū)域if?(activeEdgeTable.length?>?1)?{//?每次取兩個邊出來進(jìn)行填充for?(let?i?=?0;?i?+?1?<?activeEdgeTable.length;?i?+=?2)?{lines.push([[Math.round(activeEdgeTable[i].xi),?y],[Math.round(activeEdgeTable[i?+?1].xi),?y]])}}//?更新活動邊的xiactiveEdgeTable.forEach((item)?=>?{item.xi?+=?item.dx})//?更新掃描線yy?+=?1}return?lines }代碼其實(shí)就是上述算法過程的翻譯,理解了算法代碼并不難理解,在多邊形方法里調(diào)用一下該方法:
//?繪制手繪多邊形 polygon?(points?=?[],?opt?=?{})?{if?(points.length?<?3)?{return}//?加上填充方法let?lines?=?this.scanLines(points)lines.forEach((line)?=>?{this.drawDoubleLine(line[0][0],?line[0][1],?line[1][0],?line[1][1],?{color:?opt.fillStyle})})//?描邊let?len?=?points.length//?... }看一下最后的填充效果:效果已經(jīng)出來了,但是太密了,因?yàn)槲覀兊膾呙杈€每次加的是1,我們多加點(diǎn)試試:
scanLines?(points)?{//?...//?我們讓掃描線每次加10let?gap?=?10//?更新活動邊的xiactiveEdgeTable.forEach((item)?=>?{item.xi?+=?item.dx?*?gap//?斜率的倒數(shù)為什么也要乘10可以去看上面的推導(dǎo)過程})//?更新掃描線yy?+=?gap//?... }順便也加粗一下線段的寬度,效果如下:也可以把線段的首尾交替相連變成一筆畫的效果:具體實(shí)現(xiàn)可以去源碼里看,接下來我們看最后一個問題,就是讓填充線傾斜一點(diǎn)角度,目前都是水平的。填充線想要傾斜首先我們可以讓圖形先旋轉(zhuǎn)一定角度,這樣掃描出來的線還是水平的,然后再讓圖形和填充線一起再旋轉(zhuǎn)回去就得到傾斜的線了。上圖表示圖形逆時針旋轉(zhuǎn)后進(jìn)行掃描,下圖表示圖形和填充線順時針旋轉(zhuǎn)回去。圖形旋轉(zhuǎn)也就是各個頂點(diǎn)旋轉(zhuǎn),所以問題就變成了求一個點(diǎn)旋轉(zhuǎn)指定角度后的位置,下面來推導(dǎo)一下。上圖里點(diǎn)(x,y)原本的角度為a,線段長為r,求旋轉(zhuǎn)角度b后的坐標(biāo)(x1,y1):
x?=?Math.cos(a)?*?r//?1 y?=?Math.sin(a)?*?r//?2x1?=?Math.cos(a?+?b)?*?r y1?=?Math.sin(a?+?b)?*?r//?把cos(a+b)、sin(a+b)展開 x1?=?(Math.cos(a)?*?Math.cos(b)?-?Math.sin(a)?*?Math.sin(b))?*?r//?3 y1?=?(Math.sin(a)?*?Math.cos(b)?+?Math.cos(a)?*?Math.sin(b))?*?r//?4//?把1式和2式代入3式和4式 Math.cos(a)?=?x?/?r Math.sin(a)?=?y?/?r x1?=?((x?/?r)?*?Math.cos(b)?-?(y?/?r)?*?Math.sin(b))?*?r y1?=?((y?/?r)?*?Math.cos(b)?+?(x?/?r)?*?Math.sin(b))?*?r //?約去r x1?=?x?*?Math.cos(b)?-?y?*?Math.sin(b) y1?=?y?*?Math.cos(b)?+?x?*?Math.sin(b)由此可以得到求一個點(diǎn)旋轉(zhuǎn)指定角度后的坐標(biāo)的函數(shù):
getRotatedPos?(x,?y,?rad)?{return?[x:?x?*?Math.cos(rad)?-?y?*?Math.sin(rad),y:?y?*?Math.cos(rad)?+?x?*?Math.sin(rad)] }有了該函數(shù)我們就可以來旋轉(zhuǎn)多邊形了:
//?繪制手繪多邊形 polygon?(points?=?[],?opt?=?{})?{if?(points.length?<?3)?{return}//?掃描前先旋轉(zhuǎn)多邊形let?_points?=?this.rotatePoints(points,?opt.rotate)let?lines?=?this.scanLines(_points)//?掃描完得到的線段我們再旋轉(zhuǎn)相反的角度lines?=?this.rotateLines(lines,?-opt.rotate)lines.forEach((line)?=>?{this.drawDoubleLine(line[0][0],?line[0][1],?line[1][0],?line[1][1],?{color:?opt.fillStyle})})//?描邊let?len?=?points.length//?... }//?旋轉(zhuǎn)頂點(diǎn)列表 rotatePoints?(points,?rotate)?{return?points.map((item)?=>?{return?this.getRotatedPos(item[0],?item[1],?rotate)}) }//?旋轉(zhuǎn)線段列表 rotateLines?(lines,?rotate)?{return?lines.map((line)?=>?{return?[this.getRotatedPos(line[0][0],?line[0][1],?rotate),this.getRotatedPos(line[1][0],?line[1][1],?rotate)]}) }效果如下:圓形也是一樣,轉(zhuǎn)換成多邊形后先旋轉(zhuǎn),然后掃描再旋轉(zhuǎn)回去:
總結(jié)
本文介紹了幾種簡單圖形的手繪風(fēng)格實(shí)現(xiàn)方法,其中涉及到了簡單的數(shù)學(xué)知識及區(qū)域填充算法,如果有不合理或更好的實(shí)現(xiàn)方式請?jiān)诹粞詤^(qū)討論吧,完整的示例代碼在:github.com/wanglin2/ha…[5]。感謝閱讀,下次再會~參考文章:
https://github.com/rough-stuff/rough
https://wenku.baidu.com/view/4ee141347c1cfad6195fa7c9.html
https://blog.csdn.net/orbit/article/details/7368996
https://blog.csdn.net/wodownload2/article/details/52154207
https://blog.csdn.net/keneyr/article/details/83747501
http://www.twinklingstar.cn/2013/325/region-polygon-fill-scan-line/
關(guān)于本文
作者:街角小林?
https://juejin.cn/post/6942262577460314143
參考資料
[1]
https://roughjs.com/
[2]http://lxqnsys.com/#/demo/handPaintedStyle
[3]https://cubic-bezier.com
[4]https://wenku.baidu.com/view/4ee141347c1cfad6195fa7c9.html
[5]https://github.com/wanglin2/handPaintedStyle
相關(guān)文章
動漫生成器讓照片秒變手繪日漫風(fēng)!!!
Canvas制作水波圖實(shí)現(xiàn)原理
20個讓你效率更高的CSS代碼技巧
最后
關(guān)注公眾號:前端開發(fā)博客,在后臺回復(fù)以下關(guān)鍵字可以獲取資源。
回復(fù)「1024」領(lǐng)取前端進(jìn)階資料
回復(fù)「Vue」獲取 Vue 精選文章
回復(fù)「面試」獲取 面試 精選文章
回復(fù)「JS」獲取 JavaScript 精選文章
回復(fù)「CSS」獲取 CSS 精選文章
回復(fù)「加群」進(jìn)入500人前端精英群
回復(fù)「電子書」下載我整理的大量前端資源
回復(fù)「知識點(diǎn)」下載高清JavaScript知識點(diǎn)圖譜
看完點(diǎn)個贊,分享一下吧,讓更多的朋友能夠看到。如果你喜歡前端開發(fā)博客的分享,就給公號標(biāo)個星吧,這樣就不會錯過我的文章了。
創(chuàng)作不易,加個點(diǎn)贊、在看?支持一下哦!
總結(jié)
以上是生活随笔為你收集整理的手把手教你实现手绘风格图形的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Mac下最专业的视频剪辑软件,FCPX视
- 下一篇: 简述UIAlertView的属性和用法