Tl;DR:
- OffscreenCanvas可以让你在Worker线程中渲染图形,支持多种RenderingContext
- 两种使用方式:同步的Transfer模式与异步的Control模式
- 将Canvas的逻辑计算与渲染分离,避免UI线程阻塞
介绍
产生的契机:用户在交互时的Canvas逻辑与渲染在同一线程内执行,动画产生的卡顿可能会影响用户体验。若在后台渲染,则可以避免耗时的渲染任务阻塞主线程。
使用OffscreenCanvas与Worker结合的方式可以将渲染任务放在子线程中,有效提升用户交互时的界面流畅度。
两种使用方式
Transfer模式与Control模式。
自己起的名字,参考了这篇文章。
Transfer模式
- worker线程中创建OffscreenCanvas对象并执行渲染,给主线程返回结果(缓冲区图像或其它数据)
- 主线程使用缓冲区数据渲染Canvas元素
worker线程
let offscreen = new OffscreenCanvas(w,h);
let ctx = offscreen.getContext('2d');
// 一些渲染操作...
let image = offscreen.transferToImageBitmap();
self.postMessage({ image }, [image]);
主线程
renderWorker.onmessage = msg => {
let imageBuffer = msg.data.image;
let bitmapContext = canvas.getContext("bitmaprenderer");
bitmapContext.transferFromImageBitmap(imageBuffer);
}
这种方式可以用于H5游戏的精灵加载,文本渲染、生成海报等固定的渲染任务。
Control模式
- 主线程中移交Canvas元素的控制权
- 在worker线程执行所有的渲染操作,无需图像数据的传递即可更新Canvas元素
在该模式下不需要transfer的相关操作,内部直接对绑定的dom元素进行更新。
主线程
const offscreen = document.querySelector('canvas').transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
worker线程
onmessage = e => {
let canvas = e.data.canvas
let ctx = canvas.getContext('2d');
// 一些渲染操作...
// 这里的渲染操作会在Canvas元素上同步绘制的图像
}
加载worker文件
在使用webpack进行构建的项目中,对于worker文件需要进行一些额外的处理。
目前生态中主要有三种处理web worker的loader: worker-loader,workerize-loader和comlink-loader
关于这几种loader的介绍可以看看这篇后面的loader部分。
经测试,由于workerize-loader
目前没有对postMessage方法中的Transferable参数序列进行处理,因此无法将主线程的OffscreenCanvas对象传入worker中,详情见这个issue,worker-loader
表现正常
图片处理
在worker中使用图像数据与主线程中有所不同。
主线程
const loadImage = imgPath => {
return new Promise((resolve, reject) => {
let img = new Image();
img.setAttribute("crossOrigin", "anonymous"); // to solve "Tainted canvases may not be exported" error
img.onload = () => { resolve(img); };
img.onerror = e => { reject(new Error(e));};
img.src = imgPath;
});
};
const image = await loadImage(url)
// use image...
worker线程
const response = await fetch(url);
const blob = await response.blob();
const image = await createImageBitmap(blob);
// use image...
动画驱动
在worker线程中执行动画有两种方式:
- Timer - setTimeout, setInterval等
- rAF - rAF在DedicatedWorker中已经实现,与Window中的行为一致。
实践
了解了OffscreenCanvas与worker的相关特性,不如动手尝试一下,以一个蒙版合成的渲染任务为例。
在主线程中执行绘制
/* 0. 获取Canvas元素 */
let mainCanvas = document.querySelector('#canvas');
let mainCtx = mainCanvas.getContext('2d');
/* 1. 准备Image对象 */
let img = await loadImage(imgPath);
...
/* 2. 创建一个Canvas来合成结果图像 */
let maskLayer = document.createElement("canvas");
maskLayer.width = width;
maskLayer.height = height;
const maskCtx = maskLayer.getContext("2d");
maskCtx.drawImage(img, 0, 0);
let maskData = maskCtx.getImageData(0, 0, width, height);
for (let i = 0; i < width * height; i++) {
if (values[i] !== 255) {
maskData.data[(i + 1) * 4 - 1] = mask[i];
}
}
maskCtx.putImageData(maskData, 0, 0);
...
/* 3. 绘制到Canvas元素上 */
mainCtx.drawImage(maskLayer, 0, 0);
在worker线程中执行绘制
主线程
import CanvasWorker from "worker-loader!@/workers/canvas.worker.js";
...
/* 0. 获取Canvas元素 */
let canvas = document.querySelector('#canvas');
let offscreenCanvas = canvas.transferControlToOffscreen();
let canvasWorker = new CanvasWorker();
// 将绑定的offscreenCanvas实例传递到worker线程中
canvasWorker.postMessage({ canvas: offscreenCanvas, event: "init" }, [offscreenCanvas]);
...
/* 2.发送绘制事件 */
let img = this.resultImg || this.img;
canvasWorker.postMessage({
event: "draw"
payload: JSON.stringify({ width, height, imgSrc, mask }) // 由于结构化克隆算法的限制,这里对参数对象进行JSON序列化后赋值
});
worker线程
let canvas, ctx;
onmessage = async e => {
const data = e.data;
const { payload, event } = data;
switch (event) {
case "init": {
// 保存传入的OffscreenCanvas实例
canvas = data.canvas;
ctx = canvas.getContext("2d");
break;
}
case "draw": {
/* 0. 解析参数 */
let { width, height, mask } = JSON.parse(payload);
...
/* 1. 下载图片并获取的ImageBitmap数据 */
const response = await fetch(imgSrc);
const blob = await response.blob();
const imageBitmap = await createImageBitmap(blob);
...
/* 2. 创建一个新的OffscreenCanvas来合成结果图像 */
let maskLayer = new OffscreenCanvas(width, height);
const maskCtx = maskLayer.getContext("2d");
maskCtx.drawImage(imageBitmap, 0, 0); // 使用ImageBitMap绘制图片
let maskData = maskCtx.getImageData(0, 0, width, height);
for (let i = 0; i < width * height; i++) {
if (values[i] !== 255) {
maskData.data[(i + 1) * 4 - 1] = mask[i];
}
}
maskCtx.putImageData(maskData, 0, 0);
...
/* 3. Canvas元素上会更新绘制效果 */
ctx.drawImage(maskLayer, 0, 0);
break;
}
}
};
浅析
极简Chromium渲染流水线:
blink ---(Main Frame)---> Layer Compositor ---(Compositor Frame)---> Display Compositor ---(GL/UI Frame)---> Window
- 在OffscreenCanvas中渲染省去了Main Frame中的部分计算任务。
- Control模式中OffscreenCanvas对Canvas元素的更新不再与主线程中的其他元素保持同步,因为它们通过不同的渲染流水线。
- OffscreenCanvas类中提供了一些Layer Compositor阶段的执行方法:Commit, BeginFrame与PushFrame等。处理后的数据直接交给Display Compositor渲染,走最短的渲染路径。
- 使用Transfer模式可以实现Canvas元素与其它元素的同步更新。
blink中offscreen_canvas与html_canvas_element的主类源码:
OffscreenCanvas
与HTMLCanvasElement
共同继承的类:
- ImageBitmapSource - ImageBitmapSource API
- CanvasRenderingContextHost - 文档中的描述:
the base class for all elements that can host a rendering context
,包含通用的数据转换、尺寸设置、属性获取等方法
兼容性
其他
- 几个演示Demo
- Google I/O 17上的介绍视频 其中的commit方法已经弃用,因为现在worker中已经实现了rAF