1.2 渲染器的分层:RHI / 渲染器 / 场景
上一节把一帧拆成了 CPU 与 GPU 两条流水线。接下来要回答的是:管好这两条流水线的代码,该怎么组织? 如果全都写在一个大文件里,几个月后你换一个渲染后端就会发现,场景数据、GPU 资源、帧的调度逻辑全缠在一起,动一处就崩三处。
helio 的答案是分三层:RHI(渲染硬件接口,管「设备能做什么」)、渲染器(管「一帧怎么组织」)、场景(管「有什么要画」)。这三层的边界一旦划清,换后端、加帧图、跑无 GPU 单测都会变得简单。本节只做一件事:把三层的职责和依赖方向钉死,给出接口草图,并让编译器与测试替我们守住这条边界。
三层的职责边界
先看 helio 渲染包的目录结构。你在哪个文件里写代码,决定了你能看到什么、能被谁看到:
src/render/
├── rhi/ RHI:设备、缓冲、纹理、管线、命令录制(不认识场景)
│ ├── rhi.ts RHI 的统一词汇(接口与常量)
│ ├── webgpu-rhi.ts WebGPU 后端
│ ├── webgl2-rhi.ts WebGL2 后端
│ └── null-rhi.ts NullRHI:只记录调用,供单测
├── scene.ts 场景:实例、相机、光源(纯数据,不认识 GPU)
├── renderer.ts 渲染器:读场景 → 录命令 → 提交(唯一认识两边的一层)
└── frame/ 帧图:Pass 的组织与资源生命周期(第 14 章)
三层的职责,用一句话概括就是:
- RHI 层:只描述「设备能做什么」。缓冲、纹理、着色器模块、管线、命令录制。它把 WebGPU / WebGL2 / NullRHI 的差异挡在下面,上层拿到的永远是一套统一的词。它不认识场景,也不知道「帧」是什么。
- 渲染器层:帧的组织者。它读场景、决定这一帧要跑哪些 Pass、录成命令、提交给设备。它是唯一同时认识「场景」和「RHI」的一层。
- 场景层:纯数据。一堆实例、一台相机、若干光源。它不认识渲染器,更不认识 GPU,只依赖内核层的数学(第 8–9 章的向量与矩阵)。
为什么要把「场景」单独拎出来,而不是直接让渲染器持有游戏对象?因为场景是描述(有什么、在哪、什么材质),渲染是实现(怎么把它变成像素)。两者混在一起,你就没法在无 GPU 的服务器上加载场景、没法做离屏烘焙、也没法让物理或 AI 直接读同一份数据。边界清晰,数据就能被多方复用。
分层的三种常见错误,本书会一路刻意避开:
- 把「后端」当「上层」:让场景直接持有
GPUDevice。一旦发生,场景数据就绑死在某一个后端上,换 WebGL2 时得重写所有数据结构。 - 让 RHI 认识「帧」:把
beginFrame()塞进 RHI 接口。RHI 的职责是「录制命令」,不是「组织帧」——帧的调度属于渲染器层。 - 把渲染逻辑写进场景:在
MeshInstance上挂一个draw()方法。数据一旦带行为,就再也不能被序列化、被离屏烘焙、被多个渲染器共享。
判断标准很简单:如果删掉渲染器层,RHI 与场景还能各自单独编译、单独测试,分层就是干净的。helio 的这三层都满足这个条件。
依赖方向:只能向下,不能成环
三层的依赖方向是一条铁律:渲染器 → {RHI, 场景},而 RHI 与场景都不认识渲染器。换句话说,依赖图是一棵树,不是一张网:
- RHI 不许 import 渲染器。一旦 RHI 认识「帧」或「场景」,它就不再是通用硬件接口,换后端时得连带改上层。
- 场景不许 import RHI。数据一旦知道 GPU 的存在,就无法在 headless 环境里跑——本书所有 L1 单测都会失效。
- 只有渲染器允许有非空的依赖集合。它向下依赖 RHI 与场景,是两边的黏合剂。
这条规则如果只靠自觉,迟早会被一次随手的 import 打破。所以 helio 把它编码成一个可运行的守卫:给定一组渲染模块,扫出所有越界的依赖边。下一节的分工表是内容层面的边界,这个守卫是代码层面的边界,两者一一对应。
可运行产物:三层接口草图
先看最底层的 RHI。它只描述设备能力,不涉及任何场景概念。注意这里全是接口与联合类型——真实的设备句柄无法在命令行复现,需要在浏览器里手动验证。
// file: src/render/rhi/rhi.ts
// tier: L3
//
// helio 渲染:RHI(渲染硬件接口)—— 渲染层的最底层。
// 它只描述「设备能做什么」,不认识场景、材质、光照,也不认识「帧」。
// 三种后端(WebGPU / WebGL2 / NullRHI)各自实现这组接口。
// 本节为类型级草图:真实设备句柄无法在命令行复现,需在浏览器中手动验证。
export type BufferUsage = 'vertex' | 'index' | 'uniform' | 'storage' | 'copy-src' | 'copy-dst';
export type TextureFormat = 'rgba8unorm' | 'bgra8unorm' | 'rgba16float' | 'depth32float';
export type TextureUsage = 'render-target' | 'sampled' | 'copy-src' | 'copy-dst';
export type RhiBackend = 'webgpu' | 'webgl2' | 'null';
export interface BufferDesc { readonly label: string; readonly size: number; readonly usage: readonly BufferUsage[]; }
export interface TextureDesc {
readonly label: string;
readonly width: number;
readonly height: number;
readonly format: TextureFormat;
readonly usage: readonly TextureUsage[];
}
export interface ShaderModuleDesc { readonly label: string; readonly code: string; }
/** 后端能力表:渲染器据此决定用哪条路径(如 WebGL2 无 compute)。 */
export interface RhiCapabilities {
readonly backend: RhiBackend;
readonly maxTextureSize: number;
readonly storageBuffers: boolean;
readonly compute: boolean;
}
export interface RhiBuffer { readonly desc: BufferDesc; }
export interface RhiTexture { readonly desc: TextureDesc; }
export interface RhiShaderModule { readonly desc: ShaderModuleDesc; }
export interface RhiCommandBuffer { readonly label: string; }
/** 命令录制器:RHI 只认「命令」,不认「帧」——帧是渲染器层的概念。 */
export interface RhiCommandEncoder {
beginRenderPass(target: RhiTexture, clear: readonly [number, number, number, number]): void;
draw(vertexCount: number, instanceCount: number): void;
endRenderPass(): void;
finish(): RhiCommandBuffer;
}
/** 设备:RHI 的入口。渲染器持有它;场景永远不持有它。 */
export interface RhiDevice {
readonly capabilities: RhiCapabilities;
createBuffer(desc: BufferDesc): RhiBuffer;
createTexture(desc: TextureDesc): RhiTexture;
createShaderModule(desc: ShaderModuleDesc): RhiShaderModule;
createCommandEncoder(): RhiCommandEncoder;
submit(commands: readonly RhiCommandBuffer[]): void;
destroy(): void;
}
再看中间的场景层。它只有数据,没有方法,更没有 GPU 概念:
// file: src/render/scene.ts
// tier: L3
//
// helio 渲染:场景层 —— 纯数据,不含任何 GPU 概念。
// 渲染器读它;它不认识渲染器,更不认识 RHI。
// 只依赖内核层的数学(第 8–9 章的 Vec3/Mat4)。本节为类型级草图,运行期见第 7 章。
export interface Vec3 { readonly x: number; readonly y: number; readonly z: number; }
export interface Mat4 { readonly m: Float32Array; } // 列主序 4x4
export interface Transform {
readonly position: Vec3;
readonly rotation: Vec3; // 欧拉角(弧度)
readonly scale: Vec3;
}
export interface MeshInstance {
readonly id: number;
readonly meshId: number;
readonly materialId: number;
readonly transform: Transform;
readonly worldMatrix: Mat4; // 由渲染器在剔除前算好,见第 7 章
readonly visible: boolean;
}
export interface Camera {
readonly view: Mat4;
readonly projection: Mat4;
readonly near: number;
readonly far: number;
}
export interface Light {
readonly kind: 'directional' | 'point' | 'spot';
readonly color: Vec3;
readonly intensity: number;
}
/** 场景 = 一堆实例 + 一台相机 + 若干光源。没有「怎么画」,只有「有什么」。 */
export interface Scene {
readonly instances: readonly MeshInstance[];
readonly camera: Camera;
readonly lights: readonly Light[];
}
最后是渲染器层。它 import 了 RHI 与场景,是唯一同时认识两边的一层:
// file: src/render/renderer.ts
// tier: L3
//
// helio 渲染:渲染器层 —— 帧的组织者。
// 它是唯一同时认识「场景」和「RHI」的一层:读场景 → 录命令 → 提交。
// 本节为类型级草图;真正跑起来在第 5 章(后端)与第 14 章(帧图)。
import type { RhiDevice } from './rhi/rhi.ts';
import type { Scene } from './scene.ts';
export interface FrameContext {
readonly frameIndex: number;
readonly deltaMs: number;
}
export interface Renderer {
/** 初始化:拿到设备后建立管线与常驻 GPU 资源。 */
init(device: RhiDevice): void;
/** 组织并提交一帧:读场景 → 录命令 → 提交给设备。这是「帧」的唯一出处。 */
renderFrame(scene: Scene, ctx: FrameContext): void;
/** 释放 GPU 资源。 */
shutdown(): void;
}
三个文件合起来就是渲染层的骨架:rhi/rhi.ts 不认识 scene.ts,scene.ts 不认识 rhi/rhi.ts,只有 renderer.ts 同时 import 两者。这条依赖方向本身就是一层约束——而约束必须可执行,下面把它写成断言。
可运行产物:把依赖方向钉成断言
光有接口草图还不够,依赖方向必须可被自动检查。helio 用一个小的守卫模块编码「每层允许依赖谁」,再配一个扫描函数找出越界边:
// file: src/render/dep-graph.ts
// tier: L1
// verify: npx vitest run src/render/dep-graph.test.ts
//
// helio 渲染:三层依赖方向的可执行守卫。
// RHI 在最底,Scene 是纯数据,Renderer 是唯一同时认识两者的层。
// 依赖只准 Renderer -> {RHI, Scene};RHI 与 Scene 都不认识 Renderer。
export const RENDER_LAYERS = ['rhi', 'scene', 'renderer'] as const;
export type RenderLayer = (typeof RENDER_LAYERS)[number];
/** 每层允许 import 的层。renderer 依赖 rhi/scene;rhi 与 scene 都不依赖任何上层。 */
export const ALLOWED: Record<RenderLayer, readonly RenderLayer[]> = {
rhi: [],
scene: [],
renderer: ['rhi', 'scene'],
};
export interface RenderModule {
readonly name: string;
readonly layer: RenderLayer;
readonly imports: readonly RenderLayer[];
}
export interface Violation {
readonly from: string;
readonly fromLayer: RenderLayer;
readonly toLayer: RenderLayer;
}
/** 扫出一组渲染模块里所有不在 ALLOWED 内的非法依赖边。 */
export function findIllegalDeps(modules: readonly RenderModule[]): Violation[] {
const out: Violation[] = [];
for (const m of modules) {
const allowed = ALLOWED[m.layer];
for (const dep of m.imports) {
if (!allowed.includes(dep)) out.push({ from: m.name, fromLayer: m.layer, toLayer: dep });
}
}
return out;
}
配套测试断言:合法的三层图没有任何非法边,而「RHI 反向依赖渲染器」「场景依赖 RHI」都会被扫出来。本机实测输出:
✓ render dep-graph > 合法依赖:renderer 同时依赖 rhi 与 scene
✓ render dep-graph > RHI 反向依赖 renderer 是非法的
✓ render dep-graph > 场景层不得依赖 RHI:数据不认识 GPU
✓ render dep-graph > 只有 renderer 层允许非空依赖集合
Test Files 1 passed (1)
Tests 4 passed (4)
有了这个守卫,「RHI 不许认识场景、场景不许认识 GPU」就从一句口号,变成了 npx vitest 会替你把关的硬约束。后面每一章引入新的渲染模块时,我们都会先问一句「它在哪一层、允许依赖谁」,再动手写代码。
搞清楚了渲染器怎么分层,还剩最后一个开篇问题:本卷和站内已有的 graphics/ 专题,到底谁写什么?下一节用一张逐条对照的分工表,把「算法不重推、只做 RHI 上的实现与验收」这条原则钉死。
阅读导航:上一节:1.1 一帧都发生了什么(CPU / GPU 时间线) · 下一节:1.3 与 graphics 专题的分工表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。