关键帧动画
所谓关键帧动画,你可以理解为在时间轴上,选择几个关键的时间点,然后分别定义这几个时间点对应物体状态(比如位置、姿态、颜色等),然后基于几个关键的时间——状态数据,生成连续的动画
题外话:我也一直在探索写three.js更优雅、更系统的方式,所以下面部分采用class的方式
实现一个简单的关键帧动画
效果展示
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)是一个可重用的关键帧轨道集,它代表动画), 注意 Box 是mesh的name名称,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上有很多属性和方法
| 属性或方法 | 作用 |
|---|---|
| .clampWhenFinished | clampWhenFinished 值设为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…
我去除了部分多余内容,对关键部分进行了详细地注释
<!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>