手把手教你實(shí)現(xiàn)手繪風(fēng)格圖形

Rough.js是一個手繪風(fēng)格的圖形庫,提供了一些基本圖形的繪制能力,比如:

m2.png
m14.png

雖然筆者是個糙漢子,但是對這種可愛的東西都沒啥抵抗力,這個庫的使用本身很簡單,沒什么好說的,但是它只有繪制能力,沒有交互能力,所以使用場景有限,先來用它畫個示例圖形:

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 })

效果如下:

image-20210204153435392.png

是不是有點(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):

image-20210204165810055.png
_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()
}

效果如下:

image-20210204171243093.png

但是多試幾次就會發(fā)現(xiàn)偏離太遠(yuǎn)、彎曲程度過大:

image-20210204180036030.png

完全不像一個手正常的人能畫出來的,去上面的貝塞爾曲線網(wǎng)站上試幾次會發(fā)現(xiàn)兩個控制點(diǎn)離線段越近,曲線彎曲程度越小:

image-20210313175539327.png

所以我們要找線段附近的點(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]
}

看一下效果:

2021-03-17-10-16-45.gif

當(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])
}
image-20210207161425915.png

矩形是多邊形的一種特殊情況,四個角都是直角,一般傳參為左上角頂點(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.cosMath.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)
}

效果如下:

image-20210317134337592.png

可以看到效果很一般,就算邊的數(shù)量再多一點(diǎn)看起來也不像:

image-20210317134538803.png

如果直接用正常的線段連起來,那完全就是個正經(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):

image-20210318152243835.png

比如上圖的多邊形我們隨便找一個線段bc,對于點(diǎn)b來說上一個點(diǎn)是a,下一個點(diǎn)是c,b點(diǎn)分別加上ca的橫坐標(biāo)縱坐標(biāo)之差,得到了控制點(diǎn)c1,其他點(diǎn)也是一樣,最后算出來的控制點(diǎn)都會在外面,現(xiàn)在還差一個控制點(diǎn),我們不要讓點(diǎn)c閑著,也給它加上前后兩點(diǎn)之差:

image-20210318152754865.png

可以看到點(diǎn)c的控制點(diǎn)c2c1都在同一側(cè),這樣畫出來的曲線顯然是朝一個方向的:

image-20210318163849897.png

我們讓它對稱一下,讓點(diǎn)c的前一個點(diǎn)減后一個點(diǎn):

image-20210318154453700.png

這樣畫出來的曲線仍然不行:

image-20210318163925954.png

原因很簡單,控制點(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加的是六分之一。

2021-03-18-16-40-06.gif

事情到這里并沒有結(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])

這樣缺口就更大了:

image-20210318164148681.png

紅色的代表前兩個點(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()
// ...

效果如下:

image-20210318165518383.png

問題又來了,應(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()
}

最后的最后,也可以和上面的線段一樣畫兩次,綜合效果如下:

2021-03-18-20-32-57.gif

圓搞定了,橢圓也類似,畢竟圓是橢圓的一種特殊情況,順帶提一下,橢圓的近似周長公式如下:

image-20210318204417614.png

填充

樣式1

先來看一種比較簡單的填充:

image-20210319134159471.png

上面我們繪制的矩形四條邊是斷開的,路徑不閉合不能直接調(diào)用canvasfill方法,所以需要把這四段曲線首尾連起來:

// 繪制手繪多邊形
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()
}

效果如下:

2021-03-19-14-36-12.gif

圓就更簡單了,本身差不多就是閉合的,只要我們把最后一個點(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ī)則的多邊形怎么辦,所以需要找到一個通用的方法。

image-20210205112436404.png

填充最暴力的方法就是判斷每個點(diǎn)是否在多邊形內(nèi)部,但是這樣的計算量太大,我查了一下多邊形填充的思路,大概有兩種算法:掃描線填充和種子填充,掃描線填充更流行,Rough.js用的也是這種方法,所以接下來介紹一下這個算法。

掃描線填充很簡單,就是一條掃描線(水平線)從多邊形的底部開始往上掃描,那么每條掃描線都會和多邊形有交點(diǎn),同一條掃描線和多邊形的各個交點(diǎn)之間的區(qū)域就是我們要填充的,那么問題來了,怎么確定交點(diǎn),以及怎么判斷兩個交點(diǎn)之間屬于多邊形內(nèi)部。

image-20210319182645014.png

關(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]
image-20210319164037805.png

下面是具體的算法步驟:

1.根據(jù)多邊形的頂點(diǎn)數(shù)據(jù)創(chuàng)建ETedgeTable,按上述順序排序;

2.創(chuàng)建一個空的AETactiveEdgeTable;

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
    // ...
}

看一下最后的填充效果:

image-20210319191716772.png

效果已經(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
    
    // ...
}

順便也加粗一下線段的寬度,效果如下:

2021-03-19-19-58-37.gif

也可以把線段的首尾交替相連變成一筆畫的效果:

2021-03-19-21-11-53.gif

具體實(shí)現(xiàn)可以去源碼里看,接下來我們看最后一個問題,就是讓填充線傾斜一點(diǎn)角度,目前都是水平的。填充線想要傾斜首先我們可以讓圖形先旋轉(zhuǎn)一定角度,這樣掃描出來的線還是水平的,然后再讓圖形和填充線一起再旋轉(zhuǎn)回去就得到傾斜的線了。

image-20210319213337900.png

上圖表示圖形逆時針旋轉(zhuǎn)后進(jìn)行掃描,下圖表示圖形和填充線順時針旋轉(zhuǎn)回去。

image-20210319213401043.png

圖形旋轉(zhuǎn)也就是各個頂點(diǎn)旋轉(zhuǎn),所以問題就變成了求一個點(diǎn)旋轉(zhuǎn)指定角度后的位置,下面來推導(dǎo)一下。

image-20210320101658780.png

上圖里點(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)
        ]
    })
}

效果如下:

2021-03-20-11-14-00.gif

圓形也是一樣,轉(zhuǎn)換成多邊形后先旋轉(zhuǎn),然后掃描再旋轉(zhuǎn)回去:

image-20210320133836887.png

總結(jié)

本文介紹了幾種簡單圖形的手繪風(fēng)格實(shí)現(xiàn)方法,其中涉及到了簡單的數(shù)學(xué)知識及區(qū)域填充算法,如果有不合理或更好的實(shí)現(xiàn)方式請?jiān)诹粞詤^(qū)討論吧,完整的示例代碼在:https://github.com/wanglin2/handPaintedStyle。感謝閱讀,下次再會~

參考文章:

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,818評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,185評論 3 414
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,656評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,647評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,446評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,951評論 1 321
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,041評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,189評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,718評論 1 333
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,800評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,419評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,420評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,755評論 2 371

推薦閱讀更多精彩內(nèi)容