转载引用请注明出处:🔗https://blog.csdn.net/weixin_44013533/article/details/132081959
作者:CSDN@|Ringleader|
如果本文对你有帮助,不妨点赞收藏关注一下,你的鼓励是我前进最大的动力!ヾ(≧▽≦*)o
主要参考:
- 官方手册-动画
- B站up IGBeginner0116 动画系列
- Unity动画系统详解-洪智
注:本文使用的unity版本是2021.3.25f
注:带⭐的小节是重点或难点
一 前言
本章主要学习Unity动画基础知识,主要包含:动画片段、Animation编辑器、动画状态机、混合树 blendTree、Root Motion等内容,IK和Playable将在后续博客总结。
本文主要是一些动画基础知识介绍和editor编辑器操作示范,包含较多的案例。
值得一提的是对Root Motion和Bake into Pose进行了深入的剖析,感兴趣的可以直接看结论:root motion个人理解。
二 动画片段
动画片段文件其实是一个描述物体随时间变化(如移动、旋转、缩放等)的yaml文本文件
。我们在unity中创建的资源文件,其实大多都是由yaml语言编写的文本文件,这些文本文件包括但不限于这里的“动画片段”,“动画控制器”(状态机),预制件prefeb,甚至是“场景”文件。
2.1 创建动画片段
以创建开门动画为例,以下为创建过程:
- 创建门轴和门对象(门轴为empty object和门边缘对齐)
- 将Door作为pivot子对象,调节pivot 的rotation Y值,可以看到门能正常旋转
- 工具栏Window>Animation>Animation打开Animation窗口,调整到合适的位置。
- 选择pivot对象,点击Animation窗口中Create按钮,选择动画保存目录(例如Asset/Animations)。创建完后Project对应目录多出一个新的资源文件。
用记事本打开这个文件可以看到这是yaml语言编写的文本。
- 点击Add Property>Transform>Rotation,为对象添加动画需要变化的属性。
- 单击动画第60帧(也就是第1秒),改变rotation Y值为-90。这样就创建的包含两帧的动画,而Unity会自动在这首尾两帧间进行插值。
具体插值曲线可以在Animation窗口下方的Curves窗口进行调整
- 点击动画播放键▶播放动画
2.2 动画片段复用
在Project窗口点击动画片段,拖入模型还能预览动画,表明同一动画能在不同对象上复用。
但是不是意味着动画可以随意复用呢?我们创建一个更复杂的动画:双开门实验下。
对于同一个双开门动画文件,当更改子物体名称后,比如将left_pivot改成left_pivotxxxx,左半扇门动画将失效,但更改gate名称和子物体模型,动画仍正常使用。
根本原因还是得看动画文件yaml:
发现文件规定了特定名称子对象的变化曲线,这里的path就是相对父对象的路径,所以如果子对象名称改变,动画就会失效。同样道理,如果父子对象遵循yaml文件描述的路径,动画就能复用。
那么是否能将动画做得更复杂些呢,比如人形动画?当然可以的,上面的pivot就可以类比人体中各个关节,实现人体各个肢体动作动画。
可问题又来了,如果引入的动画和模型中关节名称不一致,岂不是要手动一个一个改?嘿嘿不用,Unity引入了Avatar替身系统,可以帮助我们快速实现人形动画的复用,后面会介绍,先介绍如何引用第三方模型和动画。
2.3 第三方动画引入
常用的动画制作工具有:3ds Max、Maya、Zbrush、Blender等。
还可以使用fuse cc + mixamo 傻瓜式制作模型和添加动作。
-
steam下载fuse(Adobe现已不支持fuse),并制作模型(类似游戏中的捏脸)
-
导出为obj,并将文件中所有内容压缩为zip格式
-
导入模型
-
骨骼自动绑定
-
选择动作并浏览
-
下载动画
例如下载单一动画idle,格式选择FBX for Unity,Skin选择是否包含模型,可以只有动作;fps选择帧率,帧率越大动作精度越高但文件越大,30就可以,unity有插帧算法;keyframe reduction帧压缩,可以设置一个阈值,如果两帧之间的变化小于这个阈值的话就把其中一帧删除掉,这里直接选择none。
也可以下载动作包。pose选项如果动画需要模型,可以选择T pose,或者选择original pose即这个FBX被上传时的姿态。
7. 导入动画。解压动作包后直接拖入Project窗口
- 修复贴图。如果模型显示不正常,需要在project选中这个模型,在inspector选中Materials 分别导出textures和materials。
如果模型依旧诡异,可能还需要调整所有材质的透明选项。
- 浏览导入的动画
2.4 人形动画复用:Avatar系统
Unity商城下载新的角色模型(比如chan)
并导入,给jump动画添加chan这个角色,发现摆出T pose,动画无法复用,原因就是骨骼结构不一致导致。
所谓的动画,就是对于当前节点,以及当前节点的子节点,一系列属性对于时间的差值控制。
能够应用动画片段的前提就是该节点以及该节点的子节点层级结构,以及所拥有的动画属性与参与动画片段的属性能够对应。
Avatar的存在主要是为了解决人物动画的复用问题,不同来源的FBX中,对于骨骼节点的层级结构,以及命名可能不尽相同,因此Unity以Avatar作为一个中介。不同的FBX都对Avatar中的标准人物骨骼结构去创建映射关系,将动画片段中的人物骨骼节点,映射到标准人物骨骼结构中,建立对标准人物骨骼结构的映射关系。
在Animator状态机进行动画控制时,依照人物模型创建的Avatar,将人物动画片段对标准人物骨骼中的节点控制,映射到当前的人物模型当中,从而就实现了对不同来源FBX中,人物动画的复用。
下面就利用Unity Avatar实现动画复用。
- 准备mixamo下载的角色模型(A模型),和A模型对应的动画jump,以及Unity Asset下载的chan角色模型(B模型)。
- project中选中A模型,在Inspector界面选择Animation Type为Humanoid,Avatar Definition选择Create From This Model,然后点击Apply,project将会多出对应的Avatar文件。
- project中选中B模型,操作同第二步。
- project选中动作jump,Rig Animation Type选择Humanoid,Avatar Definition选择Copy from other Avatar,Source选择A 模型的Avatar(相当于用Unity标准骨骼结构来解释这个动画。其实也能直接copy from this model,那么就能省略第2步操作)
点击Apply会发现jump的Animation窗口原先的特定骨骼消失了,变成Unity标准骨骼结构。
- 分别为各自模型添加动画组件,添加状态机,和各自对应的Avatar
- 运行游戏,或者给动画添加模型,发现动画能正常复用
三 Animation(动画片段制作)
3.1 基本属性
Animation窗口可以手动为对象创建动画和添加动画事件。
Dopesheet是关键帧清单,横轴代表对象的各种可动画属性,纵轴代表时间或者帧,可以通过滚轮调整尺度,按A重置尺度,帧率可以通过右侧···设置和显示。
Animation窗口有两种模式:录制模式和预览模式。
- 在录制模式下,当你对物体进行改动时(例如移动、旋转、缩放、修改属性等),Unity会自动在当前时间位置生成关键帧,记录修改的属性。
- 在预览模式下,修改物体不会自动创建/修改关键帧,如果需要创建/修改关键帧,你需要手动点击添加关键帧按钮。
快捷操作:
-
K Key All Animated,将记录当前属性列表中选中属性的关键帧,如果当前没有选中任何属性,则会记录所有属性。
-
Shift + K Key All Modified,将动画属性列表中所有已修改的属性的数值记录为关键帧。(我不知道是不是快捷键冲突,这个操作没效果)
下面就可以利用所学尝试K动画了:
3.2 编辑曲线
除了关键帧清单,还可以用Curves曲线模式查看动画关键点。
一个关键点有两条切线:一条在左侧用于向内的斜坡,另一条在右侧用于向外的斜坡。切线可控制关键点之间的曲线形状。可从许多不同的切线类型中进行选择一种类型,用于控制曲线离开一个关键点并到达下一个关键点的方式。右键单击一个关键点可以选择该关键点的切线类型。
要使动画值在通过关键点时实现平滑变化,左右切线必须共线。以下切线类型可确保平滑:
- Clamped Auto:这是默认切线模式。系统会自动设置切线,使曲线通过关键点时保持平滑。编辑关键点的位置或时间时,切线会进行调整以防止曲线“超出”目标值。如果在
Clamped Auto
模式下手动调整关键点的切线,则会切换到Free Smooth
模式。 - Auto:这是旧版切线模式,但此选项仍然保留着以便与旧项目向后兼容。除非有特别原因要使用此模式,否则请使用默认的
Clamped Auto
。当关键点设置为此模式时,系统会自动设置切线,使曲线通过关键点时保持平滑。但是,与Clamped Auto
模式相比有两个不同之处:- 当编辑关键点的位置或时间时,切线不会自动调整;切线仅在最初将关键点设置为此模式时进行调整。
- Unity 在计算切线时,不会考虑避免“超出”关键点的目标值。
- Free Smooth:拖动切线控制柄来自由设置切线。它们被锁定为共线以确保平滑。
- Flat:切线设置为水平(这是
Free Smooth
的特例)。
有时可能不希望曲线在通过关键点时是平滑的。要在曲线中产生急剧变化,请选择 Broken 切线模式之一。
Animation窗口其他操作可以参见:
- Unity动画系统详解1:在Unity中如何制作动画?
- 官方手册:曲线模式中的关键点操作
- 官方手册:编辑曲线
3.3 动画事件
动画事件允许您在时间轴中的指定点调用对象脚本中的函数。
由动画事件调用的函数也可以接受一个参数。该参数可以是 float、string、int 或 object 引用或 AnimationEvent 对象。AnimationEvent 对象具有一些成员变量,通过这些变量可将浮点、字符串、整数和对象引用以及有关触发函数调用的事件的其他信息一次性传递给该函数。
要将动画事件添加到当前播放头位置的剪辑,请单击 Event 按钮。具体操作如下:
脚本:
// 此 C# 函数可由动画事件调用
using UnityEngine;
using System.Collections;
public class ExampleClass : MonoBehaviour
{
public void PrintEvent(string s)
{
Debug.Log("PrintEvent: " + s + " called at: " + Time.time);
}
}
四 Animator(动画状态管理)
保存新的空动画剪辑时,Unity 会执行以下操作:
• 创建新的 Animator Controller 资源
• 将新剪辑以默认状态添加到 Animator Controller 中
• 将 Animator 组件添加到要应用动画的游戏对象
• 为 Animator 组件分配新的 Animator Controller
下图以 Animation 窗口中创建新动画剪辑为起点,展示了 Unity 如何分配这些部件:
在大多数情况下,拥有多个动画并在满足某些游戏条件时在这些动画之间切换是很常见的。例如,只要按下空格键,就可以从行走动画切换到跳跃动画。但是,即使您只有一个动画剪辑,仍需要将其放入 Animator Controller 以便将其用于游戏对象。
控制器使用所谓的状态机来管理各种动画状态和它们之间的过渡;状态机可视为一种流程图,或者是在 Unity 中使用可视化编程语言编写的简单程序。
创建、查看和修改 Animator Controller 资源则在 Animator 窗口中操作。
Animator 窗口有两个主要部分:主要网格化布局区域以及左侧 Layers 和 Parameters 面板。
左侧面板:
- Parameters 视图 允许您创建、查看和编辑 Animator Controller 参数。这些参数是您定义的变量,
充当状态机的输入
。 - Layers 可在单个动画控制器中同时运行多个动画层,每个动画层由一个单独状态机控制。此情况的常见用途是在控制角色一般运动动画的基础层之上设置一个单独层来播放上身动画。
主网格区包含四种节点:
-
Entry 入口。动画状态机会从这个节点开始,根据Transition进入一个默认State。
-
Any State 任意状态。用于从任意状态转换到特定状态。比如射击类游戏中,如果被子弹打中后,不管当前处于什么状态,都会倒地死亡。
-
Exit 退出状态机。一般用于嵌套的状态机的退出。
-
Custom Status 自定义状态节点。可以在空白处右键添加Empty State,也可以将Animation Clip文件拖到Animator窗口中添加一个State。第一个创建的State默认是橘黄色的,代表是默认状态。有一条黄色的箭头从Entry指向橘黄色的State。
4.1 动画状态机详解——状态
参考:
- IGBeginner0116:动画状态的基本属性及相关API
- Unity手册:动画状态机
- 大智:Unity动画系统详解3:如何播放、切换动画
选中一个自定义状态State时,在Inspector中可以看到如下内容:
-
Motion 可以设置一个Animation Clip,如果是从Animation Clip创建的动画,这里应该已经有动画了,你也可以从工程中选择动画。
-
Speed 动画的播放速度,负数为倒放,无法通过api修改,需结合下面的multiplier使用
-
Multiplier 乘数,可以使用一个参数来控制动画的播放速度,动画最终的播放速度会是Speed * Multiplier。
如下是API测试Tag、speed的简单应用(记得给对象添加下面的script,一开始我按键没起作用我还以为是InputSystem问题,忙了半天,发现压根就没添加脚本,尴尬-_-||):
代码:
using UnityEngine;
using UnityEngine.InputSystem;
public class StateMachine : MonoBehaviour
{
private Animator _animator;
private AnimatorStateInfo _animatorStateInfo;
private float animatorScalar = 1f;
void Start()
{
_animator = GetComponent<Animator>();
_animator.SetFloat("Scalar", animatorScalar);
}
void Update()
{
if (Keyboard.current.wKey.isPressed)
{
_animatorStateInfo = _animator.GetCurrentAnimatorStateInfo(0);
//_animator.GetCurrentAnimatorStateInfo(_animator.GetLayerIndex("Base Layer"));
if (_animatorStateInfo.IsTag("不能动"))
{
Debug.Log("不能操作");
return;
}
Debug.Log("可以操作");
animatorScalar += 0.1f;
_animator.SetFloat("Scalar", animatorScalar);
}
if (Keyboard.current.sKey.isPressed)
{
animatorScalar -= 0.1f;
_animator.SetFloat("Scalar", animatorScalar);
}
}
}
- MotionTime 动画归一化时间,结合parameter使用。我理解就是利用这个参数控制动画播放,0就是播放第一帧,1就是最后一帧,然后就能通过脚本手动控制动画播放。
-
Mirror 镜像动画,可以人形动画左右镜像变化。也可以使用一个参数控制。
-
Cycle Offset 循环动画初始播放的偏移量。偏移量使用的是单位化时间,范围是0-1。也可以使用参数来控制。
-
Foot IK 只用于人形动画。角色的脚是否使用反向动力学。
简单介绍一下IK。
- 我们常见的由美术制作或者由动作捕捉出来的固定动画,一般都是由骨骼的根节点(对于人性角色来说就是屁股)到末梢骨骼节点依次计算其旋转、位移和缩放 ,来决定每一块骨骼的最终位置,种被称为正向动力学****forward Kinematics。
- 但是在很多时候我们需要反过来计,比如当我们希望角色的手和脚放在一个特定的位置上,举个例子就是爬山的时候,我们需要手和脚接触在岩壁上,此时我们没有现成的动画片段来调用,我们就只能先确定手和脚的位置,再通过手和脚的这个位置,反向计算他们的各个父节点的旋转、位移和缩放了。这种就叫做反向动力学****inverse Kinematics。
那么回到我们这里的Foot IK,Unit使用了Avatar技术来为人形动画提供复用功能。
这种技术很好用但也有些不足,比如当我们把骨骼系统转化为肌肉系统之后,人形角色的双手和双脚的位置会出现一定的偏移。unit为了解决这个问题,提前为我们保存了骨骼系统下,手和脚的正确位置。并把这些位置放置在了4个IK goal上。
Foot IK的作用就是通过反向动力学,把我们脚部的实际位置,向这里的IK Goal的位置拉近一点。
IK Goal位置可以通过脚本修改,同时更改对应的权重。注意,如果权重为1,则完全用IK的位置和旋转;如果权重为0,则完全用原来动画中的位置和旋转。使用脚本使用IK时,我们需要先开启动画层的IKPass,之后在OnAnimatorIK方法中,对IK进行设置。
代码:
public class AnimatorIKFirstTest : MonoBehaviour
{
[Range(0,1)]
public float IK_weight = 0f;
public GameObject ik_object;
private Animator _animator;
private void Start()
{
_animator = GetComponent<Animator>();
}
private void OnAnimatorIK(int layerIndex)
{
_animator.SetIKPosition(AvatarIKGoal.LeftHand, ik_object.transform.position);
// 这个方法用来设置IK的权重,这个IK会和原来的动画进行混合。如果权重为1,则完全用IK的位置旋转;如果权重为0,则完全用原来动画中的位置和旋转
_animator.SetIKPositionWeight(AvatarIKGoal.LeftHand, IK_weight);
}
}
效果:
这里只是对IK简单进行应用,如果能结合射线检测的话,我们就可以根据它开发出脚步适应地形的效果,这个后续深入学习IK的章节再说。
- Write Defaults 是否初始化该State没有用到的参数为默认值。
以电梯开门动画为例,演示write default参数对动画的影响。
如下gif所示,电梯能正常升降以及在一楼开闭,但发现只要在二楼开门,电梯会闪现到一楼才能打开,这就很迷惑。
原因就在这个writeDefaults,因为电梯结构如下,电梯的上升和下降控制A对象,电梯的开闭控制BC对象,那么就会出现电梯开门动画没有对A对象属性赋值,如果此时开门动画的writeDefault被勾选,Unity会自动为A对象赋予默认值,而这个值就是在一楼的状态值。所以才会出现在二楼开门会闪现到一楼的情况。
何为“默认值”?(参考:Write Defaults的作用)
当动画机Enable时,Unity会遍历此动画机包含的所有Clip修改了哪些属性,并将OnEnable时这些属性的值作为默认值。
当将open和close的write default勾去掉后,动画逻辑则变回正常。
- Transitions 该状态参与的状态转换。下面会细讲。
4.2 动画状态机详解——状态转化
状态间通过右键添加Transition,同一方向可以设置多个Transition。
一个状态可以转换为多个状态,怎么决定这些变化呢?点击一个状态,其Transition栏列出了从这个状态发出的所有变化,如下:
- Solo:勾选这个选项后,只有选中Solo的Transition生效(显示绿色),列表中其他Transition会被禁用。如果有多个solo变换,则执行满足condition的变换。若多个solo变换同时满足触发条件,则优先执行列表中靠上的变换。
- Mute:勾选这个选项后,该条Transition会被禁用(显示红色)。如果同时选中了Solo和Mute,Mute会优先生效。
下面展示Transition顺序对执行的影响。
下面再详细看一下Transition的各个参数
Has Exit Time决定是否在播放完动画后才进行切换,先勾去,先看下面的Conditions。
4.2.1 Conditions
Conditions依赖Parameter参数
案例:上一节我们用电梯的例子其实有个bug,就是当处于close状态时,在一楼触发down会有个闪现到二楼再下降的动画,在二楼触发up也会有类似bug。
这就需要添加对楼层的判断。我们这里利用float height记录电梯高度,int floor记录电梯层数,bool 1st floor记录是否是1楼。具体代码如下:
public class StateTransition : MonoBehaviour
{
private Animator _animator;
// Start is called before the first frame update
void Start()
{
_animator = GetComponent<Animator>();
}
// Update is called once per frame
void Update()
{
float elevatorHeight = this.transform.position.y;
if (elevatorHeight > 2)
{
_animator.SetBool("1st floor", false);
_animator.SetInteger("floor", 2);
}
else
{
_animator.SetBool("1st floor", true);
_animator.SetInteger("floor", 1);
}
_animator.SetFloat("height", elevatorHeight);
}
}
通过对condition的额外判断,就能正常上下电梯。
4.2.2 Has Exit Time & Settings
Has Exit Time 是否有退出时间条件。注意以下几点:
- 如果勾选的话,那么当前动画状态播放到某个时间点的时候就自动执行这个转换。如果同时设置了Conditions,则在满足conditions后且到达时间点自动触发转换。如果Conditions和Has Exit Time都不设置,转换永不生效。
- 勾选时,只有达到设置的Exit Time,才允许这个转换被触发,也就有控制延迟;不勾选,上个动画播放完毕立刻触发转换,不会有控制延迟感。而且这个延迟时间还并不固定,详情参见:Unity的Animator中Transition有延迟的问题。
Exit Time 如果勾选了Has Exit Time,该参数是可以设置的,设置动画退出的单位化时间。例如设置为0.75,代表动画播放到75%时为true,如果没有其他条件,会直接切换到下一个State。
-
如果exit time小于1,那么state每次循环到对应位置的时候(不管动画是否设置为循环,state总是循环的),该条件都会为true。比如第一次播放到75%,第二次播放到75%……时退出条件都会为true。
-
如果exit time大于1,该条件只会检测一次。比如exit time为3.5,state的动画会在循环3次后,在播放到第4次的50%时为true。
Fixed Duration 勾选时,下方Transition Duration参数的单位是秒,不勾选时,参数会作为一个百分比。
Transition Duration transition的过渡时间。两个状态在转换时,一般不会瞬间从一个状态转换到另一个状态,而是会经过平滑混合,这个属性就是设置了平滑混合的时间。可以从下图的两个蓝色箭头看出转换的时间。
Transition Offset 目标状态开始播放的时间偏移。比如设置为0.5,则转换到下一个State时,会从50%的位置开始播放。
上面的各个参数都可以用下面的Transition图可视化表示:
Interruption Source和Ordered Interruption 这两个参数可以用来控制transition的打断。下面会进行详解。
4.2.3 Transition Interruption
在默认的情况下,动画转换时不能被打断的(注意:不是状态State不能打断,是状态转换Transition无法打断)。但是如果你需要对transition进行更多控制,可以通过配置Interruption Source和Order Interruption来满足需求。
Interruption Source有以下五种:None、Current State、Next State、Current State Then Next State、Next State Then Current State。
这五种方式决定此状态转换如何被中断。
- None:无法被其他状态变换中断
- Current State:仅能被源状态Transition列表中的转换中断,如果此时勾选Ordered Interruption,则源状态Transition例表中上面的能中断下面的,下面的无法中断上面的;若不勾选Ordered Interruption,则都可以打断。
- Next State:仅能被目的状态Transition列表中的转换中断。
- Current State Then Next State:同时能被源状态和目的状态Transition列表中的转换中断,且源状态Transition列表中的转换优先级更高。
- Next State Then Current State:同时能被源状态和目的状态Transition列表中的转换中断,且目的状态Transition列表中的转换优先级更高。
这个Interruption理解文字比较麻烦,推荐直接看视频 Unity动画系统详解 十二 动画状态过渡中断/转换打断
五 New Input System
参考:
- Unity New Input System 及其系统结构和源码浅析【Unity学习笔记·第十二】
因为要控制角色移动,这里介绍Unity新输入系统的部分内容。
-
Package Manager 安装 Input System
安装结束后会提示重启Unity,以激活新输入系统
-
选择角色模型,为角色对象添加Player Input组件,这是new Input System提供的处理玩家输入的组件
点击Create Actions新建玩家输入配置文件,这个文件定义了玩家的各种输入,如移动、转向、射击等,这个默认的配置已经满足目前的需求,不用动。
-
Player Input组件的Behavior选择Invoke Unity Events,这里的意思是当系统检测到我们的输入时就会调用一些我们写好的方法
展开下面的Events,发现里面的内容和前面的玩家输入配置里内容一一对应。点击加号就能将脚本中的方法注册其中,当系统检测到玩家输入WASD时,就会通过资源文件发现玩家的行为是 move,然后调用所注册的方法。
-
准备前进、后退动画,配置状态机(两个bool变量作为状态触发条件,注意勾去Has Eixt Time让动画即时切换)
-
编写角色控制脚本,实现按w角色前进,按s角色后退
using System;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerMoveTest : MonoBehaviour
{
private Animator _animator;
private float thredshold = 0.01f;//防止摇杆漂移或误触
private void Start()
{
_animator = GetComponent<Animator>();
_animator.SetBool("wPress", false);
_animator.SetBool("sPress", false);
}
public void PlayerMove(InputAction.CallbackContext callbackContext)
{
Vector2 movement = callbackContext.ReadValue<Vector2>();
if (movement.y > thredshold)
{
_animator.SetBool("wPress", true);
}
else
{
_animator.SetBool("wPress", false);
}
if (movement.y < -thredshold)
{
_animator.SetBool("sPress", true);
}
else
{
_animator.SetBool("sPress", false);
}
}
}
- 将组件中的刚编写的脚本拖入Player Input>Events>Player>move中,(注意不要拖Project中的脚本,必须是角色对象组件中的脚本,否则方法无法暴露),选择PlayerMove方法
- 运行游戏
动画无法循环问题:
有时会发现动画莫名卡住,如下:
后退状态无法循环,查看动画片段LoopTime循环播放没打开,原来是我编辑后没有Apply导致的。(一定要注意编辑后要应用生效!)
手动控制移速:
为了精细化控制移动,关闭角色对象Animator组件的Apply Root Motion选项(这样同时也能解决动画自带位移导致的角色自动偏航和升降问题)
同时希望在使用手柄时,能根据摇杆操作幅度控制不同的移速。于是代码如下:
using System;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerMoveTest : MonoBehaviour
{
private Animator _animator;
private float thredshold = 0.01f;//防止鼠标漂移或误触
public float forward_speed = 2f;
public float backward_speed = 1.5f;
private float current_speed;
private void Start()
{
_animator = GetComponent<Animator>();
_animator.SetBool("wPress", false);
_animator.SetBool("sPress", false);
}
private void Update()
{
Move();
}
private void Move()
{
transform.Translate(Vector3.forward * (Time.deltaTime * current_speed), Space.Self);
}
public void PlayerMove(InputAction.CallbackContext callbackContext)
{
Vector2 movement = callbackContext.ReadValue<Vector2>();
current_speed = 0;
if (movement.y > thredshold)
{
_animator.SetBool("wPress", true);
current_speed = forward_speed * movement.y; // 乘以movement.y用于摇杆,希望根据摇杆幅度进行不同速度的移动
}
else
{
_animator.SetBool("wPress", false);
}
if (movement.y < -thredshold)
{
_animator.SetBool("sPress", true);
current_speed = backward_speed * movement.y;// 注意y∈[-1,1],后退已有负号,backward_speed取正值即可
}
else
{
_animator.SetBool("sPress", false);
}
}
}
效果:
从上面的动画能看出来,当移速低时,动画依旧是大步跨越,导致出现滑步现象。我们希望有办法让角色速度低时,小跨步,速度大时大跨步。但如果是让动画师给不同速度做不同的动画,来解决这个问题,可想而知并不是明智的做法。
这就引出了Unity的BlendTree,利用BlendTree能方便地解决上面的问题,能根据角色移速融合不同的动画片段,来实现角色实际移速和角色移动幅度相一致的效果。
六 BlendTree 动画混合
6.1 BlendTree初览
- 状态机窗口中鼠标右键创建一个Blend Tree,Blend Tree就相当一个状态,不过是多个动画混合的状态罢了,而且Blend Tree里面的待混合的动画也可以是子Blend Tree混合后的动画,形成多层嵌套的树状结构,这就是Blend Tree命名的由来。
2. 双击进入Blend Tree,查看Blend Tree参数
Blend Type可以选择混合方式,1D方式关联单个float变量来控制动画混合
点击添加按钮,依次将后退、待机、前进三个动画加入
滑动轴进行预览,可以看到多个动画分配不同权重下混合后的效果
3. 关闭Automate Thresholds 并设置对应动画片段的Threshold
4. 添加speed 参数接收角色current_speed,修改状态机,并修改脚本
using System;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerMoveTest : MonoBehaviour
{
private Animator _animator;
private float thredshold = 0.01f;//防止鼠标漂移或误触
public float forward_speed = 2f;
public float backward_speed = 1.5f;
private float current_speed;
private void Start()
{
_animator = GetComponent<Animator>();
_animator.SetFloat("speed", 0f);
}
private void Update()
{
Move();
}
private void Move()
{
transform.Translate(Vector3.forward * (Time.deltaTime * current_speed), Space.Self);
_animator.SetFloat("speed", current_speed);
}
public void PlayerMove(InputAction.CallbackContext callbackContext)
{
Vector2 movement = callbackContext.ReadValue<Vector2>();
current_speed = 0;
if (movement.y > thredshold)
{
current_speed = forward_speed * movement.y; // 乘以movement.y用于摇杆,希望根据摇杆幅度进行不同速度的移动
}
else if (movement.y < -thredshold)
{
current_speed = backward_speed * movement.y;// 注意y∈[-1,1],后退已有负号,backward_speed取正值即可
}
}
}
- 运行预览
角色移速和脚步动画的一致性问题已解决,但滑步现象依旧存在,这个后续解决
6.2 Blend Tree 1D、2D案例
在游戏中,其实只要有前进和转向即可控制角色移动,那为何需要八个方向的移动呢?因为游戏中通常还有瞄准或锁定功能,在锁定敌人状态下,人物永远面向敌人,只有前进是不够的,所以会有诸如平移/侧步(strafe )、后退等动画。
如《只狼:影逝二度》中两者状态下移动的差异:
下面利用Blend Tree的1D、2D大概复现下类似的移动控制。
6.2.1 案例一:行走、奔跑
主要参考:IGBeginner0116 :在Unity中如何利用Root Motion、Input System和Cinemachine制作一个简单的角色控制器_教程
与前面的BlendTree初始案例类似
-
输入控制添加冲刺方案(应该叫run的,算了不改了)
-
Blend Tree设置如下如下
-
关闭root motion,用脚本控制角色移动。
添加刚体和碰撞体,注意要Freeze X、Z轴的rotation
否则可能出现下面的情况:
4. 为了更好追踪角色,添加cinemachine,Body选择Transposer 的World Space,Aim选择hard look at,具体含义可以参看我cinemachine章节 Unity基础(九)【cinemachine基础(body、aim参数详解)】(多fig动图示范)
- 编写代码:
using System;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerMoveTest2D : MonoBehaviour
{
private Animator _animator;
private float thredshold = 0.01f;//防止鼠标漂移或误触
public float walk_speed = 1.5f;
public float run_speed = 3.7f;
private bool isRunning = false;
private float current_speed;
private Vector2 movement;
private Quaternion _quaternion;
private void Start()
{
_animator = GetComponent<Animator>();
_animator.SetFloat("speed", 0f);
}
private void Update()
{
Rotate();
Move();
}
private void Move()
{
current_speed = 0;
if (movement.magnitude > thredshold)
{
current_speed = (isRunning ? run_speed : walk_speed) * movement.magnitude;
}
transform.Translate(Vector3.forward * (Time.deltaTime * current_speed), Space.Self);
_animator.SetFloat("speed", current_speed);
}
private void Rotate()
{
if (movement.magnitude > thredshold)
{
Quaternion targetQuaternion = Quaternion.LookRotation(new Vector3(movement.x,0,movement.y), Vector3.up);
transform.rotation = Quaternion.RotateTowards(transform.rotation, targetQuaternion, 1000 * Time.deltaTime);
}
}
public void PlayerMove(InputAction.CallbackContext callbackContext)
{
movement = callbackContext.ReadValue<Vector2>();
}
/*用新输入系统添加冲刺sprint按键绑定(键盘的shift键,xbox的B键),Player Input组件监听冲刺按键调用下面的PlayerRun方法*/
public void PlayerRun(InputAction.CallbackContext ctx)
{
float shiftValue = ctx.ReadValue<float>();
Debug.Log(shiftValue);
isRunning = ctx.ReadValue<float>() > 0;
}
}
- 效果:
6.2.2 案例2:瞄准状态下的行走
- 添加
LockView
的变量作为锁定视角的触发条件
- BlendTree选择
2D Simple Directional
,放置各方向行走动画
- 对前面的脚本进行改动:
using System;
using UnityEngine;
using UnityEngine.InputSystem;
using Object = System.Object;
public class PlayerMoveTest2D : MonoBehaviour
{
private Animator _animator;
private float thredshold = 0.01f;//防止鼠标漂移或误触
public float walk_speed = 1.5f;
public float walk_speed_with_locked_view = 1f;
public float run_speed = 3.7f;
private bool isRunning = false;
private float current_speed;
private Vector2 movement;
private Quaternion _quaternion;
public float rotateSpeed = 1000f;
/*锁定目标*/
private bool isLockedView = false;
public GameObject lockedObject;// 这里待锁定目标就简单用外部赋值代替了
private void Start()
{
_animator = GetComponent<Animator>();
_animator.SetFloat("speed", 0f);
_animator.SetBool("LockView", false);
_animator.SetFloat("speedX", 0f);
_animator.SetFloat("speedY", 0f);
}
private void Update()
{
LockAim();
Rotate();
Move();
}
private void Move()
{
if (isLockedView)
{
_animator.SetFloat("speedX", movement.x);
_animator.SetFloat("speedY", movement.y);
transform.Translate(new Vector3(movement.x, 0, movement.y) * Time.deltaTime, Space.Self);
}
else if (movement.magnitude > thredshold)
{
current_speed = (isRunning ? run_speed : walk_speed) * movement.magnitude;
_animator.SetFloat("speed", current_speed);
transform.Translate(Vector3.forward * (Time.deltaTime * current_speed), Space.Self);
}
else
{
current_speed = 0;
_animator.SetFloat("speed", current_speed);
}
}
private void Rotate()
{
/*锁定视角情况下方向键不控制方向*/
if (!isLockedView && movement.magnitude > thredshold)
{
Quaternion targetQuaternion = Quaternion.LookRotation(new Vector3(movement.x,0,movement.y), Vector3.up);
transform.rotation = Quaternion.RotateTowards(transform.rotation, targetQuaternion, rotateSpeed * Time.deltaTime);
}
}
public void PlayerMove(InputAction.CallbackContext callbackContext)
{
movement = callbackContext.ReadValue<Vector2>();
}
/*用新输入系统添加冲刺sprint按键绑定(键盘的shift键,xbox的B键),Player Input组件监听冲刺按键调用下面的PlayerRun方法*/
public void PlayerRun(InputAction.CallbackContext ctx)
{
isRunning = ctx.ReadValue<float>() > 0;
}
public void PlayerLockedView(InputAction.CallbackContext ctx)
{
isLockedView = !isLockedView;
_animator.SetBool("LockView", isLockedView);
}
/*锁定目标*/
private void LockAim()
{
if (isLockedView)
{
Quaternion targetQuaternion = Quaternion.LookRotation(this.lockedObject.transform.position - transform.position, Vector3.up);
transform.rotation =
Quaternion.RotateTowards(transform.rotation, targetQuaternion, rotateSpeed * Time.deltaTime);
}
}
}
- 添加锁定按键绑定,按键监听绑定
PlayerLockedView
方法
- 效果如下:
这里效果还需要优化,一个是瞄准方向问题(我四元数没学好之后再研究-_-),另一个是镜头问题,锁定模式下镜头应该始终在角色背后,涉及镜头切换问题,同时角色和锁定的对象作为目标组,后面一并研究。
6.3 Blend Tree 参数解释
通过案例大概了解了这个Blend Tree的用法,接下来详细了解这个BlendTree。
6.3.1 Blend Type
我理解的Blend Type就是根据所混合的多个动画间差异属性个数和特点来选择的。比如走和跑差异在线速度,左转、右转差异在角速度,所以这种就适合用1D BlendTree来混合。
至于“向前走”,“向后走”,“向左走”,“向右走”多个动画,差异在方向上,而方向需要两个参数来表示,所以适合用2D Blend Tree。2D算法有多个,分别适合不同场合。至于选择何种算法,需要先了解不同算法的原理,详细内容参看后面的双变量混合——权重分配算法浅析章节。
前两类都是通过算法计算各动画权重,如果想手动精确控制各权重就用Direct混合。
-
1D:1D 混合根据单个参数来混合子运动。
-
2D Simple Directional(2D简单方向):当你的运动代表不同的方向,如“向前走”,“向后走”,“向左走”,“向右走”,或“向上瞄准”,“向下瞄准”,“左瞄“和”右瞄“。当然了,可以在(0,0)处包含一个默认动作类似“空闲站立”或“直线瞄准”。与1D混合树不同的是,2D Simple Directional不是在同一个方向上的多个动作,比如“走”和“跑”。
-
2D Freeform Directional(2D自由方向):动画运用有不同的方向时,也可以使用这种混合类型:可以在同一个方向上有多个运动,例如“走”和“跑”。在Freeform Directional类型中,(0,0)位置必须包含一个默认动作,如“空闲站立”。
-
2D Freeform Cartesian(2D自由笛卡儿):当混合的2个参数不代表不同的方向时使用。使用Freeform Cartesian,参数X和Y可以表示不同的概念类型,例如角速度和线速度。举个例子:“向前走不转向”,“向前跑不转向”,“向前走并右转”,“向前跑并右转”等动作。
-
Direct:此类型的混合树让用户直接控制每个节点的权重。适用于面部形状或随机空闲混合。
6.3.2 阈值 | Positions
-
单变量混合树中可以拖动蓝色三角形来更改motion的阈值。如果未启用“Automate Thresholds”开关,则可以手动更改Threshold 数值来改变motion阈值。
-
双变量混合中可以拖动混合图中蓝点来更改motion的位置,或者手动改变 Pos X 和 Pos Y 的值。
6.3.3 阈值 | Positions的自动计算
Compute Thresholds或Compute Positions 下拉框中,可以根据动画中的数据,自动计算阈值或位置。
1d:
- Speed:按照动画根节点的运动速度计算
- Velocity X/Y/Z 按照根节点对应轴向上的运动速度计算
- Angular Speed(Rad)按照根节点的角速度,单位取弧度进行计算,一般角速度按这种模式计算,生成的位置参数较小,靠近原点
- Angular Speed(Deg)按照根节点的角速度,单位取角度进行计算,生成的位置参数一般较大,远离原点
2d:
二维混合的Compute Positions中,我们可以对X轴/Y轴应用一维混合中计算模式,也可以通过另外两种计算模式来同时计算XY的位置坐标.
Velocity XZ 即X轴对应动画中根节点的VelocityX,Y轴对应动画中根节点的VelocityZ,按照动画在XZ平面的运动方向来进行坐标分配
Speed And AngelSpeed 则是Y轴按照动画根节点的速度计算坐标,而X轴按照根节点的角速度计算坐标。
6.3.4 scale
通过动画速度这一列可以调节动画的播放速度,比如你想让跑步的动画播放速度变为原来的2倍,可以设置为2。
Adjust Time Scale > Homogeneous Speed按钮可以将动画的播放速度调整到动画列表中所有动画速度的平均值。先将所有动画的平均速度算出来,然后通过调节动画的speed让所有动画的速度都一致。
6.3.5 Mirroring 镜像
上面复选框可以左右镜像一个humanoid类型的动画Clip。这个功能可以使用同一个动画创建出来两个方向的动画,可以节省一倍的存储空间和内存。
比如一个向左走的动画,通过镜像可以创建出一个向右走的动画。
6.4 双变量混合——权重分配算法浅析
申明:以下权重计算原理整理自up IGBeginner0116 的视频。
- Unity 2D Freeform Cartesian混合树(2D Blend Tree)的权重分配原理【Unity动画系统详解 三十四】
- Unity 2D Simple Directional混合树(2D Blend Tree)详解【Unity动画系统详解 三十三】
- Unity 2D Freeform Directional混合树(2D Blend Tree)的权重分配原理【Unity动画系统详解 三十五】
术语约定
范例动画
(example motion):混合树中这些动画,我们称之为具体动画或者范例动画参数空间
(parameter space):由这两个参数构成的坐标空间,我们称之为参数空间范例点
(example point):范例动画在参数空间上的位置,也就是这里的蓝点,我们称之为范例点。蓝圈儿表示当前状态下相关动画片段在这个混合树中所占的权重,权重越大蓝圈就越大采样点
:而这里的红点就是游戏中实际的参数的值,我们称之为采样点
目标:在确定了采样点后,我们需要给参数空间上的每一个范例点所对应的范例动画计算出一个权重值。
权重计算原则:关于如何计算权重值,在Johansen(Mecanim动画系统主持开发工程师)的硕士论文第六章提到了七个基本原则:
- 准确性。当采样点落在范例点上时,该范例动画的权重必须是1,其他动画的权重必须是0
- 归一性。所有权重的总和必须为1
- 连续性。权重的变化必须是连续的,以保证动画过渡的丝滑
- 有界性。任何一个范例动画的权重不得小于0,不得大于1
- 范围性。只有最接近采样点的几个范例动画的权重才可以天于0,而其他的动画权重都应该是0
- 单调性。当采样点和某个动画的距离越来越远时,权重应当越来越小或者不变(即不应该有局部最小值)
- 密度不变性。在实际的工作情况下,范例动画的分布可能是不均匀的,但是采样点要能够按照自己和每个
范例动画的实际距离来分配权重,而不是把紧密的放在起的几个范例动画当做一个动画来对待
2D Simple Directional计算权重原理
- 原点处有动画片段
Unity 会找出一个由三个动画片段包裹住这个红点的三角形,这个三角形必须有一个顶点是原点位置的动画片段。
那么这个三角形具体是怎么找的呢?
Unity会分别连接原点范例点和采样点,以及原点范例点和其他范例点,找到顺逆时针夹角最小的两条,那么对应的两个范例点和原点就能包围采样点。
然后如何计算三个顶点上各自的权重呢?利用加权三角形重心法,将采样点作为这个三角形的重心,以此计算各顶点权重。
三角形顶点坐标V1、V2、V3,对于没加权的重心 V =(V1+V2+V3)/3,加上权重λ后变为V = (λ1V1+λ2V2+λ3V3)/(λ1+λ2+λ3),代入各顶点和采样点坐标值后,外加已知条件λ1+λ2+λ3=1,可得λ1 λ2 λ3的三元一次方程,即可求得各顶点的权重值。
- 原点处无动画片段
对于原点处无动画片段情况,我们先假设原点有动画片段,根据加权重心算出各顶点权重后,再将这个不存在的原点处权重平均分给所有范例点。这样就能出现大于三个范例点都有权重的情况。
2D Simple Directional混合树的优点显而易见,它性能好,我们要做的只是遍历一遍所有的动画片段,然后再计算一下重心就可以了。缺点也是显而易见的,它不允许从原点出发的同一个方向上出现两个动画片段,因为这样就可能会找不出合适的三角形;同样它也不允许原点的某个方向上的180度以内都没有顶点,因为这样也会找不到合适的三角形。
考虑到它的性能比较好,当我们不需要在某个方向上混合多个动画时,我们应该首选2D Simple Directional混合树。但是我们往往需要在同一个方向上混合多个动画片段,比如我们不只需要走还需要跑的时候,此时我们就需要寻找别的替代方案了。
2D Freeform Cartesian计算权重原理
这个算法背后就是Johansen硕士论文中的梯度带插值算法(Gradient Band Interpolation)
首先第i个范例动画,它在参数空间下有一个坐标pi,这个范例动画对参数空间中的任意一点p都有一个影响值hi,这个hi如何求呢?首先我们遍历一下除i点外的其他所有范例点,针对每一个范例点Pj,求出PiP在PiPj上的投影长度,以及它和PiPj长度的比例。这个比值在一定程度上反映了Pi点Pj点它们和P点的距离关系。
现在我们可以观察到这个比值越大,Pi的影响值就越小,这样是有一些反直观的,所以我们用1减去这个比值,这样就得到了范例点i相对于范例点j的相对影响力。垂直于PiPj可以画出两条线,两线外就是影响力为1或0的区域,中间则是介于0和1的区域。
我们在参数空间里观察一下这个算法,可以看到P1点和P2点的相对影响力关系,这里构成了一条递减的梯度带,这也就是该算法被称为梯度带算法的原因。
我们接看用同样的方式遍历所有的范例点,计算出Pi与它们之间的相对影响力,然后我们在所有的这些相对影响力中求它们的最小值,这样就得到了Pi在整个参数空间下的影响力hi。
在参数空间的示意图上可以看出它大概长这样:
那么把Pi点的影响力hi,除以所有范例点的影响力的和,也就是我们对它做了归一化之后,就可以得到Pi点的权重Wi了。这就是2D Freeform Cartesian的权重分配原理。
当前的梯度代算法,基本符合我们之前提到的所有基本原则,但是在实际应用中却存在着一定的问题。
当我们需要制作一个包括了各个方向,比如行走和奔跑的混合树,参数落在两个方向的跑中间,我们希望是两个方向跑的融合,但此时却融合了走的动画,2D Freeform Cartesian是不能够满足我们的需求的。
所以Johansen还提出了一个梯度带算法的加强版,也就是极坐标下的梯度带算法,Gradient Bands in Polar Space。对应到unity这里就是2d freeform directional混合树背后的算法。
2D Freeform Directional计算权重原理
Johansen定义了极坐标下的向量
代入上面的权重公式得到:
从公式中可以得出,当i j范例点和原点距离相等时,即|Pi|=|Pj|,则权重与∠(P,Pi)成比例,从图片上看就是梯度带沿角度平均分布。
当两个范例点相对于原点同方向,那么∠(Pi,Pj)=0,权重则与|P|-|Pi|成比例,从图片上看就是梯度带成圆环分布。
其他情况梯度带遵循阿基米德螺旋分布。
极坐标下的梯度带算法可以相对精确地对不同方向和速度的动画进行插值,其时间复杂度是O(n^2),算不上高效,所以Uniy在2D Freeform Directional混合树中对该算法进行了大量的优化。
七 Root Motion
主要参考:
- Unity官方手册-根运动
- UE4官方文档-根运动 & UE5官方文档-根运动
7.1 为什么要用Root motion?
如果动画行走播放速度和角色实际位移速度不一致,就会出现“滑步”现象,如果仅仅这样还可以通过代码调整动画或者控制角色位移来实现同步,但对于一些速度非线性的复杂动画是很难模拟的,不如将移动交给动画师决定,也就是让动画来驱动位移,引擎计算动画的根运动数据,将其应用在角色上,驱动角色移动,就能让角色的移动与动画完美匹配。
7.2 什么是Root Motion?
Root Motion直译就是“根骨骼运动”,在Unity中Root Motion的确切含义和作用定义比较模糊,说法众说不一(或者是角度不一样?),这里简单罗列一些说法:
-
[Unity3D]什么是Apply Root Motion?什么是Bake into Pose?
-
Apply Root Motion:应用根部动画。
作用:当你使用的骨骼动画会使得整个对象发生位移或偏转的时候,勾选Animator下的Apply Root Motion选项,会将位移和偏转实时附加到父物体上。
-
Bake into Pose:烘焙成姿势。
作用:将整个骨骼动画对角色产生的位移和偏转,转化为姿势,或者说BodyPose,接下来无论你是否勾选Apply Root Motion,都将不会使得父级的Transform发生变化。
-
-
Randy
所谓根节点就是一个角色的最高父节点,这个最高父节点在虚拟世界中的位置将决定角色的位置,根节点运动时,整个角色就会开始运动(走动,跑动)。在Animator组件中我们可以选定是否要引入rootMotion,即将动画中根节点的位移,植入到Animator所在的物体上。 根节点的位移植入,可以理解为,是否要让动画师来决定角色的位移。
-
IGBeginner0116
-
在自制的非骨骼动画中,root motion会把动画文件中描述的对游戏对象的坐标和角度值,转换为相对位移和相对转角,并以此来移动游戏对象。
-
而在generic动画中root motion会把动画文件中描述的根骨骼坐标值和角度值转换为相对位移和相对转角,并以此来移动游戏对象。
-
那么到了humanoid动画里,由于使用了Avatar系统,动画文件不再包含对具体骨骼的描述,我们自然也就无法通过指定根骨骼来应用root motion,unity为了解决这个问题,在humanoid动画中通过分析骨骼的结构,计算出模型的重心center of mass,这个重心也可以被称为body transform。(在预览动画这里激活这个选项,大家可以在脚本中通过animator.bodyPosition和bodyRotation来访问它的坐标和方向)。接下来unity会根据具体动画计算重心在水平平面的投影,并把这个投影当做root motion的“根骨骼节点”来对待,这个点被称作root transform。(在脚本中我们可以通过animator.rootPosition和rootRotation来访问它的坐标和指向)这个投影并不是直着投射下来的,这中间经历了一些其他的计算。
大家也可以看到游戏对象所处的位置其实就在这个 root transform上,也就是说在应用root motion时,unity会把root transform上的位移应用到游戏对象上。简单的来说,我们可以把这个unity计算出来的root transform,当做humanoid动画下代表rootmotion的根骨骼节点。
总结来说 humanoid动画下root motion的原理就是:在humanoid动画中,Unity会计算出一个Root Transform,RootMotion会把动画文件中描述的RootTransform的坐标和角度值,转换为相对位移和相对转角,并以此来移动游戏对象。 通过这种方式,我们就可以在不同的骨骼结构上复用同一个rootmotion动画。
-
-
Yene 死板地介绍Unity动画系统设计
- 这里的根节点运动并不是一般意义上的“动画中的根节点的运动”,而是Mecanim动画系统处理根节点运动的一些列相关功能,以下都直接称之为RootMotion,而Mecanim一般称原本动画中对根节点的运动为< Root Transform >
- Mecanim有很具有特色的RootMotion处理功能,这里首先要提出两个概念:
- Scene Root,模型文件中骨骼中真正的根节点,一般名称就是Scene Root,
- Model Root,是Mecanim系统中模型语义上的中心节点;
- 在非人形动画(Generic)中,默认没有Model Root,开发者可以指定任何节点为Model Root,而人形动画(Humanoid)中,Model Root不是任何一根具体的骨骼,而固定是人形骨骼的重心,在Unity给出的API中,根据导入配置选项差别,也称为Body或者Center of Mass;
- 一旦骨骼对象拥有Model Root,其上的动画就会有RootMotion的语义,其详细意义是将Model Root在模型空间内的变换映射至Scene Root上,换句话说,RootMotion可以在原动画中不包含RootTransform时在Runtime时根据设置计算得到RootTransform;
- RootMotion为开发者提供了两种处理计算得到的RootTransform的方法,一种是保留根变换,一种将根变换烘焙进Model Root中,也就是Unity官网解释的RootTransform和BodyTransform方法;具体使用方法取决于Animator和AnimationClip的设置;
那到底这个Root Motion到底什么含义呢,我们一步步探究。先从基础概念看起。
7.3 Root Motion 与 In Place动画
动画分含root motion动画和in place动画
mixamo可以选择动画为in place
root motion动画转in place动画原理大概就是将角色重心的移动轨迹压缩到一个点上,具体参见:Stay In Place Animation
7.4 名词概念辨析
Body Transform
、Root transform
、root.q
、root.t
相信大家在学习root motion时早被上面这些名词弄得晕头转向,它们分别是什么含义,有什么区别?
我们先看一下官网怎么说的:
-
身体变换(Body Transform)
身体变换是角色的质心。它用于 Mecanim 的重定向引擎,并提供最稳定的移位模型。身体方向是相对于 Avatar T 形姿势的下身和上身方向的平均值。身体变换和方向存储在动画剪辑中(使用 Avatar 中设置的肌肉定义)。它们是动画剪辑中存储的唯一世界空间曲线。所有其他:肌肉曲线和 IK(反向动力学)目标(手和脚)都是相对于身体变换进行存储的。
-
根变换(Root Transform)
根变换是身体变换在 Y 平面上的投影,并在运行时计算。在每一帧都会计算根变换的变化。变换的此变化随后应用于游戏对象以使其移动。
根据我的理解,官网所说的是针对humanoid动画而言,body transform对generic动画也同样使用,但它就不一定是角色重心,而是开发者所指定的root node。
其次上面所说的“动画剪辑中存储的唯一世界空间曲线”应该就是指root.t
和root.q
,这个说法跟Yene大佬的说法“RootMotionCurve是程序内部生成的对象,它的特殊之处在于储存的值是相对整个模型空间的变换,而不是相对父节点的局部变换”一致。
我的理解:当使用Humanoid动画或者Generic动画添加root node后,动画clip会根据重心或者所指定的root node计算得到RootMotionCurve,这个“根运动曲线”在动画clip中表现形式就是root.t
和root.q
。而Root transform估计是根据上面的RootMotionCurve和具体root transform三个维度配置计算得到,然后根据开发者设置,将各维度root transform分量,选择bake into pose还是应用到父对象。
其中root.t
和root.q
可以在运行时更改(不过要把clip拷贝出来)
7.5 root transform参数解析
- 计算出来的Root transform有三个分量:旋转(Yr)、垂直移动(Yt)、水平移动(ZXt)。每个分量都可以单独作用到body。
- loop match:红黄绿灯表示动画clip首尾帧的帧差异,比如跑步动画,首尾帧在水平移动分量上差异过大,loop match亮红灯,此时不建议将此root tranform分量bake into pose,否则动画在尾首循环时水平方向会有“闪回”的现象。同理跳跃动画垂直移动分量不建议bake into pose,左转动画在旋转分量上不建议bake into pose。
- Loop Time:循环播放动画。
- Loop Pose:对动画尾首帧进行插值,让动画循环时衔接顺滑。
- Based Upon(at Start):简单理解就是决定把模型的哪个位置对齐到模型的原点上,可以选择center of mass(或root node)、feet(仅humanoid动画)或默认,以及确定模型初始朝向。
下图是Humanoid动画Apply root motion 且都Non-baked情况下,Root Transform各参数对比。
- 可以看到 Root Transform Rotation(简写为Yr) Based on选body oritation情况下,初始方向会产生偏差,与动画师定义的初始方向有些微差别;
- Root Transform Position(XZ)选center of mass时,可以看到角色在xz初始位置还是在直线上的,说明按重心计算得到的在xz平面的初始位置与动画师设定的一致。
- 但对于Root Transform Position(Y)的三个选项original、Center of Mass、Feet间差距就比较明显:original就是动画师制作动画师设定的原点、mass center是通过unity计算得到的角色重心,feet就是脚节点位置。如果导入动画时角色脚不贴合地面就可以设为feet并通过调整offset来纠正。
这里再放一个骨骼与父节点错位情况下,root motion对动画的影响
可以看到动画并不是从骨骼初始位置开始播放,还是对齐到模型原点,这可能就是Based Upon(at Start)的含义。
7.6 Apply Root Motion 与 Bake into Pose⭐
Root Motion总共有三种状态,勾选,不勾选,以及Handled by Script(脚本实现OnAnimatorMove()方法)
勾选Apply root motion和OnAnimatorMove()方法中使用animator.ApplyBuiltinRootMotion()
等效。
根据Animator组件所在的物体,(Apply Root Motion时)位移/角位移的植入有三种不同的表现形式:
- 物体没有Rigdbody,也没有character controller组件,则位移/角位移将被植入到Transform变换中
- 物体拥有Rigdbody组件,则位移/角位移将被植入到Rigdbody的速度和角速度中,根节点的位移和角位移将借助物理系统的刚体速度,刚体角速度来体现
- 物体拥有Character Controller组件,则位移/角位移将被植入到Character Controller的速度和角速度中,根节点的位移和角位移,将借助Character Controller的运动来实现
如上所说,当没有Rigdbody和character controller组件时,在方法中更新对象的Transform transform.position += _animator.deltaPosition; transform.rotation *= _animator.deltaRotation;
也能达到同样的效果。(注意Apply root motion是考虑到对象缩放值的,对于同样动画,scale小的移动幅度也会比例缩小。)
如下是对legacy动画应用Root Motion的对比图:
legacy动画的Root Motion比较简单,应用(或等效应用)Root Motion,对象就能连续运动而不出现“闪回”现象。
但对于Mecanim动画,情况就会变复杂,因为影响对象运动除了Apply Root Motion,还有Bake into pose。
7.6.1 generic动画RootMotion不同参数对比
- No Avatar和root node配置
generic动画未配置root node情况下,root motion无效。
注意:generic 没设置avatar和root node时正常不会有root motion相关的设置的,但如果先设置avatar 和root node,然后再改为no avatar,animation clip的root transform相关配置依然可见,动画的root.q,root.t也保留,这可能是bug。
- 配置 Avatar和root node
generic动画配置root node情况,呈现三类状态,如下图所示:- 不应用root motion:父节点不运动的闪回动画播放(1、2)
- 应用root motion,但勾选bake into pose
- 勾选xz 平面bake into pose:父节点可以旋转的闪回动画播放。(3)
- 勾选Y旋转:带动父节点运动的连续动画播放,但父节点绕Y轴旋转无效(4)
- 应用root motion,且不勾选bake into pose:带动父节点运动的连续动画播放(5)
可以看到bake into pose和apply root motion一定程度上是互斥的,一个分量的root transform选择baked into pose,那这个分量就不作用于父节点。
7.6.2 humanoid动画的RootMotion不同参数对比⭐
humanoid动画,root motion呈现多种状态:
- 未apply root motion,且zx position未bake into pose:呈原地踏步状态(1、3、4)
- 未apply root motion,但zx position勾选bake into pose:父节点不运动的闪回动画播放(2)
- apply root motion,但zx position勾选bake into pose:父节点可以旋转的闪回动画播放。(5)
- apply root motion,但rotaion勾选bake into pose:带动父节点运动的连续动画播放,但父节点绕Y轴旋转无效。(6)
- apply root motion,non baked into pose:带动父节点完全运动的连续动画播放。(7)
其实2、3、4是同类型,5与6也是同类型,都是将root transform对应分量应用到body中,只是因为xz分量无法loop match,所以闪回效果很明显,如果换成旋转类的动画,勾选旋转bake一样会产生闪回效果。
所以上面主要分四类:
- bake和root motion都不作用:呈原地运动效果,可能对某些动画会产生异常显示现象,如下:
- 某一维度(水平移动、垂直移动、绕Y旋转)baked,不勾选apply root motion:勾选baked into pose维度的root transform作用到骨骼节点
- 某一维度bake,且勾选apply root motion:未勾选bake维度的root transform作用到父节点,勾选baked维度的root transform作用到骨骼节点
- 勾选apply root motion,且不勾选bake into pose:所有维度的root transform作用到父节点,骨骼节点跟随父节点
对比Humanoid和Generic动画发现,其差异在第一种情况,apply root motion和bake into pose都不作用情况下,generic的效果等同三个维度都baked,而humanoid呈现原地运动现象。
7.6.3 OnAnimatorMove方法对比
我们知道root motion除了Apply和Non-Apply还有第三种状态Handle by script,就是在脚本添加OnAnimatorMove()方法,在此方法中添加 transform.position += _animator.deltaPosition; transform.rotation *= _animator.deltaRotation;
可以做到等效ApplyRootMotion的效果,那么结合bake into pose会有什么效果呢?
public class HandleByScriptEqualMethod : MonoBehaviour
{
private Animator _animator;
void Start()
{
_animator = GetComponent<Animator>();
}
private void OnAnimatorMove()
{
transform.position += _animator.deltaPosition;
transform.rotation *= _animator.deltaRotation;
}
private void Update()
{
Debug.Log("deltaPosition = " + _animator.deltaPosition*100f);
}
}
如下是Humanoid动画各参数对比效果:
可以看到,handle by script 的Root Motion控制和Apply Root Motion效果是一样的。
但比较奇怪的是transform.position += _animator.deltaPosition;
这一步明明更新了父节点的transform了,上图中父节点依然无法移动?除非这个_animator.deltaPosition
值为0。
打印对比baked和Non-Baked的deltaPosition发现,zx平面baked into pose时deltaPosition的xz分量为0,这就是同是父节点transform更新情况下,bake时父节点无法移动的原因。
7.6.4 root motion个人理解⭐⭐
经过上面的探究,我们可以试着总结下,Bake into pose
和Apply root motion
到底做了什么:
-
当我们使用
humanoid
动画或者generic
动画添加root node
节点后,Mecanim就会启用root motion
相关功能,系统会根据动画片段计算出包含三个维度的root transform
,这个root transform
就是原动画的根节点或者重心运动轨迹数据。开发者可以根据需求将各root transform
分量bake到body tranform
中,指导Avatar系统模型的运动(这也许就是上面generic和humanoid动画在未apply root motion且未bake into pose情况存在差异的原因所在)。 -
如果对骨骼模型的父对象使用
apply root motion
,系统会将未bake into pose
的root transform
分量作用到父对象的transform
中,然后骨骼节点将跟随父对象移动。所以这也是为什么在apply root motion和未bake into pose情况下,模型能连续运动的原因,其实这可以看作“被父节点的移动带动的原地动画”。- 旋转也是同理,可以看作“被父节点的旋转所带动的不转的旋转动画”。但原地行走动画比较好理解,就是滑步嘛,但“不转的旋转动画”却难以理解,直接上图:
- 旋转也是同理,可以看作“被父节点的旋转所带动的不转的旋转动画”。但原地行走动画比较好理解,就是滑步嘛,但“不转的旋转动画”却难以理解,直接上图:
右:所有分量均bake into pose的正常动画
- 至于apply root motion更新父对象的transform是如何作用的,类似在Update方法里写
transform.position += animator.deltaPosition;
勾选bake into pose的维度其对应的deltaPosition和deltaRotation分量就为0,所以就产生父对象运动和不运动的差异。至于如何计算delta的估计和Avatar系统有关,目前不作考究。
7.7 Root Motion和BlendTree结合案例
简单配置:
简单的运动控制代码如下,但其中还存在一些问题,可以继续优化:
public class PlayerController : MonoBehaviour
{
private Animator _animator;
private static readonly int Speed = Animator.StringToHash("speed");
private Vector2 movement;
private bool isRunning = false;
private float deadzone = 0.01f;
public float walkSpeed;
public float runSpeed;
void Start()
{
_animator = GetComponent<Animator>();
_animator.SetFloat(Speed,0);
var humanScale = _animator.humanScale;
walkSpeed = 1.34f;
runSpeed = 3.74f;
}
void Update()
{
Rotate();
Move();
}
// 处理转向
private void Rotate()
{
// 按键释放时会读取值为0,导致角色方向重置,过滤掉
if (movement.magnitude > deadzone)
{
Quaternion targetQuaternion = Quaternion.LookRotation(new Vector3(movement.x,0,movement.y));
transform.rotation = Quaternion.RotateTowards(transform.rotation, targetQuaternion, 1000f*Time.deltaTime);
}
}
// 处理移动, 这里直接更新状态机的Parameter就行,具体移动交由root motion
private void Move()
{
float currentSpeed = (isRunning ? runSpeed : walkSpeed) * movement.magnitude;
_animator.SetFloat(Speed,currentSpeed);
}
// 角色移动事件回调方法
public void PlayerMove(InputAction.CallbackContext ctx)
{
movement = ctx.ReadValue<Vector2>();
}
// 角色奔跑事件回调方法
public void PlayerRun(InputAction.CallbackContext ctx)
{
switch (ctx.phase )
{
case InputActionPhase.Performed:
isRunning = true;
break;
case InputActionPhase.Canceled:
isRunning = false;
break;
}
}
}
可能存在的问题:
- 模型移动莫名产生累积垂直位移。
- 原因:run和walk clip移动过程中Y方向还是存在位移的,尽管整个动画平均Y方向位移是0,但如果不让动画完整播放多次(比如鬼畜启停),就会产生累积垂直位移。
- 解决方法:将垂直root transform bake into pose。
-
角色与其他物体碰撞后不受控旋转
- 可能原因:rigidbody受到物理力影响产生旋转
- 解决办法:rigidbody freeze Y rotation
-
衔接丝滑问题,角色反应不灵敏等:关闭Has Exit Time,调整transition duration等
-
角色启停过快动画不丝滑,以及频繁切换动画导致鬼畜动作:给运动加差值函数。
-
多个角色速度不一致。
- 原因:由于root motion根角色模型scale相关,同样的动画,对于大模型移动的会更快,小模型移动就更慢,如果想让所有使用同一动画的模型移动速度相同的话,就要除去模型scale参数的影响
- 解决方法:改变动画播放速度 :
_animator.speed /= _animator.humanScale;
-
使用root motion后最大移动速度已被动画所默认,那么如果想调整这个移动速度,就可以通过调整animation speed来实现。动画播放速度= 角色目标移速 / 动画模型运动速度。
-
新发现: 状态机还分编辑态和运行态,运行态也可以分不同副本,使用animator controller时需要注意
因为同一个状态机可以给不同对象使用,但运行却可以分别调参,但更新状态机会同步到所有编辑态和运行态。(这里编辑态和运行态是我自己的理解)
优化后的代码:
public class PlayerController : MonoBehaviour
{
private Animator _animator;
private static readonly int Speed = Animator.StringToHash("speed");
private Vector2 movement;
private bool isRunning = false;
public float walkSpeed;
public float runSpeed;
public float accelerateDamping = 10f;//速度切换的阻尼感
public float decelerateDamping = 5f;//速度切换的阻尼感
private float currentSpeed;
void Start()
{
_animator = GetComponent<Animator>();
_animator.SetFloat(Speed,0);
var humanScale = _animator.humanScale;
walkSpeed = 1.34f;
runSpeed = 3.74f;
_animator.speed /= humanScale;
}
void Update()
{
Rotate();
Move();
}
// 处理转向
private void Rotate()
{
// 按键释放时会读取值为0,导致角色方向重置,先过滤
if (!Mathf.Approximately(movement.magnitude , 0))
{
Quaternion targetQuaternion = Quaternion.LookRotation(new Vector3(movement.x,0,movement.y));
transform.rotation = Quaternion.RotateTowards(transform.rotation, targetQuaternion, 1000f*Time.deltaTime);
}
}
// 处理移动, 这里直接更新状态机的Parameter就行,具体移动交由root motion
private void Move()
{
var targetSpeed = (isRunning ? runSpeed : walkSpeed) * movement.magnitude;
// 减速时加个阻尼
if (targetSpeed < currentSpeed)
{
currentSpeed = Mathf.Lerp(currentSpeed, targetSpeed, decelerateDamping * Time.deltaTime);
}
else
{
currentSpeed = Mathf.Lerp(currentSpeed, targetSpeed, accelerateDamping * Time.deltaTime);
}
if (Mathf.Approximately(currentSpeed,0))
{
currentSpeed = 0;
}
_animator.SetFloat(Speed,currentSpeed);
}
// 角色移动事件回调方法
public void PlayerMove(InputAction.CallbackContext ctx)
{
movement = ctx.ReadValue<Vector2>();
}
// 角色奔跑事件回调方法
public void PlayerRun(InputAction.CallbackContext ctx)
{
switch (ctx.phase )
{
case InputActionPhase.Performed:
isRunning = true;
break;
case InputActionPhase.Canceled:
isRunning = false;
break;
}
}
}
优化前后对比如下,可以看启停加入差值后,角色移动变得更加丝滑真实。
八 总结
通过实践,初步了解了Unity 的Animation、状态机、root motion、bake into pose、blendTree等知识,但要在实际项目中使用动画,还需要结合IK甚至是Playable等内容,抑或是第三方动画插件,当然也涉及刚体、碰撞体等知识,还有跳跃等复杂动画判断,需要一步一步来。