骨骼动画与变形动画
深入探讨 Three.js 中的骨骼动画(Skeletal Animation)和变形动画(Morph Target Animation)技术。
概述
骨骼动画和变形动画是角色动画和复杂物体动画的两种主要技术。骨骼动画通过骨骼结构控制网格变形,变形动画通过在预定义的形态之间插值实现动画。
架构概览
plaintext
高级动画技术架构
│
├── 骨骼动画(Skeletal Animation)
│ ├── 核心组件
│ │ ├── Bone(骨骼)
│ │ ├── Skeleton(骨骼系统)
│ │ └── SkinnedMesh(蒙皮网格)
│ │
│ ├── 数据绑定
│ │ ├── skinIndex(骨骼索引)
│ │ └── skinWeight(骨骼权重)
│ │
│ └── 应用场景
│ ├── 角色动画
│ ├── 机械臂
│ └── 生物运动
│
└── 变形动画(Morph Target Animation)
├── 核心组件
│ ├── morphAttributes(变形属性)
│ └── morphTargetInfluences(变形权重)
│
├── 实现方式
│ └── 顶点位置插值
│
└── 应用场景
├── 表情动画
├── 形态变化
└── 嘴型同步技术对比
| 特性 | 骨骼动画 | 变形动画 |
|---|---|---|
| 实现原理 | 骨骼驱动网格变形 | 顶点位置插值 |
| 内存占用 | 低(骨骼数据小) | 高(存储多个形态) |
| 动画复杂度 | 高(支持复杂骨骼) | 中(预定义形态) |
| 实时控制 | 强(程序化控制骨骼) | 弱(基于权重混合) |
| 适用场景 | 角色肢体运动 | 表情、形态变化 |
| 学习曲线 | 陡峭 | 平缓 |
骨骼动画基础
骨骼系统结构
plaintext
骨骼层级结构示意
根骨骼 (Root)
│
┌───┴───┐
│ │
脊柱 臀部
│ │
┌─┴─┐ ┌┴┐
│ │ │ │
颈部 腰部 左腿 右腿
│ │
头部 ┌─┴─┐
│ │
小腿 小腿
│
脚部骨骼系统核心类
Bone 类
| 方法/属性 | 说明 |
|---|---|
constructor() | 创建骨骼实例 |
isBone | 只读,标识为骨骼对象 |
name | 骨骼名称,用于动画绑定 |
parent | 父骨骼引用 |
children | 子骨骼数组 |
position/rotation/scale | 相对于父骨骼的变换 |
getWorldPosition(target) | 获取世界坐标位置 |
getWorldQuaternion(target) | 获取世界四元数旋转 |
Skeleton 类
| 方法/属性 | 说明 |
|---|---|
constructor(bones, boneInverses) | 创建骨骼系统 |
bones | 骨骼数组 |
boneInverses | 骨骼逆矩阵数组 |
boneMatrices | 骨骼矩阵纹理数据 |
boneTexture | 骨骼矩阵纹理 |
update() | 更新骨骼矩阵 |
pose() | 设置为绑定姿势 |
getBoneByName(name) | 按名称获取骨骼 |
SkinnedMesh 类
| 方法/属性 | 说明 |
|---|---|
constructor(geometry, material) | 创建蒙皮网格 |
bind(skeleton, bindMatrix) | 绑定骨骼系统 |
normalizeSkinWeights() | 归一化骨骼权重 |
pose() | 应用绑定姿势 |
skeleton | 骨骼系统引用 |
bindMode | 绑定模式 |
bindMatrix | 绑定矩阵 |
bindMatrixInverse | 绑定逆矩阵 |
创建骨骼系统
javascript
import * as THREE from 'three';
// 创建骨骼
const bone1 = new THREE.Bone();
const bone2 = new THREE.Bone();
const bone3 = new THREE.Bone();
// 建立骨骼层级
bone1.add(bone2);
bone2.add(bone3);
// 设置骨骼位置
bone1.position.set(0, 0, 0);
bone2.position.set(0, 1, 0); // 相对于父骨骼
bone3.position.set(0, 1, 0);
// 创建骨骼数据
const bones = [bone1, bone2, bone3];
const skeleton = new THREE.Skeleton(bones);
// 绑定骨骼到网格
geometry.userData.bones = bones;
geometry.userData.skeleton = skeleton;SkinnedMesh 创建流程
plaintext
创建蒙皮网格流程
1. 创建几何体
│
▼
2. 创建骨骼层级
│
▼
3. 计算骨骼权重 ──────────────────┐
(skinIndex, skinWeight) │
│ │
▼ │
4. 创建材质(skinning: true) │
│ │
▼ │
5. 创建 SkinnedMesh │
│ │
▼ │
6. 添加根骨骼到 Mesh ◄────────────┘
│
▼
7. 绑定 Skeleton
│
▼
8. 完成蒙皮网格SkinnedMesh 完整示例
javascript
// 创建骨骼动画网格
const geometry = new THREE.CylinderGeometry(0.1, 0.1, 2, 8, 10, false);
// 创建骨骼
const bones = [];
const boneCount = 5;
for (let i = 0; i < boneCount; i++) {
const bone = new THREE.Bone();
bone.position.y = i === 0 ? 0 : 0.4;
bones.push(bone);
if (i > 0) {
bones[i - 1].add(bone);
}
}
const skeleton = new THREE.Skeleton(bones);
// 创建蒙皮网格
const material = new THREE.MeshStandardMaterial({
color: 0x00ff00,
skinning: true // 启用蒙皮
});
const mesh = new THREE.SkinnedMesh(geometry, material);
mesh.add(bones[0]); // 添加根骨骼
mesh.bind(skeleton); // 绑定骨骼
scene.add(mesh);骨骼权重详解
plaintext
骨骼权重计算示意
顶点 V 的位置计算:
FinalPosition = Σ (Weight_i × BoneMatrix_i × VertexPosition)
示例:顶点受 2 根骨骼影响
┌──────────────────────────────────────┐
│ 骨骼索引: [1, 2, 0, 0] │
│ 骨骼权重: [0.7, 0.3, 0, 0] │
│ │
│ 最终位置 = 0.7 × Bone1 × V + │
│ 0.3 × Bone2 × V │
└──────────────────────────────────────┘
注意:
- 每个顶点最多受 4 根骨骼影响
- 权重总和应为 1.0设置骨骼权重
javascript
// 设置骨骼权重(皮肤权重)
const position = geometry.attributes.position;
const skinIndices = [];
const skinWeights = [];
for (let i = 0; i < position.count; i++) {
const y = position.getY(i);
// 计算受影响的骨骼
const boneIndex = Math.floor((y + 1) * 2);
const boneWeight = ((y + 1) * 2) % 1;
// 每个顶点最多受 4 个骨骼影响
skinIndices.push(boneIndex, boneIndex + 1, 0, 0);
skinWeights.push(1 - boneWeight, boneWeight, 0, 0);
}
// 设置属性
geometry.setAttribute(
'skinIndex',
new THREE.Uint16BufferAttribute(skinIndices, 4)
);
geometry.setAttribute(
'skinWeight',
new THREE.Float32BufferAttribute(skinWeights, 4)
);TIP
骨骼权重决定了顶点受骨骼影响的程度。平滑的权重分配可以产生自然的变形效果。
手动控制骨骼
javascript
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
// 旋转骨骼
bones[1].rotation.z = Math.sin(time) * 0.5;
bones[2].rotation.z = Math.cos(time * 1.5) * 0.3;
renderer.render(scene, camera);
}从模型加载骨骼动画
GLTF 骨骼模型
javascript
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
loader.load('character.glb', (gltf) => {
const model = gltf.scene;
scene.add(model);
// 获取骨骼信息
const skinnedMesh = model.getObjectByProperty('type', 'SkinnedMesh');
const skeleton = skinnedMesh.skeleton;
// 遍历骨骼
skeleton.bones.forEach(bone => {
console.log('骨骼:', bone.name);
});
// 播放动画
const mixer = new THREE.AnimationMixer(model);
const actions = gltf.animations.map(clip => mixer.clipAction(clip));
actions[0].play();
model.userData.mixer = mixer;
});骨骼动画控制
javascript
// 获取特定骨骼
function findBone(root, name) {
let result = null;
root.traverse((child) => {
if (child.isBone && child.name === name) {
result = child;
}
});
return result;
}
const armBone = findBone(model, 'leftArm');
// 控制骨骼
function animate() {
requestAnimationFrame(animate);
// 旋转手臂
armBone.rotation.x = Math.sin(time) * 0.5;
renderer.render(scene, camera);
}骨骼辅助器
javascript
import { SkeletonHelper } from 'three/addons/helpers/SkeletonHelper.js';
// 创建骨骼辅助器
const skeletonHelper = new SkeletonHelper(model);
scene.add(skeletonHelper);
// 更新辅助器
function animate() {
requestAnimationFrame(animate);
skeletonHelper.update();
renderer.render(scene, camera);
}变形动画(Morph Targets)
原理图示
plaintext
变形动画原理
基础形态 目标形态 1 目标形态 2
○ ◐ ●
/|\ / | \ / | \
| | |
/ \ / \ / \
│ │ │
└─────────┬───────┴─────────────────┘
│
▼
morphTargetInfluences
[0.3, 0.7] 权重数组
│
▼
混合结果
◑
/│\
│
/ \Morph Targets API 参考
| 属性/方法 | 说明 |
|---|---|
geometry.morphAttributes.position | 变形目标位置数组 |
geometry.morphAttributes.normal | 变形目标法线数组 |
mesh.morphTargetInfluences | 变形权重数组(0-1) |
mesh.morphTargetDictionary | 变形目标名称到索引的映射 |
material.morphTargets | 是否启用变形目标 |
material.morphNormals | 是否启用变形法线 |
创建变形目标
javascript
// 基础几何体
const geometry = new THREE.BoxGeometry(2, 2, 2, 8, 8, 8);
// 创建变形目标
const morphTargets = [];
// 目标 1:膨胀
const morphTarget1 = new THREE.BoxGeometry(2.5, 2.5, 2.5, 8, 8, 8);
morphTargets.push({
name: 'inflate',
positions: morphTarget1.attributes.position.array
});
// 目标 2:扭曲
const morphTarget2 = geometry.clone();
const positions2 = morphTarget2.attributes.position;
for (let i = 0; i < positions2.count; i++) {
const x = positions2.getX(i);
const y = positions2.getY(i);
const z = positions2.getZ(i);
// 扭曲变形
const angle = y * 0.5;
positions2.setXYZ(
i,
x * Math.cos(angle) - z * Math.sin(angle),
y,
x * Math.sin(angle) + z * Math.cos(angle)
);
}
morphTargets.push({
name: 'twist',
positions: morphTarget2.attributes.position.array
});
// 设置变形目标
geometry.morphAttributes.position = morphTargets.map(target =>
new THREE.Float32BufferAttribute(target.positions, 3)
);
// 创建材质
const material = new THREE.MeshStandardMaterial({
color: 0x00ff00,
morphTargets: true
});
// 创建网格
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);控制变形权重
javascript
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
// 设置变形权重
mesh.morphTargetInfluences[0] = (Math.sin(time) + 1) / 2; // 膨胀
mesh.morphTargetInfluences[1] = (Math.cos(time) + 1) / 2; // 扭曲
renderer.render(scene, camera);
}WARNING
变形权重的总和不一定需要为 1,可以同时激活多个变形目标产生叠加效果。
变形动画示例:表情动画
javascript
// 表情动画
function createFace() {
const geometry = new THREE.SphereGeometry(1, 32, 32);
// 正常表情
const normalPositions = geometry.attributes.position.array.slice();
// 微笑表情
const smilePositions = geometry.attributes.position.array.slice();
// 修改嘴角位置...
// 惊讶表情
const surprisePositions = geometry.attributes.position.array.slice();
// 修改嘴和眼睛位置...
geometry.morphAttributes.position = [
new THREE.Float32BufferAttribute(normalPositions, 3),
new THREE.Float32BufferAttribute(smilePositions, 3),
new THREE.Float32BufferAttribute(surprisePositions, 3)
];
// 设置变形目标名称
geometry.morphTargetsRelative = false;
const material = new THREE.MeshStandardMaterial({
color: 0xffcc99,
morphTargets: true
});
const mesh = new THREE.Mesh(geometry, material);
// 控制表情
mesh.morphTargetInfluences = [1, 0, 0]; // 正常
return mesh;
}
// 切换表情
function setExpression(mesh, expression) {
// 表情:'normal', 'smile', 'surprise'
const index = ['normal', 'smile', 'surprise'].indexOf(expression);
mesh.morphTargetInfluences = [0, 0, 0];
mesh.morphTargetInfluences[index] = 1;
}变形动画关键帧
javascript
// 创建变形动画关键帧
const morphTrack = new THREE.NumberKeyframeTrack(
'.morphTargetInfluences[0]', // 属性路径
[0, 1, 2, 3], // 时间
[0, 1, 1, 0] // 值
);
const clip = new THREE.AnimationClip('morph', 3, [morphTrack]);
const mixer = new THREE.AnimationMixer(mesh);
const action = mixer.clipAction(clip);
action.play();组合骨骼和变形
javascript
// 骨骼动画 + 变形动画
const geometry = new THREE.SphereGeometry(1, 32, 32);
// 设置骨骼
// ...
// 设置变形目标
geometry.morphAttributes.position = [
// ...
];
const material = new THREE.MeshStandardMaterial({
color: 0x00ff00,
skinning: true, // 启用骨骼
morphTargets: true // 启用变形
});
const mesh = new THREE.SkinnedMesh(geometry, material);plaintext
骨骼 + 变形组合应用
角色模型
├── 骨骼动画
│ ├── 肢体运动
│ ├── 走路/跑步
│ └── 动作切换
│
└── 变形动画
├── 面部表情
├── 眨眼
└── 嘴型同步
组合效果:身体动作 + 面部表情从模型加载变形动画
javascript
loader.load('model.glb', (gltf) => {
const model = gltf.scene;
scene.add(model);
// 获取变形目标
model.traverse((child) => {
if (child.isMesh && child.geometry.morphAttributes.position) {
console.log('变形目标数量:', child.geometry.morphAttributes.position.length);
console.log('变形权重:', child.morphTargetInfluences);
console.log('变形字典:', child.morphTargetDictionary);
}
});
// 播放变形动画
const mixer = new THREE.AnimationMixer(model);
gltf.animations.forEach(clip => {
const action = mixer.clipAction(clip);
action.play();
});
model.userData.mixer = mixer;
});骨骼动画高级应用
逆运动学(IK)
plaintext
IK 原理示意
目标位置 (Target)
★
│
│ ┌───┐
│ │ │
└──►│ │ 骨骼链
│ │
└───┘
算法:从末端骨骼开始,逐个调整角度
使末端指向目标位置javascript
// 简单的 IK 求解(CCD-IK)
function solveIK(joints, target, iterations = 10) {
for (let i = 0; i < iterations; i++) {
// 从末端到根部遍历
for (let j = joints.length - 1; j >= 0; j--) {
const joint = joints[j];
const endEffector = joints[joints.length - 1].getWorldPosition(new THREE.Vector3());
// 计算方向
const toTarget = new THREE.Vector3().subVectors(target, joint.getWorldPosition(new THREE.Vector3()));
const toEnd = new THREE.Vector3().subVectors(endEffector, joint.getWorldPosition(new THREE.Vector3()));
// 旋转骨骼
const quaternion = new THREE.Quaternion().setFromUnitVectors(
toEnd.normalize(),
toTarget.normalize()
);
joint.quaternion.premultiply(quaternion);
joint.updateMatrixWorld(true);
}
}
}
// 使用 IK
const bones = [bone1, bone2, bone3];
const target = new THREE.Vector3(2, 2, 0);
solveIK(bones, target);骨骼物理
javascript
// 简单的骨骼弹簧物理
const bonePhysics = [];
bones.forEach(bone => {
bonePhysics.push({
velocity: new THREE.Vector3(),
spring: 0.1,
damping: 0.8
});
});
function updateBonePhysics(delta) {
bones.forEach((bone, i) => {
const physics = bonePhysics[i];
// 弹簧力
const targetRotation = new THREE.Euler(0, 0, Math.sin(time + i) * 0.5);
const springForce = new THREE.Vector3(
targetRotation.x - bone.rotation.x,
targetRotation.y - bone.rotation.y,
targetRotation.z - bone.rotation.z
).multiplyScalar(physics.spring);
// 更新速度
physics.velocity.add(springForce);
physics.velocity.multiplyScalar(physics.damping);
// 应用旋转
bone.rotation.x += physics.velocity.x;
bone.rotation.y += physics.velocity.y;
bone.rotation.z += physics.velocity.z;
});
}性能优化
骨骼数量建议
| 平台 | 建议骨骼数 | 说明 |
|---|---|---|
| 移动端低端 | ≤ 20 | 基础角色 |
| 移动端高端 | 20-50 | 复杂角色 |
| 桌面端 | 50-100 | 高精度角色 |
| 高端 PC | > 100 | 电影级模型 |
javascript
// 骨骼 LOD
function updateSkeletonLOD(camera, models) {
models.forEach(model => {
const distance = camera.position.distanceTo(model.position);
if (distance < 10) {
// 高细节动画
model.activeAction = model.highDetailAction;
} else if (distance < 30) {
// 中等细节
model.activeAction = model.mediumDetailAction;
} else {
// 低细节
model.activeAction = model.lowDetailAction;
}
});
}变形目标数量建议
| 平台 | 建议变形目标数 | 说明 |
|---|---|---|
| 移动端 | 4-8 | 基础表情 |
| 桌面端 | 8-16 | 复杂表情 |
| 高端 PC | > 16 | 电影级表情 |
内存优化
javascript
// 减少变形目标内存占用
// 1. 使用 morphTargetsRelative(增量模式)
geometry.morphTargetsRelative = true;
// 2. 只存储与基础形态不同的顶点
// 3. 使用压缩格式存储变形数据常见问题解答(FAQ)
Q: 骨骼动画不生效,网格没有变形?
A: 检查以下几点:
javascript
// 1. 材质必须启用 skinning
const material = new THREE.MeshStandardMaterial({
skinning: true // 必须为 true
});
// 2. 使用 SkinnedMesh 而非 Mesh
const mesh = new THREE.SkinnedMesh(geometry, material);
// 3. 正确绑定骨骼
mesh.add(bones[0]); // 添加根骨骼
mesh.bind(skeleton); // 绑定骨骼系统
// 4. 确保有骨骼权重数据
console.log(geometry.attributes.skinIndex);
console.log(geometry.attributes.skinWeight);Q: 骨骼变形出现"糖果纸"扭曲效果?
A: 这通常是因为骨骼权重分配不当。确保:
- 权重在关节处平滑过渡
- 权重总和为 1
- 使用
normalizeSkinWeights()方法
javascript
// 归一化骨骼权重
mesh.normalizeSkinWeights();Q: 变形目标没有效果?
A: 检查以下几点:
javascript
// 1. 材质启用 morphTargets
const material = new THREE.MeshStandardMaterial({
morphTargets: true // 必须为 true
});
// 2. 变形目标已正确设置
console.log(geometry.morphAttributes.position); // 应该有数据
// 3. morphTargetInfluences 已初始化
console.log(mesh.morphTargetInfluences); // 应该有数据
// 4. 顶点数量匹配
const baseCount = geometry.attributes.position.count;
const morphCount = geometry.morphAttributes.position[0].count;
console.log(baseCount === morphCount); // 应该为 trueQ: 如何调试骨骼动画?
A: 使用 SkeletonHelper 可视化骨骼:
javascript
import { SkeletonHelper } from 'three/addons/helpers/SkeletonHelper.js';
const helper = new SkeletonHelper(model);
scene.add(helper);
// 在动画循环中更新
function animate() {
helper.update();
// ...
}
// 打印骨骼信息
model.traverse((child) => {
if (child.isBone) {
console.log(`骨骼: ${child.name}`, child.position, child.rotation);
}
});Q: 如何让角色看向特定方向?
A: 使用骨骼的 lookAt 方法:
javascript
// 获取头部骨骼
const headBone = model.getObjectByName('Head');
// 让头部看向目标
function lookAt(target) {
// 获取骨骼在世界空间的位置
const headWorldPos = new THREE.Vector3();
headBone.getWorldPosition(headWorldPos);
// 计算方向
const direction = new THREE.Vector3().subVectors(target, headWorldPos);
// 注意:lookAt 会在世界空间操作,需要转换到骨骼局部空间
// 对于骨骼动画,通常需要更复杂的 IK 解决方案
}Q: 变形动画和骨骼动画可以同时使用吗?
A: 可以。这是角色动画的常见做法:
javascript
const material = new THREE.MeshStandardMaterial({
skinning: true, // 骨骼
morphTargets: true // 变形
});
const mesh = new THREE.SkinnedMesh(geometry, material);
// 骨骼动画控制身体
bones[1].rotation.z = Math.sin(time) * 0.5;
// 变形动画控制表情
mesh.morphTargetInfluences[0] = (Math.sin(time) + 1) / 2;完整示例
javascript
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// 场景设置
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
camera.position.z = 5;
const clock = new THREE.Clock();
// 创建骨骼动画
function createSkinnedMesh() {
// 几何体
const geometry = new THREE.CylinderGeometry(0.1, 0.1, 4, 8, 20, false);
// 创建骨骼
const bones = [];
const segmentHeight = 4 / 4;
for (let i = 0; i < 5; i++) {
const bone = new THREE.Bone();
bone.position.y = i === 0 ? 0 : segmentHeight;
bones.push(bone);
if (i > 0) {
bones[i - 1].add(bone);
}
}
// 创建蒙皮网格
const material = new THREE.MeshStandardMaterial({
color: 0x00ff00,
skinning: true
});
const mesh = new THREE.SkinnedMesh(geometry, material);
mesh.add(bones[0]);
const skeleton = new THREE.Skeleton(bones);
mesh.bind(skeleton);
return { mesh, bones };
}
const { mesh, bones } = createSkinnedMesh();
scene.add(mesh);
// 创建变形动画
function createMorphMesh() {
const geometry = new THREE.SphereGeometry(1, 32, 32);
// 变形目标 1:膨胀
const inflateGeometry = new THREE.SphereGeometry(1.3, 32, 32);
// 变形目标 2:扁平
const flattenGeometry = geometry.clone();
flattenGeometry.scale(1, 0.5, 1);
geometry.morphAttributes.position = [
new THREE.Float32BufferAttribute(inflateGeometry.attributes.position.array, 3),
new THREE.Float32BufferAttribute(flattenGeometry.attributes.position.array, 3)
];
const material = new THREE.MeshStandardMaterial({
color: 0xff6600,
morphTargets: true
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.x = 3;
return mesh;
}
const morphMesh = createMorphMesh();
scene.add(morphMesh);
// 光源
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 5, 5);
scene.add(light);
const ambientLight = new THREE.AmbientLight(0x404040);
scene.add(ambientLight);
// 动画循环
function animate() {
requestAnimationFrame(animate);
const time = clock.getElapsedTime();
// 骨骼动画
bones[1].rotation.z = Math.sin(time) * 0.5;
bones[2].rotation.z = Math.cos(time * 1.5) * 0.3;
bones[3].rotation.z = Math.sin(time * 2) * 0.2;
// 变形动画
morphMesh.morphTargetInfluences[0] = (Math.sin(time) + 1) / 2;
morphMesh.morphTargetInfluences[1] = (Math.cos(time) + 1) / 2;
controls.update();
renderer.render(scene, camera);
}
animate();