动画原理
在这里,我们将动态画面简称为动画(animation)。正如动画片的原理一样,动画的本质是利用了人眼的视觉暂留特性,快速地变换画面,从而产生物体在运动的假象。而对于Three.js程序而言,动画的实现也是通过在每秒中多次重绘画面实现的。
为了衡量画面切换速度,引入了每秒帧数FPS(Frames Per Second)的概念,是指每秒画面重绘的次数。FPS越大,则动画效果越平滑,当FPS小于20时,一般就能明显感受到画面的卡滞现象。
那么FPS是不是越大越好呢?其实也未必。当FPS足够大(比如达到60),再增加帧数人眼也不会感受到明显的变化,反而相应地就要消耗更多资源(比如电影的胶片就需要更长了,或是电脑刷新画面需要消耗计算资源等等)。因此,选择一个适中的FPS即可。
NTSC标准的电视FPS是30,PAL标准的电视FPS是25,电影的FPS标准为24。而对于Three.js动画而言,一般FPS在30到60之间都是可取的。
setInterval方法
如果要设置特定的FPS(虽然严格来说,即使使用这种方法,JavaScript也不能保证帧数精确性),可以使用JavaScript DOM定义的方法:
setInterval(func, msec)
其中,func
是每过msec
毫秒执行的函数,如果将func
定义为重绘画面的函数,就能实现动画效果。setInterval
函数返回一个id
,如果需要停止重绘,需要使用clearInterval
方法,并传入该id
,具体的做法为:
首先,在init
函数中定义每20
毫秒执行draw
函数的setInterval
,返回值记录在全局变量id
中:
id = setInterval(draw, 20);
在draw
函数中,我们首先设定在每帧中的变化(毕竟,如果每帧都是相同的,即使重绘再多次,还是不会有动画的效果),这里我们让场景中的长方体绕y轴转动。然后,执行渲染:
function draw() {
mesh.rotation.y = (mesh.rotation.y + 0.01) % (Math.PI * 2);
renderer.render(scene, camera);
}
这样,每20
毫秒就会调用一次draw
函数,改变长方体的旋转值,然后进行重绘。最终得到的效果就是FPS为50
的旋转长方体。
我们在HTML中添加一个按钮,按下后停止动画:
<button id="stopBtn" onclick="stop()">Stop</button>
对应的stop
函数为:
function stop() {
if (id !== null) {
clearInterval(id);
id = null;
}
}
requestAnimationFrame方法
大多数时候,我们并不在意多久重绘一次,这时候就适合用requestAnimationFrame方法了。它告诉浏览器在合适的时候调用指定函数,通常可能达到60FPS。
requestAnimationFrame
同样有对应的cancelAnimationFrame
取消动画:
function stop() {
if (id !== null) {
cancelAnimationFrame(id);
id = null;
}
}
和setInterval
不同的是,由于requestAnimationFrame
只请求一帧画面,因此,除了在init
函数中需要调用,在被其调用的函数中需要再次调用requestAnimationFrame
:
function draw() {
mesh.rotation.y = (mesh.rotation.y + 0.01) % (Math.PI * 2);
renderer.render(scene, camera);
id = requestAnimationFrame(draw);
}
因为requestAnimationFrame
较为“年轻”,因而一些老的浏览器使用的是试验期的名字:mozRequestAnimationFrame
、webkitRequestAnimationFrame
、msRequestAnimationFrame
,为了支持这些浏览器,我们最好在调用之前,先判断是否定义了requestAnimationFrame
以及上述函数:
var requestAnimationFrame = window.requestAnimationFrame
|| window.mozRequestAnimationFrame
|| window.webkitRequestAnimationFrame
|| window.msRequestAnimationFrame;
window.requestAnimationFrame = requestAnimationFrame;
如何取舍
setInterval
方法与requestAnimationFrame
方法的区别较为微妙。一方面,最明显的差别表现在setInterval
可以手动设定FPS,而requestAnimationFrame
则会自动设定FPS;但另一方面,即使是setInterval
也不能保证按照给定的FPS执行,在浏览器处理繁忙时,很可能低于设定值。当浏览器达不到设定的调用周期时,requestAnimationFrame
采用跳过某些帧的方式来表现动画,虽然会有卡滞的效果但是整体速度不会拖慢,而setInterval
会因此使整个程序放慢运行,但是每一帧都会绘制出来;
总而言之,requestAnimationFrame
适用于对于时间较为敏感的环境(但是动画逻辑更加复杂),而setInterval
则可在保证程序的运算不至于导致延迟的情况下提供更加简洁的逻辑(无需自行处理时间)。
stat.js是Three.js的作者Mr. Doob的另一个有用的JavaScript库。很多情况下,我们希望知道实时的FPS信息,从而更好地监测动画效果。这时候,stat.js就能提供一个很好的帮助,它占据屏幕中的一小块位置(如左上角),效果为:,单击后显示每帧渲染时间:。
首先,我们需要下载stat.js文件,可以在https://github.com/mrdoob/stats.js/blob/master/build/stats.min.js找到。下载后,将其放在项目文件夹下,然后在HTML中引用:
<script type="text/javascript" src="stat.js"></script>
在页面初始化的时候,对其初始化并将其添加至屏幕一角。这里,我们以右上角为例:
var stat = null;
function init() {
stat = new Stats();
stat.domElement.style.position = 'absolute';
stat.domElement.style.right = '0px';
stat.domElement.style.top = '0px';
document.body.appendChild(stat.domElement);
// Three.js init ...
}
然后,在上一节介绍的动画重绘函数draw
中调用stat.begin();
与stat.end();
分别表示一帧的开始与结束:
function draw() {
stat.begin();
mesh.rotation.y = (mesh.rotation.y + 0.01) % (Math.PI * 2);
renderer.render(scene, camera);
stat.end();
}
最终就能得到FPS效果了。
本节我们将使用一个弹球的例子来完整地学习使用动画效果。
首先,我们把通用的框架部分写好,按照4.1节的方法实现动画重绘函数,并按6.2节的方法加入stat.js库:
var requestAnimationFrame = window.requestAnimationFrame
|| window.mozRequestAnimationFrame
|| window.webkitRequestAnimationFrame
|| window.msRequestAnimationFrame;
window.requestAnimationFrame = requestAnimationFrame;
var scene = null;
var camera = null;
var renderer = null;
var id = null;
var stat = null;
function init() {
stat = new Stats();
stat.domElement.style.position = 'absolute';
stat.domElement.style.right = '0px';
stat.domElement.style.top = '0px';
document.body.appendChild(stat.domElement);
renderer = new THREE.WebGLRenderer({
canvas: document.getElementById('mainCanvas')
});
scene = new THREE.Scene();
id = requestAnimationFrame(draw);
}
function draw() {
stat.begin();
renderer.render(scene, camera);
id = requestAnimationFrame(draw);
stat.end();
}
function stop() {
if (id !== null) {
cancelAnimationFrame(id);
id = null;
}
}
然后,为了实现弹球弹动的效果,我们创建一个球体作为弹球模型,创建一个平面作为弹球反弹的平面。为了在draw
函数中改变弹球的位置,我们可以声明一个全局变量ballMesh
,以及弹球半径ballRadius
。
var ballMesh = null;
var ballRadius = 0.5;
在init
函数中添加球体和平面,使弹球位于平面上,平面采用棋盘格图像作材质:
// ball
ballMesh = new THREE.Mesh(new THREE.SphereGeometry(ballRadius, 16, 8),
new THREE.MeshLambertMaterial({
color: 0xffff00
}));
ballMesh.position.y = ballRadius;
scene.add(ballMesh);
// plane
var texture = THREE.ImageUtils.loadTexture('../img/chess.png', {}, function() {
renderer.render(scene, camera);
});
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(4, 4);
var plane = new THREE.Mesh(new THREE.PlaneGeometry(5, 5),
new THREE.MeshLambertMaterial({map: texture}));
plane.rotation.x = -Math.PI / 2;
scene.add(plane);
现在,每帧绘制的都是相同的效果:
为了记录弹球的状态,我们至少需要位置、速度、加速度三个矢量,为了简单起见,这里弹球只做竖直方向上的自由落体运动,因此位置、速度、加速度只要各用一个变量表示。其中,位置就是ballMesh.position.y
,不需要额外的变量,因此我们在全局声明速度v
和加速度a
:
var v = 0;
var a = -0.1;
这里,a = -0.1
代表每帧小球向y方向负方向移动0.1
个单位。
一开始,弹球从高度为maxHeight
处自由下落,掉落到平面上时会反弹,并且速度有损耗。当速度很小的时候,弹球会在平面上作振幅微小的抖动,所以,当速度足够小时,我们需要让弹球停止跳动。因此,定义一个全局变量表示是否在运动,初始值为false
:
var isMoving = false;
在HTML中定义一个按钮,点击按钮时,弹球从最高处下落:
function drop() {
isMoving = true;
ballMesh.position.y = maxHeight;
v = 0;
}
下面就是最关键的函数了,在draw
函数中,需要判断当前的isMoving
值,并且更新小球的速度和位置:
function draw() {
stat.begin();
if (isMoving) {
ballMesh.position.y += v;
v += a;
if (ballMesh.position.y <= ballRadius) {
// hit plane
v = -v * 0.9;
}
if (Math.abs(v) < 0.001) {
// stop moving
isMoving = false;
ballMesh.position.y = ballRadius;
}
}
renderer.render(scene, camera);
id = requestAnimationFrame(draw);
stat.end();
}
这样就实现小球的弹动效果了。最终的代码为:
var requestAnimationFrame = window.requestAnimationFrame
|| window.mozRequestAnimationFrame
|| window.webkitRequestAnimationFrame
|| window.msRequestAnimationFrame;
window.requestAnimationFrame = requestAnimationFrame;
var scene = null;
var camera = null;
var renderer = null;
var id = null;
var stat = null;
var ballMesh = null;
var ballRadius = 0.5;
var isMoving = false;
var maxHeight = 5;
var v = 0;
var a = -0.01;
function init() {
stat = new Stats();
stat.domElement.style.position = 'absolute';
stat.domElement.style.right = '0px';
stat.domElement.style.top = '0px';
document.body.appendChild(stat.domElement);
renderer = new THREE.WebGLRenderer({
canvas: document.getElementById('mainCanvas')
});
scene = new THREE.Scene();
camera = new THREE.OrthographicCamera(-5, 5, 3.75, -3.75, 0.1, 100);
camera.position.set(5, 10, 20);
camera.lookAt(new THREE.Vector3(0, 3, 0));
scene.add(camera);
// ball
ballMesh = new THREE.Mesh(new THREE.SphereGeometry(ballRadius, 16, 8),
new THREE.MeshLambertMaterial({
color: 0xffff00
}));
ballMesh.position.y = ballRadius;
scene.add(ballMesh);
// plane
var texture = THREE.ImageUtils.loadTexture('../img/chess.png', {}, function() {
renderer.render(scene, camera);
});
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(4, 4);
var plane = new THREE.Mesh(new THREE.PlaneGeometry(5, 5),
new THREE.MeshLambertMaterial({map: texture}));
plane.rotation.x = -Math.PI / 2;
scene.add(plane);
var light = new THREE.DirectionalLight(0xffffff);
light.position.set(10, 10, 15);
scene.add(light);
id = requestAnimationFrame(draw);
}
function draw() {
stat.begin();
if (isMoving) {
ballMesh.position.y += v;
v += a;
if (ballMesh.position.y <= ballRadius) {
// hit plane
v = -v * 0.9;
}
if (Math.abs(v) < 0.001) {
// stop moving
isMoving = false;
ballMesh.position.y = ballRadius;
}
}
renderer.render(scene, camera);
id = requestAnimationFrame(draw);
stat.end();
}
function stop() {
if (id !== null) {
cancelAnimationFrame(id);
id = null;
}
}
function drop() {
isMoving = true;
ballMesh.position.y = maxHeight;
v = 0;
}