Web Front-end10 minute read

WebVR Part 3: Unlocking the Potential of WebAssembly and AssemblyScript

What if you could incorporate clever features from other programming languages for your JavaScript project, without too much hassle? That is the general idea behind WebAssembly.

In Part 3 of our WebVR series, Toptal Full-stack Developer Michael Cole introduces you to WebAssembly and AssemblyScript, outlining how they can be harnessed to create a browser-backend for web apps.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.

What if you could incorporate clever features from other programming languages for your JavaScript project, without too much hassle? That is the general idea behind WebAssembly.

In Part 3 of our WebVR series, Toptal Full-stack Developer Michael Cole introduces you to WebAssembly and AssemblyScript, outlining how they can be harnessed to create a browser-backend for web apps.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.
Michael Cole
Verified Expert in Engineering

Michael is an expert full-stack web engineer, speaker, and consultant with over two decades of experience and a degree in computer science.

Read More

PREVIOUSLY AT

Ernst & Young
Share

WebAssembly is definitely not a replacement for JavaScript as the lingua franca of the web and the world.

WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable target for compilation of high-level languages like C/C++/Rust, enabling deployment on the web for client and server applications.”WebAssembly.org

It’s important to distinguish that WebAssembly isn’t a language. WebAssembly is like an ‘.exe’ - or even better - a Java ‘.class’ file. It is compiled by the web developer from another language, then downloaded and run on your browser.

WebAssembly is giving JavaScript all the features we occasionally wanted to borrow but never really wanted to own. Much like renting a boat or a horse, WebAssembly lets us travel to other languages without having to make extravagant “language lifestyle” choices. This has let the web focus on important things like delivering features and improving user experience.

More than 20 languages compile to WebAssembly: Rust, C/C++, C#/.Net, Java, Python, Elixir, Go, and of course JavaScript.

If you remember our simulation’s architecture diagram, we delegated the entire simulation to nBodySimulator, so it manages the web worker.

Simulation’s architecture diagram

If you remember from the intro post, nBodySimulator has a step() function called every 33ms. The step() function does these things - numbered in the diagram above:

  1. nBodySimulator’s calculateForces() calls this.worker.postMessage() to start the calculation.
  2. workerWasm.js this.onmessage() gets the message.
  3. workerWasm.js synchronously runs nBodyForces.wasm’s nBodyForces() function.
  4. workerWasm.js replies using this.postMessage() to the main thread with the new forces.
  5. The main thread’s this.worker.onMessage() marshals the returned data and calls.
  6. nBodySimulator’s applyForces() to updates the positions of the bodies.
  7. Finally, the visualizer repaints.

UI thread, web worker thread

In the previous post, we built the web worker that is wrapping our WASM computations. Today, we are building the tiny box labeled “WASM” and moving data in and out.

For simplicity, I chose AssemblyScript as the source code language to write our computations. AssemblyScript is a subset of TypeScript - which is a typed JavaScript - so you already know it.

For example, this AssemblyScript function calculates gravity between two bodies: The :f64 in someVar:f64 marks the someVar variable as a float for the compiler. Remember this code is compiled and run in a completely different runtime than JavaScript.

// AssemblyScript - a TypeScript-like language that compiles to WebAssembly
// src/assembly/nBodyForces.ts

/**
 * Given two bodies, calculate the Force of Gravity, 
 * then return as a 3-force vector (x, y, z)
 * 
 * Sometimes, the force of gravity is:  
 * 
 * Fg  =  G * mA * mB / r^2
 *
 * Given:
 * - Fg  =  Force of gravity
 * - r  = sqrt ( dx + dy + dz) = straight line distance between 3d objects    
 * - G  = gravitational constant
 * - mA, mB = mass of objects
 * 
 * Today, we're using better-gravity because better-gravity can calculate 
 * force vectors without polar math (sin, cos, tan)
 * 
 * Fbg =  G * mA * mB * dr / r^3     // using dr as a 3-distance vector lets 
 *                                   // us project Fbg as a 3-force vector
 * 
 * Given:
 * - Fbg = Force of better gravity
 * - dr = (dx, dy, dz)     // a 3-distance vector
 * - dx = bodyB.x - bodyA.x
 * 
 * Force of Better-Gravity:
 * 
 * - Fbg = (Fx, Fy, Fz)  =  the change in force applied by gravity each 
 *                                      body's (x,y,z) over this time period 
 * - Fbg = G * mA * mB * dr / r^3         
 * - dr = (dx, dy, dz)
 * - Fx = Gmm * dx / r3 
 * - Fy = Gmm * dy / r3 
 * - Fz = Gmm * dz / r3 
 * 
 * From the parameters, return an array [fx, fy, fz]
 */
