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}