📜 ⬆️ ⬇️

Accelerate WebGL / Three.js with OffscreenCanvas and Web Workers

Accelerate WebGL / Three.js with OffscreenCanvas and Web Workers

In this tutorial, I’ll tell you how using OffscreenCanvas I managed to put all the code for working with WebGL and Three.js into a separate thread of the web worker. This accelerated the work of the site and on weak devices, friezes disappeared during the page loading.

The article is based on personal experience when I added a rotating 3D ground to my site and it took 5 performance points to the Google Lighthouse - too much for easy ponts.
')

Problem


Three.js hides a bunch of difficult moments of WebGL, but it has a serious price - the library adds 563 KB to your JS build for browsers (and the library architecture does not allow trichencing to work effectively).

Some may say that the pictures often weigh the same 500 KB - and they will be very wrong. Each script KB affects the performance much more than the image KB. To make the site fast, you need to think not only about the width of the channel and the delay time - you also need to think about the CPU time of the computer for processing files. On phones and weak laptops, processing can take longer than loading.

Processing 170 KB JS goes 3.5 seconds versus 0.1 seconds for a 170 KB image
Processing 170 KB JS goes 3.5 seconds versus 0.1 seconds for a 170 KB image - Eddie Osmani

As long as the browser executes 500 KB of Three.js, the main stream of the page will be blocked and the user will see the interface frieze.

Web Workers and Offscreen Canvas


We have a solution for a long time not to remove the frieze during the long execution of JS - web workers running the code in a separate thread.

To prevent working with web workers from turning into a hell of multi-threaded programming, the web worker does not have access to the DOM. Only the main stream works from the HTML page. But how, without access to the DOM, run Three.js, which requires direct access to the <canvas> ?

There is an OffscreenCanvas for this - it allows you to pass a <canvas> to a web worker. In order not to open the gates of a multi-stream hell, after the transfer, the main thread loses access to this <canvas> - only one thread will work with it.

It seems we are close to the goal, but it turns out that only Chrome supports OffscreenCanvas .

Only Chrome supports OffscreenCanvas
OffscreenCanvas support for April 2019 according to Can I Use

But even here, in the face of the main enemy of the web developer, browser support, we should not give up. Gathering and finding the last element of the puzzle is the perfect case for “progressive improvement.” In Chrome and the browsers of the future, we will remove the frieze, and the rest of the browsers will work as before.

As a result, we will need to write one file that can work in two different environments at once - in the web worker and in the usual main JS stream.

Decision


To hide the hacks under the sugar layer, I made a small JS-library offscreen-canvas of 400 bytes (!). In the examples, the code will use it, but I will tell you how it works “under the hood”.

Let's start by installing the library:

 npm install offscreen-canvas 

We will need a separate JS file for the web worker - we will create a separate assembly file in Webpack or Parcel:

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

Collectors will constantly change the name of the file when it is deployed because of the cache busters - we will need to write the name in HTML using a preload tag . Here the example will be abstract, since the real code will strongly depend on the features of your build.

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

Now we need to get the DOM node for the <canvas> and the contents of the preload tag 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 in the presence of canvas.transferControlToOffscreen load the JS file into the web worker. And in the absence of this method - as usual <script> .

Create this webgl-worker.js for the worker:

 import insideWorker from 'offscreen-canvas/inside-worker' const worker = insideWorker(e => { if (e.data.canvas) { //       <canvas> } }) 

insideWorker checks if it has been loaded inside the web worker. Depending on the environment, it will launch different communication systems with the main thread.

The library will run the function passed to insideWorker for each new message from the main thread. Immediately after loading, createWorker will send the first message { canvas, width, height } to draw the first frame on <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() } }) 

When transferring your old code for Three.js to a web worker you may see errors, as there is no DOM API in the web worker. For example, there is no document.createElement for loading and SVG textures. So, we will sometimes need different downloaders in a web worker and inside a regular script. To check the type of environment we have 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() 

We have drawn the first frame. But most WebGL scenes must respond to user input. For example, rotate the camera when the cursor moves or draw a frame when the window is resized. Unfortunately, a web worker cannot listen to DOM events. We need to listen to them in the main thread and send messages to the web 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() + } }) 

Result


With OffscreenCanvas I won OffscreenCanvas on my website and got 100% points at Google Lighthouse. And WebGL works in all browsers, even without OffscreenCanvas support.

You can look at the live site and source code of the main thread or worker .


With OffscreenCanvas, Google Lighthouse glasses have risen from 95 to 100

Source: https://habr.com/ru/post/446682/


All Articles