Joint Pose - OrientationJoint Pose - PositionJoint Pose - ScaleJoint Pose - Affine MatrixJoint Pose - Local Space to Model SpaceJoint Pose Interpolation - Local Space vs. Model SpaceSingle Joint SkinningSkinning MatrixSkinning Matrix PaletteSkinning Matrix 总结Representing a Skeleton in MemoryWeighted SkinningWeighted Skinning with Multi-jointsWeighted Skinning BlendClipInterpolationInterpolation between PosesSimple Interpolation of Translation and ScaleQuaternion Interpolation of RotationShortest Path Fixing of NLERPProblem of NLERPSLERP : Uniform Rotation InterpolationNLERP vs. SLERPSimple Animation Runtime Pipeline
Joint Pose - Orientation
Orientation,这个词其实跟 Rotation 也很像,但是 Orientation 更难表达,它指空间上的朝向。
每个 joint 在任何时候都有各种各样的朝向变化。
在大量的动画里面的大量的关节,实际上它的运动都是以旋转为主。如果我们只看它和它的父关节之间的关系,会发现它很多时候不会发生平移,也不会发生放缩,只有旋转。所以旋转的表达是动画的一个最核心的东西。
Joint Pose - Position
位置指的就是空间上的平移。
两个 Joint 的之间的位置一般不会变。比如父 Joint 定在一个位置的时候,子 Joint 的 position 一般不会变。父骨骼转了之后,子 Joint 相应的也是跟着转。但是在游戏引擎里面这个数值还是有用的。
比如人的 Pelvis 尾椎骨骼,当这个人蹲在那站起来的时候,Pelvis 的位置 position 就会发生变化。角色的站立和蹲下都是要靠这根 Pelvis 组合顶起来,所以这根骨骼就会有很多的平移的变化。
在人的表情动画里面也有很多平移变化。
在一些特殊的机械结构里面,比如说我们要做一个古代的弩箭,这个弩箭它会拉开松起。那这个骨骼实际上也有平移变化。
Joint Pose - Scale
放缩变化实际上一般时候都不会用,但是在有些场合用的还是蛮多的,比如说在面部表达的时候,要去给它做个大眼睛小眼睛、大鼻头高鼻头。而且沿着 X 轴、 Y 轴、 Z 轴的放缩比例可能都不一样。
可以参考 Unity Transform 文档的 Limitations with Non-Uniform Scaling 部分。
Joint Pose - Affine Matrix
可以把 Joint 关节分析成三个元素:旋转、平移、放缩。
任何一四元数,我可以把它展开成一个 3 乘 3 的旋转矩阵,放缩同理。平移也可以再转化成 3 乘 4 的矩阵(Affine Matrix 仿射矩阵)。
仿射矩阵和投影矩阵不太一样,投影矩阵最后算坐标还要做透视除法(除以 )。但是在骨骼动画里面的仿射矩阵是一个 3 乘 4 的矩阵,不需要最下面 0 那一行,因为它永远是正交投影。
Joint Pose - Local Space to Model Space
动画一般都会存储在 local space 里面,就是说只会去存相对于它的父亲的变化,为什么呢?因为我们会发现当一个角色在动的时候,很多骨骼其实是没有任何动画的,或者是它只有一点点的旋转。
对于每个骨骼,我们需要知道几个概念:
- 这个关节的父关节是谁?
- 父关节在 local space 里面长什么样?
- 这个关节它是在模型坐标系里面应该是什么样子的?
- 实际上是从它的根节点开始,一个骨骼一个骨骼算下来。
Joint Pose Interpolation - Local Space vs. Model Space
为什么我们要把这个动画数据全部存到局部坐标系呢?
如果动画数据全部存在模型坐标系,以摆动手小臂作为例子,如果每个关节点在模型坐标下插值,它插出来之后,骨头会一会儿变长、一会儿变大。因为如果模型坐标系下插值的话,你插出来的是一条直线(上图)。
我们应该对 Joint 的 rotation 进行插值,这样就会看到骨骼是在进行定长的旋转(上上图左边)。
Single Joint Skinning
在 Joint 发生平移、旋转的时候,我们希望的皮肤上的 mesh 也跟着动,这里数学是怎么映射的?
Skinning Matrix
上图定义了一些符号表达。
这里定义了一个恒等式,假设一个模型顶点 V 只和一个 Joint J 绑定,绑定在 Joint J 的模型顶点 V 在 local space 里相对 Joint 的位置是不能变的,无论我们做了什么姿势。上图右上角的图中,点(蓝色 V)和 J 的相对位置,再到 t 这一帧,点(黄色 V)和 J 的相对位置,两个在局部坐标系上投影是严格一致的,不然就不叫绑定了。
因此关节在动的时候,上面的表皮也会跟着动。上图的矩阵就是最核心的一个矩阵。
任何一个关节,在单位时间 t 时,在模型空间的位置,可以认为从根节点的关节的模型空间的矩阵一路的累乘上来。
也就是说,任何时候 V 在模型空间的位置,等于它在绑定 J 那个矩阵的逆,乘上 model space 中 Joint 在 T 时刻的仿射矩阵,乘上在绑定时候的点和 Joint 的相对位置关系。
简单来说,这个顶点绑定的时候有个位置,乘上绑定关节的仿射矩阵的逆,就能知道点和 Joint 的相对位置。当关节这个位置在模型空间发生了很多变化的时候,把这个相对位置再乘上变化,就可以得到在新的模型空间中的值,也就是新的坐标值。
大家经常在做这个东西的时候,经常会忘记把那个逆矩阵乘上去。所以做动画的时候,我们一般在每一根骨骼存上逆矩阵。
Skinning Matrix 就是动画矩阵。给模型上的任何一个点,比如说只要绑定 032 号骨骼,就要找到 032 号骨骼的 Skinning Matrix。绑定的时候,就把原始 mesh 里面的坐标的 乘上这个 Skinning Matrix 就得到这个骨架动过之后点的位置。
看任何一个引擎如何存骨架信息的实现的时候,会发现它除了存绑定时候的位置、放缩、四元数旋转,一般还会存这根关节在绑定的时候,在模型空间变换的逆矩阵。因为当我们动骨架的时候,我们会算出来这个 Joint 在新的动画 pose 下在模型空间的 transform,加上逆矩阵就能得到 Skinning Matrix。这样把老的 mesh 的位置乘上 Skinning Matrix 就是它新的位置。这个公式非常重要。
Skinning Matrix Palette
假设一个人有 70 根骨骼,对骨骼进行编号 0~69,那算出来所有的 Skinning Matrix 就形成了一个 Palette 表。为什么要算这个表?因为真实绘制的时候,一个角色身上的顶点可能是几万个,但是骨骼只有几十个。因此可以先把所有的骨骼的位置算好,算好之后把蒙皮的矩阵算好。这样当顶点用的时候,就可以重用这些蒙皮矩阵的值。
这个事情非常的重要,因为接下来就会讲蒙皮实际上是由很多的权重加在一起的。实际上每一个 Joint 的矩阵会被访问很多次,所以必须得预先算好。
这里面还有一个细节,就是我们有三个坐标系,前面讲的最多的是模型坐标系。但是模型本身在空间有位移,所以在游戏引擎里面,我们一般会把蒙皮矩阵的 Palette 乘上它在世界坐标系里的 transform,这个 transform 也就是从模型坐标系向世界坐标系的 transform。
所以真正看到的 Skinning Matrix 应该就是这样的一个形式,第一个是模型向世界的转换,第二个是 依赖的这根骨骼 joint 在模型坐标系里面的变化,第三个在绑定的时候,这个 joint 在模型坐标系里面的 transfrom 的逆。这三个乘到一起就构成了矩阵的 Palette。
Skinning Matrix 总结
第一个公式:
这个公式代表每一根关节从根节点用 local 的 transform 一路累积上来,这个 transform 包含旋转、平移、放缩,算出最后这个关节在单位时间 t 时在模型空间的位置。
第二个公式:
在模型空间的变形算出来之后,那对于所有绑定的点的新的位置怎么算?就要乘上绑定时候的矩阵的逆,再乘上当前关节在模型空间的位移 pose 矩阵(第一个公式的结果),就能得到 Skinning Matrix 这样的话就能算出点的新的位置。在真正渲染的时候,再加上从模型坐标系向世界坐标系的转换,那么就能把点的最终位置算出来了。
Representing a Skeleton in Memory
- The name of the joint, either as a string or a hashed 32-bit string id
- The index of the joint’s parent within the skeleton
- The inverse bind pose transform is the inverse of the product of the translation, rotation and scale
前面说过做引擎的时候,关节数据里面一般会存一个矩阵的逆,虽然这个矩阵是一个 Affine 矩阵,它的逆能相对快地求到,但是我们还是会提前算好。
Weighted Skinning
Weighted Skinning with Multi-joints
磨皮动画实际上只是在前面基础上,让我们的任何一个表面上的点能够和多个 Joint 同时起作用。
原理其实就是加权平均。Joint 数量可以不设上限,但是一般我们不会超过 4 个。所有跟这个点关联的骨骼的加权必须要等于 1。如果不等于 1 的话,就会出现很多很奇怪的变形。
有了 Weighted Skinning 之后,怎么算新的位置?
Weighted Skinning Blend
对 Joint 1、Joint 2 分别算出在模型空间的新的坐标,做加权平均。
顶点的插值一定要在模型空间做。
Clip
一序列的关节 pose 就是一个 clip,例如走路 clip、跳跃 clip。
Interpolation
Interpolation between Poses
- Animation’s timeline is continuous
- Calculate the pose between key poses
clips 一般存 15 帧或者 20 帧。但是在游戏里面,帧的密度其实很高。比如说现在游戏里一般是 120 帧 60 帧,动画资产不可能存 60 帧,存 120 帧。所以很多时候我们要在这两个 pose 之间去插值,插值让这个动画看起来更加的平滑。
Simple Interpolation of Translation and Scale
对于比较简单的,像是位移和放缩,直接线性插值就行了。
Quaternion Interpolation of Rotation
前面提到旋转用四元数表示,那么旋转的插值就能用 和 的线性插值,再加上 normalization,这种插值叫做 NLERP。
Shortest Path Fixing of NLERP
NLERP 有个细节,Rotation 的插值其实有一个最短路径的原则。从旋转 1 向旋转 2 要沿着球面最短的路径。
相当于要从北京到新疆,从北京直接飞到新疆是最短路径。但是从北京往美国飞,沿着太平洋大西洋再穿过亚欧大陆也能到新疆,不过这个路径就不符合人的直觉了。
因此我们要算 quaternion1 点乘 quaternion2 是大于 0 还是小于0。如果是大于 0,直接插值就好了。如果是小于 0,也就是大于 180 度的角,那就反向插值。
如果不去做最短路径插值的话,会发现在某些时候关节会突然做个翻转。加了这个最短路径的差值,它会看上去相对自然一点。
Problem of NLERP
即使我们有了四元数的插值,它的速度仍然不是均匀的,会发现它一开始就是快到中间慢,到后面又开始快。
如果要做对质量要求很高的动画,会发现这个东西看上去有点不自然。
因此现在 3A 游戏中,大家提出了 SLERP 的概念。
SLERP : Uniform Rotation Interpolation
SLERP 的原理就是:把两个旋转之间的夹角 用 找出来,再用夹角 一点点的插值,这样效果比较好。
NLERP vs. SLERP
SLERP 也有点问题:
- 这个插值比较费,因为有个反三角函数的运算,计算机要查表去算。
- 当夹角 很小的时候, 也很小,用来做分母的话,这个插值是不稳定的。
- 因此一般会给个 magic number,如果它很小,就用 NLERP。夹角比较大,就用 SLERP。
Simple Animation Runtime Pipeline
至此,动画最核心的数学和它的原理就讲完了。
一个简单的动画 runtime 的 pipeline 过程:
- 首先有大量的动画的 clips。 clips 就存在角色的各个 pose。
- 找到当前帧和它的下一帧。
- 用插值算法插出现在骨架应该在的 pose。
- 把这个 pose 计算转换到 model space 里面。
- 用数学方法算 Skinning Matrix Palette。就是说每一根骨骼表达成一个它的 Skinning Matrix。
- 1~5 一般都会在 CPU 算
- 在 vertex shader 里面对每一个顶点算位置。
- 这步一般会交给 GPU。
现在也来越多的游戏,特别是主流的 3A 游戏,会把前面部分运算也全放在 GPU 里面去计算了。