第 2 課 - 繪製圓形
在這一課中,你將學到下列內容:
- 將圖形加入畫布
- 使用 SDF 繪製圓形
- 反鋸齒
- 髒旗標設計模式
啟動專案時,你會看到畫布上畫了一個圓,可以修改寬高,或切換 WebGL / WebGPU 渲染器。
width = Inputs.range([50, 300], { label: 'width', value: 100, step: 1 });height = Inputs.range([50, 300], { label: 'height', value: 100, step: 1 });renderer = Inputs.select(['webgl', 'webgpu'], { label: 'renderer' });canvas = (async () => {
const { Canvas, Circle } = Lesson2;
const canvas = await Utils.createCanvas(Canvas, 100, 100, renderer);
const circle = new Circle({
cx: 100,
cy: 100,
r: 100,
fill: 'red',
antiAliasingType: 3,
});
canvas.appendChild(circle);
let id;
const animate = () => {
canvas.render();
id = requestAnimationFrame(animate);
};
animate();
unsubscribe(() => {
cancelAnimationFrame(id);
canvas.destroy();
});
return canvas;
})();call(() => {
Utils.resizeCanvas(canvas, width, height);
});call(() => {
return canvas.getDOM();
});將圖形加入畫布
上一課我們建立了一塊空白畫布,接下來會往上面加入各種圖形。這樣的 API 要怎麼設計?身為前端開發者,你可能會想用熟悉的 Node API appendChild 來繪製:
canvas.appendChild(shape);
canvas.removeChild(shape);先暫時建立一個圖形基底類別,Circle、Ellipse、Rect 等都會繼承它:
export abstract class Shape {}用一個陣列在畫布中儲存圖形清單:
#shapes: Shape[] = [];
appendChild(shape: Shape) {
this.#shapes.push(shape);
}
removeChild(shape: Shape) {
const index = this.#shapes.indexOf(shape);
if (index !== -1) {
this.#shapes.splice(index, 1);
}
}在畫布的 render 方法中走訪圖形清單,並呼叫 render 鉤子:
render() {
const { hooks } = this.#pluginContext;
hooks.beginFrame.call();
this.#shapes.forEach((shape) => {
hooks.render.call(shape);
});
hooks.endFrame.call();
}在渲染外掛中,每一幀開始前會建立一個 RenderPass,它封裝在硬體抽象層裡。WebGL 沒有這個概念,但在 WebGPU 中,beginRenderPass 會傳回 GPURenderPassEncoder,用來記錄一系列命令,包括 draw 命令,稍後在 render 鉤子中會看到。建立 RenderPass 時我們提供下列參數:
colorAttachmentcolorResolveTocolorClearColor。這在 WebGL 中以 gl.clearColor 命令實作;在 WebGPU 中則以 clearValue 屬性宣告。這裡我們把它設為白色。
hooks.beginFrame.tap(() => {
this.#device.beginFrame();
this.#renderPass = this.#device.createRenderPass({
colorAttachment: [renderTarget],
colorResolveTo: [onscreenTexture],
colorClearColor: [TransparentWhite],
});
});對應建立過程,每一幀結束時要提交 RenderPass。在 WebGPU 中同樣很容易找到對應的 submit 方法。當然,原生 API 提交的是已編碼的命令緩衝區,硬體抽象層簡化了這些概念。
hooks.endFrame.tap(() => {
this.#device.submitPass(this.#renderPass);
this.#device.endFrame();
});最後來到 render 鉤子。每個圖形負責實作繪製自己的邏輯,外掛則負責傳入所需的 GPU 物件,例如 Device 與 RenderPass。
hooks.render.tap((shape) => {});繪製圓形
首先要定義圓的基本屬性。熟悉 SVG circle 的人會知道,可以根據圓心 cx/cy 與半徑 r 定義圓的幾何,並使用通用的繪製屬性 fill 與 stroke 滿足基本需求。
export class Circle extends Shape {
constructor(
config: Partial<{
cx: number;
cy: number;
r: number;
fill: string;
}> = {},
) {}
}畫布座標
既然談到 cx/cy 圓心這類位置屬性,就必須先弄清楚我們使用的畫布座標系。在 Canvas 與 SVG 中,座標系原點都在左上角,X 軸正向 👉,Y 軸正向 👇。然而 WebGL 使用的 裁剪座標系 遵循 OpenGL 規範,原點在視埠中心,X 軸指向 👉,Y 軸指向 👆,Z 軸指向螢幕內側。下方這個長寬比為 2 的立方體,也稱為正規化裝置座標(normalized device coordinates,NDC):
然而 WebGPU 遵循 Metal 規範,與 WebGL 的差異在於 Y 軸正向朝 👇,Z 軸正向朝外。Z 軸的裁剪範圍也不同:WebGL 是 [-1, 1],WebGPU 是 [0, 1]:

