[TOC]

18.1 使用requestAnimationFrame

很长时间以来,计时器和定时执行都是JavaScript动画最先进的工具。
虽然CSS过渡和动画方便了Web开发者实现某些动画,但JavaScript动画领域多年来进展甚微。
Firefox 4率先在浏览器中为JavaScript动画增加了一个名为mozRequestAnimationFrame()方法的API。
这个方法会告诉浏览器要执行动画了,于是浏览器可以通过最优方式确定重绘的时序。

18.1.1 早期定时动画

以前,在JavaScript中创建动画基本上就是使用setInterval()来控制动画的执行。

(function() {
  function updateAnimations() {
    doAnimation1();
    doAnimation2();
    // 其他任务
  }
  setInterval(updateAnimations, 100);
})();

updateAnimations()方法会周期性运行注册的动画任务,并反映出每个任务的变化(例如,同时更新滚动新闻和进度条)。如果没有动画需要更新,则这个方法既可以什么也不做,直接退出,也可以停止动画循环,等待其他需要更新的动画。
问题在于:无法准确知晓循环之间的延时。
定时间隔必须足够短,才能让不同的动画类型都能平滑顺畅,但又要足够长,以便产生浏览器可以渲染出来的变化。大多数浏览器会限制重绘频率,使其不超出屏幕的刷新率,这是因为超过刷新率,用户也感知不到。
如果同时运行多个动画,可能需要加以限流,以免17毫秒的重绘间隔过快,导致动画过早运行完。
虽然使用setInterval()的定时动画,比使用多个setTimeout()实现循环效率更高,但也不是没有问题:
无论setInterval()还是setTimeout()都不能保证时间精度。作为第二个参数的延时,只能保证何时会把代码添加到浏览器的任务队列,不能保证添加到队列就会立即运行。如果队列前面还有其他任务,那么就要等这些任务执行完再执行。
简单来讲,这里毫秒延时并不是说何时这些代码会执行,而只是说到时候会把回调加到任务队列。
如果添加到队列后,主线程还被其他任务占用,比如正在处理用户操作,那么回调就不会马上执行。

18.1.2 时间间隔的问题

知道何时绘制下一帧是创造平滑动画的关键。
开发者发现setInterval()和setTimeout()的不精确是个大问题。
浏览器自身计时器的精度让这个问题雪上加霜。

18.1.3 requestAnimationFrame

requestAnimationFrame()方法接收一个参数,此参数是一个要在重绘屏幕前调用的函数。
这个函数就是修改DOM样式,以反映下一次重绘有什么变化的地方。
为了实现动画循环,可把多个requestAnimationFrame()调用串联起来,就像以前使用setTimeout()时一样。

function updateProgress() {
  var div = document.getElementById('status');
  div.style.width = (parseInt(div.style.width, 10) + 5) + '%';
  if (div.style.left !== '100%') {
    requestAnimationFrame(updateProgress);
  }
}
requestAnimationFrame(updateProgress);

因为requestAnimationFrame()只会调用一次传入的函数,所以每次更新用户界面时需要再手动调用它一次。同样,也需要控制动画何时停止。结果就会得到非常平滑的动画。
目前为止,requestAnimationFrame()解决了3个问题:
浏览器不知道JavaScript动画何时开始的问题;
最佳间隔是多少的问题;
不知道自己的代码何时实际执行的问题。
最后一个问题也给出了解决方法:传给requestAnimationFrame()的函数实际上可接收一个参数,此参数是一个DOMHighRes-TimeStamp的实例(比如performance.now()返回的值),表示下次重绘的时间。这一点非常重要:requestAnimationFrame()实际上把重绘任务,安排在了未来一个已知的时间点上,而且通过这个参数告诉了开发者。基于这个参数,就可以更好地决定如何调优动画了。

18.1.4 cancelAnimationFrame

与setTimeout()类似,requestAnimationFrame()也返回一个请求ID,可用于通过另一个方法cancelAnimationFrame()来取消重绘任务。

18.1.5 通过requestAnimationFrame节流

支持这个方法的浏览器实际上会暴露出作为钩子的回调队列。
所谓钩子(hook),就是浏览器在执行下一次重绘之前的一个点。
这个回调队列是一个可修改的函数列表,包含应该在重绘之前调用的函数。
每次调用requestAnimationFrame()都会在队列上推入一个回调函数,队列的长度没有限制。
这个回调队列的行为不一定跟动画有关。不过,通过requestAnimationFrame()递归地向队列中加入回调函数,可以保证每次重绘最多只调用一次回调函数。这是一个非常好的节流工具。
在频繁执行影响页面外观的代码时(比如滚动事件监听器),可以利用这个回调队列进行节流。
例子:
看一个原生实现,其中的滚动事件监听器每次触发,都会调用名为expensiveOperation()(耗时操作)的函数。当向下滚动网页时,这个事件很快就会被触发并执行成百上千次;
改进:
想把事件处理程序的调用,限制在每次重绘前发生,那么可以把它封装到request-AnimationFrame()调用中。这样会把所有回调的执行集中在重绘钩子,但不会过滤掉每次重绘的多余调用。
此时,定义一个标志变量,由回调设置其开关状态,就可以将多余的调用屏蔽。

function expensiveOperation() {
  console.log('引用', Date.now());
}
window.addEventListener('scroll', () => {
  expensiveOperation();
});

// 改进
window.addEventListener('scroll', () => {
  window.requestAnimationFrame(expensiveOperation);
});

// 定义一个标志变量
let enqueued = false;
function expensiveOperation() {
  console.log('引用', Date.now());
  enqueued = false;
}
window.addEventListener('scroll', () => {
  if (!enqueued) {
    enqueued = true;
    window.requestAnimationFrame(expensiveOperation);
  }
});

因为重绘是非常频繁的操作,所以这还算不上真正的节流。
更好的办法是:配合使用一个计时器,来限制操作执行的频率。
计时器可以限制实际的操作执行间隔,而requestAnimationFrame控制在浏览器的哪个渲染周期中执行。
下面的例子可以将回调限制为不超过50毫秒执行一次:

let enabled = true;
function expensiveOperation() {
  console.log('引用', Date.now());
}
window.addEventListener('scroll', () => {
  if (enabled) {
    enabled = false;
    window.requestAnimationFrame(expensiveOperation);
    window.setTimeout(() => enabled = true, 50)
  }
});

18.2 基本的画布功能

创建元素时至少要设置其width和height属性,这样才能告诉浏览器在多大面积上绘图。
出现在开始和结束标签之间的内容是后备数据,会在浏览器不支持元素时显示。
与其他元素一样,width和height属性也可以在DOM节点上设置,因此可以随时修改。
整个元素还可以通过CSS添加样式,并且元素在添加样式或实际绘制内容前是不可见的。
要在画布上绘制图形,首先要取得绘图上下文。使用getContext()方法可以获取对绘图上下文的引用。
使用元素时,最好先测试一下getContext()方法是否存在。
对于平面图形,需要给这个方法传入参数”2d”,表示要获取2D上下文对象。
可用toDataURL()方法导出元素上的图像。接收一个参数:要生成图像的MIME类型(与用来创建图形的上下文无关)。
例如,要从画布上导出一张PNG格式的图片:

 <canvas id="drawing" width="200" height="200">某物的绘图</canvas>

let drawing = document.getElementById('drawing');
// 确保浏览器支持<canvas>
if (drawing.getContext) {
  // 取得图像的数据URI
  let imgURI = drawing.toDataURL('image/png');
  // 显示图片
  let image = document.createElement('img');
  image.src = imgURI;
  document.body.appendChild(image);
}

浏览器默认将图像编码为PNG格式,除非另行指定。
注:如果画布中的图像是其他域绘制过来的,toDataURL()方法就会抛出错误。

18.4 WebGL

WebGL是画布的3D上下文。
与其他Web技术不同,WebGL不是W3C制定的标准,而是Khronos Group的标准。
根据官网描述,“Khronos Group是非营利性、会员资助的联盟,专注于多平台和设备下并行计算、图形和动态媒体的无专利费开放标准”。Khronos Group也制定了其他图形API,包括作为浏览器中WebGL基础的OpenGL ES 2.0。
注:定型数组是在WebGL中执行操作的重要数据结构。

18.4.1 WebGL上下文

在完全支持的浏览器中,WebGL 2.0上下文的名字叫”webgl2”, WebGL 1.0上下文的名字叫”webgl1”。
如果浏览器不支持WebGL,则尝试访问WebGL上下文会返回null。
在使用上下文之前,应该先检测返回值是否存在。
把WebGL context对象命名为gl。大多数WebGL应用和例子遵循这个约定,因为OpenGL ES 2.0方法和值通常以”gl”开头。这样可以让JavaScript代码看起来更接近OpenGL程序。

18.4.2 WebGL基础

取得WebGL上下文后,就可以开始3D绘图了。
可以在调用getContext()取得WebGL上下文时指定一些选项。
这些选项通过一个参数对象传入,选项就是参数对象的一个或多个属性。
这些上下文选项大部分适合开发高级功能。多数情况下,默认值就可以满足要求。
如果调用getContext()不能创建WebGL上下文,某些浏览器就会抛出错误。为此,最好把这个方法调用包装在try/catch块中。

1.常量

如果你熟悉OpenGL,那么可能知道用于操作的各种常量。
这些常量在OpenGL中的名字以GL开头。
在WebGL中,context对象上的常量则不包含GL
前缀。

2.方法命名

OpenGL(同时也是WebGL)中的很多方法会包含相关的数据类型信息。
接收不同类型和不同数量参数的方法,会通过方法名的后缀体现这些信息。表示参数数量的数字(1~4)在先,表示数据类型的字符串(“f”表示浮点数,“i”表示整数)在后。
还有很多方法接收数组,这类方法用字母“v”(vector)来表示。

3.准备绘图

准备使用WebGL上下文之前,通常需要先指定一种实心颜色清除
为此,要调用clearColor()方法并传入4个参数,分别表示红、绿、蓝和透明度值。每个参数必须是0~1范围内的值,表示各个组件在最终颜色的强度。

4.视口与坐标

绘图前还要定义WebGL视口。
默认情况下,视口使用整个区域。
要改变视口,可以调用viewport()方法并传入视口相对于元素的x、y坐标及宽度和高度。
知道如何定义视口就可以只使用元素的一部分来绘图。

5.缓冲区

在JavaScript中,顶点信息保存在定型数组中。要使用这些信息,必须先把它们转换为WebGL缓冲区。创建缓冲区要调用gl.createBuffer()方法,并使用gl.bindBuffer()方法将缓冲区绑定到WebGL上下文。绑定之后,就可以用数据填充缓冲区了。
缓冲区会一直驻留在内存中,直到页面卸载。如果不再需要缓冲区,那么最好调用gl.deleteBuffer()方法释放其占用的内存。

6.错误

与JavaScript多数情况下不同的是,在WebGL操作中通常不会抛出错误。必须在调用可能失败的方法后,调用gl.getError()方法。这个方法返回一个常量,表示发生的错误类型。
每次调用gl.getError()方法会返回一个错误值。第一次调用之后,再调用gl.getError()可能会返回另一个错误值。如果有多个错误,则可以重复这个过程,直到gl.getError()返回gl.NO_ERROR。如果执行了多次操作,那么可以通过循环调用getError()
如果WebGL代码没有产出想要的输出结果,那么可以调用几次getError(),这样有可能帮你找到问题所在。

7.着色器

着色器是OpenGL中的另一个概念。
WebGL中有两种着色器:顶点着色器和片段(或像素)着色器。
顶点着色器用于把3D顶点转换为可以渲染的2D点。
片段着色器用于计算绘制一个像素的正确颜色。
WebGL着色器的独特之处在于,它们不是JavaScript实现的,而是使用一种与C或JavaScript完全不同的语言GLSL(OpenGL Shading Language)写的。

●编写着色器

GLSL是一种类似于C的语言,专门用于编写OpenGL着色器。
因为WebGL是OpenGL ES 2的实现,所以OpenGL中的着色器可以直接在WebGL中使用。
这样也可以让桌面应用更方便地移植到Web上。
每个着色器都有一个main()方法,在绘制期间会重复执行。
给着色器传递数据的方式有两种:attribute和uniform。
attribute用于将顶点传入顶点着色器;
uniform用于将常量值传入任何着色器。
attribute和uniform是在main()函数外部定义的。
在值类型关键字之后是数据类型,然后是变量名。

● 创建着色器程序

浏览器并不理解原生GLSL代码,因此GLSL代码的字符串,必须经过编译并链接到一个着色器程序中。
为便于使用,可用带有自定义type属性的