本实例主要讲解内容
这个示例展示了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不使用warping
startAction.crossFadeTo( endAction, duration, true );
}
// 设置动画权重的函数
function setWeight( action, weight ) {
action.enabled = true; // 启用动画
action.setEffectiveTimeScale( 1 ); // 设置时间缩放为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(); // 从跑步到行走(禁用)
}
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/