我們的硬體抽象層試著抹平 WebGL 與 WebGPU 的差異,但在座標系上選擇對齊 Canvas / SVG。我們認為這更符合白板使用者的習慣。

因此,如果畫布寬高是 200,用下面的方式加入的 Circle 會出現在畫布中央:
const circle = new Circle({
cx: 100,
cy: 100,
r: 50,
fill: 'red',
});
canvas.appendChild(circle);下一個問題是,如何把螢幕座標系中的 cx/cy 轉換成渲染管線使用的 NDC。我們會把畫布的寬高作為 Uniform 傳入,把圓的位置作為 Attribute 傳入。位置除以寬高會得到 [0, 1] 範圍的值,乘以二再減一,就轉換成 [-1, 1],也就是 NDC 下的值域。最後把 Y 軸向下翻轉:
layout(std140) uniform SceneUniforms {
vec2 u_Resolution; // width & height of canvas
};
layout(location = 1) in vec2 a_Position; // cx & cy
// Pixel space to [0, 1] (Screen space)
vec2 zeroToOne = (a_Position + a_Size * a_FragCoord) / u_Resolution;
// Convert from [0, 1] to [0, 2]
vec2 zeroToTwo = zeroToOne * 2.0;
// Convert from [0, 2] to [-1, 1] (NDC/clip space)
vec2 clipSpace = zeroToTwo - 1.0;
// Flip Y axis
gl_Position = vec4(clipSpace * vec2(1, -1), 0.0, 1.0);處理顏色值
與 Canvas 或 SVG 不同,字串形式的顏色值不能直接用在 WebGL 或 WebGPU 中。不過 d3-color 可以把它轉換成 { r, g, b, opacity } 格式,之後就能直接作為 vec4 傳入 attribute,或加以壓縮。最後,我們目前只支援 RGB 空間的顏色值,這表示 hsl 與 oklch 無法使用:
import * as d3 from 'd3-color';
set fill(fill: string) {
this.#fill = fill;
this.#fillRGB = d3.rgb(fill); // { r, g, b, opacity }
}樣式問題解決之後,讓我們回到幾何。三角形網格是 3D 渲染中常見的幾何表示,Three.js 的 CircleGeometry 會從圓心把圓拆成三角形,以程序化方式產生幾何。顯然三角形越多,圓就越平滑;如果只有兩個三角形,它會退化成正方形。為了得到平滑的圓,需要更多頂點,圓的數量一增加,GPU 記憶體就會顯著上升。

