当前位置: 首页 > news >正文

Three.js 人物模型动画案例教程

人物模型动画案例 ·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
相机通过 OrbitControls 环绕人物,并自动跟随模型位置。

核心概念

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
  • animatemixer.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 增强版,适合作为项目动画系统起点
http://www.cnnetsun.cn/news/3078988.html

相关文章:

  • Octo 正式开源:首个开源可信的人与agent协作平台
  • 告别高昂外包费!苏州制造企业如何用零代码平台3天自建数字孪生工厂?
  • 社交钱包开发的技术逻辑与人文转向
  • 翅片管散热器的设计与应用解析
  • 告别手动绑定!用WxValidate在微信小程序+vant weapp里优雅搞定表单校验
  • OWASP Top 10 A02加密机制失效:十大风险场景与纵深防御实战
  • 【无标题】请容许我吹一下牛
  • AI驱动测试开发:Claude Code在单元、API与UI自动化测试中的实战应用
  • AI视觉防错行为判断实时监督家电产线作业,杜绝人为失误隐患
  • 前期准备:
  • wechatapi优化:基于AC自动机的海量关键词毫秒级拦截
  • 后端工程师需要掌握的DevOps实践指南
  • 基于深度学习的骨折检测系统(YOLOv8+YOLO数据集+UI界面+Python项目+模型)
  • 计算机毕业设计之基于少儿编程课程平台管理系统的设计与实现
  • 隧道施工数字化利器|LED信息显示系统,打通安全管理可视化闭环@信悦恒科技
  • 【AWS】基于Docker搭建监控系统基础(二)
  • Spring Boot Actuator安全防护:Nginx与APISIX字符绕过漏洞深度解析与配置实践
  • Python逆向网易云音乐评论加密:AES+RSA混合加密实战解析
  • TEA系列加密算法实战:从C到Python的跨平台轻量级实现
  • 影刀RPA新手教程:电商创业者完全指南——从零到一搭建第一个自动化选品采价流程
  • GLM5.2本地部署实战:从环境搭建到性能优化全解析
  • 美团王兴的白发
  • 中兴F50怎么安装UFI-TOOLS并远程访问?完整图文教程
  • Python爬虫经典案例003:正则表达式精通指南——文本数据的精准提取技巧
  • 2026顶配单!好用的降AIGC网站全测评,效率直接拉满!
  • FileLock | 文件防删除保护工具
  • 一线观察:长期体验长春汽车贴膜后发现的技术细节
  • 市场正规的画册设计公司口碑
  • 【 Godot 4 学习笔记】Blender到Godot4
  • Flutter 应用加固方法 从 Dart 混淆到 IPA 层面的保护方案