GAS 里"眩晕中不能施法""火系技能对水盾无效"这类规则,如果用布尔字段或枚举表达,组合数量会爆炸。GameplayTag 用层级命名的标签统一承载状态描述(State.Debuff.Stun、Weapon.Sword.Fire),而它的实现快得反直觉:一个 tag 是 32 位整数,父子匹配是一次数组遍历整数比较。基于 UE5.3 源码(GameplayTagsManager.cpp、GameplayTagContainer.cpp)拆。
为什么不能用 FName 直接做
最直白的方案是标签存 FName,匹配做字符串比较。问题在两个维度:
- 比较成本:
State.Debuff.Stun.IsParentOf(X)这类查询在网络热路径上每帧成百上千次,字符串前缀比较(哪怕 Name 是驻留指针比较也要做逐段拆分)扛不住; - 内存成本:每个 tag 字符串 20~40 字节,一个 ASC 挂几十个 tag、几百个角色就是 MB 级。
GameplayTag 的答案是全局注册表 + 索引化:所有合法 tag 在启动时注册进 UGameplayTagsManager 的单例,每个 tag 分配一个 FGameplayTagId——实际是注册表数组的下标。FGameplayTag 运行时只有一个 TagId(32 位整数)加缓存的 FName。比较两个 tag 是否相等 = 比较两个整数,成本恒常,与标签名长度无关。
层级关系:编译期固化的父链
State.Debuff.Stun 的层级语义(State 是 State.Debuff.Stun 的祖先)不是在匹配时拆字符串算前缀,而是在 tag 注册时把父链缓存下来:每个 tag 的注册项里存一个显式的父 tag 索引数组 [State.Debuff, State, <根>]。
于是"X 是不是 Y 的祖先"的查询变成:
$$\text{IsParentOf}(X, Y) = \exists\, p \in \text{ParentChain}(Y) : p = X$$
遍历 Y 的父链(深度 3~5)做整数比较,单次查询 10 个整数比较以内结束。FGameplayTagContainer(tag 集合)的 HasTag 查询被拆成两个 fast path:精确匹配走有序数组的二分(container 内的 tag 按 TagId 排序存储),层级匹配走上面的父链遍历。
这套结构对热路径的影响是决定性的:复制系统每帧要扫"这个对象的 tag 容器里有没有 blocking 标签",如果是字符串语义这套系统会在网络同步里拖垮帧率——索引化之后它是纯数组操作,cache 友好。
注册表从哪来:三种来源与启动成本
合法 tag 集合是封闭世界:运行时动态造 tag 是禁项(匹配逻辑依赖注册表的完整性)。三个注册来源:
- ini 配置(
DefaultGameplayTags.ini):项目标签的主力来源,启动时全量加载; - DataTable:标签按行定义,适合策划批量维护;
- Native tag(
UGameplayTagsManager::AddNativeGameplayTag,UE5 起主推):C++ 代码里注册,模块加载时进注册表。对引擎层的通用状态(State.Dead、Event.Montage.Hit)推荐 native——tag 和用它的代码同文件,改名时编译器直接报错,比 ini 里的字符串引用安全得多。
注册顺序的坑:注册表按"先注册先占位"分配 TagId,tag 的整数身份在不同启动配置下可能不同(ini 加载顺序受配置影响)。这意味着 tag 不能跨会话持久化(存存档里的是 tag 名字符串,加载时反查注册表)。FGameplayTagContainer 的网络复制走的也是名字序列化路径,而非直接传整数。
查询机制:TagQuery 与 Count
FGameplayTagQuery 把复杂匹配("有 A 或 B,且没有 C")编译成逆波兰表达式求值栈——查询本身也是数据资产,可以配在 GE 的 ApplicationTagRequirements 里做应用条件。求值是栈机式的,无副作用可缓存。
FGameplayTagCountContainer 是 ASC 上的实际存储:每个 tag 带引用计数(同一个 tag 被多个 GE 各加一次,计数为 2,移除一个减到 1,tag 还在)——这就是"GE 到期 tag 自动消失"的实现,同样是不需要减法的幂等结构。
Loose Tag 与 NetId 的边角
- Loose tag(
AddLooseGameplayTag):不进计数容器的临时 tag,常用于一次性事件标记。它们不复制、不进预测——只该用在纯本地逻辑; - Replicated tag:走容器的属性复制,5.x 里底层是 tag 名字符串序列化(注册表映射),带宽比索引直接传大,但换来跨端注册表不一致时的健壮性。高频变动的 tag(每帧闪变的)别放复制容器,走 SetByCaller 或直接 RPC。