GAMES104课程笔记08-Basics of Animation Technology
这个系列是GAMES104-现代游戏引擎:从入门到实践(GAMES 104: Modern Game Engine-Theory and Practice)的同步课程笔记。本课程会介绍现代游戏引擎所涉及的系统架构、技术点以及引擎系统相关的知识。本节课主要介绍现代游戏引擎中的动画系统。
Introduction
动画系统是游戏引擎的重要组成部分。从远古时代开始,我们的祖先就试图用各种手段让静止的画面动起来。

直到近代,人们发现利用人眼的「视觉残留」效应可以将静止的画面产生运动的效果。这一发现构成了所有显示设备和电影动画的理论基础。

Animation Techniques in Film
目前游戏行业所使用的动画技术理论和经验基本都源自于电影行业对动画技术的探索。从早期的2D动画到如今大规模应用的三维动画,整个电影动画工业取得了令人瞩目的进步。

早期的动画主要是由艺术家逐帧手绘来实现,而在今天基于计算机的动画模拟技术已经成为了电影工业的主流。

Animation Techniques in Game
回到游戏领域,早期的游戏动画都是制作者根据真实图片进行绘制来实现的,这与早期的二维动画异曲同工。

后来随着GPU和3D游戏引擎的出现,人们开始使用计算机来直接生成游戏场景中的动画。而在今天的3A游戏大作中,结合真人动捕以及物理仿真的计算机动画已经可以以假乱真的效果。

Challenges in Game Animation
和电影行业中的计算机动画相比,游戏中的动画难点之一在于游戏是一个和玩家不断互动的过程。游戏开发者很难预设玩家的行为,而且必须考虑玩家的行为与场景进行互动的结果。

同时,游戏对于实时性有着更高的需求。在很多情况下我们甚至需要考虑游戏场景中有着上万个单位同时进行运动的情况,这样大规模的计算给游戏动画系统的性能造成了巨大的挑战。

除此之外,玩家对于现代游戏角色的动画也提出了更高的要求。我们希望游戏角色有着更加生动的表情,同时在运动过程中的行为尽可能自然。

本课程中我们将游戏引擎的动画系统分成两部分。本节课会介绍一些基础的动画知识,而在下一节中则会讨论一些业界更加前沿的技术。

2D Animation Techniques in Games
Sprite animation
首先我们来看游戏中二维动画的实现。二维动画是最早的游戏动画形式,直到今天仍然有很多游戏使用二维动画来进行表现。最简单的二维动画称为sprite animation,它是将游戏角色的行为逐帧绘制并在游戏中进行循环播放。

为了实现更加生动的表现效果,还可以在不同的视角下对同一动作进行绘制并且在实际游戏中根据需要选择合适的动作帧进行播放。这样就可以利用2D动画实现伪3D的效果。

在现代游戏中sprite animation仍然占有一席之地,很多游戏的特效就是通过预先渲染出的特效帧来实现的。

Live2D
目前虚拟主播中大范围应用的Live2D技术同样来自于2D动画,通过对一系列图像进行变形就可以实现非常生动的表现效果。

Live2D技术的核心是把角色的各个部位分解成不同的元素,通过对每个元素进行变形来实现虚拟人物的不同动作。

在放置不同元素时还可以通过对图层顺序的变化进一步提升表现力。

对于每一个图元则需要事先设置好它的网格来控制形变,这样角色的不同动作就可以通过对网格控制点的运动来进行描述。

最后根据关键帧把角色不同元素不同控制点的运动组织起来就完成了一个虚拟角色的动作。

3D Animation Techniques in Games
Degrees of Freedom
3D动画所需的技术要比2D动画复杂一些,在介绍具体的动画技术前我们需要先复习一下相关的数学知识。三维空间中物体的运动有自由度(degrees of freedom, DoF)的概念,对于刚体而言描述它的运动需要3个平动和3个旋转一共6个自由度。


Rigid Hierarchical Animation
最简单的3D动画技术是把角色的不同部位都视为刚体,然后按照一定的层次把它们组织起来。早期的3D游戏就是使用这样的方法来实现三维角色的不同行为。

Per-vertex Animation
另一种实现三维动画的方法是利用网格的顶点来控制运动,这种技术称为顶点动画(per-vertex animation)。此时网格上的每个顶点有具有3个平移自由度,通过对网格顶点坐标的变换就可以实现模型的运动。这种动画方法在人物角色上的应用比较少,但在物理仿真中则相对比较常见。

Morph Target Animation
类似于顶点动画,morph target animation同样是利用顶点来控制模型的运动。和顶点动画不同的是,morph target animation不会直接操作网格顶点的坐标而是通过顶点的位置和权重来控制整个网格的行为。morph target animation在表现角色面部表情上有很广泛的应用。

