Loading Ammo.js Asynchronously in Web Browsers

This article explains how the 3D physics library ammo.js handles asynchronous loading within modern web browser environments. Because ammo.js is a WebAssembly (Wasm) port of the C++ Bullet Physics engine, it cannot be run synchronously upon script injection; instead, it requires an asynchronous compilation and instantiation process to prevent blocking the browser’s main thread. Below, we explore the mechanics of this initialization process and how to implement it in your projects.

Why Asynchronous Loading is Required

ammo.js is compiled from C++ using Emscripten. The output consists of two parts: a large WebAssembly binary (.wasm file) containing the compiled physics engine code, and a JavaScript wrapper (.js file) that manages memory and provides the API glue.

Browsers restrict the synchronous compilation of WebAssembly modules over a certain size to keep the user interface responsive. Consequently, the browser must fetch, compile, and instantiate the WebAssembly binary asynchronously.

The Promise-Based Initialization Pattern

In modern builds of ammo.js, the library is initialized by calling the global Ammo() function. This function returns a Promise-like object (a “thenable”) that resolves once the WebAssembly module has been fully compiled, memory has been allocated, and the runtime is ready.

The standard pattern for loading and initializing ammo.js is as follows:

// Ensure the ammo.js script has been loaded via a script tag or bundler import
Ammo().then(function(AmmoLib) {
    // The physics engine is now fully loaded and initialized.
    // Use 'AmmoLib' to access Bullet Physics classes.
    const collisionConfiguration = new AmmoLib.btDefaultCollisionConfiguration();
    const dispatcher = new AmmoLib.btCollisionDispatcher(collisionConfiguration);
    const overlappingPairCache = new AmmoLib.btDbvtBroadphase();
    const solver = new AmmoLib.btSequentialImpulseConstraintSolver();
    
    const physicsWorld = new AmmoLib.btDiscreteDynamicsWorld(
        dispatcher, 
        overlappingPairCache, 
        solver, 
        collisionConfiguration
    );
    
    console.log("Ammo.js successfully loaded and physics world initialized.");
});

Using modern async/await syntax, the initialization can be written more cleanly:

async function initPhysics() {
    const AmmoLib = await Ammo();
    const collisionConfiguration = new AmmoLib.btDefaultCollisionConfiguration();
    // Additional physics setup goes here
}

The Legacy Callback Approach

In older builds of ammo.js, Emscripten relied on a global configuration object rather than a returned Promise. In these versions, you pass a configuration object containing an onRuntimeInitialized callback to the Ammo function:

Ammo({
    locateFile: (path) => `/path/to/assets/${path}`, // Optional helper to locate the .wasm file
}).then(function(AmmoLib) {
    // Module is ready
});

If you are using an older build that does not support the Promise API, the onRuntimeInitialized callback is used to detect readiness:

window.Ammo = {
    onRuntimeInitialized: function() {
        // Legacy initialization logic goes here
        console.log("Legacy Ammo.js runtime initialized.");
    }
};

Managing the Wasm Binary Path

When loading ammo.js in complex directory structures, the JavaScript wrapper may not automatically find the accompanying .wasm file. You can guide the asynchronous loader by providing a configuration object to the Ammo initializer with a locateFile function:

Ammo({
    locateFile: function(filename) {
        return '/path/to/wasm/folder/' + filename;
    }
}).then(function(AmmoLib) {
    // Module ready
});

By utilizing these asynchronous loading patterns, ammo.js ensures that the heavy WebAssembly payload is loaded efficiently and safely without disrupting the browser’s rendering performance.