のOffscreenCanvas

OffscreenCanvasは新しいブラウザの機能で現在はChromeでしか利用できませんが、他のブラウザにも来るようです。 OffscreenCanvas はWeb Workerでキャンバスにレンダリングできます。 複雑な3Dシーンのレンダリングなど重い作業をWeb Workerで行い負荷を軽減させ、ブラウザのレスポンスを低下させない方法です。 また、データが読み込まれWorkerで解析されてるのでページ読み込み中にページ表示の途切れは少ないでしょう。

OffscreenCanvasの利用を開始するのは非常に簡単です。 レスポンシブデザインの記事から3つのキューブを回転させるコードに修正してみましょう。

通常はWorkerのコードを別ファイルに分離しますが、このサイトのほとんどのサンプルコードではスクリプトをHTMLファイルに埋め込んでいます。

ここでは offscreencanvas-cubes.js というファイルを作成し、レスポンシブデザインの例から全てのJavaScriptをコピーして下さい。 そして、Workerで実行するために必要な変更を行います。

HTMLファイルにはJavaScriptのいくつかの処理が必要です。 まず最初に行う必要があるのはキャンバスを検索し、canvas.transferControlToOffscreen 呼び出してキャンバスのコントロールをオフスクリーンに転送します。

function main() {
  const canvas = document.querySelector('#c');
  const offscreen = canvas.transferControlToOffscreen();

  ...

new Worker(pathToScript, {type: 'module'})でWorkerを起動し、offscreen オブジェクトを渡します。

function main() {
  const canvas = document.querySelector('#c');
  const offscreen = canvas.transferControlToOffscreen();
  const worker = new Worker('offscreencanvas-cubes.js', {type: 'module'});
  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
}
main();

ここで重要なのはWorkerが DOM にアクセスできない事です。 HTML要素の参照やマウスイベントやキーボードイベントを受け取る事もできません。 Workerは、送られたメッセージに返信してWebページにメッセージを送り返す事だけです。

Workerにメッセージを送信するにはworker.postMessageを呼び出し、1つまたは2つの引数を渡します。 1つ目の引数はクローンされるJavaScriptオブジェクトでWorkerに送ります。 2番目の引数は任意でWorkerに 転送 したい最初のオブジェクトです。 このオブジェクトはクローンされません。 その代わりに 転送 され、メインページには存在しなくなります。 存在しなくなるというのはおそらく間違った説明であり、むしろ取り除かれます。 クローンではなく、特定のタイプのオブジェクトのみを転送する事ができます。 転送するオブジェクトには OffscreenCanvas が含まれているので、1度転送した offscreen オブジェクトをメインページに戻しても意味がありません。

Workerは onmessage ハンドラからメッセージを受け取ります。 postMessage に渡したオブジェクトはWorkerの onmessage ハンドラに渡され event.data を更新します。 上記のコードではWorkerに渡すオブジェクトに type: 'main' を宣言しています。 このオブジェクトはブラウザには何の意味もありません。Workerで使うためだけのものです。 type に基づいて、Worker内で別の関数を呼び出すハンドラを作成します。 あとは必要に応じて関数を追加し、メインページから簡単に呼び出す事ができます。

const handlers = {
  main,
};

self.onmessage = function(e) {
  const fn = handlers[e.data.type];
  if (typeof fn !== 'function') {
    throw new Error('no handler for type: ' + e.data.type);
  }
  fn(e.data);
};

上記コードのように type に基づいてハンドラを検索し、メインページから送られてきた data を渡します。 あとはレスポンシブデザインの記事から offscreencanvas-cubes.js に貼り付けた main を変更するだけです。

DOMからキャンバスを探すのではなく、イベントデータからキャンバスを受け取ります。

-function main() {
-  const canvas = document.querySelector('#c');
+function main(data) {
+  const {canvas} = data;
  const renderer = new THREE.WebGLRenderer({canvas});

  ...

最初の問題はWorkerからDOMを参照できず、resizeRendererToDisplaySizecanvas.clientWidthcanvas.clientHeight を参照できない事です。 clientWidthcanvas.clientHeight はDOMの値です。

元のコードは以下の通りです。

function resizeRendererToDisplaySize(renderer) {
  const canvas = renderer.domElement;
  const width = canvas.clientWidth;
  const height = canvas.clientHeight;
  const needResize = canvas.width !== width || canvas.height !== height;
  if (needResize) {
    renderer.setSize(width, height, false);
  }
  return needResize;
}

DOMを参照できないため、変更したサイズの値をWorkerに送る必要があります。 そこでグローバルな状態を追加し、幅と高さを維持するようにしましょう。

const state = {
  width: 300,  // canvas default
  height: 150,  // canvas default
};

これらの値を更新するための 'size' ハンドラを追加してみます。

+function size(data) {
+  state.width = data.width;
+  state.height = data.height;
+}

const handlers = {
  main,
+  size,
};

これで resizeRendererToDisplaySize を変更すると state.widthstate.height が使えるようになりました。

function resizeRendererToDisplaySize(renderer) {
  const canvas = renderer.domElement;
-  const width = canvas.clientWidth;
-  const height = canvas.clientHeight;
+  const width = state.width;
+  const height = state.height;
  const needResize = canvas.width !== width || canvas.height !== height;
  if (needResize) {
    renderer.setSize(width, height, false);
  }
  return needResize;
}

以下も同様の変更が必要です。

function render(time) {
  time *= 0.001;

  if (resizeRendererToDisplaySize(renderer)) {
-    camera.aspect = canvas.clientWidth / canvas.clientHeight;
+    camera.aspect = state.width / state.height;
    camera.updateProjectionMatrix();
  }

  ...

メインページに戻りページのリサイズの度に size イベントを送信します。

const worker = new Worker('offscreencanvas-picking.js', {type: 'module'});
worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);

+function sendSize() {
+  worker.postMessage({
+    type: 'size',
+    width: canvas.clientWidth,
+    height: canvas.clientHeight,
+  });
+}
+
+window.addEventListener('resize', sendSize);
+sendSize();

初期サイズを送るために1度sendSizeを呼んでいます。

ブラウザが OffscreenCanvas を完全にサポートしていると仮定して、これらの変更を行うだけで動作するはずです。 実行する前にブラウザが OffscreenCanvas を実際にサポートしているか確認し、サポートしていない場合はエラーを表示してみましょう。 まずはエラーを表示するためのHTMLを追加します。

<body>
  <canvas id="c"></canvas>
+  <div id="noOffscreenCanvas" style="display:none;">
+    <div>no OffscreenCanvas support</div>
+  </div>
</body>

そして、CSSを追加します。

#noOffscreenCanvas {
    display: flex;
    width: 100vw;
    height: 100vh;
    align-items: center;
    justify-content: center;
    background: red;
    color: white;
}

ブラウザが OffscreenCanvas をサポートしているか確認するためには transferControlToOffscreen を呼びます。

function main() {
  const canvas = document.querySelector('#c');
+  if (!canvas.transferControlToOffscreen) {
+    canvas.style.display = 'none';
+    document.querySelector('#noOffscreenCanvas').style.display = '';
+    return;
+  }
  const offscreen = canvas.transferControlToOffscreen();
  const worker = new Worker('offscreencanvas-picking.js', {type: 'module});
  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);

  ...

ブラウザが OffscreenCanvas をサポートしていれば、このサンプルは動作するはずです。

これは素晴らしい事ですが、今の所は全てのブラウザが OffscreenCanvas をサポートしている訳ではなく、 OffscreenCanvas サポートありとサポートなしの両方で動作するコードに変更し、サポートなしの場合はメインページのキャンバスを通常のように表示します。

余談ですがページをレスポンシブにするためにOffscreenCanvasが必要な場合、フォールバックを持つ意味がよくわかりません。 メインページで実行するかWorkerで実行するかには、Workerで実行している時にメインページで実行している時よりも多くの事ができるように 調整するかもしれません。何をするかは本当にあなた次第です。

まず最初にthree.jsのコードとWorkerの固有コードを分離しましょう。 これでメインページとWorkerの両方で同じコードを使う事ができます。 つまり、3つのファイルを持つ事になります。

  1. htmlファイル

    threejs-offscreencanvas-w-fallback.html

  2. three.jsを含むJavaScriptコード

    shared-cubes.js

  3. workerをサポートするコード

    offscreencanvas-worker-cubes.js

shared-cubes.jsoffscreencanvas-worker-cubes.js は前の offscreencanvas-cubes.js ファイルを分割したものです。

まず offscreencanvas-cubes.js を全て shared-cube.js にコピーします。 次にHTMLファイルには既に main があり、initstate をエクスポートする必要があるため main の名前を init に変更します。

import * as THREE from '../../build/three.module.js';

-const state = {
+export const state = {
  width: 300,   // canvas default
  height: 150,  // canvas default
};

-function main(data) {
+export function init(data) {
  const {canvas} = data;
  const renderer = new THREE.WebGLRenderer({canvas});

そして、three.js関連以外の部分だけを切り取ります。

-function size(data) {
-  state.width = data.width;
-  state.height = data.height;
-}
-
-const handlers = {
-  main,
-  size,
-};
-
-self.onmessage = function(e) {
-  const fn = handlers[e.data.type];
-  if (typeof fn !== 'function') {
-    throw new Error('no handler for type: ' + e.data.type);
-  }
-  fn(e.data);
-};

削除した部分を offscreencanvas-worker-cubes.js にコピーして shared-cubes.js をインポートし、main の代わりに init を呼び出します。

import {init, state} from './shared-cubes.js';

function size(data) {
  state.width = data.width;
  state.height = data.height;
}

const handlers = {
-  main,
+  init,
  size,
};

self.onmessage = function(e) {
  const fn = handlers[e.data.type];
  if (typeof fn !== 'function') {
    throw new Error('no handler for type: ' + e.data.type);
  }
  fn(e.data);
};

同様にメインページに shared-cubes.js を含める必要があります。

<script type="module">
+import {init, state} from './shared-cubes.js';

前に追加したHTMLとCSSを削除します。

<body>
  <canvas id="c"></canvas>
-  <div id="noOffscreenCanvas" style="display:none;">
-    <div>no OffscreenCanvas support</div>
-  </div>
</body>

そして、CSSは以下のようになります。

-#noOffscreenCanvas {
-    display: flex;
-    width: 100vw;
-    height: 100vh;
-    align-items: center;
-    justify-content: center;
-    background: red;
-    color: white;
-}

次にブラウザが OffscreenCanvas をサポートありなしに応じて、メインページのコードを変更して起動関数を呼び出すようにしてみましょう。

function main() {
  const canvas = document.querySelector('#c');
-  if (!canvas.transferControlToOffscreen) {
-    canvas.style.display = 'none';
-    document.querySelector('#noOffscreenCanvas').style.display = '';
-    return;
-  }
-  const offscreen = canvas.transferControlToOffscreen();
-  const worker = new Worker('offscreencanvas-picking.js', {type: 'module'});
-  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
+  if (canvas.transferControlToOffscreen) {
+    startWorker(canvas);
+  } else {
+    startMainPage(canvas);
+  }
  ...

Workerのセットアップコードを全て startWorker の中に移動します。

function startWorker(canvas) {
  const offscreen = canvas.transferControlToOffscreen();
  const worker = new Worker('offscreencanvas-worker-cubes.js', {type: 'module'});
  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);

  function sendSize() {
    worker.postMessage({
      type: 'size',
      width: canvas.clientWidth,
      height: canvas.clientHeight,
    });
  }

  window.addEventListener('resize', sendSize);
  sendSize();

  console.log('using OffscreenCanvas');
}

そして main の代わりに init を送信します。

-  worker.postMessage({type: 'main', canvas: offscreen}, [offscreen]);
+  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);

メインページで開始するには次のようにします。

function startMainPage(canvas) {
  init({canvas});

  function sendSize() {
    state.width = canvas.clientWidth;
    state.height = canvas.clientHeight;
  }
  window.addEventListener('resize', sendSize);
  sendSize();

  console.log('using regular canvas');
}

このサンプルコードではOffscreenCanvasで実行、またはメインページで実行されるようにフォールバックしています。

比較的簡単でした。ピッキングしてみましょう。 ピッキングの記事にある RayCaster の例からコードをいくつか取り出し、画面外でオフスクリーンが動作するようにします。

shared-cube.jsshared-picking.js にコピーし、ピッキング部分を追加してみましょう。 この例では PickHelper をコピーします。

class PickHelper {
  constructor() {
    this.raycaster = new THREE.Raycaster();
    this.pickedObject = null;
    this.pickedObjectSavedColor = 0;
  }
  pick(normalizedPosition, scene, camera, time) {
    // restore the color if there is a picked object
    if (this.pickedObject) {
      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
      this.pickedObject = undefined;
    }

    // cast a ray through the frustum
    this.raycaster.setFromCamera(normalizedPosition, camera);
    // get the list of objects the ray intersected
    const intersectedObjects = this.raycaster.intersectObjects(scene.children);
    if (intersectedObjects.length) {
      // pick the first object. It's the closest one
      this.pickedObject = intersectedObjects[0].object;
      // save its color
      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
      // set its emissive color to flashing red/yellow
      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
    }
  }
}

const pickPosition = {x: 0, y: 0};
const pickHelper = new PickHelper();

マウスの pickPosition を以下のように更新しました。

function getCanvasRelativePosition(event) {
  const rect = canvas.getBoundingClientRect();
  return {
    x: (event.clientX - rect.left) * canvas.width  / rect.width,
    y: (event.clientY - rect.top ) * canvas.height / rect.height,
  };
}

function setPickPosition(event) {
  const pos = getCanvasRelativePosition(event);
  pickPosition.x = (pos.x / canvas.width ) *  2 - 1;
  pickPosition.y = (pos.y / canvas.height) * -2 + 1;  // note we flip Y
}
window.addEventListener('mousemove', setPickPosition);

Workerではマウスの位置を直接読み取れないので、サイズのコードと同じようにマウスの位置を指定してメッセージを送信してみましょう。 サイズのコードと同様にマウスの位置を送信して pickPosition を更新します。

function size(data) {
  state.width = data.width;
  state.height = data.height;
}

+function mouse(data) {
+  pickPosition.x = data.x;
+  pickPosition.y = data.y;
+}

const handlers = {
  init,
+  mouse,
  size,
};

self.onmessage = function(e) {
  const fn = handlers[e.data.type];
  if (typeof fn !== 'function') {
    throw new Error('no handler for type: ' + e.data.type);
  }
  fn(e.data);
};

メインページに戻ってマウスをWorkerやメインページに渡すコードを追加します。

+let sendMouse;

function startWorker(canvas) {
  const offscreen = canvas.transferControlToOffscreen();
  const worker = new Worker('offscreencanvas-worker-picking.js', {type: 'module'});
  worker.postMessage({type: 'init', canvas: offscreen}, [offscreen]);

+  sendMouse = (x, y) => {
+    worker.postMessage({
+      type: 'mouse',
+      x,
+      y,
+    });
+  };

  function sendSize() {
    worker.postMessage({
      type: 'size',
      width: canvas.clientWidth,
      height: canvas.clientHeight,
    });
  }

  window.addEventListener('resize', sendSize);
  sendSize();

  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
}

function startMainPage(canvas) {
  init({canvas});

+  sendMouse = (x, y) => {
+    pickPosition.x = x;
+    pickPosition.y = y;
+  };

  function sendSize() {
    state.width = canvas.clientWidth;
    state.height = canvas.clientHeight;
  }
  window.addEventListener('resize', sendSize);
  sendSize();

  console.log('using regular canvas');  /* eslint-disable-line no-console */
}

全てのマウス操作コードをメインページにコピーし、sendMouse を使用するようにマイナーチェンジを加えます。

function setPickPosition(event) {
  const pos = getCanvasRelativePosition(event);
-  pickPosition.x = (pos.x / canvas.clientWidth ) *  2 - 1;
-  pickPosition.y = (pos.y / canvas.clientHeight) * -2 + 1;  // note we flip Y
+  sendMouse(
+      (pos.x / canvas.clientWidth ) *  2 - 1,
+      (pos.y / canvas.clientHeight) * -2 + 1);  // note we flip Y
}

function clearPickPosition() {
  // unlike the mouse which always has a position
  // if the user stops touching the screen we want
  // to stop picking. For now we just pick a value
  // unlikely to pick something
-  pickPosition.x = -100000;
-  pickPosition.y = -100000;
+  sendMouse(-100000, -100000);
}
window.addEventListener('mousemove', setPickPosition);
window.addEventListener('mouseout', clearPickPosition);
window.addEventListener('mouseleave', clearPickPosition);

window.addEventListener('touchstart', (event) => {
  // prevent the window from scrolling
  event.preventDefault();
  setPickPosition(event.touches[0]);
}, {passive: false});

window.addEventListener('touchmove', (event) => {
  setPickPosition(event.touches[0]);
});

window.addEventListener('touchend', clearPickPosition);

これでこのピッキングは OffscreenCanvas で動作するはずです。

もう1歩踏み込んで OrbitControls を追加してみましょう。 これはもう少し複雑です。 OrbitControls はマウス、タッチイベント、キーボードなどDOMをかなり広範囲にチェックしています。

これまでのコードとは異なり、グローバルな state オブジェクトを使う事はできません。 これを使用して動作するようにOrbitControlsのコードを全て書き換える必要はありません。 OrbitControlsは HTMLElement を取り、それに使用するDOMイベントのほとんどをアタッチします。 OrbitControlsが必要とする機能をサポートする必要があります。

OrbitControlsのソースコードを掘り下げてみると、次のイベントを処理する必要があるように見えます。

  • contextmenu
  • pointerdown
  • pointermove
  • pointerup
  • touchstart
  • touchmove
  • touchend
  • wheel
  • keydown

マウスイベントには ctrlKeymetaKeyshiftKeybuttonpointerTypeclientXclientYpageXpageY プロパティが必要です。

キーダウンイベントには ctrlKey, metaKey, shiftKey, keyCode プロパティが必要です。

ホイールイベントに必要なのは deltaY プロパティだけです。

また、タッチイベントに必要なのは touches プロパティの pageXpageY だけです。

そこでproxyオブジェクトのペアを作ってみましょう。 ある時はメインページで実行され、全てのイベント、関連するプロパティ値をWorkerに渡します。 また、ある時はWorkerで実行され、全てのイベント、DOMイベントと同じ構造をもつイベントをメインページに渡すので、OrbitControlsは違いを見分けられません。

ここにWorker部分のコードがあります。

import {EventDispatcher} from '../../build/three.module.js';

class ElementProxyReceiver extends EventDispatcher {
  constructor() {
    super();
  }
  handleEvent(data) {
    this.dispatchEvent(data);
  }
}

メッセージを受信した場合にdataを送信するだけです。 これは EventDispatcher を継承しており、DOM要素のように addEventListenerremoveEventListener のようなメソッドを提供しているので、OrbitControlsに渡せば動作するはずです。

ElementProxyReceiver は1つの要素を扱います。 私たちの場合は1つの頭しか必要ありませんが、頭で考えるのがベストです。 つまり、マネージャーを作って複数のElementProxyReceiverを管理するようにしましょう。

class ProxyManager {
  constructor() {
    this.targets = {};
    this.handleEvent = this.handleEvent.bind(this);
  }
  makeProxy(data) {
    const {id} = data;
    const proxy = new ElementProxyReceiver();
    this.targets[id] = proxy;
  }
  getProxy(id) {
    return this.targets[id];
  }
  handleEvent(data) {
    this.targets[data.id].handleEvent(data.data);
  }
}

ProxyManagerのインスタンスを作成し makeProxy メソッドにidを指定して呼び出す事で、そのidを持つメッセージに応答する ElementProxyReceiver を作成できます。

Workerのメッセージハンドラに接続してみましょう。

const proxyManager = new ProxyManager();

function start(data) {
  const proxy = proxyManager.getProxy(data.canvasId);
  init({
    canvas: data.canvas,
    inputElement: proxy,
  });
}

function makeProxy(data) {
  proxyManager.makeProxy(data);
}

...

const handlers = {
-  init,
-  mouse,
+  start,
+  makeProxy,
+  event: proxyManager.handleEvent,
   size,
};

self.onmessage = function(e) {
  const fn = handlers[e.data.type];
  if (typeof fn !== 'function') {
    throw new Error('no handler for type: ' + e.data.type);
  }
  fn(e.data);
};

共有のthree.jsコードでは OrbitControls をインポートして設定する必要があります。

import * as THREE from '../../build/three.module.js';
+import {OrbitControls} from '/examples/jsm/controls/OrbitControls.js';

export function init(data) {
-  const {canvas} = data;
+  const {canvas, inputElement} = data;
  const renderer = new THREE.WebGLRenderer({canvas});

+  const controls = new OrbitControls(camera, inputElement);
+  controls.target.set(0, 0, 0);
+  controls.update();

OffscreenCanvas以外のサンプルコード例のようにキャンバスを渡すのではなく、 inputElement を介してOrbitControlsをProxyに渡している事に注目して下さい。

次に canvasinputElement に変更し、HTMLファイルから全てのピッキングイベントのコードを共有のthree.jsコードに移動させます。

function getCanvasRelativePosition(event) {
-  const rect = canvas.getBoundingClientRect();
+  const rect = inputElement.getBoundingClientRect();
  return {
    x: event.clientX - rect.left,
    y: event.clientY - rect.top,
  };
}

function setPickPosition(event) {
  const pos = getCanvasRelativePosition(event);
-  sendMouse(
-      (pos.x / canvas.clientWidth ) *  2 - 1,
-      (pos.y / canvas.clientHeight) * -2 + 1);  // note we flip Y
+  pickPosition.x = (pos.x / inputElement.clientWidth ) *  2 - 1;
+  pickPosition.y = (pos.y / inputElement.clientHeight) * -2 + 1;  // note we flip Y
}

function clearPickPosition() {
  // unlike the mouse which always has a position
  // if the user stops touching the screen we want
  // to stop picking. For now we just pick a value
  // unlikely to pick something
-  sendMouse(-100000, -100000);
+  pickPosition.x = -100000;
+  pickPosition.y = -100000;
}

*inputElement.addEventListener('mousemove', setPickPosition);
*inputElement.addEventListener('mouseout', clearPickPosition);
*inputElement.addEventListener('mouseleave', clearPickPosition);

*inputElement.addEventListener('touchstart', (event) => {
  // prevent the window from scrolling
  event.preventDefault();
  setPickPosition(event.touches[0]);
}, {passive: false});

*inputElement.addEventListener('touchmove', (event) => {
  setPickPosition(event.touches[0]);
});

*inputElement.addEventListener('touchend', clearPickPosition);

メインページに戻り、上記で列挙した全てのイベントにメッセージを送信するコードが必要です。

let nextProxyId = 0;
class ElementProxy {
  constructor(element, worker, eventHandlers) {
    this.id = nextProxyId++;
    this.worker = worker;
    const sendEvent = (data) => {
      this.worker.postMessage({
        type: 'event',
        id: this.id,
        data,
      });
    };

    // register an id
    worker.postMessage({
      type: 'makeProxy',
      id: this.id,
    });
    for (const [eventName, handler] of Object.entries(eventHandlers)) {
      element.addEventListener(eventName, function(event) {
        handler(event, sendEvent);
      });
    }
  }
}

ElementProxy はProxyしたいイベントの要素を受け取ります。 次にWorkerにidを登録し、先ほど設定した makeProxy メッセージを使って送信します。 Workerは ElementProxyReceiver を作成しそのidに登録します。

そして登録するイベントハンドラのオブジェクトを用意します。 このようにして、Workerに転送したいイベントにハンドラを渡す事ができます。

Workerを起動する時はまずProxyを作成しイベントハンドラを渡します。

function startWorker(canvas) {
  const offscreen = canvas.transferControlToOffscreen();
  const worker = new Worker('offscreencanvas-worker-orbitcontrols.js', {type: 'module'});

+  const eventHandlers = {
+    contextmenu: preventDefaultHandler,
+    mousedown: mouseEventHandler,
+    mousemove: mouseEventHandler,
+    mouseup: mouseEventHandler,
+    pointerdown: mouseEventHandler,
+    pointermove: mouseEventHandler,
+    pointerup: mouseEventHandler,
+    touchstart: touchEventHandler,
+    touchmove: touchEventHandler,
+    touchend: touchEventHandler,
+    wheel: wheelEventHandler,
+    keydown: filteredKeydownEventHandler,
+  };
+  const proxy = new ElementProxy(canvas, worker, eventHandlers);
  worker.postMessage({
    type: 'start',
    canvas: offscreen,
+    canvasId: proxy.id,
  }, [offscreen]);
  console.log('using OffscreenCanvas');  /* eslint-disable-line no-console */
}

以下はイベントハンドラです。 受信したイベントからプロパティのリストをコピーするだけです。 sendEvent 関数に渡され作成したデータを渡します。 この関数は正しいidを追加してWorkerに送信します。

const mouseEventHandler = makeSendPropertiesHandler([
  'ctrlKey',
  'metaKey',
  'shiftKey',
  'button',
  'pointerType',
  'clientX',
  'clientY',
  'pageX',
  'pageY',
]);
const wheelEventHandlerImpl = makeSendPropertiesHandler([
  'deltaX',
  'deltaY',
]);
const keydownEventHandler = makeSendPropertiesHandler([
  'ctrlKey',
  'metaKey',
  'shiftKey',
  'keyCode',
]);

function wheelEventHandler(event, sendFn) {
  event.preventDefault();
  wheelEventHandlerImpl(event, sendFn);
}

function preventDefaultHandler(event) {
  event.preventDefault();
}

function copyProperties(src, properties, dst) {
  for (const name of properties) {
      dst[name] = src[name];
  }
}

function makeSendPropertiesHandler(properties) {
  return function sendProperties(event, sendFn) {
    const data = {type: event.type};
    copyProperties(event, properties, data);
    sendFn(data);
  };
}

function touchEventHandler(event, sendFn) {
  const touches = [];
  const data = {type: event.type, touches};
  for (let i = 0; i < event.touches.length; ++i) {
    const touch = event.touches[i];
    touches.push({
      pageX: touch.pageX,
      pageY: touch.pageY,
    });
  }
  sendFn(data);
}

// The four arrow keys
const orbitKeys = {
  '37': true,  // left
  '38': true,  // up
  '39': true,  // right
  '40': true,  // down
};
function filteredKeydownEventHandler(event, sendFn) {
  const {keyCode} = event;
  if (orbitKeys[keyCode]) {
    event.preventDefault();
    keydownEventHandler(event, sendFn);
  }
}

これで動くと思われるが、実際に試してみると OrbitControls がもう少し必要なものがあると分かります。

1つは element.focus です。Workerには必要ないのでStubを追加しておきましょう。

class ElementProxyReceiver extends THREE.EventDispatcher {
  constructor() {
    super();
  }
  handleEvent(data) {
    this.dispatchEvent(data);
  }
+  focus() {
+    // no-op
+  }
}

もう1つは event.preventDefaultevent.stopPropagation を呼び出す事です。 メインページでは既に対応してるのでそれらも不要になります。

+function noop() {
+}

class ElementProxyReceiver extends THREE.EventDispatcher {
  constructor() {
    super();
  }
  handleEvent(data) {
+    data.preventDefault = noop;
+    data.stopPropagation = noop;
    this.dispatchEvent(data);
  }
  focus() {
    // no-op
  }
}

もう1つは clientWidthclientHeight を見る事です。 以前はサイズを渡してましたが、Proxyペアを更新してそれも渡すようにします。

Workerの中では

class ElementProxyReceiver extends THREE.EventDispatcher {
  constructor() {
    super();
  }
+  get clientWidth() {
+    return this.width;
+  }
+  get clientHeight() {
+    return this.height;
+  }
+  getBoundingClientRect() {
+    return {
+      left: this.left,
+      top: this.top,
+      width: this.width,
+      height: this.height,
+      right: this.left + this.width,
+      bottom: this.top + this.height,
+    };
+  }
  handleEvent(data) {
+    if (data.type === 'size') {
+      this.left = data.left;
+      this.top = data.top;
+      this.width = data.width;
+      this.height = data.height;
+      return;
+    }
    data.preventDefault = noop;
    data.stopPropagation = noop;
    this.dispatchEvent(data);
  }
  focus() {
    // no-op
  }
}

メインページに戻るにはサイズと左と上の位置も送信する必要があります。 このままではキャンバスを移動しても処理されず、サイズを変更しても処理されないです。 移動を処理したい場合は何かがキャンバスを移動する度に sendSize を呼び出す必要があります。

class ElementProxy {
  constructor(element, worker, eventHandlers) {
    this.id = nextProxyId++;
    this.worker = worker;
    const sendEvent = (data) => {
      this.worker.postMessage({
        type: 'event',
        id: this.id,
        data,
      });
    };

    // register an id
    worker.postMessage({
      type: 'makeProxy',
      id: this.id,
    });
+    sendSize();
    for (const [eventName, handler] of Object.entries(eventHandlers)) {
      element.addEventListener(eventName, function(event) {
        handler(event, sendEvent);
      });
    }

+    function sendSize() {
+      const rect = element.getBoundingClientRect();
+      sendEvent({
+        type: 'size',
+        left: rect.left,
+        top: rect.top,
+        width: element.clientWidth,
+        height: element.clientHeight,
+      });
+    }
+
+    window.addEventListener('resize', sendSize);
  }
}

そして共有のthree.jsコードでは state は不要になりました。

-export const state = {
-  width: 300,   // canvas default
-  height: 150,  // canvas default
-};

...

function resizeRendererToDisplaySize(renderer) {
  const canvas = renderer.domElement;
-  const width = state.width;
-  const height = state.height;
+  const width = inputElement.clientWidth;
+  const height = inputElement.clientHeight;
  const needResize = canvas.width !== width || canvas.height !== height;
  if (needResize) {
    renderer.setSize(width, height, false);
  }
  return needResize;
}

function render(time) {
  time *= 0.001;

  if (resizeRendererToDisplaySize(renderer)) {
-    camera.aspect = state.width / state.height;
+    camera.aspect = inputElement.clientWidth / inputElement.clientHeight;
    camera.updateProjectionMatrix();
  }

  ...

他にもいくつかのハックがあります。 OrbitControlsは pointermovepointerup イベントをマウスキャプチャ(マウスがウィンドウの外に出た時)を処理するための要素の ownerDocument です。

さらにコードはグローバルな document を参照していますが、Workerにはグローバルなdocumentはありません。

これは2つの簡単なハックで全て解決できます。 Workerコードでは両方の問題に対してProxyを再利用します。

function start(data) {
  const proxy = proxyManager.getProxy(data.canvasId);
+  proxy.ownerDocument = proxy; // HACK!
+  self.document = {} // HACK!
  init({
    canvas: data.canvas,
    inputElement: proxy,
  });
}

これで OrbitControls が期待に沿った検査を行うための機能を提供します。

難しいのは分かっていますが手短に言うと:

ElementProxy はメインページ上で動作し、DOMイベントを転送します。 Worker内の ElementProxyReceiver は一緒に使うことができる HTMLElement を装っています。 OrbitControls と独自のコードを使用しています。

最後にOffscreenCanvasを使用していない時のフォールバックです。 必要なのはcanvas自体を inputElement として渡す事です。

function startMainPage(canvas) {
-  init({canvas});
+  init({canvas, inputElement: canvas});
  console.log('using regular canvas');
}

これでOrbitControlsがOffscreenCanvasで動作するようになりました。

これはおそらくこのサイトで最も複雑な例です。 各サンプルには3つのファイルが含まれているので少しわかりにくいです。 HTMLファイル、Workerファイル、共有のthree.jsコードなどです。

理解する事が難し過ぎず、少しでも参考になれば幸いです。 three.js、OffscreenCanvas、Web Workerを使った動作の便利な例を紹介しました。