《游戏引擎实现原理》5.2 WebGL2 后端实现与能力降级

同一套 RHI 接口的 WebGL2 实现:把 bind group 映射成 uniform block 与 texture unit、render pass 映射成 framebuffer object、WGSL 换成 GLSL ES 3.0。更关键的是能力降级——WebGPU 有而 WebGL2 没有的特性如何优雅退化,并用 clearColor 加 readPixels 真跑出像素断言。

5.2 WebGL2 后端实现与能力降级

WebGPU 是新一代 API,但浏览器兼容面还很窄。helio 的第二个后端是 WebGL2:它几乎在所有设备上可用,代价是能力集明显更小。本节要证明两件事:同一套 IRhiBackend 在 WebGL2 上能实现,以及 WebGPU 有而 WebGL2 没有的特性能优雅退化。

这里的难点不是「怎么调 WebGL2 的 API」,而是「怎么让上层代码察觉不到自己跑在哪个后端上」。

映射:bind group → uniform block / texture unit

WebGPU 的描述符模型在 WebGL2 里没有对应物,得逐项拆开:

WebGPU 概念WebGL2 对应
bind group一组 uniform block(UBO)+ 若干 texture unit
render passframebuffer object(FBO),靠绑定切换
WGSL 着色器模块GLSL ES 3.0 源码,入口固定是 main
storage buffer纹理 + texelFetch 读回
compute shadertransform feedback 或退回 CPU
depth32floatDEPTH_COMPONENT24
render bundle逐条 drawArrays 立即调用

前三条决定了实现的形状:纹理即 framebuffer(createTexture 里顺手建 FBO),pass 即绑定(beginRenderPass 里 bindFramebuffer + viewport + clear),绑定组即 draw 时的逐条应用。

能力降级:缺的能力退到哪条路径

降级逻辑不能散落在调用点,要收进一张纯数据表。表把「特性 → 依赖哪个能力位 → 缺了退到什么策略」写死,因此它能在 Node 里单测,不必开浏览器:

// file: src/render/rhi/webgl2-capabilities.ts
// tier: L1
// verify: npx vitest run src/render/rhi/webgl2-capabilities.test.ts
//
// 能力降级:WebGPU 有、WebGL2 没有的特性,不能在调用点临时 if 掉,
// 而要在一张表里统一声明「缺哪个能力 → 退到什么策略」。
// 这张表是纯数据,可以在 Node 里单测,不必开浏览器。

export const RhiFeature = {
  ComputeShader: 'compute-shader',
  StorageBuffer: 'storage-buffer',
  Depth32Float: 'depth32float',
  SrgbRenderTarget: 'srgb-render-target',
  RenderBundle: 'render-bundle',
} as const;
export type RhiFeature = (typeof RhiFeature)[keyof typeof RhiFeature];

export interface RhiCapabilities {
  readonly computeShaders: boolean;
  readonly storageBuffers: boolean;
  readonly depth32Float: boolean;
  readonly srgbRenderTarget: boolean;
  readonly renderBundles: boolean;
}

export const WEBGPU_CAPS: RhiCapabilities = {
  computeShaders: true,
  storageBuffers: true,
  depth32Float: true,
  srgbRenderTarget: true,
  renderBundles: true,
};

export const WEBGL2_CAPS: RhiCapabilities = {
  computeShaders: false,
  storageBuffers: false,
  depth32Float: false,
  srgbRenderTarget: false,
  renderBundles: false,
};

export type FallbackStrategy =
  | 'native'
  | 'transform-feedback'
  | 'texture-as-storage'
  | 'depth24'
  | 'srgb-extension'
  | 'immediate-draw';

/** 每个特性绑定一个能力位,缺了就用 fallback。 */
const FALLBACK: Record<RhiFeature, { readonly cap: keyof RhiCapabilities; readonly fallback: FallbackStrategy }> = {
  'compute-shader': { cap: 'computeShaders', fallback: 'transform-feedback' },
  'storage-buffer': { cap: 'storageBuffers', fallback: 'texture-as-storage' },
  'depth32float': { cap: 'depth32Float', fallback: 'depth24' },
  'srgb-render-target': { cap: 'srgbRenderTarget', fallback: 'srgb-extension' },
  'render-bundle': { cap: 'renderBundles', fallback: 'immediate-draw' },
};

/** 给定后端能力,某个特性是走原生还是退到哪条路径。 */
export function planFeature(feature: RhiFeature, caps: RhiCapabilities): FallbackStrategy {
  const entry = FALLBACK[feature];
  return caps[entry.cap] ? 'native' : entry.fallback;
}

/** 一次性列出所有特性的落地策略,用于启动时打印能力报告。 */
export function capabilityReport(caps: RhiCapabilities): Record<RhiFeature, FallbackStrategy> {
  const out = {} as Record<RhiFeature, FallbackStrategy>;
  for (const f of Object.values(RhiFeature)) out[f] = planFeature(f, caps);
  return out;
}

配套单测覆盖两种情况:WebGPU 能力全开时每个特性都走 native,WebGL2 能力全关时每个特性都有明确 fallback,没有遗漏。

