WebGL Tutorial
and more

动画基本原理

撰写时间:2024-06-29

修订时间:2024-06-29

本站中多个地方均有涉及动画的相关知识,动画所涉及到的知识点还是相对较多的。在这里,我们将结合canvas这个2D渲染平台,集中详细讲述动画的基本原理。

Web端的动画实现

在Web端实现动画的代码结构如下:

let prevTimeStamp; requestAnimationFrame(initFirstFrame); function initFirstFrame(timeStamp) { prevTimeStamp = timeStamp; requestAnimationFrame(animate); } function animate(timeStamp) { // your animation codes goes here // ... requestAnimationFrame(animate); }

通过requestAnimationFrame函数向系统申请制作一个动画帧。

let prevTimeStamp; let requestId; initAnimation(); function initAnimation() { //prevTimeStamp = performance.now(); prevTimeStamp = document.timeline.currentTime; requestId = requestAnimationFrame(animate); } function animate(timeStamp) { // your animation codes goes here // ... timeStamp = document.timeline.currentTime console.log(requestId); let elapsed = timeStamp - prevTimeStamp; console.log(elapsed); prevTimeStamp = timeStamp; requestId = requestAnimationFrame(animate); }

实现基本动画

ctx.fillStyle = 'green'; let x = 50; let y = canvas.clientHeight / 2; let r = 25; let offset = 1; let prevTimeStamp; requestAnimationFrame(initFirstFrame); function initFirstFrame(timeStamp) { prevTimeStamp = timeStamp; requestAnimationFrame(animate); } function animate(timeStamp) { ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); x += offset; prevTimeStamp = timeStamp; requestAnimationFrame(animate); }

在渲染每一帧的代码中,先根据x, y, r的值渲染圆球,接着改变x的值,再申请渲染下一帧,如此循环下去。由于在每一帧中,只有x的值发生改变,因此就形成了一个不断向右移动的小球的动画。

因此,动画的本质在于,一个物体的特定属性,随着时间的流逝,其值发生了变化。

由上,要设置动画,我们需考虑以下几点:

  1. 要对物体哪个属性设置动画?
  2. 在不同的时间戳,该属性值应是多少?

上面,我们对小球的X轴的坐标属性设置了动画;随着时间的流逝,X轴坐标值均增加1的增量。

我们可以同时对物体的多个属性设置动画。例如,对于一个车轮,边旋转,边前进。

let x = 50; let y = canvas.clientHeight / 2; let r = 25; let rotateAngle = 0; let offset = 1; let rotateDelta = 1; let prevTimeStamp; requestAnimationFrame(initFirstFrame); function initFirstFrame(timeStamp) { prevTimeStamp = timeStamp; requestAnimationFrame(animate); } function animate(timeStamp) { 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; prevTimeStamp = timeStamp; requestAnimationFrame(animate); }

上面通过应用了ctx的平移及旋转这两种变换,从而非常方便地实现了车轮边旋转边前进的动画效果。概括来说,就是随着时间的流逝,车轮的旋转角度及X轴坐标值均发生了改变,因此产生了两种效果的动画。

在应用变换时,平移及旋转的先后应用次序不同,所产生的效果也不同;如何围绕物体自身的中心旋转?这些都将在下面章节中具体阐述。这里我们只需了解,我们可以为物体的多个属性同时设置动画就行了。

小球碰壁反弹

在编写动画代码时,按下面简单的逻辑来处理:

  1. 先根据物体现有的状态来渲染物体
  2. 根据特定规律,改变需设置动画的属性值
  3. 申请渲染下一帧

由此,我们很容易得到小球碰到右壁后反弹回来的效果。

ctx.fillStyle = 'green'; let x = 50; let y = canvas.clientHeight / 2; let r = 25; let offset = 5; let prevTimeStamp; requestAnimationFrame(initFirstFrame); function initFirstFrame(timeStamp) { prevTimeStamp = timeStamp; requestAnimationFrame(animate); } function animate(timeStamp) { ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); renderFrame(); updateState(); prevTimeStamp = timeStamp; requestAnimationFrame(animate); } 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; } } }

代码:

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的值正好相等,则直接回退一个位置。

同样,碰到左壁后反弹:

ctx.fillStyle = 'green'; let x = 50; let y = canvas.clientHeight / 2; let r = 25; let offset = 5; let prevTimeStamp; requestAnimationFrame(initFirstFrame); function initFirstFrame(timeStamp) { prevTimeStamp = timeStamp; requestAnimationFrame(animate); } function animate(timeStamp) { ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); renderFrame(); updateState(); prevTimeStamp = timeStamp; requestAnimationFrame(animate); } 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; } }
body { overflow: hidden; }

上面,我们通过CSS将滚动条屏蔽掉。这主要适用于Chrome浏览器。Safari的滚动条会自动隐藏,因此也可以无需此CSS设置。

这一节,我们初步接触了动画领域中的碰撞检测的问题。这方面有待于进一步深入学习。

帧率

ctx.font = '48px Helvetica'; ctx.fillStyle = 'DEEPSKYBLUE'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; let prevTimeStamp; requestAnimationFrame(initFirstFrame); function initFirstFrame(timeStamp) { prevTimeStamp = timeStamp; requestAnimationFrame(animate); } function animate(timeStamp) { ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); showRates(timeStamp - prevTimeStamp); prevTimeStamp = timeStamp; requestAnimationFrame(animate); } function showRates(elapsed) { ctx.fillText(`${elapsed.toFixed(2)}ms`, canvas.clientWidth / 2, canvas.clientHeight / 2); }
body { overflow: hidden; }

对于不同的浏览器,上面的值可能不一样。Chrome的值为16.67ms,而Safari的值为16.00ms17.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的帧率的硬件支持,这种环境已经非常优越了。

下面的代码,可在实现动画的同时,实时显示每帧所需时间及其帧率。

let x = 50; let y = canvas.clientHeight / 2; let r = 25; let offset = 5; let prevTimeStamp; requestAnimationFrame(initFirstFrame); function initFirstFrame(timeStamp) { prevTimeStamp = timeStamp; requestAnimationFrame(animate); } function animate(timeStamp) { ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); showRates(timeStamp - prevTimeStamp); renderFrame(); updateState(); prevTimeStamp = timeStamp; requestAnimationFrame(animate); } 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); }
body { overflow: hidden; }

上面所显示的帧率是通过实时计算而得出的结果,也称即时帧率实际帧率。如果我们希望按照30fps的帧率来制作动画,则称为之目标帧率额定帧率

借助于上面的代码,我们可随时检查渲染每一帧的工作量是否太大而影响了应用效能。

定制目标帧率

在定制目标帧率时,初始算法是如果流逝时间小于额定帧率渲染一帧所需时间,则不予渲染,等待下一帧的到来。但特定系统系统中下一帧到来的时间是固定的,因此当两次时间累积起来再比较,可能造成前面等待的时间过多而白白浪费,且导致动画不均衡。

换个思路。虽然系统每隔16.67ms才安排一次响应我们动画代码的机会,从这个角度上看,时间是不连续的。但从每秒60帧的角度来看,时间却是连续的。我们可以将系统分配的时长累积起来。如果累积的数量尚未足够满足制作一帧所需时间,我们就等待;如果够了,我们就制作一帧动画,制作完后从总时长中减去制作一帧需时长即可。总体代码结构如下:

const FRAME_RATE = 25; const MS_PER_FRAME = 1000 / FRAME_RATE; let stockDuration = 0.0; let prevTimeStamp; function animate(timeStamp) { let duration = timeStamp - prevTimeStamp; stockDuration += duration; if (stockDuration < MS_PER_FRAME) { prevTimeStamp = timeStamp; requestAnimationFrame(animate); return; } ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); renderFrame(); updateState(); stockDuration -= MS_PER_FRAME; prevTimeStamp = timeStamp; requestAnimationFrame(animate); }

FRAME_RATE是我们需要设定的目标帧率,由此计算出的MS_PER_FRAME是制作一帧动画所需的时长,以毫秒为单位。

animate函数中,先将每次流逝时间都累加进stockDuration中,再将其与MS_PER_FRAME比较。如果其值小,说明时间太短,仍需等待。此时仅是简单地再次申请动画帧就行了。

而当累积时长够了之后,我们制作一帧动画。制作完后,从总时长中减去制作一帧所需时间。剩下的时长放在库中继续用于累积。

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; let prevTimeStamp; requestAnimationFrame(initFirstFrame); function initFirstFrame(timeStamp) { prevTimeStamp = timeStamp; requestAnimationFrame(animate); } function animate(timeStamp) { let duration = timeStamp - prevTimeStamp; stockDuration += duration; if (stockDuration < MS_PER_FRAME) { prevTimeStamp = timeStamp; requestAnimationFrame(animate); return; } ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); showRates(duration); renderFrame(); updateState(); currTime = currFrame * MS_PER_FRAME; currFrame++; stockDuration -= MS_PER_FRAME; prevTimeStamp = timeStamp; requestAnimationFrame(animate); } 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); }
body { overflow: hidden; }

上面的代码在之前的基础上还加入了计算当前帧数及当前动画时间的功能。

参考资源

  1. HTML5 Canvas Element
  2. asciimath.org
  3. MDN: Using the Web Animations API
  4. Web Animations Level 2
  5. Web Animations