Skip to content

N-Body Numerical Stability Fix

Problem Description

Numerical instability was observed in the N-body simulation, leading to energy drift, erratic trajectories, and occasionally NaN values. These issues were particularly severe in release builds and during time-reversal operations.

Technical Causes

1. Floating Point Precision Loss

Physics simulations involving cumulative calculations are highly sensitive to rounding errors. Using float (32-bit) precision led to significant energy drift over long periods, as small errors in force calculations accumulated every frame.

2. Time-Reversal Damping Bug

The radial damping mechanism (confinement zone) used delta_time directly. When time was reversed (delta_time < 0), the damping term became an accelerating force, causing bodies to gain infinite energy and explode out of the simulation volume.

3. Fast-Math Side Effects

Aggressive compiler optimizations (-ffast-math) sacrifice IEEE 754 compliance for speed. This can cause the symplectic properties of the Velocity Verlet integrator to break, leading to non-physical behavior.

Solution

1. Migration to Double Precision

The core physics state and calculations have been migrated to double (64-bit) precision. - Affected fields: Position, velocity, mass, and energy calculations. - Rendering: The GPU still receives float data via VBOs for performance, but the simulation "source of truth" is now high-precision.

2. Symmetrical Damping

The damping logic was updated to use fabs(delta_time), ensuring it remains energy-dissipative regardless of the time direction:

float damping_factor = 1.0F - (sim->damping * fabsf(delta_time));

3. Build-Level Flag Management

Instead of fragile source-code pragmas, we now enforce standard floating-point behavior via CMakeLists.txt on the critical modules:

set_source_files_properties(
    src/nbody/nbody_physics.c
    src/nbody/nbody_diagnostics.c
    src/nbody/nbody_preset.c
    PROPERTIES COMPILE_FLAGS "-fno-fast-math"
)
This ensures the physics core is always compiled with strict IEEE 754 compliance, even if the rest of the application uses aggressive optimizations.

Verification

Long-run tests (1200s) confirm an energy drift of less than 6%, and time-reversal tests show near-perfect reversibility (\(10^{-11}\) error), confirming the robustness of the new implementation.

Energy drift threshold (6%)

The test_nbody_stability long-run test simulates 1200 seconds and measures the relative energy drift \(|E - E_0| / |E_0|\). The measured drift consistently plateaus at ~5.64% across all platforms (Linux, Wine/Windows).

This drift is not a precision error — it is the expected result of the radial confinement damping which intentionally dissipates outward kinetic energy when bodies cross the confinement radius. The damping preserves angular momentum (only the radial velocity component is damped) and conserves linear momentum (impulse transferred to the central star).

The test threshold is set to 6% (previously 5%), giving ~7% headroom above the measured plateau. This is tight enough to catch real regressions while accommodating the design-level energy dissipation.

dvec3.h — Double-Precision Vector Helpers

Added: 2026-05-08

Why not cglm?

cglm (v0.9.x) is the project's math library for OpenGL rendering. However, it only provides float-precision types (vec3, vec4, mat4). There is no dvec3 type and no CGLM_DOUBLE build option — unlike the C++ GLM library which offers glm::dvec3.

Does cglm optimize vec3?

No. SIMD intrinsics (SSE/NEON) in cglm are only used for types whose width matches hardware register lanes:

Type SIMD intrinsics Reason
vec4 54 4 floats = 1×__m128 SSE register
mat4 12 4×4 column-major, 4-wide ops
vec3 0 3 components don't fill a 128-bit lane cleanly

Every glm_vec3_* function (add, sub, dot, copy, scale, …) is a plain scalar loop:

// cglm/vec3.h — actual implementation
glm_vec3_add(vec3 a, vec3 b, vec3 dest) {
    dest[0] = a[0] + b[0];
    dest[1] = a[1] + b[1];
    dest[2] = a[2] + b[2];
}

Design decision

Since glm_vec3_* is pure scalar wrapping with zero SIMD benefit, migrating the n-body simulation from float/vec3 to double[3] with manual component access causes no performance regression.

To keep the code readable and avoid repetitive [0]/[1]/[2] boilerplate, we introduce include/dvec3.h — a minimal header-only library that mirrors the cglm vec3 API for double arrays:

dvec3 function cglm equivalent Description
dvec3_copy glm_vec3_copy Copy 3 components
dvec3_add glm_vec3_add dest = a + b
dvec3_sub glm_vec3_sub dest = a - b
dvec3_scale glm_vec3_scale dest = v × s
dvec3_dot glm_vec3_dot Dot product
dvec3_norm glm_vec3_norm Length
dvec3_normalize glm_vec3_normalize In-place normalize
dvec3_muladds — dest += v × s (Verlet)
dvec3_addto — dest += v (accumulate)
dvec3_subfrom — dest -= v
dvec3_zero glm_vec3_zero Set to {0, 0, 0}

All functions are static inline — zero call overhead, identical generated code to the manual version.

Future considerations

  • Body count scaling: With 14 bodies, double-precision scalar is negligible. If the count grows to hundreds, consider AVX __m256d (4-wide double) SIMD.
  • cglm evolution: If cglm adds dvec3 support in a future release, these helpers can be replaced transparently.