SDF
使用一種稱為有號距離函數(Signed Distance Functions,SDF)的方法,只需要四個頂點。下圖視覺化了 SDF 的概念,來自新興編輯器 Zed 的實作文章 drawing-rectangles。平面上半徑 100 的圓,圓上的距離為 0,圓內為負值,圓外為正值:
原文使用 Lottie 動畫展示有向距離場的定義,以及底層圖形若干公式的推導。在 Zed 的 GPUI 中,同樣使用 SDF 繪製底層圖形,以獲得更好的效能。
通常我們在頂點著色器中建立座標系。
layout(location = 0) in vec2 a_FragCoord;
out vec2 v_FragCoord;
void main() {
v_FragCoord = a_FragCoord;
}有了距離資訊,就可以在片段著色器中使用不同圖形的 SDF 公式,帶入目前像素的座標,判斷該點是否在圖形內部。如果在外部,可以直接丟棄,否則著色。GLSL 程式碼如下。有些效果不在意部分透明度,而是想依照紋理的顏色值,要嘛顯示、要嘛完全不顯示,請見 丟棄片段。
float sdf_circle(vec2 p, float r) {
return length(p) - r;
}
void main() {
float distance = sdf_circle(v_FragCoord, 1.0);
if (distance > 0.0) {
discard;
}
outputColor = vec4(1.0, 0.0, 0.0, 1.0);
}除了使用更少的頂點,SDF 還有下列優點:
- 容易做反鋸齒。下一小節會介紹。
- 容易組合。交集與差集運算可以組合起來,完成複雜圖形。
- 容易實現一些看起來複雜的效果。例如描邊、圓角、陰影。當然,實作這些效果時,我們會介紹這個方法的一些限制。
distfunctions 也有 SDF 的解釋與詳細推導。這個方法可以繪製各種常見的 2D 甚至 3D 形狀,之後我們會繼續用它繪製矩形與文字。
讓我們回到圖形基底類別,加上一個接收繪製所需參數的方法:
export abstract class Shape {
abstract render(device: Device, renderPass: RenderPass): void;
}在外掛的 render 鉤子中呼叫,並傳入所需參數:
hooks.render.tap((shape) => {
shape.render(this.#device, this.#renderPass);
});在 Circle 的 render 方法中建立與 clip space 一致的單位座標系,包含四個頂點,透過 indexBuffer 索引陣列拆成兩個三角形(V0 -> V1 -> V2 與 V0 -> V2 -> V3):

this.#fragUnitBuffer = device.createBuffer({
viewOrSize: new Float32Array([-1, -1, 1, -1, 1, 1, -1, 1]),
usage: BufferUsage.VERTEX,
});
this.#indexBuffer = device.createBuffer({
viewOrSize: new Uint32Array([0, 1, 2, 0, 2, 3]),
usage: BufferUsage.INDEX,
});這四個頂點可以共用相同的樣式屬性,例如圓心、半徑、填色等。這能縮小頂點陣列記憶體的大小:
this.#instancedBuffer = device.createBuffer({
viewOrSize: new Float32Array([
this.#cx,
this.#cy,
this.#r,
this.#r,
this.#fillRGB.r / 255,
this.#fillRGB.g / 255,
this.#fillRGB.b / 255,
this.#fillRGB.opacity,
]),
usage: BufferUsage.VERTEX,
});接著指定頂點陣列應該如何配置,並透過 shaderLocation 與著色器關聯。
this.#inputLayout = device.createInputLayout({
vertexBufferDescriptors: [
{
arrayStride: 4 * 2,
stepMode: VertexStepMode.VERTEX,
attributes: [
{
shaderLocation: 0, // layout(location = 0) in vec2 a_FragCoord;
offset: 0,
format: Format.F32_RG,
},
],
},
{
arrayStride: 4 * 8,
stepMode: VertexStepMode.INSTANCE,
attributes: [
{
shaderLocation: 1, // layout(location = 1) in vec2 a_Position;
offset: 0,
format: Format.F32_RG,
},
{
shaderLocation: 2, // layout(location = 2) in vec2 a_Size;
offset: 4 * 2,
format: Format.F32_RG,
},
{
shaderLocation: 3, // layout(location = 3) in vec4 a_FillColor;
offset: 4 * 4,
format: Format.F32_RGBA,
},
],
},
],
indexBufferFormat: Format.U32_R,
program: this.#program,
});SDF 也可以用來繪製橢圓、矩形、文字等,但我們現在先不繼續加入其他圖形,而是先專注另一個問題。
反鋸齒
如果仔細看,或把畫面放大,會發現邊緣明顯有鋸齒。畢竟在片段著色器中,我們對每個像素點採用粗暴的判斷:要嘛著色,要嘛丟棄,中間沒有過渡。
(async () => {
const { Canvas, Circle } = Lesson2;
const canvas = await Utils.createCanvas(Canvas, 200, 200);
const circle = new Circle({
cx: 100,
cy: 100,
r: 100,
fill: 'red',
});
canvas.appendChild(circle);
let id;
const animate = () => {
canvas.render();
id = requestAnimationFrame(animate);
};
animate();
unsubscribe(() => {
cancelAnimationFrame(id);
canvas.destroy();
});
return canvas.getDOM();
})();下面我們參考文章 平滑 SDF 圖形邊緣,使用幾種不同做法並比較結果。
Smoothstep
首先想到的是,可以用 GLSL / WGSL 的內建函式 smoothstep 做平滑。相對於 step 函式,它會在指定的值域產生平滑後的值,效果類似緩動函式 ease-in/out。你可以在 Smoothstep - thebookofshaders.com 修改參數,視覺化它的形狀。例如下圖中,x 大於 0 時 y 為 1,x 小於 -0.5 時 y 為 0,中間的區域被平滑:

