02.three官方示例+编辑器+AI快速学习webgl_animation_skinning_blending
本实例主要讲解内容
这个示例展示了Three.js中骨骼动画混合(Skeletal Animation Blending)的实现方法,通过加载一个士兵模型,演示了如何在不同动画状态(如站立、行走、跑步)之间进行平滑过渡。核心技术包括动画混合器(AnimationMixer)的使用、动画权重控制、动画同步淡入淡出,以及通过GUI控制面板实时调整动画参数。
完整代码注释
<!DOCTYPE html>
<html lang="en"><head><title>three.js webgl - animation - skinning</title><meta charset="utf-8"><meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0"><link type="text/css" rel="stylesheet" href="main.css"><style>a {color: #f00;}</style></head><body><!-- 渲染场景的容器 --><div id="container"></div><!-- 信息面板,显示项目信息和注意事项 --><div id="info"><a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> - Skeletal Animation Blending(model from <a href="https://www.mixamo.com/" target="_blank" rel="noopener">mixamo.com</a>)<br/>Note: crossfades are possible with blend weights being set to (1,0,0), (0,1,0) or (0,0,1)</div><!-- 导入映射,指定模块导入路径 --><script type="importmap">{"imports": {"three": "../build/three.module.js","three/addons/": "./jsm/"}}</script><script type="module">// 导入Three.js核心库和辅助工具import * as THREE from 'three';import Stats from 'three/addons/libs/stats.module.js'; // 性能统计工具import { GUI } from 'three/addons/libs/lil-gui.module.min.js'; // GUI控制面板import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; // GLTF模型加载器// 全局变量定义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; // 单步模式下一步的大小// 初始化函数init();function init() {// 获取渲染容器const container = document.getElementById( 'container' );// 创建透视相机,设置位置和朝向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 );// 加载GLTF格式模型const loader = new GLTFLoader();loader.load( 'models/gltf/Soldier.glb', function ( gltf ) {model = gltf.scene; // 获取模型场景对象scene.add( model ); // 将模型添加到场景中// 遍历模型的所有对象,设置可投射阴影model.traverse( function ( object ) {if ( object.isMesh ) object.castShadow = true;} );// 创建骨骼辅助工具,用于可视化骨骼结构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();// 设置渲染循环,使用requestAnimationFrame持续更新和渲染场景renderer.setAnimationLoop( animate );} );// 初始化WebGL渲染器renderer = new THREE.WebGLRenderer( { antialias: true } );renderer.setPixelRatio( window.devicePixelRatio ); // 设置像素比,适配高DPI屏幕renderer.setSize( window.innerWidth, window.innerHeight ); // 设置渲染尺寸renderer.shadowMap.enabled = true; // 启用阴影渲染container.appendChild( renderer.domElement ); // 将渲染器DOM元素添加到容器中// 添加性能统计面板stats = new Stats();container.appendChild( stats.dom );// 添加窗口大小变化事件监听,调整相机和渲染器window.addEventListener( 'resize', onWindowResize );}// 创建控制面板函数function createPanel() {// 创建GUI面板const panel = new GUI( { width: 310 } );// 创建不同功能的折叠面板const folder1 = panel.addFolder( 'Visibility' ); // 可见性控制const folder2 = panel.addFolder( 'Activation/Deactivation' ); // 动画激活/停用const folder3 = panel.addFolder( 'Pausing/Stepping' ); // 暂停/单步控制const folder4 = panel.addFolder( 'Crossfading' ); // 动画淡入淡出const folder5 = panel.addFolder( 'Blend Weights' ); // 混合权重const folder6 = panel.addFolder( 'General Speed' ); // 全局速度// 控制面板设置对象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' ).onChange( showModel ); // 模型可见性控制folder1.add( settings, 'show skeleton' ).onChange( showSkeleton ); // 骨骼可见性控制folder2.add( settings, 'deactivate all' ); // 停用所有动画按钮folder2.add( settings, 'activate all' ); // 激活所有动画按钮folder3.add( settings, 'pause/continue' ); // 暂停/继续按钮folder3.add( settings, 'make single step' ); // 单步模式按钮folder3.add( settings, 'modify step size', 0.01, 0.1, 0.001 ); // 单步大小滑块crossFadeControls.push( folder4.add( settings, 'from walk to idle' ) ); // 添加淡入淡出控制按钮crossFadeControls.push( folder4.add( settings, 'from idle to walk' ) );crossFadeControls.push( folder4.add( settings, 'from walk to run' ) );crossFadeControls.push( folder4.add( settings, 'from run to walk' ) );folder4.add( settings, 'use default duration' ); // 是否使用默认时长复选框folder4.add( settings, 'set custom duration', 0, 10, 0.01 ); // 自定义时长滑块// 添加动画权重滑块,并监听变化以更新动画权重folder5.add( settings, 'modify idle weight', 0.0, 1.0, 0.01 ).listen().onChange( function ( weight ) {setWeight( idleAction, weight );} );folder5.add( settings, 'modify walk weight', 0.0, 1.0, 0.01 ).listen().onChange( function ( weight ) {setWeight( walkAction, weight );} );folder5.add( settings, 'modify run weight', 0.0, 1.0, 0.01 ).listen().onChange( function ( weight ) {setWeight( runAction, weight );} );folder6.add( settings, 'modify time scale', 0.0, 1.5, 0.01 ).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();// 如果起始动画是站立动画(持续时间较长),立即执行过渡// 否则等待当前动画完成当前循环后再执行过渡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;// 使用warping进行过渡(第三个参数为true),可以尝试设置为false不使用warpingstartAction.crossFadeTo( endAction, duration, true );}// 设置动画权重的函数function setWeight( action, weight ) {action.enabled = true; // 启用动画action.setEffectiveTimeScale( 1 ); // 设置时间缩放为1action.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(); // 从跑步到行走(禁用)}if ( idleWeight === 0 && walkWeight === 1 && runWeight === 0 ) {crossFadeControls[ 0 ].enable(); // 从行走到站立(启用)crossFadeControls[ 1 ].disable(); // 从站立到行走(禁用)crossFadeControls[ 2 ].enable(); // 从行走到跑步(启用)crossFadeControls[ 3 ].disable(); // 从跑步到行走(禁用)}if ( idleWeight === 0 && walkWeight === 0 && runWeight === 1 ) {crossFadeControls[ 0 ].disable(); // 从行走到站立(禁用)crossFadeControls[ 1 ].disable(); // 从站立到行走(禁用)crossFadeControls[ 2 ].disable(); // 从行走到跑步(禁用)crossFadeControls[ 3 ].enable(); // 从跑步到行走(启用)}}// 窗口大小变化事件处理函数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 );renderer.render( scene, camera );stats.update();}</script></body>
</html>
整体总结
这个Three.js示例展示了骨骼动画混合的实现方法,主要内容包括:
-
核心技术:
- 使用
AnimationMixer
管理多个动画 - 通过
clipAction
获取特定动画片段 - 控制动画权重实现动画混合
- 使用
crossFadeTo
方法实现平滑过渡
- 使用
-
动画控制方式:
- 直接控制:通过调整各动画权重实现混合
- 过渡控制:在不同动画状态间实现平滑过渡
- 同步过渡:确保动画在循环结束时进行过渡,避免动作中断
-
用户交互:
- 通过GUI面板提供直观控制
- 可调整动画权重、过渡时长、全局速度等参数
- 支持暂停、单步模式等特殊控制方式
交流学习: Three.js 场景编辑器 (Vue3 + TypeScript
实现)
https://threelab.cn/threejs-edit/