Deterministic Floating-Point Emulation in Games
In lockstep multiplayer games and physics-driven simulations, maintaining cross-architecture determinism across varied hardware platforms like x86 and ARM is notoriously difficult due to microarchitectural differences in IEEE-754 floating-point implementations. This article examines how game developers build deterministic floating-point emulation wrappers, detailing the mathematical pitfalls of hardware floats, the choice between software floating-point and fixed-point math, the implementation of cross-platform transcendental functions, and optimization techniques for keeping network simulations tightly synchronized.
The Problem with Native Floating-Point Hardware
While modern CPUs adhere to the IEEE-754 specification, the standard leaves several implementation details ambiguous or optional:
- Fused Multiply-Add (FMA): Operations like
(a * b) + ccan execute as a single instruction with a single rounding step on modern architectures, or as two distinct instructions with intermediate rounding on older hardware, producing different low-order bits. - Transcendental Functions: Hardware-level
trigonometric and logarithmic implementations (
sin,cos,sqrt) rely on vendor-specific approximations, microcode, or polynomial expansions that vary widely between Intel, AMD, and ARM processors. - Denormals and NaN Payloads: Sign bits and payloads
for
NaN(Not a Number) values are not standardized. Hardware flags like Flush-to-Zero (FTZ) and Denormals-are-Zero (DAZ) behave differently or may not be supported uniformly across mobile and desktop silicon. - Compiler Reordering: Optimizations like
-ffast-mathor aggressive instruction scheduling can violate the associative law (\(a + (b + c) \neq (a + b) + c\)), leading to diverging results across different compiler versions.
Strategy 1: Fixed-Point Arithmetic Wrappers
The most common approach to deterministic simulation is replacing floating-point operations entirely with fixed-point math implemented over integer types.
Developers construct custom types using integer scalars, commonly employing formats like \(Q16.16\) (32-bit total: 16 bits integer, 16 bits fractional) or \(Q48.16\) / \(Q32.32\) (64-bit total):
struct Fixed64 {
int64_t raw_value;
static constexpr int FRACTIONAL_BITS = 32;
static constexpr int64_t ONE = 1LL << FRACTIONAL_BITS;
Fixed64() : raw_value(0) {}
explicit Fixed64(int32_t val) : raw_value(static_cast<int64_t>(val) << FRACTIONAL_BITS) {}
Fixed64 operator+(const Fixed64& rhs) const {
Fixed64 result;
result.raw_value = this->raw_value + rhs.raw_value;
return result;
}
Fixed64 operator*(const Fixed64& rhs) const {
Fixed64 result;
// 128-bit intermediate storage prevents overflow during multiplication
#if defined(__SIZEOF_INT128__)
__int128 temp = static_cast<__int128>(this->raw_value) * static_cast<__int128>(rhs.raw_value);
result.raw_value = static_cast<int64_t>(temp >> FRACTIONAL_BITS);
#else
// Fallback for platforms lacking 128-bit native types
result.raw_value = multiply_split(this->raw_value, rhs.raw_value);
#endif
return result;
}
};Fixed-point math guarantees identical behavior across any architecture because integer addition, subtraction, bit-shifts, and multiplications are strictly specified in hardware logic.
Strategy 2: Pure Software Floating-Point (Soft-Float)
When a wide dynamic range is strictly required (such as in celestial mechanics or high-velocity collisions) and fixed-point scales are too restrictive, developers emulate the IEEE-754 standard entirely in software.
Instead of routing operations to the CPU's Floating-Point Unit (FPU), a soft-float wrapper unpacks the raw sign, exponent, and mantissa fields of a 32-bit or 64-bit integer, executes integer-based math, applies precise rounding rules (typically round-to-nearest, ties-to-even), and packs the fields back into an integer.
Soft-float wrappers enforce uniform behavior by:
- Hardcoding the handling of subnormal numbers without relying on CPU hardware flags.
- Canonicalizing all
NaNresults to a single predefined bit pattern (e.g.,0x7FC00000). - Preventing the generation of architecture-dependent FMA instructions by explicitly handling intermediate precision in integer registers.
Implementing Deterministic Transcendental Functions
Standard C Runtime libraries (math.h) cannot be used
because their implementations vary by platform. Deterministic wrappers
substitute them with explicit mathematical approximations:
- Look-Up Tables (LUTs): For trigonometric functions, developers precompute high-resolution tables (e.g., 4096 entries for a quarter-sine wave) and use integer linear interpolation for intermediate values.
- CORDIC Algorithms: Coordinate Rotation Digital Computer (CORDIC) algorithms compute trigonometric, hyperbolic, and square root operations using only addition, subtraction, table lookups, and bit-shifts.
- Chebyshev Polynomials: Fixed-point or software-float implementations often evaluate mini-max polynomial approximations where the order of operations and rounding modes are completely locked.
Isolating Simulation from Presentation
Because software-emulated floating-point and fixed-point types incur a measurable performance penalty compared to native hardware vector units, developers isolate their execution:
- State Partitioning: The deterministic wrapper is used strictly inside the simulation core: gameplay state, rigid-body physics, collision queries, and character logic.
- Decoupled Rendering: Graphics, animation blending,
audio, and camera tracking consume the simulation state, convert the
fixed/emulated values into native
floatordoublerepresentations, and run at native hardware speeds via standard SIMD (AVX, NEON) pipelines. - Data Marshalling: Explicit cast operators are restricted to output-only boundaries to prevent accidental pollution of the simulation loop with native floating-point types.
Vectorization and SIMD Optimizations
To mitigate the overhead of software wrappers, developers implement
custom SIMD wrappers that target integer vector instructions. Because
integer vector registers (__m128i on x86,
int32x4_t on ARM) execute identically across architectures,
math routines can process four to eight simulated values simultaneously
using integer shifts, masks, and parallel integer multiply operations.
This maintains total bit-for-bit synchronization while recovering the
performance necessary for high-tick-rate simulation engines.