0%

骨骼动画(skeletal animation)

对于像人体模型之类的复杂三维模型来说,可利用坐标系分层思想所带来的灵活性去简化一些问题,比如说,在为人形角色设计一个“手肘带动整条手臂一起转动”的动画时,对于整个角色来说,这是一个局部动画,如果直接以人物的单层物体坐标系来设计,会非常复杂,也很难直观地描述和进行这种变换,但如果利用分层的骨架系统就很轻易地操纵模型的变形。

从广义上来说,在计算机图形学(CG,computer graphics)中,绑定(rigging)指的是给不同物体建立关联的行为,比如说,存在父子关系的两个物体对象,如果移动或旋转了父对象,那么子对象也会跟着一起移动或旋转。

rigging_animate
《rigging》

骨骼动画(skeletal animation)就建立在这种思想的基础上,为模型绑定虚拟的骨架,使模型不同部分的网格与骨架中的骨骼建立联系,而关节的旋转会带动起骨骼转动,因此可以通过操纵关节来带动模型发生形变,这是一种更灵活且复杂的关系,比如说可以为关节施加约束来限定其旋转角度。

骨骼动画中的“​rigging”,虽然也是绑定的意思,但通常指的是为模型装配一幅合适的骨架。

骨架

以人体模型为例,可以根据躯体(body)的各个关节(joints)骨骼(bones)之间的连接关系,建立一幅骨架(skeleton,armature),这是人体移动和弯曲的基础。

naming-bones-in-the-human-rig
naming-bones-in-the-human-rig
《人物角色骨骼命名示例》
from: Bones naming in the human character rig

骨架上的所有关节都可看作是一个节点(node),而骨骼确定了两个相邻关节之间所保持的距离,不过这已经蕴含在了关节节点在空间的相对距离上。

可以这么理解,假设有一根棍子,它是一种理想刚体(rigid body),可以忽略形变的作用,无论怎样受到外力的影响,又或者发生了平移或旋转,棍子两端之间的距离都不会改变,可将这根棍子理解为骨骼,而棍子的两端理解为关节,也就是说,确定了两个关节的位置,实际也就等于确定了一条骨骼,反过来也一样。

skeleton_hierarchical_structure
skeleton_hierarchical_structure
《多层级骨架概念示意图》

骨架是一种多层级结构(hierarchical structure),一般会以人体的臀部(hip)中心或脊柱(spine)中心作为根节点(root node),然后从根节点的相邻关节开始,将其它所有关节节点(joint node)逐层连接起来,每个关节都可以附加一个或多个骨骼,也就是说,除了根节点以及那些末端节点外,其它层次的所有节点都存在父节点(parent node)子节点(child node),另外,每个节点都可以有多个子节点。

每个关节节点都可看作拥有一个以自身为原点的局部坐标系,即关节坐标系(joint coordinate),而所有子关节都可看作位于其父关节的局部空间中,可用相对于父关节位移旋转以及缩放去描述一个子关节坐标系,子关节可沿着父关节往根关节的方向逐层映射,直到全局坐标系。

相对于父关节的平移和缩放可以比较容易让人理解,但相对旋转会有点让人困惑,我们可以将变换拆开来看,根据约定,我们通常会采用 TRS 的顺序来将这三个变换的矩阵组合起来一起进行变换(有时会根据实际变换的顺序来将这种方法称为 ),即 ,不过在使用该组合矩阵给向量执行变换的时候,由于向量一般是从右边开始结合的,因此变换顺序并不是先平移再旋转后缩放,而是先缩放再旋转后平移,这个顺序的先后非常重要,主要是因为像旋转和缩放这样的变换通常都是相对于当前坐标系“原点”进行的,顺序不一样会导致变换的结果不同。

子关节坐标系的建立过程可分为以下四步:

build_child_coordinate
build_child_coordinate
  • 根据父关节坐标系克隆出一个子关节坐标系,此时子坐标系的空间位置、旋转以及缩放状态与父坐标系完全一致
  • 缩放(scale):对子坐标系进行缩放
  • 旋转(rotate):相对于父坐标系原点旋转子关节坐标系,由于在旋转之前,子坐标系的旋转状态与父坐标系一致,而且原点也重合,因此也可以看作是将子坐标系平移之后,相对于子坐标系自身原点进行旋转
  • 平移(translate):根据子关节相对父关节的位移,将子关节坐标系平移到子关节的位置上,使坐标系原点与子关节重叠

正向运动学(FK)

关节就像是一个轴承(bearing),骨架在调整姿势时,主要通过关节的旋转来完成,这会带动其子关节一起转动,并一路传递到末端关节(end joint)

这是因为子关节的旋转是相对于父关节的,如果父关节发生旋转,子关节为了保持与父关节的相对旋转,会被动地跟着一起旋转,其在旋转后仍保持与父关节原本的相对旋转不变,但如果关节主动发生旋转的话,就会改变与父关节的相对旋转

这就是所谓的正向运动学(FK,forward kinematics),比如说手臂的摆动,其实就是肩关节(shoulder)的转动,带动了整根手臂(arm)

skeleton_fk
skeleton_fk
《正向运动》

在骨骼动画中,通常还会为每个关节施加不同的关节约束(joint constraint),以限制关节的旋转角度范围,就像人体的关节,在正常情况下所能转动的角度是有限的,不会转到非常夸张的角度。

反向运动学(IK)

虽然正向运动学在描述那些姿势固定的姿态或动画时很方便,但是在实现一些需要动态匹配目标位置的效果时,单靠固定的动画达不到真实的效果,典型的案例有以下三个:

  • 用手抓取(grabbing)目标物体
  • 行走时,脚掌与地板进行脚位放置(foot placement,foot grounded),可从 Samples - Foot ik 来体会这种效果
  • 肘击的时候,手肘要撞向目标

以上案例除了肘击外,另外两个都是末端关节与目标的匹配,实际上,大多数使关节与目标位置匹配的需求,基本都是像手掌、脚掌、头这样的末端部位,而在一些细节非常到位的游戏中,还存在着眼睛跟踪目标(look at)并调整人体姿势之类的效果。

尽管不排除存在类似于手臂可伸长的橡胶人角色,但是在一般情况下,骨骼的长度是固定的,整幅骨架无论发生怎样的运动,都需要维持骨骼的长度不变,这实际也是维持了骨骼两端关节之间的相对距离不变。

那么,可以先调整目标关节的位置与旋转状态,使其先与目标位置进行匹配,然后根据关节之间的关系,按照一定的算法,反推其父关节以及其它关节应该处于什么状态,这种做法就是反向运动学(IK,inverse kinematics)

通常会将反向运动学(IK)的计算结果与原本的正向运动学(FK)动画,根据权重(weight)进行混合(blend),这动态调整了原本固定的动作,营造了更真实的效果,根据情况还可能会补充一些过渡(transition),以确保这种调整能够平滑(smooth)地融入到动画中。

不过由于关节自身的约束,以及关节之间所保持的关系,也束缚住了关节所能到达的极限位置,比如说,手抓取不了够不着的物体。

ik_grabbing
ik_grabbing
《反向运动》
note:图中的下半部分演示的是够不着的情况

骨骼动画

我们先来看下早期的关节动画(joint animation),这不是采用骨架带动模型变形的实现方式,所以严格来说并不是一种骨骼动画,其会将整个模型拆分成多个网格实体,这些网格会以类似于骨架的分层架构关联起来,并利用“rigging”的做法,使父节点带动子节点变换,这虽然能够做到类似骨骼动画的联动,但这种实现有非常大的缺点,因为其每块网格是彼此分离的,无论关节动画使网格如何位移、旋转,网格都仅是整体在做刚体变换,而不会变形,那么在两个网格的连接处就无法闭合,从而产生裂缝

joint_animation
《关节动画演示》

而真正的骨骼动画(skeletal animation)一般都不需要将单个模型拆分成多个网格实体,而是将模型作为一个整体来看待,通过骨架控制模型网格中的顶点变换,使整个模型真正做到变形,因此只要设计合理,否则一般不会出现在关节动画中的关节处无法闭合的问题。

关节以及骨骼的概念只是用来构建虚拟骨架的,本身并没有形状,所以骨骼本身并没有可用于渲染的东西,不过在设计或编辑骨架的时候,大多数程序会将骨架中的关节和骨骼可视化出来,以表明关节之间的关系。

实际上,骨架需要绑定到模型后才能使用,大多数的模型都采用多边形网格(polygon mesh)进行建模,而网格由顶点(vertex)构成,相邻的多边形之间可能会共用一些顶点,因此顶点是模型与骨架建立联系的基础对象,另外,任何类型的模型其实都可以进行骨架绑定。

为模型绑定(bind)骨架的过程可分为装配(rigging)蒙皮(skinning)两个主要步骤,它们通常会被一起执行,所以有时会直接把这个过程称为蒙皮

骨骼动画(skeletal animation)的实现方式以及算法有很多种,但核心思路基本是一致的,它们主要的差异在于蒙皮的实现方式不同,主要有以下两种实现方式:

  • 刚性蒙皮(rigid skinning)或称简单蒙皮(simple skinning):这是一种比较简单的蒙皮实现,基本思路是将一个模型网格划分成多个顶点组(vertex group)每个顶点都只能包含在一个顶点组内而每根骨骼都关联了一个顶点组,也就是说每个顶点只会受到来自一根骨骼的影响,这听起来跟关节动画类似,但刚性蒙皮并不会像关节动画那样将一个模型拆分成多个网格实体,顶点组仅是在模型网格内的一种逻辑划分,因此关节连接处能够闭合起来,这种蒙皮方式非常适合像机器人、汽车、剑之类的模型,不过对于像人体、动物这种具有连续皮肤的有机角色来说并不合适,因为其在关节连接处虽然不会出现裂缝,但通常会过渡得比较生硬,使得模型在关节处的变形难以做到自然弯曲

    rigid_animation
    《刚性蒙皮动画演示》
  • 柔性蒙皮(smooth skinning)或称平滑蒙皮:其在刚性蒙皮的基础上,修改了对顶点组的定义,虽然在柔性蒙皮中,每根骨骼仍然只与一个顶点组关联,但顶点可以同时包含在不同的顶点组内,此外还需要为组内的每个顶点分配该骨骼对其影响程度的权重,也就是说每个顶点可以与一根或多根骨骼相关联,或者说每个顶点可受到来自多根骨骼的影响,这种多重影响是通过像线性混合蒙皮(LBS,linear blending skinning)之类的算法进行加权混合(weighted blending)实现的,柔性蒙皮能够达到非常真实的效果,也很适合人体模型,这种方式所实现的骨骼动画通常被直接称为蒙皮动画(skinned animation),而蒙皮通常也是默认指的这种柔性蒙皮

    smooth_animation
    《柔性蒙皮动画演示》

