前言
笔者接触unity的时间也不算短了,但常感自己对它很多方面的了解还不够深入。正好最近在独自做一个RPG游戏,便决定就开发过程中使用到的引擎模块进行更深入的学习和理解,以此博客作为学习纪录。如果有幸帮助到正在阅读此文的人,那就再好不过了。
在这一篇中,我们一起来看看unity的角色动画系统。本文主要参考内容来自Unity官方文档和《游戏引擎架构》(Jason Gregory著,叶劲峰译,电子工业出版社)这本书的动画系统部分,探究在unity这个特定的引擎中是如何实现动画系统的。使用的引擎版本为2020.3.10f1,如有不严谨之处还望指出。
建议使用右侧目录。
一、游戏角色动画发展简史
根据《游戏引擎架构》,角色动画在游戏引擎中主要经过了以下形式的演变。
1. 精灵图动画
精灵图即sprite,其来源于赛璐璐动画(cel animation)。后者是在塑料片上绘画,将各种赛璐璐片重叠放在静态背景上,就能实现角色在场景中的运动,ps等软件中的图层概念与之类似。
精灵图是赛璐璐的数字版本,一般是背景透明的二维图片。把一组精灵图有序地播放,就能呈现角色的动画效果。有些动画首尾相接以保证能流畅地重复播放,称为一个cycle,基本的有idle、walk、run等循环动画。精灵图在二维游戏中运用十分广泛,例如马里奥、合金弹头等经典作品。
在unity中将图片的texture type设为sprite,就变成了一张精灵图。如果包含了不同帧,可选择multiple模式后使用Sprite Edtior(需要2D sprite包)进行切分,切分后将需要的切片全选定,拖入场景就会自动生成有序播放的2d动画。
2. 刚性层阶式动画
精灵图每一帧都要艺术家亲自绘制,而且在完成后不易调整动作,复用性也不高。而三维图形技术的发展,给游戏界带来了全新的动画方式,刚性层阶式动画(rigid hierarchical animation)就是早期时候的产物。
这个词乍一看有点摸不着头脑,我们从其英文名来拆分下。rigid的意思是“刚性的,不弯曲的”,在这种方案下,角色整体由各个部分的独立模型组成,它们各自的形状是不变的,只能改变相对的位置。hirerarchical指“分等级的”,即角色的各个部件按等级结构组织起来。以下图为例,可将躯干作为最高层,其下是头、左右大臂、左右大腿,而大腿之下又有小腿、足部……整体形成树状的层级结构。当树中的父结点运动时,子节点受到约束也跟着运动,如大腿能带动小腿和足部运动。
使用刚性层阶式动画能高效直观地调整角色的动作,但其调节的灵活程度取决于关节的数量,诸位小时候是否玩过奥特曼玩具?有的奥特曼只有俩胳膊俩腿能旋转,能摆的pose很有限。同时对于人体等模型来说,由于身体部件只是堆放在一起,失去了模型的完整性,且在运动时关节处可能会产生缝隙。
3. 逐顶点动画和变形动画
上面我们提到刚性动画的缺点,部件的分离破坏了角色的整体性。那么在保证角色模型是一个整体的情况下,要如何实现躯干的运动呢?
一种方法是直接移动模型的顶点,即逐顶点动画(per-vertex animation),这种方法能实现角色的任何变形,但由于需要存储每个顶点在时间轴上的位置信息,且数据量随模型精度而显著增长,在实时渲染的游戏引擎中并不常用到。
另一种方法是使用变形动画(morphing animation),对比逐顶点动画,只需制作部分关键帧的顶点动画,在相邻关键帧之间对顶点位置使用线性插值计算其位置,节省了制作时间。这一技术常用于角色面部动画,能模拟面部肌肉的运动,实现非常细微的表情变化。
4. 蒙皮动画
蒙皮动画(skinned animation)是我们最常接触到的技术,其在游戏和影视界的广泛应用证明了这一方案的合理性。
它集成了上述几种方案的优点,我总结有三点:
1.使用类似于刚性层阶式动画中的刚性部件——骨骼(skeleton)来控制角色的运动,带来了高效的动画调整方式和更低的数据量要求,只需要操作和存储少量的骨骼运动。
2.骨骼并不渲染,玩家真正看到的是绑定在其上的皮肤(skin),保证了角色模型的整体性。
3.模型的各顶点受绑定的骨骼约束而移动,能产生逐顶点动画中的形变效果。
二、运动的基础——角色的骨与肉
在上一节我们简单回顾了角色动画的发展历史,并了解到蒙皮动画这一现在流行的技术。那么在unity中,为了让角色能够动起来,引擎为我们提供了哪些功能?我们不妨走一遍这个过程,从将角色模型导入unity,到能够播放一个角色动画为止。
1. 模型导入设置
我们在blender中简易制作一个Low poly风格的人物模型,并绑定一套骨骼,使用软件自动绘制的权重,之后导出为FBX格式的文件,拖入unity的Asset中。
选中FBX文件,在Inspector面板中显示了导入设置(import settings),分为Model、Rig、Animation、Materials四栏,我们先只看Rig,即骨骼部分。unity提供了三个可调节项,分别是Animation Type、Avatar Definition和Skin Weights。前两项关乎动画重定向的问题,最后一项关于蒙皮动画的数据存储,我们将在后面对它们分别再详细阐述,可以从目录跳转查看。总之我们先按下图勾选,apply后将模型拖入场景。
2. 导入场景
将人物模型拖入场景,在Hierarchy中,我们看到其包含了层级结构,父物体名字与FBX文件一致,其包含了Transform和Animator组件,Animator的部分我们将在后面再说,其主要用来控制角色的运动。
在父物体之下,有Armature和Body两个子物体,它们的名字分别与blender中制作的模型的骨骼和网格模型相同,我们可以理解为角色的骨与肉。展开Armature物体,看到其下还包含了复杂的层级结构,以Hips即臀部(髋)为根节点,其下又有左右大腿和脊椎,脊椎向上为整个上半躯体。Armature中的所有物体都只包含一个Transfrom组件,即只有它们各自的位置信息,而不会渲染在场景中。
那场景中我们看到的是人物的哪一部分呢?答案位于Body物体下,我们选中它,在Inspector面板中除了Transform外,还包含了一个叫做Skinned Mesh Renderer的组件,有许多可供调节的选项,这一组件就是我们将要研究的重点。
3. Skinned Mesh Renderer
Skinned Mesh Renderer(简称SMR代替),直译为蒙皮网格渲染器。这是unity用来实现蒙皮动画的方案,我们从以下不同方面来一探究竟。
3.1. 骨骼
骨骼,英文名一般为skeleton或者armature,控制着蒙皮动画中的角色运动。骨骼并非单个的物体,而是由不同层级的子物体以树状结构组合而成,这些子物体就称为骨头(bones)。在我们的例子中,人物的一条胳膊就被简化为了大臂、小臂、手掌的三段骨头。
在skinned mesh renderer的Inspector面板中,我们只能看到Root Bone项,即骨骼的根骨头,上图中可见是Hips。当然,在C#脚本中我们还是能获取到所有的bones信息,我们新建一个Test脚本,写一段代码测试下:
public class Test : MonoBehaviour
{
[SerializeField] private SkinnedMeshRenderer skinnedMeshRenderer;
private void Start()
{
if(skinnedMeshRenderer != null)
{
PrintSmrBones(skinnedMeshRenderer);
}
}
public static void PrintSmrBones(SkinnedMeshRenderer smr)
{
Debug.Log("Root bone: " + smr.rootBone.name);
Debug.Log(smr.bones.Length + "bones:");
foreach (var trans in smr.bones)
{
Debug.Log(trans.name);
}
}
}
SkinnedMeshRenderer类继承自Render,后者为组件(Component)类。它包含了rootBone和bones属性,分别为Transform和Transform[]数组类型,表示整个骨骼的根骨头和所有骨头。我们将人物的Body物体拖给Test脚本并运行游戏,部分输出如下:
在实现角色换装时,我们需要用到skinned mesh renderer中的骨骼。以笔者在做的demo为例,笔者在blender中制作好了人物的模型,以及几套盔甲、靴子和头盔,这些服装模型与人物身体绑定了同一套骨骼,但各自的权重不同。将以上所有模型连同骨骼导入unity并设置后,在场景中进行测试,服装模型能正确地跟随人物骨骼运动。选中一个服装模型,在Inspector面板中,它也带有skinned mesh renderer组件,且经过测试其root bone和bones属性和人物body的属性完全相同,即共享了一套骨骼。
当然,在RPG游戏中,我们希望角色能动态地更换盔甲等防具,一种朴素的想法是把所有服装都做好后一起导入unity,这样角色身上就挂载了所有的服装模型,在运行时控制穿着的服装被激活,其他的部分全用SetActive(false)。这种方法在服装数很少时也许很方便直接,但当服装数目增多时,挂载这么多模型会产生不必要的性能浪费,而且当我们在三维软件中制作了新的服装后,恐怕又得重新导入一遍。因此这一方案并不适用于我们的需求。
既然如此,不如将服装模型全都作为预制体(prefab)拖到项目Asset中,在运行时再动态地挂载到角色身上,同时这些服装也就能分离出来,作为游戏中可掉落、拾取、交易的物品,听起来非常合理。但导出预制体后,我们发现其skinned mesh renderer的root bone为空,同时对bones进行输出测试,发现其保留了骨头数组的长度Length,但每一项都为null。
因此为了给角色穿上衣服,我们需要重新给服装的root bone和bones赋值,由于在三维模型中,它们与人物的身体绑定的是同一套骨骼,因此可以用下面的简短代码实现:
[SerializeField] private Transform m_TargetTrans;
[SerializeField] private SkinnedMeshRenderer m_TargetBodySmr;
public void DressUp(GameObject clothePrefab)
{
GameObject clotheObj = Instantiate<GameObject>(clothePrefab);
SkinnedMeshRenderer smr = clotheObj.GetComponent<SkinnedMeshRenderer>();
clotheObj.transform.parent = m_TargetTrans;
smr.rootBone = m_TargetBodySmr.rootBone;
smr.bones = m_TargetBodySmr.bones;
PrintSmrBones(smr);
}
注意这种方式仅适用于服装和身体绑定了完整的同一套骨骼,笔者曾遇到的坑就是把人物连同几套服装一起上传Mixamo,并使用了自动绑定,照上面的方法将服装动态挂到人物身上,运行时发现服装并不能正确地跟随骨骼运动,后来测试发现原因是Mixamo为服装绑定的骨骼不完整,例如头盔只绑定了上半身的部分骨骼,因此不能直接赋值。
3.2. 网格与蒙皮
我们提到骨骼在游戏场景中是不可见的,玩家真正看到的是各种各样的网格模型。在skinned mesh renderer的inspector面板中,我们看到有一项叫做Mesh(网格),也就是角色的“肉体”部分。
我们在场景中新建一个Cube,它带有Mesh Filter组件,其中也有Mesh项,我们当然也可以把其替换成人物的body模型,那么它和SMR中的mesh有何区别呢?为什么前者能跟随骨骼运动而后者大多数情况下是静态的?
通过查询UnityAPI,我们了解到skinned mesh renderer中提供了sharedMesh属性,也就是inspector中的Mesh项,mesh filter中则可以通过mesh或者sharedMesh访问(根据笔者查阅资料后的理解,mesh是每个mesh filter独有的,用sharedMesh产生的一个实例),它们都属于Mesh类。Mesh类中包含了模型顶点、法线、uv等基础属性,但我们关心的是boneWeights属性,这是一个BoneWeight数组,记录每顶点的骨骼权重。
// Summary:
// The BoneWeight for each vertex in the Mesh, which represents 4 bones per vertex.
public BoneWeight[] boneWeights { get; set; }
BoneWeight则是unity定义的一个结构体,主要存储顶点绑定的4段骨头的索引和权重值。
我们在Test脚本中新加部分代码如下,分别打印出SMR和mesh filter中的网格信息。
public static void PrintSmrMesh(SkinnedMeshRenderer smr)
{
Mesh smrMesh = smr.sharedMesh;
if (smrMesh != null)
{
PrintMesh(smrMesh);
}
}
public static void PrintMesh(Mesh mesh)
{
HashSet<int> hs = new HashSet<int>();
foreach (var weight in mesh.boneWeights)
{
hs.Add(weight.boneIndex0);
hs.Add(weight.boneIndex1);
hs.Add(weight.boneIndex2);
hs.Add(weight.boneIndex3);
//Debug.Log(weight.weight0 + weight.weight1 + weight.weight2 + weight.weight3);
}
Debug.Log("Bone nums:");
Debug.Log(hs.Count);
}
输出如上图,可见两者区别在于skinned mesh renderer中的蒙皮网格带有骨骼权重信息,而普通静态网格不具有。而且SMR中网格的所有BoneWeight的骨头索引数量与bones数组的数量一致,在本例中都是22段。
由于蒙皮网格模型的每个顶点都带有BoneWeight信息,又能通过其中的boneIndex访问到SMR中bones的特定成员——即该顶点绑定的所有骨头,那当我们驱动骨骼中的骨头运动时,相应的网格顶点也就能随之运动。
在游戏引擎中,一个顶点绑定4个骨头最为常见。一方面在于4个8位的骨头索引正好能填满一个32位数,或者4元的vector例如RGBA颜色;另一方面当高于4个关节时,增加每顶点绑定关节数量带来的差异不够显著,且会影响性能。从BoneWeight结构体可见unity默认采用的也是这种方案。
还记得在我们导入fbx模型时,Rig栏中提供了Skin Weights选项吗?默认为4 Bones,也可以选择Custom后自定义,由下图可见,最大支持每个顶点绑定255个骨头。同时我们还需要将Project Settings>Quality>Skin Weights项改为Unlimited,以及SMR中的Quality设为Auto。
但BoneWeight结构体不是最多只支持4根骨头吗?超过4根骨头时如何访问它们的权重和索引呢?原来在Mesh类中,还提供了一个GetAllBoneWeights方法,返回一个BoneWeight1类型的数组。
// Gets the bone weights for the Mesh.
//
// Returns:
// Returns all non-zero bone weights for the Mesh, in vertex index order.
public NativeArray<BoneWeight1> GetAllBoneWeights();
而BoneWeight1则是unity提供的另一个结构体,与BoneWeight不同,BoneWeight1表示一个顶点绑定的一根骨头的权重和索引信息,也就是说如果选择每顶点绑定255骨头,BoneWeight1的数量将会是255x网格顶点数。当我们设定每顶点绑定骨头数高于4时,unity会使用BoneWeight1来计算运动。
我们不妨顺带了解一下三维软件中的蒙皮,比起游戏引擎中单纯数据形式的存储,三维软件提供了更直观、可视化的操作。以blender为例,除了在绑定骨骼时由软件自动绘制权重外,也可以手动修改模型的蒙皮,这一操作一般称为“权重绘制”(Weight Paint)或者“刷权重”。选中一根骨头后,网格模型表面的颜色显示了不同顶点受到该骨头的影响程度,蓝色表示不受该骨头约束,红色则是骨头对此顶点的权重为1,即完全跟随其运动。我们可以使用多种笔刷,来方便快捷地增加或减少骨头在某些顶点上的权重。
3.3. 其他
除了最重要的骨骼与网格外,skinnned mesh renderer还有一些值得注意的特性。
3.3.1. BlendShapes
上面的gif图展示了BlendShapes的效果:人物身体提供了4个可调节的值,调节其值使人物网格模型的顶点移动,从而改变了人物的体型。
BlendShapes其实是逐顶点动画的一种,我们在1-3中提到过,它通过直接操作顶点来改变网格外形。可见unity并非只使用蒙皮动画,而是还融合了逐顶点动画能够细微调节的优点。像上图中调整BlendShapes的值以缩小人物身体,能减少人物穿上装备后的穿模现象。另一个重要的应用就是捏脸系统,使用BlendShapes让玩家能够在一定范围内调整角色的外观,如面部形状、五官大小、身体胖瘦等等。
当然,我们需要在三维软件中预先制作好BlendShapes,但名字可能有区别,在blender中这一功能位于Object Data Properties栏的Shape Keys,新建key后在编辑模式下改变相应的顶点即可。注意在制作完成后导入fbx到unity时,需要在Model栏中勾选Import BlendShapes。
3.3.2. Update When Offscreen
这是skinned mesh renderer的inspector面板中的一个选项,决定当该物体不在摄像机视野中时是否执行其动画,默认不会勾选。
如果不勾选这一项,unity会对场景中带有SMR的物体进行culling操作,只更新视野范围内的物体的骨骼动画,以减少一定的性能消耗。判断依据是网格模型的朝向包围盒(OBB, Oriented Bounding Box)与摄像机视锥体是否有交集,即模型周围的白色线框。在默认情况下,这个包围盒的能完全覆盖绑定姿势时的网格模型,在运动过程中,大小不发生变化,但始终跟随人物骨骼的root bone旋转。
但当人物做一些幅度较大的动作时,包围盒不一定能完全包裹住人物模型。例如在下图中,人物右手向前挥出一记直拳,手臂的一部分可能会突破包围盒,在不勾选update when offscreen的情况下,有可能会发生这样的问题:人物部分身体在视野内,而由于包围盒被摄像机剔除,不会更新其动画。为此,建议为重要角色勾选update when offscreen,以保证其骨骼动画随时都更新,或者在运行时通过脚本来动态调整Bounds,以保证包围盒的有效性。
4. Avatar
我们在2-1节导入模型时,inspector中有两项分别是Animation Type和Avatar Defination,由于这是一个人类模型,我们分别选择了Humanoid类型和Create From This Model。Animation Type比较好理解,那么Avatar又是个什么东西?
卡梅隆的电影《阿凡达(Avatar)》里,杰克在联结舱内通过神经连接,将意识转移到阿凡达身体内,得以操纵这具全新的躯体在潘多拉星球上奔驰。Avatar一词一般理解为“化身”,而这部电影也有助于我们理解unity中的avatar,我们一步步来说:
- 首先,资源的复用对游戏制作十分重要,能够节省开发成本。骨骼动画就是一项重要的资源,而由于人形生物的骨骼形态基本一致,在制作好某个角色的一套动画之后,是否能应用在其他人物模型上呢?动画重定向(Animation Retargeting)就能解决这一问题,在虚幻和unity中都提供了这一技术。
- 通过动画重定向,为主角设计的走路动画,同样也可以用在小兵身上,就像《阿凡达》中,杰克既能控制自己原本的躯体,又能操控阿凡达的身体。当然,电影中能实现这种转换,靠的是杰克的DNA和阿凡达能匹配,而在unity中,要使骨骼动画为其他人形角色使用,需要借助Avatar来匹配不同的骨骼。
- 因为对于不同的人物模型,一方面其骨骼结构可能不同,例如我们导入的low poly模型很简单,人物并没有手指和面部骨头,而对于高质量、商业级别的模型,其骨骼结构会十分精细和复杂。另一方面,骨骼的命名方式也很难完全一致,例如从髋部到胸部如果分为4段骨头,常见的命名方式就包括Hips-Spine-Chest-Upper Chest和Hips-Spine-Spine2-Spine3等。这两点使得骨骼间无法直接通用,而需要一个中间处理。
- unity中的Avatar实际上提供了一种从具体骨骼到通用人形的映射关系,即对于一副人形骨骼,对其主要位置例如躯体、四肢的骨头进行标记,这样在使用动画重定向和反向动力学(IK,Inverse Kinematics)等功能时,就能使用通用的处理方式。
4.1. Avatar的使用范围
- 角色模型需要Avatar,既可以像上面一样,对新导入的模型生成新的专用Avatar,也可以使用其他模型已生成的Avatar,勾选Copy From Other Avatar并指定Source即可,当然需要保证两者的骨骼相同。
- 有的fbx文件中,只包含了骨骼和动画信息,而没有网格模型,这些文件也需要指定Avatar,否则它们包含的动画片段将无法使用。
- unity的Animator组件用来控制角色动画,对于人形角色,需要为其指定Avatar。
4.2. 骨骼映射
我们按2-1节的导入设置后,点击Avatar Definition后面的Configure按钮,进入上图所示的界面,场景中为该角色模型,摆成了T-pose形,右边inspector面板中显示Avatar的相关内容。
右侧这些绿点表示人体通用的结构,其中实线边框的表示人形骨骼必须具有的骨头(关节),虚线边框的是可选的骨头,即使没有,骨骼也能正常运动,例如下图中头部的mapping,除了头部骨头外,脖子、眼睛、下巴都可以没有。双手手掌的全部骨头也都是可选的。
在我们初次导入fbx模型,并选择Create From This Model建立新的Avatar后,unity就自动为我们建立了骨骼映射关系。我们也可以在configure界面中,通过将hierarchy下的物体拖到inspector面板中对应栏来修改,或是点击Mapping按钮来进行清除、自动映射,还可以将当前的映射保存为Human Template(.ht)文件到本地,或是导入ht文件。
我们可以在场景中选中人物身上的骨头,对其进行移动缩放等操作以调整姿势,方便查看骨骼绑定效果,也可点击Pose按钮来重置人物姿势、变为T-pose。也可以在Muscles&Settings页面中进行姿势预览和微调,具体可以看Unity API。
4.3. Avatar Mask
我们在制作动作游戏时,可能会面临这样的需求:人物能够奔跑,能在原地挥舞大刀,也能边跑边耍大刀,前两种可以分别制作骨骼动画,那第三种情景能否不制作新的动画呢?使用Avatar Mask就可以实现。
遮罩(Mask)的思想在许多美术软件中很常见,本质上就是将操作区域进行划分,使得一些部分避免被影响。Avatar Mask则是指定了骨骼中受动画影响的区域,而其他区域不受影响。
我们在asset中新建一个Avatar Mask,在inpector面板中,展开Humanoid,点击人物身上的位置以改变遮罩。在下图中,人物上肢的骨头不会受到骨骼动画的影响。
大多数情况下,人形骨骼直接在Humanoid中可视化地操作即可,因为通过Avatar,unity能识别人形骨骼中的这些区域。如果Mask应用在非人形对象上,或者需要更细节的控制,则可以在Transform中直接导入Avatar,然后勾选受影响的骨头即可。
要实现我们前面提到的人物边跑边攻击的功能,一般还需要结合Animator Controller中的动画层功能,我们将在第四部分具体来说说。
三、动起来——片段与混合
我们在前面花了不少的篇幅来讲skinned mesh renderer和avatar,因为它们是保证角色能够正常运动的基础,接下来这一部分,我们将让角色真正动起来。
1. 姿势
在骨骼动画中,姿势(pose)可理解为在一个静止的时刻,各个骨头相对于某个参考系的状态集合,包括位置、旋转、缩放等。既然我们打算让角色动起来了,为什么又要提姿势呢?这是因为从本质上来说,动画是由一些静止的画面连续播放而产生的,对于骨骼动画来说,连续播放的是角色的姿势。
现实生活中我们做任何动作,例如抬起一只胳膊,骨骼的运动一定是连续的。但在计算机中模拟角色的骨骼运动时,采用的是一些离散的姿势,一般称为关键帧(key frame),以每秒24、30、60或者其他速率来播放一系列姿势,或者在相邻的关键帧之间进行插值(interpolation),就使角色动了起来。
T-pose是我们经常接触到的姿势,人物双臂抬起,整个身体呈现T字形。T-pose一般常用于为网格模型绑定骨骼,因为此时角色四肢与身体更为分离,容易进行顶点的绑定操作。
在游戏引擎中,整个骨骼的姿势由所有骨头的姿势的集合构成。每个骨头的姿势可用SQT(缩放Scale, 旋转Quaternion, 位移Translation)格式表示,例如下面的形式:
struct BonePose
{
float m_Scale;
Quaternion m_Rot;
Vector3 m_Trans;
...
}
而整个骨骼的姿势可能表示为:
struct SkeletonPose
{
BonePose[] m_BonePoseList;
...
}
2. 动画片段
动画片段(Animation Clip)是游戏引擎动画系统的重要部分。在影视作品或者游戏的过场CG中,场景中物体的运动经过了事先编排,如主角的走位、动作都已按时间定好,整个场景以一长串连续的帧播放产生动画。而在游戏中,往往是玩家的操作决定角色如何运动、播放怎样的动画,因此无法像CG一样把这一长串帧做“死”。为此,需要将角色的动画拆分为单个的动画片段,这些片段能明显区分出不同的动作,在游戏中实时改变角色当前播放的片段,例如行走或攻击。
在unity中,既可以使用外部文件如fbx中导入的动画片段,也可以使用unity内置的动画编辑器直接制作动画,在此只探讨前一种形式。我们可以从Asset Store或者Package Manager中导入官方的Standard Asset包,在Characters\ThirdPersonCharacter\Animation中包含了一些不错的骨骼动画片段。
2.1. 时间线
由于动画片段是不同关键帧连续播放而成,需要决定这些关键帧/姿势播放的先后顺序,因此每个动画片段都需要有自己的时间线(timeline),不同关键帧的播放时机是这条线上的坐标。
我们选中Standard Asset中的一个动画fbx文件,在Animation栏下的Clips窗内选择一段片段,下面的数轴就是它的时间线。点击动画预览的播放按钮,两个白色游标同步前进,显示当前播放的姿势在时间线上的位置。
可以调整动画片段在时间线上的开始和结束帧,以改变其覆盖的长度。或者截取不同范围内的片段,将整段动画分割为一些独立的片段,在这个例子中,除了人物跳跃的完整片段,还分割出了下落、跳起和空中的短片段。
在游戏中,动画片段从start帧播放到end帧后就结束了,除非勾选下面的Loop Time选项,在达到结束帧后又会从头开始播放,例如行走、奔跑动画一般会设置为循环。也可以设置Cycle Offset使片段不从0位置开始播放,但只会在第一次循环时起作用,要使每一次循环都改变起始帧应调整Start项。
在游戏引擎中,每个角色可能会播放多个动画片段,为此需要有一个全局时间线。当角色播放动画片段时,实际上是把它加入了全局的时间线里。当我们调整动画片段的播放速度时,改变的是片段的时间线到全局时间线的映射关系。
2.1.1. 动画曲线
我们有时需要根据动画片段的播放进度来改变一些值,举个例子:从起跳到落地过程中,角色对脚下平面的压力会发生变化,如果是在一片漂浮于水面的平板上,浮板受到的压力变化而产生非常真实的上下浮动效果。
unity中的动画曲线(Animation Curve)就能实现这样的需求,它是一条二次曲线,其x轴的范围是动画片段的范围,因变量y的值随当前帧而改变,用来表示某个数值在播放动画片段过程中的变化方式。*我们在此只探讨导入的动画片段中的curve,unity自带的动画编辑器中的curve功能有所不同。*在fbx文件的Animation页面内,展开Curves项并新建一条曲线。这条曲线也包含了关键帧,可以双击曲线图后手动添加关键帧,并靠关键帧调节曲线的形状;或者调整动画预览窗的游标,点击按钮新建关键帧。unity提供了一些常用的曲线模板。
在Curves下的输入框中,填入的是这个随动画播放而改变的数值的名字,下面的进退按钮能在关键帧之间移动。
在设置了动画曲线后,我们可以通过代码获取曲线中的y值,有两种方法。
- 通过动画控制器Animator Controller调用,但这个值的名字需要与控制器中的对应参数名一致,关于控制器在第四部分会详细说说。代码非常简单:
public Animator m_Animator;
public float m_Param;
...
param = m_Animator.GetFloat("ParamName");
- 得到这个曲线的引用,直接获取在某一时刻曲线上y的值。代码如下:
public AnimationCurve m_Curve;
public float m_Time;
...
param = m_Curve.Evaluate(m_Time);
2.1.2. 动画事件
使用动画曲线,我们能根据动画片段的播放进度调整一些值,值的变化是连续的。动画事件(Animation Event)则提供了离散的处理方式:当播放到标记的某些帧时,触发对应的事件,即执行某个脚本的函数。
例如在动作游戏中,角色的武器是否击中某物体,主要靠两者的碰撞检测。但当角色没有攻击时,我们可能希望武器的碰撞体不生效,以防止误判。简单的解决方法可以是在角色攻击的动画片段上添加动画事件:挥出武器的那一帧,将碰撞体激活,不希望再判定时再使碰撞体不生效。
我们从mixamo网站导入一段挥拳动画。选中fbx文件,在Animation栏中选择该片段后,展开下面的Events项。注意到其中也有一段时间线,只不过时间是0:00-1:00,对应该片段的开始和结束帧。在预览窗口中播放,在需要使碰撞体生效的位置暂停,然后在Events中点击按钮生成一个标签,即该时间点对应的动画事件。
其中Function栏填写需要执行的函数名称,Float、Int、String、Object中填写传递的参数。
如果游戏中的某角色有Animator组件,且Animator里有该动画片段,当角色播放这一动画时,就会在相应帧执行动画事件Function栏中的函数。函数的执行者是该角色绑定的所有C#脚本,只要该脚本中有名称一致的函数。如下图所示,Animator和脚本需要绑定在同一物体下。
我们在角色身上绑定了两个脚本,EmptyScript为默认空脚本,在HumanController中添加如下代码:
private void ColliderOn(Object pObject)
{
Debug.Log(pObject.name);
}
将动画片段设置为循环播放,运行游戏,控制台中不断输出"mat_Test"字段,即我们在动画片段中传递的Object参数的名字,执行的很顺利。那如果我们再添加一个重载的函数呢?或者在EmptyScript中也添加一个ColliderOn函数呢?我们分别在HumanController和EmptyScript中添加两段代码:
private void ColliderOn(float pFLoat)
{
Debug.Log(pFLoat);
}
private void ColliderOn(int pInt)
{
Debug.Log(pInt);
}
运行游戏,输出如gif图所示。可见unity只执行了HumanController中的第一个ColliderOn函数,以及EmptyScript中的该函数。
我们大致归纳一下:
- 动画片段播放到相应位置时,会调用角色所有脚本中的与Function栏里函数同名的函数,这些脚本需要和Animator在同一角色身上。
- 如果脚本有重载的该函数,unity只会执行第一个重载函数。
- 该函数只能有0或1个形参,且形参只能是float、int、string、enum、Object或者AnimationEvent类型,否则会报错。
- 如果使用一个AnimationEvent类型的形参,可以获取到动画事件中的全部4种参数。代码如下:
private void ColliderOn(AnimationEvent pEvent)
{
Debug.Log(pEvent.intParameter);
Debug.Log(pEvent.floatParameter);
Debug.Log(pEvent.stringParameter);
Debug.Log(pEvent.objectReferenceParameter.name);
}
当然,也可以通过代码动态地为动画片段添加事件,具体的可以查阅官方API或者网上的例子,在此就不赘述了。
2.2. Root Motion
艺术家在制作骨骼动画时,常见的有两种形式:
- 角色在原地运动,例如某些人物奔跑、走路的动画。在这些动画中,角色好像在跑步机上运动一般,在三维空间中的整体位置和旋转基本不会发生变化。
- 角色产生整体位移和旋转。这种动画比较符合现实生活中的运动,例如下图是mixamo中人物挥舞双手巨剑的动画,当完成一套攻击动作后,人物的位置也改变了不少,人物的朝向则在运动中时刻变化。
主流的游戏引擎,如unity和虚幻等,一般会为某件事情提供多种解决方案,开发者可以根据具体的需求而选择合适的方案。在播放角色动画时,角色可能同时会位移或旋转。我们有时希望将动画控制和角色Transform的改变分离开来,例如在角色奔跑时,只让Animator播放原地的跑步动画,并通过代码控制其Transform来产生移动的效果;但有时又希望把控制权交给动画,例如上面大剑攻击的动画中,人物本身的位移和旋转较为复杂,通过代码控制难以保证效果。
在unity中,这样的抉择主要围绕Root Motion展开。Root Motion可翻译为“根运动”,指在播放骨骼动画时,这个角色的身体,或者说Body Transfrom,跟随骨骼动画中的Root Transform而改变。
Root Transform是在xz平面上的一个箭头,当动画片段中骨骼运动时,箭头的根跟随人物而移动(因为向量没有位置,所以用箭头形容),方向则与人物朝向在xz平面的分量始终一致,如下图中的红色箭头。在我们为模型指定Avatar时,这个箭头就由unity生成了,在T-pose下,默认在人物双脚中间,指向人物正前方。
我们也可以手动调整这个箭头的位置,在Animation页面的Motion栏下,可以从下拉菜单中选择Root Motion Node,指定箭头的位置为骨骼结构中的某个节点。下图中我们指定了Hips为Motion的节点。
在unity中,我们可以选择是否开启Root Motion功能。一种方法是在角色的Animator组件中,勾选Apply Root Motion选项;另一种则是通过代码动态控制,例如在角色悬空时关闭Root Motion,使其能正常受到重力影响而下落。示例代码如下:
if(m_IsGrounded){
m_Animator.applyRootMotion = true;
}else{
m_Animator.applyRootMotion = false;
}
2.3. Bake
我们在上一小节提到了Transform在动画中的两种控制方式:完全由代码掌控和交给动画操纵。但unity中还有折中的方式,在fbx文件的Animation页面,分别有3个Bake into Pose的选项。Bake,即烘焙,是渲染中常用到的概念,例如把光照渲染为贴图加到物体上。
在unity的骨骼动画中,烘焙的则是骨骼动画中的Transform信息,右边的绿灯或红灯显示该动画片段首尾的旋转或位置是否一致。以Root Transform Rotation下的Bake in Pose为例,如果勾选了Bake选项,即使关闭Root Motion,人物身体也能随动画而正确地旋转,但Transform组件不会变化。关于这部分的理解,建议看下面这篇博文,十分有用。
笔者在此就只放一些图片的对比,大家可以结合参考文章来看,体会此功能的作用。
另外是一组跳跃动画,注意人物身体的移动和Transfrom组件:
3. 动画混合
在游戏中,角色的动画常会受玩家输入而实时改变,例如在奔跑时突然攻击,需要播放不同的动画片段。如果这些动画片段之间只是简单地切换,由于切换前后骨骼的姿势不能保证一致,可能会显得很生硬和突兀,例如跳帧(pop)现象,因此需要一种更顺畅的过渡。
又或者我们已经有了角色正常行走的动画,以及角色身受重伤的行走动画,能否免去制作其他负伤程度的动画,而由这两个极端情况程序化地生成中间的动画呢?
这两种需求的核心其实是一样的,都是要在不同的骨骼姿势之间插值(interpolate)。动画混合(Animation Blending)就能实现这样的需求,它的本质就是结合多个骨骼姿势产生混合的姿势。
在三、1小节中,我们提到单个骨头的姿势可以分解为缩放、旋转和位移三部分,而骨骼整体的姿势由所有骨头组合而成。使用动画混合时,需要对SQT分别进行插值,例如缩放和位移使用线性插值,而旋转的四元数使用球型线性插值。
在unity中,动画混合主要体现在两部分,动画过渡和混合树。
3.1. 动画过渡
动画过渡就是一个动画片段切换到另一个片段的过程,就像电影中的转场效果。除非切换前后两个片段的骨骼姿势完全一致,否则使用过渡能使这种切换更顺畅自然。
在unity的动画状态机中,不同状态间的转移使用到了动画过渡,状态转移的具体内容会在后面提到。我们选中一个转移,在inspector中显示了一个过渡的图表。图表告诉了我们一些信息:转移中由Swiping片段过渡到Walk片段,以及过渡的起始位置和时长。
还有一处关键信息就是图表中的白色曲线,很明显每个片段都有一条独立的曲线。unity官方文档中并没有提到曲线的作用,但不难推测它应该反映了两个片段之间的相似或者说同步程度。
假设我们只关注某根骨头的Transform.y值,那在进行动画片段的过渡时,两者的同步程度很容易度量——用两条曲线分别表示两个片段中该骨头的Transform.y随时间线的变化即可。如果不进行其他处理,我们希望从片段A切换到片段B时,两条曲线中此时y的值能够相同,即构成一条c0连续的曲线,甚至更高阶的连续。
由于引擎并不开源,我们只能猜测unity中的这种曲线可能反映的是动画片段中,整体骨骼姿势的某个度量值随时间的变化情况,在上面的转移图表中,可见两个片段此时同步程度并不高,两条曲线连c0连续都没有达到。如果希望效果更好,我们可以调整转移设置来使两者更同步,如下图所示,近乎达到了c1级别的连续。
当然,有时我们希望最大程度保留动画片段,上面为了提高连续性舍弃了Walk动画中较长的一部分。但即使不进行这样的手动同步,只要保证合适的过渡时间,片段间的过渡也能较为自然,这主要靠动画混合的作用。
在过渡过程中,引擎播放的既不是Swiping片段,也不是Walk片段,而是根据两个片段混合而成的过渡片段,过渡片段的骨骼姿势是两者之间插值的结果。假设使用的是线性插值即Lerp,并设骨骼姿势为G,则在过渡过程其值为:
其中α表示过渡的进度,G是随时间变化的函数。α为1时完成过渡并切换到下一个片段。
3.2. 混合树
我们提到过动画混合的两大作用:平滑过渡和产生新动画。混合树(Blend Tree)用于实现后者,依靠它我们能混合行走和奔跑动画,从而产生中间速度的其他动画,或是由正向的奔跑产生身体左倾和右倾的动画。
在动画过渡中,过渡前后的两个动画差异可能非常大,但靠动画混合两者能自然地转换。而在混合树中,树中包含的动作应该确保相似,如攻击动画不应该加入含有行走和奔跑的混合树中。
我们以Standard Asset中的Third Person Animator Controller为例,如下图所示。
可见这颗混合树上有4种状态,每个状态有一段做好的动画片段,分别是人物蹲下的闲置动画、蹲下并前进、蹲下并向右行以及蹲下并向左走。依靠动画混合技术,能通过这些已有的动画产生新的动画,比如向左前方蹲伏潜行。如图所示,红点代表的位置,其动画由圈起来的三个动画片段混合产生。
与前面动画过渡部分使用的一维线性插值不同,在这颗混合树中动画受两个参数控制而进行二维插值,分别是身体偏转程度Turn和向前的速度Forward。红点受到三个片段的影响,但影响程度受与片段的相近程度而不同,因此需要为每个片段分配一个权值,混合出的动画是它们的加权平均:
其中α可为该点在三个片段构成的三角形上的重心坐标,满足
当然混合树也可以使用一维插值,只要选择1D类型即可,此时只有一位参数控制。
四、运动控制——状态机与控制器
我们在前面已经实现了从导入模型到播放角色动画的过程,为接下来能真正掌控操纵角色提供了基础。在unity中,对于角色运动的控制,主要靠状态机和控制器来实现。
1.动画状态机
在Asset下,新建一个Animator Controller,即动画控制器。再选中场景中的角色,为其添加Animator组件,并将刚才的控制器拖到Controller一栏。
双击该控制器,在Animator窗口中显示了三个节点,分别是Entry、Any State和Exit,这些节点称之为状态(state)。
状态机(state machine)则是包含这些状态和它们之间相互关系的一种结构。所谓相互关系,即状态之间可以相互转移,我们在Animator窗口中右键新建一个空状态,Entry状态自动连接到了该状态,表示了两者的单向转移。
1.1. 状态
动画状态(Animation State)是动画状态机中的基本单元。除了默认的Entry、Any State、Exit状态外,每个状态应包含一段Motion(运动),即动画片段,或者状态本身是一棵Blend Tree。因为状态中包含了动画片段和一些播放设置,在状态机切换到该状态后,角色就会进行相应的运动。
点击新建的状态,在inspector中显示了该状态的信息。其中Motion栏是该状态的动画片段,Speed设置了片段的播放速度。注意在Speed下方还有一个Multiplier(乘数),如果勾选右方的Parameter,就能在默认速度的基础上,再乘上控制器中某参数的值,以动态改变片段的播放速度。
Motion Time代表动画片段播放的位置,在默认不勾选的情况下,进入该状态后,动画片段会按照Speed x Multiplier的速度从头到尾进行播放,如果片段设置了Loop Time的话还会循环播放。同样的,也可以勾选后面的Parameter,使片段的播放受参数控制,这个参数应该是从0到1之间归一化的浮点值,表示现在处在该片段的哪一位置。
我们可以这样理解,在进入一个状态后,开始播放动画片段,有一个游标在片段的时间线上移动。在不勾选Parameter的情况下,游标从头到尾行进,还可能循环。但有时我们希望自己选择怎么播放,比如在小电影中直接定位到关键情节开始,或者在某个精彩画面暂停,此时游标就完全由参数控制,参数则可以通过代码等动态地改变。举一个论坛中应用的例子,楼主制作了一段人物举枪瞄准的动画,在该片段的时间线上,瞄准的位置从正下方均匀改变到正上方,因此可以使用Motion Time的参数来控制动画播放的位置,使得人物瞄准的朝向精准地匹配。
Mirror并不代表动画倒放,而是角色身体左右进行镜像翻转,如本来是左手挥拳镜像后变成右手,因此这也只适用于人形的动画,在动画片段自身的inpector中也能设置Mirror。
Cycle Offset则是片段播放的起始位置,但对于循环动画来说,它只会在第一次循环时起作用。Mirror可通过bool参数控制,Cycle Offset可通过float改变,关于控制器中的Parameter将在后面展开。
Foot IK控制人形动画足部是否启用脚部IK,反向运动学(IK)比较复杂,本文无法对此展开。Write Defaults代表退出状态后重置一些参数,具体请看下面的文章。
Transition列表中自动列出了从该状态出发的所有转移,具体请看1.2节。
另外再提一下几个特殊的状态。黄色的为默认状态,可以在状态上右键设置为默认。Entry和Exit状态都是空节点,只表示状态机的开始和结束,不包含动画片段。Any State代表了所有的状态,如果从Any State连一根箭头到某个状态,即表示不管状态机现在的状态如何,都可以转换到这个状态,例如角色在任何时候都可能被攻击而播放BeHit动画。注意Any State只出不进,因为如果允许一个状态转移到Any State,引擎并不能知道转移到了具体的哪个状态。
1.2. 状态转移
状态是动画状态机中的基本节点,而状态转移(transition)则表示了状态之间的相互关系,就像有向图中连接节点的边。点击状态机中的一条连线,在inspector中显示了该转移的信息。
首先在inspector上部,有两个勾选框分别是Solo和Mute,Solo表示只播放该转移,Mute表示禁用该转移。
例如下图中,从Idle状态出发的三个转移都没有限制条件,默认播放完Idle状态后,会执行有最高优先级的转移,即此状态的Transitions列表的第一项。但其中一个勾选了Solo,即带绿色三角的,带红色三角的勾选了Mute。由图可见,引擎选择了带Solo的状态转移。
在调整状态机时,可以把希望生效的转移全部设置为Solo,而把需要禁用的转移设置为Mute,以方便预览效果。
Conditions栏中包含了该转移的条件列表,可以通过加减号增删条件。每个条件有控制器中的一个Parameter,以及一个随后的判定条件,只有当列表中的所有condition都满足——即每一个表达式都为true、且所有trigger触发时,这个状态转移才会执行。如果Conditions置空,状态机会按既定的顺序转移状态。
剩下的部分则是转移设置和动画的预览窗口。在转移设置中,第一个重要的属性是Has Exit Time。在勾选了这一项后,需要进行状态转移时,转移前的状态会先播放到Exit Time项对应的位置,再开始转移过程。
Exit Time也是一个归一化的值,表示结束位置在动画片段中的百分比。如果转移前状态的动画片段为Loop,则可以把Exit Time设置为大于1的值,以实现重复播放多次的效果,如果不是循环动画且Exit Time大于1,则角色会静止一段时间后进行转移。可以通过在时间线上移动游标,或者直接输入Exit Time的值。
如果不勾选Has Exit Time,此时又有两种情况:
- Conditions列表为空,即该转移不需要条件。我们也许会误以为此时会从上个状态直接转移,但实际是转移并不会发生,而是会一直停留在前一个状态。这是因为引擎并不知道该在什么时候退出前状态,因此转移是无效的,unity也会发出相应提示。
- 转移有至少一个条件。当转移的条件全部满足时,状态转移会立刻执行,而不管前一个状态播放进度如何,效果就像是打断了施法后摇而直接放了下一个技能。
接下来是转移的一些其他设置。Fixed Duration和Transition Duration(s)决定了转移中动画过渡的时间长度,在勾选了Fixed Duration的情况下,转移时长就是Transition Duration中的实际长度,单位是秒;如果不勾选,则Transition Duration是归一化的值,表示是转移前状态的动画长度的多少倍,如0.5表示过渡了2.4s x 0.5 = 1.2s。
Transition Offset改变的是转移后状态中,动画片段开始播放的位置,为0时默认从起始帧开始播放。这些值都可以在时间线中通过拖动的方式直接调整。关于过渡的方式,在动画混合部分我们已经探究过。
Interruption Source和Ordered Interruption控制了转移能否被打断以及如何会被打断。具体请看下面的原文或知乎老哥翻译后的版本,笔者并不觉得自己能写的更好了。
原文:Wait, I’ve changed my mind! State Machine Transition interruptions
译文:Animator- Interruption Source用法
1.3. 混合树
我们在动画混合部分已经提到了混合树的原理,这里主要说说相关操作。在Animator窗口中,右键点击新建一棵混合树,由inspector可见它也是状态的一种。双击混合树节点,进入这棵树的状态机页面。
在inspector中,可以选择混合树的类型,上图我们选择了1D,即一维类型。Parameter则是控制器中的某个浮点参数,通过改变该参数生成不同的混合动画。
在Motion列表中通过加减号增删状态,在每一栏状态中,需要选择其动画片段,或是一棵新混合树。Threshold译为“阈值”,在此表示该状态的权值为100%的参数值,1D的混合树明显使用的是线性插值。如果勾选了Automate Thresholds,则unity会自动用Threshold把参数值从0到1均分,如4个状态时,阈值分别为0、1/3、2/3和1(实际为浮点数形式)。
时钟图标那一栏表示状态的动画播放速度。如果在Adjust Time Scale中选择了Homogeneous Speed,会调整各状态的值,以使它们之间的相对速度一致。后面的镜像图标则决定人物动画是否要左右镜像。
其他几种混合树类型都大同小异,关键在于选择恰当的参数和动画片段。混合树在3D游戏中应用很常见,例如使用二维树实现多朝向不同速度的人物移动,使用横向偏转Turn和纵向速度Forward两个正交的参数控制。
2. 控制器
最后一部分我们讲讲对于Animator的外部控制,主要分为Layers和Parameters。
2.1. 动画层
在Animator窗口的左边栏,有一个叫做Layers的tab,在这里可以为动画状态机分层。默认情况下只有Base Layer一层,可以自行增删层级。在每个层级中,角色都有一个独立的状态机。
点击层级右侧的齿轮按钮,出现设置窗口。
自上而下来看,Weight表示该层级的影响权重,Base Layer的权重为1且只读。当存在多个动画层时,角色所处的状态是在每个层级中当前状态的混合,根据权重大小而影响程度不同。建议通过代码动态控制:
int newLayerIndex = m_Animator.GetLayerIndex("Upper Layer");
m_Animator.SetLayerWeight(newLayerIndex, 0.5f);
Mask中可以选择一个Avatar Mask,从而定义此层级中动画会影响角色的哪些身体部分,详见前面的部分。
Blending中选择该层级在混合中的类型,默认为Override,即覆盖其他层,而Additive会将该层添加到上一层之上。区别如下两图所示。
点击Sync按钮,当前层级的状态机变为和Source Layer一样的结构,其中状态的名字和状态转移都完全一致,且在当前层级和Source Layer中修改状态机时,另一方都会同步修改。
Sync提供了状态机结构的复用,但不同层级的区别在于状态中的动画片段不同,而且Blend Tree也是自定义的,在Sync时这两部分都是置空的。例如在角色受伤时,其状态机结构可以和正常状态下一样,于是添加一个动画层并选择Sync,之后选择受伤状态的各个动画片段。
在每个层级的inspector面板中, 还可以点击Add Behaviour来添加脚本,该脚本继承自StateMachineBehaviour,提供了一些接口,可在 状态机运行时打印一些信息。例如想知道何时进入了跳跃状态:
public class NewLayerBehaviour : StateMachineBehaviour
{
// OnStateEnter is called before OnStateEnter is called on any state inside this state machine
override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
if (stateInfo.IsName("Jump"))
{
Debug.Log("Jumping!");
}
}
}
2.2. 参数
我们在前面多次提到了Animator中的参数(Parameter),可见其作用十分重要。
在Animator窗口的左边栏的Parameters中显示了状态机中的所有参数。参数一共有4种类型:float、int、bool和trigger。
参数的作用主要体现在两部分:
- 在状态转移中,参数的值可决定转移条件是否成立。
- 在混合树中,参数影响了动画混合的权重。
参数的动态控制方式也主要分两种:
- 使用动画曲线改变,在状态中的某段动画片段中添加动画曲线,将因变量设置为Animator中对应浮点值的名称,当运行游戏后,我们发现该参数置为了灰色,表示无法手动更改其值。转移到该片段所在状态后,参数值才发生了变化。
- 使用代码直接控制参数,这是最直接的方法。示例如下:
m_Animator.SetInteger("Int", 2);
m_Animator.SetFloat("Float", 0.6f);
m_Animator.SetBool(2, true);
m_Animator.SetTrigger("Trigger");
函数中的第一个实参既可以是参数的名字,也可以是参数的序号。
总结
以上就是笔者对于unity中角色动画这一部分的一些记录,没想到写这篇水文断断续续花了几周时间,在这之中也对unity有了更多的了解和认识。不禁感叹游戏引擎包含的内容实在太深太广,目前对于很多方面还只是略微了解,因此要多实践、对于不懂的地方深入探究、多翻阅官方文档和逛技术论坛,以提升自己的见解和技术。感谢您的观看!