编辑器里右键节点勾 "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 编码的是身份,位置随便挪。对会被关卡设计师反复调整的场景,% 省掉的维护成本是真实的;对结构稳定的工具代码,路径更显式,还能在文件里全文搜索定位。
本文代码引自 Godot 4.2.2-stable 的 scene/main/node.h、scene/main/node.cpp,均有删减;查找分支参考官方 issue #66536 中贴出的 4.x 实现。