Multi-threaded React WebGL Applications

Multi-threaded React WebGL Applications
April 26, 2024
JavaScript, the only programming language executed natively by web browsers, is by design single-threaded. This means that all web applications run in a single thread. This is perfectly sufficient for all standard cases, but sometimes we encounter situations where huge expensive calculations are so time-consuming that the application stops responding to the user. Typical cases are applications computing complex 3D scenes. Luckily, OffscreenCanvas and Web Worker technologies are here to change it.

You can try these examples how it might improve user experience of WebGL applications.

There are online resources, which describe how to use OffscreenCanvas, even with React (@react-three/offscreen). But the technology is still new, and it is still uncommon to think of frontend applications as of multithreaded ones. Most of tutorials found online would assume that you have an isolated WebGL application, meaning that it is not exchanging information with the main thread.

From my experience, I can tell - this is a very unlikely situation. We have implemented multiple complex applications with React and WebGL at SABO. And every time both parts rendered their views from the same data!

So, inpractice, you will search for a way to synchronize the application state between multiple threads.

This article explores the more likely scenario, where both React DOM and WebGL parts of the application need read/write access to the application state.

Example

Imagine a feature for inspecting and configuring a 3D object. It might look like this:

By movingWebGL to a worker we might achieve following:

  • React DOM application running on main thread.
  • React Three Fiber application running in worker.·      

Next, let us describe the capabilities:

  • Initially we have an object with some attributes.
  • Attributes are displayed in React DOM in a side panel and can be edited.
  • Object is displayed in WebGL in main view based on its attributes.
  • Additionally, to introduce write operation on WebGL side, let's say that by clicking on an object in WebGL it also changes some of its attributes. In real application, this could be drag and drop which changes the object's position.

Problem

It should be obvious by now that WebGL code should be notified when we change the attribute in the panel and the other way around.

In general, you cannot access memory of one thread from another thread. This is a design choice to ensure thread-safe operations. Workers are supposed to communicate via messages.

That is why the most straightforward approach would be to implement a message-based communication between two workers. But before we go to that, I would like to mention a more advanced approach, which would be to store application state in SharedArrayBuffer.

SharedArrayBuffer allows shared access and supports atomic operations from different contexts(main thread or worker) without copying it. But data structure itself is used to represent a generic raw binary data buffer. For most of the time, it is too low level. Nevertheless, this is the way how a Manifold multithread engine is doing it.

SharedArrayBuffer is the most memory and speed effective, but much less user-friendly way. Even with proxy objects like bitECS (that allow you work with data as JS objects) will get you nowhere near to beloved JSON structures.

Now, let's get back to message-based communication and oneof workable solutions.

Solution

The core idea is to have a shared application store. Each part will have its local store and we will write a thin synchronization layer between via Broadcast Channel API. In the end, neither of the parts should be worried about the other one, synchronization will be seamless.

First, I have chosen valtio to store application state and soon you will see why. If you are not familiar with it, it is a library offering mutable Proxy-based reactive state management.

Store

This is basic store I used:


import { proxy } from 'valtio'

export const store = proxy({
  name: 'Suzi',
  color: '#FFA500'
})

I then connected it to my inspector panel in React DOM:


import { useSnapshot } from 'valtio'
import { store } from './store'
import { Layout, Inspector } from './dom'
import { Canvas } from './webgl'

const setName = (name: string) => (store.name = name)
const setColor = (color: string) => (store.color = color)

export const App = () => {
	const snapshot = useSnapshot(store)

	return (
		<Layout>
			<Canvas />
			<Inspector 
				name={snapshot.name}
				onNameChange={setName}
				color={snapshot.color}
				onColorChange={setColor}
			/>
		</Layout>
	)
}

And as well to my 3D scene:


import { store } from '../store'
import { invertColor } from '../utils'
import { Stage, Suzi, Label } from './components'

// example write operation
const invertAttributes = () => {
	store.color = invertColor(store.color)
	store.name = `Inverted ${store.name}`
}

export const Scene = () => {
	const snapshot = useSnapshot(store)
	
	return (
		<Stage>
			<Suzi color={snapshot.color} onClick={invertAttributes} />
			<Label>{snapshot.name}</Label>
		</Stage>
	)
}

Synchronization

I have chosen valtio because it provides nice serializable patches to the listeners that you feed to subscribe function.

Let us use it to notify other threads about changes that were made on our side.


import { subscribe } from 'valtio'

// const store = ...

export function syncStore() {
	const channel = new BroadcastChannel('sync')

	subscribe(store, (ops) => {
		channel.postMessage({ ops })
	})
}

Step by stepon example:

  1. In inspector we change name from"Suzi" to "Bob".
  2. Valtio calls our listener with argument ops, which might look like this: ["set", "name", "Bob", "Suzi"], more on the format later.
  3. We then send a message to the other thread with operations that were made, so he can apply it to his local store.

Valtio operation consists of:

  • Operation type
  • Path of affected property
  • New value (for "set" operation)
  • Old value (for "set" operation)

There are more, but we do not need them now.

Now, let us handle messages with ops (operations) when received from the other thread.


channel.onmessage = (event) => {
	event.data.ops.forEach(([type, path, newValue]) => {
	  switch (type) {
		case 'set':
		  setByPath(store, path, newValue)
		  break
		case 'delete':
		  deleteByPath(store, path)
		  break
	  }
	})
}

To apply the newvalue to a sub-tree of our store we introduce utility functions that traversethe store object according to path.


function setByPath(obj: any, path: (string | symbol)[], value: any) {
  const last = path.pop()
  let current = obj
  for (const p of path) {
    current = current[p]
  }
  current[last!] = value
}

function deleteByPath(obj: any, path: (string | symbol)[]) {
  const last = path.pop()
  let current = obj
  for (const p of path) {
    current = current[p]
  }
  delete current[last!]
}

That’s about it! Oursynchronization logic is ready. Call the function on the main thread and inrender worker to start synchronizing.

Now, whenever anything changes in store on one thread, the other one will receive an array of operations and apply it to its local version of the store.

This solution sacrifices memory for easier experience and will not be suitable for every scenario, but it solves the problem elegantly and easily.

Result

You can testyourself the deployed version here on Netlify or get the code at evstinik/multithreaded-react-webgl-example.

Share:
Nikita is a senior Front-End Developer who has been keen on programming since 2006. He enjoys not only developing complex SPA web applications but also small and medium-sized iOS applications. He is a fast learner, innovative mind, fun team-mate, and allegedly the biggest geek in SABO.

Other articles by same author

Article collaborators

SABO Newsletter icon

SABO NEWSLETTER

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

About SABO Mobile IT

We focus on developing specialized software for our customers in the automotive, supplier, medical and high-tech industries in Germany and other European countries. We connect systems, data and users and generate added value for our customers with products that are intuitive to use.
Learn more about sabo