《Cocos 游戏开发入门》3.1 场景图与节点树

本节给《方块冲刺》搭起场景图:用 2×3 仿射矩阵合成父子变换,讲清局部矩阵与世界矩阵的差别,用脏标记做惰性重算,并按深度优先遍历节点树;同时给出 mini2d 的 Node 实现、vitest 验证,以及 Cocos Creator 3.8 中 Node 与 UITransform 的对照。

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);
  }
}

对照要点:

概念mini2dCocos Creator 3.8
节点类Node(自研)Node(引擎内建)
局部变换写入setPosition(x, y)node.setPosition(x, y, z?)
世界变换读取worldMatrixnode.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 绘制顺序、层级与遮罩 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画