В этом руководстве я расскажу как с помощью
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
Комментарии (11)
Taraflex
04.04.2019 18:04+2Нам потребуется отдельный JS-файл для веб-воркера — создадим отдельный файл сборки в Вебпаке или Parcel:
entry: {
'app': './src/app.js',
+ 'webgl-worker': './src/webgl-worker.js'
}
Webpack добавляет различный рантайм для target:web и target:webworker, для webpack следует возвратить массив конфигов с разными target для app и webgl-worker.
Finesse
05.04.2019 05:41Почему оценка Lighthouse выросла, если объём работы для браузера не уменьшился? Потому что Lighthouse учитывает только начальную загрузку страницы и отрисовка Земли была перенесена в вебворкер, чтобы не блокировать отрисовку основного сайта? Почему бы тогда просто не перенести отрисовку Земли в обработчик `window.onload`, учитывая, что на сайте больше ничего не происходит?
Iskin Автор
05.04.2019 06:00Почему оценка Lighthouse выросла, если объём работы для браузера не уменьшился?
Lighthouse оценивает сумарное время на загрузку сайта — установка соединения + загрузка файла + компиляция/парсинг файла + время выолнения JS. Кроме того, он учитывает много других параметров:
- Время на которое страница фризиться (UI не обновляется из-за долгого беспрерывного выполнения UI) — 0,5 секунд
- Время через которое ЦПУ перестаёт быть загруженным на 100 % — 3,8 секунд
- Время через которое большинство подписчиков из JS подпишется на свои события — 3,8 секунд
(Посмотри скриншот результатов «до» в разделе «Результат»)
Почему бы тогда просто не перенести отрисовку Земли в обработчик
window.onload
, учитывая, что на сайте больше ничего не происходит?Для пользователя не станет лучше. Пока JS выполняется UI не может обновиться. Three.js на слабых устройствах запускается 0,5 секунд — всё это время страница не будет реагировать на действия пользователя.
Для Lighthouse тоже не станет лучше. Он ждёт 5 секунд после
onload
.Finesse
05.04.2019 06:34Я думал, что показатель «First CPU Idle» уменьшился, потому что работа стала выполняться параллельно, но судя по инструменту Performance из Google Chrome, ничего не выполняется параллельно:
Скриншот
Iskin Автор
05.04.2019 06:42> судя по инструменту Performance из Google Chrome, ничего не выполняется параллельно
Всё верно. Главный JS скрипт очень простой и всё что он делает — просто запускает воркер.
Ага, First CPU Idle уменьшился, потому что он считает только загрузку главного процесса.
customtema
24TTL?
CDN?
Спасибо! И за пост тоже!
Iskin Автор
А что это такое?
CDN никак не решает проблему того, что 500 КБ надо откомпилировать и запустить. 3,8 секунд нагрузки на ЦПУ и 0,5 секунд фриза из отчёта Google Lighthouse «до» — это как раз время запуска Three.js.
Статья как раз про решение вопроса запуска JS, а не его скачивания.