手把手教你實現手繪風格圖形

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

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

是不是有點蠢萌,本文的主要內容是帶大家手動實現上面的圖形,最終效果預覽:http://lxqnsys.com/#/demo/handPaintedStyle。話不多說,代碼見。

線段

萬物基于線段,所以先來看線段怎么畫,仔細看上圖會發現手繪版線段其實是用兩根彎曲的線段組成的,曲線可以使用貝塞爾曲線來畫,這里使用三次貝塞爾曲線,那么剩下的問題就是求起點、終點、兩個控制點的坐標了。

貝塞爾曲線可以在這個網站上嘗試:https://cubic-bezier.com/。

首先一條線段的起點和終點我們都給它加一點隨機值,隨機值比如就在[-2,2]之間,也可以把這個范圍和線段的長度關聯起來,比如線段越長,隨機值就越大。

// 直線變曲線
_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)
}

接下來就是兩個控制點,我們把控制點限定在線段所在的矩形內:

image-20210204165810055.png
_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方法前兩個點為控制點,第三個點為結束點
    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

但是多試幾次就會發現偏離太遠、彎曲程度過大:

image-20210204180036030.png

完全不像一個手正常的人能畫出來的,去上面的貝塞爾曲線網站上試幾次會發現兩個控制點離線段越近,曲線彎曲程度越?。?/p>

image-20210313175539327.png

所以我們要找線段附近的點作為控制點,首先隨機一個橫坐標點,然后可以計算出線段上該橫坐標對應的縱坐標點,把該縱坐標點加減一點隨機值即可。

_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)// 在橫坐標附近找一個隨機點
        ry = y1 + yo * this.random(0, 1)// 在線段上找一個隨機點
        return [rx, ry]
    }
    xo = x2 - x1
    rx = x1 + xo * this.random(0, 1)// 找一個隨機的橫坐標
    ry = ((rx - x1) * (y2 - y1)) / (x2 - x1) + y1// 通過兩點式求出直線方程
    ry += this.random(-2, 2)// 縱坐標加一點隨機值
    return [rx, ry]
}

看一下效果:

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

當然和Rough.js比起來還是不夠好,有興趣的可以自行去看一下源碼,反正筆者是看不懂,控制變量太多,還沒有注釋。

多邊形&矩形

多邊形就是把多個點首尾相連起來,遍歷頂點調用繪制線段的方法即可:

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

矩形是多邊形的一種特殊情況,四個角都是直角,一般傳參為左上角頂點的x坐標、y坐標、矩形的寬、矩形的高:

// 繪制手繪矩形
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

圓要怎么處理呢,首先大家都知道圓是可以使用多邊形來近似得到的,只要多邊形的邊足夠多,那么看起來就足夠圓,既然不想要太圓,那就把它恢復成多邊形好了,多邊形上面已經講過了?;謴统啥噙呅魏芎唵?,比如我們要把一個圓變成十邊形(具體還原成幾邊形你也可以和圓的周長關聯起來),那么每個邊對應的弧度就是2*Math.PI/10,然后使用Math.cosMath.sin來計算頂點的位置,最后再調用繪制多邊形的方法進行繪制:

// 繪制手繪圓
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

可以看到效果很一般,就算邊的數量再多一點看起來也不像:

image-20210317134538803.png

如果直接用正常的線段連起來,那完全就是個正經多邊形了,肯定也不行,所以核心是把線段變成隨機弧形,首先為了增加隨機性,我們把圓的半徑和各個頂點都加一點隨機增量:

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

接下來的問題又變成了計算貝塞爾曲線的兩個控制點,首先因為弧線肯定是要往多邊形外凸的,根據貝塞爾曲線的性質,兩個控制點一定是在線段的外面,直接用線段本身的兩個端點來計算的話我試了一下,比較難處理,不同的角度可能都需要特殊處理,所以我們參考Rough.js間隔一個點:

image-20210318152243835.png

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

image-20210318152754865.png

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

image-20210318163849897.png

我們讓它對稱一下,讓點c的前一個點減后一個點:

image-20210318154453700.png

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

image-20210318163925954.png

