开启Three之旅(六)关键帧动画

277 阅读7分钟

关键帧动画

所谓关键帧动画,你可以理解为在时间轴上,选择几个关键的时间点,然后分别定义这几个时间点对应物体状态(比如位置、姿态、颜色等),然后基于几个关键的时间——状态数据,生成连续的动画

题外话:我也一直在探索写three.js更优雅、更系统的方式,所以下面部分采用class的方式

实现一个简单的关键帧动画

效果展示

20241011160102_rec_.gif

KeyframeTrack设置关键帧数据

KeyframeTrack参数1是一个字符串,字符串内容是模型对象的名字.属性构成

关键帧数据(时间——状态)

  • 0秒:坐标原点
  • 3秒:x轴上100坐标
  • 6秒:z轴上100坐标
// 给名为Box的模型对象的设置关键帧数据KeyframeTrack
const times = [0, 3, 6]; //时间轴上,设置三个时刻0、3、6秒
// times中三个不同时间点,物体分别对应values中的三个xyz坐标
const values = [0, 0, 0, 100, 0, 0, 0, 0, 100];
// 创建关键帧,把模型位置和时间对应起来
// 0~3秒,物体从(0,0,0)逐渐移动到(100,0,0),3~6秒逐渐从(100,0,0)移动到(0,0,100)
const posKF = new THREE.KeyframeTrack('Box.position', times, values);

创建动画完整流程

关键帧数据(时间——状态)

  • 2秒:红色
  • 5秒:蓝色
// 从2秒到5秒,物体从红色逐渐变化为蓝色
const colorKF = new THREE.KeyframeTrack('Box.material.color', [2, 5], [1, 0, 0, 0, 0, 1]);
const times = [0, 3, 6, 9] // 时间节点
const values = [0, 0, 0, 10, 0, 0, 0, 0, 10, 0, 0, 0] // 跟时间节点对应四个位置坐标(每三个为一个位置)

创建关键帧数据和关键帧动画AnimationClip(动画剪辑(AnimationClip)是一个可重用的关键帧轨道集,它代表动画), 注意 Boxmeshname名称,position是你想操作什么属性,我这里是position

const posKF = new THREE.KeyframeTrack('Box.position', times, values) // 关键帧数据
const clip = new THREE.AnimationClip("test", 9, [posKF]) // 关键帧动画

AnimationMixer是播放器,要与你的mesh进行绑定,到这一步可以理解成,你现在的mesh中含有这个动画,并且可以进行播放,接下来我们触发播放

执行播放器AnimationMixer.clipAction()方法返回一个AnimationAction对象,AnimationAction对象用来控制如何播放,比如.play()方法。

const mixer = new THREE.AnimationMixer(this.mesh1)
const clipAction =  mixer.clipAction(clip)
clipAction.play()

但是还没结束,要结合你的刷新函数

const clock = new THREE.Clock()
render() {
    renderer.render(scene, camera);
    requestAnimationFrame(this.render.bind(this));
    const frameT = clock.getDelta()
    mixer.update(frameT)
}

动画控制

循环、倍速、暂停

还记得AnimationMixer实例有个clipAction方法会返回AnimationAction一个实例对象,AnimationAction上有很多属性和方法

属性或方法作用
.clampWhenFinishedclampWhenFinished 值设为true, 那么动画将在最后一帧之后自动暂停
.loop循环模式1.默认值次数无穷THREE.LoopRepeat 2.只执行一次THREE.LoopOnce 3.次数无穷,起始点与结束点之间来回循环THREE.LoopPingPong
.play()不必多说,就是播放
.stop()不必多说,就是停止(回到初始状态)
.paused()不必多说,就是暂停
.time把动画设置为暂停状态,然后你可以通过AnimationAction.time把动画定格在时间轴上任何位置

注意:time是一直变化的,我们可以手动改动,可以获取

更多参考官方文档AnimationAction

AnimationClip上也有很多可操作的属性和方法

以上demo的完成代码

