Skip to content

课程 1 - 初始化画布

在这节课中你将学习到以下内容:

  • 基于 WebGL1/2 和 WebGPU 的硬件抽象层
  • 画布 API 设计
  • 实现一个简单的插件系统
  • 基于硬件抽象层实现一个渲染插件

启动项目后将看到一个空画布,可以修改宽高或者切换 WebGL / WebGPU 渲染器。

js
width = Inputs.range([50, 300], { label: 'width', value: 100, step: 1 });
js
height = Inputs.range([50, 300], { label: 'height', value: 100, step: 1 });
js
renderer = Inputs.select(['webgl', 'webgpu'], { label: 'renderer' });
js
(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;
})();

硬件抽象层

我希望画布使用 WebGL 和 WebGPU 这样更底层的渲染 API,作为 WebGL 的继任者,WebGPU 有非常多的特性增强,详见From WebGL to WebGPU

  • 底层基于新一代原生 GPU API,包括 Direct3D12 / Metal / Vulkan 等。
  • 无状态式的 API,不用再忍受难以管理的全局状态。
  • 支持 Compute Shader。
  • 每个 <canvas> 创建的上下文数目不再有限制。
  • 开发体验提升。包括更友好的错误信息以及为 GPU 对象添加自定义标签。

目前 WebGPU 的生态已经延伸到了 JavaScript、C++ 和 Rust 中,很多 Web 端渲染引擎(例如 Three.js、Babylon.js)都正在或者已完成了对它的接入。这里特别提及 wgpu,除了游戏引擎 bevy,像 [Figma]、Modyfi 这样的 Web 端创意类设计工具也已经将其用于生产环境,并有着非常好的表现。下图来自:WebGPU Ecosystem

WebGPU ecosystem in 2023

当然,考虑到浏览器兼容性,现阶段我们仍需要尽可能兼容 WebGL1/2。在渲染引擎中,硬件抽象层(Hardware Abstraction Layer,简称 HAL)将 GPU 硬件细节抽象化,使得上层可以不依赖于具体的硬件实现。

我们希望基于 WebGL1/2 和 WebGPU 尽可能提供一套统一的 API,同时提供 Shader 转译和模块化功能。@antv/g-device-api 参考了 noclip 的实现,在其基础上兼容了 WebGL1,我也在一些可视化相关的项目中使用了它。

由于 WebGL 和 WebGPU 使用 Shader 语言不同,又不希望维护 GLSL 和 WGSL 两套代码,因此我们选择在运行时对 Shader 进行转译:

Transpile shader at runtime

在项目中只需要维护一套使用 GLSL 300 语法的 Shader,降级到 WebGL1 时进行关键词替换即可,在 WebGPU 环境下先转换成 GLSL 440 再交给 WASM 格式的编译器(使用了 naga 和 naga-oil )转译成 WGSL。无独有偶,Three.js Shading Language 也使用了一种更高的抽象层次,同样使用编译器输出目标运行平台的 Shader 代码。

下面展示了 Vertex Shader 中常用的 attribute 声明。这只是一个非常简单的场景,实际上涉及到纹理采样部分的语法差别非常大。

glsl
// 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 rendering: Powered by WebGPU,发现 Figma 也采用了类似的实现方案完成 WebGPU 的升级:

We maintain our existing GLSL shaders, written in WebGL 1–compliant format. The shader processor then automatically handles translating them to WGSL. This involves parsing the shaders, making the necessary translations to convert them to a newer version of GLSL, then running the open-source tool naga to convert them to WGSL.

好了,关于硬件抽象层部分已经介绍地够多了,如果对其中的实现细节感兴趣可以直接参考 @antv/g-device-api 源码。在本节课最后一小节中我们会使用到其中的部分 API。

画布 API 设计

终于进入到了我们的画布 API 设计部分。我们期待的简单用法如下:

  • 传入一个 HTMLCanvasElement <canvas> 完成画布的创建和初始化工作,包括使用硬件抽象层创建 Device(GPU 的抽象实例)
  • 创建一个渲染循环,不断调用画布的渲染方法
  • 支持重新设置画布宽高,例如响应 resize 事件
  • 适时销毁
ts
const canvas = new Canvas({
    canvas: $canvas,
});

const animate = () => {
    requestAnimationFrame(animate);
    canvas.render();
};
animate();

canvas.resize(500, 500);
canvas.destroy();

其中渲染循环的用法在渲染引擎中十分常见,例如 Three.js 中的 Rendering the scene,再比如 CanvasKit 中的 Basic draw loop。关于使用 requestAnimationFrame 而非 setTimeout 的原因详见:Performant Game Loops in JavaScript

看起来接口定义非常简单,但我们先不急着实现,因为这里存在一个异步初始化问题。

ts
interface Canvas {
    constructor(config: { canvas: HTMLCanvasElement });
    render(): void;
    destroy(): void;
    resize(width: number, height: number): void;
}

异步初始化

