外观
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 的数学关系不变。
- 父节点 scale 改变后,子节点 local position 是否改变? 不一定。Local 数据可以不变,但由父链计算出的 World 位置与形状改变。
- Dirty 为什么比每帧重算更高效? 静止节点可以复用缓存,只对发生变化且结果被需要的分支更新。
- 为什么频繁读世界包围盒也可能贵? 查询需要最新 World 结果和矩形,可能触发或迫使同步计算,并包含 JS 遍历。
- 如何缩小动画的 dirty 范围? 把需要动画的内容放入专门容器,避免其成为大量无关节点的祖先,并减少布局与动画同时改同一树。
能力验收
你应该能手算两级父子节点的 World Matrix 关系,解释父节点为什么使后代缓存失效,并通过改变树深、节点数和读取频率区分写入成本、传播范围与同步读取成本。
本课总结
text
业务操作 Transform。
引擎用 Dirty 标记待更新状态。
父子矩阵传播决定最终世界位置。
Renderer 在需要时取得最新矩阵。
矩阵更新和 GPU 提交是两个不同阶段。十一、下一课预告
text
Lesson039|Local、World、View、Screen 坐标如何转换?