// file: src/render/rhi/webgl2-capabilities.test.ts
// tier: L1
// verify: npx vitest run src/render/rhi/webgl2-capabilities.test.ts
import { describe, expect, it } from 'vitest';
import { RhiFeature, WEBGL2_CAPS, WEBGPU_CAPS, capabilityReport, planFeature } from './webgl2-capabilities.ts';

describe('webgl2-capabilities', () => {
  it('WebGPU 上每个特性都走原生', () => {
    for (const f of Object.values(RhiFeature)) expect(planFeature(f, WEBGPU_CAPS)).toBe('native');
  });

  it('WebGL2 上每个缺失特性都有明确降级路径', () => {
    expect(planFeature(RhiFeature.ComputeShader, WEBGL2_CAPS)).toBe('transform-feedback');
    expect(planFeature(RhiFeature.StorageBuffer, WEBGL2_CAPS)).toBe('texture-as-storage');
    expect(planFeature(RhiFeature.Depth32Float, WEBGL2_CAPS)).toBe('depth24');
    expect(planFeature(RhiFeature.SrgbRenderTarget, WEBGL2_CAPS)).toBe('srgb-extension');
    expect(planFeature(RhiFeature.RenderBundle, WEBGL2_CAPS)).toBe('immediate-draw');
  });

  it('能力报告覆盖全部五个特性', () => {
    const report = capabilityReport(WEBGL2_CAPS);
    expect(Object.keys(report).sort()).toEqual(Object.values(RhiFeature).sort());
    expect(Object.values(report).every((s) => s !== 'native')).toBe(true);
  });
});
$ npx vitest run src/render/rhi/webgl2-capabilities.test.ts
 ✓ webgl2-capabilities > WebGPU 上每个特性都走原生
 ✓ webgl2-capabilities > WebGL2 上每个缺失特性都有明确降级路径
 ✓ webgl2-capabilities > 能力报告覆盖全部五个特性
 Test Files  1 passed (1)
      Tests  3 passed (3)

可运行产物:clearColor + readPixels

WebGL2 的 IRhiBackend 完整实现如下。它不碰 WebGPU 的任何类型,只做上面的映射;readTexture 用 readPixels 回读当前 FBO:

// file: src/render/rhi/webgl2-rhi.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/rhi/webgl2-rhi.ts
//
// 同一套 IRhiBackend 的 WebGL2 实现。映射与 WebGPU 有三处根本不同:
//   bind group → 一组 uniform block + texture unit(没有描述符集合);
//   render pass → framebuffer object(没有 pass 对象,靠绑定切换);
//   着色器模块 → GLSL ES 3.0 源码(不是 WGSL),入口固定是 main。
import { RhiFormat } from './rhi-types.ts';
import type {
  BindGroupEntry, BufferDesc, ColorAttachment, DrawCall, IRhiBackend, RenderPipelineDesc,
  RhiBindGroup, RhiBuffer, RhiRenderPipeline, RhiShaderModule, RhiTexture, TextureDesc,
} from './rhi-types.ts';

interface TargetRecord { readonly fb: WebGLFramebuffer; readonly color: WebGLTexture; readonly width: number; readonly height: number; }

export class WebGL2RHI implements IRhiBackend {
  readonly name = 'WebGL2';
  #gl: WebGL2RenderingContext;
  #next = 1;
  #buffers = new Map<number, WebGLBuffer>();
  #targets = new Map<number, TargetRecord>();
  #sources = new Map<number, string>();
  #programs = new Map<number, WebGLProgram>();
  #groups = new Map<number, readonly BindGroupEntry[]>;

  constructor(gl: WebGL2RenderingContext) {
    this.#gl = gl;
  }

  static create(): WebGL2RHI {
    const canvas = document.createElement('canvas');
    canvas.width = 1;
    canvas.height = 1;
    const gl = canvas.getContext('webgl2');
    if (!gl) throw new Error('no WebGL2 context');
    return new WebGL2RHI(gl);
  }

  createShaderModule(code: string): RhiShaderModule {
    const id = this.#next++;
    this.#sources.set(id, code);
    return id;
  }

  createBuffer(d: BufferDesc): RhiBuffer {
    const gl = this.#gl;
    const id = this.#next++;
    const buf = gl.createBuffer()!;
    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    gl.bufferData(gl.ARRAY_BUFFER, d.size, gl.DYNAMIC_DRAW);
    this.#buffers.set(id, buf);
    return id;
  }

