跳到主要内容

Canvas

const canvas = document.querySelector('canvas');
const context = canvas.getContext('2d');

Canvas API

toBlob

canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
}, "image/jpeg", 1) // quality

toDataURL

const dataUrl = canvas.toDataURL("image/jpeg", 1); // quality

captureStream

获取Canvas的媒体流,从而实现Video预览或者媒体录制的能力。

const stream = canvas.captureStream();

// 预览能力
video.srcObject = stream;

// 录制能力
const recorder = new MediaRecorder(stream);

2D Context API

矩形绘制

  • fillRect(x, y, width, height)
  • strokeRect(x, y, width, height)
  • clearRect(x, y, width, height)
ctx.fillRect(0, 0, 100, 100)
ctx.strokeRect(20, 20, 40, 40)

ctx.clearRect(0, 0, canvas.width, canvas.height) // 清除整个画布内容

路径绘制

路径即为多个点的连线。

// case1 绘制三角形
ctx.beginPath()

ctx.moveTo(0, 0)
ctx.lineTo(100, 0)
ctx.lineTo(0, 100)

ctx.stroke()
ctx.closePath()

// case2 绘制圆形
ctx.beginPath()

ctx.arc(100, 100, 10, 0, Math.PI) // 默认逆时针

ctx.fill()
ctx.closePath()

绘制文字

ctx.font = '24px'
ctx.fillText("hello akara", 100, 100) // 绘制文字
ctx.strokeText("hello akara", 100, 100) // 绘制文字填充

上下文属性

Canvas的context本质上是一个状态机,绘制时的样式信息都是从上下文中获取的,常用的上下文属性有:

  • fillStyle:填充色
  • strokeStyle:描边色
  • globalAlpha:全局透明度
  • lineWidth:描边宽度
  • lineCap:线帽样式
  • lineJoin:连线关节处样式
  • font:字体样式,如字体大小
  • shadowOffsetXshadowOffsetYshadowBlurshadowColor:阴影样式

我们还可以通过setLineDash([4, 2])来把描边设置成虚线;除此之外,填充色可以设置成渐变色或者图片组成的模式pattern。

坐标变换

Canvas提供了坐标变换的能力,它能够移动我们的原点、旋转整个网格甚至进行缩放。

  • ctx.translate(x, y)。将原点向右移动x,向下移动y。
  • ctx.rotate(angle)。将网络顺时针旋转,如ctx.rotate(Math.PI)
  • ctx.scale(x, y)
  • ctx.transform(a, b, c, d, e, f)

坐标变换的本质是将二维向量(x1, y1)进行线性变换变成新的二维向量(x2, y2),可以用左乘矩阵进行表示:位移矩阵 * 旋转矩阵 * 缩放矩阵 * vec3(x1, y1, 0) ,而这又等价于转换矩阵transform * vec3(x1, y1, 0)(注:这里把二维升成了三维)

saverestore

我们之前提到过Canvas的2D上下文本质是一个状态机,他还提供了保存和恢复当前状态的能力(以一种栈的形式)。我们可以先把当前状态保存,然后再进行坐标转换操作、其他上下文属性的修改、甚至是下文会提到的裁剪路径,待绘制结束后再恢复到最开始的状态。

ctx.save()

ctx.tranlate(100, 100)
ctx.rotate(Math.PI / 2) // 先移动中心点,再绕新的中心点进行旋转(设置旋转矩阵)
ctx.tranlate(-100, -100) // 设置完旋转矩阵后把位移矩阵重置,方便后续绘制时坐标传入

ctx.fillStyle = 'pink'
ctx.fillRect(0, 0, 100, 100)

ctx.restore()

drawImage

Canvas提供了drawImage方法将不同的图像源绘制到我们的目标Canvas上,图像源包括Image、Video甚至另一个Canvas对象,以及后文会介绍的ImageBitMap,或者是WebCodecs产出的视频帧videoFrame。

ctx.drawImage(image, 0, 0)
ctx.drawImage(video, 0, 0)
ctx.drawImage(canvas2, 0, 0)
ctx.drawImage(videoFrame, 0, 0)

