4.1 WebGPU / WebGL2 能力探测与降级
前三卷的渲染器一直假设「GPU 想干什么就能干什么」。到工程交付这一层,这个假设立刻塌掉:用户的浏览器可能只有 WebGL2,可能是集显,可能连 navigator.gpu 都没有。跨端的第一件事不是画得好不好看,而是先搞清楚这台设备到底能给什么,再决定画成什么样。
为什么探测要在启动期一次做完
反例是把能力判断散在调用点。渲染循环里到处 if (hasCompute) …,会让「这台机器到底走哪条路」变成运行时才能拼出来的隐知识,也没法在启动时给用户一句「你的设备不支持,已降级」的说明。
helio 的做法是两段式:启动期探测一次,把原始数字(limits、features、MAX_TEXTURE_SIZE)冻成一张快照;然后由一个纯函数把快照翻译成一份 RenderConfig,里面写死「用什么后端、纹理上限多少、阴影贴图多大、哪些特性降到了哪条路径」。渲染循环只读 RenderConfig,不再做任何探测。
这条边界让核心逻辑变成纯数据进出——ProbeSnapshot 进,RenderConfig 出,不碰 GPU,可以在 Node 里单测。真实 GPU 差异才需要浏览器。
本章与站内既有内容的分工
| 主题 | 站内既有内容 | 本节的工程化增量 |
|---|---|---|
| 性能分析方法 | GPU 性能分析与调试 讲采样与瓶颈定位的方法 | 本节只做启动期能力探测,不做运行时 profiling |
| 抗锯齿方法 | 抗锯齿方法综述 讲各算法原理 | 本节不重推原理,只决定「缺 MSAA 时退到哪条路径」 |
| 引擎选型 | 游戏引擎专题 讲选型综述 | 本节只谈 helio 自己的跨端落地,不比较第三方引擎 |
分工一句话:既有内容讲「方法是什么」,本节讲「在 helio 的启动流程里怎么把方法落成一张可断言的表」。
可运行产物:从探测快照生成降级决策表
下面是本节的核心模块 src/render/rhi/probe-to-config.ts,层级 L1,完整可编译。它不 import 任何浏览器 API,输入是探测快照,输出是渲染配置加一张降级清单。
// file: src/render/rhi/probe-to-config.ts
// tier: L1
// verify: npx vitest run src/render/rhi/probe-to-config.test.ts
//
// 启动期:把浏览器探测回来的原始数字,翻译成一份具体的渲染配置,
// 并逐条记录「哪些能力缺了、退到了哪条路径」。
// 纯数据进出,不碰 GPU,可在 Node 里单测——真实 GPU 差异见同章的 L2 探测。
export interface WebGpuProbe {
readonly limits: Readonly<Record<string, number>>;
readonly features: readonly string[];
}
export interface WebGl2Probe {
readonly maxTextureSize: number;
readonly maxVertexAttribs: number;
readonly hasDepthTexture: boolean;
}
/** 一次启动探测的原始快照;某后端探测失败即为 null。 */
export interface ProbeSnapshot {
readonly webgpu: WebGpuProbe | null;
readonly webgl2: WebGl2Probe | null;
}
export type BackendChoice = 'webgpu' | 'webgl2' | 'none';
/** 《微光岛》在启动期声明的最低要求。低于它就别硬上,宁可报错。 */
export interface GameRequirement {
readonly minTextureDimension2D: number;
readonly wantsCompute: boolean;
readonly wantsStorageBuffer: boolean;
}
/** 一条降级记录:哪个特性、退到哪条路径、为什么。 */
export interface Downgrade {
readonly feature: string;
readonly strategy: string;
readonly reason: string;
}
export interface RenderConfig {
readonly backend: BackendChoice;
readonly maxTextureDimension2D: number;
readonly shadowMapSize: number;
readonly depthFormat: 'depth32float' | 'depth24unorm';
readonly useCompute: boolean;
readonly useStorageBuffer: boolean;
readonly downgrades: readonly Downgrade[];
}
/** 后端选择:优先 WebGPU,其次 WebGL2,都不满足最低纹理尺寸则 none。 */
export function selectBackend(snapshot: ProbeSnapshot, req: GameRequirement): BackendChoice {
const gpu = snapshot.webgpu;
if (gpu && (gpu.limits.maxTextureDimension2D ?? 0) >= req.minTextureDimension2D) return 'webgpu';
const gl = snapshot.webgl2;
if (gl && gl.maxTextureSize >= req.minTextureDimension2D) return 'webgl2';
return 'none';
}
/** helio 自定的纹理尺寸天花板:再大对《微光岛》的收益为零,只吃显存。 */
const TEXTURE_CEILING = 8192;
export function resolveRenderConfig(snapshot: ProbeSnapshot, req: GameRequirement): RenderConfig {
const backend = selectBackend(snapshot, req);
if (backend === 'none') {
return {
backend, maxTextureDimension2D: 0, shadowMapSize: 0, depthFormat: 'depth24unorm',
useCompute: false, useStorageBuffer: false,
downgrades: [{ feature: 'backend', strategy: 'none', reason: `无后端满足纹理下限 ${req.minTextureDimension2D}` }],
};
}
const downgrades: Downgrade[] = [];
if (backend === 'webgpu') {
const gpu = snapshot.webgpu as WebGpuProbe;
const maxTex = Math.min(gpu.limits.maxTextureDimension2D ?? TEXTURE_CEILING, TEXTURE_CEILING);
// depth32float 与 storage buffer 属 WebGPU 核心能力,无需特性位申请。
if (!gpu.features.includes('timestamp-query')) {
downgrades.push({ feature: 'gpu-timing', strategy: 'cpu-fence', reason: 'adapter 未提供 timestamp-query' });
}
return {
backend, maxTextureDimension2D: maxTex, shadowMapSize: 2048, depthFormat: 'depth32float',
useCompute: req.wantsCompute, useStorageBuffer: req.wantsStorageBuffer, downgrades,
};
}
// WebGL2:compute 与 storage buffer 不存在;helio 的 WebGL2 后端统一走 depth24 省带宽。
const gl = snapshot.webgl2 as WebGl2Probe;
const maxTex = Math.min(gl.maxTextureSize, TEXTURE_CEILING);
if (req.wantsCompute) {
downgrades.push({ feature: 'compute-shader', strategy: 'transform-feedback', reason: 'WebGL2 无 compute shader' });
}
if (req.wantsStorageBuffer) {
downgrades.push({ feature: 'storage-buffer', strategy: 'texture-as-storage', reason: 'WebGL2 无 storage buffer' });
}
downgrades.push({ feature: 'depth32float', strategy: 'depth24', reason: 'helio WebGL2 后端统一走 depth24' });
if (!gl.hasDepthTexture) {
downgrades.push({ feature: 'depth-texture', strategy: 'renderbuffer-copy', reason: '无 depth texture 扩展' });
}
return {
backend, maxTextureDimension2D: maxTex, shadowMapSize: 1024, depthFormat: 'depth24unorm',
useCompute: false, useStorageBuffer: false, downgrades,
};
}
把上面这段逻辑摊成一张表,就是本节要交付的降级决策表:每一行是「一个特性在两种后端上分别怎么落地」,最右列是触发条件。它不追求覆盖 GPU 的全部能力,只覆盖《微光岛》真正用到的那些。
| 特性 | WebGPU 落地 | WebGL2 落地 | 触发条件 |
|---|---|---|---|
| 纹理尺寸 | 原样(夹到 8192) | 原样(夹到 8192) | 总是 |
| compute shader | 原生 | transform-feedback | wantsCompute |
| storage buffer | 原生 | texture-as-storage | wantsStorageBuffer |
| depth 目标 | depth32float | depth24 | 后端为 WebGL2 |
| depth texture | 核心能力 | 核心 / renderbuffer-copy | hasDepthTexture=false |
| GPU 计时 | timestamp-query | cpu-fence | 缺 timestamp-query |
| 后端选择 | webgpu | webgl2 / none | 纹理下限不达标 |
配套单测 src/render/rhi/probe-to-config.test.ts 也完整落盘,层级 L1。关键几条断言:
// snippet: src/render/rhi/probe-to-config.test.ts 节选
it('只有 WebGL2 时降级表列出 compute / storage / depth / depth-texture 四条', () => {
const cfg = resolveRenderConfig(GL_ONLY, REQ);
expect(cfg.backend).toBe('webgl2');
expect(cfg.downgrades.map((d) => d.feature)).toEqual([
'compute-shader', 'storage-buffer', 'depth32float', 'depth-texture',
]);
});
本机实跑:npx vitest run src/render/rhi/probe-to-config.test.ts → 5 passed,npx tsc --noEmit 零错误。降级决策表因此是一条可以进 CI 的断言,而不是一段靠人肉记忆的 if 链。
浏览器里的真实探测(L2)与 L4 边界
上面那张表吃的是快照。快照从哪来?从浏览器。src/render/rhi/probe-l2.ts 层级 L2,它读回 WebGPU adapter 的 limits/features 与 WebGL2 的 MAX_TEXTURE_SIZE,喂进同一个 resolveRenderConfig,断言「探测 → 配置」这条链在真机上闭合。
// file: src/render/rhi/probe-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/rhi/probe-l2.ts
//
// 真实探测:从浏览器读回 WebGPU adapter 的 limits/features 与 WebGL2 的
// MAX_TEXTURE_SIZE,喂给 probe-to-config.ts 的 resolveRenderConfig,
// 断言「探测 → 配置」这条链在真机(headless Chromium + swiftshader)上闭合。
import { resolveRenderConfig, selectBackend, type ProbeSnapshot } from './probe-to-config.ts';
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result {
readonly checks: L2Check[];
readonly data: Record<string, unknown>;
}
const REQ = { minTextureDimension2D: 2048, wantsCompute: true, wantsStorageBuffer: true } as const;
export async function runL2(): Promise<L2Result> {
const checks: L2Check[] = [];
const data: Record<string, unknown> = {};
const push = (name: string, ok: boolean, detail: unknown): void => {
checks.push({ name, ok, detail: String(detail) });
};
let webgpu: ProbeSnapshot['webgpu'] = null;
if (navigator.gpu) {
const adapter = await navigator.gpu.requestAdapter();
if (adapter) {
const device = await adapter.requestDevice();
webgpu = {
limits: {
maxTextureDimension2D: device.limits.maxTextureDimension2D,
maxBindGroups: device.limits.maxBindGroups,
maxBufferSize: device.limits.maxBufferSize,
},
features: Array.from(adapter.features),
};
data.webgpu = webgpu;
device.destroy();
}
}
let webgl2: ProbeSnapshot['webgl2'] = null;
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2');
if (gl) {
webgl2 = {
maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) as number,
maxVertexAttribs: gl.getParameter(gl.MAX_VERTEX_ATTRIBS) as number,
hasDepthTexture: true, // WebGL2 核心即含 depth texture,无需扩展
};
data.webgl2 = webgl2;
}
const snapshot: ProbeSnapshot = { webgpu, webgl2 };
const backend = selectBackend(snapshot, REQ);
const cfg = resolveRenderConfig(snapshot, REQ);
data.backend = backend;
data.config = cfg;
push('probe.webgpu.present', webgpu !== null, webgpu ? 'ok' : 'null');
push('probe.webgl2.present', webgl2 !== null, webgl2 ? 'ok' : 'null');
push('select=webgpu', backend === 'webgpu', backend);
push('config.maxTextureDimension2D in (0,8192]',
cfg.maxTextureDimension2D > 0 && cfg.maxTextureDimension2D <= 8192, cfg.maxTextureDimension2D);
push('config.useCompute=true', cfg.useCompute === true, cfg.useCompute);
return { checks, data };
}
本机实跑 node tools/run-l2.mjs src/render/rhi/probe-l2.ts:5 checks ALL PASS。headless Chromium 走 swiftshader 软件适配器,实测读回 maxTextureDimension2D=8192、maxBindGroups=4、maxBufferSize=268435456(256 MiB),features 含 timestamp-query、texture-compression-bc、depth32float-stencil8;WebGL2 侧 MAX_TEXTURE_SIZE=8192、MAX_VERTEX_ATTRIBS=16。最终选后端 webgpu,downgrades 为空。
L4(命令行不可复现):以上数字来自软件适配器,只证明「链路通」,不代表真实显卡的 limits/features。真实设备上的 GPU 差异——独显与集显的 maxBufferSize、移动端 GPU 的特性位、驱动版本导致的能力缺失——本节需在真实设备 / 浏览器中手动验证,命令行不可复现。工程上只保证:探测代码在任何设备上跑出的快照都能喂进同一张决策表。
探测本身也可能失败:requestAdapter() 返回 null、getContext('webgl2') 返回 null、或者两个后端都拿不到。probe-l2.ts 把这些情况都表达成快照里的 null 字段,而不是抛异常——因为「探测不到」本身就是一种合法结果,它会被 selectBackend 翻译成 none,再由上层决定是提示用户升级浏览器,还是退到 2D 兜底画面。把失败也当成数据,是这套设计能单测的前提。
小结
- 能力探测在启动期一次做完,冻成
ProbeSnapshot;翻译成RenderConfig的是纯函数,可单测。 - 降级决策表把「缺哪个特性 → 退到哪条路径」写成可断言的清单,WebGL2 上固定退掉 compute、storage buffer、depth32float 等项。
- 探测失败(两个后端都拿不到)也是一种合法快照,会被翻译成
none,由上层决定兜底策略。 - 真实 GPU 差异属 L4,命令行列不出;本节只保证探测链路与决策逻辑可复现。
下一节把同一套「设备画像 → 配置」的思路用到移动端与低端设备:分辨率缩放、质量档位与内存带宽约束。
阅读导航:上一节:回到目录 · 下一节:4.2 移动端与低端设备 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。