load("res://level.tscn").instantiate() 是 Godot 开发里调得最多的函数之一,但它的内部步骤比"反序列化"这个词给人的想象要具体得多:字符串表、变体数组、节点属性表、连接表,四份数据按特定次序消费。翻 scene/resources/packed_scene.cpp 和 scene/resources/scene_state.cpp 把 instantiate 拆开,几个常见疑问(owner 为什么变了、子场景怎么嵌套、实例化为什么这么贵)都有明确答案。
PackedScene 的本质:SceneState
PackedScene 只是个薄壳,真正的数据在 SceneState(scene_state.h 的 NodeData 结构 + 几张平行数组):
// scene/resources/scene_state.h,结构示意
struct NodeData {
StringName name; // 节点名
StringName type; // 类名,如 "Node3D";实例化子场景时为 null
StringName instance; // 子场景索引(实例化 PackedScene 的子场景)
int parent; // 父节点在数组里的下标
int owner; // owner 下标,-1 表示 owner 为 null
Vector<int> groups;
Vector<PropData> properties; // (名字, 值) 对,值是 variant 数组的下标
};
注意这个设计的取舍:节点属性不按名字存字符串,而是存 StringName 下标进名字表、Variant 下标进变体表——反序列化时一次字符串 intern,之后全是整数索引。40 个节点各有一个 "position" 属性,磁盘和内存里都只出现一次 "position"。这就是为什么场景文件改小、加载快的基础:字符串压缩 + 整数索引,和 .tscn 文本格式里 [node name="x" type="y"] 的直观布局是同构的。
instantiate 的五步
PackedScene::instantiate(GenEditState) 转给 SceneState::instantiate(有删减):
Node *SceneState::instantiate(GenEditState p_edit_state) const {
// ① 建对象:type 或脚本,ClassDB::instantiate 或脚本实例
for (int i = 0; i < node_count; i++) {
NodeData &nd = nodes[i];
if (nd.instance >= 0) {
// 子场景:递归实例化,可能带覆盖占位
node = sub_scene_states[nd.instance]->instantiate(p_edit_state);
} else {
node = ClassDB::instantiate(nd.type); // 按类名建
}
node->data.name = nd.name;
created[i] = node;
}
// ② 挂树:按 parent 下标 add_child,children 数组顺序即文件顺序
for (int i = 0; i < node_count; i++) {
if (nodes[i].parent >= 0) {
created[nodes[i].parent]->add_child(created[i], ...);
}
}
// ③ 设 owner:关键一步,后面展开
for (int i = 0; i < node_count; i++) {
if (nodes[i].owner >= 0) {
created[i]->set_owner(created[nodes[i].owner]);
}
}
// ④ 刷属性:position、脚本变量、材质覆盖都在这
for (int i = 0; i < node_count; i++) {
for (PropData &pd : nodes[i].properties) {
created[i]->set(names[pd.name], variants[pd.value]);
}
}
// ⑤ 连信号:connection 表里的 (from, signal, to, method, flags)
for (ConnectionData &cd : connections) {
created[cd.from]->connect(names[cd.signal], callable, flags);
}
return created[0]; // 根节点
}
顺序本身就是在解决依赖:先建对象(①)才能挂树(②),挂完才能设 owner(③)和刷属性(④,属性里可能引用了兄弟节点,比如 $Marker3D 导出的节点引用),信号连接(⑤)放最后是因为连接的目标方法可能依赖属性已就位。任何一步换序都有实际 bug 案例,比如早期版本属性在 owner 之前刷,导致引用检查把场景内节点误判成外部节点。
owner 的重赋值:实例化的隐藏副作用
这条最容易踩坑。在编辑器里手搭场景时,子节点的 owner 是场景根。但 instantiate 时 ③ 干的事是:根节点的 owner 被置为 null,其余节点重新指向实例根。即实例化出来的那棵子树,owner 全部变成实例根自己,和上"层"场景无关。
这个设计的动机是层级组合的封闭性:把 Player.tscn 实例化后塞进 Level.tscn,保存 Level 时引擎只写"这里有个 Player 实例",绝不能把 Player 的内部节点摊平写进 Level——owner 就是这条边界的实现。实例根的 owner 为 null,正是"我是某个外部场景的财产,但我内部不归它管"的标记。
直接后果有两个:运行时往场景实例里 add_child 的节点,owner 默认是自己(没进 owner 链),保存父场景时这些动态节点不会序列化(这正是运行时生成内容想要的行为,但做关卡编辑器类功能要记得手动 set_owner);还有 unique_name 的查找范围跟着 owner 走,%Name 在实例内部解析、实例外部解析规则不同,原因也在这。
编辑态与占位符
p_edit_state 参数是给编辑器用的。正常游戏运行时传 GEN_EDIT_STATE_DISABLED,实例化出的就是纯运行时对象;编辑器里打开场景传 GEN_EDIT_STATE_MAIN/INSTANCE,此时子场景会套上 InstancePlaceholder 或建立"可编辑实例"状态——你在父场景里看到的子场景内容其实是从子场景的 SceneState 现实例化的,修改某个属性会生成一条覆盖记录存在父场景的 NodeData 里,而不是改写子场景文件。这套"实例 + 属性覆盖"模型和 Git 的子模块指针 + 本地补丁是同一个思想:基线数据只有一份,差异就地存放。
性能特征
实例化的成本结构值得心里有数。设场景有 $n$ 个节点,它的开销大约是:
$$ T \approx n \cdot (t_{alloc} + t_{ClassDB}) + m \cdot t_{set} + k \cdot t_{connect} $$
$t_{alloc}$ 是节点对象分配(Godot 节点不走 small-object 池,每个都是一次堆分配),$t_{ClassDB}$ 是按类名建对象的反射查询,$m$ 是属性总数、$k$ 是连接数。实测一个 500 节点的场景首次 instantiate 在 M 系芯片上要 3~5ms——一帧预算的 20%~30%。所以对象池(提前 instantiate 好,用的时候 add_child)在 Godot 里不是微优化,是常规操作;preload 把 PackedScene 留在内存也重要,load() 每次走磁盘 I/O 加完整解析,和 preload 缓存的共享 SceneState 差着数量级。
另外 instantiate 本身不进树——它只是造出对象树。NOTIFICATION_ENTER_TREE、_ready 都要等 add_child 发生才触发,实例根会额外收到一次 NOTIFICATION_SCENE_INSTANTIATED(20)标记"我是场景实例的根"。所以"实例化很贵、挂树很便宜"不对,两者各有成本:挂树要跑整棵子树的 enter_tree/ready 级联。
本文代码引自 Godot 4.2.2-stable 的 scene/resources/packed_scene.cpp、scene/resources/scene_state.h,均有删减。