// 碰撞测试
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import Stats from 'three/examples/jsm/libs/stats.module';
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
export default class Core {
    camera = null;
    controls = null;
    light = null;
    renderer = null;
    stats = null;
    is_load_finished = false;
    mesh1 = null;
    mixer = null;
    clock = null;
    gui = null;

    constructor() {
        this.scene = new THREE.Scene();
        this.scene.background = new THREE.Color(0x474747);
        this.init_renderer()
        this.init_camera()
        this.init_gui()
        this.init_control()
        this.init_light()
        this.init_grid()
        this.init_stats()
        this.async_init()
        this.init_animation()
    }
    async async_init() {
        await this.init_box()
        this.is_load_finished = true
    }
    init_grid() {
        const grid = new THREE.GridHelper(20, 20, 0xc1c1c1, 0x8d8d8d);
        this.scene.add(grid);
        const axesHelper = new THREE.AxesHelper(5);
        this.scene.add(axesHelper);
    }
    init_box() {
        // eslint-disable-next-line no-unused-vars
        return new Promise((reslove, reject) => {
            const box = new THREE.BoxGeometry(1, 1, 1);
            const material = new THREE.MeshBasicMaterial({ color: 0xffffff });
            this.mesh1 = new THREE.Mesh(box, material);
            this.mesh1.name = "Box"
            this.mesh1.position.set(2, 0, 0)
            this.scene.add(this.mesh1);
            reslove()
        })
    }
    init_animation() {
        const times = [0, 3, 6, 9]
        const values = [0, 0, 0, 10, 0, 0, 0, 0, 10, 0, 0, 0]
        const posKF = new THREE.KeyframeTrack('Box.position', times, values)
        const clip = new THREE.AnimationClip("test", 9, [posKF])
        this.mixer = new THREE.AnimationMixer(this.mesh1)
        const clipAction = this.mixer.clipAction(clip)
        clipAction.loop = THREE.LoopOnce
        clipAction.clampWhenFinished = true
        clipAction.time = 2
        clip.duration = 3
        clipAction.play()
        const guiControls = {
            clickButton: function () {
                console.log("暂停动画");
                clipAction.paused = !clipAction.paused
            },
            stopFunction: function () {
                clipAction.stop()
            },
            resetFunction: function () {
                clipAction.reset()
            },
            playFunction: function () {
                clipAction.play()
            }
        };
        this.gui.add(guiControls, 'clickButton').name('暂停')
        this.gui.add(clipAction, 'timeScale', 1, 5).name('1-5倍速')
        this.gui.add(guiControls, 'stopFunction').name('停止动画回到起点')
        this.gui.add(guiControls, 'playFunction').name('开始动画')
        this.gui.add(guiControls, 'resetFunction').name('重置动画')
        this.clock = new THREE.Clock()
    }
    init_camera() {
        this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        this.camera.position.set(0, 10, 10)
        this.camera.lookAt(new THREE.Vector3(0, 0, 0))
        this.scene.add(this.camera)
    }
    init_control() {
        this.controls = new OrbitControls(this.camera, this.renderer.domElement)
        this.controls.enableDamping = true
        this.controls.target.set(0, 0, 0)
    }
    init_light() {
        this.light = new THREE.DirectionalLight(0xffffff, 1)
        this.light.position.set(10, 10, 10)
        this.scene.add(this.light)
    }
    init_renderer() {
        this.renderer = new THREE.WebGLRenderer({
            antialias: true,
        });
        this.renderer.setPixelRatio(window.devicePixelRatio);
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(this.renderer.domElement);
        window.addEventListener("resize", () => {
            this.camera.aspect = window.innerWidth / window.innerHeight;
            this.camera.updateProjectionMatrix();
            this.renderer.setSize(window.innerWidth, window.innerHeight);
            this.renderer.setPixelRatio(window.devicePixelRatio);
        });
    }
    init_stats() {
        this.stats = new Stats();
        document.body.appendChild(this.stats.dom);
    }
    init_gui() {
        this.gui = new GUI()
        this.gui.close()
    }
    render() {
        this.stats.update()
        this.renderer.render(this.scene, this.camera);
        requestAnimationFrame(this.render.bind(this));
        const frameT = this.clock.getDelta()
        this.mixer.update(frameT)
    }
    destory() {
        this.renderer.domElement.remove()
        this.renderer.dispose()
    }
}

