onedrop_renderer/
noise.rs

1//! Procedural noise textures for the noise texture pack.
2//!
3//! MilkDrop 2 ships every preset with a fixed set of noise textures sampled by
4//! user comp shaders. The names and resolutions follow Geiss's reference
5//! implementation (mirrored in projectM's `PerlinNoise.cpp`):
6//!
7//! | Name             | Shape       | Channels | Used by                    |
8//! | ---------------- | ----------- | -------- | -------------------------- |
9//! | `noise_lq`       | 256 × 256   | RGBA8    | 74 / 168 in-the-wild comps |
10//! | `noise_mq`       | 64 × 64     | RGBA8    | 1 / 168                    |
11//! | `noise_hq`       | 32 × 32     | RGBA8    | 42 / 168                   |
12//! | `noisevol_lq`    | 32 × 32 × 32 | RGBA8   | 4 / 168                    |
13//! | `noisevol_hq`    | 8 × 8 × 8   | RGBA8    | 77 / 168                   |
14//!
15//! Reference MilkDrop generates each byte with `rand() & 0xFF` and lets the
16//! sampler's bilinear/trilinear filtering produce smooth pseudo-noise.
17//! Matching that exactly fixes the visual style every authored preset was
18//! tuned against, so we mirror it: independent xorshift32 stream per
19//! texture, seeded deterministically so reload paths produce bit-identical
20//! frames.
21//!
22//! The textures are generated once at engine init; the data lives in CPU
23//! memory long enough to upload to the GPU and is then dropped.
24
25/// Tiny deterministic PRNG. Replaces `rand()` from MilkDrop's reference
26/// without taking a workspace-wide dependency for a few hundred KB of init
27/// data. Mixed with the texture name's first byte so the three 2D and two
28/// 3D textures don't share a stream.
29struct XorShift32(u32);
30
31impl XorShift32 {
32    fn new(seed: u32) -> Self {
33        // xorshift32 is degenerate at 0 — bias away from it.
34        Self(seed.max(1))
35    }
36    fn next_u32(&mut self) -> u32 {
37        let mut x = self.0;
38        x ^= x << 13;
39        x ^= x >> 17;
40        x ^= x << 5;
41        self.0 = x;
42        x
43    }
44    fn next_u8(&mut self) -> u8 {
45        (self.next_u32() & 0xFF) as u8
46    }
47}
48
49/// Dimensions + byte payload for one noise texture. RGBA8 layout in all
50/// cases: 4 bytes per texel, tightly packed in row-major (and slice-major
51/// for the 3D textures), no padding.
52#[derive(Debug, Clone)]
53pub struct NoiseTexture {
54    pub width: u32,
55    pub height: u32,
56    pub depth: u32,
57    pub bytes: Vec<u8>,
58}
59
60impl NoiseTexture {
61    /// `vec4<f32>(w, h, 1/w, 1/h)` for the `texsize_<name>` uniform that real
62    /// presets reference. For 3D textures the depth axis is exposed too,
63    /// but every authored shader treats `texsize_noisevol_*` as a vec4 and
64    /// only reads `.xy` / `.zw`, so we match the MD2 convention.
65    pub fn texsize(&self) -> [f32; 4] {
66        let w = self.width as f32;
67        let h = self.height as f32;
68        [w, h, 1.0 / w, 1.0 / h]
69    }
70}
71
72/// Full noise pack used by the comp pass. Names match MD2 sampler conventions
73/// (`sampler_noise_lq`, `sampler_noisevol_hq`, …) without the `sampler_`
74/// prefix.
75pub struct NoisePack {
76    pub noise_lq: NoiseTexture,
77    pub noise_mq: NoiseTexture,
78    pub noise_hq: NoiseTexture,
79    pub noisevol_lq: NoiseTexture,
80    pub noisevol_hq: NoiseTexture,
81}
82
83impl NoisePack {
84    /// Generate the standard MD2 noise pack with a fixed seed. Every call
85    /// produces the same bytes — letting tests assert byte-level invariants
86    /// and the visual output stay stable across runs / reloads.
87    pub fn generate() -> Self {
88        Self {
89            noise_lq: gen_2d(256, 256, 0x4D2B_157Bu32),
90            noise_mq: gen_2d(64, 64, 0x9E37_79B9),
91            noise_hq: gen_2d(32, 32, 0xC2B2_AE35),
92            noisevol_lq: gen_3d(32, 32, 32, 0x5851_F42D),
93            noisevol_hq: gen_3d(8, 8, 8, 0xB7E1_5163),
94        }
95    }
96}
97
98fn gen_2d(w: u32, h: u32, seed: u32) -> NoiseTexture {
99    let mut rng = XorShift32::new(seed);
100    let mut bytes = Vec::with_capacity((w * h * 4) as usize);
101    for _ in 0..(w * h * 4) {
102        bytes.push(rng.next_u8());
103    }
104    NoiseTexture {
105        width: w,
106        height: h,
107        depth: 1,
108        bytes,
109    }
110}
111
112fn gen_3d(w: u32, h: u32, d: u32, seed: u32) -> NoiseTexture {
113    let mut rng = XorShift32::new(seed);
114    let mut bytes = Vec::with_capacity((w * h * d * 4) as usize);
115    for _ in 0..(w * h * d * 4) {
116        bytes.push(rng.next_u8());
117    }
118    NoiseTexture {
119        width: w,
120        height: h,
121        depth: d,
122        bytes,
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    /// Byte counts match the documented dimensions — guards against an
131    /// off-by-one in the generator (e.g. forgetting to multiply by 4 for
132    /// RGBA, or by `depth` for the 3D textures).
133    #[test]
134    fn byte_lengths_match_dimensions() {
135        let p = NoisePack::generate();
136        assert_eq!(p.noise_lq.bytes.len(), 256 * 256 * 4);
137        assert_eq!(p.noise_mq.bytes.len(), 64 * 64 * 4);
138        assert_eq!(p.noise_hq.bytes.len(), 32 * 32 * 4);
139        assert_eq!(p.noisevol_lq.bytes.len(), 32 * 32 * 32 * 4);
140        assert_eq!(p.noisevol_hq.bytes.len(), 8 * 8 * 8 * 4);
141    }
142
143    /// Two independent generations produce bit-identical output. Required
144    /// for the determinism contract (re-loading a preset must not visibly
145    /// change the noise).
146    #[test]
147    fn generation_is_deterministic() {
148        let a = NoisePack::generate();
149        let b = NoisePack::generate();
150        assert_eq!(a.noise_lq.bytes, b.noise_lq.bytes);
151        assert_eq!(a.noisevol_hq.bytes, b.noisevol_hq.bytes);
152    }
153
154    /// Each texture must use its own seed — if they accidentally share a
155    /// stream the comp shader's mixing of `tex2D(noise_lq, …)` with
156    /// `tex3D(noisevol_hq, …)` collapses to a single source of variance.
157    /// Checking that the first 16 bytes diverge is a cheap but specific
158    /// sanity test.
159    #[test]
160    fn textures_have_independent_streams() {
161        let p = NoisePack::generate();
162        assert_ne!(&p.noise_lq.bytes[..16], &p.noise_mq.bytes[..16]);
163        assert_ne!(&p.noise_lq.bytes[..16], &p.noise_hq.bytes[..16]);
164        assert_ne!(&p.noisevol_lq.bytes[..16], &p.noisevol_hq.bytes[..16]);
165        assert_ne!(&p.noise_lq.bytes[..16], &p.noisevol_lq.bytes[..16]);
166    }
167
168    /// Output should look like noise: roughly uniform across the 0..=255
169    /// range. We don't need a strict statistical test — a coarse histogram
170    /// check is enough to catch bugs where a bad PRNG seed sticks at a
171    /// constant or a small subrange.
172    #[test]
173    fn output_spans_full_byte_range() {
174        let p = NoisePack::generate();
175        let mut buckets = [0u32; 4];
176        for &b in &p.noise_lq.bytes {
177            buckets[(b / 64) as usize] += 1;
178        }
179        for (i, &count) in buckets.iter().enumerate() {
180            assert!(
181                count > 1000,
182                "bucket {i} only got {count} samples out of {} — distribution looks broken",
183                p.noise_lq.bytes.len()
184            );
185        }
186    }
187
188    /// `texsize` matches MD2's `vec4(w, h, 1/w, 1/h)` convention exactly —
189    /// real presets read all four components and visual output diverges if
190    /// the reciprocals are wrong.
191    #[test]
192    fn texsize_layout_matches_md2_convention() {
193        let t = NoiseTexture {
194            width: 256,
195            height: 128,
196            depth: 1,
197            bytes: vec![],
198        };
199        let v = t.texsize();
200        assert_eq!(v[0], 256.0);
201        assert_eq!(v[1], 128.0);
202        assert!((v[2] - 1.0 / 256.0).abs() < 1e-9);
203        assert!((v[3] - 1.0 / 128.0).abs() < 1e-9);
204    }
205}