Repository navigation
Theory Simulation Toolkit
src/simulation.rs is the one file of shared,
pure numerical helpers: neighbour queries over 2-D and 3-D points, a seeded random generator with a Gaussian,
hashes, a complex type with a direct and a fast Fourier transform, a fixed-step clock, and the easing
functions. "Nothing here draws or reads the system." This page states each tool as mathematics first, then as
the code does it, with the properties the tests pin and the numbers that go with them. The hashing and seeding
are developed on Determinism, the grid on Spatial hashing, the
epicycle application of the DFT on Fourier epicycles, and the placement helpers on
Stable layout.
The exponential smoother of section 6.3, here as the CPU pipeline uses it (time constant 1.5 s). See Model and rates.
The fixed-step clock of section 5 with step = 0.05 s. Top: ticks (dashes) and frames at 10 and 60 per second;
the saw-tooth is the returned fraction. Bottom: the damped oscillator of the unit test, its tick states, and
the rendered position at 10 and 60 frames per second: all on one curve.
The rule that makes a toolkit necessary is in AGENTS.md: "Shared helpers live in one place each. Don't write a
second copy." A test (no_hand_rolled_hashes_outside_simulation) enforces it for the hash and random constants
by scanning every other source file. It does not (yet) cover the easing helpers; see "Duplicates" in section 8.
Most of this file is older than the computers it runs on. The fast Fourier transform is the clearest case. Cooley and Tukey published the algorithm in Mathematics of Computation in 1965, and it is usually told as the moment the discrete Fourier transform fell from
The Gaussian generator is from 1958, when Box and Muller (Annals of Mathematical Statistics 29(2)) showed that two uniform deviates become two independent normal ones through a logarithm and a rotation. Marsaglia and Bray's polar method (SIAM Review 6, 1964) removes the trigonometry at the price of a random, rejected number of uniform draws, which section 2 gives as the reason the code does not use it. The exponential smoother that smoothing discretises has a military origin. Robert G. Brown, an operations analyst for the US Navy, built a tracking filter of this kind for antisubmarine fire control in 1944; in the early 1950s he applied it to demand for spare parts, because it needed one stored number per item where a moving average needed a window, and presented the inventory work in 1956 (his book on inventory control is dated 1959; sources differ on which counts as the beginning). Charles Holt arrived at exponentially weighted averages independently in a 1957 Office of Naval Research report that was not formally published until 2004.
Fixed-step simulation became a recognised idiom through game programming; Fiedler's "Fix Your Timestep!" (the reference list dates it 2004) is the usual citation, and the constants of FixedStep, a step, a cap on steps per frame and a cap on the gap, are that article's "semi-fixed" safeguards written as parameters. Uniform grids for neighbour queries are standard in collision detection (Ericson 2005); Bentley's k-d tree (1975) is the alternative the spatial-hashing page compares them with. The splitmix64 and xorshift generators, and the FNV hash, have their histories on Determinism.
| tool | what | cost | production callers |
|---|---|---|---|
SpatialHash |
uniform grid, radius query in 2-D | build |
Coop (NEIGHBOURHOOD = 4) |
SpatialHash3 |
the same in 3-D, plus exact |
build |
Murmuration (nearest) |
Rng |
splitmix64; unit, signed, normal
|
one 64-bit add and finaliser per draw | Necropolis, Ptolemy, Coop, Murmuration, Arbor, Cortex, Horizon, model (demo) |
hash_identity, Rng::for_identity
|
seed from pid, start time, salt | three finaliser rounds | same |
XorShift32 |
Marsaglia xorshift, shifts (13, 17, 5) | three shifts and xors | Flow |
hash::* |
legacy bit-exact hashes, FNV-1a | few ops | many views, golden test |
Complex, dft
|
direct DFT, any length |
f64
|
Ptolemy (60 samples) |
fft, inverse_fft
|
radix-2 Cooley-Tukey |
|
none yet |
FixedStep |
whole-step clock with interpolation fraction | Coop (0.05, 10, 0.5), Arbor, Murmuration, Cortex | |
growth, sprout, smoothing, smoothstep
|
easing | a few flops | most views |
fft and inverse_fft (with their helpers require_power_of_two and transform) carry
#[cfg_attr(not(test), expect(dead_code, reason = "no caller until the first view that uses it"))], and
SpatialHash3::neighbours the same with its own reason ("no caller until a view that needs a radius query").
They were built ahead of the views that need them (building block P4,
#20) and are exercised by tests only. SpatialHash3::nearest
and Rng::normal, which carried the marker until Murmuration and Horizon arrived, are in production use.
Rng is splitmix64 (state, += 0x9e3779b97f4a7c15, three-step finaliser). unit() is
(next_u64() >> 40) as f32 / 16_777_216.0, uniform on signed() is
unit() * 2 - 1 in hash_identity(id, salt) and the legacy hash::
functions are on Determinism; here is the one that has a
mathematical content of its own.
Rng::normal is the Box-Muller transform (Box and Muller 1958), in its trigonometric form, discarding the
second value of each pair:
let radius = (-2.0 * (1.0 - self.unit()).ln()).sqrt();
radius * (std::f32::consts::TAU * self.unit()).cos()If
Details that are the code's own:
-
u1 = 1 - unit()lies in$(0,1]$ , so the logarithm never sees zero. Sinceunit()is a multiple of$2^{-24}$ below 1,1 - unit()is exact inf32. -
Exactly two uniform draws per call, and no cached half. The second member of the pair
(
$R\sin\Theta$ ) is thrown away. That wastes half the transform but keeps the generator a singlestateword, and, more importantly, keeps stream positions predictable: a view that draws one Gaussian per bird consumes the same number of 64-bit words whatever else happens. The testrng_normal_leaves_the_uniform_stream_alonechecks that after onenormal()the stream equals the stream after twounit()calls. (Marsaglia's polar method avoids the trigonometry but draws a random, rejected number of uniforms; that would break this property.) -
Truncated tails. The smallest
$U_1$ is$2^{-24}$ , so$|\text{normal}|\le\sqrt{2\cdot24\ln2}=5.77$ . The mass beyond$5.77\sigma$ that is missing is about$8\times10^{-9}$ . For layout noise and flocks that is irrelevant; for a rare-event simulation it would not be. -
Test.
rng_normal_has_zero_mean_and_unit_variance: 100 000 draws fromRng::new(42), mean within 0.02 of 0 and variance within 0.02 of 1 (the standard errors are 0.003 and 0.0045, so the margin is more than four sigma).rng_normal_is_deterministic_per_seedpins determinism.
Complex { re: f32, im: f32 } with Add, Sub, Mul, norm (hypot), conj, from_angle (scale. The transforms use one convention, fixed in the doc comments and in a test:
The forward transform is unscaled and uses the negative exponent; the inverse carries the fft_follows_the_negative_exponent_convention checks exactly these,
The direct sum, f64, only the result is rounded to f32. The angle is formed from
sin_cos in
Split
because
-
Bit reversal:
partner = index.reverse_bits() >> (usize::BITS - length.trailing_zeros()), swapped whenpartner > index. This puts the even/odd recursion's leaves in order. -
Twiddle table of
$N/2$ entries$W_N^{j}$ , computed inf64and rounded once tof32, so the error grows with the$\log_2N$ rounding steps of the butterflies rather than with the angle error of anf32recurrence. -
Stages:
half = 1, 2, 4, ...; a stage spans2 * halfand uses everystride = length / (2 * half)-th table entry; each butterfly readsbuffer[start+offset]andbuffer[start+offset+half] * twiddles[offset*stride].
The length must be a power of two; anything else panics, naming the length
(require_power_of_two, tests fft_panics_on_a_length_that_is_not_a_power_of_two). Length 0 and 1 are the
identity. The accuracy test compares against dft for every power of two up to 1024 with a relative error
bound of
inverse_fft uses the conjugation identity
conjugate, run the forward transform, conjugate and scale by inverse_fft_restores_the_input for lengths 2, 16, 256, 1024.
Application: a closed curve sampled as
The mathematics (expected cost, the choice of cell size, the exactness argument for the shell search, degenerate distributions, the comparison with trees) is on Spatial hashing. The interface, as the code states it:
-
new(cell): non-positive or non-finite sizes fall back to 1. -
rebuild(points): replaces the points, reusing allocations (a test checks the buffers keep their capacity at a steady count). One sort of point indices by(cell key, index); a map from cell key to(start, end). -
neighbours(point, radius, out): indices of stored points at distance strictly less thanradius, in ascending index order; a stored query point is included at distance zero.radiusNaN, zero or negative gives an empty result. When the query box covers more cells than there are points, it scans all points instead. -
SpatialHash3::nearest(point, k, out): the exactknearest as(index, distance)sorted by distance and then index;klarger than the count returns everything; the query point itself is at distance 0 and the caller filters it.
Two implementation details are instructive. The sort key of a (index, distance) candidate is one integer,
u64::from(distance.to_bits()) << 32 | index: the bit pattern of a non-negative f32 orders like its value,
and a NaN (bits above CellHasher (a multiply and a fold of the high half
onto the low) rather than SipHash, which "cost a k-nearest query about as much as the distances it computes".
A simulation advanced by the time since the last frame,
FixedStep::new(step, max_steps, max_gap) holds clock: Option<f32> (last time seen) and accumulator. One call
advance(time, tick) -> f32:
dt = (time - last).min(max_gap) if a last time exists and time >= last, else 0
clock = time
accumulator += dt
steps = 0
while accumulator >= step and steps < max_steps: tick(step); accumulator -= step; steps += 1
accumulator = min(accumulator, step) # drop any backlog beyond one step
return clamp(accumulator / step, 0, 1)
The first call, a repeated time and a time that went back (a rewind) take no steps; after a rewind the
clock resumes from the new time (tested). A paused scene feeds the same stage.time and so takes none.
Let
The tick count depends on the clock time alone, not on how the frames fall: partition the interval into 10 or
60 frames per second and the loop executes the same ticks, in the same order, with the same step. That is the
"same path" property, tested by fixed_steps_follow_the_same_path_at_ten_and_sixty_frames_per_second (a damped
oscillator integrated at 10 and at 60 frames per second, three seconds, FixedStep::new(0.05, 10, 0.5): the
states at every step count that both runs reach are bitwise equal, at least 25 of them) and by Coop's
flock_follows_the_same_path_at_ten_and_sixty_frames_per_second. The test compares states at equal step
counts, not at equal frame times, because f32 accumulation can shift a tick across a frame boundary by one.
The returned previous and position per chicken and draws
previous + (position - previous) * alpha), the drawn state is
which is the value at time
The rendered motion is a function of the clock time alone, delayed by exactly one step, whatever the frame
rate: frames at 10 and 60 per second sample the same curve. That is the bottom panel of the figure. The price
is the delay: 50 ms for Coop's
Let a tick cost wall-clock time
-
max_gapbounds the time a single call counts,dt.min(max_gap): a long stall (a suspended laptop, a debugger breakpoint, a pause) is dropped instead of simulated. Simulation time then lags wall-clock time permanently by the dropped part, which is the right behaviour for a visualization. -
max_stepsbounds the work of one call. Withmax_steps * step >= max_gap(Coop:$10\times0.05=0.5$ ) the second cap is the first cap seen from the other side; with a smallermax_stepsthe call may leave more than a step in the accumulator, and the finalaccumulator.min(step)discards the excess so it cannot pile up (testa_long_stall_runs_at_most_max_steps_and_drops_the_rest: a 10 s stall withmax_gap100 and 10 steps runs 10 ticks, keeps one backlog step, and the next 0.01 s completes it and nothing more).
When the cap is hit, the fraction is exactly 1 (the clamp of the final min). The steady-state condition for
real-time simulation is max_steps * step = 0.5 simulated seconds per frame, five times real
time at 10 frames per second.
render.rs computes a second, simpler policy for all views: FrameClock::step(time, paused) is 0 on the first
frame, while paused and when time <= last; otherwise min(time - last, MAX_DT) with MAX_DT = 0.5. It
differs from FixedStep in two ways: it hands out a variable step, and it uses a strict comparison
(time > last) where FixedStep accepts time >= last (equal times add zero anyway). Use stage.dt for
anything that integrates a smooth, composable flow (the exponential easings below are composable, so they are
safe); use FixedStep for anything that is not. docs/VIEW_CONVENTIONS.md says to choose the step
pub const GROW_SECONDS: f32 = 0.9;
pub fn growth(births, id, time) -> f32 {
let born = births.get(&id).copied().unwrap_or(f32::NEG_INFINITY);
if time < born { return 1.0; }
let t = ((time - born) / GROW_SECONDS).min(1.0);
1.0 - (1.0 - t).powi(3)
}
pub fn sprout(growth: f32) -> f32 { 0.3 + 0.7 * growth }1.0), so headless frames and rewinds show everything grown. sprout(g) maps the
growth to a size scale in (birth, now), computed fresh each frame, which is the form the conventions ask
for ("compute closed-form animation from state and time, do not accumulate it per frame").
ROW_EASE, the lift of a risen ghost),
for Arbor's blossoms and for Horizon's orbit easing, and for Ptolemy's re-sort ease, whose angle integrates the eased rate (see the comment on its epoch).
pub fn smoothing(dt: f32, tau: f32) -> f32 { 1.0 - (-dt / tau).exp() }
// value += smoothing(dt, tau) * (target - value)The continuous system
-
$0\le w<1$ for every$\Delta t\ge0$ , and$w\to1$ as$\Delta t/\tau\to\infty$ : stable for any frame time. -
Composable:
$1-(1-w(\Delta_1))(1-w(\Delta_2))=1-e^{-(\Delta_1+\Delta_2)/\tau}=w(\Delta_1+\Delta_2)$ . Two half-length steps equal one full step when the target is constant; this is the frame-rate independence of an exponential ease, and whatthe_orientation_eases_alike_at_ten_and_sixty_frames_per_secondchecks (agreement within$10^{-3}$ rad after two seconds). - The step response is
$1-e^{-t/\tau}$ : 63.2 percent at$t=\tau$ , 95.0 percent at$3\tau$ ; the corner frequency is$f_c=1/(2\pi\tau)$ (0.106 Hz for$\tau=1.5$ s, 0.53 Hz for 0.3 s, 0.80 Hz for 0.2 s). - For small steps
$w\approx\Delta t/\tau$ . At the default 1 s sample and$\tau=1.5$ s,$w=0.487$ .
Uses in the tree: the collector's CPU smoothing (smoothing(dt, 1.5), with Scene::ease for every process's CPU and memory (smoothing(dt, 0.3) with the frame gap, resetting the table
if the gap is outside smoothing(elapsed, 0.2) in CameraController::approach, which Camera::approach applies to
the centre, rotation and pitch additively and to the zoom as
A negative dt gives a negative weight, which extrapolates away from the target; Scene::ease guards with
dt.max(0.0).
cargo test -p isotop simulation:: # all of the above, including the FFT accuracy sweep
cargo test --release -- --ignored --nocapture timing_nearest_22_of_8000 # nearest k=22 x 8000, fft 1024
./target/release/isotop --demo --view coop --width 960 --renderer cpu --time 30 --output coop.png
python3 docs/figures/simulation-fixedstep.py # regenerate the figure from the repository roottiming_nearest_22_of_8000_flock_points_and_fft_1024 is #[ignore] (timing; run in release) and asserts under
10 ms for the 8 000 queries; it prints fft 1024 in microseconds.
All in src/simulation.rs: growth,
sprout, smoothing, smoothstep, Complex, dft, fft, inverse_fft, transform,
SpatialHash, SpatialHash3 (rebuild, neighbours, nearest, visit_shell, face_distance),
Rng, hash_identity, FixedStep, XorShift32, hash. Users: coop/sim.rs (YardClock, SpatialHash),
murmuration.rs (SpatialHash3, FixedStep, Rng), arbor.rs and cortex.rs (FixedStep),
ptolemy.rs (dft, smoothstep), necropolis.rs (smoothstep), render.rs (FrameClock, growth,
smoothing), model.rs (smoothing, demo Rngs).
Duplicates that contradict the rule. simulation::smoothstep exists, yet the same polynomial is written
out again in tesseract.rs (its own fn smoothstep), cores.rs (two inline u * u * (3.0 - 2.0 * u)),
coop/creatures.rs (a closure ease), horizon/disk.rs (a closure smooth), horizon/lens.rs (the taper in the
plunge brightness) and the demo workload in model.rs; and 1.0 - (-dt / TURN_EASE).exp() is spelled out in
tesseract.rs where smoothing(dt, TURN_EASE) would do, as is Arbor's wind easing
(1.0 - (-STEP / WIND_EASING).exp()). They are numerically identical today, so replacing them changes no pixels;
no test guards it.
-
A fixed-step Flow. Flow clamps
dtto 0.1 s and draws from one sharedXorShift32; moving it ontoFixedStepwith counter-based seeding is the plan in #92. -
A higher-order integrator. The test oscillator is semi-implicit (symplectic) Euler,
$v\leftarrow v+a,h,\ x\leftarrow x+v,h$ . A leapfrog or velocity-Verlet step with the samehwould lethgrow by a factor for the same energy drift. - Real-input FFT. Every use of the transform has real input; a packed real FFT halves the work.
-
Waiting for a signal.
fftandinverse_ffthave no production caller and a test for every power of two up to 1024. Which signal in a process table has a period worth finding is unanswered. - Marsaglia versus Box-Muller. Compare the cost per normal and the stream-alignment property of the polar method with a fixed-consumption rejection (always draw four uniforms).
-
Quintic smoothstep. Replace
$3x^2-2x^3$ by the$C^2$ quintic in one view and compare the onset of motion by eye. -
Extrapolation instead of delay. Dead-reckon with the last tick velocity to draw
$P(T-T_0)$ and remove the one-step delay, at the price of a visible correction at each tick for fast-turning bodies.
- Box, G. E. P. and Muller, M. E. 1958. "A note on the generation of random normal deviates." Annals of Mathematical Statistics 29(2): 610-611.
- Cooley, J. W. and Tukey, J. W. 1965. "An algorithm for the machine calculation of complex Fourier series." Mathematics of Computation 19(90): 297-301.
- Steele, G. L. Jr., Lea, D. and Flood, C. H. 2014. "Fast splittable pseudorandom number generators." OOPSLA 2014.
- Marsaglia, G. 2003. "Xorshift RNGs." Journal of Statistical Software 8(14).
- Fiedler, G. 2004. "Fix Your Timestep!" (Gaffer On Games).
- Ericson, C. 2005. Real-Time Collision Detection. Morgan Kaufmann. (Uniform grids, chapter 7.)
- Bentley, J. L. 1975. "Multidimensional binary search trees used for associative searching." Communications of the ACM 18(9): 509-517.
isotop — maximalism for its own sake. Source: tfpickard/isotop. This wiki is published from wiki/ in the repository; edit it there. This footer is decorative.
Philosophy · Gallery · Glossary
Theory
Overview
Sampling: Linux
Sampling: macOS
Background sampler
Model and rates
Life events
History
Journal
Scene and display list
Camera and projection
Rasterization
GPU and parity
Picking
Terminal graphics
Stable layout
Determinism
Simulation toolkit
Colour
Text safety
Mathematics
Fourier epicycles
Wave equation
Flocking
Spatial hashing
Stellar photometry
Lorentz and Schlegel
Reaction-diffusion
Spiking neurons
Schwarzschild
Views
City · Orbit · Ripple
Flow · Cores · Cells
Strata · Globe · Reef
Coop · Matrix
Ptolemy · Tesseract
Necropolis · Cortex
Horizon · Murmuration
Arbor · HR
Hive · Switchboard
Armorial · Lights
Planned
Mold · Chamber
Anthill · Plumbing
Morph · Ouija
Canyon · Die
Guides
Writing a view
Recipes
Headless renders
Contributing to the wiki