drawImage函数是个重载函数,有几种不同的用法。(注:下文中的d表示目标Canvas Destination,s表示图像源头Source)

  1. drawImage(source, dx, dy)。简单的用法,以(dx, dy)为原点绘制目标图像。

  2. drawImage(source, dx, dy, dWidth, dHeight)。和用法一类似,额外提供了widthheight的参数允许我们调整所绘制的图像的大小,从而实现类似缩放的效果。

  3. drawImage(source, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)。这个用法调整了参数的顺序,可用来裁剪数据源的部分区域进行绘制。

信息

drawImage使用不同的图像源时的行为不同,性能上也略有差异,笔者在M2 Macbook Pro下,将CPU降速6倍下用15000*15000的图片进行性能测试后得出以下结论。

Demo链接

  1. drawImage(Image),JS线程API调用很快,渲染线程绘制上屏较慢(将近3秒,耗时主要集中在图像解码上)。
  2. drawImage(Canvas),JS线程API调用很快,渲染线程绘制上屏也很快。推荐
  3. drawImage(OffscreenCanvas),JS线程API调用很慢(将近3秒,耗时主要集中在图像解码上,可能是浏览器内部机制的原因图像解码完成后该函数才返回),渲染线程绘制上屏快。事实发现drawImage(image)到OffscreenCanvas上时不会触发图像解码,后续在把OffscreenCanvas绘制到Canvas上才会触发图像解码,因此在离屏渲染的时候应该选择使用Canvas而不是OffscreenCanvas。
  4. drawImage(ImageBitmap)。JS线程API调用很快,渲染线程绘制上屏也很快。和用例2差不多。推荐

在例子2和例子3中,我们需要先通过drawImage把图片绘制到用来缓存的Canvas/OffscreenCanvas上,但我没有立刻同步地把缓存的Canvas绘制到我们的目标Canvas上,而是使用了一个定时器来确保先执行渲染线程,从而保证我们的Canvas图像源本身已经绘制完毕ready了。 此时整体执行顺序如下:1. JS线程 drawImage(image) -> 2. 渲染线程 把图片绘制到Canvas上 -> 3. JS线程 drawImage(canvas) -> 4. 渲染线程 绘制Canvas到Canvas。 因此我实际上测量的是3和4的总时长,这也是离屏渲染的常见场景————我已经提前缓存好了Canvas/图像源了,现在关心的是调用drawImage(canvas)时上屏所需要的时间。

在其他的一些场景下,我们会在JS线程调用了drawImage(image)后立刻调用drawImage(canvas),相当于我们例子中把定时器去掉的效果。 此时整体的执行顺序如下:1. JS线程 drawImage(image) -> 2. JS线程 drawImage(canvas) -> 3. 渲染线程。其实这个行为和上面例子3 OffscreenCanvas是基本一致的,事实也证明此时会在步骤2的drawImage(canvas) 中耗时将近3秒用来图像转码。

getImageData/putImageData

通过getImageData可以直接拿到Canvas指定区域对应的原始像素数据。可以通过指定的数学转换实现不同的效果,比如Konva的高斯模糊等滤镜就是通过纯CPU计算实现的。

const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)

for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i] = 255; // red channel
imageData.data[i + 1] = 0; // green channel
imageData.data[i + 2] = 255; // blue channel
imageData.data[i + 3] = 200; // alpha channel
}
ctx.putImageData(imageData, 0, 0)
警告

需要特别注意的是,getImageDataputImageData都是非常耗CPU的操作,容易造成长任务。除非我们确实需要对像素做一些操作,否则都应该使用drawImage等方法来进行绘制。

GPU/CPU Canvas

默认情况下Canvas的创建和绘制都是在GPU上的(硬件加速),当我们调用getImageData或者putImageData时本质都是GPU显存和CPU内存的读写数据,这是个比较耗费性能的操作。如果我们的Canvas存在很频繁的这类读写操作,可以考虑使用willReadFrequently标识,这样Canvas的绘制数据都会被存储在CPU内存中,减少读写操作的延时,但同时也会失去GPU硬件加速的能力。

const ctx = canvas.getContext('2d', {
willReadFrequently: true
})

willReadFrequently

A boolean value that indicates whether or not a lot of read-back operations are planned. This will force the use of a software (instead of hardware accelerated) 2D canvas and can save memory when calling getImageData() frequently.