  /** 纹理即一个 framebuffer:render pass 的落点就是它绑定的 FBO。 */
  createTexture(d: TextureDesc): RhiTexture {
    const gl = this.#gl;
    const id = this.#next++;
    const color = gl.createTexture()!;
    gl.bindTexture(gl.TEXTURE_2D, color);
    gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, d.width, d.height);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    const fb = gl.createFramebuffer()!;
    gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, color, 0);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    this.#targets.set(id, { fb, color, width: d.width, height: d.height });
    return id;
  }

  createRenderPipeline(d: RenderPipelineDesc): RhiRenderPipeline {
    const gl = this.#gl;
    const id = this.#next++;
    const compile = (type: number, src: string): WebGLShader => {
      const sh = gl.createShader(type)!;
      gl.shaderSource(sh, src);
      gl.compileShader(sh);
      if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error(`GLSL 编译失败: ${gl.getShaderInfoLog(sh)}`);
      return sh;
    };
    const program = gl.createProgram()!;
    gl.attachShader(program, compile(gl.VERTEX_SHADER, this.#sources.get(d.vertexModule)!));
    gl.attachShader(program, compile(gl.FRAGMENT_SHADER, this.#sources.get(d.fragmentModule)!));
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(`链接失败: ${gl.getProgramInfoLog(program)}`);
    this.#programs.set(id, program);
    return id;
  }

  createBindGroup(entries: readonly BindGroupEntry[]): RhiBindGroup {
    // WebGL2 没有描述符集合,绑定信息在 draw 时按 uniform block / texture unit 逐条应用。
    const id = this.#next++;
    this.#groups.set(id, entries);
    return id;
  }

  writeBuffer(buffer: RhiBuffer, data: ArrayBufferView | ArrayBuffer, offset = 0): void {
    const gl = this.#gl;
    gl.bindBuffer(gl.ARRAY_BUFFER, this.#buffers.get(buffer)!);
    gl.bufferSubData(gl.ARRAY_BUFFER, offset, data as ArrayBufferView);
  }

  beginFrame(): void {
    /* WebGL2 没有显式的帧编码器,命令直接落到上下文 */
  }

  beginRenderPass(att: ColorAttachment): void {
    const gl = this.#gl;
    const t = this.#targets.get(att.texture)!;
    gl.bindFramebuffer(gl.FRAMEBUFFER, t.fb);
    gl.viewport(0, 0, t.width, t.height);
    const c = att.clearValue ?? { r: 0, g: 0, b: 0, a: 1 };
    gl.clearColor(c.r, c.g, c.b, c.a);
    gl.clear(gl.COLOR_BUFFER_BIT);
  }

  draw(call: DrawCall): void {
    const gl = this.#gl;
    gl.useProgram(this.#programs.get(call.pipeline)!);
    gl.drawArrays(gl.TRIANGLES, 0, call.vertexCount);
  }

  endRenderPass(): void {
    this.#gl.bindFramebuffer(this.#gl.FRAMEBUFFER, null);
  }

  submit(): void {
    this.#gl.flush();
  }

  /** 回读当前 framebuffer;WebGL2 的 readPixels 原点在左下,这里原样返回。 */
  async readTexture(texture: RhiTexture): Promise<Uint8Array> {
    const gl = this.#gl;
    const t = this.#targets.get(texture)!;
    gl.bindFramebuffer(gl.FRAMEBUFFER, t.fb);
    const out = new Uint8Array(t.width * t.height * 4);
    gl.readPixels(0, 0, t.width, t.height, gl.RGBA, gl.UNSIGNED_BYTE, out);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    return out;
  }
}

/** L2 验收:clearColor + readPixels,断言像素等于清屏色。 */
export async function runL2(): Promise<{ checks: { ok: boolean; name: string; detail: string }[]; data: unknown }> {
  const rhi = WebGL2RHI.create();
  const W = 8;
  const H = 8;
  const target = rhi.createTexture({ width: W, height: H, format: RhiFormat.RGBA8Unorm, usage: 0 });
  rhi.beginFrame();
  rhi.beginRenderPass({ texture: target, loadOp: 'clear', clearValue: { r: 0.2, g: 0.4, b: 0.8, a: 1 } });
  rhi.endRenderPass();
  rhi.submit();
  const pixels = await rhi.readTexture(target);
  const at = (x: number, y: number): number[] => Array.from(pixels.subarray((y * W + x) * 4, (y * W + x) * 4 + 4));
  const corner = at(0, 0);
  const center = at(4, 4);
  const isClear = (p: number[]): boolean => p[0] === 51 && p[1] === 102 && p[2] === 204 && p[3] === 255;
  return {
    checks: [
      { ok: isClear(corner), name: 'clear.corner', detail: JSON.stringify(corner) },
      { ok: isClear(center), name: 'clear.center', detail: JSON.stringify(center) },
      { ok: pixels.length === W * H * 4, name: 'readback.size', detail: `${pixels.length} bytes` },
    ],
    data: { backend: rhi.name, size: [W, H], corner, center },
  };
}

L2 验收跑在同一个运行器上(headless Chromium + 本地 http 服务),断言清屏像素等于 clearColor 的 8 位量化值:

$ node tools/run-l2.mjs src/render/rhi/webgl2-rhi.ts
PASS  clear.corner  [51,102,204,255]
PASS  clear.center  [51,102,204,255]
PASS  readback.size  256 bytes
=== run-l2 src/render/rhi/webgl2-rhi.ts: ALL PASS ===

clearColor(0.2, 0.4, 0.8, 1.0) 乘 255 取整就是 [51, 102, 204, 255],角与中心完全一致——说明 FBO 绑定与清屏这条最短路径在后端上是通的。

阅读导航:上一节:5.1 WebGPU 后端实现 · 下一节:5.3 NullRHI:记录式假后端与测试 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径