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 pass | framebuffer object(FBO),靠绑定切换 |
| WGSL 着色器模块 | GLSL ES 3.0 源码,入口固定是 main |
| storage buffer | 纹理 + texelFetch 读回 |
| compute shader | transform feedback 或退回 CPU |
| depth32float | DEPTH_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:记录式假后端与测试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。