这也是 WebGPU 和 WebGL 的一大差异,在 WebGL 中获取上下文是同步的,而 WebGPU 获取 Device 是一个异步过程:

ts
// 在 WebGL 中创建上下文
const gl = $canvas.getContext('webgl');

// 在 WebGPU 中获取 Device
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();

因此在使用我们在上一节提到的硬件抽象层时,也只能使用异步方式。这一点对于所有希望从 WebGL 过渡到 WebGPU 的渲染引擎都是 Breaking Change,例如 Babylon.js Creation of the WebGPU engine is asynchronous

ts
import {
    WebGLDeviceContribution,
    WebGPUDeviceContribution,
} from '@antv/g-device-api';

// 创建一个 WebGL 的设备
const deviceContribution = new WebGLDeviceContribution({
    targets: ['webgl2', 'webgl1'],
});
// 或者创建一个基于 WebGPU 的设备
const deviceContribution = new WebGPUDeviceContribution({
    shaderCompilerPath: '/glsl_wgsl_compiler_bg.wasm',
});
// 这里是一个异步操作
const swapChain = await deviceContribution.createSwapChain($canvas);
const device = swapChain.getDevice();
// 调用 Device API 创建 GPU 对象

由于 JavaScript 中的构造函数不支持异步,因此为画布添加一个异步的 init 方法,初始化完成后再调用渲染方法:

ts
const canvas = new Canvas();
await canvas.init();
canvas.render();

但我觉得这样并不好,首先 new 关键字已经表示了初始化含义,其次 init 方法似乎可以多次调用但实际上并不行。受 Async Constructor Pattern in JavaScript 启发,个人更倾向下面一种写法:

ts
const canvas = await new Canvas().initialized;

事实上,例如 Web Animations API 的 Animation: ready property 也使用了这种设计模式:

ts
animation.ready.then(() => {});

实现

在实现中我们使用一个私有变量持有 Promise,getter 也能确保它是只读的:

ts
export class Canvas {
    #instancePromise: Promise<this>;
    get initialized() {
        return this.#instancePromise.then(() => this);
    }
}

在构造函数中使用立即执行的异步函数表达式(IIAFE)完成初始化工作:

ts
constructor() {
  this.#instancePromise = (async () => {
    // 执行异步初始化
    return this;
  })();
}

让我们继续优化目前的设计。

插件系统

我们当然可以把调用硬件抽象层的代码放在 Canvas 的构造函数中,并在 destroy 方法中一并销毁。但后续在初始化、渲染、销毁阶段增加更多任务时,Canvas 的逻辑也会不断膨胀。我们很难在开始阶段就把所有需要支持的功能都想清楚,因此希望画布是具有可扩展性的。

ts
destroy() {
  this.device.destroy();
  this.eventManager.destroy();
  // 省略更多需要在销毁阶段触发的任务
}

基于插件的架构是一种常见的设计模式,在 Webpack、VSCode 甚至是 Chrome 中都能看到它的身影。它有以下特点:

  • 模块化。每个插件负责独立的部分,相互之间耦合度降低,更容易维护。
  • 可扩展性。插件可以在运行时动态加载和卸载,不影响核心模块的结构,实现了应用程序的动态扩展能力。

通常该架构由以下部分组成:

  • 主应用。提供插件的注册功能,在合适阶段调用插件执行,同时为插件提供运行所需的上下文。
  • 插件接口。主应用和插件之间的桥梁。
  • 插件集。一系列可独立执行的模块,每个插件遵循职责分离原则,仅包含所需的最小功能。

主应用如何调用插件执行呢?不妨先看看 webpack 的思路:

  • 在主应用中定义一系列钩子,这些钩子可以是同步或异步,也可以是串行或并行。如果是同步串行,就和我们常见的事件监听一样了。在下面的例子中 run 就是一个同步串行钩子。
  • 每个插件在注册时,监听自己关心的生命周期事件。下面例子中 apply 会在注册时调用。
  • 主应用执行钩子。
ts
class ConsoleLogOnBuildWebpackPlugin {
    apply(compiler) {
        compiler.hooks.run.tap(pluginName, (compilation) => {
            console.log('webpack 构建正在启动!');
        });
    }
}

webpack 实现了 tapable 工具库提供以上能力,为了提升大量调用场景下的性能还使用了 new Function 这样的手段,详见:Is the new Function performance really good? 的讨论。但我们只需要参考它的思路简单实现,例如同步串行执行的钩子使用了 callbacks 数组,没有任何黑科技:

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

我们定义以下钩子,名称直观反映了它们会在主应用的哪个阶段被调用:

ts
export interface Hooks {
    init: SyncHook<[]>; // 初始化阶段
    initAsync: AsyncParallelHook<[]>; // 初始化阶段
    destroy: SyncHook<[]>; // 销毁阶段
    resize: SyncHook<[number, number]>; // 宽高改变时
    beginFrame: SyncHook<[]>; // 渲染阶段
    endFrame: SyncHook<[]>; // 渲染阶段
}

包含这些钩子的插件上下文在插件注册阶段被传入,后续我们会继续扩展插件上下文:

