技术博客

Node 的 _ready 与 _process 调用链源码分析

第 23 / 149 篇 · 2025-03-04 · godot · 场景树源码分析Node

写 Godot 脚本时 _ready 和 _process 是用得最多的两个回调,但它们从 C++ 一路调到 GDScript 的路径并不直观。我翻 4.2.2 的 scene/main/node.cpp 时发现几个反直觉的细节:_ready 是自底向上调的;你根本不写 set_process(true),是引擎替你调的;而"_ready 里能安全拿到子节点"这个常识,在源码里有明确保证。这篇把两条链路分别拆开,最后回答一个更底层的问题:这套通知机制为什么不直接用 C++ 虚函数。

第一条链:add_child 到 _ready

add_child() 本身只做校验和挂接,真正把节点"激活"的是 _set_tree(),它递归调用 _propagate_enter_tree()。这个函数的顺序很关键(4.2.2 node.cpp,有删减):

void Node::_propagate_enter_tree() {
    // 先处理自己:继承 tree、depth、viewport
    if (data.parent) {
        data.tree = data.parent->data.tree;
        data.depth = data.parent->data.depth + 1;
    }
    data.inside_tree = true;

    notification(NOTIFICATION_ENTER_TREE);
    GDVIRTUAL_CALL(_enter_tree);                    // 脚本的 _enter_tree 在这
    emit_signal(SceneStringNames::get_singleton()->tree_entered);

    data.blocked++;
    for (KeyValue &K : data.children) {
        if (!K.value->is_inside_tree()) {
            K.value->_propagate_enter_tree();       // 再递归子节点
        }
    }
    data.blocked--;
}

注意是先 GDVIRTUAL_CALL(_enter_tree) 再递归子节点——_enter_tree 是自顶向下的,父节点先收到。所以 _enter_tree 里不能假设子节点已经 ready。

_ready 走的是另一个函数 _propagate_ready():

void Node::_propagate_ready() {
    data.ready_notified = true;
    data.blocked++;
    for (KeyValue &K : data.children) {
        K.value->_propagate_ready();     // 先递归子节点
    }
    data.blocked--;

    notification(NOTIFICATION_POST_ENTER_TREE);

    if (data.ready_first) {
        data.ready_first = false;
        notification(NOTIFICATION_READY);  // 自己的 _ready 最后调
        emit_signal(SceneStringNames::get_singleton()->ready);
    }
}

顺序完全反过来:先递归完所有子节点,再调自己的 NOTIFICATION_READY。这就是"在 _ready 里可以安全访问子节点"的源码依据——不是约定,是递归顺序保证的。同时 data.ready_first 标志保证每个节点一辈子只收一次 _ready(重新 add_child 回树里不会再触发)。

两个方向的递归顺序画出来是这样的(数字是回调触发次序):

_enter_tree:自顶向下(先父后子) _ready:自底向上(先子后父) 根 ① 子A ② 子B ③ 孙 ④ 根 ④ 子A ② 子B ③ 孙 ① 用途:继承上下文(tree / viewport / depth),父先就位子才能继承 用途:初始化依赖,父可以假设子已全部 ready

为什么两套顺序缺一不可

这两个顺序不是拍脑袋,各自维护一条不变量。

_enter_tree 自顶向下,维护的是上下文继承:节点的 data.tree、所属 viewport、深度,全部从父节点抄下来。父节点不先 enter,子节点就没有可抄的对象——顺序只能是父先。这决定了 _enter_tree 的语义是"我被挂进了哪个环境",而不是"我的零件齐不齐"。

_ready 自底向上,维护的是依赖初始化:组合模式下,父节点的初始化几乎总是依赖子节点——血条 UI 要拿到角色子节点、武器节点要读挂点下的插槽。让子节点先 ready,父节点的 _ready 里任意 get_node("Child") 拿到的都是初始化完毕的对象。如果顺序反过来,每个父节点都得自己写"等子节点 ready"的样板代码,引擎替你把这件每个项目都会写一遍的事做进了递归顺序里。