解析外部模型关键帧动画

gltf、fbx等可以包含动画的文件,通过threejs代码加载模型、解析模型包含的动画数据

loader.load("../工厂.glb", function (gltf) { 
    console.log('控制台查看gltf对象结构', gltf);
    // console.log('动画数据', gltf.animations);
    model.add(gltf.scene); 

    //包含关键帧动画的模型作为参数创建一个播放器
    const mixer = new THREE.AnimationMixer(gltf.scene);
    //  获取gltf.animations[0]的第一个clip动画对象
    const clipAction = mixer.clipAction(gltf.animations[0]); //创建动画clipAction对象
    clipAction.play(); //播放动画

    // 如果想播放动画,需要周期性执行`mixer.update()`更新AnimationMixer时间数据
    const clock = new THREE.Clock();
    function loop() {
        requestAnimationFrame(loop);
        //clock.getDelta()方法获得loop()两次执行时间间隔
        const frameT = clock.getDelta();
        // 更新播放器相关的时间
        mixer.update(frameT);
    }
    loop();
})

官方案例-skinning and morphin-源代码分析

官方网站上案例 threejs.org/examples/#w…

20250121140127_rec_.gif

我去除了部分多余内容,对关键部分进行了详细地注释

  <!DOCTYPE html>
<html lang="en">
	<head>
		<title>three.js webgl - skinning and morphing</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>
			body {
				color: #222;
			}
			a {
				color: #2fa1d6;
			}
			p {
				max-width: 600px;
				margin-left: auto;
				margin-right: auto;
				padding: 0 2em;
			}
		</style>
	</head>

	<body>
		<script type="importmap">
			{
				"imports": {
					"three": "../build/three.module.js",
					"three/addons/": "./jsm/"
				}
			}
		</script>

		<script type="module">
			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';
			import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
			let container, stats, clock, gui, mixer, actions, activeAction, previousAction;
			let camera, scene, renderer, model, face;
			const api = { state: 'Walking' };
			init();
			function init() {
				container = document.createElement( 'div' );
				document.body.appendChild( container );
				// camera
				camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.25, 100 );
				camera.position.set( - 5, 3, 10 );
				camera.lookAt( 0, 2, 0 );
				// scene
				scene = new THREE.Scene();
				scene.background = new THREE.Color( 0xe0e0e0 );
				scene.fog = new THREE.Fog( 0xe0e0e0, 20, 100 );
				clock = new THREE.Clock();
				// lights
				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( 0, 20, 10 );
				scene.add( dirLight );
				// ground
				const mesh = new THREE.Mesh( new THREE.PlaneGeometry( 2000, 2000 ), new THREE.MeshPhongMaterial( { color: 0xcbcbcb, depthWrite: false } ) );
				mesh.rotation.x = - Math.PI / 2;
				scene.add( mesh );
				const grid = new THREE.GridHelper( 200, 40, 0x000000, 0x000000 );
				grid.material.opacity = 0.2;
				grid.material.transparent = true;
				scene.add( grid );
				// 加载机器人模型
				const loader = new GLTFLoader();
				loader.load( 'models/gltf/RobotExpressive/RobotExpressive.glb', function ( gltf ) {
					model = gltf.scene;
					scene.add( model );
					createGUI( model, gltf.animations ); // 参数:model、model中的所有动画
				}, undefined, function ( e ) {
					console.error( e );
				} );
				// renderer
				renderer = new THREE.WebGLRenderer( { antialias: true } );
				renderer.setPixelRatio( window.devicePixelRatio );
				renderer.setSize( window.innerWidth, window.innerHeight );
				renderer.setAnimationLoop( animate );
				container.appendChild( renderer.domElement );
				// resize
				window.addEventListener( 'resize', onWindowResize );
				// stats
				stats = new Stats();
				container.appendChild( stats.dom );
			}
			// 创建GUI
			function createGUI( model, animations ) {
				const states = [ 'Idle', 'Walking', 'Running', 'Dance', 'Death', 'Sitting', 'Standing' ];
				const emotes = [ 'Jump', 'Yes', 'No', 'Wave', 'Punch', 'ThumbsUp' ];
				gui = new GUI();
				mixer = new THREE.AnimationMixer( model ); // 与模型绑定,mixer(播放器)
				actions = {};
				// 需要为每一个模型中的动画,创建一个对应的AnimationAction并保存到actions中,其可控制对应动画播放
				for ( let i = 0; i < animations.length; i ++ ) {
					const clip = animations[ i ];
					const action = mixer.clipAction( clip ); // clipAction:将动画与mixer绑定,返回一个AnimationAction,其可控制动画播放
					actions[ clip.name ] = action;  // 保存这些AnimationAction,方便后续播放动画
					if ( emotes.indexOf( clip.name ) >= 0 || states.indexOf( clip.name ) >= 4 ) { // 区分动画是播放一次还是持久化连续播放
						action.clampWhenFinished = true; // 动画最后一帧暂停
						action.loop = THREE.LoopOnce;  // 执行一次
					}
				}
				// states --> State切换触发新的动画
				const statesFolder = gui.addFolder( 'States' );
				const clipCtrl = statesFolder.add( api, 'state' ).options( states );
				clipCtrl.onChange( function () { 
					fadeToAction( api.state, 0.5 );
				} );
				statesFolder.open();
				// emotes
				const emoteFolder = gui.addFolder( 'Emotes' );
				function createEmoteCallback( name ) {
					api[ name ] = function () {
						fadeToAction( name, 0.2 );
						mixer.addEventListener( 'finished', restoreState ); // 目的是emotes播放完后,按照之前的state继续播放
					};
					emoteFolder.add( api, name );
				}
				function restoreState() {
					mixer.removeEventListener( 'finished', restoreState ); // 播放结束,按照之前的state的继续播放
					fadeToAction( api.state, 0.2 );
				}
				for ( let i = 0; i < emotes.length; i ++ ) {
					createEmoteCallback( emotes[ i ] );
				}
				emoteFolder.open();
				// expressions
				face = model.getObjectByName( 'Head_4' );
				const expressions = Object.keys( face.morphTargetDictionary );
				const expressionFolder = gui.addFolder( 'Expressions' );
				for ( let i = 0; i < expressions.length; i ++ ) {
					expressionFolder.add( face.morphTargetInfluences, i, 0, 1, 0.01 ).name( expressions[ i ] );
				}
				// 播放动画(默认Walking)
				activeAction = actions[ 'Walking' ];
				activeAction.play();
				// 默认展开
				expressionFolder.open();
			}
			// 播放动画
			function fadeToAction( name, duration ) {
				previousAction = activeAction;
				activeAction = actions[ name ];
				if ( previousAction !== activeAction ) {
					previousAction.fadeOut( duration ); // 切换动画时,先将之前的动画淡出
				}
				activeAction
					.reset() // 重置动画
					.setEffectiveTimeScale( 1 ) // 动画播放速度
					.setEffectiveWeight( 1 ) // 动画权重
					.fadeIn( duration ) // 切换到新动画时,新动画淡入
					.play(); // 播放新动画
			}
			function onWindowResize() {
				camera.aspect = window.innerWidth / window.innerHeight;
				camera.updateProjectionMatrix();
				renderer.setSize( window.innerWidth, window.innerHeight );
			}
			//
			function animate() {
				const dt = clock.getDelta();
				if ( mixer ) mixer.update( dt );
				renderer.render( scene, camera );
				stats.update();
			}
		</script>
	</body>
</html>

参考资料

关键帧动画

three.js/docs