《游戏引擎实现原理》2.1 为什么要 RHI:多后端与平台差异

从零实现渲染器的第一件事是划一条硬件抽象线。本节讲清 WebGPU、WebGL2 与 NullRHI 三个后端在特性、格式与上限上的真实差异,给出 helio 的 RHI 统一词汇(仅类型接口),并用一张能力查询表把「能不能做 X」变成一次查表,最后在 headless 浏览器里真探测一次设备的 limits。

2.1 为什么要 RHI:多后端与平台差异

卷一最后一章把内核收束成一句口号:渲染层必须能替换。到了卷二要兑现它——如果《微光岛》的渲染代码里到处是 device.createBuffer 这种 WebGPU 专有调用,它就永远换不了后端,也永远没法在无 GPU 的 CI 里跑。

RHI(Render Hardware Interface)就是这条抽象线。它不负责「画什么」,只负责把「怎么跟硬件说话」收敛成一套统一词汇,让上层的材质、帧图、场景永远只认这套词。

本章与站内既有内容的分工

这一章最容易写成一篇「图形 API 综述」。为避免和站内既有内容打架,先把分工写清楚:

既有文章它负责什么本节只做什么
/posts/graphics/渲染原理与多引擎对照的总入口不推算法,只在 helio 自己的 RHI 上把后端搭出来
/graphics-shader-compilation-pipeline/各引擎的着色器编译、变体与缓存原理在 WebGPU 上真编译 WGSL 并读回行列级诊断
/graphics-texture-memory/纹理内存、格式与压缩的通用原理定义 helio 的资源描述符并校验它的创建参数

一句话:别人讲「图形 API 长什么样」,本节讲「怎么用一套接口把三个后端装进同一个壳」。

三个后端:WebGPU / WebGL2 / NullRHI

helio 只做三个后端,覆盖三种真实处境:

维度WebGPUWebGL2NullRHI
特性探测adapter.features 集合getExtension 逐个问假装全支持
纹理格式格式表 + 能力位internalformat 查询全格式可建
上限device.limits 键值表getParameter(MAX_*)固定大值
绑定模型bind group + layoutuniform 槽位只记录不绑定
计算着色器支持不支持支持(空跑)
用途真机渲染老设备兜底无 GPU 的 CI / 确定性测试

三者的差异不是「谁强谁弱」,而是同一个概念在三套命名里长得不一样:WebGPU 说 maxTextureDimension2D,WebGL2 说 MAX_TEXTURE_SIZE;WebGPU 说 bind group,WebGL2 说 uniform 槽位。RHI 的工作就是把它们翻译进同一张表。

RHI 的统一词汇:只有接口,没有实现

下面这份文件是整卷渲染层的词汇表。它是 L3(仅类型):只有接口与常量,没有一行实现,因此命令行不可复现,需在浏览器或编辑器里手动核对。

// file: src/render/rhi/rhi.ts
// tier: L3
//
// helio 渲染层:RHI 的统一词汇。只有接口与常量,没有实现——
// 它把 WebGPU / WebGL2 / NullRHI 的差异挡在下面,上层只认这一套词。
// 仅类型,命令行不可复现,需在浏览器/编辑器里手动核对。

export const BackendKind = { WebGPU: 'webgpu', WebGL2: 'webgl2', Null: 'null' } as const;
export type BackendKindName = (typeof BackendKind)[keyof typeof BackendKind];

export const RhiTextureFormat = {
  RGBA8Unorm: 'rgba8unorm',
  BGRA8Unorm: 'bgra8unorm',
  RGBA16Float: 'rgba16float',
  Depth32Float: 'depth32float',
} as const;
export type RhiTextureFormatName = (typeof RhiTextureFormat)[keyof typeof RhiTextureFormat];

/** 后端能力的统一描述:特性集合、上限表、可用格式、MSAA 采样数。 */
export interface RhiCapabilities {
  readonly backend: BackendKindName;
  readonly features: ReadonlySet<string>;
  readonly limits: Readonly<Record<string, number>>;
  readonly textureFormats: ReadonlySet<RhiTextureFormatName>;
  readonly sampleCounts: readonly number[];
}

export interface RhiShaderModule {
  readonly label: string;
  readonly language: 'wgsl' | 'glsl-es';
  readonly source: string;
}

