How Box3D Uses SSE2/NEON SIMD to Optimize the Contact Solver Inner Loops

Box3D accelerates its contact solver by packing four rigid bodies into SIMD registers—using __m128 for SSE2 on x86 and float32x4_t for NEON on ARM—and solving constraints in parallel, reducing floating-point operations by up to 75% while keeping the solver code portable through abstraction types defined in src/simd.h.

The erincatto/box3d physics engine achieves high-performance constraint resolution by leveraging SSE2/NEON SIMD to optimize the contact solver inner loops. Instead of processing bodies one at a time, the solver vectorizes operations across four bodies simultaneously, minimizing memory bandwidth and maximizing instruction throughput on modern CPUs without relying on hand-written assembly.

SIMD Type Abstractions in src/simd.h

Box3D hides platform-specific intrinsics behind a set of wide types defined in src/simd.h and selected at compile time in src/simd.c via the B3_SIMD_SSE2 and B3_SIMD_NEON macros:

  • b3FloatW: Represents four floats packed into one SIMD register. Maps to __m128 on SSE2/x86 and float32x4_t on NEON/ARM.
  • b3Vec3W: Contains three b3FloatW lanes representing the x, y, and z components of a 3D vector for four bodies.
  • b3QuatW: Stores quaternion components (x, y, z, w) as four b3FloatW values.

These abstractions allow the contact solver to perform four velocity updates or four impulse calculations with a single instruction.

Gathering Body State with b3GatherBodies

Before entering the constraint resolution loop, the solver calls b3GatherBodies in src/contact_solver.c (around lines 1020–1050) to pack the state of four bodies into SIMD registers. This function loads linear velocity, angular velocity, position delta, and orientation data into b3BodyStateW structures:

#if defined( B3_SIMD_SSE2 ) || defined( B3_SIMD_NEON )
static b3BodyStateW b3GatherBodies( const b3BodyState* states, int* indices )
{
    /* … load each body or a dummy placeholder … */
    b3BodyStateW s;
    s.v.X = b3SetW( b1.linearVelocity.x, b2.linearVelocity.x,
                   b3.linearVelocity.x, b4.linearVelocity.x );
    s.v.Y = b3SetW( b1.linearVelocity.y, b2.linearVelocity.y,
                   b3.linearVelocity.y, b4.linearVelocity.y );
    s.v.Z = b3SetW( b1.linearVelocity.z, b2.linearVelocity.z,
                   b3.linearVelocity.z, b4.linearVelocity.z );
    /* … same for angular velocity, position delta, and quaternion … */
    return s;
}

The b3SetW helper packs four scalar floats into a single SIMD lane. On SSE2, this uses _mm_set_ps, while NEON uses vld1q_f32.

SSE2 Implementation Details

For x86 architectures, src/simd.h implements b3SetW using Intel intrinsics that store values in reverse order to match register layout:

static inline b3FloatW b3SetW( float a, float b, float c, float d )
{
    return _mm_set_ps( d, c, b, a );   // 4-wide SIMD lane
}

NEON Implementation Details

For ARM architectures, the same function uses ARM NEON intrinsics to load from a temporary array:

static inline b3FloatW b3SetW( float a, float b, float c, float d )
{
    float32_t array[4] = { a, b, c, d };
    return vld1q_f32( array );
}

Vectorized Constraint Solving

Once body data is packed into b3Vec3W structures, the contact solver performs impulse resolution using horizontal operations that process four constraints simultaneously. The helper functions b3AddW and b3MulW map directly to hardware intrinsics:

  • SSE2: _mm_add_ps and _mm_mul_ps
  • NEON: vaddq_f32 and vmulq_f32

Because the inner loop updates four bodies per iteration, the total number of scalar floating-point operations drops by a factor of four. Memory traffic is also reduced, as four velocity components load or store in a single instruction.

Scattering Results with b3ScatterBodies

After completing the SIMD iterations, b3ScatterBodies writes the updated velocities back to the original body structures. This function extracts individual lanes from the SIMD registers by casting the __m128 or float32x4_t to float arrays:

