3.1 场景图与节点树
第 2 章我们把「一个精灵怎么画到画布上」这件事讲透了:拿到纹理、切出矩形、做一次坐标变换、送进 Canvas2D。但真实游戏里没有「一个精灵」,只有几百个互相跟随的精灵——玩家的身体带着武器转,武器又带着枪口火焰转,火焰还跟着武器缩放。如果每一帧都手算这几个精灵的世界坐标,代码会在第三周就失控。
场景图(scene graph)就是为了消灭这类手算而存在的:把对象组织成一棵树,每个节点只描述「相对父节点」的变换,整棵树的绝对位置由遍历时逐级累积得到。本节从零给 mini2d 写出这棵树,并解释它背后的三个工程决策——用什么矩阵、什么时候重算、按什么顺序遍历。
本节把《方块冲刺》推进到「场景组织就绪」:主角、地面、金币不再是一堆散落的坐标,而是挂在同一个根节点下的树。这一步完成后,第 4 章的时间更新、第 6 章的组件模型、第 7 章的碰撞,都会直接复用这里的节点结构。
3.1.1 平铺列表为什么不够
最省事的做法是维护一个扁平数组:
const sprites = [
{ id: 'player', x: 120, y: 240 },
{ id: 'sword', x: 138, y: 250 },
{ id: 'spark', x: 150, y: 258 },
];
只要玩家平移,你就要手动给每个跟随物加上同样的位移;一旦玩家旋转,你还得为每个跟随物重算旋转中心。跟随层级每加深一层,公式就复杂一档,而且极易写错。场景图把这份复杂度一次性封装进「父子累积」这一条规则里:子节点的世界变换 = 父节点世界变换 × 子节点局部变换。你只维护局部量,绝对量交给遍历算。
3.1.2 局部变换与世界变换
先把两个概念钉死,后面所有代码都围绕它们的区别展开。
| 名称 | 含义 | 谁在改 | 依赖 |
|---|---|---|---|
| 局部变换 | 相对父节点原点的位置/旋转/缩放 | 游戏逻辑每帧改 | 只依赖自身 |
| 世界变换 | 相对场景根原点的最终变换 | 不直接改 | 父节点链 + 自身局部 |
《方块冲刺》里,玩家的 x 每帧加 speed * dt,这是局部量;玩家脚下的地面跟着相机滚,相机又挂在场景根上,于是地面的世界位置是「相机世界位置 × 地面局部位置」。逻辑层永远只碰局部量,渲染层永远只用世界量,两层之间隔着一次遍历。
3.1.3 用 2×3 矩阵合成变换
2D 场景图用 2×3 仿射矩阵就够了——它能表达平移、旋转、缩放、错切,且比 3×3 少一行无用数据。mini2d 的矩阵类型:
// src/core/mat2d.ts
export class Mat2D {
a = 1; b = 0; c = 0; d = 1; tx = 0; ty = 0;
static fromTRS(
out: Mat2D, x: number, y: number,
rotation: number, sx: number, sy: number,
): Mat2D {
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
out.a = cos * sx;
out.b = sin * sx;
out.c = -sin * sy;
out.d = cos * sy;
out.tx = x;
out.ty = y;
return out;
}
static multiply(out: Mat2D, parent: Mat2D, child: Mat2D): Mat2D {
const { a, b, c, d, tx, ty } = parent;
out.a = a * child.a + c * child.b;
out.b = b * child.a + d * child.b;
out.c = a * child.c + c * child.d;
out.d = b * child.c + d * child.d;
out.tx = a * child.tx + c * child.ty + tx;
out.ty = b * child.tx + d * child.ty + ty;
return out;
}
copyFrom(src: Mat2D): Mat2D {
this.a = src.a; this.b = src.b; this.c = src.c;
this.d = src.d; this.tx = src.tx; this.ty = src.ty;
return this;
}
}
fromTRS 把「平移 + 旋转 + 缩放」打包成一个矩阵;multiply 实现的就是那条核心规则。注意 multiply 的乘法顺序不可交换:parent × child 与 child × parent 结果不同,这正是「子跟随父」的数学表达。
3.1.4 脏标记与惰性重算
如果每帧对每个节点都重算局部矩阵和世界矩阵,几百个节点就是几百次三角函数与矩阵乘法——大部分是白算的,因为静止的节点根本没变。mini2d 用两级脏标记解决:
- 局部脏:自身位置/旋转/缩放被改过,局部矩阵失效;
- 世界脏:自身或祖先的变换变了,世界矩阵失效。
// src/core/node.ts(节选)
import { Mat2D } from './mat2d';
export class Node {
name = '';
parent: Node | null = null;
readonly children: Node[] = [];
active = true;
zIndex = 0;
private x = 0;
private y = 0;
private rotation = 0;
private scaleX = 1;
private scaleY = 1;
private readonly _local = new Mat2D();
private readonly _world = new Mat2D();
private _localDirty = true;
private _worldDirty = true;
constructor(name = '') { this.name = name; }
setPosition(x: number, y: number): void {
if (x === this.x && y === this.y) return; // 值没变就不脏化
this.x = x; this.y = y;
this.markLocalDirty();
}
private markLocalDirty(): void {
this._localDirty = true;
this.markWorldDirty();
}
private markWorldDirty(): void {
if (this._worldDirty) return; // 已脏则子树必已脏,可提前返回
this._worldDirty = true;
for (const child of this.children) child.markWorldDirty();
}
get localMatrix(): Mat2D {
if (this._localDirty) {
Mat2D.fromTRS(
this._local, this.x, this.y,
this.rotation, this.scaleX, this.scaleY,
);
this._localDirty = false;
}
return this._local;
}
get worldMatrix(): Mat2D {
if (this._worldDirty) {
const parent = this.parent;
if (parent) {
Mat2D.multiply(this._world, parent.worldMatrix, this.localMatrix);
} else {
this._world.copyFrom(this.localMatrix);
}
this._worldDirty = false;
}
return this._world;
}
}
markWorldDirty 里那句提前返回是这套机制的关键不变量:只要一个节点是脏的,它的整棵子树必然也是脏的。所以父节点已经脏时,无需再向下递归。这个不变量靠 addChild 保证——新挂进来的子树必须整棵标脏(见下节)。
3.1.5 遍历顺序决定一切
场景图的遍历约定是深度优先、前序:先访问父节点,再按兄弟顺序访问每个子节点。渲染、更新、命中测试全都复用这一条遍历。
// src/core/node.ts(续)
addChild(child: Node): void {
if (child.parent) child.removeFromParent();
child.parent = this;
this.children.push(child);
child.markWorldDirty(); // 新子树整棵标脏,维持不变量
}
removeFromParent(): void {
const p = this.parent;
if (!p) return;
const i = p.children.indexOf(this);
if (i >= 0) p.children.splice(i, 1);
this.parent = null;
this.markWorldDirty();
}
visit(fn: (node: Node) => void): void {
fn(this);
for (const child of this.children) child.visit(fn);
}
visit 是后面渲染器与更新系统的统一入口。第 4 章会按 visit 分发 update,第 6 章会按 visit 分发生命周期消息,本节先只保证它能稳定地走完整棵树。
3.1.6 把节点树接进渲染循环
有了世界矩阵,渲染器遍历一次就能把每个节点画到正确位置:
// src/render/render-tree.ts
import { Node } from '../core/node';
import type { Mat2D } from '../core/mat2d';
export function drawTree(
root: Node,
ctx: CanvasRenderingContext2D,
drawSprite: (ctx: CanvasRenderingContext2D, node: Node) => void,
): void {
root.visit((node) => {
if (!node.active) return;
const m: Mat2D = node.worldMatrix;
ctx.save();
ctx.setTransform(m.a, m.b, m.c, m.d, m.tx, m.ty);
drawSprite(ctx, node);
ctx.restore();
});
}
注意 setTransform 用的是 2×3 矩阵的六个分量,和 Canvas2D 的 a b c d e f 一一对应——第 2 章讲的坐标变换在这里和场景图接上了。save/restore 成对出现,保证每个节点的变换不污染兄弟节点。
3.1.7 用 vitest 验证变换与惰性重算
变换合成的正确性必须被测试钉死,否则一个符号写反就会静默错位。
// test/node.test.ts
import { describe, it, expect } from 'vitest';
import { Node } from '../src/core/node';
describe('scene graph', () => {
it('子节点世界位置 = 父 + 子局部偏移', () => {
const parent = new Node('parent');
parent.setPosition(100, 50);
const child = new Node('child');
child.setPosition(10, 0);
parent.addChild(child);
const m = child.worldMatrix;
expect(m.tx).toBeCloseTo(110);
expect(m.ty).toBeCloseTo(50);
});
it('父节点平移后子节点世界矩阵随之失效', () => {
const parent = new Node('p');
const child = new Node('c');
parent.addChild(child);
void child.worldMatrix; // 先算一次,清脏
parent.setPosition(7, 9); // 父动 → 子必须重算
expect(child.worldMatrix.tx).toBeCloseTo(7);
expect(child.worldMatrix.ty).toBeCloseTo(9);
});
it('值未变化时不产生脏标记', () => {
const n = new Node('n');
n.setPosition(1, 1);
const before = n.worldMatrix;
n.setPosition(1, 1); // 同值,应早返回
expect(n.worldMatrix).toBe(before);
});
});
toBe(before) 这条断言验证的是「惰性」本身:如果同值赋值也标脏,worldMatrix 会返回一个新建的矩阵对象,引用比较就会失败。这类测试能把性能优化的意图固化成可回归的契约。
3.1.8 Cocos Creator 3.8 对照:Node 与 UITransform
在 Cocos Creator 3.8 里,场景图由引擎内建的 Node 承担,2D 尺寸与锚点由 UITransform 承担——节点负责变换层级,组件负责渲染属性,这是和 mini2d 最直接的区别。
import { _decorator, Component, Node, UITransform, Vec3 } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('Rider')
export class Rider extends Component {
@property(Node)
pivot: Node | null = null;
private readonly _tmp = new Vec3();
start(): void {
// 节点层级:把武器挂到玩家节点下,武器自动跟随玩家
if (this.pivot) this.node.addChild(this.pivot);
}
update(): void {
if (!this.pivot) return;
// 世界坐标 → 局部坐标(对 pivot 而言),再按局部量移动
const local = this.pivot.getPosition(this._tmp);
this.pivot.setPosition(local.x + 1, local.y);
}
worldOrigin(): Vec3 {
// 世界坐标是只读派生量,由父链累积得到
return this.node.worldPosition.clone();
}
resize(): void {
// 尺寸/锚点不在 Node 上,而在独立的 UITransform 组件里
const ui = this.node.getComponent(UITransform);
if (ui) ui.setContentSize(64, 64);
}
}
对照要点:
| 概念 | mini2d | Cocos Creator 3.8 |
|---|---|---|
| 节点类 | Node(自研) | Node(引擎内建) |
| 局部变换写入 | setPosition(x, y) | node.setPosition(x, y, z?) |
| 世界变换读取 | worldMatrix | node.worldPosition / node.worldMatrix |
| 2D 尺寸锚点 | 由精灵自带 | 独立组件 UITransform |
| 子树遍历 | visit(fn) | node.children 递归 / 引擎内部遍历 |
三点差异值得记住。其一,Cocos 的 Node 是三维的,setPosition 收三个分量、旋转用四元数 setRotation 或欧拉角 eulerAngles,2D 游戏只用到 x/y 与绕 z 的旋转。其二,尺寸、锚点这些「渲染属性」被拆到 UITransform 组件上,节点本身只存变换,这与第 6 章要讲的组件模型一脉相承。其三,Cocos 内部同样用脏标记做惰性重算,node.worldPosition 是只读派生量——你不能直接写它,只能通过改局部量让它失效重算,这一点和 mini2d 的 worldMatrix 完全同构。
下一节我们会在这棵树上继续叠加「谁先画、谁后画、谁被裁掉」的规则:绘制顺序、渲染层级与遮罩。
阅读导航:上一节:目录 · 下一节:3.2 绘制顺序、层级与遮罩 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。