WebVR Part 2: Utilizing Web Workers and Browser Edge Computing

Our astrophysics simulator is driven by a powerful combination of hope, hype, and access to new computing power.

We achieve this computing power using web workers. If you are already well-versed in web workers, you might want to examine the code and proceed directly to the section on WebAssembly, which is covered in the subsequent article.

JavaScript rose to prominence as the most widely installed, learned, and accessible programming language due to its introduction of groundbreaking features to the static web:

  • Single-threaded event loop
  • Asynchronous code
  • Garbage collection
  • Data without rigid typing

Single-threaded execution eliminates concerns about the complexities and challenges of multithreaded programming.

Asynchronous code allows us to use functions as parameters that can be executed later as events within the event loop.

These features, along with Google’s significant investments in enhancing the performance of Chrome’s V8 JavaScript engine and the availability of robust developer tools, have established JavaScript and Node.js as the ideal options for microservice architectures.

Single-threaded execution is also advantageous for browser developers, who are tasked with securely isolating and running multiple browser tab runtimes, often laden with spyware, across various cores of a computer.

Question: How can a single browser tab utilize all the CPU cores of your computer? Answer: Web workers!

Web Workers and Threading

Web workers utilize the event loop to exchange messages asynchronously between threads, effectively circumventing many potential issues associated with multithreaded programming.

Furthermore, web workers can offload computations from the main UI thread, allowing it to focus on handling user interactions like clicks and animations, as well as managing the DOM.

Let’s examine some code snippets from the project’s GitHub repo.

As per our architecture diagram, we’ve handed over the entire simulation to nBodySimulator, which is responsible for managing the web worker.

Architecture diagram

Recall from the introductory post that nBodySimulator has a step() function that’s called every 33ms during the simulation. This function invokes calculateForces(), updates positions, and redraws the simulation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Methods from class nBodySimulator

 /**
   * The simulation loop
   */
  start() {
    // This is the simulation loop. step() calls visualize()
    const step = this.step.bind(this)
    setInterval(step, this.simulationSpeed)
  }

  /**
   * A step in the simulation loop.
   */
  async step() {
    // Skip calculation if worker not ready. Runs every 33ms (30fps), expect it to skip.
    if (this.ready()) {
      await this.calculateForces()
    } else {
      console.log(`Skipping calculation:  WorkerReady: ${this.workerReady}   WorkerCalculating: ${this.workerCalculating}`)
    }
    // Remove any "debris" that has traveled out of bounds - this is for the button
    this.trimDebris()

    // Now Update forces. Reuse old forces if we skipped calculateForces() above
    this.applyForces()

    // Ta-dah!
    this.visualize()
  }

The web worker’s role is to provide a dedicated thread for WebAssembly. Being a low-level language, WebAssembly only works with integers and floats. We cannot directly pass JavaScript Strings or Objects to it; instead, we use pointers to “linear memory.” For simplicity, we package our “bodies” into an array of floats called arrBodies.

We will delve deeper into this aspect in the article dedicated to WebAssembly and AssemblyScript.

Moving data in/out of the web worker

In this section, we are creating a web worker to execute calculateForces() in a separate thread. This is accomplished by first converting the bodies’ properties (x, y, z, mass) into an array of floats called arrBodies. Then, we use this.worker.postMessage() to send this array to the worker. Finally, we return a promise that the worker will resolve later in this.worker.onMessage().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// src/nBodySimulator.js
/** 
   * Use our web worker to calculate the forces to apply on our bodies.
   */
  calculateForces() {
    this.workerCalculating = true
    this.arrBodies = []

    // Copy data to array into this.arrBodies
    ...

    // return promise that worker.onmessage will fulfill
    const ret = new Promise((resolve, reject) => {
      this.forcesResolve = resolve
      this.forcesReject = reject
    })

    // postMessage() to worker to start calculation
    // Execution continues in workerWasm.js worker.onmessage()
    this.worker.postMessage({ 
      purpose: 'nBodyForces',
      arrBodies: this.arrBodies,
    })

    // Return promise for completion
    // Promise is resolve()d in this.worker.onmessage() below.
    // Once resolved, execution continues in step() above - await this.calculateForces()
    return ret
  }