混合模式

Canvas提供了globalCompositeOperation属性来实现混合模式。

裁剪Clip(Mask)

在路径绘制一节中我们介绍了如何创建路径,并通过fill()或者stroke()来填充路径或描边路径,除了这两个方法外我们还可以使用clip()来创建一个裁剪路径。后续的绘制命令都只会绘制到裁剪路径所圈出的范围内,通过这个方法可以实现遮罩Mask的效果。

ctx.save()
ctx.beginPath();
ctx.arc(100, 100, 50, 0, Math.PI * 2)
ctx.clip();
ctx.fillStyle = 'pink'
ctx.fillRect(0, 0, 100, 100)
ctx.restore() // 不恢复的话后续其他绘制都只能在clip区执行了

ctx.fillStyle = 'skyblue'
ctx.fillRect(100, 100, 100, 100)

在这个例子中我们创建了一个圆形的裁剪路径,后续绘制的粉色矩形会被裁剪到只展示圆形裁剪路径内的内容。

路径环绕规则

通过上面的例子,我们知道在Canvas中通过裁剪来实现遮罩效果是很简单的;我们可以更进一步,思考一下如何实现反向的裁剪区域,即只绘制圆形裁剪路径之外的内容?

Canvas的fillAPI能够填充路径的内部区域clip能够把路径的内部区域视为裁剪区域。当一个路径包含多个区域时,我们怎么分辨某个区域是属于路径的内部还是外部呢?这是通过内部的路径环绕规则来决定的,fillclip这两个API都支持传入参数来指定路径环绕规则,分别是默认的非零环绕规则nonzero以及奇偶环绕规则evenodd。在图形学中,这个规则可以用来判断一个点是否在多边形(路径)的内部来进行点击/碰撞计算。

非零环绕规则nonzero

Canvas默认使用非零环绕规则。简单来说,对于区域内的任意点向外无限远引一条射线,射线会经过若干条路径,假如其中两条路径是顺时针环绕的,另一条路径是逆时针环绕的,两种环绕的差值不为零,那么说明这个区域是在路径内部的。当使用fill时,这个区域会被填充;当使用clip时,这个区域会被视为裁剪区域。

奇偶环绕规则evenodd

与非零环绕规则不同的是,奇偶环绕规则无视了路径的环绕方向(顺时针或逆时针)。对于区域内的任意点向外无限远引一条射线,射线如果总是经过奇数条路径,则该区域在路径内部;否则区域在路径外部。

我们现在了解了clip默认使用的非零环绕规则的原理,那么如何实现我们最初的目标“反向裁剪”?事实上,我们可以先绘制一个顺时针方向的矩形,再在内部绘制一个逆时针反向的圆形,这样通过非零环绕规则的计算这两个图形中间的区域会被视为路径的内部,成为了裁剪区域;而圆形内部的区域,则会被视为路径的外部,不会再被视为裁剪区域。

const ctx = canvas.getContext('2d');
ctx.save()
ctx.beginPath();

ctx.rect(0, 0, 200, 200) // 先顺时针绘制矩形
ctx.arc(100, 100, 50, 0, Math.PI * 2, true) // 通过传入true来逆时针绘制圆形
ctx.clip();
ctx.fillStyle = 'pink'
ctx.fillRect(0, 0, 100, 100)
ctx.restore() // 不恢复的话后续其他绘制都只能在clip区执行了

ctx.fillStyle = 'skyblue'
ctx.fillRect(100, 100, 100, 100)

requestAnimationFrame

实现Canvas动画很简单,一般只需要遵循以下步骤即可:

  1. 清除画布
  2. 保存上下文状态
  3. 进行绘制
  4. 恢复上下文状态

至于动画的调度则可以使用setIntervalsetTimeout以及requestAnimationFrame。其中setInterval用的较少,而setTimeoutrAF的差异主要有以下几点:

  1. rAF的执行是根据屏幕刷新率来的;而setTimeout需要自己控制执行频率,无法适应不同刷新率的显示器。
  2. rAF在页面不可见时不会执行;而setTimeout仍然会执行,存在不必要的性能开销。

OffscreenCanvas