Skinned Animation
本节课的核心是介绍蒙皮动画(skinned animation)的相关技术。蒙皮动画是目前游戏行业最主流的三维动画技术,它通过控制角色内部骨骼的运动来实现整个角色的运动。和刚体动画相比,蒙皮动画可以实现更加真实和自然的运动效果。

蒙皮动画同样可以应用在二维动画上。基于2D蒙皮动画的运动会比刚体动画有更加自然的表现效果。

Physics-based Animation
在蒙皮动画之外另一大类动画形式是物理动画(physics-based animation)。物理动画是完全基于物理法则的动画模拟方法,和蒙皮动画相比需要更加深入的数学物理知识进行描述。本课程中不会过多地介绍物理动画的相关内容。

Animation Content Creation
如何获得动画呢?早期的解决方法是由动画师在软件中通过关键帧来对角色的动作进行建模,而目前越来越多的游戏和电影则是通过真人动捕来获得更加自然的运动动画。

Skinned Animation Implementation
接下来我们介绍蒙皮动画的实现细节。从整体上来看,蒙皮动画的实现包括以下5个步骤:
- 建立网格模型;
- 建立网格模型附着的骨骼;
- 为网格上每个顶点赋予骨骼对应的权重;
- 利用骨骼完成角色的运动;
- 结合顶点的骨骼权重实现网格的运动。
上述步骤看上去不是很难,但在实际编程中需要多加小心防止出现网格爆炸的问题。

Different Spaces
要描述骨骼的运动我们还需要引入相应的坐标系统。首先整个游戏世界定义了一个世界坐标系(world space),所有的物体都位于这个坐标系中;对于每个单独的模型,模型自身还定义了一个模型坐标系(model space);最后每个骨骼还定义了一个局部坐标系(local space)来描述网格顶点和骨骼的相对位置关系。任意两个坐标系之间的变换关系可以通过3个平移和3个旋转一共6个自由度来表示,这样每个顶点的坐标都可以从局部坐标系变换到模型坐标系再变换到世界坐标系上。

Skeleton
在此基础上就可以结合角色自身的特点构建出具有一定拓扑关系的骨骼模型(skeleton),这一般可以通过一棵树来表示。对于类人型的骨骼,整棵树的根节点一般位于胯部。而对于四足动物等其它类型的骨骼其根部则会位于其它位置。

Joint
我们定义骨骼与骨骼之间相连接的部位为一个关节(joint)。实际上我们不会直接按照骨骼进行编程,而是利用关节及他们直接的连接关系来表达整个骨骼的运动。

在游戏建模中除了常见的四肢外可能还会根据角色的服装和特点来构建更加复杂的骨骼模型。比如说玩家手中的武器就是通过在角色手上绑定一个新的骨骼来实现的。


除此之外,在进行建模时我们往往还会定义一个root关节。不同于前面介绍过的胯部骨骼,root关节一般会定义在角色的两脚之间,这样方便把角色固定的地面上。类似地,对于坐骑的骨骼也往往会单独把root关节定义在接近地面的位置。


很多游戏动画需要将不同的骨骼绑定到一起。最直观的例子就是角色骑马的动画,此时角色和马都有自身独立的动画而我们需要将它们组合到一起完成角色骑马的动作。要实现这种功能需要设计一个单独的mount关节,然后通过这个关节将两个模型拼接到一起。需要注意的是在拼接时不仅要考虑关节坐标的一致性,更要保证两个模型的mount关节上有一致的朝向,这样才能实现模型正确的结合。

Bind Pose
早期的骨骼建模会使用T-pose作为角色动作的基准。但在实践过程中发现T-pose会导致角色的肩部出现挤压的状况,因此现代3A游戏中更多地会使用A-pose这种姿势进行建模

Skeleton Pose
完成骨骼建模后,角色的运动就可以通过骨骼的姿态(pose)来进行描述。这里需要注意的是表达角色的不同动作时每个关节实际上具有9个自由度,除了刚体变换的6个自由度外还需要考虑3个放缩变换引入的自由度。这3个放缩自由度对于表现一些大变形的动作起着很重要的作用。

Math of 3D Rotation
2D Orientation Math
在这一节中我们会详细介绍三维空间中如何表示物体旋转这一问题,不过首先我们来回顾一下二维空间的旋转。对于二维空间中的点\((x, y)\),当它绕原点进行旋转时只需要一个旋转角度\(\theta\)就可以进行描述,旋转的过程可以通过一个旋转矩阵\(R(\theta)\)来进行表示。

