Day 110 · 动画系统 — 让设备"动"起来
前九天的车间是"静态大片":真实模型、质感材质、阴影氛围——但设备不动。今天的主题动画系统:模型自带动画怎么播放(
AnimationMixer)、代码里怎么编动作(关键帧KeyframeTrack)、以及"叶轮转 + 指示灯闪"这类设备动作怎么编排进渲染循环。这也是 Day 100 时间纪律的进阶应用——动画必须用 delta 时间驱动,否则一卡顿全乱套。做完,车间从"模型展示"变成"运转中的工厂"。
目录
一、动画从哪来:模型自带 vs 代码手写
1.1 两种来源
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 车间动画的施工图。
九、总结
车间"运转"起来了:模型在转、指示灯在闪。明天解决"怎么优雅地把这些资产装进页面"——加载态、异步边界与资源管理。
明日预告:Day 111 资产集成与边界——加载进度条、异步边界(加载完成才渲染)、资源管理(缓存/释放/降级)、以及 Vue 侧怎么通过桥拿到"加载态"(Day 103 契约的扩展)。