换个角度看,这是把拓扑排序藏进了遍历方向:场景树本身就是依赖图(父依赖子),后序遍历天然是依赖序。data.ready_first 保证一次性,则是为了避免重复初始化——节点重新入树(比如对象池回收再利用)时 _enter_tree 会重发(环境可能变了),但 _ready 不重发(初始化只做一次)。两条链各自的幂等性需求不同,所以拆成两个通知而不是一个。

NOTIFICATION_READY 里引擎替你做的事

Node::_notification() 处理 NOTIFICATION_READY 的这段代码,是我觉得最"贴心"的一段:

case NOTIFICATION_READY: {
    if (GDVIRTUAL_IS_OVERRIDDEN(_input)) {
        set_process_input(true);
    }
    // ... shortcut_input / unhandled_input 同理

    if (GDVIRTUAL_IS_OVERRIDDEN(_process)) {
        set_process(true);
    }
    if (GDVIRTUAL_IS_OVERRIDDEN(_physics_process)) {
        set_physics_process(true);
    }

    GDVIRTUAL_CALL(_ready);
} break;

GDVIRTUAL_IS_OVERRIDDEN 检查脚本有没有覆写对应虚函数。也就是说:你在脚本里写了 _process,引擎在 _ready 时刻自动帮你 set_process(true);删掉 _process 函数,下帧起这个节点就从处理列表里消失了。 这就是为什么手动调 set_process(false) 后重启节点、_process 又"复活"——重新进树会再走一遍这段自动检测。

这个自动注册的收益用数字说最清楚:一个千节点场景里,如果所有节点不管有没有 _process 都被每帧轮询一次,每次空调用的成本是通知分发加一次 GDVIRTUAL 查询,一千次累加是实打实的帧时间。自动注册把"有没有活干"的判断从每帧一次提前到 ready 时一次,每帧的循环里只剩真正有活干的节点。这是引擎层面替你做的"空节点剪枝"。

第二条链:set_process 到每帧的 _process

set_process(true) 本身不复杂,就是把节点注册进 SceneTree 的处理组:

void Node::set_process(bool p_process) {
    if (data.process == p_process) {
        return;
    }
    if (!is_inside_tree()) {
        data.process = p_process;   // 还没进树就只记标志位
        return;
    }
    if (_is_any_processing()) {
        _remove_from_process_thread_group();
    }
    data.process = p_process;
    if (_is_any_processing()) {
        _add_to_process_thread_group();  // 加进 SceneTree 的 ProcessGroup
    }
}

进树前调的 set_process 只写标志位,真正进处理列表是在 NOTIFICATION_ENTER_TREE 里(_add_to_process_thread_group())。这个设计避免了"节点还没进树就被每帧轮询"。

每帧 SceneTree::process() 调 _process(false),最终落到 _process_group() 的循环(上一篇讲过),对每个开了 process 的节点调 n->notification(NOTIFICATION_PROCESS)。通知进到 Node::_notification():

case NOTIFICATION_PROCESS: {
    GDVIRTUAL_CALL(_process, get_process_delta_time());
} break;

get_process_delta_time() 返回的是 SceneTree::process_time,即本帧的 delta。GDVIRTUAL_CALL 是个宏,展开后先查这个 Object 有没有脚本覆写 _process,有就通过 ScriptInstance 调进 GDScript/C#,没有就直接返回——开销是一次虚表查询,没有字符串比较。顺带一提,can_process_notification() 和 _process_group() 里的 n->is_processing() 是双重过滤:即使节点在列表里,暂停模式下(process_mode 不允许)也会被 can_process() 跳过。

GDVIRTUAL_CALL 是什么

值得多说一句。4.x 把 3.x 的 get_script_instance()->call_multilevel("_process", ...) 换成了 GDVIRTUAL_CALL。旧路径每次调用都要做方法名哈希查找;新机制在 _ready 阶段就把"脚本覆写了哪些虚函数"缓存成一串 bool(GDVIRTUAL_IS_OVERRIDDEN 读的就是它),每帧的 _process 调用直接命中缓存。对一帧要调几千次的回调来说,这个改动省的是实打实的 CPU。