目前来说,通常都会把骨骼动画蒙皮动画看作是同一个概念,即柔性蒙皮动画,而且通常也将蒙皮特指为柔性蒙皮,但是这两种实现方式有各自适合的应用场景,并不见得柔性蒙皮就一定优于刚性蒙皮。

为了避免混淆,在本文章中,虽然会把骨骼动画和蒙皮动画视为相同概念,但如果不特别说明,它们将都不指某种具体的实现方式,蒙皮也将一样不会特指于柔性蒙皮,如果要表达某种特定的实现,得具体说明,比如说刚性蒙皮动画、柔性蒙皮动画、刚性蒙皮、柔性蒙皮。

后续篇幅将会从骨架装配、蒙皮、骨骼变换等方面逐步来讨论骨骼动画的实现方式。

这里先用一个类比来辅助理解骨骼动画和蒙皮的思路,比如说,在现实世界中给一幅骨架模型蒙上了一层皮肤,骨骼的转动能带动皮肤发生变形,每根骨骼都会被皮肤的不同部分所包裹着,位于关节附近的皮肤可能会受到多根骨骼的影响,如果皮肤的质地比较柔软的话,整体就能很好的贴合整幅骨架,否则在一些关节处可能会过渡得比较生硬。

显示中的骨架模型一般不能做到自主运动,骨头只是起到了支撑的作用,那么可切换到人类的视角来看,人体的肌肉可以牵拉着骨骼绕着关节转动,被转动起来的骨骼又会带动起它所支撑的肌肉以及皮肤,也会带动其前端连接的其它关节和骨骼。

在蒙皮中,模型中的网格就相当于上面所说的皮肤,但不同于现实世界中通过物理作用来让骨架带动皮肤,蒙皮在这个过程中,不依赖于物理作用,而实际是一种模型不同部位的部分网格与单根或多根骨骼之间的关系绑定以及几何变换的联动。

虽然在设计时可能会对骨架进行可视化,以表明其与模型的关系,但这都是虚拟的,即使绑定了骨架,模型的内部仍然是空心的,并没有实际的骨架用于支撑网格,也没有所谓的肌肉来牵拉骨骼转动。

实际上,每根骨骼(或关节)相当于描述了一个局部空间,这个空间通过相对其父节点的平移和旋转来定义,那么调整关节的旋转状态其实就是让骨骼转动了起来,这其实就是一开始所提到的“rigging”思想。

当骨架与模型处于绑定姿势的时候,其实就已经确认了网格内的所有顶点在其所关联的骨骼空间中的相对位置,而无论骨骼如何转动,所有顶点都会继续保持这种相对关系,因此顶点会跟随着其关联的骨骼一起进行变换,而为了能够得到更平滑的效果,柔性蒙皮中的一些顶点可关联多根骨骼并通过权重的分配进行混合,此时顶点与某根骨骼的相对关系可能就不是固定的,而是受到多根骨骼影响。

骨架装配

装配(rig,rigging)也叫做绑定索具,指的是为模型创建一幅贴合的骨架,并确保所有骨骼放置的位置和方向能够与模型的解剖结构相吻合,通常是由角色绑定师(rigger)来完成。

最好在模型处于默认姿势(default pose)基本姿势(base pose)下进行骨架装配,这一般会是模型的静止姿势(rest pose),此时模型的整体通常处于一种自然舒展且轴对称(axis-aligned)中性(neutral)姿态,关节之间不会贴合的很紧密,一些角落和缝隙都会显露出来,这样可以更方便和准确地为模型进行骨架装配操作,也能使后续的蒙皮更加精确,模型在完成骨架装配后可称之为已装配模型(rigged model),一些模型可能自身就携带了已装配好的骨架信息。

为模型绑定骨架所采用的姿势将被称为绑定姿势(bind pose),骨架的姿势变化会导致模型变形,而绑定姿势就是模型唯一不存在形变的姿势,因为通常会用模型在建模完后导出的默认姿势作为绑定姿势,所以自然没有发生形变,这为动作的设计提供了统一的基准点,因此绑定姿势通常也是动画师(animator)在设计动画时的参考姿势(reference pose),这使得动画的变换序列能够更贴合骨架。

t_pose_a_pose
t_pose_a_pose
《T-pose 与 A-pose》
3d model from: ONLINE 3D VIEWER

对于像人体这样的双足模型,通常会采用 T 形姿势(T-pose)A 形姿势(A-pose)作为绑定姿势,这两种姿势都左右对称且相对中性,可更接近大多数的人体姿势。

虽然人的肩关节可纵向外展的角度范围接近 ,但日常的活动范围基本都在 之间,也就是立正时的角度到 T-pose 所抬起的角度之间。

当采用 T-pose 作为绑定姿势时,假设骨架要从这个“初始姿势”变换到“立正姿势”,由于 T-pose 的整条手臂被抬起且完全伸展,那对于立正姿势来说,此时肩膀和肘部将处于相对极端的位置,想要变换到立正姿势就需要做出比较大的形变,因为距离初始姿势越远,所需变换就会越大。而采用 A-pose 的话,肩关节的初始角度一般是 左右,这对于大部分动作姿势来说,算是一个比较中间的位置,需要做的变形程度也都比较平均。

从模型纹理的角度上看,采用 T-pose 的模型在肩关节附近的部分网格可能会被挤压的很厉害,当手臂放下时会舒张开来,此时纹理会被拉伸,如果设计不合理,就有可能会使模型肩部的纹理存在失真的可能,而 A-pose 可以减少这种现象。

根据上面的描述,看似选择 A-pose 会优于 T-pose,不过还是得视情况而定,像一些经常需要角色做出攀爬动作的游戏,采用 T-pose 反而更合适。此外,T-pose 可能会更便于角色的建模,如果仍需要 A-pose,那么可以在建模完之后,调整为 A-pose,再进行纹理雕刻(sculpt)和骨架装配。

蒙皮

骨架装配仅是将骨架中的骨骼放置在模型的合适位置上,还并没有为它们建立起某种关联,而骨架自身也并没有可用于渲染的东西,参与渲染的实际还是模型,因此,需要为骨架和模型建立起某种联系,使得能够通过操纵骨架的变换,来带动模型发生形变,从而刷新模型的渲染内容。

骨架(skeleton)中的骨骼(bone)模型(model)中的网格(mesh)建立联系的过程称为蒙皮(skinning),更具体地说,应该是组成网格的一个个顶点(vertex)一根或多根骨骼的关联,不过有的顶点也可以不与骨骼进行关联。

在进行蒙皮之前,我们需要为每根骨骼分配一个骨架内唯一的骨骼编号(bone id)以作为其标识,不过编号并不够直观,因此我们还需要进行骨骼命名(bones naming),那么就可以在设计的时候采用骨骼名字(bone name),而骨骼编号将由底层实现来维护。

由于骨架是一个类似于树的层级结构,关节之间存在父子关系,在为所有骨骼拼接变换矩阵时,子关节需要依赖父关节中的变换矩阵,因此需要确保在为子关节拼接前,其父关节已拼接完成,尽管我们可以采用以根关节为入口进行递归这种简洁的做法来遍历骨架,但如果拥有非常多关节的话,递归将会导致非常深的调用栈,所以为了保证效率,我们一般会采用顺序遍历的做法,那么此时骨骼编号的排列顺序就非常重要了,可以按照关节在深度优先或广度(层次)优先遍历时的顺序为其进行编号,以保证父关节的编号小于子关节,这样就可以确保父关节的计算优先于子关节了

bone_id_and_bone_name
bone_id_and_bone_name
《为骨架中的骨骼命名与编号的例子》

蒙皮主要有刚性蒙皮柔性蒙皮两种,它们的效果对比已经在前面提到过了,这里主要关注它们在顶点属性(vertex attribute)上的差异:

  • 刚性蒙皮(rigid skinning):每个顶点最多只能与一根骨骼关联,需要为每个顶点维护一个骨骼编号以表明其所关联的骨骼,在为顶点执行蒙皮变换时,顶点将只受到一根骨骼的影响
  • 柔性蒙皮(smooth skinning):可看作是在刚性蒙皮的基础上,使顶点可与多根骨骼关联,我们需要为每个顶点维护一个骨骼编号数组和一个权重数组以表明其所关联的所有骨骼以及对应的权重,在为顶点执行柔性蒙皮变换时,顶点将会受到来自多根骨骼的不同程度影响

基于上面的描述,我们可以为这两种类型的蒙皮实现定义顶点结构体(vertex structure)了,先来看下比较典型的常规网格顶点结构体:

1
2
3
4
5
struct vertex {
vec3 position; // 坐标向量
vec3 normal; // 法线向量
vec2 uv; // 纹理坐标
};

以上这个顶点结构可以认为是模型原始网格的顶点结构,蒙皮的顶点结构体可基于这个结构进行补充:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 刚性蒙皮顶点结构体
struct rigid_vertex {
vec3 position; // 坐标向量
vec3 normal; // 法线向量
vec2 uv; // 纹理坐标
int boneId; // 绑定骨骼编号
};

// 柔性蒙皮顶点结构体
struct smooth_vertex {
vec3 position; // 坐标向量
vec3 normal; // 法线向量
vec2 uv; // 纹理坐标
int boneIds[4]; // 骨骼编号数组
float weights[4]; // 混合权重数组
};

从上面对 smooth_vertex 结构体中 boneIds 的定义可以发现,我们限定了每个顶点最多只能关联 根骨骼,主要有两点原因:

  • 着色器以及硬件对顶点属性(vertex attribute)在数组的使用上,存在诸多限制以及兼容性问题,比如在 glsl 中,我们分别使用 ivec4vec4 来接收 boneIdsweights,而在一些旧版本的 glsl 中,并不支持类似于 in vec4 arr[3] 这样的数组
  • 实际上,在大多数情况下,使用 根骨骼已经足够了,关联更多的骨骼会增加开销

