Appearance
动画系统
Animator Controller 是什么?
一句话
Animator Controller 是 Unity 里的动画状态机资源,它决定一个角色什么时候播放 Idle、Run、Jump、Attack,以及这些动画之间怎么切换。
你可以先这样记:
c
Animation Clip:一段动作
Animator Controller:管理动作切换的状态机
Animator 组件:挂在角色身上的动画播放器
角色模型:最终被驱动起来的骨骼/网格零基础比喻
假设你在拍动画片。
Animation Clip 就像一段段已经拍好的动作视频:
c
站立 Idle
跑步 Run
跳跃 Jump
攻击 Attack
死亡 DieAnimator Controller 就像导演手里的流程表:
c
没按键 → 播放站立
按方向键 → 切到跑步
按空格 → 切到跳跃
按攻击键 → 切到攻击
血量为 0 → 切到死亡Animator 组件就像播放器。
它挂在角色身上,读取 Animator Controller,然后让角色模型动起来。
它在 Unity 里长什么样?
通常角色身上有一个 Animator 组件:
c
Player
- Transform
- CharacterController / Rigidbody
- Animator
- Controller: PlayerAnimatorController
- Avatar: PlayerAvatar
- Apply Root Motion: true/falseAnimator Controller 是一个资源文件,一般在 Project 面板里:
c
Player.controller
Enemy.controller
Boss.controller你双击它,会打开 Animator 窗口,里面有很多状态节点:
c
Entry
Idle
Run
Jump
Attack
Die
Any State
Exit它里面有什么?
1. State 状态
状态就是“现在正在播放哪种动画”。
比如:
Idle 状态:播放 Idle 动画
Run 状态:播放 Run 动画
Jump 状态:播放 Jump 动画
Attack 状态:播放 Attack 动画每个状态通常绑定一个 Animation Clip。
2. Transition 过渡
Transition 是状态之间的箭头。
比如:
c
Idle → Run
Run → Idle
Any State → Attack
Jump → Idle它表示动画可以从一个状态切到另一个状态。
例如:
c
当 Speed > 0.1 时:Idle → Run
当 Speed <= 0.1 时:Run → Idle
当 Attack Trigger 被触发时:Any State → Attack3. Parameters 参数
参数是脚本和 Animator Controller 沟通用的变量。
常见参数类型:
c
Float:小数,比如 Speed
Int:整数,比如 WeaponType
Bool:真假,比如 IsGrounded
Trigger:一次性触发,比如 Attack、Jump脚本通过这些参数告诉动画机:
我现在速度是多少
我是不是在地上
我是不是按了攻击键
我是不是死了最简单代码例子:跑步动画
假设 Animator Controller 里有一个 float 参数:
c
Speed脚本这样写:
c
using UnityEngine;
public class PlayerAnimation : MonoBehaviour
{
public Animator animator;
public float moveSpeed;
private void Update()
{
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
Vector3 input = new Vector3(h, 0f, v);
float speed = input.magnitude;
animator.SetFloat("Speed", speed);
}
}Animator Controller 里设置:
c
Idle → Run 条件:Speed > 0.1
Run → Idle 条件:Speed <= 0.1效果:
c
不按方向键:Speed = 0,播放 Idle
按方向键:Speed > 0.1,播放 Run攻击动画例子:Trigger
攻击一般用 Trigger,因为攻击是“一下”。
Animator Controller 里建一个参数:
c
Attack,类型 Trigger脚本:
c
using UnityEngine;
public class PlayerAttack : MonoBehaviour
{
public Animator animator;
private void Update()
{
if (Input.GetMouseButtonDown(0))
{
animator.SetTrigger("Attack");
}
}
}Animator 里设置:
Any State → Attack
条件:Attack Trigger为什么攻击适合用 Trigger?
因为攻击不是一直为 true。
按一下 → 播放一次攻击动画而不是:
按一下后永远保持攻击状态跳跃动画例子
可以同时用 Trigger 和 Bool。
参数:
c
Jump:Trigger
IsGrounded:Bool代码:
c
using UnityEngine;
public class PlayerJumpAnimation : MonoBehaviour
{
public Animator animator;
public bool isGrounded;
private void Update()
{
animator.SetBool("IsGrounded", isGrounded);
if (isGrounded && Input.GetKeyDown(KeyCode.Space))
{
animator.SetTrigger("Jump");
}
}
}Animator 里可以这样设计:
c
Any State → Jump
条件:Jump
Jump → Idle
条件:IsGrounded == trueAnimation Clip、Animator Controller、Animator 的区别
这是面试很容易问的。
| 名称 | 是什么 | 作用 |
|---|---|---|
Animation Clip | 动画片段 | 记录具体动作数据 |
Animator Controller | 状态机资源 | 管理动画状态和切换 |
Animator | 组件 | 挂在对象上,真正播放动画 |
Avatar | 骨骼映射 | 告诉 Unity 人形骨骼怎么对应 |
举例:
c
Run.anim 是动作本身
Player.controller 决定什么时候播放 Run
Animator 组件挂在 Player 上执行它
Avatar 让 Humanoid 骨骼能正确动起来常见参数怎么选?
移动速度用 Float
c
animator.SetFloat("Speed", speed);适合:
c
Idle / Walk / Run尤其配合 Blend Tree。
是否在地面用 Bool
c
animator.SetBool("IsGrounded", isGrounded);适合:
是否在地面
是否死亡
是否举盾
是否锁定敌人攻击、跳跃用 Trigger
c
animator.SetTrigger("Attack");适合:
攻击一次
受击一次
翻滚一次
跳跃一次武器类型用 Int
c
animator.SetInteger("WeaponType", 1);适合:
0:空手
1:剑
2:枪
3:弓什么是 Blend Tree?
如果只有两个状态:
c
Idle
Run还好。
但如果你有:
c
Idle
Walk
Run并且希望速度越快动画越接近 Run,就可以用 Blend Tree。
比如一个 Speed 参数控制:
c
Speed = 0 → Idle
Speed = 0.5 → Walk
Speed = 1 → Run代码还是:
c
animator.SetFloat("Speed", speed);Animator Controller 里由 Blend Tree 自动混合动作。
零基础理解:
c
Blend Tree 就是把多个动画按参数混合起来。什么是 Layer?
Animator Controller 可以有多个 Layer。
比如:
c
Base Layer:控制全身移动,Idle / Run / Jump
UpperBody Layer:只控制上半身,Attack / Shoot / Reload这样角色可以:
下半身跑步
上半身开枪这通常要配合:
c
Avatar MaskAvatar Mask 可以指定某个动画层只影响身体的一部分。
什么是 Any State?
Any State 表示“从任何状态都可以切过去”。
比如受击:
Idle 可以受击
Run 可以受击
Jump 可以受击
Attack 也可以受击那么就可以做:
Any State → Hit
条件:Hit Trigger再比如死亡:
Any State → Die
条件:IsDead == true注意:
Any State 很方便,但不要滥用。如果所有动画都从 Any State 切,会让状态机很乱。
Has Exit Time 是什么?
这是新手最容易被坑的地方。
Has Exit Time 的意思是:
当前动画要不要播放到一定比例后,才允许切到下一个动画。比如攻击动画:
Attack 动画至少播放到 80%,才允许回到 Idle这样攻击不会刚开始就被打断。
但是如果移动动画也开了 Has Exit Time,可能会导致:
我明明按了移动,但 Idle 要等播放完才切 Run
感觉有延迟所以常见做法:
移动类动画:通常关闭 Has Exit Time,响应更快
攻击/翻滚/受击:常开启 Has Exit Time,保证动作完整Apply Root Motion 是什么?
Apply Root Motion 决定角色位移由谁控制。
关闭时:
脚本控制角色移动
动画只负责摆动作开启时:
动画里的根节点位移会推动角色比如翻滚动画本身往前滚了一段距离,如果开启 Root Motion,角色会真的跟着动画往前移动。
常见选择:
普通角色移动:脚本控制,Root Motion 关闭
高质量动作游戏:部分动作使用 Root Motion,比如翻滚、处决、攻击位移性能和代码小技巧:StringToHash
新手常写:
c
animator.SetFloat("Speed", speed);
animator.SetTrigger("Attack");这可以用,但项目里为了减少字符串查找和拼写错误,常写成:
c
using UnityEngine;
public class PlayerAnimatorHashes : MonoBehaviour
{
public Animator animator;
private static readonly int SpeedHash = Animator.StringToHash("Speed");
private static readonly int AttackHash = Animator.StringToHash("Attack");
private void Update()
{
animator.SetFloat(SpeedHash, 1f);
if (Input.GetMouseButtonDown(0))
{
animator.SetTrigger(AttackHash);
}
}
}好处:
减少字符串反复查找
避免到处写魔法字符串
参数名改动时更容易集中维护常见坑
坑 1:Animator 组件没有挂 Controller
现象:
角色不播放动画检查:
Animator 组件的 Controller 是否为空坑 2:参数名字写错
Animator 参数叫:
c
IsGrounded代码写成:
c
animator.SetBool("isGrounded", true);大小写不同,也会出问题。
坑 3:Trigger 没有被 Transition 使用
你代码里写:
c
animator.SetTrigger("Attack");但 Animator 里没有任何 Transition 用 Attack 作为条件。
结果:
触发了也不会切动画坑 4:Has Exit Time 导致切换慢
你按攻击或移动,但动画迟迟不切。
检查:
Transition 上是否勾了 Has Exit Time
Transition Duration 是否太长坑 5:Any State 滥用
太多状态从 Any State 切,会导致:
动画跳来跳去
优先级混乱
攻击被不断打断
状态机越来越难维护坑 6:Root Motion 和脚本移动打架
如果你脚本在移动角色:
c
controller.Move(move * Time.deltaTime);同时 Animator 开了 Root Motion。
可能出现:
角色移动速度不对
动画和碰撞不同步
角色漂移面试高分回答
可以这样说:
NOTE
Animator Controller 是 Unity Mecanim 动画系统中的状态机资源,用来组织 Animation Clip、Blend Tree、Layer、Parameter 和 Transition。Animation Clip 只是一段具体动作,而 Animator Controller 决定当前播放哪个动作,以及在什么条件下切换到其他动作。脚本通常通过 Animator 组件设置 Float、Bool、Int、Trigger 参数,Controller 根据这些参数执行状态切换。项目中我会用 Float 控制移动 Blend Tree,用 Trigger 控制攻击、跳跃、受击这类一次性动作,用 Layer 和 Avatar Mask 实现上下半身分离动画,并注意 Has Exit Time、Root Motion 和参数命名这些常见坑。
最短记忆版:
c
Clip 是动作。
Controller 是状态机。
Animator 是播放器。
Parameter 是脚本给状态机的信号。
Transition 是状态之间的切换规则。
Blend Tree 是多个动画的混合。
Layer 是多层动画叠加。Animation Clip 和 Animator State 区别是什么?
一句话
Animation Clip 是真正的动作数据,Animator State 是 Animator Controller 里的一个状态节点。
你可以先这样记:
Animation Clip:动作素材
Animator State:播放这个动作的状态格子
Animator Controller:管理这些状态怎么切换的状态机
Animator:挂在角色身上的播放器零基础比喻
假设你在做一个角色动画。
Animation Clip 像一段已经录好的动作视频:
Idle.anim:站立动作
Run.anim:跑步动作
Jump.anim:跳跃动作
Attack.anim:攻击动作它里面存的是“动作本身”。
比如:
第 0 秒:手在身体旁边
第 0.2 秒:手抬起来
第 0.5 秒:刀挥出去
第 0.8 秒:手收回来Animator State 像导演流程图里的一个格子:
Idle 状态
Run 状态
Jump 状态
Attack 状态这个状态格子会说:
我现在播放哪个 Animation Clip?
我播放速度是多少?
我能不能切到下一个状态?
什么时候切?最核心区别
| 对比 | Animation Clip | Animator State |
|---|---|---|
| 是什么 | 动画片段资源 | Animator Controller 里的状态节点 |
| 存在哪里 | Project 资源里,常见 .anim 或 FBX 内 | Animator Controller 内部 |
| 负责什么 | 具体动作数据 | 播放、切换、状态逻辑 |
| 能不能单独切换 | 不能直接代表状态流转 | 可以作为状态机的一环 |
| 包含什么 | 关键帧、曲线、动画事件 | Motion、Speed、Transitions、StateMachineBehaviour |
| 类比 | 一段动作视频 | 播放这段视频的状态格子 |
Animation Clip 里面有什么
Animation Clip 记录的是随时间变化的数据。
它可以记录:
骨骼旋转
物体位置
物体缩放
BlendShape
材质参数
动画事件 Animation Event
Root Motion 位移比如一个攻击动画 Clip:
Attack.anim
0.0 秒:角色准备抬手
0.2 秒:武器挥出
0.35 秒:产生攻击判定
0.6 秒:收招所以 Clip 更像“素材”。
它不负责判断:
什么时候攻击?
攻击完切到哪里?
能不能被打断?这些不是 Clip 的职责。
Animator State 里面有什么
Animator State 是 Animator Controller 里的节点。
比如你打开 Animator 窗口,看到的这些方块:
c
Idle
Run
Jump
Attack
Die这些就是 State。
一个 State 里通常会有:
c
Motion:引用的 Animation Clip 或 Blend Tree
Speed:播放速度
Mirror:是否镜像
Transitions:能切到哪些状态
StateMachineBehaviour:进入/退出状态时执行逻辑
Tag:状态标签比如:
c
State 名字:Attack
Motion:Attack.anim
Speed:1.2
Transition:Attack 播完后回到 Idle注意这里很关键:
c
Attack 这个 State 不等于 Attack.anim 这个 ClipState 只是引用了这个 Clip。
为什么要分成 Clip 和 State
因为一个 Clip 可以被多个 State 复用。
比如你有一个动画:
c
Attack.anim你可以做两个 State:
c
LightAttack State:使用 Attack.anim,Speed = 1.2
HeavyAttack State:使用 Attack.anim,Speed = 0.7同一个动作素材,不同状态可以有不同播放速度、不同切换条件、不同逻辑。
反过来,一个 State 也不一定引用单个 Clip,它还可以引用 Blend Tree。
比如:
c
Move State
Motion = Blend Tree这个 Blend Tree 里面混合:
c
Idle.anim
Walk.anim
Run.anim所以:
c
State 的 Motion 可以是 Clip,也可以是 Blend Tree代码里通常操作的是 State 还是 Clip?
大多数时候,脚本不是直接播放 Clip,而是通过参数让 Animator Controller 切 State。
比如 Animator Controller 里有:
c
Idle State
Run State
Attack State参数:
c
Speed
Attack代码:
c
using UnityEngine;
public class PlayerAnim : MonoBehaviour
{
public Animator animator;
private void Update()
{
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
Vector3 input = new Vector3(h, 0f, v);
float speed = input.magnitude;
animator.SetFloat("Speed", speed);
if (Input.GetMouseButtonDown(0))
{
animator.SetTrigger("Attack");
}
}
}Animator Controller 里设置:
c
Idle → Run
条件:Speed > 0.1
Run → Idle
条件:Speed <= 0.1
Any State → Attack
条件:Attack Trigger这里脚本并没有说:
c
播放 Attack.anim而是说:
c
Attack Trigger 触发了然后 Animator Controller 根据 Transition 切到 Attack State。
Attack State 里面再引用 Attack.anim。
Animator.Play 播的是 State 名字
你也可以直接播放状态:
c
animator.Play("Attack");这里的 "Attack" 通常指的是:
c
Animator State 的名字不是 Animation Clip 的文件名。
如果你的状态叫:
c
AttackStateClip 叫:
c
Attack.anim那你要写:
c
animator.Play("AttackState");这也是新手很容易混的地方。
Animation Clip 可以单独存在吗?
可以。
比如 Project 面板里可以有:
c
Idle.anim
Run.anim
Attack.anim但如果你想让角色通过 Animator Controller 正常切换这些动画,你需要把它们放进 State 里。
流程是:
Animation Clip → 放到 Animator State 的 Motion 上 → Animator Controller 控制 State 切换 → Animator 组件执行 → 角色模型动起来State 可以没有 Animation Clip 吗?
可以。
State 的 Motion 可以为空,或者是 Blend Tree。
常见情况:
空状态:作为过渡用
Blend Tree 状态:用多个 Clip 混合移动比如移动状态:
c
Move State
Motion = Locomotion Blend TreeBlend Tree 里:
c
Speed = 0 播 Idle
Speed = 0.5 播 Walk
Speed = 1 播 RunAnimation Event 属于谁?
Animation Event 通常放在 Animation Clip 上。
比如攻击动画打到第 0.3 秒时,触发伤害:
c
public void EnableHitBox()
{
hitBox.SetActive(true);
}这类事件是跟 Clip 时间轴绑定的。
StateMachineBehaviour 属于谁?
StateMachineBehaviour 挂在 Animator State 上。
它可以监听状态进入、更新、退出:
c
using UnityEngine;
public class AttackStateBehaviour : StateMachineBehaviour
{
public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
Debug.Log("进入攻击状态");
}
public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
Debug.Log("退出攻击状态");
}
}所以可以这样理解:
c
Animation Event:Clip 时间轴上的事件
StateMachineBehaviour:State 生命周期事件常见坑
坑 1:把 Clip 名字当 State 名字。
c
animator.Play("Attack");如果 Animator 里的状态不叫 Attack,就可能播放失败。
坑 2:以为 SetTrigger("Attack") 会直接播放 Attack.anim。
其实不是。
它只是设置了一个参数。
必须 Animator Controller 里有 Transition 使用这个 Trigger,才会切到对应 State。
坑 3:修改一个 Clip,会影响所有引用它的 State。
比如:
c
LightAttack State 使用 Attack.anim
HeavyAttack State 也使用 Attack.anim你改了 Attack.anim 的关键帧,两个 State 都会受影响。
坑 4:State 可以调播放速度,但 Clip 数据本身没变。
c
State Speed = 2表示这个状态播放 Motion 更快,不代表 Clip 文件被改成了两倍速。
面试高分回答
IMPORTANT
Animation Clip 是具体的动画数据资源,里面记录骨骼、Transform、曲线、关键帧和动画事件。Animator State 是 Animator Controller 状态机里的一个节点,它通过 Motion 引用 Animation Clip 或 Blend Tree,并负责播放配置、过渡关系、状态行为和切换逻辑。一个 Clip 可以被多个 State 复用,一个 State 也不一定只对应一个 Clip,它可能引用 Blend Tree。脚本通常通过 Animator 参数驱动 State 切换,而不是直接操作 Clip。
最短记忆版
Clip 是动作内容。
State 是播放动作的状态节点。
Clip 管“动作长什么样”。
State 管“什么时候播、怎么播、播完去哪”。Blend Tree 适合什么场景?
一句话
Blend Tree 适合用在:同一类动作之间需要平滑过渡,而不是突然切换的场景。
比如:
站立 → 走路 → 跑步
前走 → 后退 → 左移 → 右移
普通移动 → 受伤移动
向上瞄准 → 平视瞄准 → 向下瞄准它解决的是一个问题:
我不想从 Idle 突然跳到 Run。
我希望速度慢慢变大时,动画也慢慢从站立混到走路,再混到跑步。零基础比喻
如果不用 Blend Tree,就像电灯开关:
要么关
要么开角色动画就会变成:
Idle 突然切到 Run
Run 突然切回 Idle看起来容易很硬。
Blend Tree 更像调音量旋钮:
0% 跑步
30% 走路
70% 跑步
100% 跑步它可以让多个动画按比例混合。
比如角色速度是 0.3:
Idle 多一点
Walk 少一点
Run 没有速度是 0.8:
Walk 少一点
Run 多一点所以角色动作会更自然。
最常见场景 1:Idle / Walk / Run 移动混合
这是最经典的 1D Blend Tree。
你有三个 Animation Clip:
c
Idle.anim
Walk.anim
Run.anim你有一个参数:
c
SpeedBlend Tree 设置:
c
Speed = 0 → Idle
Speed = 0.5 → Walk
Speed = 1 → Run代码只需要不断把速度传给 Animator:
c
using UnityEngine;
public class PlayerMoveAnimation : MonoBehaviour
{
public Animator animator;
private static readonly int SpeedHash = Animator.StringToHash("Speed");
private void Update()
{
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
Vector3 input = new Vector3(h, 0f, v);
float speed = input.magnitude;
animator.SetFloat(SpeedHash, speed);
}
}效果:
不按键:Speed = 0,播放 Idle
轻推摇杆:Speed = 0.3,Idle 和 Walk 混合
推满摇杆:Speed = 1,接近 Run这比你单独写:
c
Idle State
Walk State
Run State然后做很多 Transition 更清爽。
最常见场景 2:八方向移动
如果是俯视角、3D 动作游戏、双摇杆游戏,经常需要角色向不同方向移动:
前进
后退
左移
右移
左前
右前
左后
右后这时候适合 2D Blend Tree。
参数通常是:
MoveX
MoveY动画可以是:
c
MoveForward.anim
MoveBackward.anim
MoveLeft.anim
MoveRight.anim
Idle.anim代码:
c
using UnityEngine;
public class DirectionalMoveAnimation : MonoBehaviour
{
public Animator animator;
private static readonly int MoveXHash = Animator.StringToHash("MoveX");
private static readonly int MoveYHash = Animator.StringToHash("MoveY");
private void Update()
{
float x = Input.GetAxisRaw("Horizontal");
float y = Input.GetAxisRaw("Vertical");
Vector2 input = new Vector2(x, y);
input = Vector2.ClampMagnitude(input, 1f);
animator.SetFloat(MoveXHash, input.x);
animator.SetFloat(MoveYHash, input.y);
}
}Animator 里:
c
MoveX = 0, MoveY = 0 → Idle
MoveX = 0, MoveY = 1 → Forward
MoveX = 0, MoveY = -1 → Backward
MoveX = -1, MoveY = 0 → Left
MoveX = 1, MoveY = 0 → Right如果输入是:
c
MoveX = 0.7
MoveY = 0.7Unity 会把前进和右移动作混合成右前移动。
常见场景 3:瞄准方向混合
比如第三人称射击游戏,角色上半身要根据准星方向调整:
向上瞄准
平视瞄准
向下瞄准
向左瞄准
向右瞄准可以用 2D Blend Tree:
c
AimX
AimY脚本根据镜头或鼠标方向设置参数:
c
animator.SetFloat("AimX", aimDirection.x);
animator.SetFloat("AimY", aimDirection.y);这样上半身不会突然从“平视”切到“上看”,而是平滑过渡。
这类通常还会配合:
c
Animator Layer
Avatar Mask让瞄准动画只影响上半身,下半身继续跑步。
常见场景 4:不同状态的移动混合
比如角色有受伤状态。
你可能有:
c
NormalWalk.anim
InjuredWalk.anim
NormalRun.anim
InjuredRun.anim可以用参数控制:
c
Speed
HurtWeight当角色受伤程度增加时,动画慢慢从正常移动混到受伤移动。
零基础理解:
角色不是突然从正常人变成瘸腿。
而是受伤程度越高,走路姿态越偏向受伤动画。常见场景 5:表情、姿态、疲劳、重量感
Blend Tree 不只用于移动。
也可以用于:
疲劳程度:正常站立 → 疲劳站立
负重程度:空手走 → 背重物走
情绪变化:普通表情 → 愤怒表情
受击方向:前受击 / 后受击 / 左受击 / 右受击只要这个变化是“连续的”,就适合考虑 Blend Tree。
什么时候不适合 Blend Tree
不是所有动画都适合 Blend Tree。
这些通常更适合单独 State:
攻击
翻滚
受击
死亡
开门
拾取
技能释放
处决动画为什么?
因为这些动作通常是明确的一次性动作:
开始
过程
结束比如攻击动画,不是说:
30% 攻击 + 70% 站立而是:
我现在就是在攻击
攻击播完再回到 Idle 或 Run所以攻击更适合:
c
Any State → Attack State
条件:Attack Trigger而不是塞进移动 Blend Tree。
1D Blend Tree 和 2D Blend Tree 怎么选
1D Blend Tree 用一个参数。
适合:
速度从慢到快
受伤程度从轻到重
疲劳程度从低到高例子:
c
Speed
0 → Idle
0.5 → Walk
1 → Run2D Blend Tree 用两个参数。
适合:
方向
二维瞄准
前后左右移动例子:
c
MoveX
MoveYBlend Tree 和普通 Transition 的关系
它们不是互相替代的关系。
正确理解是:
Animator State 负责大状态切换
Blend Tree 负责某个状态内部的动画混合比如角色 Animator 可以这样设计:
Locomotion State:里面是 Blend Tree,负责 Idle/Walk/Run
Jump State:单独跳跃动画
Attack State:单独攻击动画
Hit State:单独受击动画
Die State:单独死亡动画也就是说:
移动这一类动作内部,用 Blend Tree
移动和攻击、跳跃、死亡之间,用 Transition完整小例子:移动 Blend Tree + 攻击 State
Animator 参数:
c
Speed:Float
Attack:TriggerAnimator 结构:
c
Locomotion State
Motion = Blend Tree
Speed 0 = Idle
Speed 0.5 = Walk
Speed 1 = Run
Any State → Attack
条件:Attack Trigger
Attack → Locomotion
Has Exit Time 打开代码:
c
using UnityEngine;
public class PlayerAnimatorController : MonoBehaviour
{
public Animator animator;
public CharacterController controller;
private static readonly int SpeedHash = Animator.StringToHash("Speed");
private static readonly int AttackHash = Animator.StringToHash("Attack");
private void Update()
{
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
Vector3 input = new Vector3(h, 0f, v);
input = Vector3.ClampMagnitude(input, 1f);
float speed01 = input.magnitude;
animator.SetFloat(SpeedHash, speed01, 0.1f, Time.deltaTime);
if (Input.GetMouseButtonDown(0))
{
animator.SetTrigger(AttackHash);
}
}
}这里这一句:
c
animator.SetFloat(SpeedHash, speed01, 0.1f, Time.deltaTime);比普通的:
c
animator.SetFloat(SpeedHash, speed01);更平滑。
因为它带了阻尼时间,Speed 参数不会突然从 0 跳到 1。
为什么 Blend Tree 能让脚步更自然
如果你直接切:
c
Idle → Run角色可能看起来像瞬移式进入跑步。
Blend Tree 会让它变成:
c
Idle → 一点点 Walk → 更多 Walk → Run视觉上更像真实人从静止加速跑起来。
但是注意:
动画混合自然,不代表脚步一定不滑。脚步是否滑,还和这些有关:
动画本身速度
角色代码移动速度
Root Motion 是否开启
Blend Tree 阈值设置是否合理如果 Run.anim 看起来每秒跑 4 米,但代码让角色每秒移动 8 米,就会脚滑。
常见坑
坑 1:参数范围和阈值不匹配。
比如 Blend Tree 设置:
c
Idle = 0
Walk = 1
Run = 2但代码传的是:
Speed = 0 到 1那永远到不了 Run。
坑 2:动画方向放错。
2D Blend Tree 里:
Forward 应该放在 MoveY = 1
Backward 应该放在 MoveY = -1
Right 应该放在 MoveX = 1
Left 应该放在 MoveX = -1放错就会出现:
按前进,角色播放后退动画坑 3:把攻击放进移动 Blend Tree。
攻击不是连续移动姿态,一般应该单独 State。
坑 4:忘了平滑参数。
输入从 0 突然到 1,动画混合也会突然变化。
可以用:
c
animator.SetFloat("Speed", speed, 0.1f, Time.deltaTime);坑 5:Blend Tree 混合了不适合混合的动作。
比如:
翻滚 + 跑步
死亡 + 跳跃
攻击 + 待机这些混起来通常很怪。
面试高分回答
TIP
Blend Tree 适合处理同一类动画内部的连续变化,比如 Idle、Walk、Run 之间按速度混合,或者前后左右移动按方向混合。它的核心价值是减少大量状态切换,让动画根据参数平滑过渡。1D Blend Tree 常用一个 Speed 参数控制移动速度,2D Blend Tree 常用 MoveX、MoveY 控制方向。项目中通常把 Locomotion 做成 Blend Tree,而攻击、受击、翻滚、死亡这类明确动作仍然做成单独 State,通过 Transition 切换。
最短记忆版
Blend Tree 适合“连续变化”的动画。
速度连续变化:用 1D。
方向连续变化:用 2D。
移动适合 Blend Tree。
攻击、死亡、翻滚通常不适合放进移动 Blend Tree。Root Motion 是什么?
一句话
Root Motion 是指:动画本身带着角色的根节点位移和旋转,并且 Unity 可以把这段位移真正应用到角色 GameObject 上。
你可以先记:
普通动画:脚在跑,但角色是否真的移动,由脚本决定。
Root Motion:动画里的根节点在移动,角色真的跟着动画走。零基础比喻
想象你有一段“翻滚动画”。
动画里角色不是原地摆动作,而是真的从当前位置往前滚了 3 米。
这 3 米位移如果只是存在动画数据里,但没有应用到角色对象上,那么:
模型看起来滚出去了
但 GameObject 的位置可能还在原地这就可能出现:
动画人滚走了
碰撞体还留在原地
角色位置和画面表现对不上如果开启 Root Motion,Unity 会把动画里的根节点位移应用到角色上:
动画往前滚 3 米
角色 GameObject 也真的往前移动 3 米
碰撞体、Transform、模型更容易对齐Root 是什么
在角色骨骼里,通常有一个最上层的根节点。
可以理解为:
Root:整个角色骨架的总根
Hips/Pelvis:身体骨盆
Spine:脊柱
Head:头
Arms:手臂
Legs:腿动画里不只是手脚在动,根节点也可能在动。
比如:
跑步动画:Root 每秒向前移动
翻滚动画:Root 快速向前移动
冲刺攻击:Root 向前冲一段
后撤步:Root 向后移动
转身动画:Root 旋转 180 度这些根节点的位移和旋转,就是 Root Motion 的来源。
Apply Root Motion 是什么
在 Animator 组件上有个选项:
c
Apply Root Motion它的意思是:
是否把动画 Clip 里的 Root 位移/旋转应用到 GameObject 的 Transform 上关闭时:
动画只负责姿势
角色位置由脚本控制开启时:
动画里的根运动会推动角色 Transform关闭 Root Motion 的情况
普通角色移动常常关闭 Root Motion。
比如你用代码控制角色:
c
using UnityEngine;
public class ScriptMove : MonoBehaviour
{
public CharacterController controller;
public Animator animator;
public float speed = 5f;
private static readonly int SpeedHash = Animator.StringToHash("Speed");
private void Update()
{
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
Vector3 input = new Vector3(h, 0f, v).normalized;
controller.Move(input * speed * Time.deltaTime);
animator.SetFloat(SpeedHash, input.magnitude);
}
}这种模式下:
脚本负责移动
Animator 负责播放动作
Root Motion 通常关闭适合:
网游角色
强操作动作游戏
需要精确控制速度的角色
需要预测和同步的联网游戏因为速度完全由代码控制,更好调,也更适合网络同步。
开启 Root Motion 的情况
有些动作非常依赖动画师做出来的位移。
比如:
翻滚
处决
击杀动画
冲刺攻击
后撤步
带位移的重攻击
爬墙翻越
攀爬动作这时如果只用代码移动,很可能动作和位移对不上。
比如翻滚:
动画里身体滚得很快
代码移动速度太慢 → 看起来脚滑/身体漂
代码移动速度太快 → 动画跟不上开启 Root Motion 后:
动画师做了多远,角色就移动多远
动画和位移更自然Root Motion 和脚本移动的区别
| 对比 | 脚本移动 | Root Motion |
|---|---|---|
| 谁决定位移 | 代码 | 动画 Clip |
| 控制性 | 更强 | 更依赖动画 |
| 动作自然度 | 需要调速度匹配动画 | 通常更自然 |
| 网络同步 | 更容易 | 更麻烦 |
| 常见用途 | 普通移动、网游、角色控制 | 翻滚、处决、攻击位移、演出动作 |
Root Motion 的底层理解
Animator 每帧播放动画时,会计算出这一帧动画根节点相对上一帧移动了多少。
Unity 里可以拿到:
c
animator.deltaPosition
animator.deltaRotation它们表示:
这一帧动画根节点产生的位置变化
这一帧动画根节点产生的旋转变化如果 Apply Root Motion 开启,Unity 默认会把这些变化应用到角色 Transform。
简单理解:
动画根节点这一帧往前动了 0.05 米
Unity 就让角色 GameObject 往前动 0.05 米OnAnimatorMove 是什么
如果你不想完全让 Unity 自动应用 Root Motion,也可以自己接管。
Unity 提供:
c
private void OnAnimatorMove()
{
}只要脚本里实现了 OnAnimatorMove,你就可以自己处理动画位移。
比如结合 CharacterController:
c
using UnityEngine;
public class RootMotionWithCharacterController : MonoBehaviour
{
public Animator animator;
public CharacterController controller;
public float gravity = -9.81f;
private float verticalVelocity;
private void OnAnimatorMove()
{
Vector3 rootDelta = animator.deltaPosition;
if (controller.isGrounded && verticalVelocity < 0f)
{
verticalVelocity = -2f;
}
verticalVelocity += gravity * Time.deltaTime;
rootDelta.y = verticalVelocity * Time.deltaTime;
controller.Move(rootDelta);
transform.rotation *= animator.deltaRotation;
}
}这段代码的意思是:
水平位移来自动画 Root Motion
竖直方向自己加重力
旋转也使用动画 deltaRotation
最终用 CharacterController.Move 移动这样比完全自动 Apply Root Motion 更可控。
Root Motion 和 Rigidbody 怎么配合
如果角色是 Rigidbody,直接在 OnAnimatorMove 里改 transform.position 不太推荐。
可以用:
c
using UnityEngine;
public class RootMotionWithRigidbody : MonoBehaviour
{
public Animator animator;
public Rigidbody rb;
private void OnAnimatorMove()
{
Vector3 nextPosition = rb.position + animator.deltaPosition;
Quaternion nextRotation = rb.rotation * animator.deltaRotation;
rb.MovePosition(nextPosition);
rb.MoveRotation(nextRotation);
}
}这样更符合物理系统。
Apply Root Motion 开了,代码还要不要移动角色
通常不要同时让两个系统抢位移。
容易出问题:
脚本 controller.Move 往前移动
Root Motion 也往前移动
结果角色速度翻倍或者:
脚本想停住
动画 Root 还在往前推
角色停不下来所以要明确谁说了算:
普通移动:脚本说了算,Root Motion 关闭
特殊动作:Root Motion 说了算,脚本暂停普通移动
混合方案:OnAnimatorMove 手动接管 deltaPosition项目里常见做法
普通状态:
Idle / Walk / Run
用脚本控制移动
Root Motion 关闭特殊动作:
Roll / DashAttack / Execution
可以使用 Root Motion
期间锁住玩家输入
动画结束后恢复脚本控制比如翻滚:
c
using UnityEngine;
public class PlayerRoll : MonoBehaviour
{
public Animator animator;
public CharacterController controller;
private bool isRolling;
public void StartRoll()
{
isRolling = true;
animator.SetTrigger("Roll");
}
public void EndRoll()
{
isRolling = false;
}
private void Update()
{
if (isRolling)
{
return;
}
// 非翻滚状态下,普通脚本移动
}
private void OnAnimatorMove()
{
if (!isRolling)
{
return;
}
controller.Move(animator.deltaPosition);
transform.rotation *= animator.deltaRotation;
}
}EndRoll 可以由 Animation Event 在翻滚动画末尾调用。
Root Motion 和脚滑
脚滑就是:
动画里脚看起来踩在地上
但角色实际移动速度和脚步不匹配脚滑常见原因:
代码移动速度和动画速度不一致
Blend Tree 阈值设置不合理
Root Motion 关闭但动画本身是带位移的
Root Motion 开启但又叠加了脚本移动
动画导入设置处理错了Root Motion 可以减少某些脚滑问题,因为位移来自动画本身。
但它不是万能解法。
如果动画本身质量不好,或者状态切换不合理,仍然会滑。
Root Motion 和网络游戏
联网游戏里,很多项目不喜欢让 Root Motion 完全决定普通移动。
原因:
网络同步更希望位置由代码/服务器逻辑控制
Root Motion 依赖动画采样
不同帧率、状态切换、动画版本可能带来同步复杂度所以网络角色常见方案:
普通移动:代码控制
动画:根据速度播放
特殊技能位移:服务器/逻辑决定最终位移,动画配合表现常见坑
坑 1:开了 Root Motion,又用脚本移动。
结果:
角色移动速度变怪
可能位移叠加坑 2:动画看起来在动,但角色 Transform 没动。
原因可能是:
Apply Root Motion 没开
Clip 没有根位移
导入设置把 Root 位移 Bake Into Pose 了坑 3:Root Motion 推着角色穿墙。
动画位移不等于自动处理碰撞。
如果需要碰撞,最好用:
c
CharacterController.Move
Rigidbody.MovePosition
NavMeshAgent 配合而不是直接让 Transform 硬改。
坑 4:所有动作都用 Root Motion。
普通移动会变得不好控制,尤其是:
操作响应
网络同步
速度调节
AI 控制面试高分回答
WARNING
Root Motion 是动画根节点产生的位移和旋转。普通动画只改变角色姿势,而 Root Motion 可以把 Animation Clip 中 Root 的 deltaPosition 和 deltaRotation 应用到角色 GameObject 上,让角色真实移动。它适合翻滚、冲刺攻击、处决、攀爬等动画位移强相关的动作;普通移动、网游角色或需要精确控制的角色通常用脚本移动并关闭 Root Motion。项目里如果要兼顾碰撞和可控性,我会在 OnAnimatorMove 中读取 animator.deltaPosition,再交给 CharacterController.Move 或 Rigidbody.MovePosition 处理。
最短记忆版
c
Root Motion = 动画自己带位移。
关闭:脚本移动,动画只摆动作。
开启:动画位移推动角色。
普通移动多用脚本。
翻滚、处决、攻击位移常用 Root Motion。动画事件有什么风险?
一句话
Animation Event 很方便,但它最大的风险是:把游戏逻辑绑在动画时间轴和字符串方法名上,容易漏触发、重复触发、难排查、难维护。
先记住:
动画事件适合做表现。
核心战斗逻辑不要完全依赖动画事件。动画事件是什么
动画事件就是在 Animation Clip 的某个时间点插一个回调。
比如攻击动画:
0.00 秒:抬手
0.25 秒:挥刀
0.35 秒:打开伤害判定
0.50 秒:关闭伤害判定
0.80 秒:攻击结束你可以在动画时间轴的 0.35 秒加一个事件:
c
EnableHitBox()在 0.50 秒加一个事件:
c
DisableHitBox()代码:
c
using UnityEngine;
public class PlayerAttackEventReceiver : MonoBehaviour
{
public GameObject hitBox;
public void EnableHitBox()
{
hitBox.SetActive(true);
}
public void DisableHitBox()
{
hitBox.SetActive(false);
}
}动画播放到对应时间点时,Unity 就会调用这些方法。
风险 1:它靠字符串找方法,重构很脆
动画事件里填的是方法名。
比如:
c
EnableHitBox如果你后来把代码改名:
c
public void OpenHitBox()
{
}但动画事件里还是旧名字:
c
EnableHitBox那就会出问题。
编译器很难像普通代码引用那样直接帮你发现:
c
EnableHitBox(); // 这种改名 IDE 会提醒但动画事件存在 .anim 或导入的动画资源里,不是普通 C# 调用。
所以风险是:
方法改名了
脚本删了
物体换了
事件还在动画里
运行时才发现没调用成功风险 2:方法必须在正确的对象上
Animation Event 通常会调用挂在同一个 GameObject 上的脚本方法。
比如:
c
Player
- Animator
- PlayerAttackEventReceiver这样可以调用:
c
public void EnableHitBox()
{
}但如果脚本挂错地方:
Player 子物体 Weapon 上有脚本
Animator 在 Player 根节点上动画事件可能找不到你想调用的方法。
新手经常会遇到:
明明写了方法,但动画事件不调用原因可能是:
脚本不在 Animator 所在对象上
方法名写错
方法不是 public
参数类型不对
Animator 没播放到那个事件时间点风险 3:动画被打断,事件可能不触发
比如攻击动画里:
0.35 秒:造成伤害但角色在 0.2 秒时被打断了:
c
Attack → Hit那 0.35 秒的动画事件可能根本到不了。
结果就是:
玩家按了攻击
动作被打断
事件没触发
伤害没结算有时候这是你想要的,比如攻击被打断就不该造成伤害。
但如果你没有意识到这一点,就会觉得:
为什么偶尔攻击没伤害?风险 4:动画过渡和 CrossFade 会让时机变复杂
Animator 不是永远完整播放一个动画。
它可能在状态之间过渡:
c
Run → Attack
Attack → Idle
Attack → Hit如果事件在过渡边界附近,可能出现:
事件没播到
事件触发时角色已经切状态
事件触发了但动作视觉还没到位尤其是:
c
Transition Duration 太长
Has Exit Time 设置复杂
CrossFade 频繁切动画动画事件的时机会变得不好判断。
风险 5:循环动画可能重复触发
如果动画是 Loop 的,比如跑步:
c
Run.anim你在脚落地时放了脚步声事件:
c
PlayFootstep()那每循环一次都会触发。
这是脚步声想要的效果。
但如果你不小心把攻击伤害事件放在循环动画里,就可能:
每一轮循环都打一次伤害所以要分清:
脚步声:重复触发合理
攻击结算:重复触发可能危险风险 6:Blend Tree 里可能多个 Clip 都带事件
假设一个 Blend Tree 里有:
c
Walk.anim
Run.anim如果两个 Clip 里都有脚步事件,而它们又在混合,事件触发就可能比你想象复杂。
可能出现:
Walk 的脚步事件触发
Run 的脚步事件也触发
脚步声太密所以在 Blend Tree 里用 Animation Event 要谨慎。
脚步声更工程化的做法可以是:
根据脚步相位统一管理
根据地面材质播放音效
或用动画事件但集中经过一个 FootstepController 过滤风险 7:Culling 可能影响动画更新
Animator 有 Culling 相关设置。
如果角色不在摄像机里,Unity 可能减少动画更新。
这对表现没问题,但如果你把重要逻辑放在动画事件里,就可能有风险。
比如远处怪物攻击玩家:
怪物不在镜头内
Animator 被裁剪或更新减少
动画事件没有按预期跑
攻击逻辑出问题所以:
看不见就可以不播的表现逻辑,可以放动画事件
看不见也必须正确的战斗逻辑,不应该依赖动画事件风险 8:参数能力有限
Animation Event 可以传一些简单参数,比如:
c
float
int
string
Object
AnimationEvent但它不适合传复杂上下文。
比如一次攻击真正需要:
攻击者
目标
技能 ID
伤害倍率
暴击信息
阵营
Buff
武器数据
网络帧号这类复杂数据最好由战斗系统管理,而不是从动画事件里硬塞参数。
风险 9:把核心伤害结算放动画事件里,会让战斗逻辑难维护
比如你直接这样做:
c
public void DealDamage()
{
enemy.TakeDamage(10);
}看起来很方便,但项目复杂后会有问题:
攻击被打断怎么办?
多个敌人怎么办?
联网谁说了算?
伤害倍率从哪里来?
Buff 怎么算?
防御、闪避、霸体怎么处理?
回放和同步怎么保证?更好的做法通常是:
动画事件只通知“攻击窗口到了”
真正伤害由战斗系统计算比如:
c
using UnityEngine;
public class AttackAnimationEvents : MonoBehaviour
{
public PlayerCombat combat;
public void OpenAttackWindow()
{
combat.OpenAttackWindow();
}
public void CloseAttackWindow()
{
combat.CloseAttackWindow();
}
public void PlaySlashEffect()
{
combat.PlaySlashEffect();
}
}战斗系统:
c
using UnityEngine;
public class PlayerCombat : MonoBehaviour
{
public Collider weaponHitBox;
private bool attackWindowOpen;
public void OpenAttackWindow()
{
attackWindowOpen = true;
weaponHitBox.enabled = true;
}
public void CloseAttackWindow()
{
attackWindowOpen = false;
weaponHitBox.enabled = false;
}
public void OnWeaponHit(IDamageable target)
{
if (!attackWindowOpen)
{
return;
}
target.TakeDamage(CalculateDamage());
}
private int CalculateDamage()
{
// 这里可以接武器、Buff、暴击、等级、技能倍率
return 10;
}
public void PlaySlashEffect()
{
Debug.Log("播放刀光特效");
}
}这样动画事件只是开关窗口,核心伤害规则还在代码系统里。
适合放动画事件的内容
比较适合:
脚步声
挥刀音效
落地音效
播放特效
镜头震动
打开/关闭武器拖尾
提示某个动作阶段到了
轻量级表现事件比如脚步声:
c
using UnityEngine;
public class FootstepEvents : MonoBehaviour
{
public AudioSource audioSource;
public AudioClip footstepClip;
public void PlayFootstep()
{
audioSource.PlayOneShot(footstepClip);
}
}谨慎放动画事件的内容
可以放,但要小心:
开启攻击判定
关闭攻击判定
开启无敌帧
关闭无敌帧
翻滚结束
连招输入窗口这些和玩法有关,但它们经常和动画时间强相关。
可以用,但最好只是通知业务系统:
动画事件负责告诉系统“现在到这个时间点了”
真正状态改变由系统统一管理不建议完全依赖动画事件的内容
不建议直接把这些完全放在动画事件里:
最终伤害结算
角色死亡判定
任务完成
掉落奖励
联网同步关键逻辑
AI 决策
核心状态机切换
重要资源增减原因是:
这些逻辑应该稳定、可测试、可追踪
不应该依赖某个动画是否刚好播放到某一帧更安全的写法:动画事件转发到系统
推荐模式:
c
Animation Event → Event Receiver → Combat / Audio / VFX 系统不要让动画事件里写一堆复杂逻辑。
例子:
c
using UnityEngine;
public class AnimationEventReceiver : MonoBehaviour
{
public PlayerCombat combat;
public PlayerVfx vfx;
public PlayerAudio audio;
public void Anim_OpenHitBox()
{
combat.OpenHitBox();
}
public void Anim_CloseHitBox()
{
combat.CloseHitBox();
}
public void Anim_PlayFootstep()
{
audio.PlayFootstep();
}
public void Anim_PlaySlashVfx()
{
vfx.PlaySlash();
}
}好处:
动画事件方法统一命名
动画事件只做转发
真正逻辑在系统里
以后排查也知道去哪里找命名建议
为了减少混乱,可以统一加前缀:
c
Anim_PlayFootstep
Anim_OpenHitBox
Anim_CloseHitBox
Anim_EnableInvincible
Anim_DisableInvincible这样一看就知道:
这是给 Animation Event 调的面试高分回答
IMPORTANT
Animation Event 的风险主要在于它是时间轴上的字符串回调,和 C# 代码引用不同,方法改名、脚本移动、参数不匹配都可能到运行时才暴露。另外动画可能被打断、过渡、循环或混合,事件就可能漏触发、重复触发或时机不稳定。所以我会把 Animation Event 用在脚步声、特效、武器拖尾、攻击窗口通知这类和动画表现强相关的点上;但最终伤害结算、死亡、任务奖励、联网同步等核心逻辑不会直接依赖动画事件,而是让事件转发到战斗系统或表现系统统一处理。
最短记忆版
动画事件优点:卡动画帧很方便。
动画事件风险:字符串脆、时机易变、打断会漏、循环会重复、逻辑会耦合。
适合:音效、特效、脚步、轻量通知。
谨慎:攻击窗口、无敌帧。
不建议:最终伤害、死亡结算、联网核心逻辑。状态机参数 Trigger、Bool、Float 怎么选?
一句话
Trigger 用来表示“一次性动作”,Bool 用来表示“持续状态”,Float 用来表示“连续变化的程度”。
最简单口诀:
Trigger:一下
Bool:是否
Float:多少比如:
攻击一下 → Trigger
是否在地面 → Bool
移动速度是多少 → Float零基础比喻
Trigger 像门铃。
你按一下,门铃响一次。
按一下攻击键 → 播一次攻击动画
按一下跳跃键 → 播一次跳跃动画
按一下翻滚键 → 播一次翻滚动画Bool 像电灯开关。
它会一直保持开或关。
是否死亡
是否在地面
是否正在瞄准
是否举盾Float 像音量旋钮。
它不是只有开关,而是可以从小到大连续变化。
速度从 0 慢慢变到 1
瞄准角度从 -1 到 1
受伤程度从 0 到 100Trigger 什么时候用
Trigger 适合“发生一次”的动作。
常见例子:
Attack:攻击一次
Jump:跳跃一次
Roll:翻滚一次
Hit:受击一次
Dodge:闪避一次
Skill:释放技能一次代码:
c
using UnityEngine;
public class PlayerAttackAnim : MonoBehaviour
{
public Animator animator;
private static readonly int AttackHash = Animator.StringToHash("Attack");
private void Update()
{
if (Input.GetMouseButtonDown(0))
{
animator.SetTrigger(AttackHash);
}
}
}Animator 里一般这样配:
c
Any State → Attack
条件:Attack Trigger为什么攻击适合 Trigger?
因为攻击不是一个长期状态。
你不是说:
我一直处于 Attack = true而是:
我按了一下攻击键,请播放一次攻击动画所以 Trigger 很适合。
Trigger 的常见坑
坑 1:Trigger 不是 Bool。
不要这样理解:
c
Attack Trigger = true它更像:
发出一次攻击信号Animator 用掉这个信号后,它就会被消耗。
坑 2:只 SetTrigger 没有 Transition 条件也没用。
代码里写:
c
animator.SetTrigger("Attack");但 Animator Controller 里没有任何箭头使用 Attack 条件,那动画不会切。
坑 3:连续触发可能被状态机吞掉。
如果你在攻击还没结束时一直点攻击:
Attack Trigger 触发了
但当前状态不能切
或者 Transition 不允许可能看起来像“按了没反应”。
项目里连招一般会额外做:
输入缓存
连招窗口
当前攻击段数不要只靠 Trigger 硬怼。
Bool 什么时候用
Bool 适合“持续存在的状态”。
常见例子:
IsGrounded:是否在地面
IsDead:是否死亡
IsAiming:是否瞄准
IsBlocking:是否格挡
IsCrouching:是否蹲下
IsClimbing:是否攀爬代码:
c
using UnityEngine;
public class PlayerGroundAnim : MonoBehaviour
{
public Animator animator;
public CharacterController controller;
private static readonly int IsGroundedHash = Animator.StringToHash("IsGrounded");
private void Update()
{
animator.SetBool(IsGroundedHash, controller.isGrounded);
}
}Animator 里可以这样用:
c
Jump/Fall → Idle
条件:IsGrounded == true
Any State → Fall
条件:IsGrounded == false为什么落地适合 Bool?
因为“是否在地面”不是一下子的动作,而是一段持续状态:
站在地面:true
跳起来:false
落地后:trueBool 的常见坑
坑 1:忘记改回 false。
比如:
c
animator.SetBool("IsAiming", true);但松开右键时忘了:
c
animator.SetBool("IsAiming", false);那角色就会一直保持瞄准状态。
坑 2:用 Bool 表示一次性动作。
比如攻击用:
c
animator.SetBool("Attack", true);如果不及时改回 false,角色可能卡在攻击状态,或者状态机反复进入攻击。
攻击这种“一下”的动作,更适合 Trigger。
Float 什么时候用
Float 适合“连续变化”。
常见例子:
Speed:移动速度
MoveX:左右移动输入
MoveY:前后移动输入
AimX:左右瞄准方向
AimY:上下瞄准方向
HurtWeight:受伤程度
TurnSpeed:转身速度最典型就是 Blend Tree。
比如:
c
Speed = 0 → Idle
Speed = 0.5 → Walk
Speed = 1 → Run代码:
c
using UnityEngine;
public class PlayerMoveAnim : MonoBehaviour
{
public Animator animator;
private static readonly int SpeedHash = Animator.StringToHash("Speed");
private void Update()
{
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
Vector3 input = new Vector3(h, 0f, v);
float speed = Mathf.Clamp01(input.magnitude);
animator.SetFloat(SpeedHash, speed);
}
}更平滑的写法:
c
animator.SetFloat(SpeedHash, speed, 0.1f, Time.deltaTime);这表示:
Speed 参数不要瞬间跳过去
用 0.1 秒左右平滑靠近目标值这样 Idle/Walk/Run 混合会更柔和。
2D Blend Tree 用 Float
如果是前后左右移动,一般用两个 Float:
c
MoveX
MoveY代码:
c
using UnityEngine;
public class PlayerDirectionAnim : MonoBehaviour
{
public Animator animator;
private static readonly int MoveXHash = Animator.StringToHash("MoveX");
private static readonly int MoveYHash = Animator.StringToHash("MoveY");
private void Update()
{
float x = Input.GetAxisRaw("Horizontal");
float y = Input.GetAxisRaw("Vertical");
Vector2 input = Vector2.ClampMagnitude(new Vector2(x, y), 1f);
animator.SetFloat(MoveXHash, input.x, 0.1f, Time.deltaTime);
animator.SetFloat(MoveYHash, input.y, 0.1f, Time.deltaTime);
}
}Animator 里:
c
MoveX = 0, MoveY = 1 → 前进
MoveX = 0, MoveY = -1 → 后退
MoveX = -1, MoveY = 0 → 左移
MoveX = 1, MoveY = 0 → 右移三者怎么选
可以按这个问题来判断:
它是按一下发生一次吗?
是 → Trigger比如:
c
Attack
Jump
Roll
Hit
它是一个会持续保持的状态吗?
是 → Bool比如:
c
IsGrounded
IsDead
IsAiming
IsBlocking
它是一个连续数值吗?
是 → Float比如:
c
Speed
MoveX
MoveY
AimY
HurtWeight完整小例子
一个角色可能这样设计参数:
Speed:Float,控制 Idle/Walk/Run Blend Tree
IsGrounded:Bool,控制空中/落地
Attack:Trigger,控制攻击
Roll:Trigger,控制翻滚
IsDead:Bool,控制死亡状态代码:
c
using UnityEngine;
public class PlayerAnimatorParams : MonoBehaviour
{
public Animator animator;
public CharacterController controller;
private static readonly int SpeedHash = Animator.StringToHash("Speed");
private static readonly int IsGroundedHash = Animator.StringToHash("IsGrounded");
private static readonly int AttackHash = Animator.StringToHash("Attack");
private static readonly int RollHash = Animator.StringToHash("Roll");
private static readonly int IsDeadHash = Animator.StringToHash("IsDead");
private bool isDead;
private void Update()
{
if (isDead)
{
animator.SetBool(IsDeadHash, true);
return;
}
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
Vector3 input = new Vector3(h, 0f, v);
float speed = Mathf.Clamp01(input.magnitude);
animator.SetFloat(SpeedHash, speed, 0.1f, Time.deltaTime);
animator.SetBool(IsGroundedHash, controller.isGrounded);
if (Input.GetMouseButtonDown(0))
{
animator.SetTrigger(AttackHash);
}
if (Input.GetKeyDown(KeyCode.LeftShift))
{
animator.SetTrigger(RollHash);
}
}
public void Die()
{
isDead = true;
animator.SetBool(IsDeadHash, true);
}
}常见错误对照
错误 1:攻击用 Bool。
c
animator.SetBool("Attack", true);容易导致:
攻击状态卡住
攻击反复触发
忘记关掉 false更推荐:
c
animator.SetTrigger("Attack");错误 2:移动用 Trigger。
c
animator.SetTrigger("Run");跑步不是“一下”,而是持续根据速度变化。
更推荐:
c
animator.SetFloat("Speed", speed);错误 3:落地用 Trigger。
落地瞬间可以用 Trigger 播落地动画,但“是否在地面”本身更适合 Bool。
c
Land:Trigger,表示落地那一下
IsGrounded:Bool,表示现在是否在地面这两个可以同时存在。
面试高分回答
IMPORTANT
Animator 参数的选择主要看信息的语义。Trigger 表示一次性事件,比如攻击、跳跃、受击、翻滚;Bool 表示持续状态,比如是否落地、是否死亡、是否瞄准;Float 表示连续变化的数值,比如移动速度、方向输入、瞄准角度,常用于 Blend Tree。项目里我会避免用 Bool 表示一次性动作,也不会用 Trigger 表示持续移动,参数语义清晰后,Animator Controller 会更稳定、更好维护。
最短记忆版
c
Trigger:发生一次,Attack / Jump / Roll / Hit
Bool:是否处于某状态,IsGrounded / IsDead / IsAiming
Float:程度是多少,Speed / MoveX / MoveY / AimY角色攻击判定如何和动画同步?
一句话
角色攻击判定和动画同步,核心是做一个攻击有效帧窗口:
前摇:不能打到人
有效帧:打开攻击判定
后摇:关闭攻击判定也就是说,不是整段攻击动画都能造成伤害,而是只有武器真正挥到敌人那几帧才有判定。
零基础比喻
假设角色挥刀攻击。
动画分三段:
1. 前摇:角色把刀举起来
2. 有效帧:刀真正挥出去,扫到敌人
3. 后摇:角色把刀收回来如果你在整段动画都开着伤害判定,会出现很奇怪的情况:
刀还没挥出去,敌人就受伤了
刀已经收回来了,敌人靠近也受伤
一次攻击打到同一个敌人很多次所以正确做法是:
动画到挥刀那一刻:打开 HitBox
挥刀结束:关闭 HitBox常见实现方式 1:Animation Event 开关 HitBox
这是最直观、最常见的做法。
在攻击动画 Attack.anim 上加两个动画事件:
c
0.30 秒:Anim_OpenHitBox()
0.48 秒:Anim_CloseHitBox()代码:
c
using UnityEngine;
public class AttackAnimationEventReceiver : MonoBehaviour
{
public PlayerCombat combat;
public void Anim_OpenHitBox()
{
combat.OpenHitBox();
}
public void Anim_CloseHitBox()
{
combat.CloseHitBox();
}
}战斗系统:
c
using System.Collections.Generic;
using UnityEngine;
public class PlayerCombat : MonoBehaviour
{
public Collider weaponHitBox;
public int damage = 10;
private readonly HashSet<IDamageable> hitTargets = new HashSet<IDamageable>();
private bool attackWindowOpen;
private void Awake()
{
weaponHitBox.enabled = false;
}
public void StartAttack()
{
hitTargets.Clear();
}
public void OpenHitBox()
{
attackWindowOpen = true;
weaponHitBox.enabled = true;
}
public void CloseHitBox()
{
attackWindowOpen = false;
weaponHitBox.enabled = false;
}
public void TryHit(IDamageable target)
{
if (!attackWindowOpen)
{
return;
}
if (hitTargets.Contains(target))
{
return;
}
hitTargets.Add(target);
target.TakeDamage(damage);
}
public void ForceEndAttack()
{
CloseHitBox();
hitTargets.Clear();
}
}武器 HitBox:
c
using UnityEngine;
public class WeaponHitBox : MonoBehaviour
{
public PlayerCombat combat;
private void OnTriggerEnter(Collider other)
{
IDamageable damageable = other.GetComponent<IDamageable>();
if (damageable != null)
{
combat.TryHit(damageable);
}
}
}伤害接口:
c
public interface IDamageable
{
void TakeDamage(int damage);
}敌人:
c
using UnityEngine;
public class EnemyHealth : MonoBehaviour, IDamageable
{
public int hp = 100;
public void TakeDamage(int damage)
{
hp -= damage;
Debug.Log("敌人受伤:" + damage);
if (hp <= 0)
{
Debug.Log("敌人死亡");
}
}
}为什么要用 HashSet 防重复命中
攻击判定窗口可能持续好几帧。
如果武器 HitBox 和敌人 Collider 一直接触,可能会触发多次:
第 1 帧:命中敌人
第 2 帧:还在接触,又命中
第 3 帧:还在接触,又命中但一次挥刀通常只想让同一个敌人受伤一次。
所以用:
c
HashSet<IDamageable> hitTargets记录这次攻击已经打中过谁。
下一次攻击开始时再清空:
c
hitTargets.Clear();角色输入和动画触发
玩家按攻击键:
c
using UnityEngine;
public class PlayerAttackInput : MonoBehaviour
{
public Animator animator;
public PlayerCombat combat;
private static readonly int AttackHash = Animator.StringToHash("Attack");
private void Update()
{
if (Input.GetMouseButtonDown(0))
{
combat.StartAttack();
animator.SetTrigger(AttackHash);
}
}
}Animator 里:
c
Any State → Attack
条件:Attack Trigger
Attack 动画中:
Anim_OpenHitBox()
Anim_CloseHitBox()这样流程就是:
按攻击键
→ Animator 播攻击动画
→ 动画挥到敌人那一帧调用 OpenHitBox
→ 武器 Trigger 检测敌人
→ Combat 计算伤害
→ 动画收招时调用 CloseHitBox关键点:动画事件不要直接算最终伤害
不推荐这样写:
c
public void Anim_DealDamage()
{
enemy.TakeDamage(10);
}原因是:
动画事件不知道打到谁
不知道有没有暴击
不知道武器倍率
不知道 Buff
不知道敌人防御
不知道是否已经命中过
联网时也不应该由客户端动画直接决定伤害更好的做法是:
动画事件只负责告诉系统:现在进入攻击有效帧
真正伤害由 Combat 系统判断和结算常见实现方式 2:不用 Collider,用 OverlapBox 检测
有些项目不用武器 Trigger,而是在有效帧主动查询范围。
比如在角色前方打一个盒子:
c
using System.Collections.Generic;
using UnityEngine;
public class OverlapAttack : MonoBehaviour
{
public Transform attackPoint;
public Vector3 halfExtents = new Vector3(0.6f, 0.6f, 0.8f);
public LayerMask enemyMask;
public int damage = 10;
private readonly HashSet<IDamageable> hitTargets = new HashSet<IDamageable>();
private bool attackWindowOpen;
public void StartAttack()
{
hitTargets.Clear();
}
public void OpenAttackWindow()
{
attackWindowOpen = true;
}
public void CloseAttackWindow()
{
attackWindowOpen = false;
}
private void Update()
{
if (!attackWindowOpen)
{
return;
}
Collider[] hits = Physics.OverlapBox(
attackPoint.position,
halfExtents,
attackPoint.rotation,
enemyMask
);
foreach (Collider hit in hits)
{
IDamageable target = hit.GetComponent<IDamageable>();
if (target == null)
{
continue;
}
if (hitTargets.Contains(target))
{
continue;
}
hitTargets.Add(target);
target.TakeDamage(damage);
}
}
private void OnDrawGizmosSelected()
{
if (attackPoint == null)
{
return;
}
Gizmos.color = Color.red;
Gizmos.matrix = Matrix4x4.TRS(
attackPoint.position,
attackPoint.rotation,
Vector3.one
);
Gizmos.DrawWireCube(Vector3.zero, halfExtents * 2f);
}
}优点:
判定范围稳定
不用依赖武器 Collider 的连续接触
适合拳头、扇形攻击、范围攻击
方便用 LayerMask 过滤敌人缺点:
需要自己调判定盒位置和大小
需要可视化 Gizmos 辅助调试常见实现方式 3:用 normalizedTime 控制有效帧
如果你不想放 Animation Event,也可以在代码里根据动画播放进度判断。
normalizedTime 可以理解成动画进度:
0.0:动画刚开始
0.5:播放到一半
1.0:播放完一遍示例:
c
using UnityEngine;
public class AttackWindowByTime : MonoBehaviour
{
public Animator animator;
public PlayerCombat combat;
private static readonly int AttackStateHash = Animator.StringToHash("Attack");
private bool hitBoxOpened;
private void Update()
{
AnimatorStateInfo state = animator.GetCurrentAnimatorStateInfo(0);
bool inAttack = state.shortNameHash == AttackStateHash;
if (!inAttack)
{
if (hitBoxOpened)
{
combat.CloseHitBox();
hitBoxOpened = false;
}
return;
}
float t = state.normalizedTime;
bool activeFrame = t >= 0.3f && t <= 0.48f;
if (activeFrame && !hitBoxOpened)
{
combat.OpenHitBox();
hitBoxOpened = true;
}
else if (!activeFrame && hitBoxOpened)
{
combat.CloseHitBox();
hitBoxOpened = false;
}
}
}这种方式的好处:
有效帧范围写在代码里
不用担心动画事件方法名写错
更容易版本管理和搜索坏处:
动画长度变了,代码里的 0.3 / 0.48 也要跟着调
动画师不如在时间轴上直观看到事件点常见实现方式 4:StateMachineBehaviour 兜底关闭
攻击动画可能被打断。
比如:
Attack 播到一半
角色被敌人打中
Animator 从 Attack 切到 Hit如果这时 HitBox 已经打开,但还没执行 CloseHitBox 动画事件,就会出大问题:
武器判定一直开着
角色不攻击也能碰到敌人造成伤害所以需要兜底。
可以用 StateMachineBehaviour:
c
using UnityEngine;
public class AttackStateBehaviour : StateMachineBehaviour
{
public override void OnStateExit(
Animator animator,
AnimatorStateInfo stateInfo,
int layerIndex)
{
PlayerCombat combat = animator.GetComponent<PlayerCombat>();
if (combat != null)
{
combat.ForceEndAttack();
}
}
}把这个 Behaviour 挂到 Attack State 上。
作用:
无论 Attack 是正常播完,还是被打断,只要离开 Attack State,就强制关闭 HitBox。这个很重要,面试里说出来很加分。
推荐的项目结构
比较稳的结构是:
c
PlayerAttackInput
负责接收输入,触发 Animator 参数
Animator Controller
负责播放攻击动画
Animation Event
只负责打开/关闭攻击窗口
PlayerCombat
负责攻击窗口、命中记录、伤害计算
WeaponHitBox / OverlapAttack
负责检测敌人
EnemyHealth
负责承受伤害不要让动画事件直接做所有事情。
更好的职责划分:
动画:告诉现在挥到哪里了
判定:判断有没有打中
战斗系统:决定伤害是多少
敌人系统:处理扣血和死亡攻击判定常见类型
近战武器常见:
c
武器 Trigger Collider
OverlapBox
OverlapSphere
SphereCast
CapsuleCast拳头/刀剑:
c
武器挂 Trigger Collider
或者使用 OverlapBox长枪/突刺:
c
CapsuleCast / SphereCast范围技能:
c
OverlapSphere扇形技能:
OverlapSphere 找目标
再用角度 dot 判断是否在扇形内简单扇形判定:
c
Vector3 toTarget = (target.position - transform.position).normalized;
float dot = Vector3.Dot(transform.forward, toTarget);
float angle = Mathf.Acos(dot) * Mathf.Rad2Deg;
if (angle <= 45f)
{
// 在前方 90 度扇形内
}为什么不要整段动画都开判定
因为攻击动画通常有三个阶段:
前摇:给玩家和敌人反应时间
有效帧:真正能打到人
后摇:攻击后的硬直如果整段都开:
前摇也能打到人,不公平
后摇也能打到人,手感怪
容易重复命中
攻击节奏失控好的动作游戏一定会控制:
起手时间
有效帧
收招时间
取消窗口
连招窗口
无敌帧攻击判定同步就是其中最基础的一环。
连招时怎么处理
连招一般是:
c
Attack1
Attack2
Attack3每一段攻击都有自己的有效帧窗口。
可以设计:
c
Attack1_OpenHitBox
Attack1_CloseHitBox
Attack1_OpenComboWindow
Attack1_CloseComboWindow
Attack2_OpenHitBox
Attack2_CloseHitBox
...但方法太多会乱。
更推荐做成通用事件:
c
public void Anim_OpenHitBox()
{
combat.OpenHitBox();
}
public void Anim_CloseHitBox()
{
combat.CloseHitBox();
}
public void Anim_OpenComboWindow()
{
combat.OpenComboWindow();
}
public void Anim_CloseComboWindow()
{
combat.CloseComboWindow();
}Combat 根据当前攻击段数决定伤害:
c
public int CurrentAttackIndex { get; private set; }
private int CalculateDamage()
{
if (CurrentAttackIndex == 1) return 10;
if (CurrentAttackIndex == 2) return 15;
if (CurrentAttackIndex == 3) return 25;
return 10;
}联网游戏要注意
如果是单机游戏:
客户端动画事件开判定,通常可以接受如果是联网游戏:
不能完全相信客户端动画事件更稳的是:
服务器决定攻击是否命中
客户端动画事件只负责表现
或者客户端预测,服务器校验因为否则玩家可能作弊:
改动画事件
提前打开判定
扩大判定范围
多次触发伤害面试可以补一句:
联网项目里,动画事件最多作为表现层同步点,真正命中和伤害应由服务器或权威逻辑判断。常见坑
坑 1:只打开不关闭。
结果:
HitBox 永远开着
角色碰到敌人就造成伤害解决:
c
CloseHitBox
OnStateExit 兜底
OnDisable 也关闭可以加:
c
private void OnDisable()
{
ForceEndAttack();
}坑 2:一次攻击打同一个敌人多次。
解决:
c
HashSet 记录本次攻击已经命中过的目标坑 3:动画被打断,关闭事件没走到。
解决:
c
StateMachineBehaviour.OnStateExit 兜底关闭坑 4:伤害写死在动画事件里。
解决:
c
动画事件只通知 Combat
Combat 统一算伤害坑 5:HitBox 一直启用。
推荐:
c
平时 disabled
有效帧 enabled
结束后 disabled坑 6:武器 HitBox 打到自己。
解决:
c
LayerMask
IgnoreCollision
阵营判断
不要让武器检测 Player 自己的 HurtBox面试高分回答
IMPORTANT
角色攻击判定一般不会整段动画都开启,而是根据攻击动画的有效帧打开和关闭 HitBox。常见做法是在 Animation Clip 上放 Animation Event,在挥刀真正命中的时间点调用 OpenHitBox,在收招时调用 CloseHitBox。HitBox 可以是武器上的 Trigger Collider,也可以是 OverlapBox、OverlapSphere、SphereCast 这类主动检测。为了避免一次攻击重复打同一个目标,我会用 HashSet 记录本次攻击已经命中过的对象。为了避免动画被打断导致 HitBox 没关闭,我会在 StateMachineBehaviour.OnStateExit 或战斗状态退出时做兜底关闭。动画事件只负责同步时机,最终伤害结算交给 Combat 系统处理,联网项目则应由服务器或权威逻辑决定命中和伤害。
最短记忆版
c
攻击同步 = 动画有效帧 + HitBox 开关 + 命中去重 + 打断兜底。
动画事件:负责时机。
HitBox:负责检测。
Combat:负责伤害。
HashSet:防止重复命中。
OnStateExit:防止打断后判定没关闭。Avatar Mask 有什么用?
一句话
Avatar Mask 是 Animator 里的身体部位过滤表,它可以让某一层动画只影响身体的一部分。
最经典例子:
下半身:继续跑步
上半身:播放开枪 / 挥刀 / 换弹动画也就是:
腿在跑
手在攻击
两个动画同时生效
但互不乱抢身体控制权零基础比喻
你可以把角色身体分成几块:
头
胸口
左手
右手
左腿
右腿如果没有 Avatar Mask,一个攻击动画可能会影响整个身体:
上半身挥刀
腿也跟着攻击动画乱动
跑步动画被覆盖用了 Avatar Mask 后,你可以告诉 Unity:
这一层攻击动画只控制上半身。
腿和脚不要动,继续交给跑步动画控制。所以它的核心作用是:
控制某个动画层影响哪些骨骼。它通常和 Animator Layer 一起用
Avatar Mask 很少单独说,一般是和 Animator Controller 的 Layer 一起使用。
比如 Animator 里有两层:
c
Base Layer:控制全身移动
UpperBody Layer:控制上半身攻击Base Layer 播:
c
Idle / Walk / Run / JumpUpperBody Layer 播:
c
Shoot / Reload / Attack然后给 UpperBody Layer 设置一个 Avatar Mask:
c
勾选:Head、Body、Left Arm、Right Arm
不勾:Left Leg、Right Leg最终效果:
角色边跑边开枪
角色边走边换弹
角色边移动边挥刀常见用途
Avatar Mask 最常用在这些场景:
跑步时上半身开枪
移动时上半身挥刀
边走边换弹
只播放上半身受击
只播放面部表情
只让手臂做瞄准姿势
只让头部看向目标比如第三人称射击游戏:
下半身:跑步 Blend Tree
上半身:瞄准 / 开枪 / 换弹如果不用 Mask,开枪动画可能把腿部姿势也覆盖掉,角色会看起来像站着开枪,跑步动作丢了。
怎么创建 Avatar Mask
在 Unity 里:
Project 面板右键
Create
Avatar Mask创建后你可以看到两种常见配置方式:
Humanoid:用人体部位勾选
Transform:按骨骼层级勾选如果角色是 Humanoid Rig,通常用:
c
Head
Body
Left Arm
Right Arm
Left Leg
Right Leg
IK如果是 Generic Rig,比如怪物、四足动物、机械臂,就常用:
Transform Mask手动指定哪些骨骼参与。
怎么在 Animator Layer 里使用
步骤大概是:
1. 打开 Animator Controller
2. 找到 Layers 面板
3. 新增一个 Layer,比如 UpperBody
4. 给这个 Layer 设置 Avatar Mask
5. 设置 Layer Weight
6. 选择 Override 或 Additive常见设置:
Base Layer:默认全身移动
UpperBody Layer:Mask = 上半身
UpperBody Weight = 1
Blending = OverrideWeight 是层权重:
0:这一层完全不生效
0.5:这一层影响一半
1:这一层完全生效Override 和 Additive 区别
Override 是覆盖。
比如:
Base Layer 正在跑步
UpperBody Layer 播放开枪
上半身姿势被开枪动画覆盖常用于:
攻击
开枪
换弹
举盾Additive 是叠加。
比如:
在原有动作基础上叠加一点呼吸
叠加后坐力
叠加轻微受击抖动常用于:
呼吸
后坐力
轻微受击
姿态修正零基础先记:
Override:我要替换这部分动作
Additive:我要在原动作上加一点变化代码例子:控制上半身攻击层权重
假设 Animator 里有一个 Layer 叫:
c
UpperBody参数:
c
Attack:Trigger代码如下,里面我加了注释:
c
using UnityEngine;
public class UpperBodyAttackController : MonoBehaviour
{
public Animator animator;
// Animator Layer 的名字,必须和 Animator 窗口里的 Layer 名字一致
private const string UpperBodyLayerName = "UpperBody";
// 使用 Hash 可以避免每帧用字符串查找参数,提高一点效率,也减少拼写散落
private static readonly int AttackHash = Animator.StringToHash("Attack");
// 缓存 Layer 索引,避免每次攻击都重新查找
private int upperBodyLayerIndex;
private void Awake()
{
// 根据 Layer 名字找到它在 Animator 里的索引
upperBodyLayerIndex = animator.GetLayerIndex(UpperBodyLayerName);
// 初始化时先关闭上半身层,避免一开始就影响角色姿势
animator.SetLayerWeight(upperBodyLayerIndex, 0f);
}
private void Update()
{
// 鼠标左键按下时,触发攻击动画
if (Input.GetMouseButtonDown(0))
{
StartUpperBodyAttack();
}
}
private void StartUpperBodyAttack()
{
// 打开上半身层,让 UpperBody Layer 的动画开始影响角色上半身
animator.SetLayerWeight(upperBodyLayerIndex, 1f);
// 触发攻击状态,Animator Controller 会根据 Attack Trigger 切到攻击 State
animator.SetTrigger(AttackHash);
}
// 可以通过 Animation Event 在攻击动画结尾调用这个方法
public void Anim_EndUpperBodyAttack()
{
// 攻击结束后关闭上半身层,让角色回到普通移动姿势
animator.SetLayerWeight(upperBodyLayerIndex, 0f);
}
}这个结构的意思是:
平时 UpperBody Layer 权重为 0,不影响角色
攻击时权重变成 1,上半身攻击动画生效
攻击结束时权重回到 0更平滑的写法:逐渐改变 Layer Weight
上面的写法是瞬间开关:
c
0 → 1
1 → 0有时会有一点突兀。
可以做成平滑过渡:
c
using UnityEngine;
public class SmoothUpperBodyLayer : MonoBehaviour
{
public Animator animator;
// 上半身层名称
private const string UpperBodyLayerName = "UpperBody";
// Attack 参数 Hash
private static readonly int AttackHash = Animator.StringToHash("Attack");
// 上半身层索引
private int upperBodyLayerIndex;
// 当前层权重
private float currentWeight;
// 目标层权重,攻击时是 1,结束时是 0
private float targetWeight;
// 权重变化速度,值越大变化越快
public float blendSpeed = 8f;
private void Awake()
{
// 缓存 Layer 索引
upperBodyLayerIndex = animator.GetLayerIndex(UpperBodyLayerName);
// 初始关闭上半身动画层
currentWeight = 0f;
targetWeight = 0f;
animator.SetLayerWeight(upperBodyLayerIndex, currentWeight);
}
private void Update()
{
// 平滑地把当前权重靠近目标权重
currentWeight = Mathf.MoveTowards(
currentWeight,
targetWeight,
blendSpeed * Time.deltaTime
);
// 把平滑后的权重设置到 Animator Layer
animator.SetLayerWeight(upperBodyLayerIndex, currentWeight);
// 按下攻击键时开始攻击
if (Input.GetMouseButtonDown(0))
{
StartAttack();
}
}
private void StartAttack()
{
// 让上半身层逐渐生效
targetWeight = 1f;
// 触发攻击动画
animator.SetTrigger(AttackHash);
}
// 动画事件:攻击动画结束时调用
public void Anim_EndAttack()
{
// 让上半身层逐渐淡出
targetWeight = 0f;
}
}这样上半身攻击层不会突然出现或消失,过渡会更自然。
代码例子:边跑边开枪
参数:
c
Speed:Float,Base Layer 用来控制移动 Blend Tree
Shoot:Trigger,UpperBody Layer 用来控制射击代码:
c
using UnityEngine;
public class RunAndShootAnimator : MonoBehaviour
{
public Animator animator;
// 移动速度参数,用来驱动 Base Layer 的 Locomotion Blend Tree
private static readonly int SpeedHash = Animator.StringToHash("Speed");
// 射击 Trigger,用来驱动 UpperBody Layer 的 Shoot State
private static readonly int ShootHash = Animator.StringToHash("Shoot");
private void Update()
{
// 读取玩家移动输入
float h = Input.GetAxisRaw("Horizontal");
float v = Input.GetAxisRaw("Vertical");
// 把输入转换成 0 到 1 的速度值
Vector2 input = Vector2.ClampMagnitude(new Vector2(h, v), 1f);
float speed = input.magnitude;
// 设置移动速度,让下半身继续跑步、走路或待机
animator.SetFloat(SpeedHash, speed, 0.1f, Time.deltaTime);
// 鼠标左键开枪
if (Input.GetMouseButtonDown(0))
{
// 触发上半身射击动画
animator.SetTrigger(ShootHash);
}
}
}Animator 结构:
c
Base Layer:
Locomotion Blend Tree
Idle / Walk / Run
UpperBody Layer:
Avatar Mask = 上半身
Shoot State
Reload State
Aim State效果:
Base Layer 让腿继续跑
UpperBody Layer 让手开枪
Avatar Mask 防止开枪动画影响腿Avatar Mask 和 Animation Clip 的关系
Avatar Mask 不是动画。
它不会自己播放任何东西。
它只是告诉 Animator:
这一层动画只允许影响哪些骨骼比如:
Shoot.anim 本来可能记录了全身动作
但 UpperBody Layer 绑了只包含上半身的 Avatar Mask
所以 Shoot.anim 最终只影响上半身Avatar Mask 和 Layer 的关系
可以这样理解:
c
Layer 决定有几层动画
Avatar Mask 决定这一层影响身体哪里
Layer Weight 决定这一层影响强度
Blend Mode 决定覆盖还是叠加组合起来就是:
c
Base Layer:全身跑步
UpperBody Layer + Avatar Mask:上半身开枪
Additive Layer + Avatar Mask:上半身后坐力
Face Layer + Avatar Mask:脸部表情Humanoid 和 Generic 的区别
如果你的角色 Rig 是 Humanoid:
可以直接用人体部位勾选
比如 Head、Body、Left Arm、Right Arm如果是 Generic:
c
比如怪物、动物、机器人
通常要用 Transform Mask
手动选择骨骼层级常见例子:
c
人形角色:Humanoid Mask
怪物尾巴:Transform Mask
机械臂:Transform Mask
四足动物前半身:Transform Mask常见坑
坑 1:Layer Weight 是 0。
你设置了 Mask,也放了动画,但没效果。
检查:
Animator Layer Weight 是否为 1如果是 0,这一层完全不生效。
坑 2:Mask 没有勾对身体部位。
比如你想上半身开枪,但只勾了 Head,没有勾手臂。
结果:
头动了,手不动坑 3:误勾了腿部。
上半身层如果把腿也勾上,可能会覆盖跑步动画。
结果:
开枪时腿部动作突然变怪坑 4:Override / Additive 选错。
攻击、射击一般用:
Override呼吸、后坐力这类轻微叠加通常用:
Additive坑 5:Root Motion 被上层动画影响理解错误。
Avatar Mask 主要控制骨骼姿势影响范围,不要把它当成移动系统。
角色真实位移通常还是由:
c
脚本移动
Root Motion
Rigidbody
CharacterController决定。
坑 6:以为 Avatar Mask 会影响碰撞体。
不会。
它只影响动画姿势,不会自动改变:
c
Collider
HitBox
HurtBox
伤害范围
物理刚体如果攻击姿势变化了,武器 HitBox 是否跟着对,需要你自己检查武器节点和碰撞体设置。
面试高分回答
TIP
Avatar Mask 用来限制 Animator 某一层动画影响的骨骼范围,通常配合 Animator Layer 使用。比如 Base Layer 播放全身移动动画,UpperBody Layer 通过 Avatar Mask 只控制头、身体和手臂,这样角色可以边跑边开枪或换弹。它的本质是身体部位过滤表,不是动画本身。项目里我会用它做上半身攻击、瞄准、换弹、受击、面部表情等分层动画,同时注意 Layer Weight、Override/Additive 模式、Humanoid/Generic Mask 的配置,以及不要让上半身层误覆盖腿部移动动画。
最短记忆版
c
Avatar Mask = 身体部位过滤表。
Layer = 第几层动画。
Weight = 这一层影响多强。
Override = 覆盖原动作。
Additive = 在原动作上叠加。
最常见用途:
下半身跑步,上半身攻击/开枪/换弹。动画层 Layer 有什么用?
一句话
Animator 里的 Layer 用来让角色同时播放多套动画逻辑,并且可以控制每一层影响身体的哪些部位、影响多强、是覆盖还是叠加。
最典型例子:
Base Layer:下半身跑步
UpperBody Layer:上半身开枪
Additive Layer:再叠加一点后坐力也就是:
腿在跑
手在开枪
肩膀有后坐力
这些可以同时发生零基础比喻
你可以把角色动画想成一张透明胶片叠一张透明胶片。
最下面一张:
跑步动画上面再盖一张:
上半身开枪动画再盖一张:
枪的后坐力抖动最后 Unity 把这些层合成起来,角色就表现为:
一边跑,一边开枪,同时肩膀有后坐力这就是 Animator Layer 的作用。
没有 Layer 会怎样
如果所有动画都塞在一层里,你可能要做很多状态:
c
Idle
Run
Shoot
RunShoot
Reload
RunReload
Aim
RunAim
Hit
RunHit状态会越来越多,Transition 也会乱。
而且你很难表达:
下半身跑步不要停
上半身单独开枪因为一层动画通常会争夺整套身体控制权。
有 Layer 后怎么拆
更合理的拆法:
c
Base Layer:
Idle / Walk / Run / Jump
UpperBody Layer:
Shoot / Reload / Attack / Aim
Additive Layer:
Recoil / Breath / SmallHit这样结构更清楚:
移动归移动层
攻击归上半身层
轻微叠加效果归 Additive 层Layer 里的几个关键概念
Weight 是层权重。
Weight = 0:这一层完全不生效
Weight = 0.5:这一层影响一半
Weight = 1:这一层完全生效比如上半身开枪:
c
不瞄准时 UpperBody Weight = 0
瞄准时 UpperBody Weight = 1Avatar Mask 是身体部位过滤表。
上半身层只影响头、胸、手臂
不影响腿和脚如果没有 Avatar Mask,上半身开枪动画可能会把腿部跑步也覆盖掉。
Override 是覆盖。
上层动画覆盖下层对应身体部位适合:
开枪
换弹
攻击
举盾Additive 是叠加。
在原动作基础上加一点变化适合:
呼吸
后坐力
轻微受击
身体晃动
疲劳抖动最常见用法:边跑边开枪
Animator 可以这样设计:
c
Base Layer:
Locomotion Blend Tree
Idle / Walk / Run
UpperBody Layer:
Avatar Mask = 上半身
Shoot
Reload
Aim
Recoil Layer:
Additive
Recoil最终效果:
c
Base Layer 让腿继续跑
UpperBody Layer 让上半身开枪
Recoil Layer 叠加一点枪械后坐力代码例子:控制上半身层权重
下面代码里有注释:
c
using UnityEngine;
public class AnimatorLayerExample : MonoBehaviour
{
public Animator animator;
// Animator 里上半身层的名字,必须和 Animator 面板里的 Layer 名字一致
private const string UpperBodyLayerName = "UpperBody";
// Shoot 是 Animator 参数,用来触发射击动画
private static readonly int ShootHash = Animator.StringToHash("Shoot");
// 缓存上半身层的索引,避免每次都通过字符串查找
private int upperBodyLayerIndex;
private void Awake()
{
// 通过层名字获取 Layer 的索引
upperBodyLayerIndex = animator.GetLayerIndex(UpperBodyLayerName);
// 一开始先关闭上半身层,让角色只播放 Base Layer 的移动动画
animator.SetLayerWeight(upperBodyLayerIndex, 0f);
}
private void Update()
{
// 按住鼠标右键时,表示进入瞄准或战斗姿态
bool aiming = Input.GetMouseButton(1);
// 瞄准时打开上半身层,不瞄准时关闭
animator.SetLayerWeight(upperBodyLayerIndex, aiming ? 1f : 0f);
// 鼠标左键按下时触发射击动画
if (Input.GetMouseButtonDown(0))
{
animator.SetTrigger(ShootHash);
}
}
}这个代码表达的是:
右键按住:上半身层生效
左键点击:播放上半身射击动画
下半身移动仍然由 Base Layer 控制更平滑的 Layer Weight 写法
上面是瞬间从 0 到 1,有时会有点硬。
可以平滑过渡:
c
using UnityEngine;
public class SmoothAnimatorLayerWeight : MonoBehaviour
{
public Animator animator;
// 上半身层名称
private const string UpperBodyLayerName = "UpperBody";
// 上半身层索引
private int upperBodyLayerIndex;
// 当前权重
private float currentWeight;
// 目标权重
private float targetWeight;
// 权重变化速度,值越大过渡越快
public float blendSpeed = 8f;
private void Awake()
{
// 获取上半身层索引
upperBodyLayerIndex = animator.GetLayerIndex(UpperBodyLayerName);
// 初始化时关闭上半身层
currentWeight = 0f;
targetWeight = 0f;
animator.SetLayerWeight(upperBodyLayerIndex, currentWeight);
}
private void Update()
{
// 按住右键时目标权重为 1,否则为 0
targetWeight = Input.GetMouseButton(1) ? 1f : 0f;
// 让当前权重平滑靠近目标权重
currentWeight = Mathf.MoveTowards(
currentWeight,
targetWeight,
blendSpeed * Time.deltaTime
);
// 把平滑后的权重设置到 Animator Layer
animator.SetLayerWeight(upperBodyLayerIndex, currentWeight);
}
}这样效果更自然:
不瞄准 → 慢慢进入瞄准姿态
松开瞄准 → 慢慢回到普通移动姿态Layer 和 Avatar Mask 的关系
这两个经常一起出现,但不是一个东西。
Layer:第几层动画
Avatar Mask:这一层动画影响身体哪些部位比如:
c
UpperBody Layer
Mask = 上半身
Weight = 1
Mode = Override意思是:
这一层动画生效
但只影响上半身
并且覆盖下层上半身动作如果不加 Mask:
c
UpperBody Layer 可能影响全身
开枪动画可能把跑步腿部覆盖掉Layer 和 Blend Tree 的关系
Layer 里面也可以放 Blend Tree。
比如:
c
Base Layer:
Locomotion Blend Tree
Speed 控制 Idle / Walk / Run
UpperBody Layer:
Aim Blend Tree
AimX / AimY 控制上半身瞄准方向所以它们关系是:
Layer 管动画分层
Blend Tree 管同一层内部的平滑混合不要混:
c
Blend Tree 不是 Layer
Layer 也不是 Blend TreeLayer 和动画状态机的关系
每个 Layer 都可以有自己的状态机。
比如:
c
Base Layer 状态机:
Idle → Walk → Run → Jump
UpperBody Layer 状态机:
Empty → Aim → Shoot → Reload
Face Layer 状态机:
Normal → Smile → Angry → Hurt它们可以同时运行。
也就是说:
c
角色可以下半身 Run
上半身 Reload
脸部 HurtSync Layer 是什么
Animator Layer 里还有一个 Sync 概念。
简单理解:
让某一层复用另一层的状态机结构
但播放不同动画常见例子:
普通移动一套动画
受伤移动一套动画两者状态结构一样:
c
Idle / Walk / Run但 Motion 不一样:
c
普通 Idle / 普通 Walk / 普通 Run
受伤 Idle / 受伤 Walk / 受伤 Run这时可以用同步层减少重复搭状态机。
零基础阶段你先知道它用途就行:
c
Sync Layer 用来复用状态机结构,减少重复配置。IK Pass 是什么
Layer 还有 IK Pass。
它和反向动力学有关,比如:
脚贴地
手抓武器
手放到目标点
头看向目标如果你要在某层使用 Animator IK 回调,可能需要打开 IK Pass。
常见回调:
c
private void OnAnimatorIK(int layerIndex)
{
// 在这里设置手、脚、头部 IK
}零基础可以先记:
IK Pass 和 Layer 有关,但不是 Layer 的核心面试点。常见场景
场景 1:跑步时开枪。
Base Layer:跑步
UpperBody Layer:开枪
Avatar Mask:上半身场景 2:边走边换弹。
Base Layer:移动
UpperBody Layer:Reload场景 3:受击叠加。
Base Layer:当前动作
Additive Layer:轻微受击抖动场景 4:面部表情。
Base Layer:身体动作
Face Layer:笑、愤怒、痛苦
Mask:脸部骨骼场景 5:瞄准姿态。
c
Base Layer:移动
UpperBody Layer:Aim Blend Tree
参数:AimX / AimY常见坑
坑 1:Layer Weight 是 0。
你会发现:
动画状态在播放
但角色完全没变化先检查:
Layer Weight 是否为 1坑 2:忘了加 Avatar Mask。
结果:
上半身动画覆盖全身
腿部跑步动作丢失坑 3:Override 和 Additive 选错。
攻击、开枪、换弹:通常用 Override
呼吸、后坐力、轻微受击:通常用 Additive坑 4:层太多,调试困难。
不要为了“看起来高级”乱加层。
可以先从:
c
Base Layer
UpperBody Layer这两层开始。
坑 5:上层动画一直不退出。
比如开枪层权重一直是 1,导致上半身长期卡在战斗姿态。
解决:
用参数或动画事件控制权重
需要时打开,不需要时关闭坑 6:以为 Layer 会自动解决碰撞和攻击判定。
不会。
Layer 只影响动画姿势。
攻击判定、HitBox、伤害计算仍然要自己做。
面试高分回答
IMPORTANT
Animator Layer 用来把动画逻辑分层,让不同层可以同时播放并合成到角色身上。最常见的是 Base Layer 控制全身移动,上半身 Layer 通过 Avatar Mask 只控制头、胸和手臂,实现边跑边开枪、换弹或攻击。每层有 Weight 控制影响强度,也有 Override 和 Additive 两种混合方式,Override 适合攻击、开枪、换弹这类覆盖动作,Additive 适合呼吸、后坐力、轻微受击这种叠加动作。Layer 可以让状态机更清晰,但也要注意权重、Mask、混合模式和层数复杂度。
最短记忆版
c
Layer = 多层动画同时工作。
Base Layer = 主动画,通常控制全身移动。
UpperBody Layer = 上半身攻击、开枪、换弹。
Avatar Mask = 限制这一层影响身体哪里。
Weight = 这一层影响多强。
Override = 覆盖。
Additive = 叠加。如何优化大量 Animator?
一句话
大量 Animator 优化的核心是:
看不见的少更新,远处的低质量更新,近处的正常更新;
同时减少每个 Animator 的骨骼、Layer、IK、Blend Tree、参数调用成本。零基础理解
一个角色动起来,不只是“播放动画”这么简单。
Unity 可能要做这些事:
c
1. Animator 状态机判断现在播哪个状态
2. Blend Tree 混合 Idle / Walk / Run
3. Layer 混合上半身、下半身、后坐力
4. IK 计算脚贴地、手持武器
5. 把动画结果写到很多骨骼 Transform
6. SkinnedMeshRenderer 根据骨骼重新蒙皮
7. 渲染角色模型、阴影、材质一个角色还好,100 个、500 个就贵了。
第一步:先用 Profiler 确认瓶颈
不要一上来乱改。
你要先看 Unity Profiler 里到底卡在哪里:
c
Animator.Update
Animator.WriteProperties
SkinnedMeshRenderer
Skinning
Render
Scripts如果是 Animator 贵,优化状态机、层、IK、参数。
如果是 SkinnedMeshRenderer 贵,优化骨骼数、蒙皮、材质、阴影、LOD。
如果是脚本贵,优化参数设置、GetComponent、批量管理。
优化 1:开启 Animator Culling
Animator.cullingMode 可以让不可见角色少算动画。
常见模式:
c
AlwaysAnimate:一直更新,最贵,但最稳定
CullUpdateTransforms:不可见时不写 Transform、IK 等
CullCompletely:不可见时完全不更新动画示例代码,带注释:
c
using UnityEngine;
public class AnimatorCullingSetup : MonoBehaviour
{
public Animator animator;
private void Awake()
{
// 如果角色看不见时不需要继续完整播放动画,可以使用剔除模式
// 这样屏幕外角色不会白白消耗完整 Animator 成本
animator.cullingMode = AnimatorCullingMode.CullUpdateTransforms;
}
}注意:
如果你的核心逻辑依赖 Animation Event,
屏幕外动画被 Cull 后,事件可能不触发。所以面试里要说一句:
核心战斗逻辑不要依赖屏幕外 Animator。优化 2:关闭 SkinnedMeshRenderer 的离屏更新
SkinnedMeshRenderer.updateWhenOffscreen 如果开启,角色不在屏幕里也可能继续更新蒙皮。
一般大量 NPC 可以关掉:
c
using UnityEngine;
public class SkinnedMeshOffscreenSetup : MonoBehaviour
{
public SkinnedMeshRenderer[] renderers;
private void Awake()
{
foreach (SkinnedMeshRenderer renderer in renderers)
{
// 角色离开屏幕时,不继续更新 Skinned Mesh
// 适合普通 NPC、小怪、远处角色
renderer.updateWhenOffscreen = false;
}
}
}但如果角色有特殊需求,比如:
离屏也必须正确更新阴影
离屏也要准确计算包围盒
离屏动画事件还要跑那要谨慎关。
优化 3:按距离做动画 LOD
不是所有角色都要满质量。
可以分层:
c
近处:完整 Animator、IK、表情、上半身 Layer
中距离:关闭 IK、减少 Layer、降低刷新频率
远处:CullCompletely 或禁用 Animator
超远:换低模、假人、公告牌、GPU 动画示例代码,带注释:
c
using UnityEngine;
public class AnimatorDistanceLod : MonoBehaviour
{
public Animator animator;
public Transform viewer;
public float nearDistance = 15f;
public float midDistance = 35f;
public float farDistance = 60f;
private int upperBodyLayerIndex;
private void Awake()
{
// 假设 Animator 里有一个上半身层,用来播放攻击、开枪、换弹
upperBodyLayerIndex = animator.GetLayerIndex("UpperBody");
}
private void Update()
{
if (viewer == null)
{
return;
}
float distance = Vector3.Distance(transform.position, viewer.position);
if (distance < nearDistance)
{
// 近处角色:完整动画质量
animator.enabled = true;
animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;
// 上半身层正常开启
animator.SetLayerWeight(upperBodyLayerIndex, 1f);
}
else if (distance < midDistance)
{
// 中距离角色:还播放动画,但减少复杂层影响
animator.enabled = true;
animator.cullingMode = AnimatorCullingMode.CullUpdateTransforms;
// 降低上半身层权重,或者直接关闭复杂层
animator.SetLayerWeight(upperBodyLayerIndex, 0f);
}
else if (distance < farDistance)
{
// 远距离角色:动画可以更激进地剔除
animator.enabled = true;
animator.cullingMode = AnimatorCullingMode.CullCompletely;
}
else
{
// 超远角色:如果不需要动画表现,可以直接停掉 Animator
animator.enabled = false;
}
}
}实际项目里不要让每个角色每帧都算距离,可以用管理器每 0.2 秒批量更新一次。
优化 4:减少 Animator Controller 复杂度
大量 Animator 最怕每个角色都有复杂状态机。
尽量减少:
c
过多 Layer
过多 Blend Tree
过多 Transition
过多 Any State
过多 IK Pass
过多 StateMachineBehaviour
过多 Animation Event比如 200 个小怪,不一定每个都需要:
上半身 Layer
表情 Layer
Additive 呼吸层
脚 IK
复杂 2D Blend Tree小怪可以用更简单的 Controller:
c
Idle
Move
Attack
Die精英怪和主角再用复杂动画系统。
优化 5:关闭不必要的 IK
IK 很方便,但不是免费的。
比如:
脚贴地 IK
手持武器 IK
头部看向目标 IK近处主角可以开。
远处 NPC 可以关。
如果你在 Animator Layer 开了 IK Pass,大量角色会增加成本。
优化思路:
主角:开 IK
近处重要 NPC:开部分 IK
远处怪物:关 IK
人群:完全不用 IK优化 6:减少骨骼和 Skinned Mesh 成本
动画不只是 Animator 成本,骨骼蒙皮也贵。
可以优化:
减少骨骼数量
减少每个顶点受几个骨骼影响
减少 SkinnedMeshRenderer 数量
减少材质数量
关闭远处角色阴影
使用 LODGroup
远处用低模低骨骼版本一个角色如果拆成很多 SkinnedMeshRenderer:
身体一个
头发一个
衣服一个
武器挂件很多个大量角色时开销会很明显。
优化 7:参数设置不要乱写
不要每帧无脑写一堆 Animator 参数。
不推荐:
c
void Update()
{
// 每帧都用字符串设置参数,角色多了会增加不必要开销
animator.SetFloat("Speed", speed);
animator.SetBool("IsGrounded", isGrounded);
animator.SetBool("IsAiming", isAiming);
}更推荐:
c
using UnityEngine;
public class AnimatorParamOptimizer : MonoBehaviour
{
public Animator animator;
// 把字符串转成 Hash,避免到处重复写字符串
private static readonly int SpeedHash = Animator.StringToHash("Speed");
private static readonly int IsGroundedHash = Animator.StringToHash("IsGrounded");
private float lastSpeed = -1f;
private bool lastGrounded;
public void SetSpeed(float speed)
{
// 如果速度变化很小,就不重复设置 Animator 参数
if (Mathf.Abs(speed - lastSpeed) < 0.01f)
{
return;
}
lastSpeed = speed;
// 使用带阻尼的 SetFloat,让动画参数平滑变化
animator.SetFloat(SpeedHash, speed, 0.1f, Time.deltaTime);
}
public void SetGrounded(bool grounded)
{
// 只有状态真的变化时,才写入 Animator
if (grounded == lastGrounded)
{
return;
}
lastGrounded = grounded;
animator.SetBool(IsGroundedHash, grounded);
}
}Animator.StringToHash 的作用是:
把参数名字符串变成 int id
减少字符串查找
减少拼写散落
更适合大量调用优化 8:不要让无效角色还在跑 Animator
死亡、隐藏、对象池里的角色要停掉。
c
using UnityEngine;
public class AnimatorLifeCycle : MonoBehaviour
{
public Animator animator;
public void OnSpawn()
{
// 对象从池里拿出来时,重新启用 Animator
animator.enabled = true;
// 可选:让 Animator 回到初始状态
animator.Rebind();
animator.Update(0f);
}
public void OnDespawn()
{
// 对象放回池里时,关闭 Animator,避免后台继续更新
animator.enabled = false;
}
}注意:
对象池复用时,要重置 Animator 状态。
否则上一个怪物的攻击/死亡状态可能残留。优化 9:超大量人群不要每个都用完整 Animator
如果是几十个角色,正常 Animator 优化就够了。
如果是几百上千个:
城市场景人群
战场士兵
观众席
远处怪群就不要每个都用完整 Mecanim Animator。
可以考虑:
GPU Animation
动画烘焙到贴图
顶点动画
Impostor / Billboard
低频采样动画
远处只播放简单循环面试里这句很加分:
大量人群不是优化 Animator,而是避免给每个人上完整 Animator。常见坑
只优化脚本,不看 SkinnedMeshRenderer
屏幕外还 AlwaysAnimate
updateWhenOffscreen 一直开着
每个小怪都有复杂 Layer 和 IK
每帧大量 SetFloat / SetBool
死亡对象 Animator 还 enabled
动画事件承担核心逻辑,导致 Cull 后逻辑异常
远处 NPC 还用主角级动画资源面试高分回答
CAUTION
优化大量 Animator 我会先用 Profiler 判断瓶颈是在 Animator 状态机、骨骼写入,还是 SkinnedMeshRenderer 蒙皮渲染。然后按距离和可见性做分级:屏幕外用 Animator Culling,远处降低动画质量或停止 Animator,中距离关闭 IK、表情和复杂 Layer,近处才保留完整动画。单个角色层面会减少骨骼数、SkinnedMeshRenderer 数、Animator Layer、Blend Tree 和 IK Pass;代码层面会缓存 Animator 参数 Hash,只在参数变化时 SetFloat/SetBool,并对对象池角色正确启停 Animator。超大量人群则考虑 GPU Animation、烘焙动画或更简单的表现方案,而不是每个角色都跑完整 Animator。
最短记忆版
c
先 Profiler。
看不见就 Cull。
远处就降级。
少骨骼、少层、少 IK。
参数用 StringToHash。
死亡/池中对象关 Animator。
几百上千人群别全用完整 Animator。参考:Unity 官方 Animator.cullingMode、AnimatorCullingMode、SkinnedMeshRenderer.updateWhenOffscreen、Animator.StringToHash。