上一節算出的 SDF 距離是負值。我們取一個較小的固定值 0.01,讓邊緣較小範圍內的距離可以被平滑,處理後的值可以當作透明度。
float alpha = smoothstep(0.0, 0.01, -distance);
outputColor = v_FillColor;
outputColor.a *= alpha;效果如下:
(async () => {
const { Canvas, Circle } = Lesson2;
const canvas = await Utils.createCanvas(Canvas, 200, 200);
const circle = new Circle({
cx: 100,
cy: 100,
r: 100,
fill: 'red',
antiAliasingType: 1,
});
canvas.appendChild(circle);
let id;
const animate = () => {
canvas.render();
id = requestAnimationFrame(animate);
};
animate();
unsubscribe(() => {
cancelAnimationFrame(id);
canvas.destroy();
});
return canvas.getDOM();
})();這個方法的問題是,它會讓圓的半徑稍微變大,畢竟多出了百分之一。另外放大之後(相機相關功能稍後再談),邊緣也不夠銳利。
固定值相除
GLSL 沒有 saturate 函式,可以用 clamp 實作:
float alpha = clamp(-distance / 0.01, 0.0, 1.0);(async () => {
const { Canvas, Circle } = Lesson2;
const canvas = await Utils.createCanvas(Canvas, 200, 200);
const circle = new Circle({
cx: 100,
cy: 100,
r: 100,
fill: 'red',
antiAliasingType: 2,
});
canvas.appendChild(circle);
let id;
const animate = () => {
canvas.render();
id = requestAnimationFrame(animate);
};
animate();
unsubscribe(() => {
cancelAnimationFrame(id);
canvas.destroy();
});
return canvas.getDOM();
})();螢幕空間導數
使用 fwidth 進行基於距離的反鋸齒 說明了如何用 fwidth 做基於距離的反鋸齒。什麼是 fwidth?
什麼是螢幕空間導數,什麼時候會用到? 與 什麼是 fwidth,它如何運作? 詳細說明了這個方法的概念與計算。簡而言之,片段著色器一次處理一個 2x2 的 quad,而不是單一個像素點。GPU 這樣做的理由,見 2011 圖形管線之旅,第 8 部分:
另外,這裡正好說明為什麼我們處理的是 2×2 像素的 quad,而不是個別像素。主要原因是導數。紋理取樣器依賴紋理座標的螢幕空間導數來選擇 mip-map 並進行過濾(如同我們在第 4 部分看到的);從 shader model 3.0 起,同一套機制也能以導數指令的形式,直接提供給像素著色器使用。
下面看看每個 2x2 quad 中偏導數是如何計算的,例如對 uv:

