Ускоряем WebGL/Three.js с помощью OffscreenCanvas и веб-воркеров +26


Ускоряем WebGL/Three.js с помощью OffscreenCanvas и веб-воркеров

В этом руководстве я расскажу как с помощью OffscreenCanvas мне удалось вынести весь код работы с WebGL и Three.js в отдельный поток веб-воркера. Это ускорило работу сайта и на слабых устройствах исчезли фризы во время загрузки страницы.

Статья основана на личном опыте, когда я добавил вращающуюся 3D-землю на свой сайт и это забрало 5 очков производительности в Google Lighthouse — слишком много для лёгких понтов.

Проблема


Three.js прячет кучу сложных моментов WebGL, но имеет серьёзную цену — библиотека добавляет 563 КБ в вашу JS-сборку для браузеров (да и архитектура библиотеки не позволяет эффективно работать тришейкингу).

Некоторые могут сказать, что картинки часто весят те же 500 КБ — и будут сильно неправы. Каждый КБ скрипта гораздо сильнее ударяет по производительности, чем КБ изображения. Чтобы сайт был быстрым, нужно думать не только о ширине канала и времени задержки — нужно так же думать о времени работы ЦПУ компьютера для обработки файлов. На телефонах и слабых ноутбуках обработка может идти дольше, чем загрузка.

Обработка 170 КБ JS идёт 3,5 секунды против 0,1 секунды для 170 КБ изображения
Обработка 170 КБ JS идёт 3,5 секунды против 0,1 секунды для 170 КБ изображения — Эдди Османи

Пока браузер будет исполнять 500 КБ Three.js, основной поток страницы будет заблокирован и пользователь будет видеть фриз интерфейса.

Веб-воркеры и Offscreen Canvas


У нас давно есть решение, чтобы не убирать фриз во время долгого исполнения JS — веб-воркеры, запускающие код в отдельном потоке.

Чтобы работа с веб-воркерами не превратилась в ад многопоточного программирования, веб-воркер не имеет доступа к DOM. Только основной поток работает с HTML страницы. Но как без доступа к DOM запустить Three.js, которая требует прямого доступа к <canvas>?

Для этого есть OffscreenCanvas — он позволяет передать <canvas> в веб-воркер. Чтобы не открывать врата многопоточного ада, после передачи, основной поток теряет доступ к этому <canvas> — только один поток будет работать с ним.

Кажется мы близки к цели, но оказывается, что только Хром поддерживает OffscreenCanvas.

Только Хром поддерживает 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




К сожалению, не доступен сервер mySQL