原因很簡單,控制點離的太遠了,所以我們少加一點差值,最后代碼如下:

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 < len; i++) {
        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加的是六分之一。

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

事情到這里并沒有結束,首先這個圓還有個缺口,原因很簡單,i + 2 < len的循環條件導致最后一個點沒連上,另外首尾也沒有相連,此外開頭一段很不自然,太直了,原因是我們路徑的起點是從第一個點開始的,但是我們的第一段曲線的結束點已經是第三個點了,所以先把路徑的起點移到第二個點:

this.ctx.moveTo(points[1][0], points[1][1])

這樣缺口就更大了:

image-20210318164148681.png

紅色的代表前兩個點,藍色的是最后一個點,為了要連到第二個點我們需要把頂點列表里的前三個點追加到列表最后:

// 把前三個點追加到列表最后
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

問題又來了,應該沒有人能徒手把圓的首尾完美無缺的連上,所以加的第二個點我們不能讓它和原來的點一模一樣,得加點偏移:

let end = [] // 處理最后一個連線點,讓它和原本的點來點隨機偏移
let radRandom = step * this.random(0.1, 0.5)// 讓該點超前一點,代表畫過頭了,也可以來點負數,代表差一點才連上,但是比較丑
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()
//...

最后一個要優化的點是起點或者說終點位置,一般來說我們徒手畫圓都是從上面開始畫,因為0度是在x軸正軸方向,所以我們減去Math.PI/2左右就能把起點移到上方,最后完整的代碼如下:

drawCircle (x, y, r) {
    // 圓變多邊形
    let stepCount = 10
    let step = (2 * Math.PI) / stepCount// 多邊形的一條邊對應的角度
    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 < 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

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

// 繪制手繪多邊形
polygon (points = [], opt = {}) {
    if (points.length < 3) {
        return
    }
    // 加上填充方法
    let lines = this.closeLines(points)
    this.fillLines(lines, opt)
    
    // 描邊
    let len = points.length
    // ...
}

closeLines方法用來把頂點閉合成曲線:

// 把多邊形的頂點轉換成首尾相連的閉合線段
closeLines (points) {
    let len = points.length
    let lines = []
    let lastPoint = null
    for (let i = 0; i < len - 1; i++) {
        // _line方法上文已經實現了,把直線段轉換成曲線
        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
}

線段有了,只要遍歷線段繪制出來最后調用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

圓就更簡單了,本身差不多就是閉合的,只要我們把最后一個點的特殊處理邏輯給去掉就行了:

// 下面幾行代碼都給去掉,使用原本的點即可
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

第二種填充會稍微復雜一點,比如下面這種最簡單的填充,其實就是一些傾斜的線段,但問題是這些線段的端點怎么確定,矩形當然可以暴力的算出來,但是不規則的多邊形怎么辦,所以需要找到一個通用的方法。

image-20210205112436404.png

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

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

image-20210319182645014.png

關于交點的計算,首先我們交點的y坐標是已知的,就是掃描線的y坐標,那么只要求出x,知道線段的兩個端點坐標,那么可以求出直線方程,然后再計算,但是有一種更簡單的方法,就是利用邊的相關性,也就是知道了線段上的某一點,其相鄰的點可以輕松的根據該點求出,下面是推導過程:

// 設直線方程
y = kx + b
// 設兩點:c(x3, y3),d點的y坐標為c點y坐標+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坐標+1,x坐標為上一個點的x坐標加上直線斜率的倒數
// 多邊形的線段是已知兩個點的,假設為a(x1, y1)、b(x2, y2),那么斜率k如下:
k = (y2 - y1) / 
// 斜率的倒數也就是
1/k = (x2 - x1) / (y2 - y1)

這樣我們從線段的一個端點開始,可以挨個計算出線段上的所有點。

詳細的算法介紹和推導過程可以看一下這個PPT:https://wenku.baidu.com/view/4ee141347c1cfad6195fa7c9.html,接下來直接來看算法的實現過程。

先簡單介紹一下幾個名詞:

1.邊表ET

邊表ET,一個數組,里面保存了多邊形所有邊的信息,每條邊保存的信息有:該邊y的最大值ymax和最小值ymin、該邊最低點的x值xi、該邊斜率的倒數dx。邊按ymin遞增排序,ymin相同則按xi遞增,xi也相同則只能看ymax,如果ymax還相同,說明兩條邊重合了,如果不重合,則按yamx遞增排序。

2.活動邊表AET

也是一個數組,里面保存著與當前掃描線相交的邊信息,隨著掃描線的掃描會發生變化,刪除不相交的,添加新相交的。該表里的邊按xi遞增排序。

比如下面的多邊形ET表順序為:

// ET
[p1p5, p1p2, p5p4, p2p3, p4p3]
image-20210319164037805.png

下面是具體的算法步驟:

1.根據多邊形的頂點數據創建ETedgeTable,按上述順序排序;

2.創建一個空的AETactiveEdgeTable;

3.開始掃描,掃描線的y=多邊形的最低點的y值,也就是activeEdgeTable[0].ymin;

4.重復下面步驟,直到ET表和AET表都為空:

(1)從`ET`表里取出與當前掃描線相交的邊,添加到`AET`表里,同樣按上面提到的順序排序

(2)成對取出`AET`表里的邊信息的`xi`值,在每對之間進行填充

(3)從`AET`表里刪除當前已經掃描到最后的邊,即`y >= ymax`

(4)更新`AET`表里剩下的邊信息的`xi`,即`xi = xi + dx`

(5)更新掃描線的`y`,即`y = y + 1`

看著并不難,接下來轉化成代碼,先創建一下邊表ET

// 創建排序邊表ET
createEdgeTable (points) {
    // 邊表ET
    let edgeTable = []
    // 將第一個點復制一份到隊尾,用來閉合多邊形
    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], // 最低頂點的x值
                dx: (p2[0] - p1[0]) / (p2[1] - p1[1]) // 線段的斜率的倒數
            })
        }
    }
    // 對邊表進行排序
    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
}

