pixiv insideは移転しました! ≫ http://inside.pixiv.blog/

まだシングルスレッドでレンダリングしてるの? HTML5 CanvasとWeb Workerの最新技術

こちらは ピクシブ株式会社 Advent Calendar 2016、13日目の記事です。

こんにちは!4月からピクシブに入社したエンジニアの@_ragg_です✨
メンテナンスチーム・pixivFACTORYチーム・pixivFANBOXチームを旅して、デザインをかじったりフロントエンドを触ったりしています、3代目社内旅行エンジニアですね!

さて、今回はHTML5 Canvasに実装されつつあるOffscreenCanvasと、Web Workerについてお話しします。まだ日本語文献の少ないアツアツのネタです??

OffscreenCanvas #とは

OffscreenCanvasは、「画面に表示されないCanvas」です。
かつて CanvasProxy と呼ばれていたのをご存じの方もいると思います、まさにそれです。

「画面に表示されないCanvas」は、「表示前に何段階か画像の加工が必要だけど、加工が終わるまでは画面に出したくない」とか「毎フレーム同じものをレンダリングしないように背景だけキャッシュしておきたい」という場合に有効です。

この説明だけだと「それってCanvasでもできるよね?」というのが率直な意見でしょう。
しかしOffscreenCanvasにはTransferableWorkerスレッド単体でも利用可能という興味深い特徴があります。(これについては後述します)

Web Worker #とは

Web Workerはブラウザ上でマルチスレッド処理を行える機能です。

Web WorkerはJavaScript上でWorkerクラスとして表現されており、new Worker('worker-script.js')のようにWorker用のスクリプトを与えることで、別のスレッド上で処理を行うことができます。 (詳しくは MDN: Web Worker を使用する をご参照ください)

Worker.postMessage()を経由することでメインスレッドからWorkerスレッドへデータ(メッセージ)を送信し、これによってメインスレッドとWorkerスレッドで協調動作を行います。

Transferable

先にお話したTransferableとは、このpostMessageメソッドを介して転送可能なオブジェクトの種類のことです。
postMessageメソッドで転送できるデータには、ObjectやStringなどのJSONにシリアライズできる値と、Transferable Objectsの2種類があります。

具体的には、JavaScriptの以下のオブジェクトがTransferableに当たります。

  • ArrayBuffer
  • MessagePort
  • OffscreenCanvas
  • ImageBitmap

OffscreenCanvasの利点

今までは、メインスレッド内であればCanvasを用いて曲線を書いたり、img要素越しに読み込んでImageData(RGBA配列)にデコードしたりできましたが、Workerスレッド内ではそのような高レベルな画像の取扱いが出来ませんでした。

そのため、Workerはメインスレッドからレンダリング途中のImageDataを受けとり、その中の数値データを順繰りに舐める類の重い処理を行うことが一般的で、Worker自身で画像をレンダリングするのは厳しみがありました。

しかし、OffscreenCanvasによってWorkerだけでも曲線を描いたり、画像をライブラリなしでデコードしたり、画面外でWebGLを触ったり、ということが出来るようになります。 *1
最近登場したServiceWorker内でも利用できるようです。

少々複雑な実装を行えば、ゲームなどでレイヤー別にWorkerを立てて並列レンダリングする、ということも可能になるかと思います?

OffscreenCanvasを触ってみる

今回はサンプルとして、Workerスレッド上でイラストにエフェクトを適用してCanvas上に表示してみます。

利用可能なブラウザ

サンプルを実行するのに必要な機能が実装されているのは、記事執筆時点でChrome Canaryに限られます。 またOffscreenCanvasはまだ安定版として提供されていない機能なので、以下の設定を行う必要があります。

  1. chrome://flags/#enable-experimental-canvas-features を開く
  2. 試験運用版の canvas 機能を有効にする
  3. Chromeを再起動する

実演

お待たせしました!いよいよ実際のコードです!

OffscreenCanvasは2つの方法で生成することができます。

  • HTMLCanvasElement#transferControlToOffscreenで生成する
    • Worker上で生成した画像をそのままCanvasに反映させたい場合はこちら
  • new OffscreenCanvas(width, height)で生成する
    • バッファーとして利用して直接画面に表示しない・Worker内でとりあえずCanvasを使いたい場合

今回はcanvas要素からOffscreenCanvasを生成してWorkerから直接レンダリングを行います。 まずはメインスレッド側のコードを見てみましょう。

// main.js
window.addEventListener('DOMContentLoaded', async e => {
  const worker = new Worker('worker.js')

  // Workerの処理完了を待つためのヘルパ
  const waitResponse = () => new Promise(resolve => {
      worker.addEventListener('message', ({data}) => {
          data.action === 'resolve' && resolve(data)
      }, {once: true})
  })

  const dest = document.createElement('canvas')
  dest.width = 640
  dest.height = 360
  document.body.appendChild(dest)

  // canvas要素からOffscreenCanvasを生成して、Workerスレッドへ転送する
  const offscreen = dest.transferControlToOffscreen()
  worker.postMessage({action: 'attachCanvas', canvas: offscreen}, [offscreen])
  await waitResponse()

  const render = async () => {
    worker.postMessage({action: 'render'})
    await waitResponse()
    requestAnimationFrame(render)
  }

  requestAnimationFrame(render)
})

次にWorker側のコードです。

// worker.js
import Filters from 'canvasfilters'
import blur from './filters/blur'
import highpass from './filters/highpass'

