写 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 自顶向下,维护的是上下文继承:节点的 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) 之后的完整时序是:
add_child→_add_child_nocheck→ 节点收到NOTIFICATION_PARENTED;- 父节点在树里 →
_propagate_enter_tree:自顶向下,各节点依次_enter_tree; _propagate_ready:自底向上,子节点先_ready,父节点后_ready;每个节点的_ready通知里顺带完成set_process(true)的自动注册;- 下一帧
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,均有删减。