动画基本原理
撰写时间:2024-06-29
修订时间:2025-11-05
本站中多个地方均有涉及动画的相关知识,动画所涉及到的知识点还是相对较多的。在这里,我们将结合canvas这个2D渲染平台,集中详细讲述动画的基本原理。
Web端的动画实现
共有3种方式,一是CSS, 二是Web Animation API, 三是requestAnimationFrame。
本文以第三种方式来实现。
requestAnimation基本用法
let index = 0;
requestAnimationFrame(animate);
function animate(timeStamp) {
if (index <= 10) {
pc.log(`%d: %f`, index, timeStamp);
}
index++;
requestAnimationFrame(animate);
}
先以animate函数作为参数,调用requestAnimation函数。animate函数共有两个职责,需先处理动画业务,最后再以该函数作为参数调用requestAnimation函数。这样动画就可不断地进行下去。
在每次调用requestAnimation函数时,requestAnimation函数都会向其回调函数注入一个timeStamp的参数。该参数的数据类型为Number。在Safari中为整数,在Chrome中为浮点数,表示上一帧渲染结束的时间戳。上面代码打印出前10帧的时间戳。
timeStamp
timeStamp值实为document.timeline的currentTime属性值。下面比较两者的数值。
let index = 0;
pc.log(document.timeline);
requestAnimationFrame(animate);
function animate(timeStamp) {
if (index <= 10) {
pc.log(`%d: timeStamp = %f, currentTime = %f`, index, timeStamp, document.timeline.currentTime);
}
index++;
requestAnimationFrame(animate);
}
可见,timeStamp含义应是指当前渲染开始的时间。
一般情况下,在动画处理部分,我们需要知道自上一帧渲染结束以来所流逝的时间。
Web Animations Module Level 2的AnimationTimeline除了有currentTime属性外,还有duration属性。但Web Animations Module Level 1无duration属性。目前Safari及Chrome仅支持Web Animations Module Level 1。
let index = 0;
let prevTimeStamp = document.timeline.currentTime;
requestAnimationFrame(animate);
function animate(currentTimeStamp) {
if (index <= 10) {
let timeElapsed = currentTimeStamp - prevTimeStamp;
pc.log(`%d: prevTimeStamp = %f, currentTimeStamp = %f, timeElapsed = %f`, index, prevTimeStamp, currentTimeStamp, timeElapsed);
}
prevTimeStamp = currentTimeStamp;
index++;
requestAnimationFrame(animate);
}
在Chrome中,有可能前两次的timeElapsed值为0或0.xx,其余的值均在[16, 17]的范围内。而在Safari中,可能前6次的值或大或小,后面的值才落在[16, 17]的范围内。
鉴于此,如果对渲染时间要求较高,需等待渲染时间变为较为稳定之后才开始正式渲染动画,可跳过前面特定的帧数。
let index = 0;
let prevTimeStamp;
skipAnimationFrames(10, animate);
function skipAnimationFrames(frameCnt, nextCallbackFn) {
let index = 0;
function updatePrevTimeStamp(currentTimeStamp) {
pc.log(`%d: %f`, index, currentTimeStamp);
prevTimeStamp = currentTimeStamp;
index++;
if (index < frameCnt) {
requestAnimationFrame(updatePrevTimeStamp);
} else {
requestAnimationFrame(nextCallbackFn);
}
}
updatePrevTimeStamp(document.timeline.currentTime);
}
function animate(currentTimeStamp) {
if (index <= 5) {
let timeElapsed = currentTimeStamp - prevTimeStamp;
pc.log(`%d: prevTimeStamp = %f, currentTimeStamp = %f, timeElapsed = %f`, index, prevTimeStamp, currentTimeStamp, timeElapsed);
}
prevTimeStamp = currentTimeStamp;
index++;
requestAnimationFrame(animate);
}
skipAnimationFrames函数的意义为,跑过前面特定次数的帧数,然后才开始渲染animate函数里面的动画内容。
requestAnimationFrame以异步的方式进行,因此updatePrevTimeStamp函数是在异步线程中被依次调用,故而我们无法在该函数内简单地向主线程地返回一个数值。因此,在updatePrevTimeStamp函数中需更新全局变量prevTimeStamp的值,以供animate函数使用。
包装核心代码
观察上面的代码,存在以下几点不足。
第一,全局变量prevTimeStamp必须事先声明,否则代码无法运行。而它只不过是计算时间流逝timeElapsed的一个中间变量。
第二,在animate函数中,由于requestAnimationFrame只注入一个时间戳的参数,我们还得手工计算timeElapsed的值。
第三,我们可能需要知道当前所渲染的帧数。
第四,我们可能需要仅渲染特定帧数即停止渲染动画,以免浪费宝贵的计算资源。
第五,甚至,由客户端来自行决定更复杂的终止渲染的条件。
鉴此,我们可将上面的核心代码包装为一个工具类方法,以方便客户端调用。
如果InitAnimationFrames提供了第3个参数,则在达到所指定的渲染次数后终止渲染。如果未提供此参数,则不停断地渲染下去。
而对每一次的动画渲染回调函数参数animateCallbackFn,都注入当前渲染帧数、当前时间戳、流逝时间以及stopAnimation函数这4个值。以下是客户端的调用:
const { AnimationFrameUtils: AFUtils } = await import('/js/esm/AnimationFrameUtils.js');
function animate(currentFrameIndex, currentTimeStamp, timeElapsed) {
pc.log(currentFrameIndex, currentTimeStamp, timeElapsed);
}
AFUtils.InitAnimationFrames(10, animate, 5);
跳过前10帧后才正式渲染动画,正式渲染5帧即停止。
下面由客户端根据自定义的业务逻辑来决定终止渲染的条件。例如,当某个数值超过129时,可在animate函数内调用所传入的stopAnimation函数指针以终止渲染:
const { AnimationFrameUtils: AFUtils} = await import('/js/esm/AnimationFrameUtils.js');
let num = 10;
function animate(currentFrameIndex, currentTimeStamp, timeElapsed, stopAnimation) {
pc.log(num);
num += 37;
if (num > 129) {
stopAnimation();
}
}
AFUtils.InitAnimationFrames(10, animate);
传入stopAnimation函数指针,可将内部实现的细节向客户端隐藏,以后版本升级后,客户端之前的代码不受影响。
充分利用函数回调机制,我们的动画渲染函数更加灵活、强健。
实现基本动画
const { AnimationFrameUtils: AFUtils } = await import('/js/esm/AnimationFrameUtils.js');
ctx.fillStyle = 'green';
let x = 50;
let y = canvas.clientHeight / 2;
let r = 25;
let offset = 1;
function animate(index, timeStamp, timeElapsed) {
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
x += offset;
}
AFUtils.InitAnimationFrames(10, animate);
在渲染每一帧的代码中,先根据x, y, r的值渲染圆球,接着改变x的值,再申请渲染下一帧,如此循环下去。由于在每一帧中,只有x的值发生改变,因此就形成了一个不断向右移动的小球的动画。
因此,动画的本质在于,一个物体的特定属性,随着时间的流逝,其值发生了变化。
由上,要设置动画,我们需考虑以下几点:
- 要对物体哪个属性设置动画?
- 在不同的时间戳,该属性值应是多少?
上面,我们对小球的X轴的坐标属性设置了动画;随着时间的流逝,X轴坐标值均增加1的增量。
我们可以同时对物体的多个属性设置动画。例如,对于一个车轮,边旋转,边前进。
const { AnimationFrameUtils: AFUtils } = await import('/js/esm/AnimationFrameUtils.js');
let x = 50;
let y = canvas.clientHeight / 2;
let r = 25;
let rotateAngle = 0;
let offset = 1;
let rotateDelta = 1;
function animate(index, timeStamp, timeElapsed) {
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
ctx.save();
ctx.translate(x, y);
ctx.rotate(Math.PI / 180 * rotateAngle);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.fillStyle = 'green';
ctx.fill();
ctx.moveTo(-r, 0);
ctx.lineTo(r, 0);
ctx.moveTo(0, -r);
ctx.lineTo(0, r);
ctx.strokeStyle = 'white';
ctx.stroke();
ctx.restore();
x += offset;
rotateAngle += rotateDelta;
}
AFUtils.InitAnimationFrames(10, animate);
上面通过应用了ctx的平移及旋转这两种变换,从而非常方便地实现了车轮边旋转边前进的动画效果。概括来说,就是随着时间的流逝,车轮的旋转角度及X轴坐标值均发生了改变,因此产生了两种效果的动画。
在应用变换时,平移及旋转的先后应用次序不同,所产生的效果也不同;如何围绕物体自身的中心旋转?这些都将在下面章节中具体阐述。这里我们只需了解,我们可以为物体的多个属性同时设置动画就行了。
小球碰壁反弹
在编写动画代码时,按下面简单的逻辑来处理:
- 先根据物体现有的状态来渲染物体
- 根据特定规律,改变需设置动画的属性值
- 申请渲染下一帧
由此,我们很容易得到小球碰到右壁后反弹回来的效果。
const { AnimationFrameUtils: AFUtils } = await import('/js/esm/AnimationFrameUtils.js');
ctx.fillStyle = 'green';
let x = 50;
let y = canvas.clientHeight / 2;
let r = 25;
let offset = 5;
function animate(index, timeStamp, timeElapsed) {
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
renderFrame();
updateState();
}
function renderFrame() {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
function updateState() {
let prevX = x;
x += offset;
if (x + r > canvas.clientWidth) {
x = canvas.clientWidth - r;
offset = -offset;
if (prevX === x) {
x += offset;
}
}
}
AFUtils.InitAnimationFrames(10, animate);
代码:
let prevX = x;
x += offset;
if (x + r > canvas.clientWidth) {
x = canvas.clientWidth - r;
offset = -offset;
if (prevX === x) {
x += offset;
}
}
用于判断小球是否碰到右壁。当offset的值大于1时,就可能出现这种情况:在当前帧下,小球尚未碰壁,而在下一帧中,小球直接穿过右壁而不是正好顶住右壁。这种情况下,需让小球正好顶住右壁再反弹。所以代码:
x = canvas.clientWidth - r;
的作用正是让小球正好与右壁相切。
然而,如果offset的值正好为1时,上面的代码将出现前后两帧均正好顶住右壁的重复情况。因此,使用变量prevX来记录当前帧下x的值,如果其值与下一帧x的值正好相等,则直接回退一个位置。
同样,碰到左壁后反弹:
const { AnimationFrameUtils: AFUtils } = await import('/js/esm/AnimationFrameUtils.js');
ctx.fillStyle = 'green';
let x = 50;
let y = canvas.clientHeight / 2;
let r = 25;
let offset = 5;
function animate(index, timeStamp, timeElapsed) {
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
renderFrame();
updateState();
}
function renderFrame() {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
function updateState() {
let prevX = x;
x += offset;
if (x + r > canvas.clientWidth) {
x = canvas.clientWidth - r;
offset = -offset;
}
if (x - r < 0) {
x = r;
offset = -offset;
}
if (prevX === x) {
x += offset;
}
}
AFUtils.InitAnimationFrames(10, animate);
body {
overflow: hidden;
}
上面,我们通过CSS将滚动条屏蔽掉。这主要适用于Chrome浏览器。Safari的滚动条会自动隐藏,因此也可以无需此CSS设置。
这一节,我们初步接触了动画领域中的碰撞检测
的问题。这方面有待于进一步深入学习。
帧率
const { AnimationFrameUtils: AFUtils } = await import('/js/esm/AnimationFrameUtils.js');
ctx.font = '48px Helvetica';
ctx.fillStyle = 'DEEPSKYBLUE';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
function animate(index, timeStamp, timeElapsed) {
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
showRates(timeElapsed);
}
function showRates(elapsed) {
ctx.fillText(`${elapsed.toFixed(2)}ms`, canvas.clientWidth / 2, canvas.clientHeight / 2);
}
AFUtils.InitAnimationFrames(10, animate);
body {
overflow: hidden;
}
对于不同的浏览器,上面的值可能不一样。Chrome的值为16.67ms,而Safari的值为16.00ms或17.00ms。
取16.67ms为例。这是自上一帧渲染完毕以来的流逝时间。也即,每隔16.67ms毫秒,系统将自动安排下一个动画帧。如果我们渲染动作足够快,比如说只需要5毫秒就完成了渲染,则系统会将剩下的时间用于其他方面。只有过了16.67ms,才会再次安排下一帧动画。这也正是requestAnimationFrame名称的由来。它由系统来统一调配计算资源,以保证其他工作不因动画而受到严重影响。
因此,我们应将渲染每一帧所需要的时间,尽可能地限定在16.67ms毫秒的时间范围内,否则,当系统安排的下一帧到来时,我们的代码仍在忙着渲染上一帧,则会因来不及在规定的时间内完成渲染而导致丢帧的情况出现。
帧率是指每秒钟能渲染多少帧,使用FPS作为单位,即frames per second
。根据上面渲染一帧需要16.67ms,我们不难得到计算帧率的公式如下:
`1 / 16.67 = x / 1000`
则:
`x = 1000 / 16.67 ~~ 59.99 fps ~~ 60 fps`
结果约为59.99帧,即帧率约为每秒60帧。根据人脑视觉残留的特点,当帧率达到24fps以上时,将获得非常好的动画效果。常见的帧率为24fps, 25fps, 30fps这三种,因此,能有60fps的帧率的硬件支持,这种环境已经非常优越了。
下面的代码,可在实现动画的同时,实时显示每帧所需时间及其帧率。
const { AnimationFrameUtils: AFUtils } = await import('/js/esm/AnimationFrameUtils.js');
let x = 50;
let y = canvas.clientHeight / 2;
let r = 25;
let offset = 5;
function animate(index, timeStamp, timeElapsed) {
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
showRates(timeElapsed);
renderFrame();
updateState();
}
function renderFrame() {
ctx.fillStyle = 'green';
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
function updateState() {
let prevX = x;
x += offset;
if (x + r > canvas.clientWidth) {
x = canvas.clientWidth - r;
offset = -offset;
}
if (x - r < 0) {
x = r;
offset = -offset;
}
if (prevX === x) {
x += offset;
}
}
function showRates(elapsed) {
ctx.font = '18px Helvetica';
ctx.fillStyle = 'gray';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
let frameRate = 1000 / elapsed;
ctx.fillText(`Duration per frame: ${elapsed.toFixed(2)} ms`, 10, 20);
ctx.fillText(`Frame rate: ${frameRate.toFixed(0)} FPS`, 10, 50);
}
AFUtils.InitAnimationFrames(10, animate);
body {
overflow: hidden;
}
上面所显示的帧率是通过实时计算而得出的结果,也称即时帧率、实际帧率。如果我们希望按照30fps的帧率来制作动画,则称为之目标帧率或额定帧率。
借助于上面的代码,我们可随时检查渲染每一帧的工作量是否太大而影响了应用效能。
定制目标帧率
在定制目标帧率时,初始算法是如果流逝时间小于额定帧率渲染一帧所需时间,则不予渲染,等待下一帧的到来。但特定系统系统中下一帧到来的时间是固定的,因此当两次时间累积起来再比较,可能造成前面等待的时间过多而白白浪费,且导致动画不均衡。
换个思路。虽然系统每隔16.67ms才安排一次响应我们动画代码的机会,从这个角度上看,时间是不连续的。但从每秒60帧的角度来看,时间却是连续的。我们可以将系统分配的时长累积起来。如果累积的数量尚未足够满足制作一帧所需时间,我们就等待;如果够了,我们就制作一帧动画,制作完后从总时长中减去制作一帧需时长即可。总体代码结构如下:
const FRAME_RATE = 25;
const MS_PER_FRAME = 1000 / FRAME_RATE;
let stockDuration = 0.0;
function animate(currentFrameIndex, currentTimeStamp, timeElapsed) {
let duration = timeElapsed;
stockDuration += duration;
if (stockDuration < MS_PER_FRAME) {
return;
}
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
showRates(duration);
renderFrame();
updateState();
currTime = currFrame * MS_PER_FRAME;
currFrame++;
stockDuration -= MS_PER_FRAME;
}
FRAME_RATE是我们需要设定的目标帧率,由此计算出的MS_PER_FRAME是制作一帧动画所需的时长,以毫秒为单位。
在animate函数中,先将每次流逝时间都累加进stockDuration中,再将其与MS_PER_FRAME比较。如果其值小,说明时间太短,仍需等待。此时仅是简单地再次申请动画帧就行了。
而当累积时长够了之后,我们制作一帧动画。制作完后,从总时长中减去制作一帧所需时间。剩下的时长放在库中继续用于累积。
const { AnimationFrameUtils: AFUtils } = await import('/js/esm/AnimationFrameUtils.js');
let x = 50;
let y = canvas.clientHeight / 2;
let r = 25;
let offset = 5;
const FRAME_RATE = 25;
const MS_PER_FRAME = 1000 / FRAME_RATE;
let stockDuration = 0.0;
let currFrame = 0;
let currTime = 0.0;
function animate(currentFrameIndex, currentTimeStamp, timeElapsed) {
let duration = timeElapsed;
stockDuration += duration;
if (stockDuration < MS_PER_FRAME) {
return;
}
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
showRates(duration);
renderFrame();
updateState();
currTime = currFrame * MS_PER_FRAME;
currFrame++;
stockDuration -= MS_PER_FRAME;
}
function renderFrame() {
ctx.fillStyle = 'green';
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
function updateState() {
let prevX = x;
x += offset;
if (x + r > canvas.clientWidth) {
x = canvas.clientWidth - r;
offset = -offset;
}
if (x - r < 0) {
x = r;
offset = -offset;
}
if (prevX === x) {
x += offset;
}
}
function showRates(elapsed) {
ctx.font = '18px Helvetica';
ctx.fillStyle = 'gray';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(`Target Frame rate: ${FRAME_RATE} FPS`, 10, 20);
ctx.fillText(`MS_PER_FRAME: ${MS_PER_FRAME.toFixed(2)} ms`, 10, 50);
ctx.fillText(`Current Frame: ${currFrame}`, 10, 80);
ctx.fillText(`Current Time: ${(currTime / 1000).toFixed(3)} s`, 10, 110);
}
AFUtils.InitAnimationFrames(10, animate);
body {
overflow: hidden;
}
上面的代码在之前的基础上还加入了计算当前帧数及当前动画时间的功能。