Day9 - 2D渲染环境基础篇 V[Canvas动画概论] - 成为Canvas Ninja ~ 理解2D渲染的精髓

OK, 我们终於来到了基础篇最後的部分,也就是Canvas动画~!(撒花)
在这个部分,我们会介绍:

  • canvas实作动画的原理
  • 一个简易动画的实作案例

Canvas动画原理

我们都知道,在现实生活中动画(Animation)的原理其实是透过绘制很多不同但连贯的图片,然後把这些图片依序播放出来。
canvas实作动画的原理其实也是一模一样。

假设今天有一个60FPS(Frame Per Second, 意思就是每秒60帧)的动画,如果我们要用Canvas来实作,那麽在动画第一秒内的流程大约会是:

  • 在canvas上面绘制第一帧的样子
  • 在1/60 秒後清除画面
  • 在canvas上面绘制第二帧的样子
  • 持续重复上面的做法, 一直做到第60帧
  • ...

原理面的部分大致上就这样,虽然看起来很简单,但是在实作的时候才真的会遭遇到各种问题。

什麽是帧率(FPS)?

你前面提到了FPS这个词,那是一个什麽样的概念?

所谓的帧率就是『每秒被播出的图像数量』。

延伸资源:不同帧率所带来的视觉效果差

在过去的年代,制作一部手绘卡通其实所费不赀,最主要的原因就是因为图像需要逐帧人工绘制,而专案限期内能绘制的图像有限,所以一般来说手绘动画的帧率相对来讲会比较低。
以常见的日式动画来讲,日式动画的帧率平均落在23.976帧/秒, 这也就是我们常听到的24帧

在现代则出现了更方便的AI中割模拟,大幅减少了人力成本,不过那是题外话了 。 BTW -- 有看咒术回战的朋友可能对24帧这词蛮熟悉的XD(投射咒法~!)

而在科技进步之後,来到网页 / 游戏的领域,基本帧率则最少也会有60帧/秒(因为画面是交由机器绘制)。

这边有人可能会开始觉得很疑惑,网页在没有动画时,本身不是静态的图像吗?为什麽还会提到帧率?

这你就不懂了~

浏览器视窗本身可以其实可以看作一个大型且复杂的canvas,你在一秒内所看到的画面,其实实际上是浏览器渲染引擎以极快的速度逐帧绘制出来的(就算是静态画面也是一样)

渲染引擎本身的绘制速度其实会受到编译的速度,还有程序逻辑的复杂度等多重原因影响,而当渲染引擎的绘制速度被拖慢(或者说不够快),这时候就会出现所谓的渲染延迟,或掉帧的现象。

举个常见的渲染延迟案例 ~ 大家在菜鸟时期一定都有碰过。当有一个网页具备一卡车的动态特效(尤其是onScroll Animation) ,使用者在网页载入完毕的同时快速把scrollbar往下拉,这时候画面会有很大的机率会白掉一大半,这个就是『渲染速度』跟不上『使用者操作UI速度』的典型案例。

通常这种状况的解决方式都是比较硬核的,需要深入程序修改细节以解决效能上的问题。

而所谓的掉帧,其实就是我们常说的动画卡顿问题,网页的掉帧问题有大有小,大的例如画面卡顿卡到跟静态图像一样,小的则像是某些时候画面会有微妙的停顿感(一般人可能分辨不出来这个XD)

接下来我们会用实际范例来演示怎麽在Canvas环境下实现一个方块移动的简易动画。

实际动画案例演示 - 方块移动

const startTime = performance.now();
const durationTotal = 5000;

function drawRect(ctx,x,y){
  // 这就是很普通的画一个方块在指定座标的位置上
  // 假设长宽都是40
  const size = 20;
   // 设定填充色
  ctx.fillStyle="#fff";
  ctx.fillRect(x,y,size*2,size*2);
 
}

function animate(ctx){
  // 预设都先清除旧画面然後重新画一个方块在新位置
  let timeNow = performance.now() - startTime;
  //
  const speed = 0.05; //假设速度是0.05px/毫秒
  
  ctx.clearRect(0,0,ctx.canvas.width,ctx.canvas.height)
  drawRect(ctx,speed*timeNow,50);
  
  // 在目前花费时间还没超过总预设花费时间的状况下就持续动作
  if(timeNow<durationTotal){
    // 告诉浏览器下一帧要做的动作, 可以想像成一个极短的setTimeout, delay时间大约是1/60秒
    // 这边我们透过递回执行animate来绘制下一帧的画面
     requestAnimationFrame(()=>{animate(ctx)})
  }
  else{
    return
  }
}


