18.1 渲染器装配与场景接入
到第 17 章为止,helio 的渲染器已经有了全套零件:RHI 与三后端、资源与管线、场景图与相机、剔除与批次、材质与着色器变体、光照与阴影、帧图、后处理、2D/UI、多线程录制。但它们大多是各自可跑的孤立模块,谁也没有把谁串起来。综合章要做的第一件事,就是给这些零件定一条装配线:输入是纯数据场景,输出是一份可以直接提交的帧计划(frame plan)。
这一节不讲新算法,只讲接口:场景进、帧计划出。这也是整卷从「一堆能跑的模块」变成「一个能跑的渲染器」的那一步。
本章与站内既有内容的分工
综合章最容易写成一篇「渲染管线全景综述」,先划清边界:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/graphics-gpu-profiling-debugging/ | 各类 GPU 计时、抓帧工具的通用原理 | 只把 helio 的装配流水线接到 GPU 时间戳上(见 18.3) |
/game-engine-architecture-ecs/ | 引擎分层的通用架构与 ECS 数据布局 | 只做「场景纯数据 → 渲染器帧计划」这一段接口 |
/posts/books/引擎内核/ | 卷一 helio 内核:调度、内存、作业系统 | 渲染器只消费它,不重复实现内核 |
一句话:别人讲「渲染管线由哪些阶段组成」,本章讲「在 helio 上怎么把它们接起来、跑出什么」。
装配流水线:从纯数据场景到帧计划
装配线把前三章的成果首尾相接:先用第 7 章的变换
把实例摆到世界空间,再用第 8 章的视锥剔除
丢掉看不见的,接着用第 9 章的排序键
与实例化合批
把剩下的排好序、并成批。产物 AssembledFrame 只含两样东西:画什么(可见实例列表)与按什么顺序画(排序后的下标),这正是第 14 章帧图
编译前的输入。
关键设计:装配层是纯计算,不碰任何 GPU 对象。它读的是 SceneInstance(纯数据),产出的是数组与整数键——所以它能在 Node 里直接跑、直接测。
// file: src/render/app/renderer-assembly.ts
// tier: L1
// verify: npx vitest run src/render/app/renderer-assembly.test.ts
//
// helio 渲染卷 18.1:渲染器装配与场景接入。把第 7 章(变换)→ 第 8 章(视锥
// 剔除)→ 第 9 章(排序 / 合批)串成一条「纯数据场景 → 帧计划」的装配流水线。
// 纯计算(L1),Node 26 直跑。
import { extractFrustumPlanes, aabbInFrustum, type Aabb, type Frustum } from '../culling/frustum.ts';
import { packKey, radixSortKeys, SortMode, type RenderItem } from '../sort/sort-key.ts';
export type Vec3 = readonly [number, number, number];
/** 场景里一个可渲染实例:纯数据,不含任何 GPU 概念。 */
export interface SceneInstance {
readonly id: number;
readonly meshId: number;
readonly materialId: number;
readonly position: Vec3;
readonly scale: number;
readonly alpha: boolean; // true 走透明通道(后画、远→近)
readonly visible: boolean; // 应用层可见性开关
}
/** 实例的世界空间包围盒:以 position 为中心、半边长 = scale 的立方体。 */
export function instanceBounds(inst: SceneInstance): Aabb {
const s = inst.scale;
const [x, y, z] = inst.position;
return { min: [x - s, y - s, z - s], max: [x + s, y + s, z + s] };
}
export interface FrameStats {
readonly total: number;
readonly culled: number;
readonly drawn: number;
readonly opaque: number;
readonly transparent: number;
readonly batches: number;
}
export interface AssembledFrame {
/** 绘制顺序:元素是「可见实例数组 visible 里的下标」,已按键非降序。 */
readonly order: Uint32Array;
/** 每个可见实例的 32 位排序键(与 visible 同序)。 */
readonly keys: Uint32Array;
readonly visible: readonly SceneInstance[];
readonly stats: FrameStats;
}
/** 装配一帧:剔除 → 视深度归一化 → 打包排序键 → 基数排序 → 统计批次。 */
export function assembleFrame(
instances: readonly SceneInstance[], viewProj: readonly number[], cameraPos: Vec3, far: number,
): AssembledFrame {
const frustum: Frustum = extractFrustumPlanes(viewProj);
const visible: SceneInstance[] = [];
let culled = 0;
for (const inst of instances) {
if (!inst.visible || aabbInFrustum(instanceBounds(inst), frustum) === 'outside') { culled++; continue; }
visible.push(inst);
}
const n = visible.length;
const keys = new Uint32Array(n);
const items: RenderItem[] = new Array(n);
for (let i = 0; i < n; i++) {
const inst = visible[i]!;
const dx = inst.position[0] - cameraPos[0];
const dy = inst.position[1] - cameraPos[1];
const dz = inst.position[2] - cameraPos[2];
const depth = Math.min(1, Math.max(0, Math.hypot(dx, dy, dz) / far));
const item: RenderItem = {
pipelineId: inst.meshId & 255, materialId: inst.materialId & 255, meshId: inst.meshId & 255,
mode: inst.alpha ? SortMode.transparent : SortMode.opaque, depth,
};
items[i] = item;
keys[i] = packKey(item);
}
const order = radixSortKeys(keys, n);
let opaque = 0, transparent = 0, batches = 0, lastHigh = -1;
for (let i = 0; i < n; i++) {
if (items[order[i]!]!.mode === SortMode.transparent) transparent++; else opaque++;
const high = keys[order[i]!]! >>> 14; // mode | pipeline | material 决定「能否同一批」
if (high !== lastHigh) { batches++; lastHigh = high; }
}
return { order, keys, visible, stats: { total: instances.length, culled, drawn: n, opaque, transparent, batches } };
}
它复用的全是前几章已经跑过的函数:extractFrustumPlanes / aabbInFrustum 来自第 8 章,packKey / radixSortKeys 来自第 9 章。装配层自己一行渲染算法都没写,只是把它们按正确顺序接起来。配套单测验证四件事:视锥外与应用层隐藏的实例被剔除、可见实例按键非降序、透明通道排在所有不透明之后、两次装配逐位一致。
可运行产物:把装配出的帧计划画到 GPU 上
装配产出的 AssembledFrame 能不能真的变成像素?下面这块 WGSL 是「帧计划」的消费者:每条实例记录含中心 xz、半边长与颜色,一次 draw(6, N) 把整份绘制列表画成俯视方块。世界 xz 经正交映射到裁剪空间(clip = center / extent),与 18.1 装配时用的正交视锥是同一套坐标。
// file: shaders/scene-assembly.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/app/scene-assembly-l2.ts
//
// helio 渲染卷 18.1:把装配流水线产出的可见实例列表画成俯视方块。
// 每实例一条记录(中心 xz、半边长、颜色),一次 draw(6, N) 全部画出。
// 世界 xz 经正交映射到裁剪空间:clip = center / extent。
struct Instance {
center: vec2<f32>, // @0 世界 xz
half: f32, // @8 半边长(世界单位)
_pad: f32, // @12 补齐 16 字节
color: vec4<f32>, // @16
};
@group(0) @binding(0) var<storage, read> instances: array<Instance>;
struct Uniforms { extent: f32 };
@group(0) @binding(1) var<uniform> u: Uniforms;
struct VSOut {
@builtin(position) pos: vec4<f32>,
@location(0) color: vec4<f32>,
};
@vertex
fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
var quad = array<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(-1.0, 1.0),
vec2<f32>(-1.0, 1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
);
let inst = instances[ii];
let c = inst.center / u.extent;
let h = inst.half / u.extent;
var o: VSOut;
o.pos = vec4<f32>(c + quad[vi] * h, 0.0, 1.0);
o.color = inst.color;
return o;
}
@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
return in.color;
}
L2 验收脚本造一个 5×5 的《微光岛》俯视网格场景(外加一个远在正交盒外的实例),把它交给 assembleFrame,再按装配出的绘制顺序打包实例记录、上传、绘制、回读。每个可见实例的中心像素必须等于它的颜色;远实例必须根本不在绘制列表里。
// file: src/render/app/scene-assembly-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/app/scene-assembly-l2.ts
//
// helio 渲染卷 18.1:在真实 WebGPU 设备上验收装配流水线。造一个俯视的
// 《微光岛》网格场景,交给 assembleFrame 剔除 + 排序,把产出的可见实例列表
// 上传、一次实例化绘制,再回读像素:每个可见实例的中心像素必须等于它的颜色;
// 一个远在正交盒外的实例必须被剔除。需在浏览器中经本地 http 服务运行,命令行不可复现。
import { assembleFrame, type SceneInstance } from './renderer-assembly.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 W = 64;
const H = 64;
const EXTENT = 6; // 正交盒半边长:世界 x,z ∈ [-6, 6] 映射到整幅画布
const HALF = 0.8;
const FAR = 100;
/** 俯视正交投影:clip = (x/extent, z/extent),深度取 y。列主序 4x4。 */
const VIEW_PROJ = [1 / EXTENT, 0, 0, 0, 0, 0, -1 / 20, 0, 0, 1 / EXTENT, 0, 0, 0, 0, 1, 1];
/** 确定性场景:5×5 网格(间距 2)+ 一个远在正交盒外的实例。 */
function scene(): SceneInstance[] {
const out: SceneInstance[] = [];
let id = 0;
for (let r = -2; r <= 2; r++) {
for (let c = -2; c <= 2; c++) {
out.push({ id: id++, meshId: 0, materialId: 0, position: [c * 2, 0, r * 2], scale: HALF, alpha: false, visible: true });
}
}
out.push({ id, meshId: 0, materialId: 0, position: [40, 0, 0], scale: HALF, alpha: false, visible: true });
return out;
}
/** 每个实例的颜色由网格坐标决定,落在 [0.1, 0.9]。 */
function colorOf(x: number, z: number): number[] {
return [(x / 4 + 1) * 0.4 + 0.1, (z / 4 + 1) * 0.4 + 0.1, 0.7, 1];
}
/** 世界 xz → 像素 (col, row),与着色器的 clip = center/extent 一一对应。 */
function worldToPixel(x: number, z: number): [number, number] {
return [Math.round((x / EXTENT * 0.5 + 0.5) * W - 0.5), Math.round((0.5 - z / EXTENT * 0.5) * H - 0.5)];
}
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();
const wgsl = await (await fetch('/shaders/scene-assembly.wgsl')).text();
const module = device.createShaderModule({ code: wgsl });
const info = await module.getCompilationInfo();
const errors = info.messages.filter((m) => m.type === 'error').map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
data.wgslErrors = errors;
push('wgsl 0 error', errors.length === 0, errors.length);
const frame = assembleFrame(scene(), VIEW_PROJ, [0, 20, 0], FAR);
data.stats = frame.stats;
push('绘制 25 个可见实例', frame.stats.drawn === 25, `drawn=${frame.stats.drawn}`);
push('剔除正交盒外 1 个实例', frame.stats.culled === 1, `culled=${frame.stats.culled}`);
// 按装配出的绘制顺序打包实例记录:center(8) + half(4) + pad(4) + color(16) = 32B
const rec = new Float32Array(frame.order.length * 8);
frame.order.forEach((srcIdx, slot) => {
const inst = frame.visible[srcIdx]!;
const o = slot * 8;
rec.set([inst.position[0], inst.position[2], inst.scale, 0], o);
rec.set(colorOf(inst.position[0], inst.position[2]), o + 4);
});
const sBuf = device.createBuffer({ size: rec.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(sBuf, 0, rec);
const uBuf = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(uBuf, 0, new Float32Array([EXTENT, 0, 0, 0]));
const fmt: GPUTextureFormat = 'rgba8unorm';
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format: fmt }] },
primitive: { topology: 'triangle-list' },
});
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: sBuf } }, { binding: 1, resource: { buffer: uBuf } }],
});
const target = device.createTexture({ size: [W, H], format: fmt, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
const bpr = 256; // W*4 = 256,正好是 256 的倍数
const readback = device.createBuffer({ size: bpr * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const enc = device.createCommandEncoder();
const pass = enc.beginRenderPass({
colorAttachments: [{ view: target.createView(), clearValue: { r: 0.05, g: 0.05, b: 0.08, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(6, frame.order.length);
pass.end();
enc.copyTextureToBuffer({ texture: target }, { buffer: readback, bytesPerRow: bpr }, [W, H]);
device.queue.submit([enc.finish()]);
await readback.mapAsync(GPUMapMode.READ);
const px = new Uint8Array(readback.getMappedRange().slice(0));
readback.unmap();
const to8 = (x: number): number => Math.round(Math.min(1, Math.max(0, x)) * 255);
let maxDiff = 0;
for (const inst of frame.visible) {
const [col, row] = worldToPixel(inst.position[0], inst.position[2]);
const off = row * bpr + col * 4;
const want = colorOf(inst.position[0], inst.position[2]).slice(0, 3).map(to8);
for (let k = 0; k < 3; k++) maxDiff = Math.max(maxDiff, Math.abs(px[off + k]! - want[k]!));
}
data.maxDiff = maxDiff;
push('每个可见实例中心像素 = 其颜色(±3)', maxDiff <= 3, `maxDiff=${maxDiff}`);
const ids = new Set(frame.visible.map((v) => v.id));
push('远实例 25 不在绘制列表', !ids.has(25), `visibleIds=${frame.visible.length}`);
device.destroy();
return { checks, data };
}
在 headless Chromium(本地 http 服务、带 --enable-unsafe-webgpu)里跑,本机实测:
PASS wgsl 0 error 0
PASS 绘制 25 个可见实例 drawn=25
PASS 剔除正交盒外 1 个实例 culled=1
PASS 每个可见实例中心像素 = 其颜色(±3) maxDiff=1
PASS 远实例 25 不在绘制列表 visibleIds=25
装配流水线统计出 26 个实例里 25 个可见、1 个被剔除,合并成 1 个批次(同材质同网格)。25 个可见实例的中心像素与各自颜色最大只差 1/255(差在 rgba8unorm 的舍入),说明「场景 → 帧计划 → 像素」这条链路是通的:装配层算出的每一格,GPU 都照原样画了出来。
(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/app/scene-assembly-l2.ts 需在浏览器中运行,命令行不可复现。)
场景能画出来了,但「什么时候画、光从哪来」还没定。下一节把一天的时辰映射成一组光照参数,让《微光岛》有昼夜。
阅读导航:上一节:回到目录 · 下一节:18.2 昼夜循环与光照表现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。