Joint Pose
🧿

Joint Pose

Joint Pose - Orientation

notion image
Orientation,这个词其实跟 Rotation 也很像,但是 Orientation 更难表达,它指空间上的朝向。
每个 joint 在任何时候都有各种各样的朝向变化。
💡
在大量的动画里面的大量的关节,实际上它的运动都是以旋转为主。如果我们只看它和它的父关节之间的关系,会发现它很多时候不会发生平移,也不会发生放缩,只有旋转。所以旋转的表达是动画的一个最核心的东西。

Joint Pose - Position

notion image
位置指的就是空间上的平移。
两个 Joint 的之间的位置一般不会变。比如父 Joint 定在一个位置的时候,子 Joint 的 position 一般不会变。父骨骼转了之后,子 Joint 相应的也是跟着转。但是在游戏引擎里面这个数值还是有用的。
notion image
比如人的 Pelvis 尾椎骨骼,当这个人蹲在那站起来的时候,Pelvis 的位置 position 就会发生变化。角色的站立和蹲下都是要靠这根 Pelvis 组合顶起来,所以这根骨骼就会有很多的平移的变化。
在人的表情动画里面也有很多平移变化。
在一些特殊的机械结构里面,比如说我们要做一个古代的弩箭,这个弩箭它会拉开松起。那这个骨骼实际上也有平移变化。

Joint Pose - Scale

notion image
放缩变化实际上一般时候都不会用,但是在有些场合用的还是蛮多的,比如说在面部表达的时候,要去给它做个大眼睛小眼睛、大鼻头高鼻头。而且沿着 X 轴、 Y 轴、 Z 轴的放缩比例可能都不一样。
notion image
可以参考 Unity Transform 文档的 Limitations with Non-Uniform Scaling 部分。

Joint Pose - Affine Matrix

notion image
可以把 Joint 关节分析成三个元素:旋转、平移、放缩。
任何一四元数,我可以把它展开成一个 3 乘 3 的旋转矩阵,放缩同理。平移也可以再转化成 3 乘 4 的矩阵(Affine Matrix 仿射矩阵)。
仿射矩阵和投影矩阵不太一样,投影矩阵最后算坐标还要做透视除法(除以 )。但是在骨骼动画里面的仿射矩阵是一个 3 乘 4 的矩阵,不需要最下面 0 那一行,因为它永远是正交投影。

Joint Pose - Local Space to Model Space

notion image
动画一般都会存储在 local space 里面,就是说只会去存相对于它的父亲的变化,为什么呢?因为我们会发现当一个角色在动的时候,很多骨骼其实是没有任何动画的,或者是它只有一点点的旋转。
对于每个骨骼,我们需要知道几个概念:
  • 这个关节的父关节是谁?
  • 父关节在 local space 里面长什么样?
  • 这个关节它是在模型坐标系里面应该是什么样子的?
    • 实际上是从它的根节点开始,一个骨骼一个骨骼算下来。

Joint Pose Interpolation - Local Space vs. Model Space

notion image
💡
为什么我们要把这个动画数据全部存到局部坐标系呢?
notion image
如果动画数据全部存在模型坐标系,以摆动手小臂作为例子,如果每个关节点在模型坐标下插值,它插出来之后,骨头会一会儿变长、一会儿变大。因为如果模型坐标系下插值的话,你插出来的是一条直线(上图)。
我们应该对 Joint 的 rotation 进行插值,这样就会看到骨骼是在进行定长的旋转(上上图左边)。

Single Joint Skinning

notion image
💡
在 Joint 发生平移、旋转的时候,我们希望的皮肤上的 mesh 也跟着动,这里数学是怎么映射的?

Skinning Matrix

notion image
上图定义了一些符号表达。
这里定义了一个恒等式,假设一个模型顶点 V 只和一个 Joint J 绑定,绑定在 Joint J 的模型顶点 V 在 local space 里相对 Joint 的位置是不能变的,无论我们做了什么姿势。上图右上角的图中,点(蓝色 V)和 J 的相对位置,再到 t 这一帧,点(黄色 V)和 J 的相对位置,两个在局部坐标系上投影是严格一致的,不然就不叫绑定了。
因此关节在动的时候,上面的表皮也会跟着动。上图的矩阵就是最核心的一个矩阵。
notion image
任何一个关节,在单位时间 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