function twoBodyForces(xA: f64, yA: f64, zA: f64, mA: f64, xB: f64, yB: f64, zB: f64, mB: f64): f64[] {

  // Values used in each x,y,z calculation
  const Gmm: f64 = G * mA * mB
  const dx: f64 = xB - xA
  const dy: f64 = yB - yA
  const dz: f64 = zB - zA
  const r: f64 = Math.sqrt(dx * dx + dy * dy + dz * dz)
  const r3: f64 = r * r * r

  // Return calculated force vector - initialized to zero
  const ret: f64[] = new Array<f64>(3)

  // The best not-a-number number is zero. Two bodies in the same x,y,z
  if (isNaN(r) || r === 0) return ret

  // Calculate each part of the vector
  ret[0] = Gmm * dx / r3
  ret[1] = Gmm * dy / r3
  ret[2] = Gmm * dz / r3

  return ret
}

This AssemblyScript function takes the (x, y, z, mass) for two bodies and returns an array of three floats describing the (x, y, z) force-vector the bodies apply to each other. We can’t call this function from JavaScript because JavaScript has no idea where to find it. We have to “export” it to JavaScript. This brings us to our first technical challenge.

WebAssembly Imports and Exports

In ES6, we think about imports and exports in JavaScript code and use tools like Rollup or Webpack to create code that runs in legacy browsers to handle import and require(). This creates a top-down dependency tree and enables cool tech like “tree-shaking” and code-splitting.

In WebAssembly, imports and exports accomplish different tasks than an ES6 import. WebAssembly imports/exports:

  • Provide a runtime environment for the WebAssembly module (e.g., trace() and abort() functions).
  • Import and export functions and constants between runtimes.

In the code below, env.abort and env.trace are part of the environment we must provide to the WebAssembly module. The nBodyForces.logI and friends functions provide debugging messages to the console. Note that passing strings in/out of WebAssembly is non-trivial as WebAssembly’s only types are i32, i64, f32, f64 numbers, with i32 references to an abstract linear memory.

Note: These code examples are switching back and forth between JavaScript code (the web worker) and AssemblyScript (the WASM code).

// Web Worker JavaScript in workerWasm.js

/**
 * When we instantiate the Wasm module, give it a context to work in:
 * nBodyForces: {} is a table of functions we can import into AssemblyScript. See top of nBodyForces.ts
 * env: {} describes the environment sent to the Wasm module as it's instantiated
 */
const importObj = {
  nBodyForces: {
    logI(data) { console.log("Log() - " + data); },
    logF(data) { console.log("Log() - " + data); },
  },
  env: {
    abort(msg, file, line, column) {
      // wasm.__getString() is added by assemblyscript's loader: 
      // https://github.com/AssemblyScript/assemblyscript/tree/master/lib/loader
      console.error("abort: (" + wasm.__getString(msg) + ") at " + wasm.__getString(file) + ":" + line + ":" + column);
    },
    trace(msg, n) {
      console.log("trace: " + wasm.__getString(msg) + (n ? " " : "") + Array.prototype.slice.call(arguments, 2, 2 + n).join(", "));
    }
  }
}

In our AssemblyScript code, we can complete the import of these functions like so:

// nBodyForces.ts
declare function logI(data: i32): void
declare function logF(data: f64): void

Note: Abort and trace are imported automatically.

From AssemblyScript, we can export our interface. Here are some exported constants:

// src/assembly/nBodyForces.ts

// Gravitational constant. Any G could be used in a game. 
// This value is best for a scientific simulation.
export const G: f64 = 6.674e-11;

// for sizing and indexing arrays
export const bodySize: i32 = 4
export const forceSize: i32 = 3

And here is the export of nBodyForces() which we will call from JavaScript. We export the type Float64Array at the top of the file so we can use AssemblyScript’s JavaScript loader in our web worker to get the data (see below):

// src/assembly/nBodyForces.ts

export const FLOAT64ARRAY_ID = idof<Float64Array>();

...

