技术博客

PackedScene::instantiate:场景实例化的完整链路

第 28 / 149 篇 · 2025-03-29 · godot · 场景树序列化源码分析

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 级联。

SceneState names 表 variants 数组 nodes / conns 1 建对象 2 挂树 3 设 owner 4 刷属性 5 连信号 活节点树(未进树) 子场景在 1 里递归; 顺序即依赖序 add_child 后才触发 enter_tree / ready

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