Worker中的OffscreenCanvas渲染实践与浅析

3,095 阅读5分钟

Tl;DR:

  1. OffscreenCanvas可以让你在Worker线程中渲染图形,支持多种RenderingContext
  2. 两种使用方式:同步的Transfer模式与异步的Control模式
  3. 将Canvas的逻辑计算与渲染分离,避免UI线程阻塞

介绍

产生的契机:用户在交互时的Canvas逻辑与渲染在同一线程内执行,动画产生的卡顿可能会影响用户体验。若在后台渲染,则可以避免耗时的渲染任务阻塞主线程。

使用OffscreenCanvas与Worker结合的方式可以将渲染任务放在子线程中,有效提升用户交互时的界面流畅度。

两种使用方式

Transfer模式Control模式

自己起的名字,参考了这篇文章。

Transfer模式

  1. worker线程中创建OffscreenCanvas对象并执行渲染,给主线程返回结果(缓冲区图像或其它数据)
  2. 主线程使用缓冲区数据渲染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模式

  1. 主线程中移交Canvas元素的控制权
  2. 在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-loadercomlink-loader

关于这几种loader的介绍可以看看这篇后面的loader部分。

经测试,由于workerize-loader目前没有对postMessage方法中的Transferable参数序列进行处理,因此无法将主线程的OffscreenCanvas对象传入worker中,详情见这个issueworker-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线程中执行动画有两种方式:

  1. Timer - setTimeout, setInterval等
  2. 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, BeginFramePushFrame等。处理后的数据直接交给Display Compositor渲染,走最短的渲染路径。
  • 使用Transfer模式可以实现Canvas元素与其它元素的同步更新。

blink中offscreen_canvas与html_canvas_element的主类源码:

OffscreenCanvasHTMLCanvasElement共同继承的类:

  • ImageBitmapSource - ImageBitmapSource API
  • CanvasRenderingContextHost - 文档中的描述:the base class for all elements that can host a rendering context,包含通用的数据转换、尺寸设置、属性获取等方法

兼容性

其他

参考