Skip to content

Parad0x84/DeterministicMath

Repository files navigation

Deterministic Math

Cross-platform deterministic fixed point math for UE5 with full BP support. Regular float math gives you slightly different results across compilers, CPUs and optimization settings, which quietly breaks anything that needs determinism.

Use Cases

  • Replay systems
  • Deterministic lockstep/rollback netcode
  • Reproducible procedural generation (seed in, same world out)

Features

  • Configurable
  • Fully constexpr
  • Full BP support (with node and details customization)
  • FDetVec3 mirrors FVector interface, so it's almost a drop-in replacement
  • Entire library tests itself upon compilation, so successful compilation gives you some guarantees (there are also automation tests you can run in-editor)

Quirks

  • Some operations are not completely safe, especially in C++. So don't plug random values into random functions
  • From/To native float is not a deterministic operation, but you can take a float in editor time and convert it to DetFloat and save it (make sure it doesn't happen at runtime)
  • RNG engines are stateful and produce the same sequence from the same seed. Not cryptographically safe
  • Entire library uses one FDetFloat type, so changing the configuration applies everywhere (BP doesn't support templates and usually you don't want more than a single config in the entire project)
  • Trig, exp and log are fast approximations (under ~0.1% error), not bit-exact to <cmath>. They're still fully deterministic
  • Results that run past the type's range saturate to its min/max instead of wrapping (no UB)
  • The Random nodes are BlueprintCallable, not BlueprintPure, on purpose. Each call advances the stream, and a pure node (which the VM can re-run on its own) would silently break determinism

Types

Type What it is
FDetFloat Fixed point scalar (Q18.13 by default, configurable) with a full <cmath> style surface
FDetVec2/3/4 2D/3D/4D vectors: dot/cross, length, normalize, projection, reflection, lerp, angles, distance
FDetSeed Opaque RNG seed built from a value or a name. Derive more independent seeds from it
FDetRandomStream Stateful RNG stream (Xoroshiro128++) for BP. C++ can use the engines in DetRNG.h directly

C++

#include "DetFloat.h"
#include "DetVec.h"
#include "DetRNG.h"

// Scalars act like floats but they're exact and portable.
constexpr FDetFloat A = FDetFloat(3) / FDetFloat(2);   // 1.5
constexpr FDetFloat B = sqrt(A * A + FDetFloat(4));

// Vectors mirror the FVector API.
constexpr FDetVec3 Velocity(FDetFloat(10), FDetFloat(0), FDetFloat(0));
constexpr FDetVec3 Dir = Velocity.GetSafeNormal();

// Seeded, reproducible RNG.
FDetRand::FXoroshiro128PP Rng(FDetSeed("Loot"));
const int32     Effect = Rng.NextI32();
const int32     Roll   = Rng.NextI32(1, 20); // inclusive [1, 20]
const FDetFloat Unit   = Rng.NextDetFloat(FDetFloat(0), FDetFloat(1));

auto Worker = Rng; Worker.Jump(); // independent parallel stream

BP

Everything is exposed to BP, and the nodes add guardrails the raw C++ doesn't: bad input (divide by zero, out-of-range, etc.) returns a safe value and logs a warning instead of crashing.

Det types get custom editors in the details panel and on graph pins, so you edit them as plain decimal numbers even though they're stored as integers. The seed field takes a decimal, a hex value, or any text (text is hashed into a seed), and it always reads back as hex.

Det types in the details panel

Det math nodes on a graph

RNG starts from a seed. The usual setup is one master seed per session, then Derive a separate seed for each system (loot, spawns, and so on) so they stay independent but reproducible. To spread work across threads, make one stream and Jump copies of it, each jumped copy is a separate non-overlapping stream. Generate Seed (FDetSeed::Generate in C++) makes a fresh unpredictable seed when you want one (it's random, not cryptographically safe). Save that seed and you can replay the whole run from it.

Random stream graph

Configuration

The whole library runs on one fixed point type, set at the top of DetFloat.h (FPM::BaseType, FPM::IntermediateType, FPM::FractionBits).

FractionBits is the only setting you'll usually change. The "Q18.13" name means 18 bits for the whole-number part and 13 for the fraction, in a signed 32 bit int; more fraction bits give a finer epsilon and less range, fewer give the opposite. The default Q18.13 is roughly +/-262,000 of range with an epsilon around 1.2e-4. At 1 unit = 1 cm that's a +/-2.6 km range resolved to about 1.2 microns. Change it by editing FractionBits, everything else follows from that one number.

Here is every format the tests pass on, using signed int32:

Format Range (units) Epsilon Dot/length limit sin error exp error
Q11.20 +/-2,048 9.5e-7 45 4.0e-4 0.001%
Q12.19 +/-4,096 1.9e-6 63 4.0e-4 0.002%
Q13.18 +/-8,192 3.8e-6 90 4.0e-4 0.004%
Q14.17 +/-16,384 7.6e-6 127 4.0e-4 0.008%
Q15.16 +/-32,768 1.5e-5 181 4.1e-4 0.015%
Q16.15 +/-65,536 3.1e-5 255 4.3e-4 0.03%
Q17.14 +/-131,072 6.1e-5 362 4.4e-4 0.06%
Q18.13 (default) +/-262,144 1.2e-4 511 5.0e-4 0.12%
Q19.12 +/-524,288 2.4e-4 724 6.1e-4 0.24%
Q20.11 +/-1,048,576 4.9e-4 1,023 8.6e-4 0.48%
Q21.10 +/-2,097,152 9.8e-4 1,448 2.7e-3 0.93%

Two columns that aren't self-explanatory:

  • Dot/length limit is the biggest a vector can get before its dot product or squared length overflows. Length, Distance and Normalize keep working past it (they compute the squared part in a wider type), so in practice you only hit this if you take a raw Dot of two large vectors. Normalize first and it never comes up.
  • sin error is the worst case error of sin over a full turn (cos, sqrt and atan look the same). exp error is the worst case error of exp as a percentage (log is similar).

sin, sqrt and atan barely change down the table because they're limited by their approximation (about 0.07%), not by the format. exp and log are the ones that get worse as you cut fraction bits.

Stick to FractionBits between 10 and 20. Lower than 10 and there aren't enough fraction bits for radians, so trig falls apart. Higher than 20 and the whole part gets so small that even an angle of 180 (in degrees) no longer fits. The automation report gives some info about your active format.

The library is generic over BaseType, FractionBits and signedness, so configurations outside the table above (or a different integer type) still compile and run, but the tests are written against the tested formats. On an untested config some of them may need their tolerances adjusted or be removed.

License

MIT, see LICENSE.txt. Parts of it are derived from fpm by Mike Lankamp, also MIT (see FPM_LICENSE.txt). Contributions come in under the same license.