技术博客

Node2D 与 CanvasItem:全局变换的脏标记传播

第 37 / 149 篇 · 2025-04-18 · godot · 场景树渲染源码分析

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 诡异问题时第一个该怀疑这里。

Node2D A B C D E F 改 A 的 transform 实线框立即重算 虚线框 = 置脏标记 读取时才合成全局矩阵 脏标记沿子树向下传播,写入 O(子树),读取命中缓存 O(1)

本文代码引自 Godot 4.2.2-stable 的 scene/main/canvas_item.h、scene/main/canvas_item.cpp,均有删减。