The process begins with the browser sending a GET request to retrieve index.html, which then executes main.js. This, in turn, creates a new nBodySimulator(). Within its constructor, we find setupWebWorker().

n-body-wasm-canvas
1
2
3
4
5
6
7
8
9
// nBodySimulator.js
/**
 * Our n-body system simulator
 */
export class nBodySimulator {

  constructor() {
    this.setupWebWorker()
    ...

Our new nBodySimulator() instance resides in the main UI thread, and the setupWebWorker() function is responsible for creating the web worker by fetching workerWasm.js from the network.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// nBodySimulator.js
 // Main UI thread - Class nBodySimulator method
 setupWebWorker() {

    // Create a web worker (separate thread) that we'll pass the WebAssembly module to.
    this.worker = new Worker("workerWasm.js");

    // Console errors from workerWasm.js
    this.worker.onerror = function (evt) {
      console.log(`Error from web worker: ${evt.message}`);
    }
    ...

When new Worker() is executed, the browser retrieves and runs workerWasm.js within a separate JavaScript runtime (and thread) and initiates message passing.

Next, workerWasm.js delves into the intricacies of WebAssembly, essentially boiling down to a single this.onmessage() function with a switch() statement.

It’s important to remember that web workers lack direct network access. Therefore, the main UI thread must deliver the compiled WebAssembly code to the web worker as a message, resolving it as resolve("action packed"). We’ll explore this process in detail in the following post.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// workerWasm.js - runs in a new, isolated web worker runtime (and thread)

this.onmessage = function (evt) {

  // message from UI thread
  var msg = evt.data 
  switch (msg.purpose) {

    // Message: Load new wasm module

    case 'wasmModule': 
      // Instantiate the compiled module we were passed.
      ...
      // Tell nBodySimulation.js we are ready
      this.postMessage({ purpose: 'wasmReady' })
      return 

    // Message: Given array of floats describing a system of bodies (x, y, z, mass), 
    // calculate the Grav forces to be applied to each body
    
    case 'nBodyForces':
      ...
      // Do the calculations in this web worker thread synchronously
      const resultRef = wasm.nBodyForces(dataRef);
      ...
      // See nBodySimulation.js’ this.worker.onmessage
      return this.postMessage({
        purpose: 'nBodyForces', 
        arrForces
      })
  }
}

Returning to the setupWebWorker() method within our nBodySimulation class, we handle messages from the web worker using a familiar onmessage() + switch() pattern.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
    // Continuing class nBodySimulator’s setupWebWorker() in the main UI thread

    // Listen for messages from workerWasm.js postMessage()
    const self = this
    this.worker.onmessage = function (evt) {
      if (evt && evt.data) {

        
        // Messages are dispatched by purpose
        const msg = evt.data
        switch (msg.purpose) {

          // Worker’s reply that it has loaded the wasm module we compiled and sent. Let the magic begin!
          // See postmessage at the bottom of this function.

          case 'wasmReady': 
            self.workerReady = true
            break

          // wasm has computed forces for us
          // Response to postMessage() in nBodySimulator.calculateForces() above

          case 'nBodyForces':
            self.workerCalculating = false
            // Resolve await this.calculateForces() in step() above
            if (msg.error) {
              self.forcesReject(msg.error)
            } else {
              self.arrForces = msg.arrForces
              self.forcesResolve(self.arrForces)
            }
            break
        }
      }
    }
    ...

In this particular instance, calculateForces() creates and returns a promise, storing resolve() and reject() as self.forcesReject() and self.forcesResolve(), respectively.

This setup allows worker.onmessage() to resolve the promise that was initially created in calculateForces().

Recall the step() function from our simulation loop:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  /**
   * This is the simulation loop.
   */
  async step() {
    // Skip calculation if worker not ready. Runs every 33ms (30fps), expect it to skip.
    if (this.ready()) {
      await this.calculateForces()
    } else {
      console.log(`Skipping calculation:  WorkerReady: ${this.workerReady}   WorkerCalculating: ${this.workerCalculating}`)
    }

This allows us to bypass calculateForces() and reuse the previous forces in case WebAssembly is still occupied with calculations.

This step function executes every 33ms. If the web worker isn’t ready, it proceeds to apply and render the previous forces. In situations where a particular step’s calculateForces() calculation extends beyond the beginning of the next step, the subsequent step will utilize forces calculated based on the position from the previous step. These previous forces are either sufficiently similar to appear “correct” or occur at such a rapid pace that they are imperceptible to the user. This trade-off effectively enhances the perceived performance, even though it might not be suitable for actual human space travel.

Can this be further enhanced? Absolutely! An alternative to employing setInterval for our step function is requestAnimationFrame().

For my current objectives, this implementation is adequate for exploring Canvas, WebVR, and WebAssembly. Feel free to provide feedback or reach out if you believe there are areas for improvement or modification.

If you’re seeking a comprehensive and modern physics engine design, take a look at the open-source Matter.js.

What About WebAssembly?

WebAssembly is a portable binary format that functions seamlessly across various browsers and operating systems. It can be generated from a multitude of languages, including C/C++, Rust, and others. For my personal project, I opted to experiment with AssemblyScript, a language built upon TypeScript, which itself is an extension of JavaScript. This choice was motivated by its turtles all the way down.

AssemblyScript compiles TypeScript code into a portable “object code” binary format, allowing for just-in-time compilation into a new, high-performance runtime environment known as Wasm. During the compilation of TypeScript into the .wasm binary, it’s also possible to generate a human-readable .wat “web assembly text” format that provides a representation of the binary code.

The concluding part of setupWebWorker() sets the stage for our next article on WebAssembly and demonstrates how to circumvent web workers’ limitations regarding network access. We fetch the wasm file in the main UI thread and then employ just-in-time compilation to transform it into a native Wasm module. This module is then sent to the web worker via postMessage():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    // completing setupWebWorker() in the main UI thread
    
    // Fetch and compile the wasm module because web workers cannot fetch()
    WebAssembly.compileStreaming(fetch("assembly/nBodyForces.wasm"))
    // Send the compiled wasm module to the worker as a message
    .then(wasmModule => {
      self.worker.postMessage({ purpose: 'wasmModule', wasmModule })
    });
  }
}

Subsequently, workerWasm.js instantiates this module, granting us the ability to invoke its functions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// wasmWorker.js - web worker onmessage function
this.onmessage = function (evt) {

  // message from UI thread
  var msg = evt.data 
  switch (msg.purpose) {

    // Message: Load new wasm module

    case 'wasmModule': 
      // Instantiate the compiled module we were passed.
      wasm = loader.instantiate(msg.wasmModule, importObj)  // Throws
      // Tell nBodySimulation.js we are ready
      this.postMessage({ purpose: 'wasmReady' })
      return 

    case 'nBodyForces':
      ...
      // Do the calculations in this thread synchronously
      const resultRef = wasm.nBodyForces(dataRef);

This is how we harness the power of WebAssembly. Examining the unredacted source code reveals that the ... represents a series of memory management operations designed to transfer data into dataRef and retrieve results from resultRef. Memory management in JavaScript—how exciting!

In the next installment, we will delve into the intricacies of WebAssembly and AssemblyScript in greater detail.

Execution Boundaries and Shared Memory

Another crucial aspect to address is the concept of execution boundaries and shared memory.

Four copies of our Bodies data

While the WebAssembly article provides a tactical perspective, it’s important to discuss runtimes. Both JavaScript and WebAssembly operate within “emulated” runtimes. In the current implementation, every time we cross a runtime boundary, a copy of our body data (x, y, z, mass) is created. Although memory copying is relatively inexpensive, it’s not the most efficient approach for high-performance scenarios.

Fortunately, a dedicated group of brilliant minds is actively engaged in developing specifications and implementations for these cutting-edge browser technologies.

JavaScript offers SharedArrayBuffer, which enables the creation of shared memory objects. This would effectively eliminate the need to copy data during postMessage() from (2) to (3) during the call and onmessage()’s copying of arrForces from (3) back to (2) when returning the result.

Similarly, WebAssembly has a Linear Memory design in progress that could potentially host a shared memory space for the nBodyForces() call from (3) to (4). Additionally, the web worker could leverage shared memory to handle the result array.

Join us next time as we embark on a captivating exploration of JavaScript memory management.

Licensed under CC BY-NC-SA 4.0