rapier.js icon indicating copy to clipboard operation
rapier.js copied to clipboard

[Draft] Mitigate JavaScript Object Allocation for Performance Gain

Open PoseidonEnergy opened this issue 6 months ago • 1 comments

This is a draft PR!

Completion status:

✅ rigid_body ✅ toi ❌ narrow_phase ❌ character_controller ❌ ray_cast_vehicle_controller ✅ collider ❌ multibody_joint ❌ impulse_joint ❌ contact ❌ ray ❌ point ❌ pid_controller ❌ shape


This PR is a spiritual continuation of #25 , except we are also now refactoring the Rust side of things, so it is possible to allocate 0 JS objects per call as opposed to 1.


Currently, every time rigidBody.translation() is called, 2 objects are created in JavaScript that have to be garbage collected. This goes for rigidBody.rotation() as well and all the other methods that return objects to JavaScript.

Here is the translation() method:

https://github.com/dimforge/rapier.js/blob/4d3b651e0b4a0b16bb3b7a40403dea67e036f128/src.ts/dynamics/rigid_body.ts#L297-L300

  1. The line this.rawSet.rbTranslation(this.handle) creates a new RawVector object.
  2. The line VectorOps.fromRaw(res) creates a new Rapier.Vector3 object by essentially cloning the first RawVector object.

In any 3D world you use Rapier.js with, you will need to call translation() and rotation() for each rigid body on every frame in order to synchronize your rendered objects (e.g. THREE.js meshes) to Rapier.js.

So, in a hypothetical world with 1,000 rigid bodies running at 60fps, this is equivalent to 60,000 calls to translation() and 60,000 calls to rotation() per second. Furthermore, since these methods internally create 2 objects each, this is equivalent to 60,000 * 2 * 2 = 240,000 objects/second that need to be garbage collected, and this is ONLY if you call translation() and rotation() once for each rigid body per frame. If you call the methods multiple times for each rigid body per frame, the object creation can easily reach millions per second.

In this pull request, I have changed the implementation for these methods to give the user the option of providing their own pre-allocated "target" object, into which x/y/z values will be copied. I have also changed the Rust counterparts to these methods to NOT return RawVector objects, but rather to take a scratchBuffer object in the form of a Float32Array so that Rust values can be copied there for JavaScript to read from.

RigidBody.translation() now looks like this:

RigidBody (rigid_body.ts)

/**
 * The world-space translation of this rigid-body.
 *
 * @param {Vector?} target - The object to be populated. If provided,
 * the function returns this object instead of creating a new one.
 */
public translation(target?: Vector): Vector {
    this.rawSet.rbTranslation(this.handle, this.scratchBuffer);
    return VectorOps.fromBuffer(this.scratchBuffer, target);
}

VectorOps (math.ts)

public static fromBuffer(buffer: Float32Array, target?: Vector): Vector {
    if (!buffer) return null;

    target ??= VectorOps.zeros();
    target.x = buffer[0];
    target.y = buffer[1];
    target.z = buffer[2];
    return target;
}

RawRigidBodySet (rigid_body.rs)

/// The world-space translation of this rigid-body.
///
/// # Parameters
/// - `scratchBuffer`: The array to be populated.
#[cfg(feature = "dim3")]
pub fn rbTranslation(&self, handle: FlatHandle, scratchBuffer: &js_sys::Float32Array) {
    self.map(handle, |rb| {
        let u = rb.position().translation.vector;
        scratchBuffer.set_index(0, u.x);
        scratchBuffer.set_index(1, u.y);
        scratchBuffer.set_index(2, u.z);
    });
}

The user can utilize this new API like this. In the following example, only one object is allocated, even though we called translation() five times:

const translation = { x: 0, y: 0, z: 0 };
world.step();
console.log(rigidBody.translation(translation));
world.step();
console.log(rigidBody.translation(translation));
world.step();
console.log(rigidBody.translation(translation));
world.step();
console.log(rigidBody.translation(translation));
world.step();
console.log(rigidBody.translation(translation));

Performance Study

Here are the performance gains for 1,000 rigid bodies simulated at 60 FPS with the new API:

  • Chrome v138 - old: 52.8ms, new: 14.1ms (-38.7ms)
  • Firefox Nightly v142.0a1 - old: 173ms, new: 7ms (-166ms)

Anecdotally, I have also experienced a great reduction in GC jank in Firefox especially, aside from the FPS gain.

Run the benchmark yourself here: https://jsfiddle.net/qyg058wd

image

PoseidonEnergy avatar Jul 26 '25 18:07 PoseidonEnergy

Just to give an update--I am continuing work on this PR this weekend.

PoseidonEnergy avatar Sep 19 '25 22:09 PoseidonEnergy

@ThierryBerger @sebcrozet Thierry/Seb, I have taken this PR out of draft mode and would like to know if we can merge what I have currently into the master branch. I would like to finish the remaining work in a separate "Phase 2" PR.

You might notice two methods in this PR called rbTranslationOriginal() and rbRotationOriginal(). These are the "original" methods that created 2 JavaScript objects per call. I left these in here for benchmarking purposes, so we can compare them to the new allocation-free methods. We can remove them right before we merge the PR.

Note: the rbTranslationOriginal() and rbRotationOriginal() methods are used in the old/new comparison at the benchmark here: https://jsfiddle.net/qyg058wd

PoseidonEnergy avatar Dec 20 '25 00:12 PoseidonEnergy