7.1 场景图与变换层级(ECS 之上的一层视图)
6.3 结束时我们手里有了纹理,但渲染器还不知道「要画什么、画在哪」。这一节补上场景侧的第一块:场景图。它不是又一套 ECS,而是架在 ECS 之上的一层视图——ECS 只存扁平的组件数组,场景图给每个实体补一个父索引,把这堆数组解释成一棵树,再算出每个节点的世界矩阵。
本节产出两个可跑文件:
src/render/scene/scene-graph.ts(L1,Node 直跑)与src/render/scene/render-instances.ts(L2,headless Chromium),外加着色器shaders/instance-transform.wgsl。文中所有节点数、实例数、像素值都来自本机实跑。
本章与站内既有内容的分工
第 7 章起进入「场景与相机」。渲染算法本身不重推,一律外链;本卷只做「在 helio 的 RHI 上搭出来、跑出真实产物」:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/graphics-math-foundations/ | 向量/矩阵与投影的数学推导 | 把层级变换落成可跑的场景图 |
/graphics-culling-visibility-systems/ | 视锥剔除与可见性的原理与引擎对照 | 只产出待渲染实例列表,剔除留给第 8 章 |
/graphics-skeletal-animation/ | 骨骼动画与蒙皮 | 只处理刚体节点的层级变换 |
ECS 之上为什么还要一层场景图
卷一的 ECS 把数据摊成一条条扁平数组:位置一条、网格句柄一条,谁也不知道谁挂在谁下面。可《微光岛》里的礁石要跟着岛屿转——这就是层级,而层级不是 ECS 的强项。
场景图的做法很克制:只加一个父索引数组。父索引 + 局部 TRS 就够描述一棵树。剩下两件事决定它好不好用:
- 拓扑序。要算
world[i],得先有world[parent]。把节点按「父一定排在子之前」排序,一次正序扫描就能算完所有世界矩阵——不递归,也不每帧重建树。 - 复合顺序。
world[i] = world[parent] * local[i](列向量:先局部、后父级)。这正是卷一 8.3《变换复合与逆变换》定下的T*R*S约定在层级上的延续。
场景图的产出不是 GPU 资源,而是一串 world 矩阵。渲染器拿到它就能画;它自己不碰 RHI。
可运行产物:把扁平组件展平成实例列表
下面是完整的 src/render/scene/scene-graph.ts。它接受 ECS 的扁平组件数组,算出拓扑序与世界矩阵,flatten() 再丢掉「不可见」和「纯分组」的节点,只留下要画的东西。
// file: src/render/scene/scene-graph.ts
// tier: L1
// verify: node src/render/scene/scene-graph.ts
//
// helio 渲染 7.1:场景图 —— ECS 之上的一层「视图」。
// ECS 存的是扁平组件数组(父索引、局部 TRS、meshId);场景图把它解释成一棵树,
// 按拓扑序算出 world 矩阵,再展平成渲染器直接消费的实例列表。Node 26 直跑。
import { type Mat4, identity, multiply } from '../../core/mat4.ts';
import { composeTRS, type Transform } from '../../core/transform.ts';
/** ECS 侧的组件视图:都是扁平数组,本身没有「树」的概念。 */
export interface NodeComponents {
readonly parent: Int32Array; // 父索引,-1 为根
readonly local: readonly Transform[];
readonly meshId: Int32Array; // -1 表示纯分组节点(不渲染)
readonly materialId: Int32Array;
readonly visible: Uint8Array;
}
/** 渲染器直接消费的实例:世界矩阵已算好,顺序即拓扑序。 */
export interface RenderInstance {
readonly node: number; readonly meshId: number; readonly materialId: number; readonly world: Mat4;
}
/** 场景图只给 ECS 加「父索引」一个概念;它不认识 GPU,也不认识 RHI。 */
export class SceneGraph {
#parent: Int32Array;
#local: Transform[];
#meshId: Int32Array;
#materialId: Int32Array;
#visible: Uint8Array;
#count = 0;
constructor(capacity: number) {
this.#parent = new Int32Array(capacity).fill(-1);
this.#local = [];
this.#meshId = new Int32Array(capacity).fill(-1);
this.#materialId = new Int32Array(capacity).fill(-1);
this.#visible = new Uint8Array(capacity).fill(1);
}
get count(): number { return this.#count; }
/** 从 ECS 的扁平组件数组建出视图:只登记引用,不复制矩阵。 */
static fromComponents(c: NodeComponents): SceneGraph {
const g = new SceneGraph(c.parent.length);
g.#parent = c.parent.slice();
g.#meshId = c.meshId.slice();
g.#materialId = c.materialId.slice();
g.#visible = c.visible.slice();
g.#local = [...c.local];
g.#count = c.parent.length;
return g;
}
add(parent: number, local: Transform, meshId = -1, materialId = -1): number {
const i = this.#count++;
this.#parent[i] = parent; this.#local[i] = local;
this.#meshId[i] = meshId; this.#materialId[i] = materialId;
return i;
}
/** 拓扑序:先按深度、再按登记下标(稳定)——父一定排在子之前。 */
topologicalOrder(): number[] {
const d = new Int32Array(this.#count);
for (let i = 0; i < this.#count; i++) {
const p = this.#parent[i]!;
d[i] = p < 0 ? 0 : d[p]! + 1; // 父先于子登记,故一趟正序扫描即可
}
return Array.from({ length: this.#count }, (_, i) => i).sort((a, b) => d[a]! - d[b]! || a - b);
}
/** 按拓扑序一次遍历算 world 矩阵:world[i] = world[parent] * local[i]。 */
worldMatrices(): Mat4[] {
const world: Mat4[] = new Array(this.#count);
for (const i of this.topologicalOrder()) {
const local = composeTRS(this.#local[i]!);
const p = this.#parent[i]!;
world[i] = p < 0 ? local : multiply(identity(), world[p]!, local);
}
return world;
}
/** 展平成实例列表:只含「可见且有网格」的节点,顺序即拓扑序。 */
flatten(): RenderInstance[] {
const world = this.worldMatrices();
const out: RenderInstance[] = [];
for (const i of this.topologicalOrder()) {
if (this.#meshId[i]! >= 0 && this.#visible[i]! === 1) {
out.push({ node: i, meshId: this.#meshId[i]!, materialId: this.#materialId[i]!, world: world[i]! });
}
}
return out;
}
}
// ---- 直接运行时:从 ECS 扁平组件数组建视图并展平(Node 26 import.meta.main)----
if ((import.meta as { main?: boolean }).main) {
const trs = (t: [number, number, number], s = 1): Transform => ({ translation: t, rotation: identity(), scale: [s, s, s] });
const r = identity(); r[0] = 0; r[2] = -1; r[8] = 1; r[10] = 0; // 绕 Y 轴 90°
// 这正是 ECS 侧会拿到的样子:5 条扁平数组,没有任何「树」
const g = SceneGraph.fromComponents({
parent: new Int32Array([-1, 0, 1, 1, 1]), // root → island → 3 rocks
local: [trs([0, 0, 0]), { translation: [0, 0, 0], rotation: r, scale: [1, 1, 1] },
trs([2, 0, 0], 0.5), trs([-2, 0, 1], 0.5), trs([0, 0, -3], 0.5)],
meshId: new Int32Array([-1, 0, 1, 1, 1]), materialId: new Int32Array([-1, 0, 1, 1, 1]),
visible: new Uint8Array(5).fill(1),
});
console.log(`nodes ${g.count} order ${g.topologicalOrder().join(',')}`);
const inst = g.flatten();
console.log(`instances ${inst.length}`);
for (const it of inst) {
const p = [it.world[12]!, it.world[13]!, it.world[14]!].map((n) => n.toFixed(3));
console.log(` node${it.node} mesh${it.meshId} world(${p.join(', ')})`);
}
}
在 /tmp/enginebook 里 node src/render/scene/scene-graph.ts(本机实测):
nodes 5 order 0,1,2,3,4
instances 4
node1 mesh0 world(0.000, 0.000, 0.000)
node2 mesh1 world(0.000, 0.000, -2.000)
node3 mesh1 world(1.000, 0.000, 2.000)
node4 mesh1 world(-3.000, 0.000, 0.000)
5 个节点(1 个分组根 + 1 个岛屿 + 3 块礁石)展平成 4 个实例——根节点 meshId = -1,被 flatten() 正确跳过。岛屿绕 Y 轴转了 90°,所以三块礁石的局部偏移 (2,0,0)、(-2,0,1)、(0,0,-3) 被父级旋转成了世界位置 (0,0,-2)、(1,0,2)、(-3,0,0)。这就是层级的全部意义:改一个父节点的旋转,整棵子树跟着动。
可运行产物:一次 instanced draw 画出所有实例
有了实例列表,渲染侧只要把每个实例的 world 矩阵塞进一块 storage buffer,就能用一次 draw(6, N) 画完所有实例。着色器 shaders/instance-transform.wgsl 按 instance_index 取自己的矩阵:
// file: shaders/instance-transform.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/scene/render-instances.ts
// 每实例一份 model 矩阵 + 颜色;按 instance_index 取自己的矩阵把单位四边形
// 变换到世界,再用俯视正交投影压进裁剪空间。片元直接输出实例颜色。
struct Instance {
model: mat4x4<f32>,
color: vec4<f32>,
};
@group(0) @binding(0) var<storage, read> instances: array<Instance>;
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 {
// 单位四边形(xz 平面,两个三角形,局部 ±0.8)
var quad = array<vec2<f32>, 6>(
vec2<f32>(-0.8, -0.8), vec2<f32>(0.8, -0.8), vec2<f32>(0.8, 0.8),
vec2<f32>(-0.8, -0.8), vec2<f32>(0.8, 0.8), vec2<f32>(-0.8, 0.8),
);
let local = quad[vi];
let world = instances[ii].model * vec4<f32>(local.x, 0.0, local.y, 1.0);
var o: VSOut;
// 俯视正交:把 world 的 xz 平面按 0.25 缩放进裁剪空间
o.pos = vec4<f32>(world.x * 0.25, world.z * 0.25, 0.0, 1.0);
o.color = instances[ii].color;
return o;
}
@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
return in.color;
}
src/render/scene/render-instances.ts(L2)起一个 headless Chromium,把展平结果画进一张 128×128 纹理,再回读像素、按「实例中心的投影位置」采样:
// file: src/render/scene/render-instances.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/scene/render-instances.ts
//
// 7.1 的 L2 验收:把场景图展平出的 world 矩阵塞进 storage buffer,一次 instanced
// draw 画出所有实例,再回读像素、按「投影后的实例中心」采样,断言每个实例的
// 颜色确实落在它该在的屏幕位置。需 headless Chromium + 本地 http。
import { SceneGraph } from './scene-graph.ts';
import { type Mat4, identity } from '../../core/mat4.ts';
import { type Transform } from '../../core/transform.ts';
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result { readonly checks: L2Check[]; readonly data: Record<string, unknown> }
/** 每个实例的 storage 记录:mat4x4(64B) + color vec4(16B) = 80B,对齐 16。 */
export const INSTANCE_FLOATS = 20;
/** run-l2 会把 /tmp/enginebook 下的文件按路径送出,故着色器可直接 fetch。 */
export async function loadWgsl(path: string): Promise<string> {
const res = await fetch(path);
if (!res.ok) throw new Error(`fetch ${path} -> ${res.status}`);
return res.text();
}
/** 把 N 个 world 矩阵 + N 个颜色画进 size×size 纹理并回读 RGBA8 像素。 */
export async function renderInstances(
device: GPUDevice, wgsl: string, models: readonly Mat4[], colors: readonly number[][], size = 128,
): Promise<Uint8Array> {
const data = new Float32Array(models.length * INSTANCE_FLOATS);
models.forEach((m, i) => { data.set(m, i * INSTANCE_FLOATS); data.set(colors[i]!, i * INSTANCE_FLOATS + 16); });
const sbuf = device.createBuffer({ size: data.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(sbuf, 0, data);
const module = device.createShaderModule({ code: wgsl });
const pipeline = device.createRenderPipeline({ layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format: 'rgba8unorm' }] } });
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: sbuf } }] });
const target = device.createTexture({ size: [size, size], format: 'rgba8unorm',
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
const enc = device.createCommandEncoder();
const pass = enc.beginRenderPass({ colorAttachments: [{ view: target.createView(), loadOp: 'clear',
storeOp: 'store', clearValue: { r: 0.05, g: 0.05, b: 0.1, a: 1 } }] });
pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.draw(6, models.length); pass.end();
const rb = device.createBuffer({ size: size * 4 * size, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
enc.copyTextureToBuffer({ texture: target }, { buffer: rb, bytesPerRow: size * 4 }, [size, size]);
device.queue.submit([enc.finish()]);
await rb.mapAsync(GPUMapMode.READ);
const out = new Uint8Array(rb.getMappedRange().slice(0));
rb.unmap();
return out;
}
export async function runL2(): Promise<L2Result> {
const checks: L2Check[] = [];
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 loadWgsl('/shaders/instance-transform.wgsl');
const trs = (t: [number, number, number], s = 1): Transform => ({ translation: t, rotation: identity(), scale: [s, s, s] });
const yaw90 = (): Mat4 => { const r = identity(); r[0] = 0; r[2] = -1; r[8] = 1; r[10] = 0; return r; };
const g = new SceneGraph(16);
const root = g.add(-1, trs([0, 0, 0]));
const island = g.add(root, { translation: [0, 0, 0], rotation: yaw90(), scale: [1, 1, 1] }, 0, 0);
g.add(island, trs([2, 0, 0], 0.5), 1, 1);
g.add(island, trs([-2, 0, 1], 0.5), 1, 1);
g.add(island, trs([0, 0, -3], 0.5), 1, 1);
const instances = g.flatten();
const colors = instances.map((it) => (it.meshId === 0 ? [1, 0, 0, 1] : [0, 1, 0, 1]));
const mod = device.createShaderModule({ code: wgsl });
const info = await mod.getCompilationInfo();
push('wgsl.0error', info.messages.filter((m) => m.type === 'error').length === 0, 'errors=0');
const SIZE = 128;
const px = await renderInstances(device, wgsl, instances.map((it) => it.world), colors, SIZE);
const sample = (x: number, y: number): number[] => { const o = (y * SIZE + x) * 4; return [px[o]!, px[o + 1]!, px[o + 2]!, px[o + 3]!]; };
let allHit = true;
const hits: Record<string, unknown> = {};
instances.forEach((it, i) => {
const x = Math.round(((it.world[12]! * 0.25 + 1) / 2) * SIZE); // 俯视正交:clip = world.xz * 0.25
const y = Math.round(((1 - it.world[14]! * 0.25) / 2) * SIZE);
const got = sample(x, y);
const want = colors[i]!.map((c) => Math.round(c * 255));
if (!got.every((v, k) => Math.abs(v - want[k]!) <= 24)) allHit = false;
hits[`node${it.node}`] = { pixel: [x, y], got };
});
push('每个实例中心像素命中自身颜色', allHit, JSON.stringify(hits));
let fg = 0;
for (let i = 0; i < px.length; i += 4) if (px[i]! > 20 || px[i + 1]! > 20) fg++;
push('前景像素数 > 0', fg > 0, `fg=${fg}`);
device.destroy();
return { checks, data: { instances: instances.length, samples: hits, fgPixels: fg } };
}
本机实跑(SwiftShader):
PASS wgsl.0error errors=0
PASS 每个实例中心像素命中自身颜色 {"node1":{"pixel":[64,64],"got":[255,0,0,255]},"node2":{"pixel":[64,96],"got":[0,255,0,255]},"node3":{"pixel":[80,32],"got":[0,255,0,255]},"node4":{"pixel":[16,64],"got":[0,255,0,255]}}
PASS 前景像素数 > 0 fg=1108
三条断言都过了。岛屿(红)的中心投影在屏幕正中 [64,64];三块礁石(绿)落在 [64,96]、[80,32]、[16,64]——换算回 clip 空间,恰好对应上面 L1 打印的三个世界位置乘 0.25 的俯视正交投影。fg=1108 是四个四边形覆盖的前景像素总数。像素断言用 ±24 容差,因为颜色经 f32 → unorm8 量化,不该拿 === 比。
小结
这一节把「场景」拆成两件事:结构(父索引 + 拓扑序)和产出(一串 world 矩阵)。场景图站在 ECS 与渲染器之间,往上不认识 GPU,往下不认识 RHI,只是一次纯数据的展平。
但有个性能问题没解决:如果每帧都对整棵树重算 world 矩阵,礁石越多越亏。下一节就处理它——脏标记传播与 world matrix 缓存。
阅读导航:上一节:回到目录 · 下一节:7.2 脏标记传播与 world matrix 缓存 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。