/** 设备接口:三个后端都实现它,上层只依赖 RhiDevice,从不 import 具体后端。 */
export interface RhiDevice {
  readonly backend: BackendKindName;
  readonly capabilities: RhiCapabilities;
  createShaderModule(desc: { label: string; source: string }): RhiShaderModule;
}

注意它刻意「什么也不做」:RhiDevice 只有 backend、capabilities 和 createShaderModule 三个成员。后面每一章新增资源或管线时,都往这个接口上加方法,而不是往具体后端里塞分支。

可运行产物:能力查询表与真实探测

有了词汇,接下来把「能不能做 X」变成一次查表。这张表是纯数据 + 纯函数,L1,能在 Node 里直跑单测。

// file: src/render/rhi/capability.ts
// tier: L1
// verify: npx vitest run src/render/rhi/capability.test.ts
//
// 能力查询表:把「这个后端能不能做 X」变成一次查表。
// 表里的数字分两类——WebGPU 规范默认 limits 与 WebGL2(GLES3) 规范保证下限,
// 都是规范写死的常量,不是实测值;真实设备探测见 webgpu-capability.ts。

import {
  BackendKind,
  RhiTextureFormat,
  type BackendKindName,
  type RhiCapabilities,
  type RhiTextureFormatName,
} from './rhi.ts';

export const GpuFeature = {
  DepthClipControl: 'depth-clip-control',
  TextureCompressionBC: 'texture-compression-bc',
  TimestampQuery: 'timestamp-query',
} as const;
const GPU_FEATURES = [
  GpuFeature.DepthClipControl,
  GpuFeature.TextureCompressionBC,
  GpuFeature.TimestampQuery,
];

/** WebGPU 规范默认 limits(device 未申请更高值时就是这些)。 */
const WEBGPU_DEFAULT_LIMITS: Readonly<Record<string, number>> = {
  maxTextureDimension2D: 8192,
  maxBufferSize: 268435456, // 256 MiB
  maxBindGroups: 4,
  maxVertexAttributes: 16,
  maxComputeWorkgroupSizeX: 256,
};
/** WebGL2(GLES3) 规范保证的 MAX_* 下限;0 表示该概念不存在。 */
const WEBGL2_MIN_LIMITS: Readonly<Record<string, number>> = {
  maxTextureDimension2D: 2048,
  maxBufferSize: 268435456,
  maxBindGroups: 0,
  maxVertexAttributes: 16,
  maxComputeWorkgroupSizeX: 0,
};
const ALL_FORMATS: readonly RhiTextureFormatName[] = [
  RhiTextureFormat.RGBA8Unorm,
  RhiTextureFormat.BGRA8Unorm,
  RhiTextureFormat.RGBA16Float,
  RhiTextureFormat.Depth32Float,
];

/** 三个后端各自的能力表。Null 全放行,因为它不真的执行。 */
export const BACKEND_CAPABILITIES: Readonly<Record<BackendKindName, RhiCapabilities>> = {
  webgpu: { backend: BackendKind.WebGPU, features: new Set(GPU_FEATURES),
    limits: WEBGPU_DEFAULT_LIMITS, textureFormats: new Set(ALL_FORMATS), sampleCounts: [1, 4] },
  webgl2: { backend: BackendKind.WebGL2, features: new Set(['depth-texture']),
    limits: WEBGL2_MIN_LIMITS, sampleCounts: [1, 4],
    textureFormats: new Set([RhiTextureFormat.RGBA8Unorm, RhiTextureFormat.RGBA16Float]) },
  null: { backend: BackendKind.Null, features: new Set(GPU_FEATURES),
    limits: WEBGPU_DEFAULT_LIMITS, textureFormats: new Set(ALL_FORMATS), sampleCounts: [1, 4] },
};

export function supportsFeature(caps: RhiCapabilities, feature: string): boolean {
  return caps.features.has(feature);
}
/** 取某项上限;未声明视为 0(即「不支持」)。 */
export function limitOf(caps: RhiCapabilities, name: string): number {
  return caps.limits[name] ?? 0;
}
/** 从期望格式列表里挑第一个后端支持的;一个都不支持返回 null。 */
export function firstSupportedFormat(
  caps: RhiCapabilities,
  wanted: readonly RhiTextureFormatName[],
): RhiTextureFormatName | null {
  for (const f of wanted) if (caps.textureFormats.has(f)) return f;
  return null;
}
/** 返回所有「要求值 > 实际上限」的项,用来在启动时给出可读的降级清单。 */
export function checkRequiredLimits(
  caps: RhiCapabilities,
  required: Readonly<Record<string, number>>,
): string[] {
  const missing: string[] = [];
  for (const [name, need] of Object.entries(required)) {
    if (limitOf(caps, name) < need) missing.push(`${name}: ${limitOf(caps, name)} < ${need}`);
  }
  return missing;
}

本机 npx vitest run src/render/rhi/capability.test.ts 的结果:

✓ capability 查询表 > WebGPU 默认 maxTextureDimension2D 是 8192
✓ capability 查询表 > WebGL2 保证的 MAX_TEXTURE_SIZE 下限是 2048
✓ capability 查询表 > WebGL2 没有 bind group 与 compute,也没有 timestamp-query
✓ capability 查询表 > 按优先级挑格式:WebGL2 没有 bgra8unorm 会跳到 rg8
✓ capability 查询表 > Null 后端全放行,格式列表与 WebGPU 相同
✓ capability 查询表 > 要求超过上限时给出可读的降级清单
 Test Files  1 passed (1)
      Tests  6 passed (6)

但表里的数字是规范写死的,不是真机。真正启动时,helio 会探测一次实际设备,把结果填进同一张表。这段是 L2,必须在浏览器里跑。

// file: src/render/rhi/webgpu-capability.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/rhi/webgpu-capability.ts
//
// 真实探测:从浏览器的 WebGPU adapter 读回 limits / features / 首选画布格式,
// 再顺手读一次 WebGL2 的 MAX_TEXTURE_SIZE,验证「同一张能力表、两套来源」。

export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result {
  readonly checks: L2Check[];
  readonly data: Record<string, unknown>;
}

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) });
  };

  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) throw new Error('requestAdapter() returned null');
  const device = await adapter.requestDevice();
  // device.limits = 本次实际申请到的上限(未指定即为规范默认值)。
  const limits = device.limits;
  const preferredFormat = navigator.gpu.getPreferredCanvasFormat();
  data.limits = {
    maxTextureDimension2D: limits.maxTextureDimension2D,
    maxBindGroups: limits.maxBindGroups,
    maxBufferSize: limits.maxBufferSize,
  };
  data.features = Array.from(adapter.features);
  data.preferredFormat = preferredFormat;

  push('maxTextureDimension2D>=8192', limits.maxTextureDimension2D >= 8192,
    limits.maxTextureDimension2D);
  push('maxBindGroups>=4', limits.maxBindGroups >= 4, limits.maxBindGroups);
  push('maxBufferSize>=268435456', limits.maxBufferSize >= 268435456, limits.maxBufferSize);
  push('preferredFormat in {bgra8unorm,rgba8unorm}',
    preferredFormat === 'bgra8unorm' || preferredFormat === 'rgba8unorm', preferredFormat);

  const canvas = document.createElement('canvas');
  const gl = canvas.getContext('webgl2');
  const glMaxTex = gl ? (gl.getParameter(gl.MAX_TEXTURE_SIZE) as number) : 0;
  data.webgl2MaxTextureSize = glMaxTex;
  push('webgl2 MAX_TEXTURE_SIZE>=2048', glMaxTex >= 2048, glMaxTex);

  device.destroy();
  return { checks, data };
}

在 headless Chromium(本地 http 服务 + --enable-unsafe-webgpu)里实跑的结果:

PASS  maxTextureDimension2D>=8192  8192
PASS  maxBindGroups>=4  4
PASS  maxBufferSize>=268435456  268435456
PASS  preferredFormat in {bgra8unorm,rgba8unorm}  bgra8unorm
PASS  webgl2 MAX_TEXTURE_SIZE>=2048  8192

这里有个值得记住的细节:device.limits 是本次实际申请到的上限(未指定就是规范默认值),而 adapter.limits 是适配器的能力天花板,通常更高。helio 用前者填表,因为它才是你真正能用到的数字。

能力表一旦落地,「换后端」就退化成「换一张表」:上层问 firstSupportedFormat,WebGPU 返回 bgra8unorm,WebGL2 自动降级到 rgba8unorm,上层代码一行不动。

阅读导航:上一节:回到目录 · 下一节:2.2 资源对象:缓冲、纹理、采样器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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