為了讓開發者更容易感受某個值在像素之間變化得有多劇烈,OpenGL / WebGL 與 WebGPU 都提供下列方法。但 WebGL1 需要啟用 GL_OES_standard_derivatives 擴充功能,WebGL2 與 WebGPU 則不需要:
dFdx計算某個參數屬性的值,在螢幕水平方向跨越一個像素時改變了多少。dFdy計算某個參數屬性的值,在螢幕垂直方向跨越一個像素時改變了多少。fwidth計算abs(dFdx) + abs(dFdy)
因此,我們有兩種方式計算一個參數在一個像素內改變了多少。它們之間有差別嗎?
pixelSize = fwidth(dist);
/* or */
pixelSize = length(vec2(dFdx(dist), dFdy(dist)));AAA - 解析式反鋸齒 指出,與 length 相比,fwidth 的額外開銷更小。雖然對角線方向有輕微偏差,但在我們的情境中幾乎可以忽略。
Fast LAA 在對角線方向有輕微偏差,使圓形看起來非常輕微地偏菱形,並在正交方向有稍銳利的曲率,尤其是圖形很小的時候。有時對角線上的邊緣也會稍微模糊。
我們傳入 SDF 計算得到的距離,並算出它改變了多少,反映到透明度上。
float alpha = clamp(-distance / fwidth(-distance), 0.0, 1.0);(async () => {
const { Canvas, Circle } = Lesson2;
const canvas = await Utils.createCanvas(Canvas, 200, 200);
const circle = new Circle({
cx: 100,
cy: 100,
r: 100,
fill: 'red',
antiAliasingType: 3,
});
canvas.appendChild(circle);
let id;
const animate = () => {
canvas.render();
id = requestAnimationFrame(animate);
};
animate();
unsubscribe(() => {
cancelAnimationFrame(id);
canvas.destroy();
});
return canvas.getDOM();
})();髒旗標
先前我們把填色、圓心等樣式屬性寫進頂點陣列,因此想修改顏色時,也必須重新修改 Buffer 裡的資料。對下面這種連續修改的情境,如果每次修改屬性都立刻呼叫底層 API,會造成大量不必要的額外開銷。
circle.fill = 'blue';
circle.fill = 'yellow';
circle.cx = 500;我們希望把修改資料這類耗時工作延後,並在適當時機合併,例如渲染之前。這時可以套用一種稱為「髒旗標」的常見設計模式:髒旗標 - Game Programming Patterns。修改屬性時,我們只設定髒旗標,不執行其他耗時操作:
set cx(cx: number) {
if (this.#cx !== cx) {
this.#cx = cx;
this.renderDirtyFlag = true;
}
}在 render 方法中,只有偵測到屬性修改時才更新底層緩衝區。這樣無論兩次渲染之間屬性被修改多少次,緩衝區都只更新一次:
if (this.renderDirtyFlag) {
this.#instancedBuffer.setSubData(
0,
new Uint8Array(
new Float32Array([
this.#cx,
this.#cy,
this.#r,
this.#r,
this.#fillRGB.r / 255,
this.#fillRGB.g / 255,
this.#fillRGB.b / 255,
this.#fillRGB.opacity,
]).buffer,
),
);
}當然,渲染完成後別忘了重設髒旗標:
this.renderDirtyFlag = false;試試看效果:
cx2 = Inputs.range([50, 300], { label: 'cx', value: 100, step: 1 });cy2 = Inputs.range([50, 300], { label: 'cy', value: 100, step: 1 });r2 = Inputs.range([50, 300], { label: 'r', value: 100, step: 1 });fill2 = Inputs.color({ label: 'fill', value: '#ff0000' });circle = (() => {
const { Circle } = Lesson2;
const circle = new Circle({
cx: 100,
cy: 100,
r: 100,
fill: 'red',
antiAliasingType: 3,
});
return circle;
})();(() => {
circle.cx = cx2;
circle.cy = cy2;
circle.r = r2;
circle.fill = fill2;
})();(async () => {
const { Canvas } = Lesson2;
const canvas = await Utils.createCanvas(Canvas, 200, 200);
canvas.appendChild(circle);
let id;
const animate = () => {
canvas.render();
id = requestAnimationFrame(animate);
};
animate();
unsubscribe(() => {
cancelAnimationFrame(id);
canvas.destroy();
});
return canvas.getDOM();
})();後續介紹場景圖時,我們也會套用髒旗標。