【W(wǎng)eb技術(shù)】1139- 手把手教你實現(xiàn)手繪風(fēng)格圖形

作者:街角小林?
https://juejin.cn/post/6942262577460314143

雖然筆者是個糙漢子,但是對這種可愛的東西都沒啥抵抗力,這個庫的使用本身很簡單,沒什么好說的,但是它只有繪制能力,沒有交互能力,所以使用場景有限,先來用它畫個示例圖形:
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?})
效果如下:
是不是有點蠢萌,本文的主要內(nèi)容是帶大家手動實現(xiàn)上面的圖形,最終效果預(yù)覽:lxqnsys.com/#/demo/hand…[2]。話不多說,代碼見。
線段
萬物基于線段,所以先來看線段怎么畫,仔細(xì)看上圖會發(fā)現(xiàn)手繪版線段其實是用兩根彎曲的線段組成的,曲線可以使用貝塞爾曲線來畫,這里使用三次貝塞爾曲線,那么剩下的問題就是求起點、終點、兩個控制點的坐標(biāo)了。貝塞爾曲線可以在這個網(wǎng)站上嘗試:cubic-bezier.com/[3]。首先一條線段的起點和終點我們都給它加一點隨機值,隨機值比如就在[-2,2]之間,也可以把這個范圍和線段的長度關(guān)聯(lián)起來,比如線段越長,隨機值就越大。
//?直線變曲線
_line?(x1,?y1,?x2,?y2)?{
????let?result?=?[]
????//?起始點
????result[0]?=?x1?+?this.random(-this.offset,?this.offset)
????result[1]?=?y1?+?this.random(-this.offset,?this.offset)
????//?終點
????result[2]?=?x2?+?this.random(-this.offset,?this.offset)
????result[3]?=?y2?+?this.random(-this.offset,?this.offset)
}
接下來就是兩個控制點,我們把控制點限定在線段所在的矩形內(nèi):
_line?(x1,?y1,?x2,?y2)?{
????let?result?=?[]
????//?起始點
????//?...
????//?終點
????//?...
????//?兩個控制點
????let?xo?=?x2?-?x1
????let?yo?=?y2?-?y1
????let?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方法前兩個點為控制點,第三個點為結(jié)束點
????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)兩個控制點離線段越近,曲線彎曲程度越?。?img class="rich_pages wxw-img" data-fileid="502611020" data-ratio="0.33127572016460904" src="https://filescdn.proginn.com/d1a60996461f6a7cb5db2ce854877857/b0391681dcdcf0027a3c504d463c718c.webp" data-type="other" data-w="972" style="display: block;margin-right: auto;margin-left: auto;border-radius: 4px;margin-bottom: 25px;">所以我們要找線段附近的點作為控制點,首先隨機一個橫坐標(biāo)點,然后可以計算出線段上該橫坐標(biāo)對應(yīng)的縱坐標(biāo)點,把該縱坐標(biāo)點加減一點隨機值即可。
_line?(x1,?y1,?x2,?y2)?{
????let?result?=?[]
????//?...
????//?兩個控制點
????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
}
//?計算兩個點連成的線段上附近的一個隨機點
getNearRandomPoint?(x1,?y1,?x2,?y2)?{
????let?xo,?yo,?rx,?ry
????//?垂直x軸的線段特殊處理
????if?(x1?===?x2)?{
????????yo?=?y2?-?y1
????????rx?=?x1?+?this.random(-2,?2)//?在橫坐標(biāo)附近找一個隨機點
????????ry?=?y1?+?yo?*?this.random(0,?1)//?在線段上找一個隨機點
????????return?[rx,?ry]
????}
????xo?=?x2?-?x1
????rx?=?x1?+?xo?*?this.random(0,?1)//?找一個隨機的橫坐標(biāo)
????ry?=?((rx?-?x1)?*?(y2?-?y1))?/?(x2?-?x1)?+?y1//?通過兩點式求出直線方程
????ry?+=?this.random(-2,?2)//?縱坐標(biāo)加一點隨機值
????return?[rx,?ry]
}
看一下效果:
當(dāng)然和Rough.js比起來還是不夠好,有興趣的可以自行去看一下源碼,反正筆者是看不懂,控制變量太多,還沒有注釋。
多邊形&矩形
多邊形就是把多個點首尾相連起來,遍歷頂點調(diào)用繪制線段的方法即可:
//?繪制手繪多邊形
polygon?(points?=?[],?opt?=?{})?{
????if?(points.length?3)?{
????????return
????}
????let?len?=?points.length
????for?(let?i?=?0;?i?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])
}
矩形是多邊形的一種特殊情況,四個角都是直角,一般傳參為左上角頂點的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)
}

