动画系统¶
UE5 的动画系统是一套从 资源 到 运行时求值 的完整管线:骨骼资源(USkeleton)定义骨架,USkeletalMesh 引用骨架与蒙皮数据,UAnimSequence 存储关键帧,UAnimInstance 与动画蓝图在运行时把多个动画源混合成最终姿势,最后由 USkeletalMeshComponent 驱动蒙皮渲染
1 动画系统概览¶
核心组成:
| 层次 | 类型 | 职责 |
|---|---|---|
| 骨骼 | USkeleton |
定义骨骼层级与命名,是动画的共享契约 |
| 网格 | USkeletalMesh |
蒙皮网格,引用 USkeleton |
| 动画数据 | UAnimSequence / UAnimMontage / UBlendSpace |
关键帧、蒙太奇、混合空间 |
| 运行时 | UAnimInstance / FAnimInstanceProxy |
每帧求值动画图 |
| 求值框架 | FAnimNode_Base |
动画图的节点 |
| 组件 | USkeletalMeshComponent |
驱动求值并更新骨骼矩阵 |
2 核心资产体系¶
2.1 USkeleton¶
USkeleton 定义骨骼层级(名称 + 父子关系 + 参考姿势),本身不含几何数据,是所有动画资源的共享契约。动画序列只存"骨骼名 → 关键帧",运行时再映射到具体网格
2.2 USkeletalMesh¶
USkeletalMesh 是蒙皮网格,引用一个 USkeleton,保存顶点、蒙皮权重与材质。同一个骨架可以被多个网格复用
2.3 动画资产¶
| 资产 | 类型 | 用途 |
|---|---|---|
| 动画序列 | UAnimSequence |
单条关键帧动画 |
| 蒙太奇 | UAnimMontage |
可分段的组合动画(攻击、受击) |
| 混合空间 | UBlendSpace / UBlendSpace1D |
按参数插值多条动画(站立→走→跑) |
| 叠加空间 | UAimOffsetBlendSpace |
叠加型瞄准偏移 |
| 复合动画 | UAnimComposite |
按时间串联多条序列 |
3 AnimInstance:运行时宿主¶
UAnimInstance 是动画的运行时宿主,定义于 Engine/Source/Runtime/Engine/Classes/Animation/AnimInstance.h。它依附于 USkeletalMeshComponent(注意 Within 限定符):
// AnimInstance.h(简化)
UCLASS(transient, Blueprintable, BlueprintType, Within = SkeletalMeshComponent)
class ENGINE_API UAnimInstance : public UObject
{
GENERATED_BODY()
public:
// 每帧调用,C++ 侧更新逻辑(读输入、算参数)
virtual void NativeUpdateAnimation(float DeltaSeconds) {}
// 生命周期回调
virtual void NativeInitializeAnimation() {}
virtual void NativeBeginPlay() {}
// 蓝图可重写的事件
UFUNCTION(BlueprintImplementableEvent, meta = (DisplayName = "Blueprint Update Animation"))
void BlueprintUpdateAnimation(float DeltaTimeX);
// 蒙太奇接口
UFUNCTION(BlueprintCallable, Category = "Animation|Montage")
float Montage_Play(UAnimMontage* MontageToPlay, float InPlayRate = 1.0f, ...);
UFUNCTION(BlueprintCallable, Category = "Animation|Montage")
void Montage_Stop(float InBlendOutTime, const UAnimMontage* Montage = nullptr);
};
NativeUpdateAnimation 和 BlueprintUpdateAnimation 的关系
切到动画蓝图后,UAnimInstance 会被替换成 UAnimBlueprintGeneratedClass 的实例。引擎每帧调用 NativeUpdateAnimation(C++ 侧),蓝图侧的 BlueprintUpdateAnimation(事件图 Update Animation)也会被调用,两者职责相同、只是语言不同,通常只用其一
4 动画蓝图与 AnimGraph¶
动画蓝图编译后会生成一个 UAnimBlueprintGeneratedClass,其中的动画图(AnimGraph)被编译成一棵 FAnimNode_Base 结构体树,节点以 FStructProperty 的形式存放在生成类里
5 FAnimNode 与 FAnimInstanceProxy:求值核心¶
5.1 FAnimNode_Base¶
所有动画节点继承 FAnimNode_Base,定义于 Engine/Source/Runtime/Engine/Public/Animation/AnimNodeBase.h。它有四个生命周期函数:
// AnimNodeBase.h(简化)
struct ENGINE_API FAnimNode_Base
{
// 1. 初始化(只调用一次)
virtual void Initialize_AnyThread(const FAnimationInitializeContext& Context);
// 2. 缓存骨骼索引(LOD 变化时调用)
virtual void CacheBones_AnyThread(const FAnimationCacheBonesContext& Context);
// 3. 更新:算权重、推进播放时间、决定状态
virtual void Update_AnyThread(const FAnimationUpdateContext& Context);
// 4. 求值:算出最终姿势
virtual void Evaluate_AnyThread(FPoseContext& Output);
};
Update 与 Evaluate 分离的意义
这是 UE 动画最重要的设计之一。Update 负责"逻辑"(时间、权重、状态机切换),频率可以很低;Evaluate 负责"采样混合出姿势",开销大。分离后可以对远处角色只做 Update、跳过 Evaluate,或用 Update Rate Optimization(URO)降低更新频率,大幅节省 CPU
5.2 FAnimInstanceProxy¶
为支持多线程求值,UAnimInstance 被拆成两部分:
| 部分 | 线程 | 职责 |
|---|---|---|
UAnimInstance |
游戏线程 | 蓝图逻辑、参数计算 |
FAnimInstanceProxy |
工作线程 | 保存动画节点树并执行求值 |
// Engine/Source/Runtime/Engine/Public/Animation/AnimInstanceProxy.h(简化)
struct ENGINE_API FAnimInstanceProxy
{
virtual void Initialize(UAnimInstance* InAnimInstance);
virtual void UpdateAnimation(float DeltaSeconds); // 更新节点树
virtual void EvaluateAnimation(FPoseContext& Output); // 求值出姿势
FAnimNode_Base* GetRootNode(); // 根节点
USkeletalMeshComponent* GetSkelMeshComponent() const;
};
6 姿势系统:FCompactPose 与 BoneContainer¶
求值的产物是 FCompactPose(局部空间骨骼变换数组),定义于 Engine/Source/Runtime/Engine/Public/BonePose.h:
// BonePose.h(简化)
struct FCompactPose
{
TArray<FTransform> Bones; // 每个骨骼的局部变换(紧凑索引)
void ResetToRefPose(); // 重置为参考姿势
void NormalizeRotations(); // 归一化旋转,防止累积误差
};
求值上下文 FPoseContext 把姿势与曲线打包在一起:
// AnimInstanceProxy.h(简化)
struct FPoseContext : public FAnimationBaseContext
{
FCompactPose Pose; // 局部空间姿势
FBlendedCurve Curves; // 动画曲线
void ResetToRefPose();
};
紧凑索引(Compact Index) 由 FBoneContainer 维护:动画只需处理当前 LOD 下真正用到的骨骼,FBoneContainer 负责在"骨骼资产索引"与"紧凑索引"之间做映射,避免遍历整棵骨架
7 求值流程¶
一帧动画求值的完整链路:
求值完成后:
为什么分局部空间与组件空间
FCompactPose 用 局部空间(每个骨骼相对父骨骼)存储,混合与插值都在局部空间做,效率最高;但蒙皮需要 组件空间(相对组件原点)的矩阵,所以在渲染前由 FAnimationRuntime::ConvertPoseToComponentSpace 一次性转换
8 状态机与混合¶
8.1 状态机¶
状态机节点 FAnimNode_StateMachine 负责在多个状态间切换:
- State:一个状态,通常引用一条动画
- Transition:状态间的转移,含条件与混合时长
- Blend:切换时用
FAlphaBlend平滑过渡
8.2 混合节点¶
| 节点 | 作用 |
|---|---|
FAnimNode_BlendListByBool |
按布尔切换两个姿势 |
FAnimNode_BlendListByEnum |
按枚举切换多个姿势 |
FAnimNode_LayeredBoneBlend |
按骨骼分层混合(上半身射击 + 下半身跑) |
FAnimNode_BlendSpacePlayer |
播放混合空间 |
FAnimNode_ApplyAdditive |
叠加动画(受击抖动、瞄准偏移) |
FAnimNode_Slot |
播放槽,蒙太奇通过它插入 |
分层混合的原理(FAnimationRuntime::BlendPosesPerBoneFilter)是:对每根骨骼,根据"是否在混合骨骼列表内"决定取哪个姿势,从而让上下半身各自播放不同动画
9 蒙太奇与动画通知¶
9.1 蒙太奇¶
UAnimMontage 定义于 Engine/Source/Runtime/Engine/Classes/Animation/AnimMontage.h,它按 Slot(槽) 组织动画,并支持分段播放:
// AnimMontage.h(简化)
UCLASS(BlueprintType)
class ENGINE_API UAnimMontage : public UAnimCompositeBase
{
UPROPERTY(EditAnywhere, Category = "Montage")
TArray<FSlotAnimationTrack> SlotAnimTracks; // 按槽组织的动画轨道
UPROPERTY(EditAnywhere, Category = "Montage")
float BlendInTime; // 淡入时间
UPROPERTY(EditAnywhere, Category = "Montage")
float BlendOutTime; // 淡出时间
};
播放与分段控制:
// 播放蒙太奇,返回播放时长
float Duration = AnimInstance->Montage_Play(AttackMontage, 1.0f);
// 跳转到指定段落
AnimInstance->Montage_JumpToSection(TEXT("Combo2"), AttackMontage);
// 停止(带淡出时间)
AnimInstance->Montage_Stop(0.2f, AttackMontage);
蒙太奇通过"槽"插入动画图:动画图里的 FAnimNode_Slot 指定槽名,蒙太奇播放时其姿势就从这个槽进入混合树
9.2 动画通知¶
UAnimNotify 与 UAnimNotifyState 定义于 Engine/Source/Runtime/Engine/Classes/Animation/AnimNotifies/AnimNotify.h:
// AnimNotify.h(简化)
UCLASS(abstract, Blueprintable)
class ENGINE_API UAnimNotify : public UObject
{
// 瞬时触发(如播放音效、生成特效)
virtual void Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation,
const FAnimNotifyEventReference& EventReference);
};
UCLASS(abstract, Blueprintable)
class ENGINE_API UAnimNotifyState : public UObject
{
// 区间触发:开始 / 每帧 / 结束
virtual void NotifyBegin(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation,
float TotalDuration, const FAnimNotifyEventReference& EventReference);
virtual void NotifyTick(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation,
float FrameDeltaTime, const FAnimNotifyEventReference& EventReference);
virtual void NotifyEnd(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation,
const FAnimNotifyEventReference& EventReference);
};
Notify 与 NotifyState 怎么选
瞬时事件(放音效、生成粒子、判定命中)用 UAnimNotify;有持续区间的效果(拖尾、无敌帧、持续位移)用 UAnimNotifyState,它会在区间内每帧回调
10 根运动 Root Motion¶
根运动让动画驱动角色位移,而不是靠代码移动。开关在动画序列上:
// AnimSequence.h(简化)
UPROPERTY(EditAnywhere, Category = "Root Motion")
bool bEnableRootMotion; // 是否启用根运动
UPROPERTY(EditAnywhere, Category = "Root Motion")
bool bForceRootLock; // 是否锁定根骨骼
流程是"动画提取 → 组件消费 → 应用到角色":
// SkeletalMeshComponent.h(简化)
FRootMotionMovementParams ConsumeRootMotion(); // 取出并清空累积的根运动
bool HasValidRootMotion() const;
根运动与网络同步的配合
根运动必须在服务器权威下应用:服务器应用真实位移并把结果复制给客户端,客户端做预测与校正。若两端各自提取根运动,容易出现位置不一致
11 性能优化¶
骨骼动画是 CPU 大户,UE 提供了多级优化,其中最重要的两个概念是 LOD 与 URO
11.1 LOD 是什么¶
LOD(Level of Detail,细节层次)指同一个物体准备多份不同精度的版本,按"离相机多远 / 占屏幕多大"切换使用,把算力与显存花在真正看得清的对象上:
LOD 思想在各系统里的体现:
| 系统 | LOD 的表现 |
|---|---|
| 静态网格 | 不同面数的模型 |
| 骨骼网格 | 减少骨骼数量、简化蒙皮、切换材质 |
| 动画 | 降低 Update / Evaluate 频率,甚至跳过求值 |
| 纹理 | Mipmap |
| 材质 | 简化着色 |
骨骼网格 LOD 由 USkeletalMesh::LODInfo 描述,切换依据是"预测屏幕尺寸"——引擎算出该网格在当前相机下占屏幕的比例,落在哪个区间就用哪一级 LOD:
动画的 LOD 由 FAnimUpdateRateParameters 描述,定义于 Engine/Source/Runtime/Engine/Public/Animation/AnimUpdateRateParams.h:
这正是 Update 与 Evaluate 分离 在性能上的直接收益:远处角色可以让 UpdateRate = 4(逻辑 ¼ 频率推进)、EvaluationRate = 4,甚至 bSkipEvaluation = true(根本不重算姿势),开销骤降而视觉几乎无差别
URO 与 LOD 的关系
URO(Update Rate Optimization)是 动画层面的 LOD 策略:它以骨骼网格的 LOD 为基础,为每一级 LOD 配置 FAnimUpdateRateParameters,实现"越远更新越少"。所以 URO 不是独立的优化,而是 LOD 思想在动画上的落地
11.2 其他优化手段¶
| 手段 | 说明 |
|---|---|
| Update Rate Optimization(URO) | 远处角色降低 Update 频率,跳过部分帧 |
| 动画压缩 | UAnimBoneCompressionSettings 压缩关键帧 |
| 曲线压缩 | UAnimCurveCompressionSettings 压缩曲线 |
| Animation Budget Allocator | 插件,统一调度大量角色的更新预算 |
| Significance Manager | 按"重要性"决定更新频率 |
12 AnimNotifyState 与联机判定¶
12.1 单机为什么没问题¶
单机只有一个进程,动画求值与游戏逻辑共享同一条时间线:NotifyBegin 一触发就开碰撞箱,NotifyEnd 就关,判定自然准确
12.2 联机下的问题¶
把"开武器碰撞箱"这种 游戏判定 交给 AnimNotifyState,在联机环境会踩到几类问题:
| 问题 | 说明 |
|---|---|
| 动画是表现,不是同步数据 | 动画求值在各端独立进行,触发时机受帧率、动画 LOD、URO 影响,各端并不一致 |
| 服务器可能不跑动画 | Dedicated Server 无渲染,动画可能被跳过或降频,AnimNotifyState 在服务器根本不触发或不准确 |
| 客户端不可信 | AnimNotifyState 在客户端执行,客户端可改动画速度、直接调用开碰撞箱,形成作弊 |
| 时序不一致 | 客户端本地判定命中,服务器却算不中(或反之),出现"打了没伤害" |
| 重复触发 | 多播或多端各自开碰撞箱,可能产生重复伤害、重复特效 |
| 依赖动画进度 | 触发点取决于动画播放进度,而进度在服务器与客户端因网络更新频率不同而不一致 |
核心矛盾:动画是表现层(cosmetic),判定必须由服务器权威决定,两者不能混为一谈
12.3 更好的解决思路¶
思路一:判定放服务器,动画只做表现
- 服务器用
SweepMultiByChannel/OverlapMultiByChannel做命中检测(由UFUNCTION(Server, Reliable, WithValidation)触发或在服务器直接执行) - 客户端只播表现(特效、音效、拖尾)
- 碰撞箱的开关状态用
UPROPERTY(ReplicatedUsing = OnRep_HitWindow)同步,客户端仅用于表现
思路二:用逻辑时间轴代替动画进度
把攻击拆成前摇 / 命中窗 / 后摇,由服务器的时间轴(FTimerHandle 或组件)推进,不依赖动画播到哪一帧
思路三:用 Gameplay Ability System(GAS)
- 用
UGameplayAbility+UAbilityTask_PlayMontageAndWait驱动动画 - 命中用
WaitGameplayEvent/ GameplayEffect 处理 NetExecutionPolicy设为ServerOnly或LocalPredicted,由 GAS 处理预测与回滚- 这是 UE 官方推荐的技能 / 战斗方案
思路四:让服务器拥有可用的动画时间轴
- 蒙太奇在服务器播放后会复制到客户端(
UAnimInstance复制MontageInstances,FAnimMontageInstance通过SaveNetState/ApplyNetState同步播放位置),但 不要 依赖它的 Notify 做判定 - 若服务器确实需要动画进度(如根运动),确保服务器也
Montage_Play并正常推进
思路五:客户端预测 + 服务器校验
- 客户端为手感做本地预测表现(立即开特效),服务器判定为准
- 服务器结果与预测不符时回滚或纠正(如血量回滚)
- 预测只做表现,不产生权威结果
结论一句话
AnimNotifyState 适合驱动 表现(特效、音效、拖尾、相机震动);一旦涉及 胜负判定(命中、伤害、碰撞箱),就必须移到服务器权威的逻辑层,用可复制的状态、逻辑时间轴或 GAS 来驱动,动画只负责"看起来对"
13 常见陷阱与最佳实践¶
- 骨骼资源(
USkeleton)一旦被大量动画引用,不要 随意改骨骼名或层级,会导致引用失效 NativeUpdateAnimation只做轻量逻辑,重计算提前缓存- 动画蓝图里避免每帧
GetAllActorsOfClass之类的重操作 - 大量角色用 URO 与 Animation Budget Allocator 控制开销
- 根运动要么全用、要么全不用,混用容易位置错乱
- 蒙太奇分段用
Montage_JumpToSection,不要手动改播放位置 AnimNotify里不要做阻塞操作,它在动画求值路径上被调用