WEBKT

现代Web 3D引擎架构:如何设计一套兼容WebGL2与WebGPU的材质系统

2 0 0 0

随着 WebGPU 在各大主流浏览器中正式商用,Web 3D 渲染技术迎来了一次划时代的飞跃。相比于基于状态机的 WebGL,WebGPU 带来了更低的 CPU 开销、更直接的 GPU 控制以及对 Compute Shader 的原生支持。

然而,对于现存的 Web 3D 引擎或自研引擎来说,如何在不丢弃 WebGL2 存量市场的前提下,平滑地过渡并适配 WebGPU 成了架构设计上的最大难题。其中,**材质系统(Material System)**作为连接场景数据与底层渲染管线的核心枢纽,其重构与设计方案决定了整套引擎的生命周期。

本文将从底层管线差异、资源绑定抽象、Shader 编译器设计以及渲染状态缓存四个维度,深度解析如何设计一套能够同时兼容 WebGL2 与 WebGPU 的通用材质系统。


一、 状态机 vs. 显式管线:材质系统的底层冲突

要设计兼容层,首先要认清 WebGL2 和 WebGPU 在对待“材质”时的底层逻辑差异:

维度 WebGL2 (State-Machine) WebGPU (Explicit Pipeline)
执行逻辑 全局状态绑定,动态修改。例如 gl.useProgram(program) 之后,通过 gl.uniformXX 逐个更新。 状态完全封装。通过 GPURenderPipeline 预先烘焙所有渲染状态(混合、深度、顶点格式等)。
着色器语言 GLSL ES 300 (#version 300 es) WGSL (WebGPU Shading Language)
资源传递 散装 Uniform 变量或 Uniform Buffer Object (UBO)。 绑定组(Bind Group)与绑定组布局(Bind Group Layout)。
状态切换成本 极低(但会导致大量的 CPU 验证与驱动开销)。 极高(Pipeline 创建是高耗时操作,必须缓存复用)。

在 WebGL2 中,材质通常只是“Shader + 一堆 Uniform 参数”的组合。而在 WebGPU 中,材质必须被映射为管线状态对象(PSO)绑定组(Bind Group)

因此,我们的通用材质系统不能再采用 WebGL2 时代那种“随用随设”的动态 Uniform 绑定机制,而必须采用基于元数据声明、资源组管理、管线状态静态化的现代渲染器架构。


二、 核心抽象:通用材质系统架构图

为了兼容两者,我们需要在引擎上层建立一套与底层 API 无关的抽象表达。

                       +-------------------------+
                       |     Material (上层材质)  |
                       +-------------------------+
                                    |
            +-----------------------+-----------------------+
            |                                               |
            v                                               v
+-----------------------+                       +-----------------------+
|  WebGL2 Render Pipeline|                       |  WebGPU Render Pipeline|
+-----------------------+                       +-----------------------+
| - Program (GLSL 300)  |                       | - GPURenderPipeline   |
| - Uniform Locations   |                       | - GPUBindGroups [0..3]|
| - Blend/Depth States  |                       | - WGSL Shaders        |
+-----------------------+                       +-----------------------+

在上层,开发者声明的材质结构如下:

interface MaterialDescriptor {
    vertexShaderSource: string;   // 统一的 Shader 模板或源码
    fragmentShaderSource: string;
    properties: Record<string, any>; // 材质属性,如 color, roughness
    textures: Record<string, Texture>; // 贴图资源
    states: RenderStates;         // 混合、深度测试、裁剪等状态
}

三、 关键技术点一:统一的 Shader 编译器设计

这是最棘手的问题:WebGL2 使用 GLSL,WebGPU 使用 WGSL。让开发者写两套 Shader 显然不可接受。目前业界有三种主流解决方案:

  1. 基于 Node-Based(节点式)材质系统:如 Three.js 的 NodeMaterial。通过 TS/JS 动态生成 AST(抽象语法树),再根据当前渲染后端分别输出 GLSL 和 WGSL。这是最彻底、体验最好的方案。
  2. 使用 WebAssembly 运行 SPIRV-Cross / Naga:在上层统一编写 GLSL 或 HLSL,在运行时通过 WASM 工具链将其编译为 WebGL2 的 GLSL 300 es 以及 WebGPU 的 WGSL。
  3. 基于宏与正则的预处理器(Lightweight Transpiler):约束开发者编写一种“方言”(通常是带有特定标注的 GLSL),引擎在运行时通过字符串替换和正则将其翻译。

下面展示一种基于 Uniform 块(UBO)强制规范的轻量级转译设计。由于 WebGPU 强制要求 Uniform 必须放入 Buffer 中,因此我们在 WebGL2 中也必须放弃传统的散装 Uniform 绑定,全面采用 Uniform Buffer Object (UBO)

统一的 Shader 声明范式

我们可以定义一种特殊的块标记,由引擎提取并转换:

// 通用 Shader 模板
#layout(set=0, binding=0) uniform MaterialUniforms {
    vec4 u_Color;
    float u_Roughness;
};

#layout(set=0, binding=1) uniform sampler2D u_MainTexture;

WebGL2 后端,引擎会将其转译为标准的 GLSL 300 es:

#version 300 es
layout(std140) uniform MaterialUniforms {
    vec4 u_Color;
    float u_Roughness;
};
uniform sampler2D u_MainTexture; // WebGL2 采样器不能放在 UBO 中

WebGPU 后端,引擎会将其转译为 WGSL:

struct MaterialUniforms {
    u_Color: vec4<f32>,
    u_Roughness: f32,
}
@group(0) @binding(0) var<uniform> materialUniforms: MaterialUniforms;
@group(0) @binding(1) var u_MainTexture: texture_2d<f32>;
@group(0) @binding(2) var u_MainSampler: sampler; // WGSL 纹理与采样器分离

四、 关键技术点二:资源绑定组与布局的抽象

WebGPU 引入了 GPUBindGroup(绑定组)和 GPUBindGroupLayout(绑定组布局)的概念,将资源(Buffers, Textures, Samplers)成组送往 GPU,极大减少了 Draw Call 之间的 CPU 切换开销。

为了兼容 WebGL2,我们需要在上层实现一个 Bind Group 模拟层

1. 划分绑定频次(Frequency Groups)

现代引擎通常将资源划分为四个绑定组(Sets),按更新频率排序:

  • Set 0 (Global/Frame): 相机矩阵、投影矩阵、全局光照参数、时间(每帧更新一次)。
  • Set 1 (Pass): 阴影贴图、屏幕空间后期参数(每个 Render Pass 更新一次)。
  • Set 2 (Material): 材质本身的贴图、颜色、粗糙度参数(切换材质时更新)。
  • Set 3 (Object/Model): 骨骼矩阵、世界矩阵(每个绘制对象更新一次)。

2. 定义统一的 Resource Binder

export interface BindingResource {
    binding: number;
    type: "uniform-buffer" | "texture" | "sampler";
    resource: WebGLBuffer | GPUBuffer | WebGLTexture | GPUTextureView | GPUSampler;
}

export class BindGroup {
    public id: string;
    constructor(
        public setIndex: number,
        public resources: BindingResource[]
    ) {
        this.id = this._generateHash();
    }
    
    private _generateHash(): string {
        // 生成唯一标识用于缓存复用
        return this.resources.map(r => `${r.binding}-${r.type}`).join("|");
    }
}

WebGPU 后端,该类直接映射为 GPUBindGroup
WebGL2 后端,引擎需要遍历 resources,在绘制前手动执行等价的底层 API:

// WebGL2 模拟 BindGroup 绑定
public bindGroupWebGL2(group: BindGroup) {
    for (const res of group.resources) {
        if (res.type === "uniform-buffer") {
            const ubo = res.resource as WebGLBuffer;
            // 绑定到指定的 Uniform Block Binding Point
            gl.bindBufferBase(gl.UNIFORM_BUFFER, res.binding, ubo);
        } else if (res.type === "texture") {
            gl.activeTexture(gl.TEXTURE0 + res.binding);
            gl.bindTexture(gl.TEXTURE_2D, res.resource as WebGLTexture);
        }
    }
}

五、 关键技术点三:渲染状态(Render State)与管线缓存

WebGPU 不允许在绘制时动态改变混合模式(Blend)、深度测试(Depth Test)和多重采样(MSAA)。这些状态在 GPURenderPipeline 创建时就已经“焊死”在 Pipeline 内部了。如果材质的 opacity 改变导致混合模式变化,WebGPU 必须切换到另一个 Pipeline。

而在 WebGL2 中,这些都是全局状态(如 gl.enable(gl.BLEND))。

管线状态哈希(Pipeline Hashing)

为了解决这一差异并保证 WebGPU 后端的性能,材质系统必须引入 Pipeline 缓存机制。我们需要计算出一个唯一的 PipelineHash,用来标识当前材质所需要的渲染管线。

class PipelineState {
    // 顶点格式描述
    vertexLayout: string = ""; 
    // Shader 程序的唯一 ID
    shaderId: string = "";     
    // 渲染状态
    depthWriteEnabled: boolean = true;
    depthCompare: string = "less";
    blendState: string = "none";
    cullMode: string = "back";

    public getHash(): string {
        return `${this.shaderId}_${this.vertexLayout}_${this.depthWriteEnabled}_${this.depthCompare}_${this.blendState}_${this.cullMode}`;
    }
}

在渲染循环中:

  1. 材质收集当前的渲染状态、绑定的顶点格式和 Shader。
  2. 调用 PipelineState.getHash()
  3. PipelineCache 中查询。
    • 如果是 WebGPU 后端
      • 若命中缓存,直接调用 commandEncoder.setPipeline(cachedPipeline)
      • 若未命中,则使用 device.createRenderPipeline() 异步/同步创建新的管线,存入 Map 后使用。
    • 如果是 WebGL2 后端
      • 若命中缓存,仅在使用不同的 Shader 时调用 gl.useProgram(program)
      • 根据缓存的状态差异,调用最小化的 gl.enable/disable/depthFunc/blendFunc 状态切换。

六、 实战:内存布局对齐的巨坑(std140 与 WGSL Alignment)

在实现兼容材质系统时,90% 的开发者都会在 Uniform Buffer 的内存布局对齐上踩坑。

WebGPU 的 WGSL 内存对齐规则与 WebGL2 的 std140 基本一致,但有一些微妙且致命的差异(例如 vec3 的对齐规则)。为了让同一份二进制数据 buffer 能同时上传给 WebGL2 的 UBO 和 WebGPU 的 Uniform Buffer,必须在 CPU 端做严格的字节对齐计算

规则:

  • float / int32: 占用 4 字节,对齐边界为 4。
  • vec2: 占用 8 字节,对齐边界为 8。
  • vec3 / vec4: 占用 16 字节,对齐边界为 16!注意:vec3 会强制占用 16 字节的物理空间。
  • mat4: 占用 64 字节,对齐边界为 16(等同于 4 个 vec4)。

CPU 端 Uniform 缓冲区构建器实现:

export class UniformBufferAllocator {
    private _data: Float32Array;
    private _byteLength: number;

    constructor(sizeInBytes: number) {
        // 保证 UBO 整体大小是 16 字节的倍数
        this._byteLength = Math.ceil(sizeInBytes / 16) * 16;
        this._data = new Float32Array(this._byteLength / 4);
    }

    // 严格按照 std140 规则写入数据
    public setFloat(offset: number, value: number) {
        this._data[offset] = value;
    }

    public setVec3(offset: number, value: number[]) {
        // offset 必须是 4 的倍数 (16字节对齐)
        this._data[offset] = value[0];
        this._data[offset + 1] = value[1];
        this._data[offset + 2] = value[2];
        // offset + 3 是填充区(Padding),留空
    }

    public setMatrix(offset: number, value: Float32Array) {
        this._data.set(value, offset);
    }

    public getBufferData(): Float32Array {
        return this._data;
    }
}

在材质更新属性时,不再使用原生的 JS 对象,而是直接修改这个预先分配好内存的 Float32Array,然后一笔写入 GPU。在 WebGL2 中调用 gl.bufferSubData,在 WebGPU 中调用 device.queue.writeBuffer。这种设计不仅抹平了平台差异,还极大降低了由于 JS 垃圾回收(GC)带来的帧率抖动。


七、 总结与落地建议

兼容 WebGL2 与 WebGPU 的材质系统设计核心可以归结为一句话:“用现代图形学的管线化思想去重构 WebGL2,而不是用 WebGL2 的状态机思想去套用 WebGPU。”

在具体实施上,建议采取以下演进路线:

  1. 架构先行:停止在渲染逻辑中直接调用 gl.uniformgl.bindTexture。优先实现 UBO 机制和资源绑定组的抽象(Bind Group)。
  2. Shader 收拢:选择一门源语言(推荐定制化的 GLSL 或直接上 TypeScript 节点材质),写好预编译器,这是确保双端表现一致的基础。
  3. 状态静态化:将原本分散在 Mesh、Material 中的渲染状态整合进一个 RenderState 对象中,通过哈希化管理,为 WebGPU 的 Pipeline 烘焙做好准备。

通过这种自底向上的现代渲染架构设计,你的 Web 3D 引擎不仅能在旧设备上稳定流畅地运行 WebGL2,还能在支持 WebGPU 的现代设备上瞬间释放次世代图形技术的完整算力。

架构师零一 WebGPUWebGL23D引擎开发

评论点评