ts
export interface PluginContext {
    hooks: Hooks;
    canvas: HTMLCanvasElement;
}
export interface Plugin {
    apply: (context: PluginContext) => void;
}

在画布初始化时调用 apply 方法并传入上下文完成插件的注册,同时触发初始化同步和异步钩子,在下一节中我们实现的渲染插件会完成异步初始化:

ts
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 参数配置,随后传入插件上下文:

ts
constructor(config: {
  canvas: HTMLCanvasElement;
  renderer?: 'webgl' | 'webgpu';
}) {}

this.#pluginContext = {
  canvas,
  renderer,
};

接下来我们介绍如何在渲染插件中使用硬件抽象层。

SwapChain

在 OpenGL / WebGL 中 Default Framebuffer 和通常的 Framebuffer Object(FBO) 不同,它是在初始化上下文时自动创建的。在调用绘制命令时如果没有特别指定 FBO,OpenGL 会自动将渲染结果写入 Default Framebuffer,其中的颜色缓冲区 Color Buffer 最终会显示在屏幕上。

但 Vulkan 中没有这个概念,取而代之的是 SwapChain,下图来自 Canvas Context and Swap Chain 展示了它的工作原理。GPU 向后缓冲中写入渲染结果,前缓冲用于向屏幕展示,两者可以交换。

Double buffering

如果不使用这种双缓冲机制,由于屏幕的刷新频率和 GPU 写入渲染结果的频率不一致,就很有可能出现前者更新时恰好后者在写入的情况,此时会造成撕裂现象。因此还需要配合垂直同步,强制展示时不允许更新,下图来自 Canvas Context and Swap Chain 展示了这一过程的时序图。

Double buffering and V-Sync

在 WebGPU 中使用者通常不会直接接触到 SwapChain,这部分功能被整合进了 GPUCanvasContext 中。同样遵循 WebGPU 设计的 wgpu 将 SwapChain 合并到了 Surface 中,使用者同样也不会直接接触到。但我们的硬件抽象层仍使用了这一概念进行封装。这样在插件初始化时,就可以根据 renderer 参数创建 SwapChain 和 Device:

ts
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>

ts
const $canvas = document.getElementById('canvas');
$canvas.style.width = `${width}px`; // CSS 像素
$canvas.style.height = `${height}px`;

const scale = window.devicePixelRatio;
$canvas.width = Math.floor(width * scale); // 屏幕实际像素
$canvas.height = Math.floor(height * scale);

我们在描述画布宽高、图形尺寸时使用 CSS 像素,而在创建 SwapChain 时使用屏幕实际像素。在 resize 时传入的宽高也使用了 CSS 像素,因此需要进行转换:

ts
hooks.resize.tap((width, height) => {
  this.#swapChain.configureSwapChain(
    width * devicePixelRatio,
    height * devicePixelRatio,
  );
});

那么如何获取 devicePixelRatio 呢?当然我们可以直接使用 window.devicePixelRatio 获取,绝大部分情况下都没有问题。但如果运行的环境中没有 window 对象呢?例如:

  • Node.js 服务端渲染。例如使用 headless-gl
  • 在 WebWorker 中渲染,使用 OffscreenCanvas
  • 小程序等非标准浏览器环境

因此更好的做法是支持创建画布时传入,未传入时再尝试从 globalThis 中获取。我们对 Canvas 的构造函数参数进行如下修改:

ts
export interface CanvasConfig {
  devicePixelRatio?: number;
}

const { devicePixelRatio } = config;
const globalThis = getGlobalThis();
this.#pluginContext = {
  devicePixelRatio: devicePixelRatio ?? globalThis.devicePixelRatio,
};

其他钩子实现如下:

ts
hooks.destroy.tap(() => {
    this.#device.destroy();
});

hooks.beginFrame.tap(() => {
    this.#device.beginFrame();
});

hooks.endFrame.tap(() => {
    this.#device.endFrame();
});

最后,将该插件添加到画布的插件列表中:

ts
[new Renderer(), ...plugins].forEach((plugin) => {
  plugin.apply(this.#pluginContext);
});

效果展示

由于还没有绘制任何图形,画布一片空白,我们如何知道底层 WebGL / WebGPU 命令的调用情况呢?在 Web 端调试可以使用 Chrome 浏览器插件:Spector.jsWebGPU Inspector

下图展示了使用 Spector.js 捕捉到的首帧命令,可以看到我们创建了一系列 FrameBuffer、Texture 等 GPU 对象:

Spector.js snapshot

切换到 WebGPU 渲染后:

ts
const canvas = await new Canvas({
  canvas: $canvas,
  renderer: 'webgpu',
}).initialized;

打开 WebGPU Inspector 可以看到当前我们创建的 GPU 对象和每一帧调用的命令:

WebGPU inspector snapshot

扩展阅读

如果你完全没有 WebGL 基础,可以先尝试学习:

更多关于插件设计模式的介绍:

Released under the MIT License.