Rough.js是一個手繪風(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ù)覽:http://lxqnsys.com/#/demo/handPaintedStyle。話不多說,代碼見。
線段
萬物基于線段,所以先來看線段怎么畫,仔細(xì)看上圖會發(fā)現(xiàn)手繪版線段其實(shí)是用兩根彎曲的線段組成的,曲線可以使用貝塞爾曲線來畫,這里使用三次貝塞爾曲線,那么剩下的問題就是求起點(diǎn)、終點(diǎn)、兩個控制點(diǎn)的坐標(biāo)了。
貝塞爾曲線可以在這個網(wǎng)站上嘗試:https://cubic-bezier.com/。
首先一條線段的起點(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 - 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方法前兩個點(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 - y1
rx = 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 - x1
rx = 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.length
for (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)
}
圓
圓要怎么處理呢,首先大家都知道圓是可以使用多邊形來近似得到的,只要多邊形的邊足夠多,那么看起來就足夠圓,既然不想要太圓,那就把它恢復(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 = 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ù)量再多一點(diǎn)看起來也不像:
如果直接用正常的線段連起來,那完全就是個正經(jīng)多邊形了,肯定也不行,所以核心是把線段變成隨機(jī)弧形,首先為了增加隨機(jī)性,我們把圓的半徑和各個頂點(diǎn)都加一點(diǎn)隨機(jī)增量:
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)
}
}
接下來的問題又變成了計算貝塞爾曲線的兩個控制點(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.length
this.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, c3
let point = points[i]
// 控制點(diǎn)1
c1 = [
point[0] + (points[i + 1][0] - points[i - 1][0]) / 5,
point[1] + (points[i + 1][1] - points[i - 1][1]) / 5
]
// 控制點(diǎn)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)致最后一個點(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 = 10
let 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.length
this.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, 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
方法用來把頂點(diǎn)閉合成曲線:
// 把多邊形的頂點(diǎn)轉(zhuǎn)換成首尾相連的閉合線段
closeLines (points) {
let len = points.length
let lines = []
let lastPoint = null
for (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.fillStyle
for (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)
樣式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:https://wenku.baidu.com/view/4ee141347c1cfad6195fa7c9.html,接下來直接來看算法的實(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)椋?/p>
// 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) {
// 邊表ET
let edgeTable = []
// 將第一個點(diǎn)復(fù)制一份到隊(duì)尾,用來閉合多邊形
let _points = points.concat([[points[0][0], points[0][1]]])
let len = _points.length
for (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)建排序邊表ET
let edgeTable = this.createEdgeTable(points)
// 活動邊表AET
let 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]
])
}
}
// 更新活動邊的xi
activeEdgeTable.forEach((item) => {
item.xi += item.dx
})
// 更新掃描線y
y += 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) {
// ...
// 我們讓掃描線每次加10
let gap = 10
// 更新活動邊的xi
activeEdgeTable.forEach((item) => {
item.xi += item.dx * gap// 斜率的倒數(shù)為什么也要乘10可以去看上面的推導(dǎo)過程
})
// 更新掃描線y
y += 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// 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)
由此可以得到求一個點(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ū)討論吧,完整的示例代碼在:https://github.com/wanglin2/handPaintedStyle。感謝閱讀,下次再會~
參考文章:
- 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/