Faster WebGL/Three.js 3D graphics with OffscreenCanvas and Web Workers
Tags: javascript, webdev, opensource, tutorial
Published: 2019-04-02 15:03:59

---
title: Faster WebGL/Three.js 3D graphics with OffscreenCanvas and Web Workers
published: true
canonical_url: https://evilmartians.com/chronicles/faster-webgl-three-js-3d-graphics-with-offscreencanvas-and-web-workers
description: Use the combination of OffscreenCanvas and Web Workers with my offscreen-canvas library to make your Three.js WebGL apps more performant even on low-end devices while keeping support for all browsers.
tags: javascript, webdev, opensource, tutorial
cover_image: https://thepracticaldev.s3.amazonaws.com/i/hpvvc525018j1rr9xkac.png
---

**Translations:** [Russian](https://habr.com/ru/post/446682/)

Learn how to improve WebGL performance when creating complex scenes with [Three.js](https://threejs.org/) library, by moving the render away from the main thread into a Web worker with `OffscreenCanvas`. Your 3D will render better on low-end devises and the average performance will go up. 

After I [added](https://twitter.com/sitnikcode/status/1098238272768217088) a 3D WebGL model of an earth on my [personal website](https://sitnik.ru/), I found that I immediately lost 5% on [Google Lighthouse](https://developers.google.com/web/tools/lighthouse/). 

In this article, I will show you how to win back the performance without sacrificing cross-browser compatibility with a tiny library that I wrote for this purpose.

## The Problem

With Three.js it is easy to create complex WebGL scenes. Unfortunately, it has a price. Three.js will add around [563 KB](https://bundlephobia.com/result?p=three@0.102.1) to your JS bundle size (and due to its architecture it is not really tree-shakeable).

You may say that the average background image could have the same 500 KB. But every kilobyte of JavaScript costs more to your website’s overall performance than a kilobyte of image data. Latency and bandwidth are not the only things to consider if you aim for a fast website: it is also important to consider how much time will the CPU spend on processing your content. And on lower-end devices, processing resources can take more time than downloading them.

<figure>
 <img src="https://thepracticaldev.s3.amazonaws.com/i/xs4kuxe0hat7b31lqg2z.png" alt="3.5 seconds to process 170 KB of JS and 0.1 second for 170 KB of JPEG">
 <figcaption>3.5 seconds to process 170 KB of JS and 0.1 second for 170 KB of JPEG. <a href="https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/javascript-startup-optimization/">Addy Osmani</a></figcaption>
</figure>

Your webpage will be effectively frozen while the browser processes 500KB of Three.js code, as executing JavaScript takes up the main thread. Your user will bot be able to interact with a page until a scene is fully rendered.

## Web Workers and Offscreen Canvas

Web Workers is a solution to avoid page freeze during JS execution. It is a way to move some JavaScript code to a separated thread.

Unfortunately, multi-thread programming is very hard. To make it simpler, Web Workers do not have access to DOM. Only the main JavaScript thread has this access. However, Three.js requires and access to the `<canvas>` node located in the DOM.

[`OffscreenCanvas`](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) is a solution to this problem. It allows you to transfer canvas access the to Web Worker. It is still thread safe as the main thread cannot access `<canvas>` once you opt for this workaround.

Sounds like we got our bases covered, but here’s the problem: Offscreen Canvas API is supported by Google Chrome only.

<figure>
 <img src="https://thepracticaldev.s3.amazonaws.com/i/o2nqcjv8cwvc76rgrh8q.png" alt="Only Chrome, Android and Chrome for Android supports OffscreenCanvas according to Can I Use">
 <figcaption>Browsers with Offscreen Canvas support in April of 2019 according to <a href="https://caniuse.com/#feat=offscreencanvas">Can I Use</a></figcaption>
</figure>

However, even in the face of our main enemy, cross-browser issues, we shall not be afraid. Let’s use progressive enhancement: we will improve performance for Chrome and future browsers. Other browsers will run Three.js the old way in the main JavaScript thread.

We need to come up with a way to write a single file for two different environments, keeping in mind that many DOM APIs will not work inside the Web Worker.


## The Solution

To hide all the hacks and keep the code readable I created a tiny **[offscreen-canvas](https://github.com/ai/offscreen-canvas)** JS library (just 400 bytes). The following examples will rely on it, but I will also explain how it works under the hood.

First, add `offscreen-canvas` npm package to your project:

```sh
npm install offscreen-canvas
```

We will need to provide a separated JS file for Web Worker. Let’s create a separate JS bundle in webpack’s or Parcel’s config.

```diff
  entry: {
    'app': './src/app.js',
+   'webgl-worker': './src/webgl-worker.js'
  }
```

Bundlers will add a cache buster to bundle’s file names in production. To use the name in our main JS file, let’s add a [preload](https://3perf.com/blog/link-rels/) tag. The exact code will depend on the way you generate HTML.

```html
    <link type="preload" as="script" href="./webgl-worker.js">
  </head>
```

Now we should get the canvas node and a worker URL in the main JS file.

```js
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`](https://github.com/ai/offscreen-canvas/blob/master/create-worker.js) looks for `canvas.transferControlToOffscreen` to detect `OffscreenCanvas` support. If the browser supports it, the library will load JS files as a Web Worker. Otherwise, it will load the JS file as a regular script.

Now, let’s open `webgl-worker.js`


```js
import insideWorker from 'offscreen-canvas/inside-worker'

const worker = insideWorker(e => {
  if (e.data.canvas) {
    // Here we will initialize Three.js
  }
})
```

[`insideWorker`](https://github.com/ai/offscreen-canvas/blob/master/inside-worker.js) checks if it was it loaded in Web Worker. Depending on the environment, it will use different ways to communicate with the main thread.

The library will execute the callback on any message from the main thread. The first message from `createWorker` for our worker will always be the object with `{ canvas, width, height }` to initialize canvas. 

```diff
+ 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 in Web Worker will not have size, we will set it manually to avoid errors from Three.js
+     if (!canvas.style) canvas.style = { width, height }
+     renderer = new WebGLRenderer({ canvas, antialias: true })
+     renderer.setPixelRatio(pixelRatio)
+     renderer.setSize(width, height)
+
+     render()
    }
  })
```

While creating an initial state of the scene, we can find some error messages from Three.js. Not all the DOM APIs are available in a Web Worker. For instance, there is no `document.createElement` to load SVG texture. We will need a different loader for Web Worker and regular script environments. We can detect the environment by `worker.isWorker` property:

```diff
      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()
```

We rendered the initial state of the scene. But most of WebGL scenes need to react to user actions. It could be rotating a camera with a mouse. Or updating `canvas` on window resize. Unfortunately, Web Worker doesn’t have access to any of the DOM’s events. We need to listen to events in the main thread and send messages to the worker:

```diff
  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
+   })
+ })
```

```diff
  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()
+   }
  })
```

## The Result

Using `OffscreenCanvas`, I fixed UI freezes on my personal site in Chrome and got a full 100 score on Google Lighthouse. And my WebGL scene still works in all other browsers.

You can check the result: **[demo](https://sitnik.ru/)** and **source code** for [main thread](https://github.com/ai/sitnik.ru/blob/master/src/earth/earth.js) and [worker](https://github.com/ai/sitnik.ru/blob/master/src/earth/worker.js).

<figure>
 <img src="https://thepracticaldev.s3.amazonaws.com/i/n6esc7r6qba9vip43xsy.png" alt="With OffscreenCanvas Google Lighthouse Performance rate increased from 95 to 100">
 <figcaption>With OffscreenCanvas Google Lighthouse Performance rate increased from 95 to 100</figcaption>
</figure>