人物模型动画案例 ·Model Animation· ▶ 在线运行案例
- 案例合集:三维可视化功能案例(threehub.cn)
- 开源仓库github地址:https://github.com/z2586300277/three-cesium-examples
- 400个案例代码:网盘链接
你将学到什么
- glTF 模型中的骨骼动画(Skeletal Animation)如何播放
AnimationMixer+AnimationAction的核心 API- crossFadeTo实现待机 / 行走 / 跑步之间的平滑过渡
- 用lil-gui面板调试权重、暂停、单步播放
效果说明
加载Soldier.glb士兵模型,在灰色地面上展示待机、行走、跑步三套动作。右侧 GUI 面板可:
- 切换显示模型 / 骨骼辅助线
- 暂停、单步推进动画
- 一键 crossFade 切换动作(如「从待机到行走」)
- 手动调节各动作混合权重与全局timeScale
核心概念
glTF 动画数据结构
glTF 加载完成后,gltf.animations是一个AnimationClip 数组,每个 clip 包含一组关键帧轨道(位置、旋转、缩放或骨骼矩阵):
loader.load(url, (gltf) => {
model = gltf.scene; const animations = gltf.animations; // [clip0, clip1, clip2, ...] });
本案例中 clip 索引对应关系(以 Soldier.glb 为准):
| 索引 | 动作 | |------|------| |animations[0]| idle 待机 | |animations[1]| run 跑步 | |animations[3]| walk 行走 |
::: tip 不同模型的 clip 顺序不同,加载后应console.log(animations.map(a => a.name))确认。 :::
AnimationMixer 播放管线
AnimationClip → mixer.clipAction(clip) → AnimationAction
↓ action.play() / crossFadeTo() ↓ 每帧 mixer.update(delta) → 更新骨骼矩阵 → 模型动起来
mixer = new THREE.AnimationMixer(model);idleAction = mixer.clipAction(animations[0]); idleAction.play();
// 渲染循环中 mixer.update(clock.getDelta());
crossFadeTo 过渡
两个动作同时播放,通过权重渐变实现无缝切换:
setWeight(endAction, 1);
endAction.time = 0; startAction.crossFadeTo(endAction, duration, true); // 过渡时长(秒) 是否同步时间轴
setEffectiveWeight(weight)控制每个 action 的贡献比例;三个 action 同时play()时,权重之和通常为 1。SkeletonHelper
skeleton = new THREE.SkeletonHelper(model);
skeleton.visible = false; // GUI 可切换显示
用于调试骨骼层级与关节方向,上线前隐藏即可。
实现步骤
- init— Scene、Camera、Renderer、阴影、地面、灯光
- GLTFLoader加载 Soldier.glb →
scene.add(model) - 创建
AnimationMixer,绑定 idle / walk / run 三个clipAction - createPanel— lil-gui 六组控制项
renderer.setAnimationLoop(animate)替代手写 rAF- animate—
mixer.update(delta)+ 相机跟随 + OrbitControls 代码要点
import * as THREE from "three";import Stats from "three/examples/jsm/libs/stats.module.js"; import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
let scene, renderer, camera, stats; let model, skeleton, mixer, clock;
const crossFadeControls = [];
let idleAction, walkAction, runAction; let idleWeight, walkWeight, runWeight; let actions, settings;
let singleStepMode = false; let sizeOfNextStep = 0;
let controls; let cameraTarget = new THREE.Vector3();
init();
function init() { const container = document.getElementById("box");
camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 1, 100 ); camera.position.set(1, 2, -3); camera.lookAt(0, 1, 0);
clock = new THREE.Clock();
scene = new THREE.Scene(); scene.background = new THREE.Color(0xa0a0a0); scene.fog = new THREE.Fog(0xa0a0a0, 10, 50);
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x8d8d8d, 3); hemiLight.position.set(0, 20, 0); scene.add(hemiLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 3); dirLight.position.set(-3, 10, -10); dirLight.castShadow = true; dirLight.shadow.camera.top = 2; dirLight.shadow.camera.bottom = -2; dirLight.shadow.camera.left = -2; dirLight.shadow.camera.right = 2; dirLight.shadow.camera.near = 0.1; dirLight.shadow.camera.far = 40; scene.add(dirLight);
// 相机辅助器 // scene.add( new THREE.CameraHelper( dirLight.shadow.camera ) );
// 地面 const mesh = new THREE.Mesh( new THREE.PlaneGeometry(100, 100), new THREE.MeshPhongMaterial({ color: 0xcbcbcb, depthWrite: false }) ); mesh.rotation.x = -Math.PI / 2; mesh.receiveShadow = true; scene.add(mesh);
const loader = new GLTFLoader();
loader.load( FILE_HOST + 'files/model/Soldier.glb', function (gltf) { model = gltf.scene; scene.add(model);
model.traverse(function (object) { if (object.isMesh) object.castShadow = true; }); console.log(model, "模型"); //
skeleton = new THREE.SkeletonHelper(model); skeleton.visible = false; scene.add(skeleton);
//
createPanel();
//
const animations = gltf.animations;
mixer = new THREE.AnimationMixer(model);
idleAction = mixer.clipAction(animations[0]); walkAction = mixer.clipAction(animations[3]); runAction = mixer.clipAction(animations[1]);
actions = [idleAction, walkAction, runAction];
activateAllActions();
renderer.setAnimationLoop(animate); } );
renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; container.appendChild(renderer.domElement);
stats = new Stats(); container.appendChild(stats.dom);
stats.dom.style.position = 'absolute'; stats.dom.style.left = '30px'; stats.dom.style.top = '0px';
window.addEventListener("resize", onWindowResize);
controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.screenSpacePanning = false; controls.minDistance = 3; controls.maxDistance = 20; controls.maxPolarAngle = Math.PI / 2; }
function createPanel() { const panel = new GUI({ width: 310 });
const folder1 = panel.addFolder("可见性控制"); const folder2 = panel.addFolder("动画激活/停用"); const folder3 = panel.addFolder("暂停/步进"); const folder4 = panel.addFolder("动画过渡"); const folder5 = panel.addFolder("混合权重"); const folder6 = panel.addFolder("全局速度");
settings = { "show model": true, "show skeleton": false, "deactivate all": deactivateAllActions, "activate all": activateAllActions, "pause/continue": pauseContinue, "make single step": toSingleStepMode, "modify step size": 0.05, "from walk to idle": function () { prepareCrossFade(walkAction, idleAction, 1.0); }, "from idle to walk": function () { prepareCrossFade(idleAction, walkAction, 0.5); }, "from walk to run": function () { prepareCrossFade(walkAction, runAction, 2.5); }, "from run to walk": function () { prepareCrossFade(runAction, walkAction, 5.0); }, "use default duration": true, "set custom duration": 3.5, "modify idle weight": 0.0, "modify walk weight": 1.0, "modify run weight": 0.0, "modify time scale": 1.0, };
folder1.add(settings, "show model").name("显示模型").onChange(showModel); folder1 .add(settings, "show skeleton") .name("显示骨骼") .onChange(showSkeleton); folder2.add(settings, "deactivate all").name("停用所有"); folder2.add(settings, "activate all").name("激活所有"); folder3.add(settings, "pause/continue").name("暂停/继续"); folder3.add(settings, "make single step").name("单步执行"); folder3.add(settings, "modify step size", 0.01, 0.1, 0.001).name("步长修改"); crossFadeControls.push( folder4.add(settings, "from walk to idle").name("从行走到待机") ); crossFadeControls.push( folder4.add(settings, "from idle to walk").name("从待机到行走") ); crossFadeControls.push( folder4.add(settings, "from walk to run").name("从行走到跑步") ); crossFadeControls.push( folder4.add(settings, "from run to walk").name("从跑步到行走") ); folder4.add(settings, "use default duration").name("使用默认时长"); folder4.add(settings, "set custom duration", 0, 10, 0.01).name("自定义时长"); folder5 .add(settings, "modify idle weight", 0.0, 1.0, 0.01) .name("待机权重") .listen() .onChange(function (weight) { setWeight(idleAction, weight); }); folder5 .add(settings, "modify walk weight", 0.0, 1.0, 0.01) .name("行走权重") .listen() .onChange(function (weight) { setWeight(walkAction, weight); }); folder5 .add(settings, "modify run weight", 0.0, 1.0, 0.01) .name("跑步权重") .listen() .onChange(function (weight) { setWeight(runAction, weight); }); folder6 .add(settings, "modify time scale", 0.0, 1.5, 0.01) .name("时间缩放") .onChange(modifyTimeScale);
folder1.open(); folder2.open(); folder3.open(); folder4.open(); folder5.open(); folder6.open(); }
function showModel(visibility) { model.visible = visibility; }
function showSkeleton(visibility) { skeleton.visible = visibility; }
function modifyTimeScale(speed) { mixer.timeScale = speed; }
function deactivateAllActions() { actions.forEach(function (action) { action.stop(); }); }
function activateAllActions() { setWeight(idleAction, settings["modify idle weight"]); setWeight(walkAction, settings["modify walk weight"]); setWeight(runAction, settings["modify run weight"]);
actions.forEach(function (action) { action.play(); }); }
function pauseContinue() { if (singleStepMode) { singleStepMode = false; unPauseAllActions(); } else { if (idleAction.paused) { unPauseAllActions(); } else { pauseAllActions(); } } }
function pauseAllActions() { actions.forEach(function (action) { action.paused = true; }); }
function unPauseAllActions() { actions.forEach(function (action) { action.paused = false; }); }
function toSingleStepMode() { unPauseAllActions();
singleStepMode = true; sizeOfNextStep = settings["modify step size"]; }
function prepareCrossFade(startAction, endAction, defaultDuration) { // 根据用户选择切换默认/自定义过渡持续时间 const duration = setCrossFadeDuration(defaultDuration);
// 确保不在单步模式下,且所有动作都未暂停 singleStepMode = false; unPauseAllActions();
// 如果当前动作是'机'(持续4秒),立即执行过渡 // 否则等待当前动作完成其当前循环 if (startAction === idleAction) { executeCrossFade(startAction, endAction, duration); } else { synchronizeCrossFade(startAction, endAction, duration); } }
function setCrossFadeDuration(defaultDuration) { // 根据用户选择切换默认/自定义过渡持续时间 if (settings["use default duration"]) { return defaultDuration; } else { return settings["set custom duration"]; } }
function synchronizeCrossFade(startAction, endAction, duration) { mixer.addEventListener("loop", onLoopFinished);
function onLoopFinished(event) { if (event.action === startAction) { mixer.removeEventListener("loop", onLoopFinished);
executeCrossFade(startAction, endAction, duration); } } }
function executeCrossFade(startAction, endAction, duration) { // 在开始过渡之前,确保结束动作的权重为1 setWeight(endAction, 1); endAction.time = 0;
// 使用渐变过渡 - 第三个参数设为false可以尝试无扭曲过渡 startAction.crossFadeTo(endAction, duration, true); }
// 此函数是必需的,因为animationAction.crossFadeTo()会禁用其起始动作 // 并将起始动作的时间缩放设置为((起始动画持续时间)/(结束动画持续时间)) function setWeight(action, weight) { action.enabled = true; action.setEffectiveTimeScale(1); action.setEffectiveWeight(weight); }
// 由渲染循环调用 function updateWeightSliders() { settings["modify idle weight"] = idleWeight; settings["modify walk weight"] = walkWeight; settings["modify run weight"] = runWeight; }
// 由渲染循环调用 function updateCrossFadeControls() { if (idleWeight === 1 && walkWeight === 0 && runWeight === 0) { crossFadeControls[0].disable(); // 禁用从行走到待机 crossFadeControls[1].enable(); // 启用从待机到行走 crossFadeControls[2].disable(); // 禁用从行走到跑步 crossFadeControls[3].disable(); // 禁用从跑步到行走 } }
function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight); }
function animate() { idleWeight = idleAction.getEffectiveWeight(); walkWeight = walkAction.getEffectiveWeight(); runWeight = runAction.getEffectiveWeight();
// 如果权重从"外部"修改(通过过渡效果),更新面板值 updateWeightSliders();
// 根据当前权重值启用/禁用过渡控制 updateCrossFadeControls();
// 获取上一帧到当前帧的时间差,用于混合器更新(非单步模式下) let mixerUpdateDelta = clock.getDelta();
// 如果在单步模式下,执行一步然后停止(直到用户再次点击) if (singleStepMode) { mixerUpdateDelta = sizeOfNextStep; sizeOfNextStep = 0; }
// 更新动画混合器、状态面板,并渲染此帧 mixer.update(mixerUpdateDelta);
if (model) { // 更新相机目标点 cameraTarget.copy(model.position); cameraTarget.y += 1; // 更新轨道控制器的目标点 controls.target.copy(cameraTarget); }
// 更新控制器 - 这会处理缩放和旋转 controls.update();
renderer.render(scene, camera);
stats.update(); }完整源码:GitHub
小结
- 骨骼动画三件套:AnimationClip → AnimationMixer → AnimationAction
- 动作切换优先用crossFadeTo,比硬切
stop()+play()自然 - 本案例是 Three.js 官方webgl_animation_walk的中文 GUI 增强版,适合作为项目动画系统起点