《游戏引擎实现原理》4.1 WebGPU / WebGL2 能力探测与降级

跨端交付的第一道关是能力探测:WebGPU 的 limits/features、WebGL2 的 MAX_TEXTURE_SIZE 读回来之后,怎么翻译成一份具体的渲染配置与降级清单。本节真跑 helio 的启动期探测逻辑(L1 单测)与 headless 真实探测(L2),并划清 L4 的边界。

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-feedbackwantsCompute
storage buffer原生texture-as-storagewantsStorageBuffer
depth 目标depth32floatdepth24后端为 WebGL2
depth texture核心能力核心 / renderbuffer-copyhasDepthTexture=false
GPU 计时timestamp-querycpu-fence缺 timestamp-query
后端选择webgpuwebgl2 / 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 移动端与低端设备 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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