3D Orientation Math
Euler Angle
三维空间中的旋转要更复杂一些。我们可以把任意三维空间的旋转分解为绕三个轴的旋转,每个旋转都对应一个三维旋转矩阵,这样就可以通过绕三个轴的旋转角度来进行表达。这种描述三维旋转的方法称为欧拉角(Euler angle)。

欧拉角在很多领域都有大量的应用,比如说飞行器的导航和姿态描述一般都是基于欧拉角的。

但是需要说明的是欧拉角有很多局限性,比如说欧拉角是依赖于旋转顺序的。在使用欧拉角时必须指明绕三个旋转轴进行旋转的顺序,同样的欧拉角按照不同的顺序进行旋转会得到不同的结果。

欧拉角的另一个缺陷在于万向锁(gimbal lock):在有些情况下按照欧拉角进行旋转会出现退化的现象,导致物体的旋转会被锁死在某个方向上。


总结一下,欧拉角的主要缺陷如下:
- 万向锁及相应的自由度退化问题;
- 很难对欧拉角进行插值;
- 很难通过欧拉角对旋转进行叠加;
- 很难描述绕\(x, y, z\)轴之外其它轴的旋转。
由于这些缺陷的存在,游戏引擎中几乎不会直接使用欧拉角来表达物体的旋转。

Quaternion
在游戏引擎中更常用的旋转表达方式是四元数(quaternion),它由Hamilton爵士于19世纪提出。

我们知道二维空间中的旋转可以使用复数来进行表示。换句话说,二维平面上的旋转等价于复数乘法。

类似地,我们可以认为四元数是复数在三维空间的推广。一个四元数\(q\)具有1个实部和3个虚部\(i, j, k\),四元数的运算法则如下:

可以证明,任意的三维旋转可以通过一个单位四元数来表示。当我们需要对点\(\mathbf{v}\)进行旋转时,只需要先把\(\mathbf{v}\)转换成一个纯四元数,然后再按照四元数乘法进行变换,最后取出虚部作为旋转后的坐标即可:

进一步可以证明用欧拉角表达的旋转都对应着一个四元数的表示。同样地,四元数与旋转矩阵直接也存在着相应的转换关系。


使用四元数来表达三维旋转的优势在于我们可以使用简单的代数运算来获得旋转的逆运算、旋转的组合以及两个单位向量之间的相差的旋转量。

对于绕任意轴旋转的情况,我们同样可以利用旋转轴和旋转角度的信息来构造出四元数进行表达。

Joint Pose
Affine Matrix
有了三维旋转的表达方法后我们就可以利用关节的姿态来控制角色模型的运动。具体来说,我们每个关节的姿态可以分为平移、旋转和缩放三个部分,把它们组合到一起就可以通过一个仿射矩阵(affine matrix)来描述关节的姿态。






对于骨骼上的每一个关节,我们实际上只需要存储它相对于父节点的相对姿态。这样在计算绝对姿态时可以利用仿射矩阵的传递性从根节点出发进行累乘即可。

这种利用相对坐标系来描述位姿关系的好处在于它可以正确地对角色动作进行插值,而如果直接从绝对坐标系进行插值则会得到错误的结果。

Skinning Matrix
在前面我们介绍过模型的每个顶点是附着在骨骼上的,因此在关节姿态发生变化后顶点会跟着关节一起运动。

记顶点\(V\)在关节\(J\)定义的局部坐标系下的坐标为\(V_b^l\),初始时刻进行绑定时\(V\)在模型坐标系下的坐标为\(V_b^m\)。在\(t\)时刻,当关节位姿发生变化后顶点的局部坐标保持不变。此时顶点在模型坐标系下的坐标和局部坐标直接满足变换关系:
\[V^l(t) = V_b^l = (M_{b(J)}^m)^{-1} V_b^m\]其中\(M_{b(J)}^m\)即为初始时刻进行绑定时关节\(J\)对应的姿态。

利用\(t\)时刻关节的位姿\(M_J^m(t)\),可以得到顶点\(V\)模型坐标系下的坐标\(V^m(t)\)与初始时刻模型坐标系下绑定的坐标\(V_b^m\)之间的变换关系:
\[V^m(t) = M_J^m(t) V_J^l = M_J^m(t) \cdot (M_{b(J)}^m)^{-1} V^m_b = K_J V_b^m\]其中\(K_J = M_J^m(t) \cdot (M_{b(J)}^m)^{-1}\)称为关节\(J\)的蒙皮矩阵(skinning matrix)。

注意到蒙皮矩阵的第二项包含矩阵求逆运算,在游戏引擎中为了提高计算效率一般会直接存储整个逆阵。


对于同一个顶点绑定到多个骨骼的情况则需要通过插值进行处理。此时顶点\(V\)会同时存储它所绑定到的关节以及对应的权重,其在世界坐标系下的坐标为它在每个关节上定义的局部坐标转换到世界坐标后的加权和。


Interpolation
在动画制作过程中一般只会记录下一系列关键帧上骨骼的姿态,而要得到实际的动画还需要通过插值来获得中间帧上模型的运动。



线性插值是最基本的插值方法,我们可以通过对关节姿态的插值来计算中间帧上的模型运动。

对于三维旋转的插值要相对复杂一些,不过我们可以借助四元数的运算来进行处理。要获得插值后的旋转只需要对四元数直接进行线性插值,然后再进行归一化即可,这样的方法称为NLERP。

需要说明的是NLERP并不是真的对旋转进行线性插值。当动画的帧数较高时NLERP会有明显的违和感,这是由于它没有考虑旋转并不是线性空间。


想要真的对旋转进行线性插值可以使用SLERP这样的算法,不过SLERP的计算代价要比NLERP要大一些。


AnimationRuntime Pipeline
我们把上面介绍过的算法整理一下就得到了一个简单的蒙皮动画管线如下。现代3A游戏在此基础上还会更多地把计算配置到GPU上来充分计算资源。

Animation Compression
动画压缩(animation compression)是计算机动画中非常实用的技术。实际上直接存储每个模型上每个关节的姿态需要占用非常多的资源,因此利用一些压缩技术来减少动画存储空间有着非常重要的意义。


DoF Reduction
在广泛的实践中人们发现不同关节不同自由度的信号之间有着巨大的差异。以大腿关节为例,在大多数情况下它的缩放自由度都是1而且大部分的平动自由度都是0,它的运动基本都是来自于旋转;而对于手指这样的关节,它的旋转很少但是平动会相对多一些。


因此最简单的动画压缩方法是直接缩减运动的自由度,把关节的缩放和平动自由度直接去掉只保存旋转。

Keyframe
对于旋转自由度我们可以使用关键帧(keyframe)来对信号进行离散,然后通过插值来重建原始信号。

在离散时还可以利用不等间距采样的方式来进一步压缩信号。

直接使用线性插值来描述非等间距采样的旋转信号仍然不够自然,这里推荐使用Catmull-Rom曲线来对关键帧进行插值。Catmull-Rom曲线只需要一个锐度参数\(\alpha\)以及4个控制点就可以获得C1连续的光滑曲线。基于Catmull-Rom曲线可以实现非常高精度的信号离散和重建效果。


Quantization
进一步压缩数据时还可以考虑使用低精度的存储方式来记录位移信号。比如说可以通过规范化的方法将32位浮点数转换为16位无符号整数来表示,这样虽然损失了一些精度但却可以极大地减少存储空间。


对四元数进行压缩时可以利用单位四元数每一位上数值的范围来进行化简。具体来说我们可以首先使用2个bit来表示四元数的哪一位被丢掉了,剩下的3位可以分别使用15个bit来进行表达。这样一个四元数可以使用48个bit来进行存储,远小于使用4个float所需的128个bit。



Error Propagation
数据压缩必然会导致精度损失的问题。对于一些末端的关节,由于误差传播的效应可能会产生非常大的累计误差。这种现象的直观反映就是模型可能会产生视觉上可见的偏移。


要缓解这种累计误差首先需要定量化的描述误差。我们可以直接对比数据压缩前后模型每个顶点上的坐标差异,但这种做法的计算代价过于巨大,目前工业界的主流处理方法是在关节上设置虚拟顶点然后利用虚拟顶点压缩前后的差异来描述误差。



而要缓解累计误差我们可以为不同的关节设置不同的存储精度,或是通过主动补偿的方式来进行修正。总体而言,对于累计误差目前没有非常完善的处理方法。


Animation DCC Process
本节课最后介绍了动画制作的流程。一般来说蒙皮动画的制作包括建立网格模型、绑定骨骼、蒙皮、制作骨骼动画以及导出等步骤。

动画制作的第一步是建立网格模型。一般来说建模师设计的三维模型会具有远高于动画需求的精度,因此在动画制作阶段往往只会使用低精度的模型进行处理。

需要注意的一点是为了保证最终动画成品的效果,建模师在建模时一般会在关节处对网格进行加密。

接下来需要为模型制作骨骼,目前主流的三维建模软件都集成了骨骼的功能。在角色基本骨骼的基础上一般还会根据游戏玩法进一步添加一些额外的关节,包括武器、坐骑等。

然后我们需要把顶点绑定到骨骼上,这一般需要在软件自动计算的基础上结合建模师的经验进行手动校正。

在动画建模阶段则需要由动画师根据关键帧设置模型的动作。

最后游戏引擎需要提供相应的模型导出功能。目前工业标准是使用FBX格式来保存动画所需的全部几何运动数据。