我们的主页面存在着互斥的JS线程(通常也被称为主线程)和渲染线程。每当JS线程执行完一个JS任务,就会切到渲染线程执行渲染任务(即屏幕内容的绘制),绘制完后又回到JS线程执行下一个JS任务。 这也是为什么长任务会导致页面卡顿,一般为了保证画面稳定在60帧的刷新率,我们需要每16.6秒绘制一次,但考虑到渲染线程本身的执行也是需要时间的,所以最终留给我们的每个JS任务的执行时间是比这更短的。

一般对于前后依赖的长时间JS同步任务,有种优化手段是利用如setTimeout或者Promise.then的能力把任务拆分成多个任务来异步执行(JS中的异步指的是单线程异步),从而避免任务过长导致页面卡顿。但任务整体的耗时是不会减少的(甚至更长了)。

而对于互相独立的同步任务(JS任务或者渲染任务),我们可以借助Web Worker的能力实现并行的计算和渲染。 Web Worker都有着自己的JS线程和渲染线程,它们的执行不会影响到我们主页面的执行和渲染。而为了利用到Web Worker的渲染线程,我们需要使用到Canvas,然而不幸的是Web Worker环境下无法访问到DOM元素,为了解决这个问题浏览器在Web Worker下提供了和DOM完全解耦的Canvas————OffscreenCanvas。

在上文中我们也简单提到过OffscreenCanvas,它本身的绘制能力和普通的Canvas基本一致,并没有神奇的魔法来提升我们的绘制性能。能够在Web Worker下执行,充分利用Web Worker的渲染线程能力,是它最大的亮点。比如我们可以使用下文将要介绍的transferControlToOffscreen,实现通过Web Worker的渲染线程绘制主页面的内容。

并行处理

Web Worker提供给我们并行计算和渲染的能力,当我们存在特别多互相独立的任务时,理论上我们可以创建无数个Web Worker来实现并行加速。但实际上Web Worker的创建开销是不小的(进程级别),所以并不现实。我们一般会借助GPU的能力,来处理各种并行计算的复杂场景。

transferControlToOffscreen

在JS线程调用Canvas的transferControlToOffscreen方法可以生成一个OffscreenCanvas实例,同时会把自身上下文的所有权转移给该实例。

这意味着我们将无法直接访问Canvas的上下文,但通过OffscreenCanvas却可以拿到Canvas的上下文。而我们可以把OffscreenCanvas传递给Web Worker,即可通过在Web Woker中调用OffscreenCanvas的能力来间接绘制JS线程的Canvas。

main.js
const canvas = document.createElement('canvas')
canvas.width = 5000
canvas.height = 5000
document.body.appendChild(canvas)

const offscreenCanvas = canvas.transferControlToOffscreen()
const worker = new Worker('./worker.js')
worker.postMessage({ canvas: offscreenCanvas }, [offscreenCanvas])
worker.js
let canvas = null;
self.onmessage = function(evt) {
if (evt.data.canvas) {
canvas = evt.data.canvas;
draw()
}
}

function draw() {
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'pink'
ctx.fillRect(0, 0, canvas.width, canvas.height);
requestAnimationFrame(draw)
}
}

transferToImageBitmap

除了上述的方法外,我们也可以直接在Web Worker中初始化OffscreenCanvas实例并进行绘制操作。而为了将绘制内容同步到JS线程的Canvas上,我们首先可能会想到getImageData但很明显这太耗性能了,又或者把OffscreenCanvas传递到JS线程但我Worker线程后面还要用到所以也不行。因此浏览器给OffscreenCanvas提供了transferToImageBitmap的能力来解决这个问题。

在前述章节中我们介绍过,getImageData的本质是把GPU显存中的数据写入到CPU内存中,存在不小的性能开销。而transferToImageBitmap可以简单理解成GPU显存到GPU显存的传递,即把OffscreenCanvas当前绘制的内容转移到另一块GPU显存空间中,性能是比较好的,并且此时如果再尝试通过getImageData读取OffscreenCanvas的数据会发现都已经被重置了。

ImageBitmap

createImageBitMap

const image = new Image();
image.src = '';
image.onload = function() {
createImageBitmap(image).then(bitMap => {
ctx.drawImage(bitMap, 0, 0);
})
}