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を参照できず、resizeRendererToDisplaySize
が canvas.clientWidth
と canvas.clientHeight
を参照できない事です。
clientWidth
と canvas.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.width
と state.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つのファイルを持つ事になります。
htmlファイル
threejs-offscreencanvas-w-fallback.html
three.jsを含むJavaScriptコード
shared-cubes.js
workerをサポートするコード
offscreencanvas-worker-cubes.js
shared-cubes.js
と offscreencanvas-worker-cubes.js
は前の offscreencanvas-cubes.js
ファイルを分割したものです。
まず offscreencanvas-cubes.js
を全て shared-cube.js
にコピーします。
次にHTMLファイルには既に main
があり、init
と state
をエクスポートする必要があるため main
の名前を init
に変更します。
import * as THREE from 'three'; -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.js
を shared-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のソースコードを掘り下げてみると、次のイベントを処理する必要があるように見えます。
マウスイベントには ctrlKey
、 metaKey
、 shiftKey
、 button
、 pointerType
、 clientX
、 clientY
、 pageX
、 pageY
プロパティが必要です。
キーダウンイベントには ctrlKey
, metaKey
, shiftKey
, keyCode
プロパティが必要です。
ホイールイベントに必要なのは deltaY
プロパティだけです。
また、タッチイベントに必要なのは touches
プロパティの pageX
と pageY
だけです。
そこでproxyオブジェクトのペアを作ってみましょう。 ある時はメインページで実行され、全てのイベント、関連するプロパティ値をWorkerに渡します。 また、ある時はWorkerで実行され、全てのイベント、DOMイベントと同じ構造をもつイベントをメインページに渡すので、OrbitControlsは違いを見分けられません。
ここにWorker部分のコードがあります。
import {EventDispatcher} from 'three'; class ElementProxyReceiver extends EventDispatcher { constructor() { super(); } handleEvent(data) { this.dispatchEvent(data); } }
メッセージを受信した場合にdataを送信するだけです。
これは EventDispatcher
を継承しており、DOM要素のように addEventListener
や removeEventListener
のようなメソッドを提供しているので、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 'three'; +import {OrbitControls} from 'three/addons/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に渡している事に注目して下さい。
次に canvas
を inputElement
に変更し、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.preventDefault
と event.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つは clientWidth
と clientHeight
を見る事です。
以前はサイズを渡してましたが、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は pointermove
と pointerup
イベントをマウスキャプチャ(マウスがウィンドウの外に出た時)を処理するための要素の 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を使った動作の便利な例を紹介しました。