技术博客

动画通知与曲线:AnimNotify 触发管线和驱动逻辑的正确姿势

第 58 / 100 篇 · 2026-07-17 · ue5 · 动画系统游戏逻辑源码分析

攻击第 12 帧出伤害判定框、脚步声踩在落地帧、冲刺动画驱动相机抖动强度——这三类需求共同点是"逻辑要跟动画的时间轴走"。UE 给的两个原语是 AnimNotify(离散事件)和动画曲线(连续标量),它们共享同一条求值管线但在不同的出口消费。用错出口的代价很具体:把伤害判定写进通知,网络对局里就是不同步的;把脚步声写成曲线轮询,又是浪费。基于 UE5.3 源码(Engine/Source/Runtime/Engine/Animation/AnimNotifies)拆机制,再给使用边界。

资产侧:通知不是"动画上挂的回调"

UAnimSequenceBase 持有 Notifies 数组,每个 FAnimNotifyEvent 记录:触发时间(TriggerTime,以序列长度为基准的秒)、关联的 UAnimNotify 对象、轨道名(TrackName)。关键认知:UAnimNotify 是一个独立的 UObject 资产,序列里存的是指向它的引用——同一个"脚步落地"通知对象可以被几百条序列复用,回调代码只有一份。这也是通知能被蓝图子类化(重写 Received_Notify)的机制基础。

事件性的通知有两种形态:

  • UAnimNotify:瞬时触发,一个时间点;
  • UAnimNotifyState:时间窗,对应 Received_NotifyBegin / NotifyTick / NotifyNotifyEnd 三件套——攻击判定框、抓取窗口这类"持续一段时间"的逻辑必须用 State,Begin/End 配对保证了窗口闭合,不会有"按下没抬起"的泄漏。

求值侧:队列 + 分发,不是回调直调

触发不是"时间到了直接调 Received_Notify"。时序在序列求值内部:求值函数对比本次和上次求值的时间窗 $[t_{prev}, t_{cur}]$,落在窗内的通知事件被收集进 FAnimNotifyQueue(SkeletalMeshComponent 上的队列,随组件存在)。这一步是纯收集——只判断"哪些事件的时间点越过了窗口",不执行任何逻辑。

执行发生在 AnimInstance 的更新边界:UAnimInstance::NativeUpdateAnimation 走到 DispatchQueuedAnimEvents,把队列里积累的事件逐个分发到 Notify/NotifyBegin/NotifyTick/NotifyEnd。这个两级设计回答了一个实战问题:为什么通知回调里改骨骼无效——分发时骨骼 pose 已经求值完毕写回组件了,通知回调拿到的窗口是"动画更新之后",此时改 pose 会被下一帧求值覆盖。要做"通知触发时改骨骼"(比如受击瞬间的骨骼偏移),得把逻辑挪到 pose 链路里(AnimGraph 节点或 SkeletalControl),而不是通知回调。

窗口检测的数学形态:事件触发时间 $\tau$ 满足

$$t_{prev} \le \tau < t_{cur} \quad \text{(正向播放)}$$

反向播放是 $\tau \in (t_{cur}, t_{prev}]$。注意左闭右开的方向不对称:正向播放恰好落在 $t_{cur}$ 上的事件会触发,反向则不会——循环播放(Loop)时边界事件漏触发/双触发的 bug 都从这里来。SetNextPositionWithEvents 处理循环回绕,把窗口拆成 $[t_{prev}, L) \cup [0, t_{cur}]$ 两段各扫一遍。

曲线:同一求值的连续出口

动画曲线(Float Curve,挂在序列的 CurveData 里,经 SmartName 索引)走的是另一条路:求值时对当前时间做曲线插值,产出标量进 FBlendedCurve——不是事件,是每帧都在的数组。消费端在 AnimInstance:GetCurveValue(Name) 直接查混合后的曲线值。

两条出口的语义差异决定用途分工:

需求形态 原语 理由
离散一次性(出招判定、脚步、换弹勾子) AnimNotify / State 单次语义明确,窗口配对可靠
连续量(步幅速度、混合权重、IK 强度) 动画曲线 每帧有值,可参与混合求值
持续窗口逻辑 AnimNotifyState Begin/End 配对,无泄漏

网络边界:通知和曲线都不复制

这条最容易踩:AnimNotify 的收集和分发只在本地发生,曲线求值同样只在本地。两端各自播动画,各自在各自的时间轴上触发通知——所以伤害判定写进 Received_Notify 里,网络对局中服务器根本没执行这段代码,判定结果全靠客户端自律。正确的分层:

  • 表现层触发(音效、特效、屏幕震动):通知/曲线直接驱动,两端各自本地播,允许微小时间差;
  • 游戏逻辑判定(伤害、位移、状态切换):通知只做表现触发,逻辑判定走服务器权威路径——通知在客户端触发表现,同时发 RPC 或走 GAS 能力让服务器裁决。UE5 的 UAnimNotify_GameplayEvent 就是把通知翻译成 GameplayEvent 发给 GAS 的桥,保证逻辑入口统一。

Montage 上的通知还有一层特殊处理:FAnimMontageInstance 在 NotifyWindow 跨越时走同一队列,但 Montage 的 section 切换和 jump 会让时间窗跳变——跨 section 的通知依赖 TriggerTime 的连续性,section 边界恰好有通知时要实测一下(jump 会跳过中间窗口,落在被跳过区间内的通知不触发,这是"Montage 里脚步通知偶尔丢"的常见根因)。

性能注记

每帧成本有两块:求值时的窗口扫描(通知数 × 序列数)和 Dispatch 的虚调用。通知数量在几百条序列里累积起来可观——stat anim 里的 Notify Time 一项就是这个。控制手段:通知尽量挂 Montage 而非基础序列(Montage 只在播放时扫描其引用的序列片段),通用脚步这类高频通知合并成曲线驱动。

求值与分级的完整时序:

AnimNotify 管线:窗口扫描 → 队列 → 更新边界统一分发 序列求值 扫描窗口 [t_prev, t_cur) 循环拆两段各扫一遍 FAnimNotifyQueue 只收集不执行 Native + Blueprint 事件 DispatchQueuedAnimEvents 动画更新边界统一分发 此刻改 pose 已无效 曲线出口(连续量) 求值时插值 → FBlendedCurve AnimInstance::GetCurveValue 消费 参与混合权重 / 步幅 / IK 强度 同样不复制:两端各自求值 逻辑分层边界 表现:通知直接驱动(音效/特效) 逻辑:通知只触发表现,判定走服务器权威 桥:UAnimNotify_GameplayEvent → GAS Montage jump 跳过区间内的通知不触发