Skeleton with Assimp 骨骼动画解析

  • 2019 年 10 月 3 日
  • 筆記

Skeleton with Assimp 骨骼动画解析

骨骼动画是图形学中十分常见应用很广泛的一个技术,也是比较基础的内容,作为图形学的工程师需要将这一部分内容梳理清晰,主要关键在于几点:第一,分清楚骨骼节点两个概念;第二,熟悉使用 Assimp(或者其他的)的解析方式,并编程实现骨骼的解析和动画的播放。

理解骨骼

首先,为什么会有骨骼动画这么一种东西的存在呢?如果我们从我们自己的身体上观察,就可以发现,我们全身可以活动的部分,其内部基本都有一根主要的骨头,比如小臂的挥动,小臂上所有的肌肉皮肤都一起和骨骼运动。回过头来看 3D 绘制,通常我们需要绘制的是一个 mesh, 也就是物体的表面部分,可以认为是一张皮。我们希望绘制的对象也可以像人体一样做一些动作。那么同样的,我们将这张皮上面的每一个最小单位,如顶点(vertex)都绑定一根骨头上去,骨头怎么动,皮就怎么动。这个部分,叫做蒙皮(skin rigging),是由艺术家完成的[1](真实情况中,一个点可能受多个骨骼影响,需要确定具体的权重)。这样,我们只需要考虑有限的几个骨头的运动就可以描述人体整体的运动,简化了很多,同时也是对自然规律的模拟。

节点又是什么?

节点就是一个点,在骨骼的语境中,可能称为“关节”,但是关节不同与骨骼。Keep in mind: 骨骼是有长度的线段,仅有空间中一个点的位置无法描述骨骼。只有用两个点,才能组成一条线段,只有线段才能代表骨骼。即使在 Unity 或其他软件中,用 node, 节点,关节 代表骨骼,但是心里要清楚这一点。如下图,RA 是一个骨骼,RB, BC 都分别是骨骼,但是我们不能说 A 是一个骨骼,单独提 A 是没有意义的,这只能是一个点。

艺术家的工作,将所有的顶点,都和骨骼绑定起来,显然,下图这个骨骼的配套的 mesh 的上面部分的顶点,都与 RA 骨骼绑定起来了,而在左下方和右下方的顶点,基本绑定在 BC 和 DE 两个骨骼上。在这几个节点处的顶点,会与多个骨骼绑定,每个骨骼有一定的权重。同时,艺术家会提供一个动画的关键帧的骨骼的姿态,即在关键帧时,每一个骨骼的位置。

    A -                            B---- R ----- D    |             |    |             |    |             |    C             E    ------------------    R -> A  R -> B -> C  R -> D -> E  

根据艺术家提供的数据,究竟我们怎么确定每一个点的位置呢? 首先骨骼之间存在父子结构关系,上图的 RB 骨骼是 BC 骨骼的父亲。我们也可以用节点来描述,那么就是 R 点是 B 点的 父亲, B 点是 C 点的父亲(如上图箭头所示)。在父子层级关系上我们用骨骼或者用节点都是可以描述的,其本质上描述的是同一个骨骼,就是图中画的那样。Assimp 用来帮助解析 FBX 文件,我们从 Assimp 中获取所有的信息。对于任意时刻的骨骼的位置,Assimp 提供每个骨骼相对上一级的变换,以 transform matrix 表示,从根节点开始遍历,就可以得到每一个骨骼相对根节点的变换,如果认为根节点就是在世界坐标系的中心,这就是从 bone space -> world space 的变换了。bone space 就是以这个骨骼当中的某个点作为坐标系的原点,具体是哪一个点,其实我们也是无法得知的,这个信息对于计算和理解都不重要,只要知道 bone space 就是骨骼的局部坐标系,知道绑定了这个骨骼的每一个点,在这个局部坐标系当中的位置即可。对于绑定了这个骨骼的每一个点,设其在 bone space 中的位置为 bone_pos, 那么,其在世界坐标系中的位置就是 bone_pos 乘上计算出来的变换矩阵。

Assimp 解析指南

使用 Assimp 加载 FBX 文件获得 aiScene 这是所有数据的入口。

Bone

aiBone *bone = aiScene->mMeshes[]->mBones[];

mBones 数组里面存储了所有的骨骼,每个骨骼存储对应绑定的顶点和该顶点的权重,以及一个 mOffsetMatrix 这个矩阵十分有用,后文提及。

Node

aiNode *root_node = aiScene->mRootNode  // root node  aiNode *child_node = root_node->mChildren[i]  // get child node

aiNode 中除了存储父子关系相关信息外,最重要的属性就是 mTransformation 这就是相对于上一级 node 的变换矩阵。

这里我们又见到了 node 和 bone 两个说法,在 Assimp 的规定中,每一个 bone 必定会有一个相同名称的 node 与其对应,反过来不成立。每一个骨骼的变换矩阵就是同名节点的变换矩阵。

Bind Pose

Bind Pose 是根据 mesh 的顶点信息,不考虑骨骼,直接绘制得到的结果,也就是绘制对象初始的状态。另外,也可以用上 Assimp 读取出来的数据来验证。[2]

前文提到,bone space 的顶点位置是计算的前提,但是实际上,我们读取的到的 mesh 的顶点位置,是以 model space 也即模型空间来描述的,正因为如此,我们可以直接绘制出初始状态的模型来。那么如何得到 bone space 的位置呢?从理论上来说,逐级遍历得到 BoneToWorld transform matrix, 这个矩阵的逆矩阵就是 WorldToBone transform matrix, 即:

final_pos = (transform_matrix) * (transform_matrix)^(-1) * world_pos;  // means: final_pos = world_pos;

看起来无意义吧,因为逐级计算矩阵再求逆这个操作实在复杂,Assimp 直接提供了这个逆矩阵,就是 mOffsetMatrix, 上式可以写作:

final_pos = (transform_matrix) * (offset_matrix) * world_pos; // means: final_pos = world_pos;

可以利用这个方法验证 FBX 读取和计算是否正确。正常情况下应该和直接绘制的结果一样,如果不一样,就是某个地方出错了(最有可能出错的地方是逐级遍历节点计算变换矩阵)。

Animation

得到 bone space position 以后,计算每一帧的姿态就很简单了。aiScene 包含了若干个 aiAnimation,每个代表一组动画,每个 aiAnimation 包含一个 aiNodeAnim 的数组,称之为 mChannels, 根据 aiNodeAnim->mNodeName 找到对应的 node,那么这个 node 在某个特定时刻的 transform matrix 就可以通过对 Position, Rotation and Scaling 的插值计算出来,该矩阵仍然只代表相对父节点的变换,仍然通过逐级遍历得到每个节点的在特定时刻的,AnimationBoneToWorldTransformMatrix. 最后的计算:

final_pos = (animation_transform_matrix) * (offset_matrix) * world_pos;

Reference

[1] Skeletal Animation With Assimp

[2] can’t get bones/skinning to work

Asset-Importer-Lib Documentation

Bones Animation – Matrices and calculations