技术博客

AttributeSet 与聚合器:BaseValue/CurrentValue 为什么分开存

第 61 / 100 篇 · 2026-10-04 · ue5 · GAS数值系统源码分析

GAS 的属性系统有一个初看反直觉的设计:FGameplayAttributeData 里明明存了 BaseValue 和 CurrentValue 两个字段,但 CurrentValue 并不是"上次算好缓存的结果"那么简单——它的真身是全部生效 GameplayEffect 的 modifier 在求值通道里分层叠加的表达式。这篇讲这对字段的分工、聚合器的求值结构,以及为什么这个设计是预测同步能收敛的前提。源码在 AttributeSet.h / GameplayEffectAggregator.cpp(UE5.3)。

两本账的分工

BaseValue 和 CurrentValue 对应游戏里两种本质不同的数值变化:

  • BaseValue 是永久账:升级加的血量、装备强化加的攻击力——这类变化写入后长期有效,撤销场景极少;
  • CurrentValue 是即时账:当前血量、被 debuff 削弱的攻击——它被一堆有时限的 GE 持续扰动,GE 到期就要"自动还原"。

如果只有一本账,GE 到期还原就得记录"这个 GE 当初加了多少"然后做减法。减法是数值系统里最先出 bug 的运算:浮点累积误差、应用顺序差异、重复扣除,全在减法路径上爆发。分成两本账之后,GE 的撤销不需要算术——把 GE 从生效列表里移除,重算,账目自动平。CurrentValue 永远是从 BaseValue 出发的导出量,不存在"忘记减"的窗口。

这也直接服务于网络收敛:客户端和服务器应用 GE 的顺序不可能逐帧一致(预测键的确认节奏不同),只要求值是"集合求值"而非"操作序列回放",顺序差异就不会累积成数值差。

聚合器:四个求值通道

FAggregator(每个被修改的属性挂一个)维护的不是值,是 modifier 列表——每条 ActiveGameplayEffect 贡献的 modifier 按操作类型分到四个通道:

$$\text{Current} = \frac{\big(\text{Base} + \sum_i A_i\big)\times\big(1 + \sum_j M_j\big)}{1 + \sum_k D_k}$$

$A_i$ 是 Additive 通道的修正(固定值加减),$M_j$ 是 Multiplicative(百分比,加性堆叠:两个 +50% 是 +100% 不是 +125%),$D_k$ 是 Divide(少见,除法通道)。Override 通道不在公式里——一旦有 Override 生效,它直接替换整个求值结果,其他通道全部短路。

通道顺序是刻意定的:先加后乘。直觉解释:固定值 buff(+10 攻击)应该吃百分比 buff(+20%)的加成,所以加法在乘法内层;如果反过来,百分比 buff 就会被固定值"稀释"掉语义。"加性堆叠"的乘法通道也是同理的取舍:乘法交换结合律让"两个 50% 增益叠成 100%"——规则简单、可预测、顺序无关。想要乘性堆叠(1.5×1.5)的游戏得自己写 Execution(7030 篇)。

求值时机是惰性的:modifier 增删把 aggregator 标 dirty,下次 Evaluate() 才重算。效果执行路径(PostGameplayEffectExecute 拿到的 CurrentValue)是刚算出来的新值;UI 查询走 GetNumericAttributeChecked 同样触发惰性求值。这个惰性是性能关键——一帧应用五个 GE 只做一次重算。

UE5 里聚合器引入了 ModChannel(FAggregatorModChannel):modifier 先归 channel(如 Channel 0 常规、Channel 1 独立计算的某类效果),channel 内按上面的四通道求值,channel 之间再按序合成。这让"护盾先扣、血量后扣"这类分层结算有了解法:护盾 GE 走 channel 1 并设上限钳制,不污染主 channel 的公式。

AttributeSet 的钩子:变更在哪里被拦截

聚合器算出值后要写回 FGameplayAttributeData(CurrentValue 字段,用于复制和 UI 直读),写入路径上有两个 AttributeSet 虚函数:

  • PreAttributeChange(const FGameplayAttribute&, float& NewValue):改前拦截,典型用途是夹取——血量上限变了把当前血量 clamp 到 MaxHealth;
  • PostGameplayEffectExecute(const FGameplayEffectModCallbackData&):一次 GE 的效果结算完成后调用,这里拿得到"这次改动改的是哪个属性、改了多少(Delta)"。扣血到零触发死亡、消耗法力不足打断施法,都挂这里。

注意 PreAttributeChange 的调用频率陷阱:它在每次属性求值写入前都触发,不是"GE 应用时"触发。在里面写重逻辑会拖慢所有求值路径;而且它无法区分"这次修改来自哪个 GE"——需要来源信息的场景必须用 PostGameplayEffectExecute(回调数据里有 Source ASC 和 GE spec)。

复制策略:只同步"必要层"

属性复制由 ASC 的 EGameplayEffectReplicationMode 分档:玩家自己的 ASC 用 Full(GE 详情全量同步,UI 能显示"你身上有几个 buff"),AI 用 Minimal(只同步聚合后的属性值,GE 列表不同步——反正没人看 AI 的 buff 栏)。无论哪档,底层都是 FGameplayAttributeData 的 UPROPERTY 复制(5.x 走 Push Model,属性没脏不发送)。BaseValue 变更是结构性事件(升级),CurrentValue 变更是高频事件(掉血),两本账分开让复制粒度可以分别控制——这也是 Base/Current 分离的第三个理由。

数值管线全景

聚合器求值:GE modifier 按通道分层,Base 出发导出 Current Active GE 列表 GE₁ +5 攻击 GE₂ +50% 攻击 GE₃ −30% 攻击 GE₄ Override=1 到期移除 → 标 dirty 重算即还原,无减法 modifier FAggregator 四通道 ① Base + Σ Add ② × (1 + Σ Mul) ③ ÷ (1 + Σ Div) Override 存在则整链短路 惰性求值:dirty 后下次查询才算 UE5:ModChannel 先分层再合成 FGameplayAttributeData BaseValue 永久账 CurrentValue 即时账 写回时过钩子: PreAttributeChange PostGameplayEffectExecute Push Model 复制 顺序无关性:集合求值而非操作序列回放 → 客户端与服务器顺序不同也收敛 常见坑:PreAttributeChange 每帧多次触发,别在里面写重逻辑或读 GE 来源