/**
 * Given N bodies with mass, in a 3d space, calculate the forces of gravity to be applied to each body.
 * 
 * This function is exported to JavaScript, so only takes/returns numbers and arrays.
 * For N bodies, pass and array of 4N values (x,y,z,mass) and expect a 3N array of forces (x,y,z)
 * Those forces can be applied to the bodies mass to update its position in the simulation.
 * Calculate the 3-vector each unique pair of bodies applies to each other.
 * 
 *   0 1 2 3 4 5
 * 0   x x x x x
 * 1     x x x x
 * 2       x x x
 * 3         x x
 * 4           x
 * 5
 * 
 * Sum those forces together into an array of 3-vector x,y,z forces
 * 
 * Return 0 on success
 */
export function nBodyForces(arrBodies: Float64Array): Float64Array {

  // Check inputs

  const numBodies: i32 = arrBodies.length / bodySize
  if (arrBodies.length % bodySize !== 0) trace("INVALID nBodyForces parameter. Chaos ensues...")

  // Create result array. This should be garbage collected later.
  let arrForces: Float64Array = new Float64Array(numBodies * forceSize)

  // For all bodies:

  for (let i: i32 = 0; i < numBodies; i++) {
    // Given body i: pair with every body[j] where j > i
    for (let j: i32 = i + 1; j < numBodies; j++) {
      // Calculate the force the bodies apply to one another
      const bI: i32 = i * bodySize
      const bJ: i32 = j * bodySize

      const f: f64[] = twoBodyForces(
        arrBodies[bI], arrBodies[bI + 1], arrBodies[bI + 2], arrBodies[bI + 3], // x,y,z,m
        arrBodies[bJ], arrBodies[bJ + 1], arrBodies[bJ + 2], arrBodies[bJ + 3], // x,y,z,m
      )

      // Add this pair's force on one another to their total forces applied x,y,z

      const fI: i32 = i * forceSize
      const fJ: i32 = j * forceSize

      // body0
      arrForces[fI] = arrForces[fI] + f[0]
      arrForces[fI + 1] = arrForces[fI + 1] + f[1]
      arrForces[fI + 2] = arrForces[fI + 2] + f[2]

      // body1    
      arrForces[fJ] = arrForces[fJ] - f[0]   // apply forces in opposite direction
      arrForces[fJ + 1] = arrForces[fJ + 1] - f[1]
      arrForces[fJ + 2] = arrForces[fJ + 2] - f[2]
    }
  }
  // For each body, return the sum of forces all other bodies applied to it.
  // If you would like to debug wasm, you can use trace or the log functions 
  // described in workerWasm when we initialized
  // E.g. trace("nBodyForces returns (b0x, b0y, b0z, b1z): ", 4, arrForces[0], arrForces[1], arrForces[2], arrForces[3]) // x,y,z
  return arrForces  // success
}

WebAssembly Artifacts: .wasm and .wat

When our AssemblyScript nBodyForces.ts is compiled into a WebAssembly nBodyForces.wasm binary, there is an option to also create a “text” version describing the instructions in the binary.

WebAssembly Artifacts

Inside the nBodyForces.wat file, we can see these imports and exports:

