技术博客

组(Group)系统:SceneTree 里的组哈希表

第 27 / 149 篇 · 2026-09-25 · godot · 场景树源码分析数据结构

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 就交代在这儿了。这种场景该在注册时就把节点分桶存进数组,组只留作低频管理入口。

节点侧 data.grouped enemies 持久位 pickups 持久位 ... 反向清理用:退出树时逐个摘组 树侧 group_map enemies HashSet pickups HashSet ... 正向查询用:按组名 O(1) 定位 add_to_group 双写

本文代码引自 Godot 4.2.2-stable 的 scene/main/node.cpp、scene/main/scene_tree.cpp,均有删减。