OffscreenCanvas
- это относительно новая функция браузера, которая в настоящее время доступна только в Chrome,
но, очевидно, будет доступна и в других браузерах. OffscreenCanvas
позволяет веб-воркеру выполнять
рендеринг на холст. Это способ переложить тяжелую работу, такую как рендеринг сложной 3D-сцены, на веб-воркера, чтобы не замедлить скорость отклика браузера.
Это также означает, что данные загружаются и анализируются в воркере, поэтому возможно меньше мусора во время загрузки страницы.
Начать использовать его довольно просто. Давайте разберём пример 3 вращающихся кубов из статьи об отзывчивости.
Обычно у воркера есть свой код, разделенный в другой файл сценария. Для большинства примеров на этом сайте скрипты встроены в HTML-файл страницы, на которой они находятся.
В нашем случае мы создадим файл с именем offscreencanvas-cubes.js
и скопируем в него весь JavaScript из адаптивного примера. Затем мы внесем изменения, необходимые для его работы в воркере.
Нам все еще нужен JavaScript в нашем HTML-файле. Первое, что нам нужно сделать там, это найти холст,
а затем передать управление этим холстом за пределы экрана, вызвав canvas.transferControlToOffscreen
.
function main() { const canvas = document.querySelector('#c'); const offscreen = canvas.transferControlToOffscreen(); ...
Затем мы можем запустить наш воркер с new Worker(pathToScript, {type: 'module'})
.
и передать ему 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();
Важно отметить, что воркеры не могут получить доступ к DOM
. Они не могут просматривать элементы HTML, а также получать события мыши или клавиатуры.
Единственное, что они обычно могут делать, - это отвечать на отправленные им сообщения.
Чтобы отправить сообщение воркеру, мы вызываем worker.postMessage
and
и передаем ему 1 или 2 аргумента. Первый аргумент - это объект JavaScript, который будет клонирован
и отправлен исполнителю. Второй аргумент - это необязательный массив объектов,
которые являются частью первого объекта, который мы хотим передать воркеру.
Эти объекты не будут клонированы. Вместо этого они будут перенесены и перестанут существовать на главной странице.
Прекращение существования - это, вероятно, неправильное описание, скорее они кастрированы.
Вместо клонирования можно передавать только определенные типы объектов.
Они включают OffscreenCanvas
, поэтому после переноса offscreen
обратно на главную страницу он бесполезен.
Воркеры получают сообщения от своего обработчика сообщений onmessage
. Объект,
который мы передали в postMessage
, прибывает в объект event.data
, переданный
обработчику onmessage
на воркере. В приведенном выше коде объявляется type: 'main'
в объекте, который он передает воркеру. Мы создадим обработчик,
который на основе типа будет вызывать другую функцию в воркере. Затем мы можем добавлять функции по мере необходимости и легко вызывать их с главной страницы.
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
из адаптивной статьи.
Затем вместо того, чтобы искать холст в DOM, мы получим его из данных события.
-function main() { - const canvas = document.querySelector('#c'); +function main(data) { + const {canvas} = data; const renderer = new THREE.WebGLRenderer({canvas}); ...
Помня о том, что воркеры вообще не видят DOM, первая проблема, с которой мы сталкиваемся, -
resizeRendererToDisplaySize
не может смотреть на canvas.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; }
Вместо этого нам нужно будет отправлять размеры по мере их изменения воркеру. Итак, давайте добавим некоторое глобальное состояние и сохраним там ширину и высоту.
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();
Мы также вызываем его один раз, чтобы отправить начальный размер.
И всего с этими несколькими изменениями, если ваш браузер полностью
поддерживает 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: 100%; height: 100%; align-items: center; justify-content: center; background: red; color: white; }
а затем мы можем проверить наличие transferControlToOffscreen
, чтобы узнать, поддерживает ли браузер 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]); ...
и при этом, если ваш браузер поддерживает OffscreenCanvas
, этот пример должен работать
Так что это здорово, но поскольку не каждый браузер поддерживает OffscreenCanvas
на данный момент,
давайте изменим код для работы с OffscreenCanvas
, а если нет, то вернемся к использованию холста на главной странице, как обычно.
Кстати, если вам нужен OffscreenCanvas, чтобы ваша страница была отзывчивой, тогда неясно, в чем смысл использования запасного варианта. Возможно, в зависимости от того, выполняете ли вы в конечном итоге работу на главной странице или в воркере, вы можете настроить объем выполняемой работы так, чтобы при работе в воркере вы могли делать больше, чем при работе на главной странице. Что вы делаете, действительно зависит от вас.
Первое, что нам, вероятно, следует сделать, - это отделить код three.js от кода, специфичного для воркера. Что мы можем использовать один и тот же код как на главной странице, так и на рабочем. Другими словами, теперь у нас будет 3 файла
наш html файл.
threejs-offscreencanvas-w-fallback.html
JavaScript, содержащий наш код three.js.
shared-cubes.js
наш код поддержки воркера
offscreencanvas-worker-cubes.js
shared-cubes.js
и offscreencanvas-worker-cubes.js
по сути являются разделением нашего
предыдущего файла offscreencanvas-cubes.js
. Сначала мы копируем весь файл offscreencanvas-cubes.js
в shared-cube.js
. Затем мы переименовываем main
в init
, так как у нас уже есть main
в нашем HTML-файле, и нам нужно экспортировать 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
, а также вызов init
вместо main
.
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); };
Точно так же нам нужно включить three.js и 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: 100%; - height: 100%; - 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); + } ...
Мы переместим весь код, который у нас был для настройки воркера, внутрь 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'); }
и отправить init
вместо main
- 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);
Воркер не может напрямую считывать положение мыши, поэтому, как и код размера, давайте отправим сообщение с указанием положения мыши.
Как и код размера, мы отправим позицию мыши и обновим 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); };
Вернувшись на нашу главную страницу, нам нужно добавить код, чтобы передать мышь воркеру или главной странице.
+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
.
Сделаем еще один шаг и добавим OrbitControls
. Это будет немного больше.
OrbitControls
довольно широко используют DOM для проверки мыши, событий касания и клавиатуры.
В отличие от нашего кода, мы не можем использовать объект глобального state
, не переписав весь код OrbitControls
для работы с ним. OrbitControls
принимают элемент, к которому они присоединяют большинство используемых ими событий DOM. Возможно, мы могли бы передать наш собственный объект, имеющий ту же поверхность API, что и элемент DOM. Нам нужно только поддерживать функции, которые необходимы OrbitControls
.
Копаясь в исходном коде OrbitControls похоже, что нам нужно обработать следующие события.
Для событий мыши нам нужны свойства ctrlKey
, metaKey
, shiftKey
,
button
, pointerType
, clientX
, clientY
, pageX
, и pageY
.
Для событий нажатия клавиатуры нам нужны свойства ctrlKey
, metaKey
, shiftKey
,
и keyCode
.
Для события wheel нам нужно только свойство deltaY
А для событий касания нам понадобятся только pageX
и pageY
из свойства touches
.
Итак, создадим пару прокси-объектов. Одна часть будет работать на главной странице, получать все эти события и передавать соответствующие значения свойств воркеру. Другая часть будет запускаться в воркере, получать эти события и передавать их, используя события, которые имеют ту же структуру, что и исходные события DOM, поэтому OrbitControls
не сможет определить разницу.
Вот код рабочей части.
import {EventDispatcher} from 'three'; class ElementProxyReceiver extends EventDispatcher { constructor() { super(); } handleEvent(data) { this.dispatchEvent(data); } }
Все, что он делает, - это если он получает сообщение, то отправляет его. Он наследуется от EventDispatcher
, который предоставляет такие методы, как addEventListener
и removeEventListener
, точно так же, как элемент DOM, поэтому, если мы передадим его в OrbitControls
, он должен работать.
ElementProxyReceiver
обрабатывает 1 элемент. В нашем случае нам нужен только один, но лучше думать головой, так что давайте заставим менеджера управлять более чем одним из них.
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 с идентификатором, который создаст ElementProxyReceiver
, который будет отвечать на сообщения с этим идентификатором.
Давайте подключим его к обработчику сообщений нашего воркера.
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); };
Нам также нужно добавить 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();
Обратите внимание, что мы передаем OrbitControls
нашему прокси через inputElement
вместо передачи холста, как в других примерах, отличных от OffscreenCanvas
.
Затем мы можем переместить весь код события выбора из файла HTML в общий код three.js, а также изменить canvas
на inputElement
.
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
берет элемент, события которого мы хотим проксировать. Затем он регистрирует идентификатор у воркера, выбирая его и отправляя через сообщение makeProxy
, которое мы настроили ранее. Рабочий создаст ElementProxyReceiver
и зарегистрирует его для этого идентификатора.
Затем у нас есть объект обработчиков событий для регистрации. Таким образом, мы можем передавать обработчики только тех событий, которые мы хотим переслать воркеру.
Когда мы запускаем воркер, мы сначала создаем прокси и передаем наши обработчики событий.
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
, в которую они передают созданные данные. Эта функция добавит правильный идентификатор и отправит его воркеру.
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
нужно еще кое-что.
Один из них - element.focus
. Нам не нужно, чтобы это происходило в воркере, поэтому давайте просто добавим заглушку.
class ElementProxyReceiver extends THREE.EventDispatcher { constructor() { super(); } handleEvent(data) { this.dispatchEvent(data); } + focus() { + // no-op + } }
Другой - они вызывают 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 } }
Другой - они смотрят на clientWidth
и clientHeight
. Раньше мы передавали размер, но мы можем обновить пару прокси, чтобы передать его.
В воркере...
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
, но в воркере нет глобального документа.
Мы можем решить все это с помощью 2 быстрых приемов. В нашем рабочем коде мы повторно используем прокси для обеих задач
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 в ElementProxyReceiver
в воркере, который маскируется под HTMLElement
, который мы можем использовать как с OrbitControls
, так и с нашим собственным кодом.
И последнее - это наш запасной вариант, когда мы не используем OffscreenCanvas. Все, что нам нужно сделать, это передать сам холст как наш inputElement
.
function startMainPage(canvas) { - init({canvas}); + init({canvas, inputElement: canvas}); console.log('using regular canvas'); }
и теперь у нас должен быть OrbitControls, работающий с OffscreenCanvas
Это, наверное, самый сложный пример на этом сайте. Это немного сложно понять, потому что для каждого образца задействовано 3 файла. HTML-файл, рабочий файл, общий код three.js.
Я надеюсь, что это было не так уж сложно понять, и что он предоставил несколько полезных примеров работы с three.js, OffscreenCanvas и веб-воркерами.