notion image
假设一个人有 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
notion image
前面说过做引擎的时候,关节数据里面一般会存一个矩阵的逆,虽然这个矩阵是一个 Affine 矩阵,它的逆能相对快地求到,但是我们还是会提前算好。
 

Weighted Skinning

Weighted Skinning with Multi-joints

notion image
💡
磨皮动画实际上只是在前面基础上,让我们的任何一个表面上的点能够和多个 Joint 同时起作用。
原理其实就是加权平均。Joint 数量可以不设上限,但是一般我们不会超过 4 个。所有跟这个点关联的骨骼的加权必须要等于 1。如果不等于 1 的话,就会出现很多很奇怪的变形。
💡
有了 Weighted Skinning 之后,怎么算新的位置?

Weighted Skinning Blend

notion image
对 Joint 1、Joint 2 分别算出在模型空间的新的坐标,做加权平均。
⚠️
顶点的插值一定要在模型空间做。

Clip

notion image
一序列的关节 pose 就是一个 clip,例如走路 clip、跳跃 clip。

Interpolation

Interpolation between Poses

  • Animation’s timeline is continuous
notion image
 
  • Calculate the pose between key poses
notion image
clips 一般存 15 帧或者 20 帧。但是在游戏里面,帧的密度其实很高。比如说现在游戏里一般是 120 帧 60 帧,动画资产不可能存 60 帧,存 120 帧。所以很多时候我们要在这两个 pose 之间去插值,插值让这个动画看起来更加的平滑。

Simple Interpolation of Translation and Scale

notion image
对于比较简单的,像是位移和放缩,直接线性插值就行了。

Quaternion Interpolation of Rotation

notion image
前面提到旋转用四元数表示,那么旋转的插值就能用 的线性插值,再加上 normalization,这种插值叫做 NLERP

Shortest Path Fixing of NLERP

notion image
NLERP 有个细节,Rotation 的插值其实有一个最短路径的原则。从旋转 1 向旋转 2 要沿着球面最短的路径。
💡
相当于要从北京到新疆,从北京直接飞到新疆是最短路径。但是从北京往美国飞,沿着太平洋大西洋再穿过亚欧大陆也能到新疆,不过这个路径就不符合人的直觉了。
因此我们要算 quaternion1 点乘 quaternion2 是大于 0 还是小于0。如果是大于 0,直接插值就好了。如果是小于 0,也就是大于 180 度的角,那就反向插值。
如果不去做最短路径插值的话,会发现在某些时候关节会突然做个翻转。加了这个最短路径的差值,它会看上去相对自然一点。

Problem of NLERP

notion image
即使我们有了四元数的插值,它的速度仍然不是均匀的,会发现它一开始就是快到中间慢,到后面又开始快。
如果要做对质量要求很高的动画,会发现这个东西看上去有点不自然。
因此现在 3A 游戏中,大家提出了 SLERP 的概念。

SLERP : Uniform Rotation Interpolation

notion image
https://twitter.com/freyaholmer/status/1176137498323501058
SLERP 的原理就是:把两个旋转之间的夹角 找出来,再用夹角 一点点的插值,这样效果比较好。

NLERP vs. SLERP

notion image
SLERP 也有点问题:
  • 这个插值比较费,因为有个反三角函数的运算,计算机要查表去算。
  • 当夹角 很小的时候, 也很小,用来做分母的话,这个插值是不稳定的。
    • 因此一般会给个 magic number,如果它很小,就用 NLERP。夹角比较大,就用 SLERP。
 

Simple Animation Runtime Pipeline

notion image
💡
至此,动画最核心的数学和它的原理就讲完了。
一个简单的动画 runtime 的 pipeline 过程:
  1. 首先有大量的动画的 clips。 clips 就存在角色的各个 pose。
  1. 找到当前帧和它的下一帧。
  1. 用插值算法插出现在骨架应该在的 pose。
  1. 把这个 pose 计算转换到 model space 里面。
  1. 用数学方法算 Skinning Matrix Palette。就是说每一根骨骼表达成一个它的 Skinning Matrix。
    1. 1~5 一般都会在 CPU 算
  1. 在 vertex shader 里面对每一个顶点算位置。
    1. 这步一般会交给 GPU。
现在也来越多的游戏,特别是主流的 3A 游戏,会把前面部分运算也全放在 GPU 里面去计算了。