add_to_group("enemies") 之后 get_tree().get_nodes_in_group("enemies") 就能拿到所有敌人,比递归 find_children 干净得多。但组的实现藏着几个反直觉的细节:顺序是不稳定的、组在场景切换时默认会丢、而且 add_to_group 一次要写两张表。翻 scene/main/node.cpp 和 scene/main/scene_tree.cpp 看实现。
存储:节点侧和树侧各一份
组的存储是双向索引,两边都存(node.cpp,有删减):
void Node::add_to_group(const StringName &p_identifier, bool p_persistent) {
ERR_FAIL_COND(!is_inside_tree());
GroupData gd;
gd.persistent = p_persistent;
if (data.grouped.has(p_identifier)) {
return; // 已在组里
}
data.grouped.insert(p_identifier, gd); // ① 节点自己记:我加入了哪些组
GroupInfo gi;
gi.group = p_identifier;
SceneTree::get_singleton()->add_to_group(this, gi); // ② 树侧登记
}
节点侧的 data.grouped 是 HashMap<StringName, GroupData>,值里只有一个 persistent 标志。树侧 SceneTree::add_to_group 把它塞进 SceneTree::data.group_map:HashMap<StringName, Group>,其中 Group 内部是一组 HashSet<Node *>。
为什么需要两份?方向不同。节点收到 NOTIFICATION_EXIT_TREE 时要把自己从所有组里摘掉,这个问题是"这个节点在哪些组里"——查节点侧的 data.grouped 是 O(自己的组数);而 get_nodes_in_group("enemies") 的问题是"这个组里有哪些节点"——查树侧的 group_map 是 O(1) 定位加 O(组大小) 遍历。双写换来两个方向的查询都便宜,退出树时 Node::_propagate_exit_tree 正是靠节点侧的表做反向清理。
persistent 标志的意义藏在序列化里:persistent = true 的组成员关系会被写进场景文件(tscn 里节点的 groups=["a", "b"] 字段),重载后自动恢复;默认的 false 是纯运行时状态,场景一换全没了。所以"切换场景后 autoload 里的组还在、场景节点上的组没了"不是 bug,是持久化语义的直接结果。
查询:快,但顺序不保证
SceneTree::get_nodes_in_group 的实现就是一次哈希查找加集合拷贝:
TypedArray<Node> SceneTree::get_nodes_in_group(const StringName &p_group) const {
TypedArray<Node> ret;
const Group *g = data.group_map.getptr(p_group);
if (!g) return ret;
for (const Node *E : *g) {
ret.append(const_cast<Node *>(E));
}
return ret;
}
get_first_node_in_group 更直白——取 HashSet 的第一个元素。问题来了:HashSet 的迭代顺序由哈希值和桶布局决定,插入顺序、节点名、树位置一概不影响它。今天第一个元素是 A,明天往组里加了 B,A 可能就不再是第一个。我踩过一次:用 get_first_node_in_group("spawn_points") 当默认出生点,某次改动后出生点悄悄换了一个,排查半天才想起来是哈希表 rehash。
需要稳定顺序时,正确做法是把节点另存数组自己维护,组只当"快速筛选"用。或者给节点加个 priority 属性,遍历组内成员排序后取最大。
组调用 call_group("enemies", "take_damage", 10) 走的也是同一张表:定位组,逐个 Object::call。它对组内每个节点是一次完整的脚本方法调用,组大、方法重时这一帧的开销就是 $n$ 次脚本调用的总和,没有批处理魔法。和信号广播对比:信号是节点自己持有接收者列表,call_group 是全局注册表查表——前者适合"一对多但范围明确",后者适合"跨场景按标签找人"。
成本账
把组操作的开销按量级排一下($g$ 为单节点组数,$s$ 为组大小):
| 操作 | 成本 | 说明 |
|---|---|---|
add_to_group / remove_from_group |
$O(1)$ | 两侧各一次哈希插入/删除 |
is_in_group |
$O(1)$ | 查节点侧 data.grouped |
get_nodes_in_group |
$O(s)$ | 哈希定位 + 遍历拷贝 |
call_group |
$O(s \cdot c)$ | $c$ 为单次脚本调用成本 |
| 节点退出树 | $O(g)$ | 按节点侧表反向清树侧 |
内存上是 $O(\text{节点数} \times \text{平均组数})$ 的两倍冗余——同一个成员关系在节点侧和树侧各存一份引用。这个冗余买的就是"双方向 $O(1)$",和数据库里的二级索引是同一个思路。
什么时候不该用组
组解决的是"运行时按标签找节点",有几个场景它有更合适的替代:
- 编辑器里标记用 unique_name(
%Name)或节点元数据,组设计给运行时遍历,编辑器筛选它也行但不如前者直接。 - 父子强关系不要塞进组。组里装的是"碰巧满足某条件的任意节点",父子关系变化(重挂、释放)组能自动跟上,但业务上的"归属"用 owner/引用表达更清晰。我见过把 UI 血条注册进 "hud_elements" 组的工程,实际想要的只是
get_parent().get_node("HUD"),组在这里是绕路。 - 每帧遍历大组要掂量。
get_nodes_in_group每帧调、组里上千节点、还要逐个转型判空的话,一帧的 CPU 就交代在这儿了。这种场景该在注册时就把节点分桶存进数组,组只留作低频管理入口。
本文代码引自 Godot 4.2.2-stable 的 scene/main/node.cpp、scene/main/scene_tree.cpp,均有删减。