Node2D.position = ... 之后,get_global_transform() 立刻能拿到正确结果,中间没有显式的重算步骤。2D 场景动辄几百上千个节点,如果每次改局部变换都全树重算世界矩阵,开销吃不消。scene/main/canvas_item.cpp 的答案是脏标记加惰性求值——写操作只置位,读操作才计算。
两个缓存:local 和 global
CanvasItem 存两份变换(scene/main/canvas_item.h,示意):
class CanvasItem : public Node {
// 局部变换:position/rotation/scale 的矩阵形式,写时立即重算
Transform2D xform;
// 全局变换:惰性计算,读时若脏则先合成
Transform2D global_transform;
// 位标记之一:GLOBAL_TRANSFORM_DIRTY
uint32_t item_index_and_flags = 0;
};
局部变换是写时计算:set_position / set_rotation 这类 setter 当场把新值乘成 xform 矩阵,这个矩阵永远是新的。全局变换是读时计算:set_transform 只把 GLOBAL_TRANSFORM_DIRTY 标志位置上,global_transform 保持旧值,直到有人调 get_global_transform() 才做合成:
// scene/main/canvas_item.cpp(Godot 4.2.2,有删减)
Transform2D CanvasItem::get_global_transform() const {
if (global_transform_dirty) {
// 局部矩阵 × 父节点全局矩阵,链式上溯
global_transform = xform * get_parent_transform();
global_transform_dirty = false;
}
return global_transform;
}
脏标记怎么传播
改一个中间节点的 transform,它的全部后代全局变换都失效了。传播规则是"向下递归置位":_notify_transform() 从当前节点出发,沿 data.children 深度优先遍历,把每个 CanvasItem 子节点的 GLOBAL_TRANSFORM_DIRTY 都置上:
改 transform
→ 自己置位
→ DFS 所有后代 CanvasItem 置位
→ 需要的话通知 Viewport 重排渲染项
为什么是向下置位而不是向上记录深度?因为读的方向是单向的(只有 get_global_transform 读),失效方向就取它的逆。所有可能受影响的后代都被预先标记,读任何一个都必然命中重算路径,正确性不依赖"哪次写改了什么"。
这个设计的成本账值得算一下:写操作 $O(s)$($s$ = 子树规模),读操作命中缓存时 $O(1)$,未命中时 $O(h)$($h$ = 到根的深度,因为要现取父变换)。典型的 2D 游戏每帧只有少量节点动(角色、子弹),它们的子树规模小,置位开销可以忽略;大量静止节点的缓存永远是干净的,全局矩阵连碰都不碰。
什么时候会真坏掉
这个机制有个隐含的约定:子节点不主动监听父节点。如果某个节点在被标记脏之后、读取全局变换之前,父链上的某个祖先又改了 transform,没关系——重算时现取的父变换必然是最新的(父变换自己也走同样的惰性求值,递归向上)。整条链的正确性靠"读时递归求值"兜底,和 CPU cache 的写无效化是一个思路。
真正要注意的是 CanvasItem 之外的父节点类型:如果 CanvasItem 的父节点不是 CanvasItem(直接挂在 Node 或 Node3D 下),get_parent_transform() 拿不到父变换,会回退到单位矩阵或视口 canvas 变换——这种"断层"布局下全局变换永远以断层处为根重新计算,不是 bug,但查 transform 诡异问题时第一个该怀疑这里。
本文代码引自 Godot 4.2.2-stable 的 scene/main/canvas_item.h、scene/main/canvas_item.cpp,均有删减。