Efficiently Spawn and Destroy Projectiles in Ammo.js

Handling thousands of projectiles in ammo.js (the WebAssembly port of the Bullet physics engine) requires careful memory and CPU management to prevent garbage collection spikes and WebAssembly heap exhaustion. This article details the most memory-efficient strategy for managing high-volume projectiles: combining object pooling with manual Emscripten memory management and rigid body recycling.

The Pitfall of Dynamic Spawning and Destroying

In JavaScript, developers often instantiate new objects and rely on the garbage collector to clean them up. In ammo.js, this approach is disastrous. Ammo.js objects are wrappers around WebAssembly (C++) memory. Simply dereferencing an Ammo object in JavaScript will not free the underlying C++ memory, leading to rapid memory leaks.

Conversely, repeatedly calling Ammo.destroy() and instantiating new Ammo.btRigidBody objects during gameplay fragments the WebAssembly heap and incurs severe CPU overhead.

The Solution: Object Pooling and Recycling

To achieve maximum efficiency, you must instantiate a fixed maximum number of projectile physics bodies during initialization and recycle them. Instead of spawning and destroying, you activate and deactivate projectiles.

Here is the step-by-step implementation of an efficient projectile pool.

1. Pre-allocate the Pool

Create a pool of rigid bodies at application startup. For projectiles, use simple collision shapes like btSphereShape or btBoxShape rather than complex meshes. Sharing a single shape instance across all projectiles saves significant memory.

const projectilePool = [];
const activeProjectiles = new Set();
const maxProjectiles = 1000;

// Share a single shape instance to save memory
const projectileShape = new Ammo.btSphereShape(0.1); 

function initProjectilePool(dynamicsWorld) {
    const localInertia = new Ammo.btVector3(0, 0, 0);
    projectileShape.calculateLocalInertia(0.1, localInertia);

    for (let i = 0; i < maxProjectiles; i++) {
        const startTransform = new Ammo.btTransform();
        startTransform.setIdentity();

        const motionState = new Ammo.btDefaultMotionState(startTransform);
        const rbInfo = new Ammo.btRigidBodyConstructionInfo(
            0.1, // mass
            motionState,
            projectileShape,
            localInertia
        );

        const body = new Ammo.btRigidBody(rbInfo);
        
        // Prevent body from falling asleep permanently
        body.setActivationState(4); // DISABLE_DEACTIVATION

        // Store references to clean up easily later
        projectilePool.push({
            body: body,
            motionState: motionState,
            transform: startTransform
        });

        // Clean up construction info immediately
        Ammo.destroy(rbInfo);
    }
    Ammo.destroy(localInertia);
}

2. Spawning (Activating) a Projectile

When a weapon fires, retrieve a rigid body from the inactive pool, reset its physical state, and add it to the physics world.

// Reusable vectors to avoid allocation during gameplay
const tempPosition = new Ammo.btVector3();
const tempVelocity = new Ammo.btVector3();
const zeroVector = new Ammo.btVector3(0, 0, 0);

function spawnProjectile(dynamicsWorld, positionArray, directionArray, speed) {
    if (projectilePool.length === 0) {
        // Pool exhausted, ignore or recycle the oldest active projectile
        return;
    }

    const projectile = projectilePool.pop();
    const body = projectile.body;

    // Reset transform (Position & Rotation)
    const trans = body.getWorldTransform();
    trans.setIdentity();
    tempPosition.setValue(positionArray[0], positionArray[1], positionArray[2]);
    trans.setOrigin(tempPosition);
    body.setWorldTransform(trans);
    projectile.motionState.setWorldTransform(trans);

    // Clear forces and set new velocity
    body.clearForces();
    body.setLinearVelocity(zeroVector);
    body.setAngularVelocity(zeroVector);

    tempVelocity.setValue(
        directionArray[0] * speed, 
        directionArray[1] * speed, 
        directionArray[2] * speed
    );
    body.setLinearVelocity(tempVelocity);

    // Add back to the simulation
    dynamicsWorld.addRigidBody(body);
    activeProjectiles.add(projectile);
}

3. Destroying (Deactivating) a Projectile

When a projectile hits a target or its lifetime expires, remove it from the physics world and return it to the pool. Do not destroy the WebAssembly object.

function despawnProjectile(dynamicsWorld, projectile) {
    const body = projectile.body;

    // Remove from simulation
    dynamicsWorld.removeRigidBody(body);

    // Remove from active tracking and return to pool
    activeProjectiles.delete(projectile);
    projectilePool.push(projectile);
}

Optimizing the Simulation Loop

To further reduce memory and CPU overhead when managing thousands of active projectiles, implement these three optimizations:

  1. Broadphase Collision Filtering: Projectiles rarely need to collide with other projectiles. Use collision groups and masks when calling dynamicsWorld.addRigidBody(body, group, mask) to prevent the physics solver from processing projectile-to-projectile collisions.
  2. Raycasting for Fast Projectiles: If your projectiles travel at extreme speeds, continuous collision detection (CCD) can be heavy. Consider swapping rigid bodies entirely for lightweight mathematical raycasts (dynamicsWorld.rayTest()) which consume virtually zero memory.
  3. Avoid Garbage Collection in the Render Loop: Never instantiate new Ammo.btVector3() or new Ammo.btTransform() inside your update loop. Declare them globally as reusable scratchpad variables and write to them using .setValue() or .setIdentity().