攻击第 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 只在播放时扫描其引用的序列片段),通用脚步这类高频通知合并成曲线驱动。
求值与分级的完整时序: