跳到主要内容

动画与 Canvas 图形

图形和动画已经日益成为浏览器中现代 Web 应用程序的必备功能,但实现起来仍然比较困难。视觉上复杂的功能要求性能调优和硬件加速,不能拖慢浏览器。目前已经有一套日趋完善的 API 和工具可以用来开发此类功能。

毋庸置疑,<canvas> 是 HTML5 最受欢迎的新特性。这个元素会占据一块页面区域,让 JavaScript 可以动态在上面绘制图片。<canvas> 最早是苹果公司提出并准备用在控制面板中的,随着其他浏览器的迅速跟进,HTML5 将其纳入标准。目前所有主流浏览器都在某种程度上支持 <canvas> 元素。

与浏览器环境中的其他部分一样,<canvas> 自身提供了一些 API,但并非所有浏览器都支持这些 API,其中包括支持基础绘图能力的 2D 上下文和被称为 WebGL 的 3D 上下文。支持的浏览器的最新版本现在都支持 2D 上下文和 WebGL。

使用 requestAnimationFrame

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

早期定时动画

以前,在 JavaScript 中创建动画基本上就是使用 setInterval() 来控制动画的执行。下面的例子展示了使用 setInterval() 的基本模式:

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

这种定时动画的问题在于无法准确知晓循环之间的延时。定时间隔必须足够短,这样才能让不同的动画类型都能平滑顺畅,但又要足够长,以便产生浏览器可以渲染出来的变化。一般计算机显示器的屏幕刷新率都是 60Hz,基本上意味着每秒需要重绘 60 次。大多数浏览器会限制重绘频率,使其不超出屏幕的刷新率,这是因为超过刷新率,用户也感知不到。

因此,实现平滑动画最佳的重绘间隔为 1000 毫秒/60,大约 17 毫秒。以这个速度重绘可以实现最平滑的动画,因为这已经是浏览器的极限了。如果同时运行多个动画,可能需要加以限流,以免 17 毫秒的重绘间隔过快,导致动画过早运行完。

虽然使用 setInterval()的定时动画比使用多个 setTimeout()实现循环效率更高,但也不是没有问题。无论 setInterval()还是 setTimeout()都是不能保证时间精度的。作为第二个参数的延时只能保证何时会把代码添加到浏览器的任务队列,不能保证添加到队列就会立即运行。如果队列前面还有其他任务,那么就要等这些任务执行完再执行。简单来讲,这里毫秒延时并不是说何时这些代码会执行,而只是说到时候会把回调加到任务队列。如果添加到队列后,主线程还被其他任务占用,比如正在处理用户操作,那么回调就不会马上执行。

时间间隔的问题

知道何时绘制下一帧是创造平滑动画的关键。直到几年前,都没有办法确切保证何时能让浏览器把下一帧绘制出来。随着 <canvas> 的流行和 HTML5 游戏的兴起,开发者发现 setInterval()setTimeout() 的不精确是个大问题。

浏览器自身计时器的精度让这个问题雪上加霜。浏览器的计时器精度不足毫秒。以下是几个浏览器计时器的精度情况:

  • IE8 及更早版本的计时器精度为 15.625 毫秒;
  • IE9 及更晚版本的计时器精度为 4 毫秒;
  • Firefox 和 Safari 的计时器精度为约 10 毫秒;
  • Chrome 的计时器精度为 4 毫秒。

IE9 之前版本的计时器精度是 15.625 毫秒,意味着 0~15 范围内的任何值最终要么是 0,要么是 15, 不可能是别的数。IE9 把计时器精度改进为 4 毫秒,但这对于动画而言还是不够精确。Chrome 计时器精度是 4 毫秒,而 Firefox 和 Safari 是 10 毫秒。更麻烦的是,浏览器又开始对切换到后台或不活跃标签页中的计时器执行限流。因此即使将时间间隔设定为最优,也免不了只能得到近似的结果。

requestAnimationFrame

Mozilla 的 Robert O’Callahan 一直在思考这个问题,并提出了一个独特的方案。他指出,浏览器知道 CSS 过渡和动画应该什么时候开始,并据此计算出正确的时间间隔,到时间就去刷新用户界面。但对于 JavaScript 动画,浏览器不知道动画什么时候开始。他给出的方案是创造一个名为 mozRequestAnimationFrame() 的新方法,用以通知浏览器某些 JavaScript 代码要执行动画了。这样浏览器就可以在运行某些代码后进行适当的优化。目前所有浏览器都支持这个方法不带前缀的版本,即 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() 已经解决了浏览器不知道 JavaScript 动画何时开始的问题, 以及最佳间隔是多少的问题,但是,不知道自己的代码何时实际执行的问题呢?这个方案同样也给出了解决方法。

传给 requestAnimationFrame() 的函数实际上可以接收一个参数,此参数是一个 DOMHighRes- TimeStamp 的实例(比如 performance.now()返回的值),表示下次重绘的时间。这一点非常重要: requestAnimationFrame() 实际上把重绘任务安排在了未来一个已知的时间点上,而且通过这个参数告诉了开发者。基于这个参数,就可以更好地决定如何调优动画了。

cancelAnimationFrame

setTimeout() 类似,requestAnimationFrame() 也返回一个请求 ID,可以用于通过另一个方法 cancelAnimationFrame() 来取消重绘任务。下面的例子展示了刚把一个任务加入队列又立即将其取消:

let requestID = window.requestAnimationFrame(() => {
console.log('Repaint!');
window.cancelAnimationFrame(requestID);
});

通过 requestAnimationFrame 节流

requestAnimationFrame 这个名字有时候会让人误解,因为看不出来它跟排期任务有关。支持这个方法的浏览器实际上会暴露出作为钩子的回调队列。所谓钩子(hook),就是浏览器在执行下一次重绘之前的一个点。这个回调队列是一个可修改的函数列表,包含应该在重绘之前调用的函数。每次调用 requestAnimationFrame() 都会在队列上推入一个回调函数,队列的长度没有限制。

这个回调队列的行为不一定跟动画有关。不过,通过 requestAnimationFrame() 递归地向队列中加入回调函数,可以保证每次重绘最多只调用一次回调函数。这是一个非常好的节流工具。在频繁执行影响页面外观的代码时(比如滚动事件监听器),可以利用这个回调队列进行节流。

先来看一个原生实现,其中的滚动事件监听器每次触发都会调用名为 expensiveOperation() (耗时操作)的函数。当向下滚动网页时,这个事件很快就会被触发并执行成百上千次:

function expensiveOperation() {
console.log('Invoked at', Date.now());
}

window.addEventListener('scroll', () => {
expensiveOperation();
});

如果想把事件处理程序的调用限制在每次重绘前发生,那么可以像这样下面把它封装到 requestAnimationFrame() 调用中:

function expensiveOperation() {
console.log('Invoked at', Date.now());
}
window.addEventListener('scroll', () => {
window.requestAnimationFrame(expensiveOperation);
});

这样会把所有回调的执行集中在重绘钩子,但不会过滤掉每次重绘的多余调用。此时,定义一个标志变量,由回调设置其开关状态,就可以将多余的调用屏蔽:

let enqueued = false;
function expensiveOperation() {
console.log('Invoked at', Date.now());
enqueued = false;
}
window.addEventListener('scroll', () => {
if (!enqueued) {
enqueued = true;
window.requestAnimationFrame(expensiveOperation);
}
});

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

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

基本的画布功能

创建 <canvas> 元素时至少要设置其 width 和 height 属性,这样才能告诉浏览器在多大面积上绘图。出现在开始和结束标签之间的内容是后备数据,会在浏览器不支持 <canvas> 元素时显示。比如:

<canvas id="drawing" width="200" height="200"> A drawing of something. </canvas>

其他元素一样,width 和 height 属性也可以在 DOM 节点上设置,因此可以随时修改。整个元素还可以通过 CSS 添加样式,并且元素在添加样式或实际绘制内容前是不可见的。

要在画布上绘制图形,首先要取得绘图上下文。使用 getContext() 方法可以获取对绘图上下文的引用。对于平面图形,需要给这个方法传入参数"2d",表示要获取 2D 上下文对象:

let drawing = document.getElementById('drawing');
// 确保浏览器支持<canvas>
if (drawing.getContext) {
let context = drawing.getContext('2d');
// 其他代码
}

使用 <canvas> 元素时,最好先测试一下 getContext() 方法是否存在。有些浏览器对 HTML 规范中没有的元素会创建默认 HTML 元素对象。这就意味着即使 drawing 包含一个有效的元素引用, getContext() 方法也未必存在。

可以使用 toDataURL() 方法导出 <canvas> 元素上的图像。这个方法接收一个参数:要生成图像的 MIME 类型(与用来创建图形的上下文无关)。例如,要从画布上导出一张 PNG 格式的图片,可以这样做:

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

浏览器默认将图像编码为 PNG 格式,除非另行指定。Firefox 和 Opera 还支持传入"image/jpeg" 24 进行 JPEG 编码。因为这个方法是后来才增加到规范中的,所以支持的浏览器也是在后面的版本实现的,包括 IE9、Firefox 3.5 和 Opera 10。

2D 绘图上下文

填充和描边

2D 上下文有两个基本绘制操作:填充和描边。填充以指定样式(颜色、渐变或图像)自动填充形状,而描边只为图形边界着色。大多数 2D 上下文操作有填充和描边的变体,显示效果取决于两个属性: fillStylestrokeStyle

这两个属性可以是字符串、渐变对象或图案对象,默认值都为"#000000"。字符串表示颜色值,可以是 CSS 支持的任意格式:名称、十六进制代码、rgb、rgba、hsl 或 hsla。比如:

let canvas = document.getElementById('drawing');
// 确保浏览器支持 <canvas>
if (canvas.getContext) {
let context = canvas.getContext('2d');
context.strokeStyle = 'red';
context.fillStyle = '#0000ff';
}

绘制矩形

矩形是唯一一个可以直接在 2D 绘图上下文中绘制的形状。与绘制矩形相关的方法有 3 个: fillRect()strokeRect()clearRect()。这些方法都接收 4 个参数:矩形 x 坐标、矩形 y 坐标、 矩形宽度和矩形高度。这几个参数的单位都是像素。

fillRect() 方法用于以指定颜色在画布上绘制并填充矩形。填充的颜色使用 fillStyle 属性指定。

示例
const canvas = document.getElementById('drawing');
// 确保浏览器支持<canvas>
if (canvas.getContext) {
let context = canvas.getContext('2d');

// 绘制红色矩形
context.fillStyle = '#ff0000';
context.fillRect(10, 10, 50, 50);

// 绘制半透明蓝色矩形
context.fillStyle = 'rgba(0,0,255,0.5)';
context.fillRect(30, 30, 50, 50);
}

strokeRect() 方法使用通过 strokeStyle 属性指定的颜色绘制矩形轮廓。

let canvas = document.getElementById('drawing');
// 确保浏览器支持<canvas>
if (canvas.getContext) {
let context = canvas.getContext('2d');

// 绘制红色轮廓的矩形
context.strokeStyle = '#ff0000';
context.strokeRect(10, 10, 50, 50);

// 绘制半透明蓝色轮廓的矩形
context.strokeStyle = 'rgba(0,0,255,0.5)';
context.strokeRect(30, 30, 50, 50);
}

使用 clearRect() 方法可以擦除画布中某个区域。该方法用于把绘图上下文中的某个区域变透明。 通过先绘制形状再擦除指定区域,可以创建出有趣的效果,比如从已有矩形中开个孔。

// 在前两个矩形重叠的区域擦除一个矩形区域
context.clearRect(40, 40, 10, 10);

绘制路径

2D 绘图上下文支持很多在画布上绘制路径的方法。通过路径可以创建复杂的形状和线条。要绘制路径,必须首先调用 beginPath() 方法以表示要开始绘制新路径。然后,再调用下列方法来绘制路径。

arc(x, y, radius, startAngle, endAngle, counterclockwise)

以坐标(x, y)为圆心,以 radius 为半径绘制一条弧线,起始角度为 startAngle,结束角度为 endAngle(都是弧度)。最后一个参数 counterclockwise 表示是否逆时针计算起始角度和结束角度(默认为顺时针)。

arcTo(x1, y1, x2, y2, radius)

以给定半径 radius,经由(x1, y1)绘制一条从上一点到(x2, y2)的弧线。

bezierCurveTo(c1x, c1y, c2x, c2y, x, y)

以(c1x, c1y)和(c2x, c2y)为控制点, 绘制一条从上一点到(x, y)的弧线(三次贝塞尔曲线)。

lineTo(x, y)

绘制一条从上一点到(x, y)的直线。

moveTo(x, y)

不绘制线条,只把绘制光标移动到(x, y)。

quadraticCurveTo(cx, cy, x, y)

以(cx, cy)为控制点,绘制一条从上一点到(x, y)的弧线(二次贝塞尔曲线)。

rect(x, y, width, height)

以给定宽度和高度在坐标点(x, y)绘制一个矩形。这个方法与 strokeRect()fillRect() 的区别在于,它创建的是一条路径,而不是独立的图形。

创建路径之后,可以使用 closePath() 方法绘制一条返回起点的线。如果路径已经完成,则既可以指定 fillStyle 属性并调用 fill() 方法来填充路径,也可以指定 strokeStyle 属性并调用 stroke() 方法来描画路径,还可以调用 clip() 方法基于已有路径创建一个新剪切区域。

示例
let canvas = document.getElementById('drawing');
// 确保浏览器支持<canvas>
if (canvas.getContext) {
let context = canvas.getContext('2d');
// 创建路径
context.beginPath();

// 绘制外圆
context.arc(100, 100, 99, 0, 2 * Math.PI, false);

// 绘制内圆
context.moveTo(194, 100);
context.arc(100, 100, 94, 0, 2 * Math.PI, false);

// 绘制分针
context.moveTo(100, 100);
context.lineTo(100, 15);
// 绘制时针
context.moveTo(100, 100);
context.lineTo(35, 100);
// 描画路径
context.stroke();
}
http://localhost:3000

A drawing of something.

备注

要绘制完整的圆形,必须从 0 弧度绘制到 2π 弧度(使用数学常量 Math.PI)。

路径是 2D 上下文的主要绘制机制,为绘制结果提供了很多控制。因为路径经常被使用,所以也有一个 isPointInPath() 方法,接收 x 轴和 y 轴坐标作为参数。这个方法用于确定指定的点是否在路径上,可以在关闭路径前随时调用,比如:

if (context.isPointInPath(100, 100)) {
alert('Point (100, 100) is in the path.');
}

2D 上下文的路径 API 非常可靠,可用于创建涉及各种填充样式、描述样式等的复杂图像。

绘制文本

文本和图像混合也是常见的绘制需求,因此 2D 绘图上下文还提供了绘制文本的方法,即 fillText()strokeText()。这两个方法都接收 4 个参数:要绘制的字符串、x 坐标、y 坐标和可选的最大像素宽度。而且,这两个方法最终绘制的结果都取决于以下 3 个属性。

  • font: 以 CSS 语法指定的字体样式、大小、字体族等,比如"10px Arial"。
  • textAlign:指定文本的对齐方式,可能的值包括"start"、"end"、"left"、"right"和 "center"。推荐使用"start"和"end",不使用"left"和"right",因为前者无论在从左到右书写的语言还是从右到左书写的语言中含义都更明确。
  • textBaseLine: 指定文本的基线,可能的值包括"top"、"hanging"、"middle"、"alphabetic"、"ideographic"和"bottom"。

这些属性都有相应的默认值,因此没必要每次绘制文本时都设置它们。fillText() 方法使用 fillStyle 属性绘制文本,而 strokeText() 方法使用 strokeStyle 属性。通常,fillText() 方法是使用最多的,因为它模拟了在网页中渲染文本。

示例
context.font = 'bold 14px Arial';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('12', 100, 20);
http://localhost:3000

A drawing of something.

因为表盘中垂直的线条是居中的,所以文本的对齐方式就一目了然了。类似地,通过修改 textBaseline 19 属性,可以改变文本的垂直对齐方式。比如,设置为"top"意味着 y 坐标表示文本顶部,"bottom"表示文本底部,"hanging"、"alphabetic"和"ideographic"分别引用字体中特定的基准点。

由于绘制文本很复杂,特别是想把文本绘制到特定区域的时候,因此 2D 上下文提供了用于辅助确定文本大小的 measureText() 方法。这个方法接收一个参数,即要绘制的文本,然后返回一个 TextMetrics 对象。这个返回的对象目前只有一个属性 width,不过将来应该会增加更多度量指标。

measureText() 方法使用 font、textAlign 和 textBaseline 属性当前的值计算绘制指定文本后的大小。

变换

上下文变换可以操作绘制在画布上的图像。2D 绘图上下文支持所有常见的绘制变换。在创建绘制上下文时,会以默认值初始化变换矩阵,从而让绘制操作如实应用到绘制结果上。对绘制上下文应用变换,可以导致以不同的变换矩阵应用绘制操作,从而产生不同的结果。

  • rotate(angle):围绕原点把图像旋转 angle 弧度。
  • scale(scaleX, scaleY):通过在 x 轴乘以 scaleX、在 y 轴乘以 scaleY 来缩放图像。scaleX 和 scaleY 的默认值都是 1.0。
  • translate(x, y): 把原点移动到(x, y)。执行这个操作后,坐标(0, 0)就会变成(x, y)。
  • transform(m1_1, m1_2, m2_1, m2_2, dx, dy): 像下面这样通过矩阵乘法直接修改矩阵。m1_1 m1_2 dx m2_1 m2_2 dy 001
  • setTransform(m1_1, m1_2, m2_1, m2_2, dx, dy): 把矩阵重置为默认值,再以传入的参数调用 transform()。

绘制图像

2D 绘图上下文内置支持操作图像。如果想把现有图像绘制到画布上,可以使用 drawImage() 方法。 这个方法可以接收 3 组不同的参数,并产生不同的结果。最简单的调用是传入一个 HTML 的 <img> 元素, 以及表示绘制目标的 x 和 y 坐标,结果是把图像绘制到指定位置。

let image = document.images[0];
context.drawImage(image, 10, 10);

绘制出来的图像与原来的图像一样大。如果想改变所绘制图像的大小,可以再传入另外两个参数:目标宽度和目标高度。这里的缩放只影响绘制的图像,不影响上下文的变换矩阵。

context.drawImage(image, 50, 10, 20, 30);

还可以只把图像绘制到上下文中的一个区域。此时,需要给 drawImage() 提供 9 个参数: 要绘制的图像、源图像 x 坐标、源图像 y 坐标、源图像宽度、源图像高度、目标区域 x 坐标、目标区域 y 坐标、 目标区域宽度和目标区域高度。这个重载后的 drawImage() 方法可以实现最大限度的控制,比如:

context.drawImage(image, 0, 10, 50, 50, 0, 100, 40, 60);

阴影

2D 上下文可以根据以下属性的值自动为已有形状或路径生成阴影。

  • shadowColor:CSS 颜色值,表示要绘制的阴影颜色,默认为黑色。
  • shadowOffsetX:阴影相对于形状或路径的 x 坐标的偏移量,默认为 0。
  • shadowOffsetY:阴影相对于形状或路径的 y 坐标的偏移量,默认为 0。
  • shadowBlur:像素,表示阴影的模糊量。默认值为 0,表示不模糊。

这些属性都可以通过 context 对象读写。只要在绘制图形或路径前给这些属性设置好适当的值,阴影就会自动生成。

let canvas = document.getElementById('drawing');
// 确保浏览器支持<canvas>
if (canvas.getContext) {
let context = canvas.getContext('2d');

context.shadowOffsetX = 5;
context.shadowOffsetY = 5;
context.shadowBlur = 4;
context.shadowColor = 'rgba(0, 0, 0, 0.5)';

// 绘制红色矩形
context.fillStyle = '#ff0000';
context.fillRect(10, 10, 50, 50);
// 绘制蓝色矩形 26
context.fillStyle = 'rgba(0,0,255,1)';
context.fillRect(30, 30, 50, 50);
}

渐变

渐变通过 CanvasGradient 的实例表示,在 2D 上下文中创建和修改都非常简单。要创建一个新的线性渐变,可以调用上下文的 createLinearGradient() 方法。这个方法接收 4 个参数:起点 x 坐标、 起点 y 坐标、终点 x 坐标和终点 y 坐标。调用之后,该方法会以指定大小创建一个新的 CanvasGradient 对象并返回实例。

有了 gradient 对象后,接下来要使用 addColorStop() 方法为渐变指定色标。这个方法接收两个参数:色标位置和 CSS 颜色字符串。色标位置通过 0~1 范围内的值表示,0 是第一种颜色,1 是最后一种颜色。

let gradient = context.createLinearGradient(30, 30, 70, 70);
gradient.addColorStop(0, 'white');
gradient.addColorStop(1, 'black');

这个 gradient 对象现在表示的就是在画布上从(30, 30)到(70, 70)绘制一个渐变。渐变的起点颜色为白色,终点颜色为黑色。可以把这个对象赋给 fillStylestrokeStyle 属性,从而以渐变填充或描画绘制的图形

let canvas = document.getElementById('drawing');
// 确保浏览器支持<canvas>
if (canvas.getContext) {
let context = canvas.getContext('2d');

let gradient = context.createLinearGradient(30, 30, 70, 70);
gradient.addColorStop(0, 'white');
gradient.addColorStop(1, 'black');

// 绘制红色矩形
context.fillStyle = '#ff0000';
context.fillRect(10, 10, 50, 50); // 绘制渐变矩形
context.fillStyle = gradient;
context.fillRect(30, 30, 50, 50);
}

径向渐变(或放射性渐变)要使用 createRadialGradient() 方法来创建。这个方法接收 6 个参数,分别对应两个圆形圆心的坐标和半径。前 3 个参数指定起点圆形中心的 x、y 坐标和半径,后 3 个参 20 数指定终点圆形中心的 x、y 坐标和半径。在创建径向渐变时,可以把两个圆形想象成一个圆柱体的两个圆形表面。把一个表面定义得小一点,另一个定义得大一点,就会得到一个圆锥体。然后,通过移动两个圆形的圆心,就可以旋转这个圆锥体。

let gradient = context.createRadialGradient(55, 55, 10, 55, 55, 30);
gradient.addColorStop(0, 'white');
gradient.addColorStop(1, 'black'); // 绘制红色矩形
context.fillStyle = '#ff0000';
context.fillRect(10, 10, 50, 50); // 绘制渐变矩形
context.fillStyle = gradient;
context.fillRect(30, 30, 50, 50);

图案

图案是用于填充和描画图形的重复图像。要创建新图案,可以调用 createPattern() 方法并传入两个参数:一个 HTML <img> 元素和一个表示该如何重复图像的字符串。第二个参数的值与 CSS 的 background-repeat 属性是一样的,包括"repeat"、"repeat-x"、"repeat-y"和"no-repeat"。

let image = document.images[0];
let pattern = context.createPattern(image, 'repeat');
// 绘制矩形
context.fillStyle = pattern;
context.fillRect(10, 10, 150, 150);

传给 createPattern() 方法的第一个参数也可以是 <video> 元素或者另一个 <canvas> 元素。

图像数据

2D 上下文中比较强大的一种能力是可以使用 getImageData() 方法获取原始图像数据。这个方法接收 4 个参数: 要取得数据中第一个像素的左上角坐标和要取得的像素宽度及高度。

返回的对象是一个 ImageData 的实例。每个 ImageData 对象都包含 3 个属性:width、height 和 data,其中,data 属性是包含图像的原始像素信息的数组。每个像素在 data 数组中都由 4 个值表示,分别代表红、绿、蓝和透明度值。换句话说,第一个像素的信息包含在第 0 到第 3 个值中,比如:

let imageData = context.getImageData(10, 5, 50, 50);

let data = imageData.data,
red = data[0],
green = data[1],
blue = data[2],
alpha = data[3];

这个数组中的每个值都在 0~255 范围内(包括 0 和 255)。对原始图像数据进行访问可以更灵活地操作图像。例如,通过更改图像数据可以创建一个简单的灰阶过滤器:

