WebGL实现3D模型的骨骼动画,核心是通过骨骼层级变换影响模型顶点位置,结合蒙皮权重让顶点跟随多个骨骼运动,最终在着色器中完成顶点位置的实时计算。

骨骼动画基础概念
骨骼动画由两部分核心数据组成,分别是骨骼结构和蒙皮数据。骨骼是一个树形层级结构,每个骨骼对应一个变换矩阵,父骨骼的变换会影响所有子骨骼。蒙皮数据记录了每个模型顶点受哪些骨骼影响,以及对应的影响权重,权重总和通常为1。
动画播放时,会根据当前时间计算每一根骨骼的变换矩阵,再结合顶点的蒙皮权重,计算出顶点的最终位置。这个过程需要在GPU的顶点着色器中完成,才能保证动画的流畅性。
骨骼数据解析
通常3D模型会导出为glTF等格式,其中包含骨骼和动画的相关数据。我们需要先解析出骨骼的初始矩阵、层级关系,以及每一帧动画对应的骨骼变换矩阵。以下是一个简单的骨骼数据解析示例:
// 解析glTF中的骨骼数据
function parseSkeleton(glTFData) {
const skins = glTFData.skins;
const nodes = glTFData.nodes;
const skeletonList = [];
for (let i = 0; i < skins.length; i++) {
const skin = skins[i];
const skeleton = {
joints: [], // 骨骼节点列表
inverseBindMatrices: [], // 骨骼初始逆绑定矩阵
jointCount: skin.joints.length
};
// 获取骨骼层级节点
for (let j = 0; j < skin.joints.length; j++) {
const jointNode = nodes[skin.joints[j]];
skeleton.joints.push({
name: jointNode.name,
localMatrix: jointNode.matrix || mat4.create(), // 局部变换矩阵
worldMatrix: mat4.create(), // 世界变换矩阵
parent: null // 父骨骼索引
});
}
// 解析逆绑定矩阵,这里假设逆绑定矩阵数据存在accessors中
const inverseBindAccessor = glTFData.accessors[skin.inverseBindMatrices];
skeleton.inverseBindMatrices = inverseBindAccessor.data;
skeletonList.push(skeleton);
}
return skeletonList;
}
顶点着色器实现骨骼变换
骨骼动画的核心计算都在顶点着色器中完成,需要将骨骼变换矩阵和蒙皮权重作为属性传入着色器,计算每个顶点的最终位置。以下是顶点着色器的实现代码:
attribute vec3 a_position; // 顶点初始位置
attribute vec4 a_jointWeights; // 蒙皮权重,最多4个骨骼影响
attribute vec4 a_jointIndices; // 影响的骨骼索引
uniform mat4 u_jointMatrices[50]; // 骨骼变换矩阵数组,最多50根骨骼
uniform mat4 u_modelMatrix; // 模型矩阵
uniform mat4 u_viewMatrix; // 视图矩阵
uniform mat4 u_projectionMatrix; // 投影矩阵
void main() {
vec4 finalPosition = vec4(0.0, 0.0, 0.0, 0.0);
// 遍历影响当前顶点的4根骨骼,加权计算最终位置
for (int i = 0; i < 4; i++) {
float weight = a_jointWeights[i];
if (weight > 0.0) {
int jointIndex = int(a_jointIndices[i]);
// 骨骼变换矩阵乘以顶点位置,乘以权重累加
vec4 transformedPosition = u_jointMatrices[jointIndex] * vec4(a_position, 1.0);
finalPosition += transformedPosition * weight;
}
}
// 应用模型、视图、投影矩阵得到最终裁剪空间位置
gl_Position = u_projectionMatrix * u_viewMatrix * u_modelMatrix * finalPosition;
}
动画矩阵更新逻辑
在JavaScript端,需要根据当前动画时间更新每一根骨骼的变换矩阵,再将矩阵传递给着色器。以下是矩阵更新的示例代码:
// 更新骨骼变换矩阵
function updateJointMatrices(skeleton, animation, currentTime) {
const joints = skeleton.joints;
const jointMatrices = [];
// 先计算每根骨骼的当前局部变换矩阵(根据动画帧插值得到)
for (let i = 0; i < joints.length; i++) {
const joint = joints[i];
// 这里简化动画插值逻辑,实际需要根据动画的关键帧数据计算
const animFrame = getAnimationFrame(animation, i, currentTime);
const localMatrix = animFrame.matrix;
joint.localMatrix = localMatrix;
}
// 计算每根骨骼的世界变换矩阵,父骨骼矩阵影响子骨骼
for (let i = 0; i < joints.length; i++) {
const joint = joints[i];
if (joint.parent === null) {
// 根骨骼世界矩阵等于局部矩阵
mat4.copy(joint.worldMatrix, joint.localMatrix);
} else {
// 子骨骼世界矩阵 = 父骨骼世界矩阵 * 子骨骼局部矩阵
mat4.multiply(joint.worldMatrix, joints[joint.parent].worldMatrix, joint.localMatrix);
}
// 最终骨骼变换矩阵 = 世界矩阵 * 逆绑定矩阵
const finalJointMatrix = mat4.create();
mat4.multiply(finalJointMatrix, joint.worldMatrix, skeleton.inverseBindMatrices[i]);
jointMatrices.push(finalJointMatrix);
}
return jointMatrices;
}
// 将骨骼矩阵传递给着色器
function setJointMatrices(gl, program, jointMatrices) {
const jointMatrixLocation = gl.getUniformLocation(program, 'u_jointMatrices');
// 将矩阵数组扁平化后传入
const flatMatrices = [];
for (let i = 0; i < jointMatrices.length; i++) {
flatMatrices.push(...jointMatrices[i]);
}
gl.uniformMatrix4fv(jointMatrixLocation, false, new Float32Array(flatMatrices));
}
完整渲染流程
整合以上步骤,完整的骨骼动画渲染流程如下:
- 加载3D模型,解析骨骼、蒙皮、动画数据
- 初始化WebGL上下文、着色器程序、缓冲区
- 每一帧根据当前时间计算骨骼变换矩阵,传递给着色器
- 绑定模型顶点属性,执行绘制命令
- 循环更新动画时间,实现连续动画效果
需要注意骨骼数量不能超过着色器中定义的数组长度,蒙皮权重通常最多取4个影响最大的骨骼,避免性能损耗。如果模型顶点数量较多,还需要考虑使用顶点缓冲区对象批量传递数据,提升渲染效率。