圓
圓要怎么處理呢,首先大家都知道圓是可以使用多邊形來近似得到的,只要多邊形的邊足夠多,那么看起來就足夠圓,既然不想要太圓,那就把它恢復(fù)成多邊形好了,多邊形上面已經(jīng)講過了?;謴?fù)成多邊形很簡單,比如我們要把一個圓變成十邊形(具體還原成幾邊形你也可以和圓的周長關(guān)聯(lián)起來),那么每個邊對應(yīng)的弧度就是2*Math.PI/10,然后使用Math.cos和Math.sin來計算頂點的位置,最后再調(diào)用繪制多邊形的方法進(jìn)行繪制:
//?繪制手繪圓
circle?(x,?y,?r)?{
????let?stepCount?=?10
????let?step?=?(2?*?Math.PI)?/?stepCount
????let?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ù)量再多一點看起來也不像:
如果直接用正常的線段連起來,那完全就是個正經(jīng)多邊形了,肯定也不行,所以核心是把線段變成隨機弧形,首先為了增加隨機性,我們把圓的半徑和各個頂點都加一點隨機增量:
circle?(x,?y,?r)?{
????let?stepCount?=?10
????let?step?=?(2?*?Math.PI)?/?stepCount
????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?=?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)
????}
}
接下來的問題又變成了計算貝塞爾曲線的兩個控制點,首先因為弧線肯定是要往多邊形外凸的,根據(jù)貝塞爾曲線的性質(zhì),兩個控制點一定是在線段的外面,直接用線段本身的兩個端點來計算的話我試了一下,比較難處理,不同的角度可能都需要特殊處理,所以我們參考Rough.js間隔一個點:
比如上圖的多邊形我們隨便找一個線段bc,對于點b來說上一個點是a,下一個點是c,b點分別加上c減a的橫坐標(biāo)縱坐標(biāo)之差,得到了控制點c1,其他點也是一樣,最后算出來的控制點都會在外面,現(xiàn)在還差一個控制點,我們不要讓點c閑著,也給它加上前后兩點之差:
可以看到點c的控制點c2和c1都在同一側(cè),這樣畫出來的曲線顯然是朝一個方向的:
我們讓它對稱一下,讓點c的前一個點減后一個點:
這樣畫出來的曲線仍然不行:
原因很簡單,控制點離的太遠(yuǎn)了,所以我們少加一點差值,最后代碼如下:
circle?(x,?y,?r)?{
????//?...
????let?len?=?points.length
????this.ctx.beginPath()
????//?路徑的起點移到第一個點
????this.ctx.moveTo(points[0][0],?points[0][1])
????this.ctx.strokeStyle?=?'#000'
????for?(let?i?=?1;?i?+?2?????????let?c1,?c2,?c3
????????let?point?=?points[i]
????????//?控制點1
????????c1?=?[
????????????point[0]?+?(points[i?+?1][0]?-?points[i?-?1][0])?/?5,
????????????point[1]?+?(points[i?+?1][1]?-?points[i?-?1][1])?/?5
????????]
????????//?控制點2
????????c2?=?[
????????????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)致最后一個點沒連上,另外首尾也沒有相連,此外開頭一段很不自然,太直了,原因是我們路徑的起點是從第一個點開始的,但是我們的第一段曲線的結(jié)束點已經(jīng)是第三個點了,所以先把路徑的起點移到第二個點:
this.ctx.moveTo(points[1][0],?points[1][1])
這樣缺口就更大了:
紅色的代表前兩個點,藍(lá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)該沒有人能徒手把圓的首尾完美無缺的連上,所以加的第二個點我們不能讓它和原來的點一模一樣,得加點偏移:
let?end?=?[]?//?處理最后一個連線點,讓它和原本的點來點隨機偏移
let?radRandom?=?step?*?this.random(0.1,?0.5)//?讓該點超前一點,代表畫過頭了,也可以來點負(fù)數(shù),代表差一點才連上,但是比較丑
end[0]?=?x?+?rx?*?Math.cos(step?+?radRandom)//?要連的最后一個點實際上是列表里的第二個點,所以角度是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)化的點是起點或者說終點位置,一般來說我們徒手畫圓都是從上面開始畫,因為0度是在x軸正軸方向,所以我們減去Math.PI/2左右就能把起點移到上方,最后完整的代碼如下:
drawCircle?(x,?y,?r)?{
????//?圓變多邊形
????let?stepCount?=?10
????let?step?=?(2?*?Math.PI)?/?stepCount//?多邊形的一條邊對應(yīng)的角度
????let?startOffset?=?-Math.PI?/?2?+?this.random(-Math.PI?/?4,?Math.PI?/?4)//?起點偏移角度
????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?=?[]?//?處理最后一個連線點,讓它和原本的點來點隨機偏移
????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.length
????this.ctx.beginPath()
????this.ctx.moveTo(points[1][0],?points[1][1])
????this.ctx.strokeStyle?=?'#000'
????for?(let?i?=?1;?i?+?2?????????let?c1,?c2,?c3
????????let?point?=?points[i]
????????let?num?=?6
????????c1?=?[
????????????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方法用來把頂點閉合成曲線:
//?把多邊形的頂點轉(zhuǎn)換成首尾相連的閉合線段
closeLines?(points)?{
????let?len?=?points.length
????let?lines?=?[]
????let?lastPoint?=?null
????for?(let?i?=?0;?i?1;?i++)?{
????????//?_line方法上文已經(jīng)實現(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],?//?上一個點存在則使用上一個點的終點來作為該點的起點
????????????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],?//?終點是第一條線段的起點
????????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.fillStyle
????for?(let?i?=?0;?i?+?1?????????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()
}
效果如下:
圓就更簡單了,本身差不多就是閉合的,只要我們把最后一個點的特殊處理邏輯給去掉就行了:
//?下面幾行代碼都給去掉,使用原本的點即可
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)

樣式2
第二種填充會稍微復(fù)雜一點,比如下面這種最簡單的填充,其實就是一些傾斜的線段,但問題是這些線段的端點怎么確定,矩形當(dāng)然可以暴力的算出來,但是不規(guī)則的多邊形怎么辦,所以需要找到一個通用的方法。
填充最暴力的方法就是判斷每個點是否在多邊形內(nèi)部,但是這樣的計算量太大,我查了一下多邊形填充的思路,大概有兩種算法:掃描線填充和種子填充,掃描線填充更流行,Rough.js用的也是這種方法,所以接下來介紹一下這個算法。掃描線填充很簡單,就是一條掃描線(水平線)從多邊形的底部開始往上掃描,那么每條掃描線都會和多邊形有交點,同一條掃描線和多邊形的各個交點之間的區(qū)域就是我們要填充的,那么問題來了,怎么確定交點,以及怎么判斷兩個交點之間屬于多邊形內(nèi)部。
關(guān)于交點的計算,首先我們交點的y坐標(biāo)是已知的,就是掃描線的y坐標(biāo),那么只要求出x,知道線段的兩個端點坐標(biāo),那么可以求出直線方程,然后再計算,但是有一種更簡單的方法,就是利用邊的相關(guān)性,也就是知道了線段上的某一點,其相鄰的點可以輕松的根據(jù)該點求出,下面是推導(dǎo)過程:
//?設(shè)直線方程
y?=?kx?+?b
//?設(shè)兩點:c(x3, y3),d點的y坐標(biāo)為c點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)為上一個點的x坐標(biāo)加上直線斜率的倒數(shù)
//?多邊形的線段是已知兩個點的,假設(shè)為a(x1, y1)、b(x2, y2),那么斜率k如下:
k?=?(y2?-?y1)?/?
//?斜率的倒數(shù)也就是
1/k?=?(x2?-?x1)?/?(y2?-?y1)
這樣我們從線段的一個端點開始,可以挨個計算出線段上的所有點。詳細(xì)的算法介紹和推導(dǎo)過程可以看一下這個PPT:wenku.baidu.com/view/4ee141…[4],接下來直接來看算法的實現(xiàn)過程。先簡單介紹一下幾個名詞:1.邊表ET邊表ET,一個數(shù)組,里面保存了多邊形所有邊的信息,每條邊保存的信息有:該邊y的最大值ymax和最小值ymin、該邊最低點的x值xi、該邊斜率的倒數(shù)dx。邊按ymin遞增排序,ymin相同則按xi遞增,xi也相同則只能看ymax,如果ymax還相同,說明兩條邊重合了,如果不重合,則按yamx遞增排序。2.活動邊表AET也是一個數(shù)組,里面保存著與當(dāng)前掃描線相交的邊信息,隨著掃描線的掃描會發(fā)生變化,刪除不相交的,添加新相交的。該表里的邊按xi遞增排序。比如下面的多邊形ET表順序為:
//?ET
[p1p5,?p1p2,?p5p4,?p2p3,?p4p3]
下面是具體的算法步驟:1.根據(jù)多邊形的頂點數(shù)據(jù)創(chuàng)建ET表edgeTable,按上述順序排序;2.創(chuàng)建一個空的AET表activeEdgeTable;3.開始掃描,掃描線的y=多邊形的最低點的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)?{
????//?邊表ET
????let?edgeTable?=?[]
????//?將第一個點復(fù)制一份到隊尾,用來閉合多邊形
????let?_points?=?points.concat([[points[0][0],?points[0][1]]])
????let?len?=?_points.length
????for?(let?i?=?0;?i?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],?//?最低頂點的x值
????????????????dx:?(p2[0]?-?p1[0])?/?(p2[1]?-?p1[1])?//?線段的斜率的倒數(shù)
????????????})
????????}
????}
????//?對邊表進(jìn)行排序
????edgeTable.sort((e1,?e2)?=>?{
????????//?按ymin遞增排序
????????if?(e1.ymin?????????????return?-1
????????}
????????if?(e1.ymin?>?e2.ymin)?{
????????????return?1
????????}
????????//?ymin相同則按xi遞增
????????if?(e1.xi?????????????return?-1
????????}
????????if?(e1.xi?>?e2.xi)?{
????????????return?1
????????}
????????//?xi也相同則只能看ymax
????????//?ymax還相同,說明兩條邊重合
????????if?(e1.ymax?===?e2.ymax)?{
????????????return?0
????????}
????????//?如果不重合,則按yamx遞增排序
????????if?(e1.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)建排序邊表ET
????let?edgeTable?=?this.createEdgeTable(points)
????//?活動邊表AET
????let?activeEdgeTable?=?[]
????//?開始掃描,從多邊形的最低點開始
????let?y?=?edgeTable[0].ymin
????//?循環(huá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?????????????????//?如果掃描線的間隔加大,可能高低差比較小的線段會被整個直接跳過,導(dǎo)致死循環(huán),需要考慮到這種情況
????????????????if?(edgeTable[i].ymin?<=?y?&&?edgeTable[i].ymax?>=?y?||?edgeTable[i].ymax?????????????????????let?removed?=?edgeTable.splice(i,?1)
????????????????????activeEdgeTable.push(...removed)
????????????????????i--
????????????????}
????????????}
????????}
????????//?從AET表里刪除y=ymax的記錄
????????activeEdgeTable?=?activeEdgeTable.filter((item)?=>?{
????????????return?y?????????})
????????//?按xi從小到大排序
????????activeEdgeTable.sort((e1,?e2)?=>?{
????????????if?(e1.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?2)?{
????????????????lines.push([
????????????????????[Math.round(activeEdgeTable[i].xi),?y],
????????????????????[Math.round(activeEdgeTable[i?+?1].xi),?y]
????????????????])
????????????}
????????}
????????//?更新活動邊的xi
????????activeEdgeTable.forEach((item)?=>?{
????????????item.xi?+=?item.dx
????????})
????????//?更新掃描線y
????????y?+=?1
????}
????return?lines
}
代碼其實就是上述算法過程的翻譯,理解了算法代碼并不難理解,在多邊形方法里調(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)出來了,但是太密了,因為我們的掃描線每次加的是1,我們多加點試試:
scanLines?(points)?{
????//?...
????
????//?我們讓掃描線每次加10
????let?gap?=?10
????//?更新活動邊的xi
????activeEdgeTable.forEach((item)?=>?{
????????item.xi?+=?item.dx?*?gap//?斜率的倒數(shù)為什么也要乘10可以去看上面的推導(dǎo)過程
????})
????//?更新掃描線y
????y?+=?gap
????
????//?...
}
順便也加粗一下線段的寬度,效果如下:
也可以把線段的首尾交替相連變成一筆畫的效果:
具體實現(xiàn)可以去源碼里看,接下來我們看最后一個問題,就是讓填充線傾斜一點角度,目前都是水平的。填充線想要傾斜首先我們可以讓圖形先旋轉(zhuǎn)一定角度,這樣掃描出來的線還是水平的,然后再讓圖形和填充線一起再旋轉(zhuǎn)回去就得到傾斜的線了。
上圖表示圖形逆時針旋轉(zhuǎn)后進(jìn)行掃描,下圖表示圖形和填充線順時針旋轉(zhuǎn)回去。
圖形旋轉(zhuǎn)也就是各個頂點旋轉(zhuǎn),所以問題就變成了求一個點旋轉(zhuǎn)指定角度后的位置,下面來推導(dǎo)一下。
上圖里點(x,y)原本的角度為a,線段長為r,求旋轉(zhuǎn)角度b后的坐標(biāo)(x1,y1):
x?=?Math.cos(a)?*?r//?1
y?=?Math.sin(a)?*?r//?2
x1?=?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)
由此可以得到求一個點旋轉(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)頂點列表
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)格實現(xiàn)方法,其中涉及到了簡單的數(shù)學(xué)知識及區(qū)域填充算法,如果有不合理或更好的實現(xià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/
參考資料
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

回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~
點擊“閱讀原文”查看 120+ 篇原創(chuàng)文章
