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 只做三个后端,覆盖三种真实处境:
| 维度 | WebGPU | WebGL2 | NullRHI |
|---|---|---|---|
| 特性探测 | adapter.features 集合 | getExtension 逐个问 | 假装全支持 |
| 纹理格式 | 格式表 + 能力位 | internalformat 查询 | 全格式可建 |
| 上限 | device.limits 键值表 | getParameter(MAX_*) | 固定大值 |
| 绑定模型 | bind group + layout | uniform 槽位 | 只记录不绑定 |
| 计算着色器 | 支持 | 不支持 | 支持(空跑) |
| 用途 | 真机渲染 | 老设备兜底 | 无 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 资源对象:缓冲、纹理、采样器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。