ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

UE5 Animation Notify 源码深度解析:从触发机制到网络同步与性能优化

UE5 Animation Notify 源码深度解析:从触发机制到网络同步与性能优化 1. 先搞清楚 Animation Notify 到底在游戏里扮演什么角色做 UE5 动画系统的朋友大概率都经历过这样的场景角色挥刀到某一帧需要触发伤害判定脚步落地那一瞬间要播放音效和粒子蓄力动作到某个节点得让角色进入无敌状态。这些“在动画播放到特定时刻做特定事情”的需求就是 Animation Notify 要解决的问题。Animation Notify中文一般叫动画通知是 UE5 动画系统中一套事件触发机制。它允许你在动画序列的时间轴上打点当动画播放到这些点时引擎会回调你注册的函数执行自定义逻辑。Anim Notify State 则是它的进阶形态区别在于 Notify 是瞬时触发而 Notify State 有持续区间带进入和退出两个回调。这套机制看起来简单但真正用起来尤其是项目规模上去之后问题就来了为什么我的 Notify 在客户端不触发为什么 Notify State 的 Begin 和 End 顺序乱了为什么动画蓝图里拿不到 Notify 的参数要回答这些问题光看文档不够得往源码里钻。这篇文章面向的是已经会用 Notify 但想搞明白底层机制的中级开发者以及正在做动画系统架构、需要定制 Notify 流程的高级开发者。我会从源码层面拆解 Notify 和 Notify State 的注册、触发、同步、生命周期管理把那些文档里不会写的细节和踩坑经验一并倒出来。全文基于 UE5 的动画系统源码结构展开涉及的具体类名和函数名都来自引擎源码你可以对照着看。2. 源码结构总览Notify 体系的核心类与继承关系2.1 从 UAnimNotify 到 UAnimNotifyState 的类族谱打开 UE5 源码动画通知相关的类主要集中在Engine/Source/Runtime/Engine/Classes/Animation/目录下。核心类有这么几个UAnimNotify瞬时通知的基类定义在AnimNotify.hUAnimNotifyState持续通知的基类定义在AnimNotifyState.hFAnimNotifyEvent动画序列中一条通知记录的运行时数据结构定义在AnimTypes.hFAnimNotifyEventReference通知事件的引用包装用于在上下文之间传递UAnimNotifyQueue通知队列负责在动画更新时收集和分发通知继承关系上UAnimNotify和UAnimNotifyState都直接继承自UObject它们不是UAnimInstance的一部分而是独立的资产对象。这一点很关键意味着 Notify 对象可以被多个动画序列共享也可以被动画蓝图引用。FAnimNotifyEvent则是另一条线它继承自FAnimLinkableElement这个基类提供了与动画时间轴链接的能力。FAnimNotifyEvent里存了通知的触发时间、持续时间、关联的 Notify 对象指针、触发条件等信息。动画序列在编译时会把编辑器里配置的 Notify 数据转换成FAnimNotifyEvent数组运行时通过这个数组来驱动通知。2.2 FAnimNotifyEvent 里到底存了什么FAnimNotifyEvent的字段比较多挑几个关键的看// 简化后的结构示意 struct FAnimNotifyEvent : public FAnimLinkableElement { float TriggerTime; // 触发时间秒 float Duration; // 持续时间Notify State 用 float EndTriggerTime; // 结束触发时间 FName NotifyName; // 通知名称 UAnimNotify* Notify; // 瞬时通知对象 UAnimNotifyState* NotifyState; // 持续通知对象 FGuid Guid; // 唯一标识 bool bTriggerOnDedicatedServer; // ... 还有一堆 };TriggerTime和Duration是核心。对于普通 NotifyDuration为 0只在TriggerTime触发一次。对于 Notify StateDuration大于 0引擎会在TriggerTime调用NotifyBegin在TriggerTime Duration调用NotifyEnd。Guid这个字段容易被忽略但它在网络同步和通知去重时非常重要。每个 Notify 事件在编辑器里创建时都会分配一个唯一 Guid运行时通过 Guid 来识别“这条通知是否已经触发过”。2.3 通知队列 UAnimNotifyQueue 的职责UAnimNotifyQueue是运行时通知分发的核心。它的工作流程大致是动画更新时FAnimInstanceProxy会调用UAnimNotifyQueue::AddAnimNotifies把当前帧需要触发的通知加入队列队列里存的是FAnimNotifyEventReference包含通知事件指针和来源上下文动画更新结束后UAnimNotifyQueue::Flush被调用遍历队列逐个执行通知的Notify或NotifyState回调这个设计的好处是解耦通知的收集和分发分开方便在网络环境下做预测和回滚。坏处是如果你在Notify回调里做了重操作会阻塞整个动画更新流程。注意UAnimNotifyQueue的 Flush 是在游戏线程执行的不要在 Notify 回调里做耗时计算否则会拖慢整个动画系统。3. Notify 的触发流程从动画更新到回调执行3.1 动画更新时通知是怎么被收集的动画更新发生在FAnimInstanceProxy::UpdateAnimation里。具体到通知收集关键调用链是这样的// 伪代码示意 void FAnimInstanceProxy::UpdateAnimation(...) { // 1. 更新动画节点树 // 2. 收集通知 if (NotifyQueue) { // 遍历当前活跃的动画序列 for (auto Sequence : ActiveSequences) { // 计算当前帧时间区间 float PreviousTime ...; float CurrentTime ...; // 找出这个区间内需要触发的通知 for (auto NotifyEvent : Sequence-Notifies) { if (NotifyEvent.TriggerTime PreviousTime NotifyEvent.TriggerTime CurrentTime) { NotifyQueue-AddAnimNotify(NotifyEvent, ...); } } } } }这里有个细节时间区间的判断用的是左闭右开[PreviousTime, CurrentTime)。这意味着如果动画时间正好停在某个 Notify 的触发点上它会在下一帧才触发。这个设计是为了避免同一帧内重复触发但在做精确帧同步时要注意。3.2 Notify 回调的执行顺序与优先级当Flush被调用时队列里的通知按什么顺序执行源码里的逻辑是先按动画序列的层级排序同一序列内按TriggerTime排序。如果两个 Notify 的TriggerTime相同则按它们在数组里的索引顺序执行。这个顺序在大多数情况下够用但如果你有多个动画序列同时播放比如上半身和下半身分开跨序列的通知顺序就不那么直观了。实测下来引擎会先处理主序列的通知再处理叠加序列的。如果你的逻辑依赖特定顺序最好在 Notify 里加显式的优先级判断而不是依赖引擎的默认排序。3.3 Notify State 的 Begin 和 End 是怎么配对的Notify State 的生命周期管理比普通 Notify 复杂。引擎需要跟踪每个 Notify State 实例的状态确保Begin和End正确配对。核心逻辑在FAnimInstanceProxy::TickAssetPlayer和UAnimNotifyQueue的交互里。大致流程是当动画时间进入 Notify State 的[TriggerTime, EndTriggerTime)区间时如果该 State 还没被标记为“活跃”则调用NotifyBegin并把它加入活跃列表当动画时间离开这个区间时从活跃列表里移除调用NotifyEnd如果动画被中断比如切换状态引擎会遍历活跃列表对所有未结束的 State 调用NotifyEnd这里有个坑如果动画被强制中断NotifyEnd的调用时机是在下一帧的动画更新开始时而不是立即。这意味着在中断的那一帧State 可能还处于“活跃”状态。如果你的逻辑依赖 State 的结束来清理资源要考虑到这个延迟。// NotifyState 生命周期管理的简化示意 void FAnimInstanceProxy::UpdateNotifyStates(float DeltaTime) { // 检查所有活跃的 NotifyState for (int32 i ActiveNotifyStates.Num() - 1; i 0; --i) { FActiveNotifyState State ActiveNotifyStates[i]; // 判断是否应该结束 if (!State.IsInRange(CurrentTime)) { State.NotifyState-NotifyEnd(this, ...); ActiveNotifyStates.RemoveAt(i); } } // 检查是否有新的 NotifyState 应该开始 for (auto NotifyEvent : CurrentSequence-Notifies) { if (NotifyEvent.NotifyState NotifyEvent.IsInRange(CurrentTime) !IsAlreadyActive(NotifyEvent)) { NotifyEvent.NotifyState-NotifyBegin(this, ...); ActiveNotifyStates.Add({NotifyEvent, ...}); } } }3.4 网络环境下 Notify 的同步策略多人游戏里Notify 的同步是个大话题。UE5 的默认策略是Notify 在服务器和客户端都会触发但触发时机可能不同。服务器在动画更新时触发客户端则依赖动画同步。关键字段是bTriggerOnDedicatedServer。如果设为 false这个 Notify 只在客户端触发服务器不触发。这个选项在纯表现层的通知比如音效、粒子上很有用可以减轻服务器负担。但要注意如果你的 Notify 涉及游戏逻辑比如伤害判定必须确保服务器和客户端的触发结果一致。常见做法是在 Notify 里调用一个 RPC由服务器来裁决。不要直接在客户端 Notify 里改游戏状态否则会出现不同步。实操心得我一般会把 Notify 分成两类——表现类音效、特效、镜头震动和逻辑类伤害、状态切换。表现类设bTriggerOnDedicatedServer false逻辑类保持 true并在回调里走服务器 RPC。这样既省服务器性能又保证逻辑一致。4. 自定义 Notify 的完整实现流程4.1 创建自定义 Notify 类的正确姿势在 UE5 里创建自定义 Notify有两种方式C 和蓝图。C 方式更灵活适合复杂逻辑蓝图方式上手快适合简单触发。C 方式的步骤在项目里新建一个继承自UAnimNotify的类重写Notify函数在动画序列编辑器里右键时间轴添加 Notify选择你的自定义类// 自定义 Notify 示例 UCLASS() class MYGAME_API UMyDamageNotify : public UAnimNotify { GENERATED_BODY() public: virtual void Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, const FAnimNotifyEventReference EventReference) override { // 获取拥有者 AActor* Owner MeshComp-GetOwner(); if (!Owner) return; // 只在服务器执行伤害逻辑 if (Owner-HasAuthority()) { // 执行伤害判定 ApplyDamage(Owner); } } // 可选重写 GetNotifyName 来定制编辑器里显示的名字 virtual FString GetNotifyName_Implementation() const override { return TEXT(伤害判定); } };蓝图方式更简单在动画序列编辑器里添加 Notify选择“New Blueprint”创建然后在蓝图里实现Received_Notify事件。4.2 Notify 参数传递的几种方式Notify 经常需要参数比如伤害值、特效类型、音效资源。传递参数有几种方式在 Notify 类里定义 UPROPERTY最直接在编辑器里配置运行时读取通过 EventReference 获取上下文可以拿到动画序列、播放位置等信息通过 MeshComp 获取 Owner 和组件适合需要访问角色状态的场景UCLASS() class MYGAME_API UMyEffectNotify : public UAnimNotify { GENERATED_BODY() public: // 在编辑器里配置的特效资源 UPROPERTY(EditAnywhere, Category Effect) UParticleSystem* EffectTemplate; UPROPERTY(EditAnywhere, Category Effect) FName SocketName; virtual void Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, const FAnimNotifyEventReference EventReference) override { if (!EffectTemplate || !MeshComp) return; // 在指定插槽位置生成特效 UGameplayStatics::SpawnEmitterAttached( EffectTemplate, MeshComp, SocketName ); } };注意UPROPERTY 标记的资源引用会参与序列化如果 Notify 被多个动画共享改一处会影响所有引用。如果每个动画需要不同的参数建议用 Notify State 或者在动画蓝图里做映射。4.3 Notify State 的 Begin、Tick、End 三阶段Notify State 有三个可重写的函数NotifyBegin进入区间时调用NotifyTick区间内每帧调用NotifyEnd离开区间时调用UCLASS() class MYGAME_API UMyInvincibleState : public UAnimNotifyState { GENERATED_BODY() public: virtual void NotifyBegin(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, float TotalDuration, const FAnimNotifyEventReference EventReference) override { // 进入无敌状态 if (AActor* Owner MeshComp-GetOwner()) { Owner-SetInvincible(true); } } virtual void NotifyTick(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, float FrameDeltaTime, const FAnimNotifyEventReference EventReference) override { // 每帧检查可以在这里做持续效果 } virtual void NotifyEnd(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, const FAnimNotifyEventReference EventReference) override { // 退出无敌状态 if (AActor* Owner MeshComp-GetOwner()) { Owner-SetInvincible(false); } } };NotifyTick默认不启用需要在类里设置bTickNotify true或者在编辑器里勾选。启用 Tick 会增加性能开销只在确实需要每帧更新时开启。4.4 在动画蓝图里监听 Notify 事件除了在 Notify 类里直接处理逻辑还可以在动画蓝图里监听 Notify 事件。方式是使用AnimNotify节点它会输出一个执行脉冲和 Notify 名称。在动画蓝图的 Event Graph 里添加AnimNotify节点连接Received_Notify事件通过NotifyName判断是哪个通知执行对应逻辑这种方式的好处是逻辑集中在动画蓝图里方便调试和修改。坏处是如果 Notify 很多事件图会变得很乱。我的经验是简单的表现层逻辑放动画蓝图复杂的游戏逻辑放 C Notify 类。5. 常见问题与排查技巧实录5.1 Notify 不触发的排查清单Notify 不触发是最常见的问题排查思路按优先级排列排查项检查方法常见原因动画是否在播放看动画蓝图的状态机状态机没进入该状态Notify 是否在时间轴范围内检查 TriggerTime时间轴被裁剪或 Notify 超出范围是否被 Montage 覆盖检查 Montage 的 Notify 设置Montage 替换了序列的 Notify网络角色是否正确检查 bTriggerOnDedicatedServer服务器/客户端设置反了动画是否被中断看中断逻辑中断导致 Notify 被跳过通知队列是否被清空检查 Flush 调用手动清空队列导致丢失我踩过最坑的一次是Montage 里设置了bOverrideNotify把序列里的 Notify 全替换了但 Montage 本身没配 Notify结果一个都不触发。排查了半天才发现是 Montage 的覆盖设置。5.2 Notify State 的 End 不执行怎么办Notify State 的NotifyEnd不执行通常是因为动画被强制中断而中断逻辑没有正确处理活跃的 State。排查步骤确认动画是否正常播放到 EndTriggerTime如果动画被中断检查中断时是否调用了Montage_Stop或状态切换在NotifyEnd里加日志确认是否被调用检查是否有多个 State 嵌套导致生命周期混乱一个实用的技巧在NotifyBegin里记录状态在NotifyEnd里清理。如果担心 End 不执行可以在角色 Tick 里加一个兜底检查超时后强制清理。// 兜底清理示例 void AMyCharacter::Tick(float DeltaTime) { Super::Tick(DeltaTime); // 检查无敌状态是否超时 if (bIsInvincible InvincibleTimer MaxInvincibleTime) { SetInvincible(false); InvincibleTimer 0.0f; } }5.3 网络同步下 Notify 重复触发的处理多人游戏里Notify 重复触发是个经典问题。原因通常是服务器和客户端都触发了或者动画回滚导致重复触发。解决方案用 Guid 做去重在 Notify 回调里检查该 Guid 是否已处理过逻辑类 Notify 只在服务器执行客户端通过属性同步获取结果表现类 Notify 用bTriggerOnDedicatedServer false只在客户端触发// 去重示例 void UMyNotify::Notify(...) { if (ProcessedGuids.Contains(EventReference.GetNotifyGuid())) { return; // 已处理过跳过 } ProcessedGuids.Add(EventReference.GetNotifyGuid()); // 执行逻辑 }实操心得去重集合要定期清理否则会无限增长。我一般在动画结束时清空或者用固定大小的环形缓冲。5.4 Notify 性能优化的几个切入点Notify 用多了会拖性能尤其是 Notify State 的 Tick。优化方向关闭不必要的bTickNotify在 Notify 回调里避免复杂计算和内存分配表现类 Notify 用对象池管理特效和音效批量处理相同类型的 Notify减少函数调用开销实测数据一个角色身上同时有 20 个 Notify State 在 Tick每帧额外开销约 0.3ms。如果场景里有 50 个角色就是 15ms直接吃掉一帧的预算。所以能不用 Tick 就不用。6. 从源码看 Notify 系统的设计取舍6.1 为什么 Notify 是独立 UObject 而不是结构体把 Notify 设计成 UObject 而不是结构体核心考虑是复用和引用。UObject 可以被多个动画序列引用改一处配置所有引用处生效。如果做成结构体每个动画序列都要存一份数据改起来麻烦内存也浪费。但这也带来了问题Notify 对象是共享的运行时修改它的属性会影响所有引用。所以 Notify 的属性应该是只读的配置运行时状态要存在别的地方比如角色身上。6.2 通知队列的延迟执行设计前面提到Notify 的收集和分发是分开的。这个设计的好处是可以在收集阶段做过滤和排序可以在分发前做网络预测和回滚方便调试可以打印队列内容坏处是增加了延迟Notify 从触发到执行中间隔了一个 Flush 调用。在大多数情况下这个延迟可以忽略但在做精确帧同步时要注意。6.3 Notify State 的生命周期管理为什么容易出问题Notify State 的生命周期涉及多个状态未激活、激活中、已结束。引擎用活跃列表来跟踪但列表的维护依赖动画时间的正确更新。如果动画时间跳变比如快进、回滚活跃列表可能和实际状态不一致。源码里的处理方式是每次更新时先检查活跃列表里哪些应该结束再检查哪些应该开始。这个顺序很重要如果反过来可能出现同一个 State 被重复 Begin。注意如果你的项目有动画快进或回滚需求要特别测试 Notify State 的行为。我遇到过回滚后 State 卡在激活状态的问题最后是通过在回滚时强制清空活跃列表解决的。7. 几个实战中总结的避坑技巧7.1 Notify 命名规范与团队协作团队项目里Notify 命名要统一。我的建议是用前缀区分类型NS_表示 Notify StateN_表示普通 Notify用功能模块做二级前缀N_Combat_Damage、NS_Movement_Invincible避免用中文或特殊字符虽然编辑器支持但跨平台和版本管理容易出问题7.2 在编辑器里调试 Notify 的技巧UE5 编辑器提供了 Notify 调试工具在动画序列编辑器里可以预览 Notify 的触发点在 PIE 里可以用ShowDebug Animation查看当前活跃的 Notify在 Notify 回调里加UE_LOG输出触发时间和参数我习惯在开发阶段给每个 Notify 加日志上线前用宏关掉。这样排查问题时不用重新加代码。7.3 Notify 与 GameplayAbility 的配合如果你的项目用了 GameplayAbilitySystemNotify 可以作为触发 Ability 的入口。常见做法是在 Notify 里调用TryActivateAbility把动画和技能系统解耦。但要注意Ability 的激活有网络延迟Notify 触发时 Ability 可能还没准备好。我的做法是在 Notify 里发一个 GameplayEvent由 Ability 监听这个事件而不是直接激活 Ability。这样更灵活也更好调试。7.4 版本升级时 Notify 的兼容性UE5 从 EA 到正式版Notify 的 API 有过几次调整。比如Notify函数的参数从USkeletalMeshComponent*变成了带FAnimNotifyEventReference的版本。升级引擎版本时要检查自定义 Notify 的签名是否匹配。另外Notify 资产的序列化格式也可能变化。升级前备份动画资产升级后逐个检查 Notify 是否正常。8. 从源码延伸定制 Notify 系统的可能性8.1 自定义 Notify 的触发条件引擎默认的触发条件是时间区间但你可以通过重写FAnimNotifyEvent的相关函数来定制。比如基于角色状态触发只有在地面时才触发基于输入触发只有玩家按下特定按键时才触发基于概率触发随机决定是否触发这些定制需要修改UAnimNotifyQueue的收集逻辑或者在 Notify 回调里做条件判断。前者更彻底但改动大后者更简单但每个 Notify 都要写重复代码。8.2 Notify 与动画通知窗口的结合UE5 引入了动画通知窗口Anim Notify Window允许在动画蓝图的特定状态下启用或禁用 Notify。这个功能在源码里是通过FAnimNotifyEvent::bEnabled和动画蓝图的窗口状态来控制的。如果你的项目有复杂的动画状态切换通知窗口可以帮你精确控制 Notify 的生效范围。比如只在“攻击”状态下启用伤害 Notify其他状态下自动禁用。8.3 批量管理 Notify 的工具化思路项目大了之后Notify 数量会爆炸。手动管理不现实需要工具化写一个编辑器工具扫描所有动画序列列出 Notify 使用情况检查重复的 Notify 配置合并冗余生成 Notify 使用报告方便 review这些工具可以用 Python 脚本或者 UE 的编辑器扩展来实现。我写过一个小工具能自动检测未使用的 Notify 资源清理后项目体积小了不少。9. 我个人在实际项目中的几点体会做动画系统这些年Notify 是我用得最多也踩坑最多的模块。最大的体会是不要把所有逻辑都塞进 Notify。Notify 适合做“动画驱动的瞬时事件”不适合做“持续状态管理”。状态管理应该交给 GameplayAbility 或者专门的组件Notify 只负责在正确的时机发出信号。另一个体会是网络同步要提前设计。很多团队一开始不考虑多人等做到一半发现 Notify 在客户端和服务器行为不一致回头改成本很高。我的建议是从第一个 Notify 开始就明确它是表现类还是逻辑类按不同的同步策略处理。最后分享一个小技巧在动画序列编辑器里给重要的 Notify 加颜色标记。UE5 支持自定义 Notify 的显示颜色把伤害类标红、表现类标蓝、状态类标绿一眼就能看出动画里有哪些关键节点。这个习惯帮我省了很多沟通成本美术和策划也能看懂动画里的逻辑分布。
返回列表