骨骼动画PUNK#2

May 2, 2024

打算给自己的游戏引擎中的模型加入对动画的支持,这里有几种思路可以考虑:

  1. 我可以把模型拆成几个部分,然后在每个循环里用CPU计算它们的变化,然后分别渲染。
  2. 或者,我可以在模型中追加骨骼数据,然后让GPU计算每个顶点的变化。

前者要求一种一般的动画系统的实现,后者则要求一种特殊的骨骼动画的实现。本文讨论后者。

骨骼动画的描述

当我们谈论骨骼(skeleton)动画,我们实际上说的都是关节(joint)。

首先我们考察日常生活中的关节,以我的胳膊肘为例:

  1. 胳膊肘自身可以进行欧拉角的旋转操作,尽管它只能进行一个方向的旋转,但是我们有些关节(比如手肘)可以在更多欧拉角上进行旋转,它的上限是三个。除此之外,如果我的手臂是机械臂,那么这个关节还可以进行伸长操作,我们至多可以设想三个方向上的伸长操作,或者更应该说成是缩放操作。
  2. 胳膊肘在空间中有一个位置,哪怕胳膊肘自身固定住,我移动肩膀,手臂也会随之移动,因为胳膊肘自身位置变了。胳膊肘的位置仅取决于同上一个关节的关系,即与肩膀的相对位置及所进行的运动。
  3. 手臂上的肉都绑定于胳膊肘,它与胳膊肘的相对位置在运动中保持不变。

根据这几点,我们相应地可以给出一种方案来描述一个抽象的关节:

  1. 关节存储着对顶点进行操作的信息,通常是一个用于旋转的四元数,如果骨骼能够缩放,那么还需要一个表示缩放的量。
  2. 关节自身也是一个可以进行旋转和缩放的矢量,它在顶点空间中有一个位置。关节的方向及位置由父关节所进行的操作(施加在其上的操作)以及它同父关节的相对位置(矢量本身的值)所决定。
  3. 顶点可以被视作一个不再进行操作的关节,完全由关节进行的操作、对关节进行的操作、它与关节的相对位置所决定。

根据第一点,我们可以把关节设想为一个球体,就像人偶的关节一样。根据第二点,我们可以在这个球体上加一个箭头来表示方向。同时,我们可以在箭头末端再加上一个球体表示子关节的位置。——这样,我们就可以给出关节的一种最常用表示方法。(如图是Blender中的表示方法)

blender-joint-structure

总之,根据这几点,我们或许可以给出关节的数据描述:

struct Joint {
  // 常量
  rotation: vec4<f32>,    // 用于进行旋转的四元数
  scale: vec3<f32>,       // 用于进行缩放的量

  // 变量
  rotation_self: vec4<f32>,    // 自身的旋转量
  scale_self: vec3<f32>,       // 自身的缩放量
  translation_self: vec3<f32>, // 自身相对父关节位移量
}

目前为止,我们一直把关节看作是实体,即它拥有变量、自身可以作为操作的对象。它既可以进行旋转、缩放操作,自身又可以作为旋转、缩放、平移操作的对象。正因如此,单凭关节的层次结构便足以规定模型的运动。

这一规定可以进一步化简。我们注意到,这里所进行的操作完全是线性变换的操作,都属于仿射变换的一种。同时,我们其实不需要计算出关节自身的位置,在顶点着色器中我们只需要得到顶点的位置。实际上,关节仅取决于它在其中所发挥的作用,也就是进行这样一种线性变换。这样,关节就可以消去其作为实体的特点,不再是一个保有位置的向量,而是成为了对向量的操作本身——回忆一下,线性变换本身也可以作为线性变换的对象。

这样,我们可以化简这个抽象的关节的定义:

  1. 关节自身是一个线性变换,它规定了对向量进行旋转、缩放、平移的操作。
  2. 关节作为一个线性变换,它可以同其他线性变换联结在一起。设它为$f$,那么它与父关节$g$所进行的变换为$g\cdot f$。
  3. 顶点是一个向量,完全由关节进行的变换、对关节进行的变换所决定。

这样,关节的数据描述就被简化为:

struct Joint {
  rotation: vec4<f32>,
  scale: vec3<f32>,
  translation: vec3<f32>,
}

甚至干脆使用仿射变换的矩阵来表示:

struct Joint {
  transform: mat4x4<f32>,
}

注意到,因为关节自身的平移是无关紧要的,所以它被挪用为对其变换对象的平移。如果我们把骨骼和动画区分开来看,那么可以说它不像旋转和缩放,它不属于动画的定义,而属于骨骼的定义,它的意义在于它令不同关节的变换不再是对同一个中心的变换。除了一些夸张化处理的动画效果,不会有动画效果需要去改变它的值。

骨骼动画说到底属于数据压缩的领域,属于性能优化的领域。为什么要有骨骼动画?一个简单的回答是:因为存在数据瓶颈,因为需要压缩。因为渲染程序最终处理的是数据,它一视同仁地对待这些不同的环节,把它们当作线性变换来处理。但是其中的环节(尤其在动画工作者眼中)是有着各种区别的。骨骼动画种令人困惑的地方就在于此,当你查看数据的定义,你看到的实际上是被压缩了的数据的定义。因此要理解骨骼动画,就要理解其压缩的过程是如何进行简化的。

在这样简化后,上述Blender的表示方法仍然是成立的,但意义要重新理解:现在由这个图形所构成的不再是一个实体结构,而是一种限制性的结构,它限定了变换所能进行的操作。

骨骼动画的计算

关节联结为一棵树、一个hierarchy,那么对于一个顶点最终的变换,就是回溯性地应用这棵树上的所有变换:

$$ v_{anim}=T_{root} \cdot\cdot\cdot T_{joint}\cdot v_{origin} $$

或者递归形式:

$$ \left\{\begin{array}{l} v_{anim} = T_{joint}^{(global)}\cdot v_{origin}\ T_{joint}^{(global)} = T_{parent}^{(global)}\cdot T_{joint} \end{array}\right. $$

换句话说,只要我们通过一个递归的过程求得了全局变换$T_{joint}^{(global)}$,将它应用于顶点即可得到变换后的位置。

为了效率考虑,既然每一帧中骨骼的位置是确定的,那么最好对每个关节上所进行的全局变换$(T_{joint})_{global}$只进行一次计算:

@group(2) @binging(0)
var<storage, read> parent: array<u16>;
@group(2) @binging(1)
var<storage, read> joint_transform: array<matrix<mat4x4<f32>>>;
@group(2) @binging(2)
var<storage, read_write> joint_transform_global: array<matrix<mat4x4<f32>>>;

fn global_joint_transform_of(joint_id: i32) {
    let parent_id = parent[joint_id];
    if (parent_id == -1) {
        joint_transform_global[joint_id] = joint_transform[joint_id];
    } else {
        joint_transform_global[joint_id] = joint_transform_global[parent_id] * joint_transform_of(joint_id);
    }
}

这样,几乎没有其他需要注意的问题了。

蒙皮动画

如果一个顶点只和一个关节相绑定,那么骨骼动画就像机械人一样僵硬。一种解决方法是使用蒙皮(Skinning)技术。

在数据的处理和展示上,它大体上同上面的过程相一致,只不过把每个顶点同多个关节按照不同的权重联结起来:

$$ v_{anim}=\sum_{j=1}^{n} w_{j} \cdot T_{j}\cdot v_{origin}=T_{blend}\cdot v_{origin} $$

这样,最终得到的矩阵$T_{blend}$是一个难以解码的压缩文件。事情到了这一步,就不太容易再去形象地理解这里发生的变换是怎样的一种情况了。——但仍是可能的,比如设想一个被多个不同方向的力拉扯的弹性表面,其形变同其到各个施力点的距离相关。以这样的方式,蒙皮动画能实现花哨的效果:丰富的面部表情,流畅的动作……这样动画看起来就不像是骷髅架子一样了。

但是,蒙皮动画是一种昂贵的技术,因为对于每一个顶点,我们都要为其指定不同的权重和关节,这会令数据规模膨胀$n$倍。所以一般只允许顶点与少量关节(比如4个)相联结,这限制了实时渲染出的画面的精细度。

游戏史上最早的一批3D游戏中,《超级马力欧64》《塞尔达传说:时之笛》便已经运用了蒙皮技术,哪怕今天司空见惯的动画表现往往也已经运用了如此复杂的技术。因此没有任何理由偷懒不去实现它。

——事情既然到了这一步,我们发现并非所有数据都是能够被开发者所掌控的了。于是不得不把许多东西要交给分工(艺术家和他的工作流),交给现成的行业规范(例如glTF)。不然,我们得自己重新发明整套工作流。

骨骼动画格式总结

骨骼动画的格式中,最关键的是以下及部分的数据:

  1. 骨骼关节树的结构。
  2. 每个关节在每一个关键帧上的状态。
  3. 顶点和关节的绑定(包含权重)。

还有一点本文没有涉及到,就是关键帧和时间相关的数据定义。它们与更加广义的动画概念对应概念没有什么不同。大体上,我们需要在根据当前的动画时间,对关键帧之间进行插值。

这一小片精神,这一小块血肉,随你处置,愿你善待它