如果实在想要关联更多的骨骼,可以用类似下面这种做法,我认为这种做法的兼容性可能会好些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//
// c/c++
//

int boneIds_0[4];
float weights_0[4];

int boneIds_1[4];
float weights_1[4];

//
// shader
//

layout(location = N) in ivec4 boneIds_0;
layout(location = N+1) in vec4 weights_0;

layout(location = N+2) in ivec4 boneIds_1;
layout(location = N+3) in vec4 weights_1;

蒙皮网格

由于网格是由顶点构成的,我们在定义了蒙皮顶点结构体(skinned vertex structure)之后,就可以将常规的模型网格(model mesh)转换为一个蒙皮网格(skinned mesh),而包含了蒙皮信息(skinning data)的模型,可将之称为蒙皮模型(skinned model),至于如何填充合适的骨骼编号以及分配权重,我们暂时先不管。

original_model_mesh
original_model_mesh
《未蒙皮的常规模型网格》

假设上图是某个已装配好骨架但尚未蒙皮的原始模型网格,我们来看下分别采用刚性蒙皮和柔性蒙皮时,关节在弯曲后,各自所呈现出来的效果差异。

刚性蒙皮

rigid_skinned_mesh
rigid_skinned_mesh
《刚性蒙皮网格》

从上图可以发现,刚性蒙皮网格的关节在旋转之后,两根骨骼的关节连接处所发生的形变,表现出了不自然的弯曲。

这是因为在刚性蒙皮的实现中,每个顶点都只能包含在一个顶点组内,而骨骼与顶点组是一一对应的关系,也就是说顶点组内的所有顶点仅受到一根骨骼的影响,因此骨骼所关联的顶点组整体就像是一个“刚体”一样,无论关节如何旋转、平移,顶点组整体都只会发生刚体变换(rigid transformation),而不会发生形变(deformation),具体来说就是,组内的这些顶点会彼此保持固定的相对位置,也都与关节保持固定的相对位置,虽然从整个模型空间来看,旋转会使这些相对位置发生变化,但如果从关节的局部空间来看的话,它们依旧保持不变,因为整个顶点组也会随着骨骼一起旋转,这就是为什么刚性蒙皮会被称为“刚体”原因。

但是,网格在整体上也确实是发生了形变的,从上图中的黄色表面(surface)可以确认这一点,这是因为表面是由顶点来确认的,而这块表面用到的四个顶点中, bone0 以及 bone1 的顶点组各占了两个,虽然顶点组内部保持固定的相对关系,但是顶点组与顶点组之间不需要维持这种关系,那么在两根骨骼的关节连接处自然就会存在形变,并且也会闭合起来,因为不同的顶点组仍处于同一个网格中,顶点组仅是逻辑上的划分,而不像关节动画那样会拆分成多个网格实体。

柔性蒙皮

smooth_skinned_mesh
smooth_skinned_mesh
《柔性蒙皮网格》

从上图可以看出,与刚性蒙皮相比,柔性蒙皮网格在关节连接处的过渡要平滑自然得多,因此柔性蒙皮通常都会被叫做平滑蒙皮

smooth_vertex_group
smooth_vertex_group
example:图中演示了部分顶点同时包含在不同顶点组内

柔性蒙皮中的顶点组会更加灵活,虽然骨骼与顶点组仍然是一一对应的关系,但是顶点可以同时包含在不同的顶点组内,也就是说一个顶点可以与多根骨骼进行关联,因此顶点也不再需要一直与某根骨骼保持完全固定的相对关系了,而是与多根骨骼保持某种灵活的相对关系。

smooth_skinning
smooth_skinning
example:将橙色点根据与两根骨骼各自50%的权重进行线性混合的柔性蒙皮变换

上图以一个顶点演示了柔性蒙皮的变换过程,其实就是在顶点与其关联的所有骨骼各自执行刚性蒙皮变换的基础上,再将这些变换的结果进行加权混合(weighted blending),也就是说顶点最终的位置会受到多根骨骼的影响,因此柔性蒙皮能够真正做到非常细致的变形,特别是在关节连接处,合理的权重分配可以使其弯曲得更加自然,而不仅仅是只是闭合。

刷权重

柔性蒙皮中的顶点组,需要为组内的每个顶点记录蒙皮权重(skin weight),这是用来量化骨骼对其关联的某个顶点造成影响(influence)的程度,取值范围为 ,另外,一个顶点所关联的每根骨骼对该顶点的蒙皮权重累加起来都会等于

分配或调整蒙皮权重的行为叫做刷权重(weight painting,paint skin weight),一般有以下四种情况:

  • 将某根骨骼对某个顶点的蒙皮权重从零调整到非零,那么该顶点将会自动加入该骨骼的顶点组中
  • 从非零调整到零的话,将会把顶点移出对应的顶点组
  • 如果希望某些顶点只允许一根骨骼对其造成影响,可以将骨骼对顶点的蒙皮权重设定为 ,此时该顶点将只与一个顶点组(骨骼)关联,与刚性蒙皮的效果一样
  • 为某个顶点分配多根骨骼的蒙皮权重,这些权重值的累加起来等于 ,此时顶点将会同时包含在多个顶点组内,受到多根骨骼影响

在设计时,权重一般都通过冷/热颜色系统(cold/hot color system)渐变(gradient)来可视化,低权重区域(接近于 )显示为蓝色(冷色),高权重区域(接近于 )显示为红色(暖色),中间值为彩虹色(蓝、绿、黄、橙、红),如下图所示:

sculpt-paint_weight-paint_introduction_color-code
sculpt-paint_weight-paint_introduction_color-code
《blender中的颜色光谱及其各自对应的权重》
from: weight paint - blender

下面从一个在 blender 中给角色刷权重的例子,来看刷权重一般是怎样进行以及可视化的。

paint_weight_0paint_weight_1

左图表明了此时“上臂”的顶点组 LeftArm 仅受到 bone:LeftArm 关节的影响,因为这里选中了 bone:LeftArm 骨骼,并显示出 LeftArm 顶点组中的所有顶点对于这根骨骼的权重是完全红色的,即权重值为

右图与左图的情况类似,“前臂”的顶点组 LeftForeArm 中的所有顶点仅受到 bone:LeftForeArm 关节的影响。

左右两幅图还表明了,bone:LeftArm 关节仅会影响 LeftArm 顶点组,bone:LeftForeArm 关节仅会影响 LeftForeArm 顶点组,而都不会影响模型的其它部位,因为从图中可看出其它位置的颜色都是蓝色的,即权重值为

现在先来看看,在这种权重分配的情况下,旋转 bone:LeftForeArm 关节后的效果,如下图:

paint_weight_2
paint_weight_2

可以看到,前臂的旋转完全不会影响到上臂的顶点,现在来改一下,我想要当前臂旋转时,上臂靠近肘关节的部分顶点能够随着前臂的旋转而发生变形,那么在刷权重后,bone:LeftForeArm 所对应的权重冷热图如下:

paint_weight_3
paint_weight_3
note:这里仅显示了手臂部分的冷热图,模型的其它部位仍然都是蓝色的

这里仅是为了演示效果,所以只调整了前臂这部分顶点给 bone:LeftForeArm 骨骼分配的权重,bone:LeftArm 的权重不用管,那么此时旋转 bone:LeftForeArm 关节后的效果,如下图:

paint_weight_4
paint_weight_4

从上图可看到,虽然 bone:LeftArm 并没有旋转,但是由于上臂的前半段顶点对于 bone:LeftForeArm 骨骼的权重不为零,所以会随着 bone:LeftForeArm 关节的旋转而发生变形。

刷权重通常都是在设计阶段完成的,而不会在应用阶段实时地完整计算权重分配,因为这种计算存在比较大的开销,另外,大多数设计工具都存在自动权重(automatic weights)计算工具,能自动分配权重,不过大多数时候会以结合自动权重并手动调整的方式来进行设计,这样既能减轻设计负担,也能够保证权重的分配精确合理。

蒙皮信息

虽然已经讨论了刚性蒙皮和柔性蒙皮各自需要携带怎样的蒙皮信息(skinning data),但没有讨论这些信息是怎么得出来的,通常不会等到应用阶段再去实时地计算,因为这种信息的计算量会比较大而且很难做到精确,一般应该在设计阶段处理好蒙皮信息,可将这个过程称为蒙皮PS:“蒙皮”这个词可用于各种场景,比如在应用阶段生成蒙皮网格或执行蒙皮变换的时候也都可以叫做蒙皮。

在应用阶段,顶点在底层通过骨骼编号(或数组)与一根或多根骨骼进行关联的,但这种做法并不适合设计阶段,因为顶点的数量非常多,一个个顶点去操作的话会非常复杂,而无论是刚性蒙皮还是柔性蒙皮,每根骨骼都关联着一个专属的顶点组,因此蒙皮信息可以从顶点组的角度来看待

在设计阶段,我们一般用直观的骨骼名字来代表某根骨骼,而不是骨骼编号,那么可以为顶点组命名(vertex group naming),然后通过骨骼名字(bone name)顶点组名字(vertex group name)相匹配的方式,让顶点组与骨骼绑定在一起,而每个顶点组可代表一根骨骼所携带的蒙皮信息。

那么如何构造蒙皮信息的问题,此时就可看作为如何构造顶点组了:

  • 刚性蒙皮:为骨骼创建一个顶点组,并将合适的顶点放入组内
  • 柔性蒙皮:在刚性蒙皮的基础上,允许顶点同时放入到不同的顶点组中,此外还需要为组内的每个顶点分配合适的蒙皮权重

以上有两个问题需要考虑,不过在设计工具上,都能简单解决:

  • 【刚性蒙皮】如何确定顶点应该放入哪个组?
    • 建模的时候直接按照不同部位分组设计
    • 也可在建模完后再手动分组,或者在完成骨架装配后,通过设计工具内的自动绑定工具,根据绑定姿势自动分组,然后手动调整以确保准确
  • 【柔性蒙皮】如何给那些同时位于多个组的顶点分配蒙皮权重?
    • 分配权重的过程也相当于进行了分组,在设计工具中,可以先通过其内的自动权重工具,得到初始的权重值,然后通过刷权重来手动调整

蒙皮信息的精确度非常重要,其直接影响了骨骼动画的最终效果,在设计工具中,对于刚性蒙皮和柔性蒙皮,通常都会提供多种不同的蒙皮计算工具,可根据模型类型的不同(比如有的模型带有一些挂架)采取不同的算法,从而得到更贴合的蒙皮信息。比如在 maya 中,柔性蒙皮信息的计算,其实就是权重如何分配的问题,主要有以下几种方式:

  • 最近距离(closest distance):这将根据与顶点的距离来给关节分配权重,忽略掉骨架的层次结构,比如说我们要给一个顶点关联五根骨骼,首先找到距离该点最近的关节,其对该点的影响将是最大的,会被分配到一个最大的权重值,那么距离该点第二近的关节,也就是对该点影响第二大的关节,权重值排第二,以此类推,直到分配完五关节的权重

    maya_closest_distance
    maya_closest_distance
    《maya使用“closest distance”方法为一个顶点分配五个关节蒙皮权重》
  • 骨架层次中的最近距离(closest in hierarchy):在“最近距离”的基础上,不能忽略骨架的层次结构,也就是说,在确认下一个对顶点影响最大的关节时,将从最近关节在骨架层次结构的亲属(父关节或子关节)中寻找,而不仅仅是考虑哪个关节最近

    maya_closest_in_hierarchy
    maya_closest_in_hierarchy
    《maya使用“closest in hierarchy”方法为一个顶点分配五个关节蒙皮权重》
  • 热量贴图(heat map):将网格用作热量源,在网格周围发射权重值,通过热量扩散技术来分发权重,越接近对象,权重值越高

  • 测地线体素(geodesic voxel)

    测地线体素绑定(geodesic voxel binding)适用于复杂的生产级网格,其可以将多个网格或具有多个连接组件的网格一起进行绑定,并将它们视为单一体积,这种方法在计算前需要暂时将输入几何体体素化(voxelization),也就是将三维几何对象转化为非常接近于输入几何体的一系列体素(voxels),下图展示了在不同分辨率下,网格体素化后的样子:

    voxels
    voxels
    《以不同分辨率体素化后的角色网格》
    from: “测地线体素”(Geodesic Voxel)绑定 - maya

    体素也叫做体积像素(volume pixel),用于描述三维对象占用的容量或空间,这个概念有点类似于二维图像中的最小单位——像素(pixel),可把体素理解为像素的三维对应物,因此体素也可以叫做立体像素(three-dimensional pixel),而体素化可看作是对模型进行了简化,从而得到均匀的网格,这么做可以降低给三维网格计算测地距离时的复杂度。

    蒙皮的计算将在体素结构中进行,其实思路也是基于对象与关节间的距离,而主要的差异是,这种方法并不是采取平直空间(flat space)中“点与点之间,直线距离最短”的距离测量方式,而是采用弯曲空间(curved space)测地线(geodesic)测量,即沿曲面的最短距离,三维网格中的测地线距离(geodesic distance)就是两个顶点沿着网格表面的最短路径的距离,如下图所示:

    3d_mesh_geodesic_distance

    《三维网格的测地距离》
    from: 几何特征系列:Average Geodesic Distance(平均测地距离)

    测地线体素绑定的优势是可以消除一些顶点间的串扰(crosstalk),如下面的对比:

    closest_in_hierarchy_crosstalk
    《采用“closest in hierarchy”蒙皮时所出现的明显串扰》
    geodesic_voxel_no_crosstalk
    《采用“geodesic voxel”蒙皮后,串扰明显减少了》
    from: Geodesic Voxel Binding in Maya 2015

    不过要使这种方式能够正常工作,需要确保所有关节被封闭在网格体积的“内部”,否则无法正确计算测地线距离。

maya 中的这些绑定方法,各有优缺点,比如“closest distance”方法可能会导致不良的几何变形,而“closest in hierarchy”方法可能会导致无法分配某些关节,对于不同的模型、不同的部位可能适用于不同的方法,因此可以根据需求在模型的不同部分上使用对应的方法,比如,躯干可能使用“closest distance”绑定会更好,而手臂则使用“closest in hierarchy”绑定。

自动蒙皮的基本思路

虽然设计工具能完成大部分工作,但有时我们可能会想要直接在代码中实现某些需求,比如:

  • 通过自动蒙皮(automatic skinning)自动权重(automatic weights)算法实时计算出三维模型的蒙皮信息
  • 为二维图片制作蒙皮动画
  • 定位动画视频种角色所绑定的骨架,进而把角色的骨骼动画给提取出来

以上这些需求,各自的实现方式不太一样,有的高级算法可能会采用深度学习(比如基于GNN)来完成,这里不考虑那些复杂的实现,下面将讨论下给已装配骨架的三维模型自动计算蒙皮信息的基本思路,但仅是思路,不代表任何真实实现

一般来说,蒙皮都是在模型装配骨架后的绑定姿势下进行的,该姿势下,模型不存在任何的形变,而无论姿势如何动态调整,所执行的变换都基于绑定姿势和蒙皮信息,因此绑定姿势的选取非常关键,为了减少判断误差导致生成的蒙皮信息精度不足,需要先确保模型的装配骨架满足以下两个要求:

  • 尽可能地让骨架的绑定姿势处于一种较为舒展的姿势,避免每根骨骼之间贴合得太近
  • 骨骼所放置的位置和方向要与模型的解剖结构相吻合

我们先直接从骨骼的角度来看,假设为每根骨骼定义一个包围盒(bounding box),然后将所有位于骨骼包围盒范围内的顶点加入到骨骼的顶点组中,包围盒的位置、方向、长度都可以通过骨骼的两个关节位置来确认,但是我们无法确认包围盒应该采用多大的宽和高才合适,而骨骼长度也不能覆盖到那些与骨骼有些许偏差的顶点,也就是说这种方法很难做到精确,可能会存在遗漏,如果包围盒范围设置太大,也可能把不该包含的顶点给包含进来。

automatic_skinning_0
automatic_skinning_0
《使用骨骼包围盒的例子》

由于上面这种做法局限性比较大,现在换个思路,对于刚性蒙皮来说,从视觉上来看,每根骨骼都是与它所关联的每一个顶点距离最近的那一根骨骼,如下图:

automatic_skinning_1
automatic_skinning_1

需要注意的是,两根相邻骨骼之间会共用一个关节,通常会用骨骼的头部关节来代表整根骨骼,关于这一点会在之后讲到

我们通过两个关节可定义一根骨骼的起点终点以及长度,这其实确认了空间中的一条骨骼线段(bone line segment),该线段的宽度不重要,可以根据顶点到骨骼线段的距离,来代表顶点到这一根骨骼的距离,那么只要找到顶点与哪一根骨骼线段的距离最短,就可以认为这根骨骼对该顶点的影响最大。

顶点到线段的距离,其实就是顶点到线段的最短距离,可以先判断顶点沿线段方向的投影点是否落在线段上,如果在,那么距离就是垂线的长度,否则就是线段离顶点最近的那个端点到顶点的距离,如下图所示:

calc_point_to_line_segment
calc_point_to_line_segment
《点到线段距离的计算方法》

但如果一个顶点到两根骨骼线段的距离都相等呢?比如下图:

automatic_skinning_2
automatic_skinning_2

一个好像可行的方法是,通过顶点的法向量(normal vector)来确定顶点的朝向(orientation),如果顶点朝向某根骨骼,就认为该顶点不关联这根骨骼,因为这相当于顶点背面朝外了,如下图所示:

automatic_skinning_3
automatic_skinning_3

但当顶点的法向量不偏向于任何一根骨骼的时候,这其实又会发生矛盾,如下图:

automatic_skinning_4
automatic_skinning_4

因此,采用法向量判断应该没有多大的用处,当一个顶点与多根骨骼线段的距离相等时,可以让它任意挑选一根。

以上这种刚性蒙皮的做法是基于顶点与骨骼线段的距离进行判断,但对于柔性蒙皮来说,顶点得通过多根骨骼进行加权混合,如果采用这种做法,将无法得到准确的蒙皮权重。

automatic_skinning_4
automatic_skinning_4
《假设通过顶点到骨骼线段的距离来计算权重》

比如上图中,顶点与到两条骨骼线段的距离是相等的,那么这两根骨骼(或关节)将都分配到 的权重,也就是说它们对顶点的影响是相同的,但是如果从关节的角度来看的话,这个顶点距离 joint1 更近,显然顶点受到 joint1 弯曲带来的影响要大于 joint0

因此,对于柔性蒙皮,我们应该使用顶点到关节的距离来判断,那么上面的例子,会变成下图这样:

automatic_skinning_6
automatic_skinning_6

我们可根据骨架的层次结构以及顶点到关节的距离来执行柔性蒙皮,方法是,先找到与顶点最近的一个关节,这个关节对顶点的影响最大被分配到的权重也将是最大的,然后根据最近关节在骨架中的层次结构,从其亲属(父关节或子关节)中寻找下一个与顶点最近的关节,重复这个过程直到分配完。

automatic_skinning_7
automatic_skinning_7
example:为顶点关联五个关节,右图的表格按照权重大小排列

在我们这个讨论中,将仅根据顶点与关节的距离关系计算蒙皮权重,而计算的具体方法基于反距离加权(IDW,inverse distance weighting)基于距离的权重分配(distance-based weight assignment),可结合上图的例子来看具体步骤:

  1. 计算顶点到所关联的每个关节的距离倒数

  2. 求以上倒数总和

  3. 归一化权重(WN,weight normalization)

不过上面这种计算方法比较简单粗暴,可以选择其它更好的做法,比如加入衰减速率(dropoff rate),这描述的是关节对顶点的影响随着它们的距离变化而降低的程度。

至此,以上所讨论的自动计算蒙皮信息的基本思路,可总结为:

  • 刚性蒙皮:根据顶点与骨骼线段的距离,关联其中距离最短的那一根骨骼
  • 柔性蒙皮:根据骨架的层次结构以及顶点到关节的距离关联多根骨骼,并根据距离关系来计算蒙皮权重

总的来说,这两个思路都非常简单,不过真实效果可能比较一般,也不能用于所有情况,特别是对于那些较为复杂的模型而言,比如说,如果使用上面的柔性蒙皮,像下图这种情况就可能会出现判断不准确。

automatic_skinning_8
automatic_skinning_8
example:假设L1与L2大小相等

上图中,虽然紫色顶点到绿色关节和蓝色关节的距离是相同的,但从轮廓上来看,这个模型就像是两根指头,所以显然是蓝色关节对紫色顶点的影响最大,而绿色关节应该几乎不会影响到紫色顶点。

我们上面的柔性蒙皮从平直空间坐标系来测量顶点与关节的距离,即“点与点之间,直线距离最短”,一种更好的方法是参考 maya 中的“测地线体素绑定”,即测量顶点与关节之间的测地线距离,其实就是沿着模型网格的表面进行测量,这种方法需要先将网格暂时体素化以降低复杂度。

automatic_skinning_9
automatic_skinning_9
example:紫色顶点分别与绿色关节与蓝色关节的测地线距离

下面将以骨骼和关节为切入点,逐步来看怎么在应用阶段驱动蒙皮动画

骨骼空间

bone_structure
bone_structure

一根骨骼可以通过一个头部关节(head)和一个尾端关节(tail)来确认其起始和结束位置,它们之间的部分为骨骼的主体(body)且没有大小的概念,从头部关节与尾端关节的相对位置可判断出骨骼的长度以及方向,也就是说,在底层实现的时候,可直接根据关节来对骨骼进行定义

bone_and_model_space
bone_and_model_space

每个关节都可看作是一个局部空间,即关节空间(joint space),因此每一个子关节都可看作是位于其父关节空间内,而根关节则是位于骨架的全局坐标系,或者说是模型坐标系(model coordinate),即模型空间(model space)

一般会使用关节矩阵(joint matrix)来描述一个关节及其关节空间,该矩阵表明了关节相对于父关节所做的平移(T)旋转(R)缩放(S),也提供了将关节空间中的坐标,映射到父关节坐标系的方法,即通过关节矩阵与关节局部空间内的点坐标向量相乘。

根据约定,通常采用 TRS(translate,rotate,scale)的方式来构造关节矩阵,原因在前面篇幅其实已经讲过了,这里不再重复,构造的方法可以拆开来看,先为这三个变换各自构建一个矩阵,然后将这三个矩阵按照平移(T)旋转(R)缩放(S)的顺序相乘,注意这个顺序不能变,而与向量结合的实际顺序是从右边开始的,即缩放、旋转再到平移。 实际上,除了子关节外,也可将顶点附加于某个关节,并使其处于关节空间中,那么当一个关节旋转时,其所有后代关节以及所有附加顶点,都会被动地跟着一起旋转。

bone_space_rotation
bone_space_rotation

对于一根骨骼来说,尾端关节可看作位于头部关节的局部空间内,如果要将顶点关联(attach)到这根骨骼上,也可看作是位于该空间当中。

也就是说,如果我们所关注的对象是与骨骼相关联的顶点的话,骨骼的长度以及尾端关节的位置其实都不重要了,可以直接将头部关节空间看作是整根骨骼的局部空间,即骨骼空间(bone space),将头部关节的关节矩阵称为骨骼矩阵(bone matrix),并用头部关节来代表这根骨骼,这也是为啥骨骼和关节这两个术语经常被互换使用的原因,通常所说的骨骼,其实指的就是其头部关节所蕴含的矩阵变换

note!!!:为了避免混淆,这里先为骨骼定义两个用于描述其所包含的局部变换的矩阵名称

矩阵命名别名描述
骨骼矩阵(bone matrix)局部骨骼变换矩阵(local bone transformation matrix)可用来描述骨骼处于任何姿势下,映射到其父关节的局部变换,这间接表明了骨骼在父关节坐标系下的空间位置,无论绑定姿势还是非绑定姿势,都可以使用这个名称来描述
局部绑定矩阵(local bind matrix)局部绑定姿势矩阵(local bind pose matrix)特指骨骼在处于绑定姿势时的骨骼矩阵,为关节自身固定不变的属性,描述了关节在绑定姿势下,相对于父关节的局部变换

骨骼变换

现在需要考虑的是,怎么利用骨骼矩阵来控制关节的旋转状态,以及怎样带动与之关联的顶点,以下会结合一系列的伪代码和图例来逐步达成这个目标。

1
2
3
4
5
struct bone {
int id;
mat4 localBindMatrix; // (in bind pose) <child space> to <parent space>
array<bone> childs;
};

这里定义了一个初始的骨骼(bone)对象,由于骨骼可以用其头部关节来定义,因此这也可认为是定义了一个关节(joint),每个成员的含义如下:

  • id:骨骼所绑定的骨骼编号,用于将骨骼与某些信息进行关联,比如数组中的索引位置、为顶点关联骨骼提供标识依据
  • localBindMatrix:为骨骼的局部绑定姿势矩阵(local bind pose matrix),是关节自身固定不变的属性,描述了关节在绑定姿势下,相对于父关节的局部变换

  • childs:关联所有后代关节

bind_pose_bone_local_trans
bind_pose_bone_local_trans
《演示如何将点映射到父关节局部空间中》
note:示例中的骨架处于绑定姿势

如上图所示,每个关节的局部绑定矩阵所提供的变换,可以将位于其骨骼空间中的顶点,映射到父关节的骨骼空间中,不过局部绑定矩阵仅适用于骨架的绑定姿势,这是一种固定不变的局部变换,另外,除了根关节外,其它所有关节并不能直接通过该矩阵映射到模型坐标系中。

实际上,我们真正想要做到的是,将模型的所有顶点根据骨架的姿势进行变换,从而让模型整体相对于绑定姿势发生变形,为了达成这个目标,有以下几个问题是需要考虑的:

  • 如何将骨骼空间中的局部向量映射到模型坐标系中?

  • 如何将模型坐标系中的全局向量映射到骨骼空间中?

    之所以需要考虑这个问题,是因为大多数模型的所有顶点,都是以模型坐标系的坐标向量存储的,因此不能直接用这些全局坐标来应用骨骼的局部变换

  • 如何让关节相对绑定姿势发生转动从而调整骨架姿势

组合变换矩阵

无论骨架处于绑定姿势还是非绑定姿势,要将骨骼空间中的顶点映射到模型坐标系中,都可以根据以下两个原理来完成:

  • 骨骼矩阵提供了从子关节到父关节的空间映射
  • 可将根关节(root joint)父关节看作是模型坐标系的原点,因此根关节所提供的骨骼矩阵,能将位于根关节局部空间中的点映射到模型坐标系中

具体的做法是,将顶点向量与其所处关节局部空间的骨骼矩阵相乘,从而映射到父关节空间中,此时父关节的角色相当于变成了一个子关节,因此,可以不断往其直系祖先关节方向前进,持续这种矩阵与向量相乘的过程,直到与根关节的骨骼矩阵相乘,顶点向量就被映射到模型坐标系中了。

不过以上做法并不能一步到位,如果为每个顶点都这么做的话,将会有大量重复的运算,更高效的做法是先将当前关节的骨骼矩阵与其所有直系祖先关节的骨骼矩阵相乘,从而得到一个组合变换矩阵(combined transformation matrix),然后再用这个矩阵与顶点向量相乘,由于该矩阵是关节局部空间到模型全局空间的映射,因此也可以叫做全局变换矩阵(global transformation matrix)

此时可将某根骨骼的骨骼矩阵(局部变换矩阵)看作是该骨骼的局部姿势(local pose),而将其组合变换矩阵视为是骨骼的全局姿势(global pose),局部姿势相对于其父关节,而全局姿势则相对于整个模型空间。

由于骨骼矩阵是 TRS 形式的组合,所以组合变换矩阵仍然是一个 TRS 矩阵

在拼接组合变换矩阵的时候,需要注意矩阵相乘的顺序,比如说,不能先与祖父关节的矩阵相乘,再与父关节的矩阵相乘,因为矩阵乘法不满足交换律

通过组合变换这种便利的方法,在考虑怎样将顶点在不同的空间中进行映射时,就可以仅关注矩阵了,只需在为顶点执行变换前,确认清楚顶点目前所处的空间坐标系是否与矩阵匹配

尽管使用组合变换矩阵的做法是高效的,但由于骨架类似于树的结构,一些关节可能会存在相同的父关节,所以用上面这种从后代关节往根关节方向的矩阵拼接方式,可能会存在重复运算,所以这种方式在通常的代码实现上是不高效的。

利用矩阵乘法满足结合律的特点可改进这个问题,简单来说就是将拼接的方向反过来,但矩阵的左右顺序不能够改变,以从根关节往目标关节前进的方式,来拼接这个组合变换矩阵。 可用递归遍历(recursive traversal)的方法,来为骨架中的所有骨骼计算组合变换矩阵(全局变换矩阵),由于每遍历到一根骨骼的时候,其父关节的组合变换矩阵就已经计算好了,因此,每根骨骼的组合变换矩阵其实就是其自身骨骼矩阵与其父关节的组合变换矩阵相乘,表达公式如下:

矩阵结合的顺序是需要注意的,如果父关节采用的是右乘的结合方式,子关节就不能用左乘,需要保持一致,因为矩阵乘法不满足交换律

calc_combined_matrix
calc_combined_matrix
《计算每根骨骼的组合变换矩阵》
note:这对绑定姿势和非绑定姿势都适用

虽然采用递归的方式非常简洁,但在骨架拥有非常多关节的时候,递归会使调用栈非常深,如果将骨骼编号按照骨骼在深度优先遍历(DFS,depth first search)广度(层次)优先遍历(BFS,breath first search)时的顺序来排列的话,就不需要使用递归,可直接用顺序遍历(in-order traversal)的方式来计算,因为此时父关节的编号必定小于子关节,在计算子关节组合变换矩阵的时候,不用担心其父关节的还没计算好,不过这种做法还需要知道每根骨骼的父关节编号,所以真正实现的时候,需要维护的信息比递归方式多。

in_order_bone_id
in_order_bone_id
《骨骼编号分别按DFS以及BFS顺序排列的示意图》

对于处于绑定姿势的每根骨骼来说,其组合变换矩阵(全局变换矩阵)被称为骨骼的绑定姿势矩阵(bind pose matrix),这描述了在处于没有形变的绑定姿势下,从骨骼空间到模型坐标系的映射

绑定姿势矩阵将使用关节的局部绑定矩阵来拼接,不掺杂任何变形,表达公式如下: 绑定姿势矩阵逆矩阵称为逆绑定姿势矩阵(inverse bind pose matrix),其描述了在绑定姿势下,处于模型坐标系中的顶点,怎样映射到特定关节的骨骼空间中。

这个矩阵非常重要,具体用来干嘛的,后续会讨论到,这里先更新骨骼对象的定义,我们需要将它存储起来。

1
2
3
4
5
6
struct bone {
int id;
mat4 localBindMatrix; // (in bind pose) <child space> to <parent space>
mat4 bindPoseInvMatrix; // <model space> to <bind pose local space>
array<bone> childs;
};

由于非绑定姿势的组合变换矩阵计算,还需要考虑到当前姿势相对于绑定姿势的局部变换,所以这里先给出拼接绑定姿势矩阵以及逆绑定姿势矩阵的伪代码。

递归拼接方式伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 该数组存放了一幅骨架中的所有骨骼(关节),一共构建了5根骨骼
// 每根骨骼的骨骼编号,描述了其在数组中的索引位置
array<bone> skeleton(5);

// 根关节
root = skeleton[0];
root.id = 0;

// 构造整幅骨架
// ...

// 用递归的方式计算每根骨骼的“绑定姿势矩阵”以及“逆绑定姿势矩阵”
void combined_bone_transform_recursive(bone joint, mat4 parent)
{
// “绑定姿势矩阵”
mat4 bindPoseMatrix = joint.localBindMatrix * parent;

// “逆绑定姿势矩阵”
joint.bindPoseInvMatrix = bindPoseMatrix.inverse();

// 递归所有子关节
for (child : joint.childs) {
combined_bone_transform_recursive(child, bindPoseMatrix)
}
}

// mat(1)是一个单位矩阵,表示什么都不做,这是为根关节准备的
// 因为根关节的骨骼矩阵,不仅描述了根关节局部空间到模型坐标系的变换
// 还直接反映了根关节在模型坐标系中的空间位置,所以不需要再组合别的映射
combined_bone_transform_recursive(root, mat(1));

顺序拼接方式伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 骨架
array<bone> skeleton(5);

// 记录每根骨骼的绑定姿势矩阵
array<mat4> bindPoseMatrices(5);

// 构造整幅骨架
// ...

// 获取骨骼父关节的骨骼编号
int get_parent_bone_index(id) {
// ...
}

void combined_bone_transform_in_order(skeleton)
{
// 先记录根关节的“绑定姿势矩阵”和“逆绑定姿势矩阵”
bindPoseMatrices[0] = skeleton[0].localBindMatrix;
skeleton[0].bindPoseInvMatrix = bindPoseMatrices[0].inverse();

// 遍历除根关节外的其余所有关节
for (int i = 1; i < skeleton.length(); i++) {
// 获取父关节的骨骼编号
int parentId = get_parent_bone_index(i);

// “绑定姿势矩阵”
bindPoseMatrices[i] = skeleton[i].localBindMatrix * bindPoseMatrices[parentId];

// “逆绑定姿势矩阵”
joint.bindPoseInvMatrix = bindPoseMatrices[i].inverse();
}
}

combined_bone_transform_in_order(skeleton);

模型空间到骨骼空间的映射

假设模型中的一个顶点与一根骨骼相关联,那么只有当顶点采用骨骼空间的局部坐标来描述的时候,才能够通过通过骨骼的组合变换矩阵,映射到模型坐标系中。

但实际上,大多数模型内的所有顶点都是采用模型坐标系存储的,即使模型在设计的时候,根据骨架对顶点进行了分组,也是如此,因此模型的所有顶点都不能直接应用骨骼的局部变换。

model_head_vertex_group

头部网格的顶点根据头部骨骼分为了一组,但这些顶点都是采用模型坐标系存储的
note:这个模型示例,使用了面来表示,并没有直接显示顶点

之所以这么做,主要是为了灵活性,如果顶点采用了骨骼空间的局部坐标值来存储,意味着模型与骨架之间存在着耦合关系,会导致可移植性较差,模型想要套用别的骨架就需要先转换,反过来,骨架也一样不能直接被其它模型使用。

进一步来说,骨骼动画通常都是根据骨架来设计的,如果骨架与模型耦合,就不能轻易地将这些骨骼动画套用到不同的模型上使用,而采用模型与骨架解耦合的方式,就可将对模型的建模以及动画的设计分离开来,只要骨架匹配,就能让动画适用不同模型。

基于此,为了让模型的顶点能够应用骨骼的变换,就需要先将其从模型坐标系映射到关联的骨骼局部空间中,可以用逆向思维来解决这个问题。

如何将骨骼空间中的点映射到模型坐标系的方法前面已经讲过了,简单说就是将骨骼自身的骨骼矩阵,以及其所有直系祖先关节的骨骼矩阵拼接成一个组合变换矩阵,然后用该矩阵为向量做变换。

由于组合变换矩阵是可逆的,我们可先计算出组合变换矩阵逆矩阵(inverse matrix),这样就可以反过来将模型坐标系的点映射到骨骼的局部空间中,不过到底采用哪个组合变换的逆矩阵来执行,还需要考虑。

由于组合变换矩阵是以 TRS 顺序结合的,所以其逆矩阵将会变成以下形式:

一根骨骼与其顶点组的关系应该是保持相对固定的,之所以说是相对的,是因为柔性蒙皮可以让顶点关联多根骨骼(顶点组)进行混合,基于这一点来看的话,要想通过逆矩阵来完成映射,就需要无论骨架姿势如何变化,顶点组内的坐标值与骨骼的变换得同步更新,不然无法将顶点准确地映射到具体的骨骼空间中

但实际上,一般不能直接去更新模型顶点数据中的坐标值,因为这些数据通常都是先存放在内存中的,如果存在着大量顶点,当顶点发生变换就直接去更新内存的话,就会导致大量的内存写入开销。所以通常的做法是,让内存中的顶点数据一直保持不变,一般为绑定姿势时的顶点坐标值,当顶点需要变换时,再把变换矩阵以及顶点向量传入到顶点着色器中完成,并将变换后的全局坐标向量用于后续的其它变换。

既然模型的顶点数据一直保持在绑定姿势时的全局坐标值,为了能够应用逆矩阵来进行映射,就需要让骨架也处于绑定姿势时的状态,那么前面所提到的逆绑定姿势矩阵(inverse bind pose matrix)就可以派上用场了。

综上,无论整幅骨架需要变换到什么样的姿势,都可以根据以下要点进行:

  1. 执行变换的顶点数据一直都是同一份,为所有顶点处于绑定姿势时的模型空间坐标

  2. 这些顶点会先通过关联骨骼的逆绑定姿势矩阵,从模型坐标系映射到对应的骨骼局部空间中,此时骨架和模型都处在绑定姿势

  3. 绑定姿势的骨骼空间中,应用局部骨骼变换,调整骨骼的姿势

  4. 完成局部变换后,再通过骨骼在新姿势下的组合变换矩阵(全局变换矩阵)映射回模型坐标系

因此,将模型空间中的顶点映射骨骼空间的做法,就是使用逆绑定姿势矩阵顶点坐标向量相乘,需要留意的点是,由于顶点数据一直保持为绑定姿势,所以在变换前后,顶点都处在绑定姿势下的,只是变换前位于模型坐标系中,变换后则位于骨骼局部坐标系。

表达公式如下: model_space_to_bone_space

《绑定姿势下,模型空间到骨骼空间的映射》

姿势调整

上一节已经明确的是,模型的顶点数据是保持不变的,为处于绑定姿势时的模型空间坐标值,因此姿势或动画都应该是相对于绑定姿势来设计的,也就是说,绑定姿势是设计骨骼动画的参考姿势(reference pose)

姿势或动画在设计完后,会导出一系列相对于绑定姿势的局部变换信息,这些变换是针对骨骼的局部空间来设计的,所以顶点需要映射到骨骼空间来完成这种局部的骨骼变换,再通过组合变换矩阵映射回模型空间,以完成全局的骨骼变换,从而使整个模型的姿势发生变形。

不过,对于那些完全静态的姿势来说,可能会直接在绑定姿势的时候就调整好了,或者说直接换了一幅骨架,那么这种变换可能就没必要考虑,所以以下的内容,基本都是相对于骨骼动画而言的。

骨骼动画在设计完后,会导出一系列关键帧(key frame),每个关键帧都包含了局部的变换信息,这些信息一般不会以矩阵的形式导出,为了节省空间,通常将平移缩放三维向量表示,旋转则用四元数表示。

关键帧中的变换信息可根据 TRS 顺序组合成一个关键帧矩阵(key frame matrix),但一般不会直接使用这样的矩阵,因为两个关键帧之间通常会有很多空白的非关键帧(non-key frame)需要填补,否则动画无法平滑过渡(smooth transition)

为此,需要根据当前动画时间(current animation time)的上一个关键帧以及下一个关键帧,进行插值(interpolate),如果骨骼存在关节约束的话也得考虑进去,计算好当前帧的变换状态后,再根据 TRS 顺序构建一个当前帧矩阵(current frame matrix),也可称为动画矩阵(animation matrix)

由于骨骼动画基本只需要调整骨骼的旋转状态,所以对关键帧插值的对象通常都是用于表示旋转的四元数,而四元数的插值一般采用球面线性插值(slerp,spherical linear interpolation)

以下给出骨骼结合姿势调整后,计算组合变换矩阵的计算方法和伪代码,表达公式如下: calc_current_pose_matrix

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 为了保持简单,之后的伪代码都采用递归的方式来计算

// 存储所有骨骼的组合变换矩阵
array<mat4> combinedMatrices;

class Frame {
// 获取指定骨骼的局部相对旋转,用四元数表示
quaternion get_rotation(bone joint);

// 获取当前关键帧的时间位置
int time();
};

// 动画驱动器
class animator {
// 获取当前时间的上一个关键帧
Frame get_prev_keyframe(int time);

// 获取当前时间的下一个关键帧
Frame get_next_keyframe(int time);

// 获取骨骼的当前帧矩阵
mat4 get_current_frame_matrix(int time, bone joint);
};

mat4 animator::get_current_frame_matrix(int time, bone joint)
{
Frame prevKeyFrame = this.get_prev_keyframe(time);
Frame nextKeyFrame = this.get_next_keyframe(time);

// 根据前后两个关键帧对当前帧进行插值
// 四元数通常采用球面线性插值(slerp, spherical linear interpolation)
quaternion currentFrameRotation = slerp(time, prev.time(), next.time(), prev.get_rotation(joint), next.get_rotation(joint));

// 将四元数转为矩阵并返回
return currentFrameRotation.to_matrix();
}

void combined_bone_transform(int time, bone joint, mat4 parent)
{
// 获取当前帧矩阵
mat4 currentFrameMatrix = animator.get_current_frame_matrix(time, joint);

// 将当前帧矩阵与局部绑定矩阵相乘,得到局部骨骼变换矩阵
mat4 boneMatrix = currentFrameMatrix * joint.localBindMatrix;

// 拼接当前骨骼在新姿势下的组合变换矩阵
mat4 combinedMatrix = boneMatrix * parent;

// 记录当前骨骼的组合变换矩阵
combinedMatrices[joint.id] = combinedMatrix;

// 递归所有子关节
for (child : joint.childs) {
combined_bone_transform(child, combinedMatrix)
}
}

combined_bone_transform(time, root, mat(1));

一些动画引擎(animation engine)可能会直接将当前帧矩阵与关节的局部绑定矩阵相乘然后返回给调用者,这样调用者就不用再考虑与局部绑定矩阵组合的问题了,并且仍然是骨骼空间内的局部变换,这个矩阵组合可直接用骨骼矩阵(bone matrix)来称呼,其它的叫法也有很多,比如:

  • 当前姿势矩阵(current pose matrix)
  • 动画姿势矩阵(animated pose matrix)
  • 局部骨骼变换矩阵(local bone transformation matrix)或简称局部矩阵(local matrix)
  • 骨骼姿势变换矩阵(bone pose transformation matrix)或简称骨骼姿势(bone pose)

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 大多数的实现方式差不多都是这种
// 使用这种方式后,其实骨骼对象就可以不用携带局部绑定矩阵了,可以直接由动画驱动器来维护

// 存储所有骨骼的组合变换矩阵
array<mat4> combinedMatrices;

// 动画驱动器
class animator {
// 获取骨骼的当前姿势矩阵
mat4 get_current_pose_matrix(int time, bone joint);
};

void combined_bone_transform(int time, bone joint, mat4 parent)
{
// 拼接当前骨骼在新姿势下的组合变换矩阵
mat4 combinedMatrix = animator.get_current_pose_matrix(time, joint) * parent;

// 记录当前骨骼的组合变换矩阵
combinedMatrices[joint.id] = combinedMatrix;

// 递归所有子关节
for (child : joint.childs) {
combined_bone_transform(child, combinedMatrix)
}
}

combined_bone_transform(time, root, mat(1));

以上给出了骨架在姿势调整后,骨骼的组合变换矩阵的计算方法,但还只是供骨骼局部坐标系的顶点使用的,要为模型坐标系的顶点做变换,需要将其与逆绑定姿势矩阵结合起来。

全局骨骼变换

这里用我认为比较合理的方式,为骨骼动画所涉及到的各种矩阵进行命名,并仅限于这篇文章之内,这些矩阵实际上并没有一个统一的命名方式。

矩阵命名别名范围描述
骨骼矩阵(bone matrix)局部骨骼变换矩阵(local bone transformation matrix)局部可用来描述骨骼处于任何姿势下,映射到其父关节的局部变换,这间接表明了骨骼在父关节坐标系下的空间位置,无论绑定姿势还是非绑定姿势,都可以使用这个名称来描述
局部绑定矩阵(local bind matrix)局部绑定姿势矩阵(local bind pose matrix)局部特指骨骼在处于绑定姿势时的骨骼矩阵,为关节自身固定不变的属性,描述了关节在绑定姿势下,相对于父关节的局部变换
绑定姿势矩阵(bind pose matrix)绑定形状矩阵(bind shape matrix)全局骨骼处于绑定姿势时的全局变换矩阵,此时模型没有任何变形
逆绑定姿势矩阵(inverse bind pose matrix)偏移矩阵(offset matrix)全局绑定姿势矩阵的逆矩阵,用来将位于模型坐标系的顶点,映射到骨骼的局部坐标系中
动画矩阵(animation matrix)当前帧矩阵(current frame matrix)局部每一帧动画相对于绑定姿势的骨骼空间所做的相对变换
当前姿势矩阵(current pose matrix)动画姿势矩阵(animated pose matrix)局部局部绑定矩阵与动画矩阵的组合,这个矩阵可以直接用来描述
组合变换矩阵(combined transformation matrix)全局变换矩阵(global transformation matrix)全局用于将骨骼空间中的顶点映射到模型全局坐标系中
父关节的组合矩阵(parent's combine matrix)全局可通过当前骨骼的局部变换矩阵与其父关节组合矩阵相乘来得到当前骨骼的组合变换矩阵
最终骨骼矩阵(final bone matrix)蒙皮矩阵(skinning matrix)全局组合变换矩阵逆绑定姿势矩阵的组合,用来为模型坐标系中的顶点执行骨骼变换

局部绑定矩阵(逆绑定姿势矩阵(是由每个关节自身携带且固定不变的,而绑定姿势矩阵一般不需要使用,其余的大多都是实时计算出来的。

假设模型坐标系中有一个顶点,其与某根骨骼相关联,可通过以下方法将该顶点映射到骨骼空间中: 此时,就可以在骨骼空间中,为顶点执行局部骨骼变换(local bone transformation)了,这会根据新姿势对顶点进行变换,不过变换后的顶点仍然位于骨骼空间中: 不过我们希望模型坐标系中的顶点,在根据当前帧的姿势进行变换后,仍位于模型坐标系中,这种变换被称为全局骨骼变换(global bone transformation),或简称骨骼变换(bone transformation)

因此,真正所需的矩阵应该是顶点所关联骨骼的最终骨骼矩阵(,这是在每次更新骨架姿势的时候,实时为每根骨骼计算出来的,刚性蒙皮柔性蒙皮的最终骨骼矩阵的计算方法都是一样的,如下: 伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 存储所有骨骼的最终骨骼矩阵
array<mat4> finalBoneMatrices;

void compute_bind_pose_inverse_transform(bone joint, mat4 parent)
{
// “绑定姿势矩阵”
mat4 bindPoseMatrix = joint.localBindMatrix * parent;

// “逆绑定姿势矩阵”
joint.bindPoseInvMatrix = bindPoseMatrix.inverse();

// 递归所有子关节
for (child : joint.childs) {
compute_bind_pose_inverse_transform(child, bindPoseMatrix)
}
}

void compute_final_bone_transform(int time, bone joint, mat4 parent)
{
// 获取当前帧矩阵(动画矩阵)
mat4 animMatrix = animator.get_current_frame_matrix(time, joint);

// 将当前帧矩阵与局部绑定矩阵相乘,得到局部骨骼变换矩阵(当前姿势矩阵)
mat4 poseMatrix = animMatrix * joint.localBindMatrix;

// 拼接当前骨骼的组合变换矩阵
mat4 combinedMatrix = poseMatrix * parent;

// 拼接并记录当前骨骼的最终骨骼矩阵
finalBoneMatrices[joint.id] = combinedMatrix * joint.bindPoseInvMatrix;

// 递归所有子关节
for (child : joint.childs) {
compute_final_bone_transform(time, child, combinedMatrix)
}
}

compute_bind_pose_inverse_transform(root, mat(1));
compute_final_bone_transform(time, root, mat(1));

为某个顶点执行骨骼变换的方法是,用其相关联骨骼的最终骨骼矩阵()与顶点的模型坐标向量相乘:

note!!!:刚性蒙皮的实现中,每个顶点只会受到一根骨骼的影响,因此可直接应用以上方法完成变换,但对于柔性蒙皮来说,由于其每个顶点都可关联多根骨骼的实现方式,所以得先用以上方式为顶点用所关联的每根骨骼分别计算变换,然后将所有结果进行加权混合,这部分内容到后续再讨论。

蒙皮动画

现在可以考虑怎么样为整个模型的所有顶点执行骨骼变换来驱动蒙皮动画了,可以把这个过程中,为所有顶点所执行的变换叫做蒙皮变换(skinning transformations),或者直接称为蒙皮(skinning)

刚性蒙皮和柔性蒙皮的每根骨骼的最终骨骼矩阵()的拼接方法都是相同的,只是执行蒙皮的方法不一样,我们这里直接把这个矩阵称为蒙皮矩阵(,skinning matrix)

在看伪代码之前,需要先考虑下应该在什么执行环境中进行蒙皮,以及一些优化问题。

执行环境与优化

首先要明白的是,现代的三维模型基本都存在着大量的顶点,并且与骨骼数一般不在一个量级,比如前面示例图中的人体模型,存在着 个顶点,但只有 根骨骼,显然顶点的数量远大于骨骼。

而骨骼与顶点,在执行蒙皮的过程中,扮演着不同的角色:

  • 骨骼为顶点执行蒙皮提供所需的变换矩阵,而每根骨骼的蒙皮矩阵()的拼接依赖于其父关节
  • 顶点是执行蒙皮的主体对象,虽然一些顶点可能会关联着同一根骨骼,但只要确定了蒙皮矩阵()即可,每个顶点的计算都是独立的,彼此不存在依赖问题,更重要的一点是, 每个顶点所执行的操作都是相同的,只是参数会不同

基于顶点对骨骼的依赖,以及它们数量级上的差异,可以先为所有骨骼计算好蒙皮矩阵()并收集起来,然后再为所有的顶点进行蒙皮。

从执行环境来看:

  • 蒙皮矩阵()的拼接,需要处理逻辑关系(如递归),这更适合在 CPU 上执行
  • 大批量顶点的骨骼变换,虽然参数不同,但都是些重复性操作,没有依赖问题,通常可以利用 GPU 所提供的硬件加速来并发执行

因此,整个模型的蒙皮可以分为两步进行:

  1. CPU 上为所有骨骼拼接蒙皮矩阵(,并根据骨骼编号收集为一个骨骼变换集(bone transformation set),这其实就是根据骨骼编号排列而成的一个矩阵数组,可叫做矩阵调色板(matrix palette)

    需要注意的是,蒙皮动画还需要考虑关键帧插值的问题,不过这里不讨论这个

  2. 为渲染管线构建并绑定一个顶点着色器(vertex shader),将矩阵调色板作为全局变量传入其中,而模型的所有顶点将以绑定姿势时的模型空间坐标向量流入渲染管线,它们会在顶点着色器中,根据自身关联的骨骼编号或骨骼编号数组,从矩阵调色板中提取蒙皮矩阵(来执行蒙皮,这一切由 GPU 并发完成,如果执行的是柔性蒙皮,顶点属性中还会携带权重数组,另外,顶点的法向量(normal vector)也需要进行变换,以适应光照的变化,变换方式与顶点向量类似

可以发现,这其实充分利用了 CPU 和 GPU 各自所擅长的领域, CPU 更适合处理逻辑控制,而 GPU 更擅长高并发地处理向量、矩阵之类的数学运算,它们的结合属于一种异构计算(heterogeneous computing)

顶点在顶点着色器中完成了骨骼变换后,仍处于模型坐标系下,需要根据其所关联的角色,进行世界变换,映射到世界坐标系中,并且还需要执行观察者变换、投影变换等一系列映射,其中一些映射可仍在着色器中完成,一些则在后续渲染流程中进行,不过,在此不关心这些。

这里其实还存在着一个问题,蒙皮矩阵()一般是一个 的矩阵,存在着 个元素,每个元素可能采用 类型,所以一个 的大小可能为 字节,而 GPU 顶点着色器能够接纳的全局变量大小通常是有限制的,如果一个骨架存在着大量的骨骼,其矩阵调色板的大小就有可能突破这个限制,有很多方法可避免这种问题,比如:

  • 通过合理设计减少骨骼数
  • 对模型进行预处理,将其分割为多个部分,使每个部分的骨骼数量不超过阈值,不过这会很复杂,也可能会带来一些额外问题
  • 将矩阵调色板封装为一张浮点型纹理(floating point texture),以传入纹理的方式来绕过全局变量的限制问题,通过对纹理采样(sampling)来获取矩阵,这是种比较灵活的做法,而且大多数设备都支持,在后面的伪代码中会给出一个这种做法的例子

此外,还需要注意这些变换的数据持久化(data persistence)形式,虽然文中对骨骼以及动画的描述都是采用矩阵的形式,但这仅是在应用阶段,它们保存在模型文件时,通常不会采用矩阵来存储,因为一个 的矩阵有 个元素,并不是每个元素都用得上,这样会造成空间浪费。

一般的做法是将平移(translation)缩放(scaling)各自用一个三维向量(3d vector)存储,旋转(rotation)则采用四元数(quaternion)存储,实际上,缩放对于骨骼以及动画来说都很少用,而动画基本仅使用旋转。

1
2
3
4
5
struct TRS {
vector3 translation;
quaternion rotation;
vector3 scaling;
};

等到实际应用的时候,以 TRS(translate,rotate,scale)的顺序将这些变换构造成矩阵:

刚性蒙皮动画

刚性蒙皮的执行方法非常简单,将顶点与其关联骨骼的蒙皮矩阵相乘即可。

顶点着色器伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 最大骨骼数
const int MAX_BONES = 100;

// 模型坐标系顶点
layout(location = 0) in vec3 position;

// 顶点法向量
layout(location = 1) in vec3 normal;

// 顶点关联骨骼的骨骼编号
layout(location = 2) in int boneId;

uniform mat4 model;
uniform mat4 projection;
uniform mat4 view;

uniform mat4 matrixPalette[MAX_BONES];

// 蒙皮后的法向量
out vec3 o_normal;

void main() {
// 需要注意矩阵的结合方向
mat4 mvp = projection * view * model;

// 每个顶点仅关联一根骨骼的刚性蒙皮
gl_Position = mvp * matrixPalette[boneId] * vec4(position, 1.0f);

// 更新法向量,为了简便,后续内容将会省略这个操作
o_normal = normalize(mat3(matrixPalette[boneId]) * normal);
}

这里给出一个采用浮点纹理存储矩阵调色板的顶点着色器示例,伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// 模型坐标系顶点
layout(location = 0) in vec3 position;

// 顶点关联骨骼的骨骼编号,本实现是刚性蒙皮,每个顶点只会与一根骨骼关联
layout(location = 1) in int boneId;

uniform mat4 model;
uniform mat4 projection;
uniform mat4 view;

// 骨骼数目
uniform float numBones;

// 矩阵调色板纹理
// 该纹理有以下条件:
// - 采用浮点纹理(floating point texture),每个通道(channel)使用一个float类型
// - 每个像素拥有R、G、B、A四个通道,即一个像素存在四个浮点值
// - 确保我们能够获取到纹理的原始数据,为该纹理进行以下处理:
// \ 关闭过滤(filter)
// \ 调整包裹模型(wrap)使uv坐标被钳制(clamp)在[0,1]范围内,超出的部分将被强制调整为0或1
// \ etc.
uniform sampler2D matrixPaletteTexture;

// 《像素坐标系与纹理坐标系的对应》
// - uv坐标为浮点数,取值范围为[0,1.0],起始位置在左下角
// - pixel坐标为整数,行列的取值范围分别为row:[0,height), col:[0,width)
// - u相当于纹理的column,v相当于纹理的row
// (0.0, 1.0) (1.0, 1.0)
// ↑ +-----+
// (image height) v | |
// +-----+
// (0.0, 0.0) u (1.0, 0.0)
// (image with) →
//
// 《像素坐标转纹理坐标》
// pix0 pix1 pix2 pix3
// +-----+-----+-----+-----+
// (one row) → | | | | |
// +-----+-----+-----+-----+
// (0.0, v) ↑ ↑ ↑ ↑ (1.0, v)
// 由于纹理原始的大小被归一化了,相当于每一个像素的分辨率被放大了,从一个确定的整数,变成了一个浮点范围
// 因此每个像素所对应的纹理坐标,可以选取其中间位置,计算方法为(pixel_coord + 0.5) / image_size
// 那么像素坐标到uv坐标的映射方式如下:
// u = (col + 0.5) / width
// v = (row + 0.5) / height
// 假设上图是一个原始大小为width: 4px,height:1px的纹理,那么pix2的uv坐标如下:
// pix2_u = (2.0 + 0.5) / 4.0 = 0.625
// pix2_v = (0.0 + 0.5) / 1.0 = 0.5


// 《矩阵调色板纹理的构造方式》
// - 由于每一个像素有四个float通道,一个像素刚好是矩阵一行的大小,因此我们可以使用四个像素来表示一个矩阵
// - 矩阵调色板设定成一张大小为width:4px,height:(numBones)px的纹理
// - 纹理的每一行代表了一根骨骼,根据骨骼的编号来排列
// - 每行的四个像素代表了一根骨骼的蒙皮矩阵
//
// 以下为一个拥有三根骨骼的矩阵调色板纹理布局例子
// +-----+-----+-----+-----+
// (bone2 matrix) → | | | | |
// +-----+-----+-----+-----+
// (bone1 matrix) → | | | | |
// +-----+-----+-----+-----+
// (bone0 matrix) → | | | | |
// +-----+-----+-----+-----+
// ↑ ↑ ↑ ↑

// 这些偏移量用于定位每行的4列像素
#define ROW_U0 ((0.0 + 0.5) / 4.0)
#define ROW_U1 ((1.0 + 0.5) / 4.0)
#define ROW_U2 ((2.0 + 0.5) / 4.0)
#define ROW_U3 ((3.0 + 0.5) / 4.0)

// 采样骨骼的蒙皮矩阵
mat4 getFinalBoneMatrix(float boneId) {
float v = (boneId + 0.5) / numBones;
return mat4(
texture2D(matrixPaletteTexture, vec2(ROW_U0, v)),
texture2D(matrixPaletteTexture, vec2(ROW_U1, v)),
texture2D(matrixPaletteTexture, vec2(ROW_U2, v)),
texture2D(matrixPaletteTexture, vec2(ROW_U3, v))
);
}

void main() {
mat4 mvp = projection * view * model;

// 每个顶点仅关联一根骨骼的刚性蒙皮
gl_Position = mvp * getFinalBoneMatrix(boneId) * vec4(position, 1.0f);
}

柔性蒙皮动画

由于柔性蒙皮关联着多根骨骼,需要将多根骨骼对顶点的影响进行混合,常用的混合方式有以下两种:

  • 线性混合蒙皮(LBS,linear blending skinning)
  • 对偶四元数蒙皮(DQS,dual quaternion skinning)

这里只考虑 LBS 的做法,这是多数实现的默认做法,其实就是每根骨骼都执行骨骼变换,然后将每个结果根据权重进行线性混合:

example from: WebGL Skinning

顶点着色器伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 关联骨骼数目
const int MAX_BONE_INFLUENCE = 4;

// 模型坐标系顶点
layout(location = 0) in vec3 position;

// 骨骼编号数组
layout(location = 1) in ivec4 boneIds;

// 权重数组
layout(location = 2) in vec4 weights;

uniform mat4 model;
uniform mat4 projection;
uniform mat4 view;

uniform mat4 matrixPalette[100];

void main() {
mat4 mvp = projection * view * model;

vec4 skinned = vec4(0.0f);
for (int i = 0; i < MAX_BONE_INFLUENCE; i++) {
skinned += matrixPalette[boneIds[i]] * vec4(position, 1.0f) * weights[i];
}

gl_Position = mvp * skinned;
}

其它动画技术

Intro_to_Sprite_Anims_cover
Intro_to_Sprite_Anims_cover
《精灵图动画(sprite animation)》
from: Introduction to Sprite Animations
grease-pencil_properties_onion-skinning_example
grease-pencil_properties_onion-skinning_example
《洋葱皮(onion skinning)》
from: Onion Skinning
franky_morphs
franky_morphs
《变形目标动画(morph target animation)》
from: Multi-target blending and blend shapes

skel2d25

《2D蒙皮动画(2D skinned animation)》
from: Multi-target blending and blend shapes

推荐阅读

下面这些文章可了解更多关于自动蒙皮算法的细节和实现思路:

请我喝瓶肥仔快乐水?

欢迎关注我的其它发布渠道