Skip to content

Lesson 038|Transform Dirty 如何在渲染阶段得到最终 World Matrix? ​

Tags: #Creator2.x #Stage04 #Transform #DirtyFlag #WorldMatrixDifficulty: ⭐⭐⭐⭐☆


真实问题:只移动父面板,为什么几百个子节点都要更新 ​

结算面板包含 300 个装饰与列表节点。动画只改变最外层 Panel 的 scale,Profiler 却显示大量 Transform 工作。

节点最终世界变换来自整条父链:

text
Child World
= Parent World × Child Local

父节点一变,所有后代的 World 结果都可能失效。Dirty 不是错误标记,而是引擎避免每帧无条件重算所有矩阵的缓存协议。

🎯 本课目标 ​

本课把 Stage01 的 Transform 知识接到 Renderer:

渲染组件如何获得可以真正用于绘制的世界变换?


二、Local Matrix 到 World Matrix ​

节点自己的 position、rotation、scale 组成 Local Matrix:

text
Local Matrix = position + rotation + scale

父子关系继续合成:

text
Child World Matrix
    = Parent World Matrix × Child Local Matrix

最终渲染顶点需要的是世界变换或进一步乘以 Camera 矩阵后的结果。


三、Dirty 的意义 ​

ts
node.x = 100;
node.y = 200;
node.scale = 2;

引擎可以先记录:

text
TransformDirty = true

而不是每次 setter 都完整重算父子树和渲染数据。这样可以把多次修改合并到真正需要读取时处理。


四、脏状态如何传播 ​

父节点变换变化会影响子节点:

text
Panel.x 改变
    ↓
Panel World Matrix 需要更新
    ↓
Button 的 World Matrix 也失效
    ↓
子树渲染位置需要重新确认

因此复杂 UI 上层节点频繁移动,可能带来较大的变换传播成本。


五、渲染阶段为什么需要最新矩阵 ​

Sprite 的本地四边形顶点可能固定为:

text
(-w/2, -h/2)
( w/2, -h/2)
( w/2,  h/2)
(-w/2,  h/2)

Renderer 需要通过 Node 的世界矩阵把它们变到正确位置:

text
Local Vertex
    ↓ World Matrix
World Vertex
    ↓ Camera Matrix
Screen / Clip Vertex

如果矩阵没有更新,画面就会使用旧位置。


六、读取也可能触发同步计算 ​

ts
node.x = 100;
const worldPosition = node.convertToWorldSpaceAR(cc.v2(0, 0));

当业务主动要求“现在就得到世界坐标”时,引擎可能需要先确保父子矩阵是最新的。

工程风险是:

text
循环中反复修改 Transform
→ 立即读取世界坐标
→ 再修改
→ 再读取

这会破坏批量更新的收益,产生额外同步计算。


七、案例:复杂面板动画 ​

text
Panel
├── Header
├── List
│   ├── Item1
│   ├── Item2
│   └── ...
└── Footer

如果每帧修改 Panel 的 scale 和 position,整个子树的世界变换都可能受到影响。优化方向包括:

  • 减少动画期间不必要的层级深度。
  • 把不需要跟随的对象移出子树。
  • 避免循环中频繁强制读取世界坐标。
  • 将复杂列表的内容更新和面板动画分开。

八、常见误区 ​

误区一:Dirty 就代表画面马上更新 ​

Dirty 只是状态标记,不等于矩阵已经重算或 GPU 已提交。

误区二:父节点移动只改一个节点的成本 ​

它可能使整个子树的世界变换失效。

误区三:读取坐标永远是零成本 ​

如果读取要求最新世界结果,可能触发同步计算。

误区四:所有渲染组件都需要重新生成顶点 ​

有些组件只需更新变换或状态,有些才需要重建几何数据,具体由组件实现决定。


九、深入推导:Dirty 回答的是“缓存还能不能用” ​

先用一个可以手算的例子理解 Local 与 World。

假设父节点 Panel 只做平移,位置是 (100, 50);子节点 Icon 的 Local Position 是 (20, 10)。暂时忽略旋转和缩放:

text
Icon World Position
= Panel World Position + Icon Local Position
= (100, 50) + (20, 10)
= (120, 60)

现在只把 Panel.x 从 100 改成 200。Icon 的 Local Position 仍是 (20, 10),但 World Position 必须变成 (220, 60)。

这说明:

text
子节点 Local 数据没有改变
≠
子节点 World 结果仍然有效

加入旋转和缩放后不能再直接相加,需要矩阵乘法:

text
ChildWorld = ParentWorld × ChildLocal

矩阵乘法顺序不能随意交换。父节点先建立坐标系,子节点的 Local Transform 再在这个坐标系中解释。父节点旋转 90° 后,子节点 Local x 正方向在世界中也会跟着转向。

再把同一帧的三次写入展开:

text
T1:Panel.x = 100
→ Panel Local 变脏
→ 后代 World 结果不能再信任

T2:Panel.x = 120
→ 仍然是同一类失效

T3:Panel.x = 150
→ 最终 Local 值确定

渲染或世界坐标查询需要结果
→ 使用最终 x=150 计算

Dirty 的价值不是让写入免费,而是避免为不会被消费的中间值反复完成整条计算。状态标记、传播和最终计算仍有成本。

Creator 2.4.x 可能通过标记传播、版本号或其他缓存策略实现这一目标。业务代码应该依赖“父结果变化会使子 World 缓存失效”这条关系,而不是依赖某个私有 dirty bit。

每个节点都有本地变换输入:

text
position
rotation
scale
anchor / size 对 UI 几何的影响

引擎可以缓存由它们计算出的结果。当输入变化时,不必立刻递归计算所有后代,只需使相关缓存失效;当渲染、坐标查询或其他系统需要 World 结果时,再保证它是最新的。

概念流程:

text
parent.x 改变
→ parent local/world 标记失效
→ descendants world 结果失效
→ 某阶段请求 child world
→ 从有效祖先开始向下更新
→ child 获得最新 World Matrix

具体 dirty 位和更新函数属于引擎实现细节,不应在业务代码中读写私有字段。

十、Creator 2.4.x 实验:观察父链传播 ​

场景结构 ​

text
Canvas
└── Root
    └── Level1
        └── Level2
            └── Marker

每层设置不同 position、rotation 和 scale,Marker 使用明显 Sprite。

TransformProbe.ts ​

ts
const { ccclass, property } = cc._decorator;

@ccclass
export default class TransformProbe extends cc.Component {
    @property(cc.Node)
    root: cc.Node = null;

    @property(cc.Node)
    marker: cc.Node = null;

    private tick = 0;
    private animateRoot = false;

    toggleRootAnimation() {
        this.animateRoot = !this.animateRoot;
    }

    update() {
        if (this.animateRoot) {
            this.tick++;
            this.root.angle = Math.sin(this.tick * 0.02) * 20;
        }
    }

    lateUpdate() {
        const world = this.marker.convertToWorldSpaceAR(cc.v2());
        cc.log('[transform] marker world', world.x, world.y);
    }
}

实际实验中不要每帧保留大量日志;先短时观察,再关闭日志测性能。

实验 A:逐层改变 ​

分别只改 Root、Level1、Marker 的 position。记录 Marker 世界锚点变化,验证任何祖先变化都会影响后代世界结果。

实验 B:深度与数量 ​

建立两组相同数量节点:

text
Flat:大量同级节点
Deep:多层父子节点

分别动画根节点与叶子节点,观察 Profiler。结果取决于数量、访问和渲染,但应能解释变脏影响范围不同。

实验 C:高频读取 ​

在 update 中对大量节点调用世界坐标/包围盒查询,与只在必要时查询对比。某些读取可能要求同步得到最新变换,因此“只是读”也可能带来计算。

十一、为什么布局系统会形成连锁 ​

Transform Dirty 只描述空间变换缓存,Layout 还可能修改节点尺寸和位置,从而形成另一条失效链:

text
Label.string 改变
→ Label 测量得到新宽度
→ Content Size 改变
→ 父 Layout 重新排列兄弟节点
→ 兄弟 Local Position 改变
→ 多个节点 World Matrix 失效
→ Renderer 使用新位置

所以“只改一个 Label”不等于只影响一个 Transform。排查时要沿依赖方向问:谁读取了这个尺寸,又修改了谁的位置?

如果脚本交替执行“改文字 → 读取世界包围盒 → 再改文字 → 再读取”,就可能多次迫使布局和世界变换同步到最新状态,破坏原本可以合并的延迟更新。

Widget/Layout 修改位置和尺寸后,会继续影响 Transform 与渲染数据:

text
Label 文本改变
→ 节点尺寸改变
→ Layout 重排兄弟节点
→ 多个 local transform 变化
→ world transform 更新
→ Sprite/Label 顶点更新

所以 Transform 成本常不是某个动画脚本单独造成,而是布局反馈链。

十二、真实项目故障:ScrollView 反复查询世界包围盒 ​

列表有 500 个 Item。项目每帧遍历全部 Item 并调用世界包围盒判断可见性,即使只有十几个在视口附近。

问题链:

text
Scroll content 移动
→ 所有后代 world 结果可能变化
→ 500 次世界包围盒查询
→ 强制获取最新矩阵与矩形
→ JS 遍历和变换计算同时增加

修复采用虚拟列表:

  • 只维护可见区附近 Item;
  • 用内容偏移和固定高度计算索引;
  • 避免全量世界坐标查询;
  • 将边界检查集中到滚动事件或合理频率;
  • 用相同数据量对比帧时间。

十三、故障排查 ​

子节点位置看似滞后一帧 ​

检查读取发生在 update、lateUpdate 还是渲染之后;检查动画、Tween、Layout 是否在不同阶段再次写变换。不要通过多读几次世界矩阵碰运气。

静止页面 Transform 仍然很高 ​

寻找每帧微小写入:重复赋相同 position、Widget/Layout 持续刷新、动画未停止、浮点抖动、脚本读写往返。

只移动一个节点却影响整树 ​

确认它是否是大子树祖先。把动画放到更小的视觉容器,避免让不需要移动的节点成为后代。

十四、练习、推导答案与版本边界 ​

本课只依赖公开 Transform 行为解释 Dirty;Creator 2.4.x 内部标志名称和 RenderFlow 实现不作为业务 API。Creator 3.x 的 Transform 更新实现不同,但父 World × 子 Local 的数学关系不变。

  1. 父节点 scale 改变后,子节点 local position 是否改变? 不一定。Local 数据可以不变,但由父链计算出的 World 位置与形状改变。
  2. Dirty 为什么比每帧重算更高效? 静止节点可以复用缓存,只对发生变化且结果被需要的分支更新。
  3. 为什么频繁读世界包围盒也可能贵? 查询需要最新 World 结果和矩形,可能触发或迫使同步计算,并包含 JS 遍历。
  4. 如何缩小动画的 dirty 范围? 把需要动画的内容放入专门容器,避免其成为大量无关节点的祖先,并减少布局与动画同时改同一树。

能力验收 ​

你应该能手算两级父子节点的 World Matrix 关系,解释父节点为什么使后代缓存失效,并通过改变树深、节点数和读取频率区分写入成本、传播范围与同步读取成本。

本课总结 ​

text
业务操作 Transform。
引擎用 Dirty 标记待更新状态。
父子矩阵传播决定最终世界位置。
Renderer 在需要时取得最新矩阵。
矩阵更新和 GPU 提交是两个不同阶段。

十一、下一课预告 ​

text
Lesson039|Local、World、View、Screen 坐标如何转换?