import loadAsBlob from './utils/load-as-blob'

new class WorkerProcess {
  constructor() 
  {
    this.handleMessage()
  }

  handleMessage()
  {
    self.onmessage = async ({data: {action, ...props}}) => {
        switch (action) {
          case 'attachCanvas':
            await this.attachCanvas(props)
            break;

          case 'render':
            await this.render()
            break;
        }

        self.postMessage({action: 'resolve'})
    }
  }

  async _preload()
  {
    const imageBlob = await loadAsBlob('/src/images/example.png')
    this.sourceImage = await createImageBitmap(imageBlob)
  }

  async attachCanvas({canvas})
  {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    await this._preload()
  }

  async render()
  {
    const {ctx, sourceImage, canvas: {width, height}} = this
    ctx.clearRect(0, 0, width, height)
    ctx.drawImage(sourceImage, 0, 0)

    const srcImageBuffer = ctx.getImageData(0, 0, width, height)

    const imageBlurred = blur(srcImageBuffer, 10)
    const imageLowPass = blur(highpass(srcImageBuffer, 230), 80)
    const destinate = Filters.screenBlend(
        Filters.multiplyBlend(srcImageBuffer, imageBlurred), 
        imageLowPass
    )

    ctx.putImageData(new ImageData(destinate.data, destinate.width, destinate.height), 0, 0)
    ctx.commit()
  }
}

解説

かいつまんで大切そうな所だけ解説します。

まずは以下のコードでcanvas要素とOffscreenCanvasを生成します。

// main.js内
// canvas要素を作る
const dest = document.createElement('canvas')
// canvas要素に紐付いたOffscreenCanvasオブジェクトを取得する
const offscreen = dest.transferControlToOffscreen() 

このとき、transferControlToOffscreenを呼ばれたcanvas要素は、canvas要素から直接getContextメソッドをコールできなくなります。処理をOffscreenCanvasへ委譲したためです。以降はoffscreenを経由してのみ操作が可能です。

⭕ offscreen.getContext('2d')
❌ dest.getContext('2d')

そして取得したOffscreenCanvasをworker.postMessageでworkerへ転送します。

// main.js内
worker.postMessage({action: 'attachCanvas', canvas: offscreen}, [offscreen])

postMessageメソッドのインターフェースは、MDN: Worker.postMessage()では以下のように示されています。

myWorker.postMessage(aMessage, transferList);

第1引数(aMessage)にはJSONにシリアライズ可能な任意のオブジェクトを渡し、第2引数(transferList)にはaMessage内に含まれる、workerへ転送したいTransferableなオブジェクトを配列で渡します。

ここでtransferListに指定され、転送されたオブジェクトはメインスレッド上からは触れなくなります。 Chromeの場合、転送したのがArrayBufferであればその中身はbyteLength=0の空データに、OffscreenCanvasであれば幅と高さが0のキャンバスになり、メソッドをコールするとエラーになります。(転送と呼んでいるのはそのためです。)

転送されたワーカー側では、OffscreenCanvasから2Dレンダリング用のコンテキストを取得しています。ここは通常のCanvasと変わらないですね

// worker.js内
this.ctx = canvas.getContext('2d')

そしてメインスレッド側からレンダリングの要求が投げられ、Workerスレッド側でレンダリングが実行されます。

// worker.js内
async render()
{
    const {canvas, ctx, sourceImage, canvas: {width, height}} = this
    ctx.clearRect(0, 0, width, height)

    // レンダリング処理...

    ctx.putImageData(new ImageData(destinate.data, destinate.width, destinate.height), 0, 0)
    ctx.commit()
}

この中のctx.commit()が通常のCanvasと異なる点です。 commitメソッドを呼ぶことにより、現在OffscreenCanvasにレンダリングされている内容を、生成元のCanvasへ反映します。これでメインスレッド側のCanvasへのレンダリングが完了です。
(commitメソッドはcanvas要素から生成されたOffscreenCanvasでのみ利用可能です。new OffscreenCanvasで生成されたインスタンスでは使用できません。)


ここまでのサンプルの動作はこちらから確認していただけます。
(先に述べた通り、experimental-canvas-featuresが有効なChrome Canaryでのみ動作します。それ以外では真っ白な画面になると思いますのでご了承ください?)
ソースコードはGitHubで公開しています。

正常に動作する環境であればOffscreenCanvasによるそこそこ重めのエフェクト処理を行うサンプルが表示されるはずです、OffscreenCanvasでテキストレンダリングが使えない関係で開発者コンソールに何FPSでレンダリングされているか表示させています。 おおよそどんな流れでOffscreenCanvasが利用できるか参考になれば幸いです!

f:id:devpixiv:20161213162229p:plain

まとめ

OffscreenCanvasはパフォーマンス目的に使うとかなり使い所が難しい(今回のサンプルではシングルスレッドと目立った差がなかった)ですが、WHATWGのWikiを見ると「ServiceWorker内でのアイコン生成に使いたい」というユースケースがあるようで、パフォーマンスに限らず、いい感じに使えるポイントがありそうです。

新しすぎてまだ普通の環境では使えないものですが、pixiv SketchpixivFACTORYのプレビュー生成で使える日を首を長くして待っています。

本日は首が長くなりすぎた @_ragg_ がお送りしました、明日は @syoichi がお送りします。 引き続きピクシブ株式会社 Advent Calendar 2016をお楽しみください??

参考

*1: fillTextなど一部のメソッドが使えないようです(´・ω・`)