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:
- 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. - 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. - Avoid Garbage Collection in the Render Loop: Never
instantiate
new Ammo.btVector3()ornew Ammo.btTransform()inside your update loop. Declare them globally as reusable scratchpad variables and write to them using.setValue()or.setIdentity().