Skip to main content

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}