;; This is a comment in nBodyForces.wat
(module
 ;; compiler defined types
 (type $FUNCSIG$iii (func (param i32 i32) (result i32)))
 …

 ;; Expected imports from JavaScript
 (import "env" "abort" (func $~lib/builtins/abort (param i32 i32 i32 i32)))
 (import "env" "trace" (func $~lib/builtins/trace (param i32 i32 f64 f64 f64 f64 f64)))

 ;; Memory section defining data constants like strings
 (memory $0 1)
 (data (i32.const 8) "\1e\00\00\00\01\00\00\00\01\00\00\00\1e\00\00\00~\00l\00i\00b\00/\00r\00t\00/\00t\00l\00s\00f\00.\00t\00s\00")
 ...

 ;; Our global constants (not yet exported)
 (global $nBodyForces/FLOAT64ARRAY_ID i32 (i32.const 3))
 (global $nBodyForces/G f64 (f64.const 6.674e-11))
 (global $nBodyForces/bodySize i32 (i32.const 4))
 (global $nBodyForces/forceSize i32 (i32.const 3))
 ...

 ;; Memory management functions we’ll use in a minute
 (export "memory" (memory $0))
 (export "__alloc" (func $~lib/rt/tlsf/__alloc))
 (export "__retain" (func $~lib/rt/pure/__retain))
 (export "__release" (func $~lib/rt/pure/__release))
 (export "__collect" (func $~lib/rt/pure/__collect))
 (export "__rtti_base" (global $~lib/rt/__rtti_base))

 ;; Finally our exported constants and function
 (export "FLOAT64ARRAY_ID" (global $nBodyForces/FLOAT64ARRAY_ID))
 (export "G" (global $nBodyForces/G))
 (export "bodySize" (global $nBodyForces/bodySize))
 (export "forceSize" (global $nBodyForces/forceSize))
 (export "nBodyForces" (func $nBodyForces/nBodyForces))

 ;; Implementation details
 ...

We now have our nBodyForces.wasm binary and a web worker to run it. Get ready for blastoff! And some memory management!

To complete the integration, we have to pass a variable array of floats to WebAssembly and return a variable array of floats to JavaScript.

With naive JavaScript bourgeois, I set out to wantonly pass these gaudy variable-sized arrays in and out of a cross-platform high-performance runtime. Passing data to/from WebAssembly was, by far, the most unexpected difficulty in this project.

However, with many thanks for the heavy lifting done by the AssemblyScript team, we can use their “loader” to help:

// workerWasm.js - our web worker
/**
 * AssemblyScript loader adds helpers for moving data to/from AssemblyScript.
 * Highly recommended
 */
const loader = require("assemblyscript/lib/loader")

The require() means we need to use a module bundler like Rollup or Webpack. For this project, I chose Rollup for its simplicity and flexibility and never looked back.

Remember our web worker runs in a separate thread and is essentially an onmessage() function with a switch() statement.

loader creates our wasm module with some extra handy memory management functions. __retain() and __release() manage garbage collection references in the worker runtime __allocArray() copies our parameter array into the wasm module’s memory __getFloat64Array() copies the result array from the wasm module into the worker runtime

We can now marshal float arrays in and out of nBodyForces() and complete our simulation:

// workerWasm.js
/**
 * Web workers listen for messages from the main 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.
      wasm = loader.instantiate(msg.wasmModule, importObj)  // Throws
      // Tell nBodySimulation.js we are ready
      this.postMessage({ purpose: 'wasmReady' })
      return 


    // Message: Given array of floats describing a system of bodies (x,y,x,mass), 
    // calculate the Grav forces to be applied to each body

    case 'nBodyForces':
      if (!wasm) throw new Error('wasm not initialized')

      // Copy msg.arrBodies array into the wasm instance, increase GC count
      const dataRef = wasm.__retain(wasm.__allocArray(wasm.FLOAT64ARRAY_ID, msg.arrBodies));
      // Do the calculations in this thread synchronously
      const resultRef = wasm.nBodyForces(dataRef);
      // Copy result array from the wasm instance to our javascript runtime
      const arrForces = wasm.__getFloat64Array(resultRef);

      // Decrease the GC count on dataRef from __retain() here, 
      // and GC count from new Float64Array in wasm module
      wasm.__release(dataRef);
      wasm.__release(resultRef);
      
      // Message results back to main thread.
      // see nBodySimulation.js this.worker.onmessage
      return this.postMessage({
        purpose: 'nBodyForces', 
        arrForces
      })
  }
}

With all we’ve learned, let’s review our web worker and WebAssembly journey. Welcome to the new browser-backend of the web. These are links to the code on GitHub:

  1. GET Index.html
  2. main.js
  3. nBodySimulator.js - passes a message to its web worker
  4. workerWasm.js - calls the WebAssembly function
  5. nBodyForces.ts - calculates and returns an array of forces
  6. workerWasm.js - passes the results back to the main thread
  7. nBodySimulator.js - resolves the promise for forces
  8. nBodySimulator.js - then applies the forces to the bodies and tells the visualizers to paint

From here, let’s start the show by creating nBodyVisualizer.js! Our next post creates a visualizer using Canvas API, and the final post wraps up with WebVR and Aframe.

Understanding the basics

  • Can WebAssembly replace JavaScript?

    WebAssembly isn’t a language, so it cannot replace JavaScript. Also, developing features and user experience in WebAssembly is less efficient.

  • Why is WebAssembly faster?

    WebAssembly is faster because it does less and was designed for performance instead of developer usability.

  • Can JavaScript be compiled to WebAssembly?

    Yes, AssemblyScript compiles to WebAssembly and feels like Typescript.

Hire a Toptal expert on this topic.
Hire Now
Michael Cole

Michael Cole

Verified Expert in Engineering

Dallas, United States

Member since September 10, 2014

About the author

Michael is an expert full-stack web engineer, speaker, and consultant with over two decades of experience and a degree in computer science.

Read More
authors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.

PREVIOUSLY AT

Ernst & Young

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

Join the Toptal® community.