使用 Skia 繪製 2D 圖形

在羚瓏智能設計工具——程序化設計裏,我們需要根據設計師給到的作圖規範來繪製對應的圖形,通過輸入不同的參數輸出不同的設計結果,下面的圖就是程序化設計裏一個 2.5D 背景模型生成圖片的一些例子。那我們使用的繪圖工具就是 skia。

tu1

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 畫筆,見下文

繪製結果:

exp1

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)

繪製結果:

jianbian

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 - -
  1. Porter-Duff 模式:通常用於執行裁剪操作

    Porter-Duff

  2. 可分離混合模式:可以混合顏色,通常用於照亮或變暗圖像。

    fenli

  3. 不可分離混合模式:可以混合顏色,通常通過對色調、飽和度和亮度顏色級別進行操作。

    bukefenli

Matrix

Matrix 矩陣工具,用於圖形變換、數學計算等,主要有三個:

  1. ColorMatrix: 用於計算顏色
  2. Matrix: 3x3矩陣計算,常用於二維圖形變換
  3. 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:

pingyi

平移變換的中間矩陣:

                │ 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 倍:

bukefenli

縮放變換的中間矩陣:

                │ 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) 旋轉了 θ 度:

bukefenli

旋轉變換的中間矩陣:

                │  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 傾斜變換

傾斜變換可以使圖形在水平或垂直方向上傾斜。

如下圖在垂直方向上傾斜了 α 度:

bukefenli

下圖在水平方向上傾斜了 θ 度:

bukefenli

傾斜變換的中間矩陣:

                │    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 的距離:

bukefenli

由此,我們可以知道變換前以及變換後每個頂點的座標,通過這些座標值,可以計算透視中間矩陣。

首先,通過 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 模型的例子,來繪製一個這樣的圖形:

bukefenli

3.1 圖層分析

這個圖形主要由上下兩部分組成。上部分由一個漸變背景層以及一個方格覆蓋層組成,需要進行背景顏色漸變以及方格繪製;下部分由一個漸變背景層以及一個棋盤格覆蓋層組成,同樣需要背景顏色漸變以及方格繪製,同時圖形有透視效果,需要進行透視變換。

bukefenli

由此,可以將該圖形拆解成上下兩個部分,因爲同樣由方格層以及背景層組成,其實可以將之繪製成一個圖形,通過輸入不同的參數進行變化(透視、方格填色)。

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)

繪製結果:

bukefenli

方格層

  • 根據畫布寬高和間距計算出 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)

繪製結果:

bukefenli

棋盤方格

  • 棋盤方格只需要將方格層繪製樣式設置爲填充即可。
rectsPaint.setStyle(PaintStyle.Fill)

canvas.drawPath(rectsPath, rectsPaint)

繪製結果:

bukefenli

透視方格

  • 將方格層加上透視變換即可實現透視效果。
// 透視矩陣
const m = getPerspectiveMatrix(width, height)
// 矩陣變換
rectsPath.transform(m)

canvas.drawPath(rectsPath, rectsPaint)

繪製結果:

bukefenli

圖形組合

  • 將方格層圖形與透視方格圖形組合。
<>
  <PerpectiveRect
    width={512}
    height={450}
    beginColor={c0}
    endColor={c3}
    isGradient
  />
  <PerpectiveRect
    width={512}
    height={300}
    beginColor={c0}
    endColor={c3}
    isGradient      // 是否漸變
    isPerspective   // 是否透視
    isXRect         // 是否棋盤格
  />
</>

繪製結果:

bukefenli

總結

至此,我們便完成了整體背景圖案的繪製。在這裏,我們實現了一套使用 JSX 來編寫圖形組件的形式,通過控制不同的傳參,繪製出不同的結果,這也和程序化設計的目標一致——通過輸入不同的參數輸出不同的設計結果。通過這樣編寫大量的圖形組件,使得程序化設計輸出了豐富多彩的背景圖案,也大大提高了羚瓏模板的豐富度。

參考資料

skia.org

https://learn.microsoft.com/en-us/xamarin/xamarin-forms/user-interface/graphics/skiasharp/

https://stackoverflow.com/questions/48416118/perspective-transform-in-skia

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章