static void b3ScatterBodies( b3BodyState* states, int* indices,
                             const b3BodyStateW* simdBody )
{
    const float* vx = (const float*)&simdBody->v.X;
    const float* vy = (const float*)&simdBody->v.Y;
    const float* vz = (const float*)&simdBody->v.Z;
    const float* wx = (const float*)&simdBody->w.X;
    const float* wy = (const float*)&simdBody->w.Y;
    const float* wz = (const float*)&simdBody->w.Z;

    if ( indices[0] != 0 && (states[indices[0]-1].flags & b3_dynamicFlag) )
    {
        b3BodyState* s = states + (indices[0]-1);
        b3Vec3 v = { vx[0], vy[0], vz[0] };
        b3Vec3 w = { wx[0], wy[0], wz[0] };
        /* … apply lock flags … */
        s->linearVelocity = v;
        s->angularVelocity = w;
    }
    /* … repeat for indices[1] … indices[3] … */
}

This scatter pattern ensures the compiler generates optimal movss instructions on x86 or vld1.32 on ARM for extracting individual elements.

Complete SIMD Workflow Example

The following example demonstrates the gather-compute-scatter pattern used by the contact solver. Compile with -DB3_SIMD_SSE2 -msse2 for x86 or -DB3_SIMD_NEON -mfpu=neon for ARM:

/* tiny_simd_demo.c – shows how to pack four floats, add a constant, and unpack */
#define B3_SIMD_SSE2          // comment this line and enable B3_SIMD_NEON for ARM

#include "src/simd.h"
#include <stdio.h>

int main(void)
{
    /* 4 body linear‑velocity X components */
    float a = 1.0f, b = 2.0f, c = 3.0f, d = 4.0f;

    /* Pack them into a SIMD lane */
    b3FloatW v = b3SetW( a, b, c, d );

    /* Add the same impulse to every body */
    b3FloatW impulse = b3SplatW( 0.5f );          // 0.5,0.5,0.5,0.5
    b3FloatW vNew = b3AddW( v, impulse );

    /* Unpack */
    const float *p = (const float*)&vNew;
    printf("Updated velocities: %.2f %.2f %.2f %.2f\n",
           p[0], p[1], p[2], p[3]);
    return 0;
}

Summary

Box3D achieves high-performance contact solving through strategic SSE2/NEON SIMD optimization:

  • Type Abstraction: b3FloatW, b3Vec3W, and b3QuatW in src/simd.h provide a portable interface over __m128 and float32x4_t.
  • Data Packing: b3GatherBodies loads four bodies into SIMD registers using b3SetW, reducing memory latency.
  • Parallel Processing: Constraint iterations use b3AddW and b3MulW to process four impulses simultaneously via _mm_add_ps or vaddq_f32.
  • Data Unpacking: b3ScatterBodies extracts results efficiently, writing back to body states only when dynamic flags permit.
  • Zero Assembly: The implementation relies entirely on compiler intrinsics selected at compile time via B3_SIMD_SSE2 and B3_SIMD_NEON macros.

Frequently Asked Questions

What is the difference between b3FloatW and b3Vec3W in Box3D?

b3FloatW represents a single SIMD register containing four floating-point values (one per body), while b3Vec3W aggregates three b3FloatW instances to represent the x, y, and z components of a 3D vector for four bodies simultaneously. This allows the contact solver to perform vector math on four bodies at once using standard SIMD lanes.

How does Box3D handle SIMD on platforms without SSE2 or NEON?

When neither B3_SIMD_SSE2 nor B3_SIMD_NEON is defined, Box3D falls back to scalar implementations where b3FloatW is simply a struct of four floats and operations are performed element-wise in C. This ensures the physics engine runs correctly on all architectures while optimizing for SIMD where available.

Why does Box3D process exactly four bodies at a time in the contact solver?

SSE2 and NEON registers are 128 bits wide, which accommodates exactly four 32-bit floats (4 × 32 = 128). Processing four bodies simultaneously maximizes register utilization and provides a theoretical 4× speedup in the inner loops, while keeping the code complexity manageable compared to wider SIMD variants like AVX-512.

Does using SIMD intrinsics affect the determinism of the physics simulation?

No, Box3D maintains deterministic behavior across platforms because the SIMD abstraction layers—b3AddW, b3MulW, and b3SetW—produce identical mathematical results to their scalar counterparts, just computed in parallel. The constraint solver follows the same iteration count and clamping logic regardless of whether SSE2, NEON, or scalar code paths execute.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →