Web workers in 3D web applications

From your experience when does it make sense to use web workers in a 3D web app?

For example, when adding interaction in the scene with Ray casting or generating paths using path finding algorithms, could workers be useful or better to keep that on the main thread? Other use cases?

Web workers can do lots of Good if you have a decently large app. It takes a bit of work to figure out how to handle the details. I use a setup where everything runs in workers except rendering and animated models. SharedArrayBuffer as attribute buffers allows you to control the scene from a worker thread without using postMessage which is too slow for reliable rendering.

3 Likes

But it’s only supported by Chrome and FF. Transfering regular buffers doesn’t has a big cost, it’s mainly just passing the pointers.

3 Likes

In context of Game AI programming, we (the YUKA team) made no good experiences with workers. There is a lot of stuff like line of sight tests, path finding or inference logic that could run in a separate thread. But we’ve realized in our performance evaluation tests that the thread management and communication overhead with workers is just too high for these types of unit of work. You would have to merge different tasks into bigger ones so the thread has more work to do. But this approach is just not feasible.

Instead of using workers, we made good experiences by using window.requestIdleCallback(). We build a lightweight layer (YUKA.Task and YUKA.TaskQueue) on top of this API in order to provide a simple task management for the library. We can now wrap certain amount of non time-critical work like pathfinding in tasks and automatically execute them when the browser has some idle time.

11 Likes

Thanks guys for your great feedback!

@Oskasb SharedArrayBuffer seems to be very interesting, but its current very limited support makes me to not use it for now, until all the main browsers will implement the spec.

@Mugen87 If pathfinding is used for player movement, could not be a risk to put the path finding calculation on an idleCallback?

About raycasting, is worker postMessage too slow for this use case (e.g. collisions or path constraints)?

1 Like

You can define a deadline after your code is definitely executed. Besides, a path finding query normally returns a path which is usually an array of waypoints or edges the AI steers along. So the movement itself (I mean the translation from a point in 3D space to another point) is not affected by path finding. The worst thing that can happen is that the AI moves a bit in the wrong direction. In most cases, this is not noticeable for the player.

2 Likes

Good points! I was thinking more to a point & click adventure, where you can use path finding to build a path of waypoints for the player to navigate around.

Transferable objects can be used as a halfway decent protocol as fallback where SharedArrayBuffer is missing. But that route you need to expect a frame or two of latency so it is a bit harder to live with. You can also use offscreen canvas to move the whole rendering to a worker. This should allow you to run a css based ui in the main thread, but this is speculation on my part as i havnt tried it myself.

I’ve been working on my game for the past 3 or 4 years or so and have rewritten the engine multiple times from scratch due to unforseen performance overhead when running everything in a single (main) thread.

From my experience, running the entire renderer (threejs) in a worker will give you a pretty big performance increase if you’re having a lot of things happening in the background and on the UI-side of things. Remember that your browser does alot of DOM-related tasks on the main thread too whenever a user clicks something or an element’s CSS is updated.

Having threejs in a dedicated worker allows it to keep rendering images while your browser can perform its tasks unhindered on the main thread as well, giving your users a much smoother experience (no microstutters). You’ll have to use requestAnimationFrame from a worker though, which is relatively new.

As for things like pathfinding, I’ve moved this to a separate thread as well because my level geometry is made up of voxels, so its pretty small. You don’t want to ping-pong your entire scene graph over to other threads, thats too much overhead due to serialization. So basically just sending the mutated state of the level to the worker whenever it changes (which isn’t very often), then I simply query for a path using “from” and “to” vectors from the pathfinding thread.

It takes a lot of work to set-up “inter thread communication” properly and effectively, and it can create a lot of overhead if done improperly, but it can most certainly give more benefits than doing everything in a single thread. My engine (or rather framework) uses dependency injection to have “small modules” which can communicate with each other over different threads. Everything is written in Typescript, which makes development so much easier and more comprehensible.

