在羚瓏智能設計工具——程序化設計裏,我們需要根據設計師給到的作圖規範來繪製對應的圖形,通過輸入不同的參數輸出不同的設計結果,下面的圖就是程序化設計裏一個 2.5D 背景模型生成圖片的一些例子。那我們使用的繪圖工具就是 skia。
1.Skia
1.1 Skia 簡單介紹
Skia 是一個開源 2D 圖形庫,它提供適用於各種硬件和軟件平臺的通用 API。 它作爲 Google Chrome 和 ChromeOS、Android、Flutter 和許多其他產品的圖形引擎。Skia 支持多語言調用, C++/C#/Java/Python/Rust/WASM 等。
程序化設計有瀏覽器端以及服務端的繪製需求,所以我們選擇 canvaskit-wasm 這個 Skia 打包出來的供 JS 調用的 WebAssembly NPM 包,在 web 端以及 nodejs 端都能使用,這樣就滿足了多端的需求。
1.2 常用繪圖 API
Surface
Surface 是一個對象,用於管理繪製畫布命令的內存,通過處理這段內存信息,可以將它轉成圖片。
下面的代碼顯示如何加載這個包並進行 API 調用:
import CanvasKitInit from 'canvaskit-wasm'
const loadLib = CanvasKitInit({
locateFile(file) {
return 'https://unpkg.com/[email protected]/bin/' + file
}
})
loadLib.then(lib => {
// 創建 500x500 的 surface
const surface = lib.MakeSurface(500, 500)
// 獲取畫布
const canvas = surface.getCanvas()
})
Canvas
Canvas 是 Skia 繪圖上下文,它提供了繪圖的接口。
- canvas.drawRect():繪製一個矩形
- canvas.drawCircle():繪製一個圓
- canvas.drawLine():繪製一條直線
- canvas.drawPath():繪製一條路徑
- canvas.drawArc():繪製一條圓弧
- canvas.drawText():繪製文字
- ...
Path
Path 繪製路徑。
- path.moveTo(x, y):從(x,y)開始繪製一個路徑
- path.lineTo():將直線添加到路徑
- path.arcTo():將弧線添加到路徑
- path.cubicTo():添加貝塞爾曲線
- path.quadTo():添加二次貝齊爾曲線
- path.close():閉合路徑
- path.addRect():添加一個矩形到路徑
- path.addCircle():添加圓
- path.addOval():添加橢圓
- path.addRoundedRect():添加圓角矩形
- path.addArc():添加圓弧
- path.addPath():添加另一個路徑
- ...
Path 繪製例1:
// 繪製三角形
const path = new CanvasKit.Path()
path.moveTo(10, 10)
path.lineTo(100, 10)
path.lineTo(10, 100)
path.close()
// 繪製貝塞爾曲線
const arcPath = new CanvasKit.Path()
arcPath.moveTo(55, 55)
arcPath.cubicTo(120, 150, 130, 180, 200, 200)
// 添加曲線路徑
path.addPath(arcPath)
canvas.drawPath(path, paint) // paint 畫筆,見下文
繪製結果:
Paint
Paint 畫筆,用於存儲當前繪製圖形的樣式信息。
- paint.setColor():設置畫筆顏色
- paint.setAlphaf():設置透明度
- paint.setAntiAlias():抗鋸齒
- paint.setBlendMode():設置混合模式
- paint.setStyle():設置畫筆樣式
- paint.setStrokeWidth():設置描邊寬度
- paint.setColorFilter():設置顏色篩選器
- paint.setImageFilter():設置圖像篩選器
- paint.setMaskFilter():設置掩碼篩選器
- paint.setShader():設置着色器
- ...
例1 中需要加上 Paint 進行樣式繪製:
const { Path, parseColorString } = CanvasKit
const paint = new Paint()
paint.setStyle(PaintStyle.Stroke)
paint.setColor(parseColorString('#000000'))
canvas.drawPath(path, paint)
Shader
Shader 着色器,用於繪製漸變、噪聲、平鋪等效果。
- shader.MakeColor():設置着色器顏色
- shader.MakeLinearGradient():線性漸變
- shader.MakeRadialGradient():徑向漸變
- shader.MakeSweepGradient():掃描漸變
- shader.MakeTwoPointConicalGradient():兩點圓錐漸變
- shader.MakeFractalNoise():柏林噪聲
- shader.MakeTurbulence():平鋪柏林噪聲
- shader.MakeBlend():組合多個着色器效果
Shader 繪製例2:
const { Shader, parseColorString, TileMode } = CanvasKit
const shader = Shader.MakeLinearGradient(
[0, 0], // 漸變開始點
[50, 50], // 漸變結束點
[
parseColorString('#ff0000'),
parseColorString('#ffff00'),
parseColorString('#0000ff')
], // 漸變顏色
[0, 0.5, 1], // 顏色範圍比例
TileMode.Clamp, // 範圍外顏色樣式模式
)
paint.setShader(shader)
繪製結果:
Blendmode
Blendmode 混合模式,用於確定當兩個圖形對象互相重疊時需要如何繪製。主要分爲三大類:
Porter-Duff | 分離 | 不可分離 |
---|---|---|
Clear | Modulate | Hue |
Src | Overlay | Saturation |
Dst | Darken | Color |
SrcOver | Lighten | Luminosity |
DstOver | ColorDodge | - |
SrcIn | ColorBurn | - |
DstIn | HardLight | - |
SrcOut | SoftLight | - |
DstOut | Difference | - |
SrcATop | Exclusion | - |
DstATop | Multiply | - |
Xor | - | - |
Plus | - | - |
-
Porter-Duff 模式:通常用於執行裁剪操作
-
可分離混合模式:可以混合顏色,通常用於照亮或變暗圖像。
-
不可分離混合模式:可以混合顏色,通常通過對色調、飽和度和亮度顏色級別進行操作。
Matrix
Matrix 矩陣工具,用於圖形變換、數學計算等,主要有三個:
- ColorMatrix: 用於計算顏色
- Matrix: 3x3矩陣計算,常用於二維圖形變換
- M44: 4x4矩陣計算,三維圖形變換
矩陣是圖形變換不可或缺的計算工具,接下來詳細闡述一下關於二維圖形變換的工具——Matrix。
2. 圖形變換
所有的圖形變換本質上是點的座標變換,即:
(x, y) => (x', y')
要實現點的座標變換,需要藉助一箇中間矩陣與座標點相乘之後得到變換結果:
(x, y) × 中間矩陣 = (x', y')
在 Skia 中需要藉助一個 3x3 的矩陣進行座標變換(原因見下文):
│ ScaleX SkewY Persp0 │
| x y 1 | × │ SkewX ScaleY Persp1 │ = | x' y' z' |
│ TransX TransY Persp2 │
這裏可以理解爲在三維的某個面上進行圖形變換,爲了方便計算,我們將 z 值設爲 1,相當於在 z 值爲 1 的平面上進行變換:
z' = 1
xFinal = x' / z' = x'
yFinal = y' / z' = y'
最後就得到了最終變換的結果:
(x, y) => (xFinal, yFinal)
Skia 中也提供了一個方便的方法實現座標變換:
Matrix.mapPoints(mat, [x, y]) // 得到經 mat 矩陣變換之後的 x/y 座標
Skia 中的座標系與經典直角座標系(笛卡爾座標系)有所區別,它的 y 軸正方向是向下的,所以變換矩陣也有一些區別。
接下來詳細介紹一下常見的圖形變換。
2.1 平移變換
平移變換在水平方向和垂直方向移動圖形對象,如下圖寬高爲 1 的矩形(單位矩形)由 (0,0) 點向 x 軸移動到 X,向 y 軸移動到 Y:
平移變換的中間矩陣:
│ 1 0 0 │
| x y 1 | × │ 0 1 0 │ = | x' y' 1 |
│ X Y 1 │
這裏可以解釋一下爲何需要使用 3x3 矩陣去做變換,是因爲二維矩陣無法表達 平移 這種最基礎的圖形變換,2x2 矩陣表示兩個維度中的線性變換,線性變換無法改變 (0,0),所以需要藉助升維來解決。見參考資料。
在 Skia 中可以使用 Matrix.translated()
方法來方便做平移變換:
Matrix.translated(X, Y)
2.2 縮放變換
縮放變換會更改圖形對象的大小,如下圖矩形在 x 方向上縮放了 W 倍,在 y 方向上縮放了 H 倍:
縮放變換的中間矩陣:
│ W 0 0 │
| x y 1 | × │ 0 H 0 │ = | x' y' 1 |
│ 0 0 1 │
在 Skia 中可以使用 Matrix.scaled()
方法來方便做縮放變換:
Matrix.scaled(W, H)
2.3 旋轉變換
旋轉變換使圖形圍繞某個點進行旋轉,如下圖矩形圍繞着 (0,0) 旋轉了 θ 度:
旋轉變換的中間矩陣:
│ cos(θ) sin(θ) 0 │
| x y 1 | × │ -sin(θ) cos(θ) 0 │ = | x' y' 1 |
│ 0 0 1 │
在 Skia 中可以使用 Matrix.rotated()
方法來方便做旋轉變換:
Matrix.rotated(toRadians(θ), 0, 0) // 需要將角度轉換爲弧度
2.4 傾斜變換
傾斜變換可以使圖形在水平或垂直方向上傾斜。
如下圖在垂直方向上傾斜了 α 度:
下圖在水平方向上傾斜了 θ 度:
傾斜變換的中間矩陣:
│ 1 tan(α) 0 │
| x y 1 | × │ tan(θ) 1 0 │ = | x' y' 1 |
│ 0 0 1 │
在 Skia 中可以使用 Matrix.skewed()
方法來方便做傾斜變換:
Matrix.skewed(tan(α), tan(θ), 0, 0)
2.5 透視變換
透視變換可以實現圖形的透視效果,它可以使矩形變換成任意凸四邊形,下圖將底邊在水平方向分別擴展了 X1、X2 的距離:
由此,我們可以知道變換前以及變換後每個頂點的座標,通過這些座標值,可以計算透視中間矩陣。
首先,通過 Skia 的中間矩陣變換計算可以得到以下公式:
x' = ScaleX·x + SkewX·y + TransX
y' = SkewY·x + ScaleY·y + TransY
z' = Persp0·x + Persp1·y + Persp2
xFinal = x' / z'
yFinal = y' / z'
z' = 1
於是可以得到 xFinal, yFinal:
xFinal = (ScaleX·x + SkewX·y + TransX) / (Persp0·x + Persp1·y + Persp2)
yFinal = (SkewY·x + ScaleY·y + TransY) / (Persp0·x + Persp1·y + Persp2)
將變換前的 (0, 0)、(w, 0)、(0, h)、(w, h) 以及變換後的 (x1, y1)、(x2, y2)、(x3, y3)、(x4, y4) 代入公式:
x1 = (ScaleX·0 + SkewX·0 + TransX) / (Persp0·0 + Persp1·0 + Persp2)
y1 = (SkewY·0 + ScaleY·0 + TransY) / (Persp0·0 + Persp1·0 + Persp2)
x2 = (ScaleX·w + SkewX·0 + TransX) / (Persp0·w + Persp1·0 + Persp2)
y2 = (SkewY·w + ScaleY·0 + TransY) / (Persp0·w + Persp1·0 + Persp2)
x3 = (ScaleX·w + SkewX·h + TransX) / (Persp0·w + Persp1·h + Persp2)
y3 = (SkewY·w + ScaleY·h + TransY) / (Persp0·w + Persp1·h + Persp2)
x4 = (ScaleX·0 + SkewX·h + TransX) / (Persp0·0 + Persp1·h + Persp2)
y4 = (SkewY·0 + ScaleY·h + TransY) / (Persp0·0 + Persp1·h + Persp2)
簡化之後:
x1·Persp2 - TransX = 0
y1·Persp2 - TransY = 0
Persp0·w·x2 + Persp2·x2 - ScaleX·w - TransX = 0
Persp0·w·y2 + Persp2·y2 - SkewY·w - TransY = 0
Persp0·w·x3 + Persp1·h·x3 + Persp2·x3 - ScaleX·w - SkewX·h - TransX = 0
Persp0·w·y3 + Persp1·h·y3 + Persp2·y3 - SkewY·w - ScaleY·h - TransY = 0
Persp1·h·x4 + Persp2·x4 - SkewX·h - TransX = 0
Persp1·h·y4 + Persp2·y4 - ScaleY·h - TransY = 0
最後,將具體的座標值代入,就能將最終值求解出。
以下是最終參考計算方法:
export type Point = { x: number; y: number }
export function createPerspectiveMatrixFromPoints(
topLeft: Point,
topRight: Point,
botRight: Point,
botLeft: Point,
w: number,
h: number,
) {
const { x: x1, y: y1 } = topLeft
const { x: x2, y: y2 } = topRight
const { x: x3, y: y3 } = botRight
const { x: x4, y: y4 } = botLeft
const scaleX =
(y1 * x2 * x4 -
x1 * y2 * x4 +
x1 * y3 * x4 -
x2 * y3 * x4 -
y1 * x2 * x3 +
x1 * y2 * x3 -
x1 * y4 * x3 +
x2 * y4 * x3) /
(x2 * y3 * w + y2 * x4 * w - y3 * x4 * w - x2 * y4 * w - y2 * w * x3 + y4 * w * x3)
const skewX =
(-x1 * x2 * y3 -
y1 * x2 * x4 +
x2 * y3 * x4 +
x1 * x2 * y4 +
x1 * y2 * x3 +
y1 * x4 * x3 -
y2 * x4 * x3 -
x1 * y4 * x3) /
(x2 * y3 * h + y2 * x4 * h - y3 * x4 * h - x2 * y4 * h - y2 * h * x3 + y4 * h * x3)
const transX = x1
const skewY =
(-y1 * x2 * y3 +
x1 * y2 * y3 +
y1 * y3 * x4 -
y2 * y3 * x4 +
y1 * x2 * y4 -
x1 * y2 * y4 -
y1 * y4 * x3 +
y2 * y4 * x3) /
(x2 * y3 * w + y2 * x4 * w - y3 * x4 * w - x2 * y4 * w - y2 * w * x3 + y4 * w * x3)
const scaleY =
(-y1 * x2 * y3 -
y1 * y2 * x4 +
y1 * y3 * x4 +
x1 * y2 * y4 -
x1 * y3 * y4 +
x2 * y3 * y4 +
y1 * y2 * x3 -
y2 * y4 * x3) /
(x2 * y3 * h + y2 * x4 * h - y3 * x4 * h - x2 * y4 * h - y2 * h * x3 + y4 * h * x3)
const transY = y1
const persp0 =
(x1 * y3 - x2 * y3 + y1 * x4 - y2 * x4 - x1 * y4 + x2 * y4 - y1 * x3 + y2 * x3) /
(x2 * y3 * w + y2 * x4 * w - y3 * x4 * w - x2 * y4 * w - y2 * w * x3 + y4 * w * x3)
const persp1 =
(-y1 * x2 + x1 * y2 - x1 * y3 - y2 * x4 + y3 * x4 + x2 * y4 + y1 * x3 - y4 * x3) /
(x2 * y3 * h + y2 * x4 * h - y3 * x4 * h - x2 * y4 * h - y2 * h * x3 + y4 * h * x3)
const persp2 = 1
return [scaleX, skewX, transX, skewY, scaleY, transY, persp0, persp1, persp2]
}
3. 繪製舉例
拿文章開頭 2.5D 模型的例子,來繪製一個這樣的圖形:
3.1 圖層分析
這個圖形主要由上下兩部分組成。上部分由一個漸變背景層以及一個方格覆蓋層組成,需要進行背景顏色漸變以及方格繪製;下部分由一個漸變背景層以及一個棋盤格覆蓋層組成,同樣需要背景顏色漸變以及方格繪製,同時圖形有透視效果,需要進行透視變換。
由此,可以將該圖形拆解成上下兩個部分,因爲同樣由方格層以及背景層組成,其實可以將之繪製成一個圖形,通過輸入不同的參數進行變化(透視、方格填色)。
3.2 圖形繪製
背景層
- 給整個圖形畫一個方框,加上漸變着色器即完成背景繪製。
const backgroundPaint = new Paint()
backgroundPaint.setStyle(PaintStyle.Fill)
const points = {
begin: [0, height],
end: [0, 0],
}
const colors = [parseColorString(beginColor), parseColorString(endColor)]
const shader = Shader.MakeLinearGradient(points.begin, points.end, colors, [0, 1], TileMode.Clamp) // 漸變
backgroundPaint.setShader(shader)
// 繪製矩形
canvas.drawRect(Rect.makeXYWH(0, 0, width, height).toArray(), backgroundPaint)
繪製結果:
方格層
- 根據畫布寬高和間距計算出 x 方向和 y 方向上繪製的方格個數 + 1,然後根據奇偶數排列繪製矩形,並使用平移矩陣將整體居中。
- 針對方格層圖形進行漸變顏色填充或線條顏色填充繪製。
const rectsPath = new Path()
for (let i = 0; i < lineNum + 1; i++) { // 循環遍歷繪製方格
for (let j = 0; j < yLineNum + 1; j++) {
if (i % 2 === 0 && j % 2 === 0) {
const rect = Rect.makeXYWH(rectSize * i, rectSize * j, rectSize, rectSize)
rectsPath.addRect(rect.toArray())
}
if (i % 2 === 1 && j % 2 === 1) {
const rect = Rect.makeXYWH(rectSize * i, rectSize * j, rectSize, rectSize)
rectsPath.addRect(rect.toArray())
}
}
}
const overlayShader = Shader.MakeLinearGradient( // 方格層漸變
points.begin,
points.end,
overlayColors,
[0, 1],
TileMode.Clamp,
)
const rectsPaint = new Paint()
rectsPaint.setAntiAlias(true)
rectsPaint.setStyle(PaintStyle.Stroke)
rectsPaint.setShader(overlayShader)
canvas.drawPath(rectsPath, rectsPaint)
繪製結果:
棋盤方格
- 棋盤方格只需要將方格層繪製樣式設置爲填充即可。
rectsPaint.setStyle(PaintStyle.Fill)
canvas.drawPath(rectsPath, rectsPaint)
繪製結果:
透視方格
- 將方格層加上透視變換即可實現透視效果。
// 透視矩陣
const m = getPerspectiveMatrix(width, height)
// 矩陣變換
rectsPath.transform(m)
canvas.drawPath(rectsPath, rectsPaint)
繪製結果:
圖形組合
- 將方格層圖形與透視方格圖形組合。
<>
<PerpectiveRect
width={512}
height={450}
beginColor={c0}
endColor={c3}
isGradient
/>
<PerpectiveRect
width={512}
height={300}
beginColor={c0}
endColor={c3}
isGradient // 是否漸變
isPerspective // 是否透視
isXRect // 是否棋盤格
/>
</>
繪製結果:
總結
至此,我們便完成了整體背景圖案的繪製。在這裏,我們實現了一套使用 JSX 來編寫圖形組件的形式,通過控制不同的傳參,繪製出不同的結果,這也和程序化設計的目標一致——通過輸入不同的參數輸出不同的設計結果。通過這樣編寫大量的圖形組件,使得程序化設計輸出了豐富多彩的背景圖案,也大大提高了羚瓏模板的豐富度。
參考資料
https://learn.microsoft.com/en-us/xamarin/xamarin-forms/user-interface/graphics/skiasharp/
https://stackoverflow.com/questions/48416118/perspective-transform-in-skia