Ускоряем 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

Комментарии (11)


  1. customtema
    04.04.2019 08:49
    +1

    добавил вращающуюся 3D-землю на свой сайт


    24TTL?

    библиотека добавляет


    CDN?

    я сделал маленькую JS-библиотеку


    Спасибо! И за пост тоже!


    1. Iskin Автор
      04.04.2019 09:00

      24TTL?

      А что это такое?


      CDN?

      CDN никак не решает проблему того, что 500 КБ надо откомпилировать и запустить. 3,8 секунд нагрузки на ЦПУ и 0,5 секунд фриза из отчёта Google Lighthouse «до» — это как раз время запуска Three.js.


      Статья как раз про решение вопроса запуска JS, а не его скачивания.


  1. trawl
    04.04.2019 09:27

    Просмотров статьи всего 251, а сайт уже не открывается :(


    1. Iskin Автор
      04.04.2019 09:43

      Включи VPN. Роскомнадзор забанил Netlify.


  1. 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.


  1. majestik
    04.04.2019 18:15

    С OffscreenCanvas я победил фризы на моём сайте и получил 100% очков в Google Lighthouse.

    Получается фризы исчезли только в Chrome, а в остальных осталось как прежде?


    1. Iskin Автор
      04.04.2019 18:15

      Ага (но на мобилке WebGL я убрал, чтобы не было фриза)


  1. Finesse
    05.04.2019 05:41

    Почему оценка Lighthouse выросла, если объём работы для браузера не уменьшился? Потому что Lighthouse учитывает только начальную загрузку страницы и отрисовка Земли была перенесена в вебворкер, чтобы не блокировать отрисовку основного сайта? Почему бы тогда просто не перенести отрисовку Земли в обработчик `window.onload`, учитывая, что на сайте больше ничего не происходит?


    1. 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.


      1. Finesse
        05.04.2019 06:34

        Я думал, что показатель «First CPU Idle» уменьшился, потому что работа стала выполняться параллельно, но судя по инструменту Performance из Google Chrome, ничего не выполняется параллельно:


        Скриншот


  1. Iskin Автор
    05.04.2019 06:42

    > судя по инструменту Performance из Google Chrome, ничего не выполняется параллельно

    Всё верно. Главный JS скрипт очень простой и всё что он делает — просто запускает воркер.

    Ага, First CPU Idle уменьшился, потому что он считает только загрузку главного процесса.