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を使った動作の便利な例を紹介しました。