Faster WebGL/Three.js 3D graphics with OffscreenCanvas and Web Workers
Translations
Learn how to improve WebGL performance when creating complex scenes with Three.js library, by moving the render away from the main thread into a Web worker with OffscreenCanvas
. Your 3D will render better on low-end devices, and the average performance will go up.
After I added a 3D WebGL model of an earth on my personal website, I found that I immediately lost 5% on Google 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 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.
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
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.
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 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:
npm install offscreen-canvas
We will need to provide a separated JS file for the Web Worker. Let’s create a separate JS bundle in webpack’s or Parcel’s config.
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 tag. The exact code will depend on the way you generate 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.
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
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
import insideWorker from 'offscreen-canvas/inside-worker'
const worker = insideWorker(e => {
if (e.data.canvas) {
// Here we will initialize Three.js
}
})
insideWorker
checks if it was 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.
+ 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:
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:
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()
+ }
})
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 and source code for main thread and worker.