3 - 使用对偶四元数插值

在看完 GAMES104 动画系统那一部分后,我也想写一个简单的动画系统,又要开一个坑了!本文将简要介绍如何用对偶四元数给蒙皮动画插值。

对偶四元数

背景

我们在之前用的是直接四元数蒙皮(Direct Quaternion Blending)方法,它将变换拆成两部分,平移和缩放用矩阵表示,旋转用四元数表示,可能会导致一些严重的断裂现象;而使用对偶四元数插值平移和旋转,缩放用矩阵表示则会使插值结果流畅。

数学原理

一个对偶四元数 dq 由两个四元数 p,q 和一个 ϵ 组成: dq=p+ϵq, ϵ2=0  ϵ0 其中 p 是实部 (real),q 是对偶部 (dual)。

然后对于蒙皮动画,只需知道对偶四元数的加法即可,有对偶四元数 dq1 dq2 如下: dq1=p1+ϵq1dq2=p2+ϵq2 那么它俩的和如下: dq1+dq2=(p1p2)+ϵ(q1+q2)

存储数据

对于两个四元数,有如下性质:

  • 进行加法后归一化,就能得到两个四元数的均值。很适合顶点的旋转。
  • 进行加法后不归一化,相当于两个四维向量相加。很适合顶点的平移。

因此可以将旋转和平移存储为四元数,用一个对偶四元数来管理。首先是作为实部的 p,用于存储旋转: p(ϕ)=cos(ϕ2)+sin(ϕ2)i+sin(ϕ2)j+sin(ϕ2)k 然后将平移存储到对偶四元数的虚部 q 中: q(t)=(tx2i+ty2j+tz2k)p 那么平移信息可由如下计算得到: t=2qp 需要注意的是,在最后别忘了归一化对偶四元数,否则会出现一些模型上的变形。

此外,对偶四元数可以用矩阵表示: ϵ=[0101]a+bϵ=[a0ba]

代码实现

glm 数学库

glm 在 <glm/gtx/dual_quaternion.hpp> 中实现了对偶四元数:

glm::dualquat dq;

可用数组索引的方式来获取对偶四元数的实部和虚部:

glm::quat p = dq[0];
glm::quat q = dq[1];

由于 GLSL 不支持四元数和对偶四元数,需要在 CPU 侧将四元数转换成 2x4 矩阵,然后传给 GPU。glm 通过 glm::mat2x4_cast() 将一个对偶四元数转换为一个 2x4 矩阵:

glm::mat2x4 dqMat = glm::mat2x4_cast(dq);

使用对偶四元数

Animator 类中,添加一个新的成员变量,用于存储关节的对偶四元数:

std::vector<glm::mat2x4> m_boneDualQuaternions;

这里使用 glm::mat2x4 的原因是 Shader 只接受这种类型的四元数。

然后别忘了在构造函数中初始化它的大小:

m_boneDualQuaternions.resize(100);

由于使用 uniform 传递变量,数组元素最多 100 个,对于一些动画关键帧很多的模型无法使用。在下一篇文章中我们将使用 着色器存储缓冲对象 SSBO 来解除这一限制,此外也能预先将关键帧信息存入纹理,然后采样。

接下来需要通过 glm::decompose() 将计算好的蒙皮矩阵拆解,结果会存储到下列临时变量中,其中我们只用到旋转和平移:

   
// 使用对偶四元数 // 1. 准备临时变量 glm::quat orientation; glm::vec3 scale; glm::vec3 translation; glm::vec3 skew; glm::vec4 perspective; // 2. 拆解蒙皮矩阵 if (glm::decompose(m_finalBoneMatrices[index], scale, orientation, translation, skew, perspective)) { glm::dualquat dq; dq[0] = orientation; dq[1] = glm::quat(0.0, translation.x, translation.y, translation.z) * orientation * 0.5f; m_boneDualQuaternions[index] = glm::mat2x4_cast(dq); } else { LOG_WARN(std::format("[{}] Could not decompose skinning matrix for bone {}, use direct quat instead for animation...", __FUNCTION__, index)); m_useDualQuaternion = false; }

修改着色器

CPU 端有数据以后,就该修改着色器代码了,首先添加对应的 uniform 变量:

uniform mat2x4 u_FinalBonesDQs[MAX_BONES];

然后写一个新的函数 getBoneTransform() 获取加权且插值后的对偶四元数:

   
// 获取加权且插值后的对偶四元数 mat2x4 getBoneTransform() { // 获取影响该顶点的对偶四元数 mat2x4 dq0 = u_FinalBonesDQs[boneIds.x]; mat2x4 dq1 = u_FinalBonesDQs[boneIds.y]; mat2x4 dq2 = u_FinalBonesDQs[boneIds.z]; mat2x4 dq3 = u_FinalBonesDQs[boneIds.w]; // 根据最短旋转路径加权, 防止动作发生突变 vec4 shortestWeights = weights; shortestWeights.y *= sign(dot(dq0[0], dq1[0])); shortestWeights.z *= sign(dot(dq0[0], dq2[0])); shortestWeights.w *= sign(dot(dq0[0], dq3[0])); // 得到加权插值后的对偶四元数, 别忘了标准化 mat2x4 result = shortestWeights.x * dq0 + shortestWeights.y * dq1 + shortestWeights.z * dq2 + shortestWeights.w * dq3; float norm = length(result[0]); return result / norm; }

有了对偶四元数后,还需要将其转换为矩阵,通过新的函数 getSkinMatFromDQ() 实现:

   
mat4 getSkinMatFromDQ() { mat2x4 boneDQ = getBoneTransform(); vec4 r = boneDQ[0]; // 旋转 vec4 t = boneDQ[1]; // 平移 return mat4( 1.0 - (2.0 * r.y * r.y) - (2.0 * r.z * r.z), (2.0 * r.x * r.y) + (2.0 * r.w * r.z), (2.0 * r.x * r.z) - (2.0 * r.w * r.y), 0.0, (2.0 * r.x * r.y) - (2.0 * r.w * r.z), 1.0 - (2.0 * r.x * r.x) - (2.0 * r.z * r.z), (2.0 * r.y * r.z) + (2.0 * r.w * r.x), 0.0, (2.0 * r.x * r.z) + (2.0 * r.w * r.y), (2.0 * r.y * r.z) - (2.0 * r.w * r.x), 1.0 - (2.0 * r.x * r.x) - (2.0 * r.y * r.y), 0.0, 2.0 * (-t.w * r.x + t.x * r.w - t.y * r.z + t.z * r.y), 2.0 * (-t.w * r.y + t.x * r.z + t.y * r.w - t.z * r.x), 2.0 * (-t.w * r.z - t.x * r.y + t.y * r.x + t.z * r.w), 1 ); }

其中,矩阵转换的格式如下: T=[txRtytz0001] R 是旋转矩阵,由四元数转换而成。

那么就能通过如下形式变换顶点了:

   
mat4 skinMat = getSkinMatFromDQ(); vs_objData.Normal = u_Normal * normalize(mat3(skinMat) * normal); vs_objData.FragPos = vec3(u_Model * skinMat * vec4(position, 1.0)); vs_objData.TexCoords = texCoords; gl_Position = u_MVP * skinMat * vec4(position, 1.0);

这样应该就能用对偶四元数进行蒙皮动画插值了,效果应该会更好。在下一篇文章中我们将打破 uniform 数组的限制,使用着色器存储缓存对象 SSBO 来存储更多的关键帧信息!

参考资料