【Three.js】day110-animation-system

作者:mario 发布时间: 2026-09-07 阅读量:6 评论数:0

Day 110 · 动画系统 — 让设备"动"起来

前九天的车间是"静态大片":真实模型、质感材质、阴影氛围——但设备不动。今天的主题动画系统:模型自带动画怎么播放(AnimationMixer)、代码里怎么编动作(关键帧 KeyframeTrack)、以及"叶轮转 + 指示灯闪"这类设备动作怎么编排进渲染循环。这也是 Day 100 时间纪律的进阶应用——动画必须用 delta 时间驱动,否则一卡顿全乱套。做完,车间从"模型展示"变成"运转中的工厂"。


目录


一、动画从哪来:模型自带 vs 代码手写

1.1 两种来源

来源

例子

特点

用在哪

模型自带

建模软件里做的关键帧动画(泵叶轮转、机械臂运动)

精细、但受限于模型

复杂机械动作

代码手写

KeyframeTrack 编(指示灯闪烁、设备振动)

灵活、改起来快

状态反馈、简单动作

1.2 工业场景的典型"动作"清单

✅ 设备级:叶轮/扇叶旋转、指示灯闪烁、告警脉冲、机械臂往复
✅ 环境级:扫描线扫过、粒子飘动(Week 17)
❌ 数据级:由 WS 驱动的运动——第 6 个月(Week 26+)

本周做设备级动作(静态可演示),数据驱动的运动留给孪生主线。红线第 6 条延续。


二、关键帧动画基础:KeyframeTrack 与插值

2.1 原理:关键帧 + 插值

动画的本质:在若干"时间点"上设定属性值(关键帧),中间的值由 GPU/引擎插值平滑

时间线   0s        0.5s        1s
旋转    0°     →   90°    →   0°      (三个关键帧)
              (插值)      (插值)
结果:叶片 0→90→0 往复摆动

2.2 三个核心对象

import * as THREE from "three";

// ① KeyframeTrack:一条"属性随时间变化"的曲线
//    (对象.属性路径, 时间数组, 值数组)
const track = new THREE.NumberKeyframeTrack(
  ".rotation[y]",                // 目标:rotation.y
  [0, 0.5, 1],                   // 关键帧时间点(秒)
  [0, Math.PI / 2, 0]            // 对应时刻的值(弧度)
);

// ② AnimationClip:把多条 track 打包成一个"完整动作"
const clip = new THREE.AnimationClip("swing", 1, [track]);   // 名字/时长/曲线们

// ③ 之后用 AnimationMixer 播放这个 clip(见第三节)

记忆钩子KeyframeTrack = 一条曲线,AnimationClip = 一组曲线的集合(一个"动作")。模型的自带动画本质也是 AnimationClip 数组。


三、AnimationMixer:动画的"混音台"

3.1 为什么需要 mixer

模型有多个动作(叶轮转 + 指示灯闪),每个动作可能同时进行、还可能切换——需要一个"总调度器"来混播。AnimationMixer 就是这个调度器。

import * as THREE from "three";

// 1. 为模型创建 mixer(绑定到模型对象)
const mixer = new THREE.AnimationMixer(model);

// 2. 从 clip 取出"可播放的动作"(Action)
const swingAction = mixer.clipAction(clip);
swingAction.play();               // 开始播放

// 3. 动作控制
// swingAction.stop()  停止
// swingAction.paused = true   暂停
// swingAction.timeScale = 2   倍速(0.5 慢放 / 2 快进)

3.2 多个动作共存

// 两个动作同时播:叶轮转 + 指示灯闪(各是独立 clip)
const spinAction = mixer.clipAction(spinClip);
const blinkAction = mixer.clipAction(blinkClip);
spinAction.play();
blinkAction.play();
// mixer 会把两个动作的变换"叠加"到模型上(内部自动处理混合)

记忆钩子:mixer = 混音台(混音器的字面意思)。不同动作像不同音轨,mixer 负责"同时放 + 调音量(权重)"。


四、播放模型自带动画(gltf.animations)

Day 108 提到模型可能自带动画(存在 gltf.animations 里)——今天真正播放它:

import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

const gltf = await new GLTFLoader().loadAsync("/assets/models/pump.glb");

const model = gltf.scene;
scene.add(model);

// 模型自带动画:gltf.animations 是 AnimationClip 数组
console.log(gltf.animations);   // 看模型带了哪些动作(如 "Spin"、"Pulse")

if (gltf.animations.length > 0) {
  const mixer = new THREE.AnimationMixer(model);
  // 播放第一个动作(或按名字查找)
  const action = mixer.clipAction(gltf.animations[0]);
  action.play();
  // ★ 重要:mixer 要在循环里 update(delta)——见第六节
}

常见坑:模型动画"不播"——绝大多数是因为 mixer.update(delta) 没进渲染循环。mixer 自己不会动,必须每帧喂时间(第六节)。


五、代码编排动作:叶轮转 + 指示灯闪

没有自带动画时,用代码编。今天做两件事:设备缓慢自转(复用 Day 100 的直接改属性),和用 clip 编排的指示灯闪烁(对比两种姿势):

5.1 直接改属性(简单旋转——Day 100 姿势)

// 设备整体缓慢自转:直接改 rotation(Day 100 时间纪律)
const tick = () => {
  const delta = clock.getDelta();
  model.rotation.y += 0.3 * delta;   // 每秒 0.3 弧度
  // ...
};