接下來進行掃描操作:

scanLines (points) {
    if (points.length < 3) {
        return []
    }
    let lines = []
    // 創建排序邊表ET
    let edgeTable = this.createEdgeTable(points)
    // 活動邊表AET
    let activeEdgeTable = []
    // 開始掃描,從多邊形的最低點開始
    let y = edgeTable[0].ymin
    // 循環的終點是兩個表都為空
    while (edgeTable.length > 0 || activeEdgeTable.length > 0) {
        // 從ET表里把當前掃描線的邊添加到AET表里
        if (edgeTable.length > 0) {
            // 將當前ET表里和掃描線相交的邊添加到AET表里
            for (let i = 0; i < edgeTable.length; i++) {
                // 如果掃描線的間隔加大,可能高低差比較小的線段會被整個直接跳過,導致死循環,需要考慮到這種情況
                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
            }
        })
        // 如果存在活動邊,則填充活動邊之間的區域
        if (activeEdgeTable.length > 1) {
            // 每次取兩個邊出來進行填充
            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
}

代碼其實就是上述算法過程的翻譯,理解了算法代碼并不難理解,在多邊形方法里調用一下該方法:

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

效果已經出來了,但是太密了,因為我們的掃描線每次加的是1,我們多加點試試:

scanLines (points) {
    // ...
    
    // 我們讓掃描線每次加10
    let gap = 10
    // 更新活動邊的xi
    activeEdgeTable.forEach((item) => {
        item.xi += item.dx * gap// 斜率的倒數為什么也要乘10可以去看上面的推導過程
    })
    // 更新掃描線y
    y += gap
    
    // ...
}

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

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

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

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

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

image-20210319213337900.png

上圖表示圖形逆時針旋轉后進行掃描,下圖表示圖形和填充線順時針旋轉回去。

image-20210319213401043.png

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

image-20210320101658780.png

上圖里點(x,y)原本的角度為a,線段長為r,求旋轉角度b后的坐標(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)

由此可以得到求一個點旋轉指定角度后的坐標的函數:

getRotatedPos (x, y, rad) {
    return [
        x: x * Math.cos(rad) - y * Math.sin(rad),
        y: y * Math.cos(rad) + x * Math.sin(rad)
    ]
}

有了該函數我們就可以來旋轉多邊形了:

// 繪制手繪多邊形
polygon (points = [], opt = {}) {
    if (points.length < 3) {
        return
    }
    // 掃描前先旋轉多邊形
    let _points = this.rotatePoints(points, opt.rotate)
    let lines = this.scanLines(_points)
    // 掃描完得到的線段我們再旋轉相反的角度
    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
    // ...
}

// 旋轉頂點列表
rotatePoints (points, rotate) {
    return points.map((item) => {
        return this.getRotatedPos(item[0], item[1], rotate)
    })
}

// 旋轉線段列表
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

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

image-20210320133836887.png

總結

本文介紹了幾種簡單圖形的手繪風格實現方法,其中涉及到了簡單的數學知識及區域填充算法,如果有不合理或更好的實現方式請在留言區討論吧,完整的示例代碼在:https://github.com/wanglin2/handPaintedStyle。感謝閱讀,下次再會~

參考文章:

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容