技术博客

unique_name_in_owner 与 %NodePath:百分号语法糖背后的查找表

第 29 / 149 篇 · 2025-08-03 · godot · 场景树源码分析NodePath

编辑器里右键节点勾 "Access as Unique Name" 后,脚本里就能写 %Label 代替 get_node("UI/HUD/Label")。这个 % 很容易让人以为只是"写短点",但翻 node.cpp 会发现它是另一套机制:unique name 存在 owner 节点的一张哈希表里,查找根本不走路径。深节点、浅节点、被挪过位置的节点,查询成本一样。这篇把注册、查找、失效三个环节拆开。

注册:写进 owner 的查找表

Node::set_unique_name_in_owner(true) 干的事(node.cpp,有删减):

void Node::set_unique_name_in_owner(bool p_enabled) {
    ERR_FAIL_COND(!is_inside_tree());
    ERR_FAIL_COND(data.owner == nullptr);

    if (p_enabled) {
        data.owner->add_owned_unique_node(data.name, this);   // 登记
    } else {
        data.owner->remove_owned_unique_node(data.name, this);
    }
    data.unique_name_in_owner = p_enabled;
}

存储在 owner 节点上:HashMap<StringName, Node *> Node::data.owned_unique_nodes(node.h 的 Data 结构里)。两个硬性约束写死在入口校验里:必须在树里、必须有 owner。所以刚 new 出来还没 add_child 的节点开不了 unique name,场景根(owner 为 null)也开不了——它自己没有上级 owner 可以登记进。

重复注册会直接报错:"unique node with name ... already exists in owner"。这保证了表内名字唯一,查找时不需要处理歧义。注意唯一性的作用域是一个 owner 一份表:同一个场景实例里 %Player 只有一个,但不同场景实例(不同 owner)各自有各自的 %Player,互不干扰。这正是 %Name 适合"场景内部固定部件"(HUD 的分数标签、角色的武器挂点)而不适合全局单例的原因——它的可见范围天然跟着 owner 边界走。

查找:get_node 里的特判分支

%Label 本质是 get_node("%Label") 的语法糖,GDScript 编译器原样生成 get_node 调用。查找发生在 Node::get_node_or_null 的路径解析循环里(node.cpp,4.x 版本,有删减):

Node *Node::get_node_or_null(const NodePath &p_path) const {
    ...
    Node *current = nullptr;
    Node *root = nullptr;

    for (int i = 0; i < path.size(); i++) {
        const StringName &name = path[i];

        if (i == 0 && name.is_node_unique_name()) {   // % 开头:第一跳特判
            if (current->data.owned_unique_nodes.size()) {
                Node **unique = current->data.owned_unique_nodes.getptr(name);
                ERR_FAIL_NULL_V(unique, nullptr);
                current = *unique;
            } else if (current->data.owner) {
                // 自己没有表,借 owner 的表
                Node **unique = current->data.owner->data.owned_unique_nodes.getptr(name);
                ...
                current = *unique;
            }
            continue;
        }
        // 普通路径段:逐级 _get_child_by_name
        ...
    }
}

这段代码有个容易被漏看的回退逻辑:从节点 A 调 %X 时,先查 A 自己的 owned_unique_nodes(A 是不是某个场景的 owner、手里有表),有表就用表;没表就借 A 的 owner 的表。所以深节点上写 %Label 照样能命中——查找会爬到 owner 边界,不需要调用方站在实例根上。

和普通路径的成本差是数量级的:"UI/HUD/Label" 要逐级走三段 _get_child_by_name(每段是子节点数组的线性/哈希扫描,最坏 $O(\text{兄弟数})$),深度越深越贵;"%Label" 是一次哈希表定位,$O(1)$,成本与深度、宽度都无关。

% 还能混在路径里用:get_node("%Player/Hand/Sword") 第一跳走表,后面照常逐级解析。这给了它一个普通短路径做不到的能力:路径开头不锚定位置,只锚定身份——不管 Player 在场景里被挪到哪个分支下,这行代码都不用改。

失效与边界

几个实测过的坑:

  • 改名即失效。unique name 的键是 data.name 的 StringName,编辑器里给节点改名(set_name)会同步维护表,但运行时用 name = "NewName" 改名字不会重注册。改名后旧的 unique name 还指着它,新名字查不到——运行期动态改名 + unique name 是危险组合。
  • 跨 owner 看不见。在场景 A 里写 %Label,而 Label 属于内嵌的场景实例 B(owner 是 B 的根),A 的查找只到 A 的 owner 表,查不到 B 内部的 unique name。要拿 B 的部件,正常用 $B/Label 或从 B 的引用进。
  • 不进树查不了。注册要求 inside_tree,运行时实例化后、add_child 之前的窗口期 % 查询会失败。在 _init() 里写 %Label 拿引用必然报错,要等 _ready(_ready 已在树里且 owner 链完整)。
  • 序列化是布尔位,不是路径。场景文件里存的是节点的 unique_name_in_owner = true 标志,实例化时引擎按 owner 链重建查找表。所以移动场景文件里的节点不影响 %,改的是名字才会。

和 @onready 变量的分工

@onready var label = %Label 和 @onready var label = $UI/HUD/Label 都是"ready 时缓存引用",区别在解耦程度:路径写法把节点的位置编码进了代码,重构场景树时要回来改脚本;unique name 编码的是身份,位置随便挪。对会被关卡设计师反复调整的场景,% 省掉的维护成本是真实的;对结构稳定的工具代码,路径更显式,还能在文件里全文搜索定位。

Level 根 owner = 自身 Player 实例 owner = Level Label owner = Player 根 Player 根的查找表 owned_unique_nodes: "Label" → Label 节点 %Label 查询 O(1) 哈希命中 不走路径遍历

本文代码引自 Godot 4.2.2-stable 的 scene/main/node.h、scene/main/node.cpp,均有删减;查找分支参考官方 issue #66536 中贴出的 4.x 实现。