5.2 用 KeyframeTrack 编排(往复/闪烁——今天新姿势)

// 指示灯闪烁:0s 亮 → 0.5s 灭 → 1s 亮(往复)
// 用材质 emissiveIntensity(自发光强度)做"亮灭"
const blinkTrack = new THREE.NumberKeyframeTrack(
  "material.emissiveIntensity",
  [0, 0.25, 0.5, 0.75, 1],          // 时间点
  [1, 0, 1, 0, 1]                   // 亮度值(亮灭交替)
);
const blinkClip = new THREE.AnimationClip("blink", 1, [blinkTrack]);

// 注意:track 路径是相对 model 的——"material" 指 model 的第一个子 Mesh 的材质
const mixer = new THREE.AnimationMixer(model);
const blinkAction = mixer.clipAction(blinkClip);
blinkAction.setLoop(THREE.LoopPingPong, Infinity);   // 往复循环(乒乓)
blinkAction.play();

两种姿势的取舍:单向匀速运动(旋转)直接改属性最便宜;往复/闪烁/曲线运动用 clip(自动插值、可循环、可倍速)。生产环境以"用最少代码做出效果"为准。


六、与渲染循环集成:mixer.update(delta)

这是动画系统唯一不能忘的一行

import * as THREE from "three";

// 引擎里统一管理所有设备的 mixer
const mixers: THREE.AnimationMixer[] = [];

// 每个带动画的模型创建 mixer 后加入列表
function attachMixer(model: THREE.Object3D): THREE.AnimationMixer {
  const mixer = new THREE.AnimationMixer(model);
  mixers.push(mixer);
  return mixer;
}

// 渲染循环:所有 mixer 每帧 update(delta)(Day 100 时间纪律的进阶应用)
const clock = new THREE.Clock();
const tick = () => {
  const delta = clock.getDelta();

  // 1. 更新所有动画(必须喂真实 delta!)
  mixers.forEach((m) => m.update(delta));

  // 2. 直接改属性的动画(Day 100 姿势)
  // models.forEach((m) => m.rotation.y += 0.3 * delta);

  // 3. 相机 + 渲染(Day 109 起用 composer)
  controls.update();
  composer.render();
  rafId = requestAnimationFrame(tick);
};

为什么必须 mixer.update(delta) 而不是固定值:mixer 内部按"真实流逝时间"推进动画进度。用固定值(如 update(0.016))在帧率波动时会加速/减速动画——与 Day 100 的"动画速度被帧率绑架"是同一个坑clock.getDelta() 保证动画时间与真实时间同步。


七、常见坑点

坑 1:动画不播放

排查:① mixer 建了吗 ② clipAction().play() 调了吗 ③ mixer.update(delta) 进循环了吗(最常漏)④ clip 是否为空。

坑 2:动画忽快忽慢

原因:update() 用了固定值而非真实 delta。修法:mixer.update(clock.getDelta())(第六节)。

坑 3:多个动作互相"打架"

原因:两个 clip 同时改同一属性(如都改 rotation)。修法:不同动作改不同属性,或用 action.fadeIn/fadeOut 平滑切换

坑 4:track 路径写错(动画没效果)

原因:".rotation[y]" 写成 "rotation.y",或 target 对象层级不对。修法:路径第一个点必须指向"对象",如 ".rotation[y]" 挂在 model 上;子 Mesh 用 "childName.rotation[x]"

坑 5:忘了清理 mixer

组件卸载后 mixer 仍在推进,动画对象泄漏。修法:dispose 时遍历 mixers 清空 + mixer.stopAllAction()(成对纪律)。


八、自测挑战

T1 · 模型自带动画(50 分钟)

找一个带动画的 GLB 模型,播放它的 gltf.animations[0],确认在循环里 mixer.update(delta) 后动起来。再故意不调 update,观察"不动"——把坑 1 钉死。

T2 · 代码编动作(60 分钟)

给车间设备编两个动作:① 整体缓慢自转(改属性)② 指示灯闪烁(KeyframeTrack + LoopPingPong)。两者同时进行互不干扰。

T3 · 倍速与暂停(30 分钟)

timeScale 实现"加速/减速",用 action.paused 实现暂停。验证:动画进度与真实时间同步(停 5 秒再放,动画从正确位置继续)。

T4 · 动作编排复盘(20 分钟)

把"设备动作清单"列成表(动作名 / 实现方式:改属性 or clip / 时长 / 循环方式),存进笔记——这份清单是 Day 112 车间动画的施工图


九、总结

环节

要点

动画来源

模型自带(gltf.animations)vs 代码手写(KeyframeTrack)

原理

关键帧 + 插值:时间点设定值,中间自动平滑

Mixer

混音台:多动作同时播、可控制(play/pause/倍速)

播放模型动画

mixer.clipAction(gltf.animations[0]).play()

代码编排

NumberKeyframeTrack + AnimationClip + LoopPingPong

铁律

mixer.update(clock.getDelta()) 每帧必调——时间纪律进阶

车间"运转"起来了:模型在转、指示灯在闪。明天解决"怎么优雅地把这些资产装进页面"——加载态、异步边界与资源管理。


明日预告:Day 111 资产集成与边界——加载进度条、异步边界(加载完成才渲染)、资源管理(缓存/释放/降级)、以及 Vue 侧怎么通过桥拿到"加载态"(Day 103 契约的扩展)。

评论