// This runs in a worker context.
@Thread // Mark this class as a "spawnable worker" that can be created using the ThreadFactory.
class PathfinderThread
{
    @Delegate // mark the method is a delegator for threading.
    public async getPath(from: Vector3, to: Vector3): Promise<Vector3[]>
    {
        const route = [];
        // Route building logic here.
        return route;
    }
}

// This runs in another thread. In this case, the renderer thread where Threejs lives.
@Injectable
export class Renderer
{
    @Inject(ThreadFactory) private threadFactory: ThreadFactory;

    private pathFinder: ThreadDelegate<PathFinder>;

    constructor()
    {
        (async() => {
            this.pathFinder = await this.threadFactory.spawn(PathFinder);
            
            // Fetch the route. The "request" here is the [[ThreadDelegate]] that knows about methods
            // marked with the @Delegate annotation and communicates with them as regular javascript functions.
            // Data (de-)serialization happens automatically based on received types.
            const myRoute = await this.pathFinder.request.getRoute(
                new Vector3(0, 0, 0),
                new Vector3(10, 5, 23)
            );
            console.log(myRoute); // Will show an empty array :p 
        })();
    }
}

tl;dr: Yes, web workers are benificial, but only if done properly and used for the right reasons. The only thing I personally found most benificial is moving the renderer (threejs) to a worker. The other things like AI-logic or game-logic completely depends on what your workers need to know and if the communication between threads is worth the overhead.

10 Likes

Thank you for your useful info!
When you talk about “moving the render to a worker”, do you mean using the OffscreenCanvas?

Yeah that’s exactly it.

Pass an OffscreenCanvas as a “transferable” to the worker from the main thread and pass the instance inside the worker to the WebGLRenderer.

2 Likes

Do you have any example code with the renderer in a worker? I find this puzzling and intriguing.

Super curious what this looks like.

There is an example of this on the ThreeJS examples page: https://threejs.org/examples/webgl_worker_offscreencanvas.html

5 Likes

Ah got it. Gonna wait till browser compatibility is better, but definitely will play with the concept in the meantime, thanks!

Ehy guys, do you know if does exist a version of the GLTFLoader which is web-worker compatible?

I am trying to move my loading logic in web worker because I have to load a reasonably large glb scene file and it blocks the ui on the main thread during the parsing, but I’ve tried to load the same model on worker and it seems the GLTFLoader relies on the window object:

GLTFLoader.js:2126 Uncaught (in promise) ReferenceError: window is not defined
    at GLTFParser.THREE.GLTFLoader.GLTFParser.loadTexture (GLTFLoader.js:2126)
    at GLTFParser.THREE.GLTFLoader.GLTFParser.getDependency (GLTFLoader.js:1834)
    at GLTFParser.THREE.GLTFLoader.GLTFParser.assignTexture (GLTFLoader.js:2228)
    at GLTFParser.THREE.GLTFLoader.GLTFParser.loadMaterial (GLTFLoader.js:2301)
    at GLTFParser.THREE.GLTFLoader.GLTFParser.getDependency (GLTFLoader.js:1830)
    at GLTFParser.THREE.GLTFLoader.GLTFParser.loadMesh (GLTFLoader.js:2673)
    at GLTFParser.THREE.GLTFLoader.GLTFParser.getDependency (GLTFLoader.js:1814)
    at GLTFLoader.js:3141
    at GLTFParser.THREE.GLTFLoader.GLTFParser.loadNode (GLTFLoader.js:3205)
    at GLTFParser.THREE.GLTFLoader.GLTFParser.getDependency (GLTFLoader.js:1810)

Or if you can point me to any working example of loading assets and gltf specifically using web workers. Thank you this is my first attempt using workers :slight_smile:

GLTFLoader, and really almost all other loaders that come with ThreeJS use window.Image to load textures. The native Image class is not available in workers (at time of writing).

You can do one of the following things (no there is no sample code to take from somewhere as far as I know), and all options will take a considerate amount of time - depending on your level:

Option 1:

Split the loader so it loads textures in the main thread (the window) and passes loaded images as ImageData or ImageBitmap instances to the worker using Transferable objects. This method still means that you’d effectively have to load all image-data on the main thread, then pass it to the worker. This may have a slight performance impact still, depending on the type of textures you’re loading.

Option 2:

Write your own (or take an existing) image decoder. These are usually built for NodeJS-like environments and allow you to take raw image data by reading the file and transforming it into an ImageData object that contains an ArrayBuffer and the dimensions of the image. Passing an ImageData object to THREE.Texture works out of the box and is probably the best way to go about this.

I personally tackled this problem by going for option 2 by ditching all existing loaders or reworking some of them so they use my own asset management system (main reason so textures don’t get loaded twice, manageable from an editor, having surface properties, etc.). The asset manager works in a separate thread so textures can pretty much be “streamed” when requested. Since the native Image-API isn’t available in a worker-context, I’ve taken some existing image decoders from NPM and stuck with them. Since my engine runs on Electron, I have the luxury of using NodeJS-API’s inside a worker-context. This means having direct access to the file system and having the ability to directly read the data of any given file, instead having to go through an HTTP-request.

There are always “many roads to Rome”, pick one and see what works best for you.

3 Likes

what about the problem of browser compatibility? do u have any fallback strategy?

In my game engine I currently employ workers for 2 main purposes:

  1. building terrain geometry and metadata, terrain is split into chunks, you send a heightmap along with some metadata to a worker and it sends you back tile geometry and spatial idnex for said geometry.
  2. Compression. Game save data used to be quite large, so I added a compressor, it runs in a worker thread. Not much to say, compression can take a while.

Beyond that I wrote a custom threading engine in JS, most of my long-running tasks are executed using that engine. It implements dependency graphs and is basically a stripped down version of time-sharing scheduler that you’d find in an OS. Main reason I wrote that thing is the sheer convenience of being able to pass data around without having to implement additional communication interfaces. I am very satisfied with it and pretty much everything that takes more than a couple of milliseconds runs on that engine. To make a few things:

  • level loading
  • AI solvers
  • enemy group generator

I also have a WebWorker interface on top of Ammo.js in my engine, but it’s disabled for this game, as physics is not used in this title.

In my experience it only makes sense to put into a worker things which require relatively little communication compared to amount of computation they do. #CaptainObvious :policewoman:

6 Likes

Considering the support for offscreen canvas i don’t see this really as an option. If in a worker or the main thread a overloaded bad managed scene will perform bad either way, the major part should be synchronous with each frame including user input. It rather makes sense to give workers any tasks which can be asynchronous in my opinion.

The only reason i use a offscreen canvas if available is for rendering procedural data sets in Tesseract, what is rather new, before tiles were only rendered in the main-thread in a semi-threaded way by distributing the workload what still works at 60 FPS.

1 Like

For anyone coming back reading this.

Above options regarding workers for gltfLoader should not be needed for gltfLoader anymore since gltfLoader uses ImageBitmapLoader on most major browsers (except safari and old versions of firefox) and it will automatically decode the textures via workers.

		// Use an ImageBitmapLoader if imageBitmaps are supported. Moves much of the
		// expensive work of uploading a texture to the GPU off the main thread.

		const isSafari = /^((?!chrome|android).)*safari/i.test( navigator.userAgent ) === true;
		const isFirefox = navigator.userAgent.indexOf( 'Firefox' ) > - 1;
		const firefoxVersion = isFirefox ? navigator.userAgent.match( /Firefox\/([0-9]+)\./ )[ 1 ] : - 1;

		if ( typeof createImageBitmap === 'undefined' || isSafari || ( isFirefox && firefoxVersion < 98 ) ) {

			this.textureLoader = new TextureLoader( this.options.manager );

		} else {

			this.textureLoader = new ImageBitmapLoader( this.options.manager );

		}