function draw(){
  const cvs = document.querySelector('canvas');
  const ctx = cvs.getContext('2d');
  animate(ctx);
}


(()=>{
  draw();
})()

codepen连结: https://codepen.io/mizok_contest/pen/qBjyVaa

在上面这个看似简单的范例中其实隐藏着两个重点。

  • 用requestAnimationFrame(RAF)来告诉浏览器进行下一帧图像的绘制
  • 用performance.now()来追踪动画进行时间

接下来我们会就这两个重点讲解背後的原因。

为什麽要使用requestAnimationFrame()?

如果去找一些比较早期的Canvas教程,可能会发现它里面都是用SetTimeOut 或是SetInterval去计算帧间时差(将时间设置为1000/60 毫秒) 。
虽然说用setTimeOut/setInterval 来计算帧间时差并不算错,但是就是比较粗糙。
使用requestAnimationFrame(以下简称RAF)的优点有二:

  • 不像setTimeOut/setInterval是固定给一个固定的时间差,RAF可以视为一个动画帧结束之後的callback,所以相对的他比较不会受到帧率误差的影响
  • 不像setTimeOut/setInterval,RAF并不会在背景运作,当你把分页切换到别的分页,RAF就会被中断,这个设计改善了浏览器运作的效能,同时也减少电源的消耗

为什麽要使用performance.now()?

我们先来说说performance.now()在这个案例里面用途是什麽,还有他是一个什麽样的api。

performance.now()简单来说就是一个用来计算document生命周期的方法,他会在document物件被载入的时候开始计时。

有些人接着可能会问:

那他跟Date.now()差别差在哪?不能用Date.now()就好吗?

performance.now()作为一个比较年轻的api,跟Date.now()比起来其实有更多适用的场景,原因有二:

  • performance.now() 能够提供超越毫秒的精准度,他所计算出来的时间会是附带浮点数的毫秒,所以他更适合用在游戏或动画这种需要高精确度的运算场景
  • Date.now()实际上是从1970年1月1日0分0秒开始估算(也就是所谓的Unix时间),然而现今的年代其实很少需要一个从1970年开始计时的功能。而且Date实际上是会Base on 装置的系统时间,当系统时间在某种状况下受到变动,运用Date.now去计算时间差的Web APP 可能会出现误差。

根据developers.google.com的解释,Date.now比较适用在确认绝对时间的场景,而performance.now则适用於计算相对时间的情境。

developers.google.com上关於performance.now的解释可以看这边

看到这边大家应该已经很能理解使用performance.now的诸多好处~

但是接着可能就又会有人再问:

那为什麽不能直接用经过的帧数作计算?例如预设一个变数给定总帧数,然後每一圈RAF就-1,扣到0的时候就停止运动?

原因很简单~还记得我们前面有介绍过浏览器的FPS数字其实会受到其他因素的影响吗?有时候如果FPS偏低,那麽就意味着可能有某几帧的耗时比较长,这麽一来,如果用帧数来判断移动距离,就会出现运动速度不均匀的状况。

虽然说在过去这种误差可能不容易被察觉,但是在现代,尤其是在开发游戏的场景,物件移动的精确度其实越来越重要,所以相对的也要求开发人员不能随便用旧方法交差。

小结

这次我们介绍了如何在Canvas上实作动画,但是这其实还是非常基本的部分,我们接下来即将要脱离基础篇,正式来介绍一些比较复杂的Canvas应用场景~


<<:  D14 - 用 Swift 和公开资讯,打造投资理财的 Apps { 加权指数K线图实作.2 }

>>:  【Day09】Blocking & Non-Blocking 的差异

Batch Processing (1) - Batch Processing with Unix Tools

Batch Processing 从去年开始写 本系列文 开始到现在,我们着墨的都是现代系统的样子,...

每个人都该学的30个Python技巧|技巧 2:Python语法基本功 — 数字与字串(字幕、衬乐、练习)

昨天认识了两种编辑器,你挑好你喜欢的环境了吗,第二天就要开始进入写程序的环节罗,有没有很期待୧⍢⃝୨...

全端入门Day30_结尾

昨天介绍了Golang的http,今天是这30天的结尾。 这30天,我收获良多因为我觉得这是一个毅力...

【Day29】Cordic 演算法的实现

假设今天再做某种数位信号处理时,不小心用到了 arctan(y/x) 函数,那麽当然可以用泰勒展开得...

使用 Vaadin Directory 组件显示Google地图 - day27

目的 地图反查经纬度,将地图显示在立委服务地区旁。 本篇重点: 导入Vaadin direction...