这里有一个值得展开的实现思路:虚函数覆写状态本质上是脚本类的静态属性——同一个脚本的所有实例,覆写集合完全一样。但 GDVIRTUAL 的缓存挂在实例侧(每个 Object 一份 bool 数组),代价是每实例几个字节,换来的是查询时零间接:不用先找到脚本类再查表,一次数组下标就有答案。每帧几千次调用、每次省一层间接寻址,这种"把不变量物化到实例上"的写法在引擎热路径里反复出现,ClassDB 那边缓存 MethodBind* 是同一个套路。

顺带解释一下为什么这套东西不直接用 C++ 虚函数表:Node 的脚本回调目标是 GDScript/C#/GDExtension,语言边界在运行时才确定,C++ 虚表是编译期产物,够不到。通知加 GDVIRTUAL 本质上是手工实现的一张跨语言虚表——通知编号是索引,GDVIRTUAL_IS_OVERRIDDEN 缓存是"这一项是否非空"的加速位。理解了这一点,notification() 里那些看起来绕的正反向分发逻辑就都顺了。

退场:退出树与释放的顺序

对称地看一眼退出侧,能解释"为什么 queue_free 比 free 安全"。remove_child 触发 _propagate_exit_tree():先递归子节点、再调自己的 GDVIRTUAL_CALL(_exit_tree) 和 NOTIFICATION_EXIT_TREE——退出和进入一样是子节点先行,所以 _exit_tree 里访问子节点也是安全的,这一点和直觉(以为是父先退)相反,源码里的循环顺序写得很清楚。

真正的释放在 NOTIFICATION_PREDELETE 里。Node::_notification 处理它时有个硬校验(node.cpp 原文):如果节点还在树里而调用不在主线程,直接 ERR_PRINT("Attempted to free a node that is currently added to the SceneTree from a thread...") 并拒绝释放。这就是"跨线程 free 节点会报错"的出处,queue_free() 安全的原因则是它把释放推迟到帧尾的 _flush_delete_queue(),保证回到主线程且时机干净。另外 PREDELETE 里还会自动把自己从父节点摘除并级联删除所有子节点——只 remove_child 不 free 的节点就成了孤儿(Orphaned Node),退出时引擎会逐个打印出来。

还有一个和 process_mode 联动的细节:set_process_mode() 改变可处理状态时,会向整棵子树传播 NOTIFICATION_PAUSED/UNPAUSED 或 NOTIFICATION_DISABLED/ENABLED(_propagate_process_owner)。游戏里做暂停菜单时,可以在 _notification 里拦这两个通知来停动画、静音道,比轮询 get_tree().paused 干净。

完整时序图

把两条链合起来,一个 add_child(node) 之后的完整时序是:

  1. add_child → _add_child_nocheck → 节点收到 NOTIFICATION_PARENTED;
  2. 父节点在树里 → _propagate_enter_tree:自顶向下,各节点依次 _enter_tree;
  3. _propagate_ready:自底向上,子节点先 _ready,父节点后 _ready;每个节点的 _ready 通知里顺带完成 set_process(true) 的自动注册;
  4. 下一帧 SceneTree::process() → _process_group() → NOTIFICATION_PROCESS → GDVIRTUAL_CALL(_process, delta)。

三个实用推论:

  • _ready 里拿子节点引用是安全的,拿父节点的状态要小心(父的 _ready 还没跑);
  • 想让某个节点不处理,写 set_process(false) 不如直接删掉 _process 函数彻底;
  • 在 _enter_tree 里做跨节点初始化是危险动作,执行顺序和直觉相反。

本文代码引自 Godot 4.2.2-stable 的 scene/main/node.cpp、scene/main/scene_tree.cpp、core/object/object.cpp,均有删减。