В этом руководстве я расскажу как с помощью OffscreenCanvas
мне удалось вынести весь код работы с WebGL и Three.js в отдельный поток веб-воркера. Это ускорило работу сайта и на слабых устройствах исчезли фризы во время загрузки страницы.
Статья основана на личном опыте, когда я добавил вращающуюся 3D-землю на свой сайт и это забрало 5 очков производительности в Google Lighthouse — слишком много для лёгких понтов.
Проблема
Three.js прячет кучу сложных моментов WebGL, но имеет серьёзную цену — библиотека добавляет 563 КБ в вашу JS-сборку для браузеров (да и архитектура библиотеки не позволяет эффективно работать тришейкингу).
Некоторые могут сказать, что картинки часто весят те же 500 КБ — и будут сильно неправы. Каждый КБ скрипта гораздо сильнее ударяет по производительности, чем КБ изображения. Чтобы сайт был быстрым, нужно думать не только о ширине канала и времени задержки — нужно так же думать о времени работы ЦПУ компьютера для обработки файлов. На телефонах и слабых ноутбуках обработка может идти дольше, чем загрузка.
Обработка 170 КБ JS идёт 3,5 секунды против 0,1 секунды для 170 КБ изображения — Эдди Османи
Пока браузер будет исполнять 500 КБ Three.js, основной поток страницы будет заблокирован и пользователь будет видеть фриз интерфейса.
Веб-воркеры и Offscreen Canvas
У нас давно есть решение, чтобы не убирать фриз во время долгого исполнения JS — веб-воркеры, запускающие код в отдельном потоке.
Чтобы работа с веб-воркерами не превратилась в ад многопоточного программирования, веб-воркер не имеет доступа к DOM. Только основной поток работает с HTML страницы. Но как без доступа к DOM запустить Three.js, которая требует прямого доступа к <canvas>
?
Для этого есть OffscreenCanvas — он позволяет передать <canvas>
в веб-воркер. Чтобы не открывать врата многопоточного ада, после передачи, основной поток теряет доступ к этому <canvas>
— только один поток будет работать с ним.
Кажется мы близки к цели, но оказывается, что только Хром поддерживает OffscreenCanvas
.
Поддержка OffscreenCanvas на апрель 2019 по данным Can I Use
Но даже тут, перед лицом главного врага веб-разработчика, поддержки браузеров, мы не должны сдаваться. Собираемся и находим последний элемент пазла — это идеальный случай для «прогрессивного улучшения». В Хроме и браузерах будущего мы уберём фриз, а остальные браузеры будут работать как раньше.
В итоге нам нужно будет написать один файл, который сможет работать сразу в двух разных средах — в веб-воркере и в обычном основном JS-потоке.
Решение
Чтобы скрыть хаки под слоем сахара, я сделал маленькую JS-библиотеку offscreen-canvas в 400 байт (!). В примерах код будет использовать её, но я буду рассказывать, как она работает «под капотом».
Начнём с установки библиотеки:
npm install offscreen-canvas
Нам потребуется отдельный JS-файл для веб-воркера — создадим отдельный файл сборки в Вебпаке или Parcel:
entry: {
'app': './src/app.js',
+ 'webgl-worker': './src/webgl-worker.js'
}
Сборщики будут постоянно менять имя файла при деплое из-за кеш-бастеров — нам нужно будет записать имя в HTML с помощью preload-тега. Тут пример будет абстрактный, так как реальный код будет сильно зависеть от особенностей вашей сборки.
<link type="preload" as="script" href="./webgl-worker.js">
</head>
Теперь нам нужно в основном JS-файле получить DOM-узел для <canvas>
и содержимое preload-тега.
import createWorker from 'offscreen-canvas/create-worker'
const workerUrl = document.querySelector('[rel=preload][as=script]').href
const canvas = document.querySelector('canvas')
const worker = createWorker(canvas, workerUrl)
createWorker
при наличии canvas.transferControlToOffscreen
загрузит JS-файл в веб-воркер. А при отсутствии этого метода — как обычный <script>
.
Создаём этот webgl-worker.js
для воркера:
import insideWorker from 'offscreen-canvas/inside-worker'
const worker = insideWorker(e => {
if (e.data.canvas) {
// Тут мы будем рисовать сцену на <canvas>
}
})
insideWorker
проверяет, был ли он загружен внутри веб-воркера. В зависимости от окружения он запустит разные системы связи с основным потоком.
Библиотека будет на каждое новое сообщение из основного потока запускать функцию, переданную в insideWorker
. Сразу после загрузки, createWorker
пошлёт первое сообщение { canvas, width, height }
, чтобы отрисовать первый кадр на <canvas>
.
+ import {
+ WebGLRenderer, Scene, PerspectiveCamera, AmbientLight,
+ Mesh, SphereGeometry, MeshPhongMaterial
+ } from 'three'
import insideWorker from 'offscreen-canvas/inside-worker'
+ const scene = new Scene()
+ const camera = new PerspectiveCamera(45, 1, 0.01, 1000)
+ scene.add(new AmbientLight(0x909090))
+
+ let sphere = new Mesh(
+ new SphereGeometry(0.5, 64, 64),
+ new MeshPhongMaterial()
+ )
+ scene.add(sphere)
+
+ let renderer
+ function render () {
+ renderer.render(scene, camera)
+ }
const worker = insideWorker(e => {
if (e.data.canvas) {
+ // canvas в веб-воркере будет без размера — мы выставим его вручную, чтобы избежать ошибок от Three.js
+ if (!canvas.style) canvas.style = { width, height }
+ renderer = new WebGLRenderer({ canvas, antialias: true })
+ renderer.setPixelRatio(pixelRatio)
+ renderer.setSize(width, height)
+
+ render()
}
})
При переносе вашего старого кода для Three.js в веб-воркер вы можете увидеть ошибки, так как в веб-воркере нет DOM API. Например, нет document.createElement
для загрузкии SVG-текстур. Так что, нам будут иногда нужны разные загрузчики в веб-воркере и внутри обычного скрипта. Для проверки типа окружения у нас есть worker.isWorker
:
renderer.setPixelRatio(pixelRatio)
renderer.setSize(width, height)
+ const loader = worker.isWorker ? new ImageBitmapLoader() : new ImageLoader()
+ loader.load('/texture.png', mapImage => {
+ sphere.material.map = new CanvasTexture(mapImage)
+ render()
+ })
render()
Мы отрисовали первый кадр. Но большинство WebGL-сцен должны реагировать на действия пользователя. Например, вращать камеру при движении курсора или дорисовывать кадр при изменении размеров окна. К сожалению, веб-воркер не может слушать DOM-события. Нам надо слушать их в основном потоке и посылать сообщения в веб-воркер.
import createWorker from 'offscreen-canvas/create-worker'
const workerUrl = document.querySelector('[rel=preload][as=script]').href
const canvas = document.querySelector('canvas')
const worker = createWorker(canvas, workerUrl)
+ window.addEventListener('resize', () => {
+ worker.post({
+ type: 'resize', width: canvas.clientWidth, height: canvas.clientHeight
+ })
+ })
const worker = insideWorker(e => {
if (e.data.canvas) {
if (!canvas.style) canvas.style = { width, height }
renderer = new WebGLRenderer({ canvas, antialias: true })
renderer.setPixelRatio(pixelRatio)
renderer.setSize(width, height)
const loader = worker.isWorker ? new ImageBitmapLoader() : new ImageLoader()
loader.load('/texture.png', mapImage => {
sphere.material.map = new CanvasTexture(mapImage)
render()
})
render()
- }
+ } else if (e.data.type === 'resize') {
+ renderer.setSize(width, height)
+ render()
+ }
})
Результат
С OffscreenCanvas
я победил фризы на моём сайте и получил 100% очков в Google Lighthouse. И WebGL работает во всех браузерах, даже без поддержки OffscreenCanvas
.
Можете глянуть живой сайт и исходники основного потока или воркера.
С OffscreenCanvas очки Google Lighthouse поднялись с 95 до 100
Автор: Андрей «A.I.» Ситник