dualis_core/rng.rs
1//! A deterministic pseudo-random generator, and the sampling built on it.
2//!
3//! Every stochastic choice in a simulation has to be reproducible or the results
4//! cannot be compared, so this is a plain seeded generator with no global state
5//! and no entropy source. Two runs of the same scene draw the same numbers, on
6//! every platform and in WebAssembly.
7//!
8//! # Determinism survives parallelism, but only through [`Rng::for_index`]
9//!
10//! A single [`Rng`] threaded through a trace is reproducible only while the draws
11//! happen in one order. Hand that generator to several threads and the order
12//! becomes whatever the scheduler decided this time, so the run stops being
13//! repeatable — the invariant is lost precisely when the simulation gets big
14//! enough to need it.
15//!
16//! [`Rng::for_index`] is the way out. It hashes a `(seed, index)` pair into an
17//! independent stream, statelessly, so ray 10 000 can be drawn before ray 3 and
18//! neither result changes. Seed by whatever identifies the work — pixel, sample,
19//! bounce, cell, particle — and the answer no longer depends on the order the
20//! work happened to be done in.
21
22use glam::DVec3;
23
24use crate::vector::basis_for;
25
26/// Deterministic xorshift64* PRNG. Avoids a dependency and keeps scene
27/// generation identical on every platform (important for WASM + tests).
28///
29/// `Clone` is deliberate: cloning captures the exact stream position, which is
30/// how a speculative draw can be replayed or a substream forked at a known point.
31#[derive(Clone, Debug, PartialEq, Eq, Hash)]
32pub struct Rng(u64);
33
34/// SplitMix64's finaliser: an avalanche mix that decorrelates inputs differing by
35/// a single bit. Used to turn structured indices — a pixel number, a bounce depth
36/// — into seeds that behave like independent ones.
37fn mix64(mut z: u64) -> u64 {
38 z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
39 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
40 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
41 z ^ (z >> 31)
42}
43
44impl Rng {
45 /// A generator from a seed.
46 ///
47 /// Every seed but zero is used as given; xorshift is stuck at zero forever,
48 /// so that one value is replaced.
49 pub fn new(seed: u64) -> Self {
50 Rng(if seed == 0 {
51 0x9E37_79B9_7F4A_7C15
52 } else {
53 seed
54 })
55 }
56
57 /// An independent stream for one piece of work, identified by index.
58 ///
59 /// Stateless and order-free: the stream for index 10 000 is what it is
60 /// whether or not index 3 was ever drawn. That is what lets a trace be
61 /// parallel and bit-reproducible at the same time, and it is why every
62 /// stochastic loop should seed per item rather than share one generator.
63 ///
64 /// Both arguments are mixed, so `(seed, index)` and `(index, seed)` differ and
65 /// adjacent indices do not produce correlated streams.
66 pub fn for_index(seed: u64, index: u64) -> Rng {
67 Rng::new(mix64(seed ^ mix64(index)))
68 }
69
70 /// Fork a child stream and advance this one past it.
71 ///
72 /// For nesting that has no natural index — a recursive bounce that needs its
73 /// own sampling without disturbing the caller's sequence. Where an index
74 /// exists, prefer [`Rng::for_index`]: splitting is still order-dependent.
75 pub fn split(&mut self) -> Rng {
76 let drawn = self.next_u64();
77 Rng::new(mix64(drawn))
78 }
79
80 fn next_u64(&mut self) -> u64 {
81 let mut x = self.0;
82 x ^= x >> 12;
83 x ^= x << 25;
84 x ^= x >> 27;
85 self.0 = x;
86 x.wrapping_mul(0x2545_F491_4F6C_DD1D)
87 }
88
89 /// Uniform in [0, 1).
90 pub fn unit(&mut self) -> f64 {
91 (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
92 }
93
94 /// Uniform in `[lo, hi)`. One draw from the stream, whatever the bounds.
95 pub fn range(&mut self, lo: f64, hi: f64) -> f64 {
96 lo + (hi - lo) * self.unit()
97 }
98
99 /// Uniform point inside a disc of the given radius.
100 pub fn in_disc(&mut self, radius: f64) -> (f64, f64) {
101 let r = radius * self.unit().sqrt();
102 let phi = std::f64::consts::TAU * self.unit();
103 (r * phi.cos(), r * phi.sin())
104 }
105
106 /// Uniform direction on the unit sphere.
107 pub fn on_sphere(&mut self) -> DVec3 {
108 let z = self.range(-1.0, 1.0);
109 let phi = std::f64::consts::TAU * self.unit();
110 let r = (1.0 - z * z).max(0.0).sqrt();
111 DVec3::new(r * phi.cos(), r * phi.sin(), z)
112 }
113
114 /// Uniform direction on the hemisphere about `normal`.
115 pub fn on_hemisphere(&mut self, normal: DVec3) -> DVec3 {
116 let d = self.on_sphere();
117 if d.dot(normal) < 0.0 {
118 -d
119 } else {
120 d
121 }
122 }
123
124 /// Cosine-weighted direction about `normal` — the Lambertian scatter.
125 ///
126 /// A matte surface does not spray light evenly over the hemisphere: it sends
127 /// it in proportion to the cosine of the angle from the normal, which is why
128 /// it looks equally bright from every direction. Sampled by Malley's method
129 /// (a uniform disc lifted onto the hemisphere), so the cosine weight is in the
130 /// distribution and the estimator needs no correction factor.
131 pub fn cosine_hemisphere(&mut self, normal: DVec3) -> DVec3 {
132 let n = normal.normalize();
133 let (x, y) = self.in_disc(1.0);
134 let z = (1.0 - x * x - y * y).max(0.0).sqrt();
135 let (u, v) = basis_for(n);
136 (u * x + v * y + n * z).normalize()
137 }
138
139 /// A standard normal deviate, mean 0 and variance 1.
140 ///
141 /// Read noise, mechanical jitter, thermal fluctuation, Brownian motion: the
142 /// noise in a simulation is Gaussian far more often than it is uniform. Plain
143 /// Box-Muller rather than the polar form, because it draws exactly two numbers
144 /// every time — a rejection loop would make stream consumption depend on the
145 /// values drawn, and that is a needless dependency in something whose whole
146 /// job is being predictable.
147 pub fn gaussian(&mut self) -> f64 {
148 // Guard the log against an exact zero, which `unit` can return.
149 let u1 = self.unit().max(f64::MIN_POSITIVE);
150 let u2 = self.unit();
151 (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
152 }
153
154 /// A normal deviate with the given mean and standard deviation.
155 pub fn normal(&mut self, mean: f64, std_dev: f64) -> f64 {
156 mean + std_dev * self.gaussian()
157 }
158
159 /// A Poisson deviate: the number of independent events that happened, when the
160 /// expected number was `mean`.
161 ///
162 /// The distribution of counting things that arrive at random — photons on a detector,
163 /// decays in a sample, molecules crossing a boundary. Its defining property is that
164 /// **the variance equals the mean**, so the noise on a count of `N` is `√N` and the
165 /// signal-to-noise ratio of counting improves only as the square root of how long you
166 /// count. That is not a limitation of any instrument; it is what counting is.
167 ///
168 /// Two methods, chosen by the mean rather than by the draw, so stream consumption
169 /// stays a function of the inputs:
170 ///
171 /// - Below 30, inverse transform from a *single* uniform. Walking the cumulative
172 /// distribution costs `O(mean)` time and exactly one draw, where the textbook
173 /// product-of-uniforms method would consume a variable number and make the stream
174 /// depend on the values it produced.
175 /// - At 30 and above, a rounded normal. The skew there is 0.18 and the tail error is
176 /// under a percent, which is far below any detector's calibration — and the exact
177 /// method's cost grows with the mean while its benefit does not.
178 pub fn poisson(&mut self, mean: f64) -> u64 {
179 // A NaN mean is neither positive nor negative, so it needs saying separately —
180 // `mean <= 0.0` alone would let it through into the loop below.
181 if mean.is_nan() || mean <= 0.0 {
182 return 0;
183 }
184 if mean < 30.0 {
185 let u = self.unit();
186 // p is P(k) and cumulative is P(X <= k), stepped up together.
187 let mut p = (-mean).exp();
188 let mut cumulative = p;
189 let mut k = 0u64;
190 // A generous cap: at mean 30 the distribution is spent by 100, and this only
191 // guards against a uniform draw arbitrarily close to one.
192 let cap = (mean * 20.0) as u64 + 100;
193 while u > cumulative && k < cap {
194 k += 1;
195 p *= mean / k as f64;
196 cumulative += p;
197 }
198 k
199 } else {
200 let drawn = mean + self.gaussian() * mean.sqrt();
201 drawn.round().max(0.0) as u64
202 }
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 /// The property the whole determinism claim rests on: an indexed stream does
211 /// not care when it was asked for. Drawing the indices backwards gives the
212 /// same numbers as drawing them forwards, so a parallel trace that finishes
213 /// its work in any order still produces one answer.
214 #[test]
215 fn indexed_streams_are_order_free() {
216 let forward: Vec<f64> = (0..64)
217 .map(|i| Rng::for_index(0xD0A1_15EE, i).unit())
218 .collect();
219 let backward: Vec<f64> = (0..64)
220 .rev()
221 .map(|i| Rng::for_index(0xD0A1_15EE, i).unit())
222 .collect();
223 let mut backward_reordered = backward;
224 backward_reordered.reverse();
225 assert_eq!(forward, backward_reordered);
226 }
227
228 /// Adjacent indices must not be correlated: a per-pixel seed of `y * w + x`
229 /// is the common case, and if index i and i+1 shared most of their stream the
230 /// image would show it as structure.
231 #[test]
232 fn adjacent_indices_are_decorrelated() {
233 let draws: Vec<Vec<f64>> = (0..16)
234 .map(|i| {
235 let mut r = Rng::for_index(7, i);
236 (0..8).map(|_| r.unit()).collect()
237 })
238 .collect();
239 for i in 0..draws.len() {
240 for j in (i + 1)..draws.len() {
241 assert_ne!(draws[i], draws[j], "streams {i} and {j} collided");
242 }
243 }
244 // Neighbouring streams should not even agree on their first value to a
245 // few digits, which a weak mixer would allow.
246 for i in 1..draws.len() {
247 assert!(
248 (draws[i][0] - draws[i - 1][0]).abs() > 1e-6,
249 "streams {} and {i} start too close",
250 i - 1
251 );
252 }
253 }
254
255 /// Swapping seed and index gives a different stream — otherwise seeding by
256 /// (frame, pixel) and (pixel, frame) would alias.
257 #[test]
258 fn seed_and_index_are_not_interchangeable() {
259 assert_ne!(
260 Rng::for_index(3, 9).unit(),
261 Rng::for_index(9, 3).unit(),
262 "the pair must be ordered"
263 );
264 }
265
266 /// Zero is the one seed xorshift cannot use — it would emit zero forever.
267 #[test]
268 fn the_degenerate_seed_is_handled() {
269 let mut zero = Rng::new(0);
270 let draws: Vec<f64> = (0..4).map(|_| zero.unit()).collect();
271 assert!(draws.iter().all(|&v| v > 0.0 && v < 1.0), "{draws:?}");
272 assert!(draws[0] != draws[1]);
273 }
274
275 /// A fork diverges from its parent rather than replaying it.
276 #[test]
277 fn a_split_stream_diverges_from_its_parent() {
278 let mut parent = Rng::new(42);
279 let mut child = parent.split();
280 let p: Vec<f64> = (0..8).map(|_| parent.unit()).collect();
281 let c: Vec<f64> = (0..8).map(|_| child.unit()).collect();
282 assert_ne!(p, c);
283 // Cloning captures the position exactly, which is what makes a draw
284 // replayable.
285 let mut a = Rng::new(42);
286 let mut b = a.clone();
287 assert_eq!(a.unit(), b.unit());
288 }
289
290 /// The generator's numbers are fixed, not merely reproducible-in-principle.
291 /// This is the test that would fail if the algorithm, the seeding or the
292 /// [0,1) conversion were ever changed — which is the point, since a changed
293 /// stream silently invalidates every recorded result.
294 #[test]
295 fn the_stream_is_pinned() {
296 let mut r = Rng::new(0x5A17_7E3D);
297 // A cheap order-sensitive digest of 10 000 draws.
298 let mut hash = 0u64;
299 for _ in 0..10_000 {
300 hash = hash.rotate_left(7).wrapping_mul(0x1000_0000_01B3)
301 ^ (r.unit() * (1u64 << 53) as f64) as u64;
302 }
303 assert_eq!(hash, PINNED_DIGEST, "the generator's output has changed");
304 }
305
306 /// Changing this constant is never the fix. If this test fails, the stream
307 /// moved, and every result recorded against the old one is now unreproducible.
308 const PINNED_DIGEST: u64 = 6_777_642_030_472_145_829;
309
310 /// Box-Muller has to actually be normal: zero mean, unit variance, and a
311 /// tail that reaches past three sigma without running away.
312 #[test]
313 fn gaussians_are_standard_normal() {
314 let mut r = Rng::new(1234);
315 const N: usize = 100_000;
316 let draws: Vec<f64> = (0..N).map(|_| r.gaussian()).collect();
317 let mean = draws.iter().sum::<f64>() / N as f64;
318 let variance = draws.iter().map(|d| (d - mean).powi(2)).sum::<f64>() / N as f64;
319 assert!(mean.abs() < 0.02, "mean {mean}");
320 assert!((variance - 1.0).abs() < 0.03, "variance {variance}");
321 // About 0.27% of a normal distribution lies beyond three sigma.
322 let tail = draws.iter().filter(|d| d.abs() > 3.0).count() as f64 / N as f64;
323 assert!((tail - 0.0027).abs() < 0.002, "three-sigma tail {tail}");
324 assert!(draws.iter().all(|d| d.is_finite()));
325 }
326
327 /// A cosine-weighted sample stays in the hemisphere and leans towards the
328 /// normal. The mean of the cosine over a Lambertian distribution is 2/3, which
329 /// is a closed form and therefore worth testing against.
330 #[test]
331 fn cosine_sampling_leans_towards_the_normal() {
332 let mut r = Rng::new(99);
333 let n = DVec3::new(1.0, 2.0, -0.5).normalize();
334 const N: usize = 50_000;
335 let mut cos_sum = 0.0;
336 for _ in 0..N {
337 let d = r.cosine_hemisphere(n);
338 let c = d.dot(n);
339 assert!(c > -1e-9, "sample left the hemisphere: {c}");
340 assert!((d.length() - 1.0).abs() < 1e-9);
341 cos_sum += c;
342 }
343 let mean_cos = cos_sum / N as f64;
344 assert!(
345 (mean_cos - 2.0 / 3.0).abs() < 0.01,
346 "Lambertian mean cosine should be 2/3, got {mean_cos}"
347 );
348 }
349
350 /// The property that defines a Poisson count: its variance equals its mean.
351 ///
352 /// Checked across the crossover between the two methods, because that is where an
353 /// error would hide — either side alone could be right while the join is not.
354 #[test]
355 fn poisson_variance_equals_its_mean() {
356 const N: usize = 200_000;
357 for mean in [0.5f64, 3.0, 12.0, 29.0, 31.0, 200.0, 5000.0] {
358 let mut r = Rng::new(0xC0FFEE);
359 let draws: Vec<f64> = (0..N).map(|_| r.poisson(mean) as f64).collect();
360 let measured_mean = draws.iter().sum::<f64>() / N as f64;
361 let variance = draws
362 .iter()
363 .map(|d| (d - measured_mean).powi(2))
364 .sum::<f64>()
365 / N as f64;
366 assert!(
367 (measured_mean / mean - 1.0).abs() < 0.02,
368 "mean {mean}: got {measured_mean}"
369 );
370 assert!(
371 (variance / mean - 1.0).abs() < 0.05,
372 "mean {mean}: variance {variance} should equal the mean"
373 );
374 }
375 }
376
377 /// At a small mean the exact probabilities are checkable term by term, which the
378 /// variance alone would not catch — a distribution with the right first two moments
379 /// can still be the wrong distribution.
380 #[test]
381 fn a_small_poisson_matches_its_exact_probabilities() {
382 const N: usize = 400_000;
383 let mean = 2.5f64;
384 let mut r = Rng::new(7);
385 let mut counts = [0usize; 12];
386 for _ in 0..N {
387 let k = r.poisson(mean) as usize;
388 if k < counts.len() {
389 counts[k] += 1;
390 }
391 }
392 // P(k) = e^-m m^k / k!
393 let mut factorial = 1.0;
394 for (k, count) in counts.iter().enumerate() {
395 if k > 0 {
396 factorial *= k as f64;
397 }
398 let exact = (-mean).exp() * mean.powi(k as i32) / factorial;
399 let measured = *count as f64 / N as f64;
400 assert!(
401 (measured - exact).abs() < 3e-3,
402 "P({k}): measured {measured:.5}, exact {exact:.5}"
403 );
404 }
405 }
406
407 /// Counting nothing counts nothing, and a nonsensical mean does not panic.
408 #[test]
409 fn a_degenerate_poisson_counts_nothing() {
410 let mut r = Rng::new(1);
411 assert_eq!(r.poisson(0.0), 0);
412 assert_eq!(r.poisson(-5.0), 0);
413 assert_eq!(r.poisson(f64::NAN), 0);
414 // And a large mean stays finite rather than overflowing the cast.
415 assert!(r.poisson(1e12) > 0);
416 }
417
418 /// Uniform hemisphere sampling has mean cosine 1/2, which is how it differs
419 /// from the cosine-weighted one — and getting the two confused is a
420 /// factor-of-4/3 error in every diffuse bounce.
421 #[test]
422 fn uniform_hemisphere_is_not_cosine_weighted() {
423 let mut r = Rng::new(5);
424 let n = DVec3::Z;
425 const N: usize = 50_000;
426 let mean_cos: f64 = (0..N).map(|_| r.on_hemisphere(n).dot(n)).sum::<f64>() / N as f64;
427 assert!((mean_cos - 0.5).abs() < 0.01, "got {mean_cos}");
428 }
429}