04.three官方示例+编辑器+AI快速学习webgl_animation_skinning_additive_blending

本实例主要讲解内容

这个示例展示了Three.js中**骨骼动画的叠加混合(Additive Animation Blending)**技术。通过加载一个机器人模型,演示了如何在基础动画(如站立、行走、跑步)之上叠加额外的动画效果(如潜行姿态、悲伤表情、点头同意等),实现更丰富的角色表现。

核心技术包括:

  • 如何将动画设置为叠加模式
  • 基础动画与叠加动画的权重控制
  • 动画之间的平滑过渡
  • 叠加动画参考帧的处理在这里插入图片描述

完整代码注释

<!DOCTYPE html>
<html lang="en">
	<head>
		<title>three.js webgl - additive 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: blue;
			}
			.control-inactive button {
				color: #888;  // 非活动状态的控制面板按钮样式
			}
		</style>
	</head>
	<body>
		<div id="container"></div>  <!-- 渲染场景的容器 -->
		<div id="info">
			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> - Skeletal Additive Animation Blending
			(model from <a href="https://www.mixamo.com/" target="_blank" rel="noopener">mixamo.com</a>)<br/>
		</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 { OrbitControls } from 'three/addons/controls/OrbitControls.js';  // 轨道控制器
			import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';  // GLTF模型加载器

			// 全局变量定义
			let scene, renderer, camera, stats;  // 场景、渲染器、相机和性能统计
			let model, skeleton, mixer, clock;  // 模型、骨骼辅助工具、动画混合器和时钟

			const crossFadeControls = [];  // 存储淡入淡出控制按钮的数组

			let currentBaseAction = 'idle';  // 当前基础动画动作
			const allActions = [];  // 存储所有动画动作的数组

			// 基础动画动作配置,包含权重信息
			const baseActions = {
				idle: { weight: 1 },
				walk: { weight: 0 },
				run: { weight: 0 }
			};

			// 叠加动画动作配置,包含权重信息
			const additiveActions = {
				sneak_pose: { weight: 0 },
				sad_pose: { weight: 0 },
				agree: { weight: 0 },
				headShake: { weight: 0 }
			};

			let panelSettings, numAnimations;  // 控制面板设置和动画数量

			// 初始化函数
			init();

			function init() {

				// 获取渲染容器
				const container = document.getElementById( 'container' );
				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 );

				// 创建地面平面
				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/Xbot.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 );

					// 获取模型中的所有动画
					const animations = gltf.animations;
					mixer = new THREE.AnimationMixer( model );  // 创建动画混合器

					numAnimations = animations.length;  // 记录动画数量

					// 处理所有动画
					for ( let i = 0; i !== numAnimations; ++ i ) {

						let clip = animations[ i ];  // 获取当前动画片段
						const name = clip.name;  // 获取动画名称

						if ( baseActions[ name ] ) {  // 如果是基础动画

							// 创建动画动作并激活
							const action = mixer.clipAction( clip );
							activateAction( action );
							baseActions[ name ].action = action;  // 存储动画动作
							allActions.push( action );  // 将动作添加到所有动作数组

						} else if ( additiveActions[ name ] ) {  // 如果是叠加动画

							// 将动画设置为叠加模式
							THREE.AnimationUtils.makeClipAdditive( clip );

							// 如果是姿态类动画,提取特定帧作为姿态
							if ( clip.name.endsWith( '_pose' ) ) {
								clip = THREE.AnimationUtils.subclip( clip, clip.name, 2, 3, 30 );
							}

							// 创建动画动作并激活
							const action = mixer.clipAction( clip );
							activateAction( action );
							additiveActions[ name ].action = action;  // 存储动画动作
							allActions.push( action );  // 将动作添加到所有动作数组

						}

					}

					// 创建控制面板
					createPanel();

					// 设置渲染循环
					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元素添加到容器中

				// 设置相机
				camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 1, 100 );
				camera.position.set( - 1, 2, 3 );

				// 添加轨道控制器,允许用户旋转和缩放场景
				const controls = new OrbitControls( camera, renderer.domElement );
				controls.enablePan = false;  // 禁用平移
				controls.enableZoom = false;  // 禁用缩放
				controls.target.set( 0, 1, 0 );  // 设置控制器目标点
				controls.update();  // 更新控制器状态

				// 添加性能统计面板
				stats = new Stats();
				container.appendChild( stats.dom );

				// 添加窗口大小变化事件监听,调整相机和渲染器
				window.addEventListener( 'resize', onWindowResize );

			}

			// 创建控制面板函数
			function createPanel() {

				// 创建GUI面板
				const panel = new GUI( { width: 310 } );

				// 创建不同功能的折叠面板
				const folder1 = panel.addFolder( 'Base Actions' );  // 基础动画控制面板
				const folder2 = panel.addFolder( 'Additive Action Weights' );  // 叠加动画权重控制面板
				const folder3 = panel.addFolder( 'General Speed' );  // 全局速度控制面板

				// 初始化控制面板设置
				panelSettings = {
					'modify time scale': 1.0  // 动画全局速度设置
				};

				// 获取所有基础动画名称,并添加"None"选项
				const baseNames = [ 'None', ...Object.keys( baseActions ) ];

				// 为每个基础动画添加控制按钮
				for ( let i = 0, l = baseNames.length; i !== l; ++ i ) {

					const name = baseNames[ i ];
					const settings = baseActions[ name ];
					
					// 为每个基础动画按钮设置点击事件
					panelSettings[ name ] = function () {

						// 获取当前和目标动画动作
						const currentSettings = baseActions[ currentBaseAction ];
						const currentAction = currentSettings ? currentSettings.action : null;
						const action = settings ? settings.action : null;

						// 如果当前动画和目标动画不同,则执行过渡
						if ( currentAction !== action ) {
							prepareCrossFade( currentAction, action, 0.35 );
						}

					};

					// 将控制按钮添加到面板并存储到控制数组
					crossFadeControls.push( folder1.add( panelSettings, name ) );

				}

				// 为每个叠加动画添加权重控制滑块
				for ( const name of Object.keys( additiveActions ) ) {

					const settings = additiveActions[ name ];

					// 添加滑块并监听变化
					panelSettings[ name ] = settings.weight;
					folder2.add( panelSettings, name, 0.0, 1.0, 0.01 ).listen().onChange( function ( weight ) {
						setWeight( settings.action, weight );  // 设置动画权重
						settings.weight = weight;  // 更新设置中的权重值
					} );

				}

				// 添加全局速度控制滑块
				folder3.add( panelSettings, 'modify time scale', 0.0, 1.5, 0.01 ).onChange( modifyTimeScale );

				// 默认打开所有折叠面板
				folder1.open();
				folder2.open();
				folder3.open();

				// 为控制按钮添加激活/禁用状态样式
				crossFadeControls.forEach( function ( control ) {

					// 设置按钮为非活动状态
					control.setInactive = function () {
						control.domElement.classList.add( 'control-inactive' );
					};

					// 设置按钮为活动状态
					control.setActive = function () {
						control.domElement.classList.remove( 'control-inactive' );
					};

					// 根据初始权重设置按钮状态
					const settings = baseActions[ control.property ];
					if ( ! settings || ! settings.weight ) {
						control.setInactive();
					}

				} );

			}

			// 激活动画动作的函数
			function activateAction( action ) {

				// 获取动画片段和对应的设置
				const clip = action.getClip();
				const settings = baseActions[ clip.name ] || additiveActions[ clip.name ];
				
				// 设置动画权重并播放
				setWeight( action, settings.weight );
				action.play();

			}

			// 修改动画全局速度的函数
			function modifyTimeScale( speed ) {
				mixer.timeScale = speed;  // 设置动画混合器的时间缩放
			}

			// 准备动画过渡的函数
			function prepareCrossFade( startAction, endAction, duration ) {

				// 如果当前动画是"idle",或者没有起始或目标动画,立即执行过渡
				if ( currentBaseAction === 'idle' || ! startAction || ! endAction ) {
					executeCrossFade( startAction, endAction, duration );
				} else {
					// 否则等待当前动画完成当前循环后再执行过渡
					synchronizeCrossFade( startAction, endAction, duration );
				}

				// 更新控制按钮状态
				if ( endAction ) {
					// 如果有目标动画,更新当前基础动画名称
					const clip = endAction.getClip();
					currentBaseAction = clip.name;
				} else {
					// 否则设置为"None"
					currentBaseAction = 'None';
				}

				// 更新所有控制按钮的激活/禁用状态
				crossFadeControls.forEach( function ( control ) {
					const name = control.property;
					if ( name === currentBaseAction ) {
						control.setActive();  // 设置为活动状态
					} else {
						control.setInactive();  // 设置为非活动状态
					}
				} );

			}

			// 同步动画过渡的函数,确保在动画循环结束时进行过渡
			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 ) {

				if ( endAction ) {  // 如果有目标动画

					// 设置目标动画权重为1,并重置时间
					setWeight( endAction, 1 );
					endAction.time = 0;

					if ( startAction ) {
						// 从起始动画过渡到目标动画(带warping效果)
						startAction.crossFadeTo( endAction, duration, true );
					} else {
						// 淡入目标动画(没有起始动画)
						endAction.fadeIn( duration );
					}

				} else {  // 如果没有目标动画
					// 淡出起始动画
					startAction.fadeOut( duration );
				}

			}

			// 设置动画权重的函数
			function setWeight( action, weight ) {
				action.enabled = true;  // 启用动画
				action.setEffectiveTimeScale( 1 );  // 设置时间缩放为1
				action.setEffectiveWeight( weight );  // 设置动画权重
			}

			// 窗口大小变化事件处理函数
			function onWindowResize() {
				camera.aspect = window.innerWidth / window.innerHeight;  // 更新相机宽高比
				camera.updateProjectionMatrix();  // 更新相机投影矩阵
				renderer.setSize( window.innerWidth, window.innerHeight );  // 更新渲染器尺寸
			}

			// 动画循环函数,每一帧都会被调用
			function animate() {

				// 更新所有动画的权重信息
				for ( let i = 0; i !== numAnimations; ++ i ) {
					const action = allActions[ i ];
					const clip = action.getClip();
					// 更新基础动画或叠加动画的权重
					const settings = baseActions[ clip.name ] || additiveActions[ clip.name ];
					settings.weight = action.getEffectiveWeight();
				}

				// 获取自上一帧以来的时间增量,用于更新动画混合器
				const mixerUpdateDelta = clock.getDelta();

				// 更新动画混合器,渲染场景,并更新性能统计
				mixer.update( mixerUpdateDelta );
				renderer.render( scene, camera );
				stats.update();
			}

		</script>

	</body>
</html>

整体总结

这个Three.js示例展示了骨骼动画的叠加混合技术,主要内容包括:

  1. 叠加动画原理

    • 叠加动画基于参考帧计算偏移量,可在基础动画之上叠加额外效果
    • 使用THREE.AnimationUtils.makeClipAdditive()将动画设置为叠加模式
    • 对于姿态类动画,使用subclip()提取特定帧作为参考姿态
  2. 动画控制系统

    • 基础动画系统:站立、行走、跑步等主要动作
    • 叠加动画系统:潜行姿态、悲伤表情、点头等次要动作
    • 动画过渡:使用crossFadeTo()fadeIn/fadeOut()实现平滑过渡
  3. 用户交互

    • 基础动画切换:通过按钮在不同基础动画间切换
    • 叠加动画权重控制:通过滑块调整各叠加动画的影响程度
    • 全局速度控制:调整所有动画的播放速度

这种叠加动画技术在游戏、虚拟角色动画等场景中非常有用,可以让角色表现更加丰富多样,同时保持动画系统的灵活性和可维护性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值