《游戏引擎实现原理》7.1 场景图与变换层级(ECS 之上的一层视图)

把 ECS 的扁平组件数组解释成一棵场景树:给每个实体补一个父索引,按拓扑序算出 world 矩阵,再展平成渲染器直接消费的实例列表,并在 headless Chromium 里用一次 instanced draw 画出全部实例、回读像素核对位置。

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 缓存 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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