3 - 使用对偶四元数插值
在看完 GAMES104 动画系统那一部分后,我也想写一个简单的动画系统,又要开一个坑了!本文将简要介绍如何用对偶四元数给蒙皮动画插值。
对偶四元数
背景
我们在之前用的是直接四元数蒙皮(Direct Quaternion Blending)方法,它将变换拆成两部分,平移和缩放用矩阵表示,旋转用四元数表示,可能会导致一些严重的断裂现象;而使用对偶四元数插值平移和旋转,缩放用矩阵表示则会使插值结果流畅。
数学原理
一个对偶四元数
然后对于蒙皮动画,只需知道对偶四元数的加法即可,有对偶四元数
存储数据
对于两个四元数,有如下性质:
- 进行加法后归一化,就能得到两个四元数的均值。很适合顶点的旋转。
- 进行加法后不归一化,相当于两个四维向量相加。很适合顶点的平移。
因此可以将旋转和平移存储为四元数,用一个对偶四元数来管理。首先是作为实部的
此外,对偶四元数可以用矩阵表示:
代码实现
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 ); }
其中,矩阵转换的格式如下:
那么就能通过如下形式变换顶点了:
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 来存储更多的关键帧信息!
参考资料
- 蒙皮算法之对偶四元数 (Skinning algorithm for dual Quaternions) - 知乎 (zhihu.com)
- 《C++ Game Animation Programming》