第 40 章 动画系统:让模型”活”起来的两套机关
你有没有盯着游戏里一个角色发呆过:他站着不动的时候,就是一尊雕塑;可一旦他迈开腿、挥起刀、被击中的瞬间身体一颤,你就”相信”他是个活物了。这一章我想先问你一句扎心的话——你眼睛看到的”活”,到底是怎么从那个一堆三角形拼出来的模型里冒出来的?
我先不讲 Unity 里那个 Animator 窗口怎么拖,也不急着给你看状态机长什么样。咱们先用”溯源学习法”把根子刨出来:所谓”动画”,在数学和图形上到底是什么?你只有先想明白”它本质上在改什么”,后面那些 Animator、Animation Clip、Blend Tree 才不是一堆记不住的名词,而是一套你闭眼都能推出来的”必然如此”的设计。
你写 Go 后端时,一个对象的”状态变化”是你自己在业务代码里逐字段改的;游戏里一个模型的”动作变化”,底层也是”逐字段改”——只不过改的不是业务字段,而是顶点或骨头的位置、旋转、缩放。想通这一层,动画系统对你就不神秘了。
40.1 现象:静止的模型和会走的模型,差在哪
先说现象。你在第 4 章学过,一个 3D 模型本质上就是一堆三角形,每个三角形由顶点(vertex)定义位置。一个角色可能有几万个顶点。当你看到一个角色”站着”,那是渲染管线把这几万个顶点的当前位置画了出来;当你看到他”走路”,那其实是这几万个顶点的位置,随时间在变。
所以动画的”现象层”谜底很朴素:动画 = 随时间改变顶点的变换(transform)。 你第 2 章就学了”变换 = 搬坐标”,第 27 章你又亲手把”局部坐标经过 Model 矩阵搬到世界”走了一遍。动画干的事,无非是——让那些顶点的变换,不是固定值,而是”时间的函数”。
那现实问题来了:一个角色几万个顶点,你打算怎么让他动?最蠢的办法是”逐帧记录每个顶点该在哪”。假设 3 万个顶点,每帧存每个顶点的 (x,y,z),动画 30 秒、每秒 30 帧,那就是 3 万 × 3 × 30 × 30 ≈ 8 千万个浮点数。一段动画就吃掉几十 MB,一个游戏几百段动画就爆了。而且更致命的是——你让美术一帧一帧手摆几万个顶点?这活儿没法干。
工程师的方案,就是这一章的标题里”两套机关”的第一套:骨骼动画(Skeletal Animation)。它的核心思想是——别动那几万个顶点,去动”几根骨头”,再让顶点”贴”在骨头上跟着走。这就跟提线木偶一个道理:木偶师不重塑木偶的每一寸木头,他只拉那十几根线,整只木偶就活了。骨头,就是那十几根线;顶点,就是木偶的皮。
落进引擎:Unity 里你看到的那个”骨骼”(在 Skinned Mesh 里),就是这套线;动画系统每帧算好每根骨头的变换,再把顶点”蒙”上去。这就是你第 18 章见过的 SkinnedMeshRenderer——它和普通的 MeshRenderer 区别就在这:它的顶点会被骨骼带动,而普通 MeshRenderer 的顶点是死的。
反问你一句:如果”动画 = 随时间改变换”这个本质你认了,那”为什么一定要用骨头,而不能让美术直接把几万个顶点每帧摆好”这个问题,你心里的答案应该已经冒出来了——是为了省数据和自然。咱们下一节把这两点凿实。
给你一个更具体的数字感,免得”省数据”只是句空话:假设一个角色两万顶点,走路动画两秒、每秒 24 帧。逐顶点方案要存 20000 × 3(xyz)× 48(帧)≈ 288 万个浮点数,光这一段就约 11 MB;而骨骼方案只需 30 根骨头 ×(旋转四元数 4 + 位移 3)× 48 帧 ≈ 1 万浮点数,才几十 KB。同样一段动画,数据量差两百多倍。更狠的是:骨骼动画的”几根骨头”还能被所有同类角色共用,逐顶点方案每段动画却要为不同外形重新存一遍。这就是为什么你看到的游戏里成百上千的 NPC 能同时动,而硬盘没被动画撑爆——省下来的每一 KB,都是工程师用”绑骨头”换的。
40.2 骨骼动画:给模型绑一套”骨头”,顶点被骨头带着走
先把”骨骼”(skeleton)这个词落地。骨骼不是你眼睛看到的模型,而是一棵层级树(hierarchy),树上每个节点叫一根”骨头”(bone)。你回想第 2 章讲的”父子局部坐标”——一个坦克的炮管,它的位置是相对坦克说的(局部坐标),坦克一移动炮管跟着动。骨骼用的正是同一套层级逻辑。
一个人物骨骼大概长这样:根骨头在盆骨,盆骨下挂脊柱,脊柱挂左肩、右肩,左肩挂大臂,大臂挂小臂,小臂挂手……这是一个典型的父子链。当你让”大臂”这根骨头绕肩膀旋转 30 度,“小臂”和”手”作为它的子节点,会自动跟着转——因为子物体的世界变换 = 父的 Model 矩阵 × 子的局部变换(这正连回你第 27 章亲手推过的”父子层级链 = 一串 Model 矩阵连乘”)。所以”抬起大臂,整条胳膊跟着抬”,在数学上不是魔法,是矩阵乘法链的连锁反应。
那顶点怎么”贴”在骨头上?这里有个关键概念,首现必须解释:蒙皮(skinning)。美术在绑骨时,会给每个顶点指定”它主要受哪几根骨头影响、各占多少权重(weight)“。比如手肘附近的顶点,可能 70% 跟小臂走、30% 跟大臂走。每帧渲染时,引擎对每个顶点做这样一件事:
顶点世界位置 = 各影响骨头的变换 × 顶点局部位置 × 各自权重,再加和
这就是**线性蒙皮(Linear Blend Skinning,LBS)**的核心算式。骨头一转,顶点按权重被”拽”过去,于是胳膊弯曲时肘部皮肉是平滑过渡的,而不是断裂的。你把它想象成:橡皮泥糊在一个铁丝骨架(骨头)上,你掰铁丝,橡皮泥跟着变形——橡皮泥就是顶点,铁丝就是骨骼,糊的”黏度”就是权重。
现在回答”为什么用骨骼而不是逐顶点”,工程师的理由你该能自己列出来了:
- 省数据:一段走路动画,只需记录几十根骨头的旋转/位移序列(骨骼数远小于顶点数),不用记录几万个顶点的位置序列。数据量差几个数量级。
- 自然:关节本质是”绕轴旋转”,用骨头旋转描述关节弯曲,符合人体/生物结构,弯曲起来顺滑;逐顶点手工摆,关节处极易撕裂穿帮。
- 可复用(换皮):同一套骨骼的走/跑/跳动画,可以套到不同外形(不同蒙皮)的角色上。你第 17、18 章反复见的”组合/复用”思想,在这里又出现一次——动画资产和模型资产解耦。
这里再咬合一处你学过的:骨骼每根骨头的变换,最终仍是喂给第 27 章那条”模型局部 → 世界”的链。所谓”播放动画”,本质就是每帧用动画数据刷新各骨头的局部变换,再经父子层级连乘得到每根骨头的世界矩阵,最后用蒙皮把顶点拉到正确位置。你看,第 2 章的父子坐标、第 27 章的变换链,在这里又一次严丝合缝地咬合。你调动画时不用手算矩阵,但知道”它底层就在刷新骨头变换”,你就再也不会把 Animator 当成黑箱。
一个容易混的点:骨骼动画改的是骨头的变换,顶点是被”带着走”的,所以顶点本身没有独立的关键帧。这跟后面 40.4 要讲的关键帧不矛盾——关键帧记在骨头上,不在顶点上。
40.3 动画状态机 Animator:Idle / Run / Attack 之间怎么切
光有”一段走路动画”还不够。你玩游戏时,角色不会永远走路——他站着发呆(Idle)、走(Walk)、跑(Run)、攻击(Attack)、被击中(Hit)、死亡(Die)。现实问题是:这么多动作片段,什么时候播哪个、怎么从 A 平滑切到 B?
你要是没学过状态机,第一反应可能是写一大坨 if (speed > 0) PlayWalk(); else if (speed > runThreshold) PlayRun(); ...。我告诉你,真项目里这么写,三个月后这段逻辑就是谁都不敢碰的屎山——条件互相打架、切换顺序混乱、加一个新动作要改二十个地方。
工程师的方案,你第 39 章会专门学的有限状态机(FSM,Finite State Machine):把”正在播的动画”当成一个状态,把”切到另一个动画”当成状态之间的转移(transition),把”什么条件下转移”写成转移条件。Animator,本质上就是一个专为动画定制、还能在编辑器里可视化拖拽的状态机。
举个具体例子:状态有 Idle、Walk、Run、Attack。转移条件:
- 当”速度 Speed > 0.1”时,
Idle → Walk; - 当”Speed > 0.8”时,
Walk → Run; - 当”按下攻击键(一个 Trigger 参数)“时,从任意移动态
→ Attack,播完回到原态。
你写 Go 时订单有”待支付→已支付→已发货→已完成”的流转,那玩意和第 39 章讲的 FSM 是一个脑子;Animator 不过是把”状态 = 订单阶段”换成了”状态 = 动画片段”,把”事件 = 用户付款”换成了”参数 = 速度/触发”。所以你不是从零学一个东西,你是在用已有的 FSM 思维,去理解一个图形化的 FSM 编辑器。 这正应了第 35 章说的——你已有的编程脑子,就是学游戏的最强杠杆。
但这里有个走路、跑步切换的刚需,纯硬切状态机会难看:从 Walk 突然跳到 Run,角色动作会”啪”地一跳,不连贯。工程师的解法是 Animator 里一个专门机制:混合树(Blend Tree)。
混合树解决的是”两个动作之间平滑过渡”。它不把 Walk 和 Run 当成两个互不相干的开关状态,而是把它们放在同一棵”混合树”下,用一个连续参数(比如 Speed)在两者间插值:Speed 低时偏 Walk 姿势,Speed 高时偏 Run 姿势,中间值是两者的融合。这连回你第 37 章会专讲的插值(lerp)——两个姿势(其实是两组骨头变换)按 result = lerp(walkPose, runPose, t) 线性混合,t 就是 Speed 归一化后的值。你走路到跑步,本就是个连续频谱,不是开关,混合树就是用插值把”连续”还给了你。
类比帮你记:Walk 和 Run 像电风扇的 1 档和 3 档。硬切状态机是你直接把档位从 1 掰到 3,“咔”一下转速突变;混合树是你中间经过了 2 档,转速平滑爬升。玩家眼睛对”突变”极敏感,对”连续”则毫无察觉——游戏手感(第 23 章讲的”游戏感”)一大半就藏在这种连续里。
40.4 关键帧与曲线:动画 = 关键帧之间插值,曲线控制快慢
你可能会想:一段动画 30 秒,难道要存 30×30 = 900 个时刻每根骨头的变换?那也太占地方。工程师的方案是关键帧(Keyframe):只在少数”关键时间点”记录骨头的变换,中间没记录的帧,用插值算出来。
比如”举手”动画,美术只在第 0 帧记”手臂下垂”、第 20 帧记”手臂举到顶”,中间第 1、2、…、19 帧的胳膊角度,由引擎按插值补上。这又连回第 37 章的插值:默认是线性插值(linear,匀速),即每一帧角度增量相同。
但线性插值有个毛病:真实动作很少是匀速的。你抬手,起步慢、中间快、快到顶时又慢下来(缓动 ease)。如果举手全程匀速,看起来像机器人、像提线木偶被生拉。所以动画系统允许你给每个属性画一条动画曲线(Animation Curve),曲线横轴是时间、纵轴是属性值,曲线的斜率就是”这一刻变得多快”。你把它调成”两头平、中间陡”,就是 ease-in-out(缓入缓出),动作立刻有了”生命感”。
术语落地:曲线在这里不是你数学课画的 y=f(x) 函数图那么抽象,它就是动画编辑器里那条你能拖来拖去的折线/贝塞尔线。你第 23 章学”游戏感”时说的”打击感来自顿帧和缓动”,底层就是这条曲线——攻击动画在命中的那一帧附近,把位移曲线压平(顿一下),再猛地弹开,就是”打击感”的来源。
反问:你现在回头看 40.2 说的”动画 = 随时间改骨头变换”,是不是更具体了?它其实是”在关键帧记录骨头变换,在非关键帧按曲线插值出骨头变换”。关键帧决定’姿势长啥样’,曲线决定’变得有多顺’。两者缺一,动画要么占空间要么像机器人。
落进引擎:你在 Animation 窗口里看到的时间轴,每一个小菱形就是一个关键帧;选中它、调它前后的切线(tangent),就是在改曲线。你第 18 章 18.9 提到的 AnimationClip,就是”一段带着若干属性关键帧 + 曲线的数据包”。
40.5 Timeline / 过场动画:多条轨道同步演出
前面讲的都是”单个角色自己的动作”。但游戏里有种更重的演出需求:过场动画(cutscene)。比如主角走到门前,门自动开,镜头绕到侧面,同时背景音乐起、NPC 开口说话。这里涉及多个系统要在同一时间轴上同步:角色动画、相机运动、音频、甚至特效。
现实中你要手写”第 3.2 秒播角色开门、第 3.5 秒相机开始绕、第 4.0 秒放音效”,又是满屏 if (time > 3.2f) 的灾难。工程师的方案是 Timeline(时间线):一个像视频剪辑软件的多轨道时间轴。每条轨道放一种内容——一条”动画轨道”控制某角色播哪段动画,一条”相机轨道”控制相机怎么运镜,一条”音频轨道”放声音,一条”激活轨道”控制某个物体什么时候出现/消失。所有轨道共享同一条时间轴,你拖拽片段排布,演出就同步了。
类比最准的就是视频剪辑软件(Premiere / 剪映):音频轨、视频轨、字幕轨上下叠着,播放头扫过去,各轨内容同时发生。Unity 的 Timeline 就是把”剪辑软件”做进了引擎,让你用”导演”而不是”程序员”的方式做剧情。
对主程的意义:过场演出别在游戏逻辑代码里用一堆计时器硬凑,用 Timeline 把”演出”和”玩法逻辑”分离——演出归 Timeline,玩法归你的游戏状态机。这样策划想改过场节奏,拖拖轨道就行,不用找你改代码。又是一次”表现与逻辑分离”(你在第 18 章 18.16 学到的设计心法)。
40.6 根运动 Root Motion:位移由动画驱动,还是由你代码驱动
这一节是个大坑,我必须单独拎出来讲,因为新手十有八九在这里摔。
现象:一个走路动画,美术在制作时,不仅让腿在动,还让角色整体在”往前挪”(因为真人走路本来就是边走边前进的)。那么问题来了——角色在世界里往前走的那段位移,到底算谁的?
这里有两条路线,二选一,不能贪:
- 路线 A:根运动(Root Motion)由动画驱动。动画数据里本身就包含”根骨头(Root)每帧往前挪多少”,引擎播动画时,顺带把角色 GameObject 的 Transform 按动画里的位移一起挪。好处:脚踩地和位移严丝合缝——因为位移是动画师按脚踩地节奏 K 的,脚落地时位移恰好停下,绝不会”脚在地面打滑”。
- 路线 B:位移由你的代码驱动。动画只管”腿在蹬”,位移你自己在脚本里按速度改 Transform/CharacterController。好处:位移你完全可控,容易和寻路(NavMeshAgent)、物理(第 41 章)、网络同步配合。
坑在”两者叠加”:你既勾了用根运动、又在代码里每帧 transform.Translate(前进),角色就会”跑两步顶一步”——位移翻倍,或者位移和脚踩地错位,脚在地面滑步(业界叫 foot sliding,看着极假)。所以规则是二选一,别双开。
取舍直觉:
- 写实单人动画(走路、翻滚、爬墙)常用根运动,因为”脚不滑”的质感值钱,而且这类移动不需要和复杂寻路/物理纠缠。
- 需要精确控制、要躲障碍、要联机同步的角色移动,常用代码驱动(配合 CharacterController 或 Rigidbody),因为你得让位移听逻辑的话,而不是听动画师的话。
连回后面:第 41 章讲物理,你会知道角色位移若交给 Rigidbody,就得在 FixedUpdate 里用力/速度驱动,而不是在 Update 里改 Transform——这和”根运动 vs 代码驱动”是同一类取舍的延伸:位移的”真相来源”只能有一个,否则系统互相打架。主程的日常,很大一部分就是理清”这个值的真相到底归谁”。
40.7 Unity 实操:Animator 组件、Animation Clip 与状态机窗口
讲了这么多”为什么”,落进引擎看看”怎么挂”。先连回第 18 章 18.9:想播动画,挂 Animator 组件 + 准备 AnimationClip 资源。
实操三件套:
- AnimationClip(动画片段):美术/动画师导出的”一段动画数据包”,比如
Run.clip、Attack.clip。它本身不含状态逻辑,就是”一段带关键帧和曲线的动作”。 - Animator Controller(动画控制器):在 Animator 窗口里编辑的”状态机图”。你把各个 Clip 拖成状态,画箭头连转移,给转移设条件参数(比如一个叫
Speed的 float、一个叫Attack的 Trigger)。 - Animator 组件:挂在角色 GameObject 上,引用上面的 Controller。它每帧根据参数驱动状态机,最终刷新骨骼变换。
给你一个最小可用的”角色移动播放 Run 动画”例子。假设状态机里有 Idle 和 Run 两个状态,用一个 float 参数 Speed 控制混合(Speed 接近 0 偏 Idle,大于某阈值偏 Run,甚至用了 40.3 的混合树):
using UnityEngine;
[RequireComponent(typeof(Animator))]
public class SimpleMove : MonoBehaviour
{
private Animator animator;
private CharacterController controller; // 代码驱动位移,见 40.6 路线 B
void Awake()
{
animator = GetComponent<Animator>();
controller = GetComponent<CharacterController>();
}
void Update()
{
// 1) 读输入:垂直轴 -1~1
float input = Input.GetAxis("Vertical");
// 2) 代码驱动位移(路线 B,不用根运动,避免和动画位移叠加)
Vector3 move = transform.forward * input * 3f; // 速度 3 米/秒
controller.Move(move * Time.deltaTime);
// 3) 把"速度大小"喂给 Animator,状态机据此在 Idle/Run 间混合
// animator.SetFloat 就是"改状态机参数",对应 40.3 的转移条件
float speed = Mathf.Abs(input) * 3f;
animator.SetFloat("Speed", speed);
}
}逐行点一下关键:
GetComponent<Animator>():拿到第 18 章说的那个组件。controller.Move(...):用 CharacterController 做代码驱动位移(路线 B),这样位移归逻辑管,不和根运动冲突(40.6 的坑避开了)。animator.SetFloat("Speed", speed):这就是”驱动状态机”的那一下。你喂进去的Speed值,正是 40.3 混合树 / 状态转移用的参数。值大 → 偏 Run;值小 → 偏 Idle。你写的 Go 里”改一个状态字段触发状态机流转”,和这行SetFloat是同一个意图。
攻击的例子,用一个 Trigger 参数(触发一次就跳到 Attack,播完自动回):
void OnAttackInput()
{
animator.SetTrigger("Attack"); // 对应状态机里 Attack 转移的 Trigger 条件
}SetTrigger 和 SetFloat 的区别你品一下:float 是”持续量”(速度一直在变),trigger 是”一次性事件”(攻击按了一下)。这正是 FSM 里”持续条件”和”事件”两种驱动方式的对应——你第 39 章的状态机会讲得更透。
一句话收实操:AnimationClip 是”动作素材”,Animator Controller 是”动作的状态机”,Animator 组件是”每帧跑这个状态机并刷新骨骼的引擎接线”。你脚本文里只做两件事:读游戏状态(速度/是否攻击),用
SetFloat/SetTrigger把状态喂给 Animator。剩下的”播哪段、怎么混”,交给状态机。这就是”逻辑和表现分离”在动画上的具体落点。
40.8 收个尾,连回主线
咱们把这一章扣回全书的钥匙:“让模型活起来”不是魔法,是两套机关——骨骼动画负责”姿势随时间变”(改骨头变换 + 蒙皮拽顶点),动画状态机负责”什么时候播什么、怎么平滑切”(FSM + 混合树插值)。两者底层都回到你已学的”变换链”和”插值”。 你从”逐顶点摆”的死路,走到”绑骨头 + 关键帧 + 曲线 + 状态机”,每一步都是工程师被现实逼出来的方案。
- 自然现象 / 问题:静止模型和会动的模型,差在顶点位置随时间变;逐顶点记录不可行(数据爆炸、不自然)。
- 工程师的方案:骨骼动画(层级骨头 + 蒙皮,省数据且自然)、关键帧 + 曲线(省存储且顺滑)、动画状态机 + 混合树(管理多动作切换与平滑过渡)、根运动(位移真相归动画还是代码,二选一)。
- 落进引擎的参数:Animator(状态机驱动器)、AnimationClip(片段)、Controller 里的参数(Speed/Attack 等)、Transition 条件、Root Motion 开关、Animation Curve(缓动)。
你带走的最核心一句话:动画的本质是”随时间驱动的变换”,骨骼是高效的驱动中介,状态机是动作切换的大脑;理解这套,你就不会把 Animator 当黑箱,而会像搭状态机一样去设计角色的动作。 下一章,我们讲让物体”掉下去、撞上来、弹开去”的另一套机关——物理系统,而它和动画一样,都要回到主循环的固定步长(第 38 章)上。
【思考题】
- 骨骼动画相比”逐顶点记录每帧位置”,工程师主要解决了哪两个问题?蒙皮(skinning)里的”权重(weight)“解决的是什么现象——为什么手肘附近的顶点要同时受大臂和小臂两根骨头影响?
- 第 40.3 节说 Animator 本质上是一个”专为动画定制的可视化状态机”,它和你第 39 章会学的有限状态机(FSM)是什么关系?为什么走路切跑步用”混合树(Blend Tree)“比用两个硬切状态更自然?
- 根运动(Root Motion)的”坑”是什么?如果你既勾了用动画根运动位移、又在脚本里每帧
transform.Translate(前进),角色会出现什么怪现象?什么场景下你该选”代码驱动位移”而非根运动? - 第 40.2 节说”骨骼每根骨头的变换最终仍喂给第 27 章那条 Model 矩阵链”。请你用第 2 章的父子局部坐标,解释”抬起大臂,小臂和手为什么自动跟着抬”——它在矩阵上对应什么操作?
【参考答案】
- 骨骼动画主要解决两点:数据量(一段动画只需存几十根骨头的变换序列,而非几万顶点的位置序列,差几个数量级)和自然性(关节用绕轴旋转描述,弯曲顺滑,逐顶点手摆易撕裂)。权重(weight)解决”关节附近顶点该跟谁走”:手肘既在大臂末端、又连着小臂,纯跟一根骨头会让肘部皮肉断裂;给它”70% 跟小臂 + 30% 跟大臂”的权重,蒙皮时按权重加和,肘部就平滑过渡了。这正是橡皮泥糊在铁丝架上、掰架子的自然变形。
- Animator 就是 FSM 在动画领域的特化:状态 = 动画片段,转移 = 动作切换,转移条件 = 参数(Speed/Trigger)。你第 39 章学的”状态 + 转移 + 条件”脑子直接能套。走路切跑步用混合树更自然,是因为走和跑本就是”速度连续变化”的频谱,硬切两个状态会”啪”地突变(玩家眼睛极敏感);混合树用插值(第 37 章 lerp)在两组骨头姿势间平滑融合,把”连续”还给玩家,这正是”游戏感”(第 23 章)的来源之一。
- 坑是”位移真相来源冲突、两者叠加”:既用根运动又代码 Translate,角色位移会翻倍(跑两步顶一步),或脚在地面打滑(foot sliding,脚踩地和位移错位,极假)。应选”代码驱动位移”的场景:需要精确控制、要绕障碍(NavMeshAgent)、要物理/碰撞(第 41 章)、要联机同步时——这些情况下位移得听逻辑的话,不能听动画师的。写实单人演出(走路/翻滚)才优先用根运动换”脚不滑”的质感。
- 第 2 章的父子局部坐标:子物体的位置是相对父说的。大臂是父、小臂/手是孙,子物体的世界变换 = 父的 Model 矩阵 × 子的局部变换(第 27 章连乘链)。抬起大臂 = 改大臂的局部旋转,这会让大臂的世界矩阵变;小臂作为子节点,它的世界矩阵 = 大臂新世界矩阵 × 小臂局部,于是小臂被”带着”转;手再是其子,同理连锁。所以”抬大臂,整条胳膊跟抬”在矩阵上就是一串 Model 矩阵从上往下的连锁乘法——不是每根骨头单独算,是父变子必变。