第 1 課 - 初始化畫布
在這一課中,你將學到下列內容:
- 以 WebGL1/2 與 WebGPU 為基礎的硬體抽象層(HAL)
- 設計我們的 Canvas API
- 實作一個簡單的外掛系統
- 實作基於 HAL 的渲染外掛
安全性注意:@antv/g-device-api
GHSA-hqjp-pg4j-q8g2 將 npm 版本 1.7.13 與 1.8.13 標示為含有竊取憑證的 preinstall 指令碼的惡意發行版本。請勿安裝這些版本。 截至 2026 年 10 月 13 日,該公告未列出已修補的版本。
下方著色器編譯器的 WASM URL 固定在 1.6.13。這是截至該日可從 npm 與 UNPKG 取得的最新版本,並與本儲存庫 pnpm-lock.yaml 中的 JavaScript 套件一致。其 WASM 與先前使用的 1.6.8 產物,以及本儲存庫內建的編譯器,位元組完全相同;這次變更只是對齊版本號,並未改變編譯器的功能。版本 1.6.13 未被列為受影響版本,而且在瀏覽器中取得 WASM 並不會執行 npm 的安裝指令碼。這兩項事實都不構成對該產物的安全性稽核。
依照本儲存庫跟著課程操作時,請以 pnpm install --frozen-lockfile 保持鎖定檔不變。避免未指定版本或 latest 的安裝,並檢查任何相依性更新的來源。替代編譯器必須先經過驗證,才能在此建議使用。目前使用 @infinite-canvas-tutorial/device-api 的 ECS 專案,請優先採用其內建的 WASM 資源,讓 JavaScript 繫結與編譯器放在一起,並避免在執行期向 CDN 發出請求。
啟動專案時,你會看到一塊空白畫布,可以改變寬高,或在 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' });(async () => {
const { Canvas } = Lesson1;
const $canvas = document.createElement('canvas');
$canvas.style.outline = 'none';
$canvas.style.padding = '0px';
$canvas.style.margin = '0px';
$canvas.style.border = '1px solid black';
const canvas = await new Canvas({
canvas: $canvas,
renderer,
shaderCompilerPath:
'https://unpkg.com/@antv/[email protected]/dist/pkg/glsl_wgsl_compiler_bg.wasm',
}).initialized;
const resize = (width, height) => {
const scale = window.devicePixelRatio;
$canvas.width = Math.floor(width * scale);
$canvas.height = Math.floor(height * scale);
$canvas.style.width = `${width}px`;
$canvas.style.height = `${height}px`;
canvas.resize(width, height);
};
resize(width, height);
const animate = () => {
canvas.render();
requestAnimationFrame(animate);
};
animate();
return $canvas;
})();硬體抽象層
我希望畫布使用更低階的渲染 API,例如 WebGL,以及 WebGL 的後繼者 WebGPU。WebGPU 有許多功能強化,請見 從 WebGL 到 WebGPU:
- 底層基於新一代原生 GPU API,包括 Direct3D12 / Metal / Vulkan 等。
- 無狀態 API,不再有難以管理的全域狀態。
- 支援 Compute Shader。
- 不再限制每個
<canvas>能建立的 context 數量。 - 改善開發者體驗。包括更友善的錯誤訊息,以及可為 GPU 物件自訂標籤。
WebGPU 生態系現已延伸到 JavaScript、C++ 與 Rust,不少 Web 端渲染引擎(例如 Three.js、Babylon.js)正在接上,或已經完成接上。特別要提到 wgpu:除了遊戲引擎 bevy 之外,它也被 Modyfi 這類基於 Web 的創意設計工具用於正式環境,而且表現非常好。下圖來自:WebGPU 生態系

當然,考量瀏覽器相容性,我們仍然需要盡可能相容 WebGL1/2。在渲染引擎中,硬體抽象層(HAL)會抽象 GPU 硬體的細節,讓上層不必依賴特定的硬體實作。
我們希望盡可能提供一套基於 WebGL1/2 與 WebGPU 的統一 API,並一併處理 Shader 的翻譯與模組化。本課使用 @antv/g-device-api,它的實作參考了 noclip,並加入 WebGL1 相容性。安裝此相依性之前,請先參閱上方的安全性注意。
由於 WebGL 與 WebGPU 使用不同的著色器語言,而我們不想維護 GLSL 與 WGSL 兩套程式碼,因此選擇在執行期翻譯著色器:

專案中只需要維護一套使用 GLSL 300 語法的著色器:降級到 WebGL1 時替換關鍵字,交給 WASM 形式的 編譯器(使用 naga 與 naga-oil)之前先轉換成 GLSL 440,再轉成 WGSL。無獨有偶,Three.js Shading Language 也使用更高層的抽象,並同樣用編譯器為目標執行期平台輸出著色器程式碼。
下面展示頂點著色器中常用的 attribute 宣告。這是非常簡單的情境;一到紋理取樣,語法實際上會差很多。
// GLSL 300
layout(location = 0) in vec4 a_Position;
// compiled GLSL 100
attribute vec4 a_Position;
// compiled GLSL 440
layout(location = 0) in vec4 a_Position;
// compiled WGSL
var<private> a_Position_1: vec4<f32>;
@vertex
fn main(@location(0) a_Position: vec4<f32>) -> VertexOutput {
a_Position_1 = a_Position;
}最近我讀到這篇文章:Figma 渲染:由 WebGPU 驅動,發現 Figma 也採用了類似的實作方式來完成 WebGPU 升級:
我們維持既有的 GLSL 著色器,以符合 WebGL 1 的格式撰寫。著色器處理器接著會自動把它們翻譯成 WGSL。這包含解析著色器、進行必要的翻譯以轉成較新版本的 GLSL,再執行開源工具 naga 把它們轉成 WGSL。
硬體抽象層就先談到這裡。如果你對實作細節有興趣,可以直接參考 @antv/g-device-api 的原始碼。本課最後一小節會用到其中一部分 API。
設計畫布 API
終於來到我們畫布 API 的設計部分。我們期待的簡單用法如下:
- 傳入 HTMLCanvasElement
<canvas>,完成畫布的建立與初始化,包括使用硬體抽象層建立 Device(GPU 的抽象實例)。 - 建立渲染迴圈,持續呼叫畫布的渲染方法。
- 支援調整畫布大小,例如回應
resize事件。 - 在適當的時機銷毀自身。
const canvas = new Canvas({
canvas: $canvas,
});
const animate = () => {
requestAnimationFrame(animate);
canvas.render();
};
animate();
canvas.resize(500, 500);
canvas.destroy();渲染迴圈在渲染引擎中非常常見,例如 Three.js 的 渲染場景,以及 CanvasKit 的 基本繪製迴圈。關於為什麼使用 requestAnimationFrame 而不是 setTimeout,請見 JavaScript 中的高效能遊戲迴圈。
看起來是很簡單的介面定義,但先別急著實作,因為這裡有非同步初始化的問題。
interface Canvas {
constructor(config: { canvas: HTMLCanvasElement });
render(): void;
destroy(): void;
resize(width: number, height: number): void;
}非同步初始化
這也是 WebGPU 與 WebGL 的一個重大差異。在 WebGL 中,取得 context 是同步過程;在 WebGPU 中,取得 Device 則是非同步的:
// create a context in WebGL
const gl = $canvas.getContext('webgl');
// obtaining a device in WebGPU
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();因此,使用上一節提到的硬體抽象層時,也必須採用非同步做法。對所有想從 WebGL 過渡到 WebGPU 的渲染引擎來說,這都是破壞性變更,例如 Babylon.js:WebGPU 引擎的建立是非同步的:
import {
WebGLDeviceContribution,
WebGPUDeviceContribution,
} from '@antv/g-device-api';
// create a device in WebGL
const deviceContribution = new WebGLDeviceContribution({
targets: ['webgl2', 'webgl1'],
});
// create a device in WebGPU
const deviceContribution = new WebGPUDeviceContribution({
shaderCompilerPath: '/glsl_wgsl_compiler_bg.wasm',
});
// here's the asynchronous process
const swapChain = await deviceContribution.createSwapChain($canvas);
const device = swapChain.getDevice();
// create GPU objects with Device API由於 JavaScript 的建構子不支援非同步操作,因此為畫布加上非同步的 init 方法來完成初始化,並在初始化完成後呼叫渲染方法:
const canvas = new Canvas();
await canvas.init();
canvas.render();但我不認為這是好做法。首先,new 關鍵字本身已經暗示了初始化。其次,init 方法看起來可以呼叫多次,實際上卻不行。受到 JavaScript 的非同步建構子模式 的啟發,我個人更喜歡下面這種語法:
const canvas = await new Canvas().initialized;事實上,這個設計模式也有其他使用者,例如 Web Animations API 的 Animation:ready 屬性:
animation.ready.then(() => {});實作
在實作中,我們用一個私有變數保存 Promise,getter 也確保它是唯讀的:
export class Canvas {
#instancePromise: Promise<this>;
get initialized() {
return this.#instancePromise.then(() => this);
}
}在建構子中使用立即呼叫的非同步函式運算式(IIAFE)來執行初始化:
constructor() {
this.#instancePromise = (async () => {
// Omit the specific implementation...
return this;
})();
}讓我們繼續最佳化目前的設計。
以外掛為基礎的架構
當然,我們可以把呼叫硬體抽象層的程式碼放在 Canvas 建構子裡,並在 destroy 方法中一起銷毀。然而,隨著初始化、渲染與銷毀階段要做的事情變多,Canvas 的邏輯會不斷膨脹。一開始很難把所有需要支援的功能都想清楚,因此我們希望畫布是可擴充的。
destroy() {
this.device.destroy();
this.eventManager.destroy();
// Omit other tasks...
}以外掛為基礎的架構是常見的設計模式,webpack、VS Code,甚至 Chrome 都看得到。它有下列特性:
- 模組化。每個外掛負責獨立的一部分,彼此耦合降低,較容易維護。
- 可擴充性。外掛可以在執行期動態載入與卸載,不影響核心模組的結構,讓應用程式具備動態擴充的能力。
這種架構通常包含下列部分:
- 主應用程式。它提供註冊外掛的功能,在適當時機呼叫外掛執行,並提供外掛執行所需的 context。
- 外掛介面。它是主應用程式與外掛之間的橋樑。
- 外掛集合。一組可獨立執行的模組,每個外掛遵循職責分離原則,只包含最少的必要功能。
主應用程式如何呼叫外掛執行?先看看 webpack 的做法:
- 在主應用程式中定義一系列鉤子。這些鉤子可以是同步或非同步,也可以是序列或平行。如果是同步且序列,它們就類似我們熟悉的一般事件監聽器。在下面的範例中,
run是同步序列鉤子。 - 每個外掛在註冊時監聽自己關心的生命週期事件。在下面的範例中,註冊期間會呼叫
apply。 - 主應用程式觸發這些鉤子。
class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.run.tap(pluginName, (compilation) => {
console.log('webpack starting...');
});
}
}webpack 實作了 tapable 工具組來提供這些能力,並在大量呼叫的情境中使用 new Function 來提升效能,討論見 new Function 的效能真的好嗎?。不過我們可以沿著同樣的思路簡單實作。例如,同步序列執行的鉤子使用一個 callbacks 陣列,沒有黑魔法:
export class SyncHook<T> {
#callbacks: ((...args: AsArray<T>) => void)[] = [];
tap(fn: (...args: AsArray<T>) => void) {
this.#callbacks.push(fn);
}
call(...argsArr: AsArray<T>): void {
this.#callbacks.forEach(function (callback) {
/* eslint-disable-next-line prefer-spread */
callback.apply(void 0, argsArr);
});
}
}我們定義下列鉤子,名稱能直接看出它們會在主應用程式的哪個階段被呼叫:
export interface Hooks {
init: SyncHook<[]>;
initAsync: AsyncParallelHook<[]>;
destroy: SyncHook<[]>;
resize: SyncHook<[number, number]>; // When height or width changed.
beginFrame: SyncHook<[]>;
endFrame: SyncHook<[]>;
}包含這些鉤子的外掛 context 會在外掛註冊階段傳入,之後我們還會繼續擴充外掛 context:
export interface PluginContext {
hooks: Hooks;
canvas: HTMLCanvasElement;
}
export interface Plugin {
apply: (context: PluginContext) => void;
}在畫布初始化時呼叫 apply 並傳入 context,就完成了外掛註冊,並觸發同步與非同步鉤子的初始化。下一節實作的渲染外掛會完成非同步初始化:
import { Renderer } from './plugins';
this.#instancePromise = (async () => {
const { hooks } = this.#pluginContext;
[new Renderer()].forEach((plugin) => {
plugin.apply(this.#pluginContext);
});
hooks.init.call();
await hooks.initAsync.promise();
return this;
})();現在我們已經具備實作第一個外掛所需的全部知識。
渲染器外掛
我們希望支援 WebGL 與 WebGPU,因此在畫布建構子中透過 renderer 參數進行設定,之後再傳入外掛 context:
constructor(config: {
canvas: HTMLCanvasElement;
renderer?: 'webgl' | 'webgpu';
}) {}
this.#pluginContext = {
canvas,
renderer,
};接下來說明如何在渲染外掛中使用硬體抽象層。
SwapChain
在 OpenGL / WebGL 中,預設 Framebuffer 不同於一般的 Framebuffer Object(FBO)。初始化 context 時會自動建立預設 Framebuffer。呼叫繪製命令時如果沒有指定 FBO,OpenGL 會自動把渲染結果寫入預設 Framebuffer,其中的 Color Buffer 最終會顯示在螢幕上。
Vulkan 則不是這樣,而是使用 SwapChain。下圖來自 Canvas Context 與 Swap Chain,說明它的運作方式:GPU 把渲染結果寫入 backbuffer,frontbuffer 用來顯示到螢幕上,兩者可以交換。

如果沒有這種雙緩衝機制,GPU 寫入渲染結果的同時,螢幕很可能正在更新,從而造成撕裂。因此也需要使用垂直同步,強制顯示器在不允許的時機不更新。下圖來自 Canvas Context 與 Swap Chain,顯示這個過程的時序。

在 WebGPU 中,使用者通常不能直接存取 SwapChain,它被整合進 GPUCanvasContext。同樣遵循 WebGPU 設計的 wgpu 把 SwapChain 合併進 Surface,使用者也不能直接存取。不過我們的硬體抽象層仍然用這個概念來封裝。這樣就能在外掛初始化期間,依照 renderer 參數建立 SwapChain 與 Device:
import {
WebGLDeviceContribution,
WebGPUDeviceContribution,
} from '@antv/g-device-api';
import type { SwapChain, DeviceContribution, Device } from '@antv/g-device-api';
export class Renderer implements Plugin {
apply(context: PluginContext) {
const { hooks, canvas, renderer } = context;
hooks.initAsync.tapPromise(async () => {
let deviceContribution: DeviceContribution;
if (renderer === 'webgl') {
deviceContribution = new WebGLDeviceContribution();
} else {
deviceContribution = new WebGPUDeviceContribution();
}
const { width, height } = canvas;
const swapChain = await deviceContribution.createSwapChain(canvas);
swapChain.configureSwapChain(width, height);
this.#swapChain = swapChain;
this.#device = swapChain.getDevice();
});
}
}devicePixelRatio
devicePixelRatio 描述繪製單一 CSS 像素時應該使用多少實際螢幕像素。通常我們會用下面的程式碼設定 <canvas>:
const $canvas = document.getElementById('canvas');
$canvas.style.width = `${width}px`; // CSS Pixels
$canvas.style.height = `${height}px`;
const scale = window.devicePixelRatio;
$canvas.width = Math.floor(width * scale); // Screen Pixels
$canvas.height = Math.floor(height * scale);描述畫布寬高與圖形尺寸時,我們使用 CSS 像素;建立 SwapChain 時則使用螢幕上的實際像素。resize 傳入的寬高也使用 CSS 像素,因此需要轉換:
hooks.resize.tap((width, height) => {
this.#swapChain.configureSwapChain(
width * devicePixelRatio,
height * devicePixelRatio,
);
});那麼要如何取得 devicePixelRatio?當然可以使用 window.devicePixelRatio,大多數情況下都沒問題。但如果執行環境中沒有 window 物件呢?例如:
- Node.js 伺服器端渲染。例如使用 headless-gl
- 在 WebWorker 中渲染,使用 OffscreenCanvas。
- 小程式這類非標準瀏覽器環境
因此更好的做法是:建立畫布時支援傳入這個值,沒有傳入時再嘗試從 globalThis 取得。我們把 Canvas 的建構子參數修改如下:
export interface CanvasConfig {
devicePixelRatio?: number;
}
const { devicePixelRatio } = config;
const globalThis = getGlobalThis();
this.#pluginContext = {
devicePixelRatio: devicePixelRatio ?? globalThis.devicePixelRatio,
};其他鉤子的實作如下:
hooks.destroy.tap(() => {
this.#device.destroy();
});
hooks.beginFrame.tap(() => {
this.#device.beginFrame();
});
hooks.endFrame.tap(() => {
this.#device.endFrame();
});最後,把外掛加入畫布的外掛清單:
[new Renderer(), ...plugins].forEach((plugin) => {
plugin.apply(this.#pluginContext);
});示範
由於我們還沒有繪製任何圖形,畫布是空白的,要怎麼知道底層呼叫了哪些 WebGL / WebGPU 命令?在 Web 端除錯可以使用 Chrome 外掛:Spector.js 與 WebGPU Inspector。
下圖是使用 Spector.js 擷取的第一幀命令,可以看到我們建立了一系列 GPU 物件,例如 FrameBuffer、Texture 等:

切換到 WebGPU 渲染之後:
const canvas = await new Canvas({
canvas: $canvas,
renderer: 'webgpu',
}).initialized;打開 WebGPU Inspector,就能看到目前建立的 GPU 物件,以及每一幀呼叫的命令:

延伸閱讀
如果你完全沒有 WebGL 的基礎,可以先試著學習:
更多關於外掛設計模式的內容: