首页 / Three.js 入门教程 / 骨骼动画与变形

Three.js 入门教程

骨骼动画与变形

本教程共 40 篇 · 第 27 篇 · 更新于 2026-08-14 · 约 8 分钟阅读

Three.js动画骨骼动画蒙皮SkeletonSkinnedMeshMorphTarget变形目标

本节目标:理解角色动画的两种主流技术——骨骼动画(Bone / Skeleton / SkinnedMesh)和变形目标(MorphTarget),学会用代码创建骨骼层级与变形几何体,并知道 glTF 模型里这两类动画如何播放。

两种让角色动起来的技术

骨骼动画(蒙皮,skinning):把网格顶点”绑”到骨头上,骨头带动顶点。四肢、躯干、尾巴这类关节运动全靠它。

变形目标(MorphTarget,也叫 blend shapes):几何体保存多套顶点位置,在几套形状之间按权重混合。面部表情、口型最适合。

一个角色常常两种都用:官方示例 RobotExpressive 就是骨骼播放走路、跑步等动作,变形目标单独控制表情。

Bone:骨骼与层级

BoneObject3D 的子类,有 position、rotation、scale,可以像场景树一样 add 子骨骼:

const shoulder = new THREE.Bone();
const elbow = new THREE.Bone();
const hand = new THREE.Bone();

shoulder.add(elbow);
elbow.add(hand);

父骨骼转动,子骨骼跟着转——这就是关节。骨骼本身不可见,动画时我们转它的 rotation:

elbow.rotation.x = Math.sin(t) * 0.5; // 肘关节弯曲

Skeleton:把骨头串成骨架

new Skeleton(bones) 把骨头数组交给骨架管理。骨架负责把每根骨头的变换打包上传给 GPU(boneMatricesboneTexture),顶点着色器才能按骨头计算位置。

骨架还保存 boneInverses:绑定姿势(bind pose,模型摆好骨架时的姿势)的逆矩阵。顶点位置以绑定姿势为基准记录,每帧用逆矩阵和当前骨骼变换做差值,顶点才能正确跟随骨骼。

实际开发中 Skeleton 几乎都由 loader 创建:GLTFLoader 加载带蒙皮的模型时,会自动生成骨架和蒙皮网格,不需要手写。

调试时想看到骨架,three.js 提供了 SkeletonHelper,把骨骼链画成线框:

const helper = new THREE.SkeletonHelper(model);
scene.add(helper); // 模型里所有骨骼一目了然

加载 glTF 角色后加上它,能立刻确认骨骼层级对不对、动画有没有作用在预期的骨头上。

SkinnedMesh:蒙皮网格

蒙皮网格 = 普通网格 + 骨架。几何体上多两个顶点属性:

  • skinIndex:每个顶点受哪几根骨头影响(最多 4 个索引)。
  • skinWeight:对应权重,四个权重之和为 1。

渲染时,顶点位置 = 各骨头变换按权重混合的结果。权重决定了”关节附近的顶点跟谁走”:肘关节内侧的顶点一半跟大臂、一半跟小臂,弯曲时才不会撕裂。

SkinnedMesh 相关方法:bind(skeleton) 绑定骨架、normalizeSkinWeights() 归一化权重、pose() 回到绑定姿势。

手动写蒙皮属性很繁琐,实际项目几乎不手写——在 Blender 里做好蒙皮导出 glTF,three.js 自动装配。代码里找到蒙皮网格用这个:

const skinned = model.getObjectByProperty('isSkinnedMesh', true);

可运行示例:骨骼手臂

下面用三根骨头搭一条手臂,每根骨头上挂一个小方块方便观察。这不是真正的蒙皮(方块各自独立),但能清楚看到骨骼层级的联动。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>27. 骨骼动画</title>
  <style>
    * { margin: 0; padding: 0; }
    body { overflow: hidden; background: #1a1a2e; }
    canvas { display: block; }
  </style>
</head>
<body>
  <script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/"
    }
  }
  </script>

  <script type="module">
  import * as THREE from 'three';

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x1a1a2e);
  scene.add(new THREE.AmbientLight(0xffffff, 1));
  const dirLight = new THREE.DirectionalLight(0xffffff, 2);
  dirLight.position.set(2, 4, 3);
  scene.add(dirLight);

  const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
  camera.position.set(2, 2, 5);
  camera.lookAt(0, 0.5, 0);

  // 三节骨骼:肩 -> 肘 -> 手,每节 1 个单位长
  const shoulder = new THREE.Bone();
  const elbow = new THREE.Bone();
  const hand = new THREE.Bone();
  shoulder.add(elbow);
  elbow.add(hand);
  elbow.position.y = -1; // 相对父骨骼向下 1 个单位
  hand.position.y = -1;
  scene.add(shoulder);

  // 每节骨骼挂一个小方块,便于观察
  function attachSegment(bone, color) {
    const mesh = new THREE.Mesh(
      new THREE.BoxGeometry(0.35, 0.95, 0.35),
      new THREE.MeshStandardMaterial({ color })
    );
    mesh.position.y = -0.5; // 方块中心落在骨骼末端附近
    bone.add(mesh);
  }
  attachSegment(shoulder, 0x4dabf7);
  attachSegment(elbow, 0xf783ac);
  attachSegment(hand, 0xffd43b);

  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(innerWidth, innerHeight);
  document.body.append(renderer.domElement);

  const clock = new THREE.Clock();

  function animate() {
    requestAnimationFrame(animate);
    const t = clock.getElapsedTime();

    shoulder.rotation.x = Math.sin(t) * 0.3;          // 肩膀小幅摆动
    elbow.rotation.x = -0.6 - Math.sin(t * 2) * 0.6;  // 肘关节弯曲
    hand.rotation.x = Math.sin(t * 3) * 0.4;          // 手腕抖动

    renderer.render(scene, camera);
  }
  animate();
  </script>
</body>
</html>

运行后手臂像钟摆一样摆动,肘关节和手腕各自弯曲。注意 hand 没有直接设置世界坐标——它的位置由父骨骼链层层叠加决定。

MorphTarget:变形目标

变形目标不依赖骨骼:几何体除了默认顶点位置,还存几套备用位置,渲染时按权重混合。

const geometry = new THREE.SphereGeometry(1, 32, 32);
// 克隆一份顶点位置,压扁作为"目标形状"
const target = geometry.clone();
target.scale(1, 0.4, 1);
geometry.morphAttributes.position = [target.getAttribute('position')];

然后通过网格的 morphTargetInfluences 控制混合权重(0 完全不变形,1 完全变成目标形状):

const mesh = new THREE.Mesh(geometry, material);
mesh.morphTargetInfluences[0] = 0.5; // 压扁一半

morphTargetDictionary 是名称到索引的映射,多人协作时按名字找目标更稳:

mesh.morphTargetInfluences[mesh.morphTargetDictionary['squash']] = 0.5;

变形目标也能交给动画系统:AnimationClip.CreateFromMorphTargetSequence 可以把一串目标形状(比如表情序列)转成剪辑,用第 26 章的 mixer 播放。

Note

每个变形目标都是一份完整的顶点数据。目标越多、网格越精细,内存占用越大。角色的口型动画通常只对脸部区域做变形目标,而不是整个身体。

glTF 里的变形目标同样由 loader 自动接入:加载后 mesh.morphTargetInfluencesmesh.morphTargetDictionary 直接可用,官方 RobotExpressive 示例就是滑动脸部网格的 morphTargetInfluences 来调表情。表情动画也可以存进 glTF 的动画剪辑,交给 mixer 播放——和骨骼动画的播放方式完全一样,你不用关心内部是哪种技术。

可运行示例:会呼吸的球

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>27. 变形目标</title>
  <style>
    * { margin: 0; padding: 0; }
    body { overflow: hidden; background: #1a1a2e; }
    canvas { display: block; }
  </style>
</head>
<body>
  <script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/"
    }
  }
  </script>

  <script type="module">
  import * as THREE from 'three';

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x1a1a2e);
  scene.add(new THREE.AmbientLight(0xffffff, 1));
  const dirLight = new THREE.DirectionalLight(0xffffff, 2);
  dirLight.position.set(2, 4, 3);
  scene.add(dirLight);

  const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
  camera.position.set(0, 0.5, 4);
  camera.lookAt(0, 0, 0);

  // 基础球 + 压扁的目标形状
  const geometry = new THREE.SphereGeometry(1, 48, 48);
  const target = geometry.clone();
  target.scale(1.3, 0.35, 1.3);
  geometry.morphAttributes.position = [target.getAttribute('position')];

  const mesh = new THREE.Mesh(
    geometry,
    new THREE.MeshStandardMaterial({ color: 0x69db7c, flatShading: true })
  );
  scene.add(mesh);

  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(innerWidth, innerHeight);
  document.body.append(renderer.domElement);

  const clock = new THREE.Clock();

  function animate() {
    requestAnimationFrame(animate);
    // 权重按正弦摆动:0 -> 1 -> 0,球一呼一吸
    mesh.morphTargetInfluences[0] = 0.5 + 0.5 * Math.sin(clock.getElapsedTime() * 2);
    renderer.render(scene, camera);
  }
  animate();
  </script>
</body>
</html>

骨骼数量对性能的影响不大:每根骨头每帧更新一次矩阵,角色几十根骨骼毫无压力。真正的成本在蒙皮顶点——每个顶点每帧要做最多 4 次矩阵变换,高模加多骨骼才是要关注的地方。

想练手,Mixamo、Ready Player Me 这类在线工具导出的角色自带骨骼和动画剪辑,直接加载就能播放,是熟悉骨骼动画的好素材。

最后记住:角色动画的效果由美术决定,three.js 只负责如实播放——模型导出的姿势、动画长度、循环方式都会原样呈现。代码能做的,是选择合适的剪辑并控制好切换时机。

小结

蒙皮网格的顶点由骨骼驱动,不要直接改 SkinnedMesh 的 position 或 rotation——骨骼变换会和你手动设置的值打架。要整体移动角色,把模型放进一个 Group,改 Group。

蒙皮网格的包围盒不会自动更新,做射线检测(第 23 章)时可能点不中动画中的模型,必要时手动 computeBoundingSphere() 刷新。