完整示例
let drawing = document.getElementById('drawing');
// 确保浏览器支持 <canvas>
if (drawing.getContext) {
let context = drawing.getContext('2d'),
image = document.images[0],
imageData,
data,
i,
len,
average;

// 确保图片已加载完成
image.onload = function () {
// 将图像绘制到 <canvas> 上
context.drawImage(image, 0, 0);

// 获取图像的宽度和高度
let width = image.width;
let height = image.height;

// 取得图像数据
imageData = context.getImageData(0, 0, width, height);
data = imageData.data;

// 遍历每个像素,将其转换为灰度
for (i = 0, len = data.length; i < len; i += 4) {
let red = data[i];
let green = data[i + 1];
let blue = data[i + 2];

// 计算 RGB 平均值
average = Math.floor((red + green + blue) / 3);

// 设置 R, G, B 为平均值 (灰度),保持 alpha 不变
data[i] = average; // R
data[i + 1] = average; // G
data[i + 2] = average; // B
// alpha 值不变 data[i + 3] = alpha;
}

// 将修改后的数据写回 ImageData 并应用到 <canvas> 上显示出来
context.putImageData(imageData, 0, 0);
};

// 如果图片已经加载,可以直接执行灰度转换
if (image.complete) {
image.onload();
}
}

这个例子首先在画布上绘制了一个图像,然后又取得了其图像数据。for 循环遍历了图像数据中的每个像素,注意每次循环都要给 i 加上 4。每次循环中取得红、绿、蓝的颜色值,计算出它们的平均值。 然后再把原来的值修改为这个平均值,实际上相当于过滤掉了颜色信息,只留下类似亮度的灰度信息。 之后将 data 数组重写回 imageData 对象。最后调用 putImageData() 方法,把图像数据再绘制到画布上。结果就得到了原始图像的黑白版。

合成

2D 上下文中绘制的所有内容都会应用两个属性: globalAlphaglobalCompositionOperation,其中,globalAlpha 属性是一个范围在 0~1 的值(包括 0 和 1),用于指定所有绘制内容的透明度,默认值为 0。如果所有后来的绘制都需要使用同样的透明度,那么可以将 globalAlpha 设置为适当的值,执行绘制,然后再把 globalAlpha 设置为 0。比如:

// 绘制红色矩形
context.fillStyle = '#ff0000';
context.fillRect(10, 10, 50, 50);
// 修改全局透明度
context.globalAlpha = 0.5;
// 绘制蓝色矩形
context.fillStyle = 'rgba(0,0,255,1)';
context.fillRect(30, 30, 50, 50);
// 重置
context.globalAlpha = 0;

globalCompositionOperation 属性表示新绘制的形状如何与上下文中已有的形状融合。这个属性是一个字符串,可以取下列值。

属性值描述
source-over默认值,新图形绘制在原有图形上面。
source-in新图形只绘制出与原有图形重叠的部分,画布上其余部分全部透明。
source-out新图形只绘制出不与原有图形重叠的部分,画布上其余部分全部透明。
source-atop新图形只绘制出与原有图形重叠的部分,原有图形不受影响。
destination-over新图形绘制在原有图形下面,重叠部分只有原图形透明像素下的部分可见。
destination-in新图形绘制在原有图形下面,画布上只剩下二者重叠的部分,其余部分完全透明。
destination-out新图形与原有图形重叠的部分完全透明,原图形其余部分不受影响。
destination-atop新图形绘制在原有图形下面,原有图形与新图形不重叠的部分完全透明。
lighter新图形与原有图形重叠部分的像素值相加,使该部分变亮。
copy新图形将擦除并完全取代原有图形。
xor新图形与原有图形重叠部分的像素执行“异或”计算。
合成效果示例
// 绘制红色矩形
context.fillStyle = '#ff0000';
context.fillRect(10, 10, 50, 50);
// 设置合成方式
context.globalCompositeOperation = 'destination-over'; // 绘制蓝色矩形
context.fillStyle = 'rgba(0,0,255,1)';
context.fillRect(30, 30, 50, 50);

WebGL

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

WebGL 上下文

在完全支持的浏览器中,WebGL 2.0 上下文的名字叫"webgl2",WebGL 1.0 上下文的名字叫 "webgl1"。如果浏览器不支持 WebGL,则尝试访问 WebGL 上下文会返回 null。在使用上下文之前, 应该先检测返回值是否存在:

let drawing = document.getElementById('drawing');
// 确保浏览器支持<canvas>
if (drawing.getContext) {
let gl = drawing.getContext('webgl');
if (gl) {
// 使用WebGL
}
}

这里把 WebGL context 对象命名为 gl。大多数 WebGL 应用和例子遵循这个约定,因为 OpenGL ES 2.0 方法和值通常以"gl"开头。这样可以让 JavaScript 代码看起来更接近 OpenGL 程序。