Skip to main content

roxlap_cavegen/
worley.rs

1//! Worley cave-shape classification.
2//!
3//! Algorithm (Tom Dobrowolski's `CaveGen`, simplified):
4//!
5//! 1. Place `seed_count` random seeds uniformly in the voxel
6//!    volume. The first `air_ratio × seed_count` are tagged "air";
7//!    the rest are tagged "solid".
8//! 2. For each voxel `p`:
9//!    - `d_a` = distance to the nearest **air** seed
10//!    - `d_s` = distance to the nearest **solid** seed
11//!    - The voxel is air iff `d_a < d_s`, else solid.
12//! 3. (CD.5.3) Add a Perlin overlay to `d_a` to break up the clean
13//!    Worley facets. Currently disabled (overlay = 0).
14//!
15//! `anisotropy` scales the z component of the distance: `> 1.0`
16//! produces caves that are wider than tall (mineshaft-style); `< 1.0`
17//! gives tall narrow caves; `1.0` is isotropic.
18//!
19//! Determinism: same `seed` + same params + same code → byte-stable
20//! grid (within toolchain — cross-CPU FP might diverge slightly,
21//! same caveat as the rendering tests).
22
23use crate::perlin::PerlinNoise3D;
24use crate::rng::SplitMix64;
25use crate::{CaveParams, MAXZDIM};
26
27/// Wavelength of the Perlin overlay's lowest octave, in voxels.
28/// Tuned so a `seed_count = 128` cave at `vsid = 256` shows
29/// recognisable surface roughness without dissolving the Worley
30/// cell structure.
31const PERLIN_LOWEST_FREQUENCY: f32 = 1.0 / 16.0;
32
33/// Perlin overlay's effect in voxel-distance units when
34/// `perlin_amplitude = 1.0` and the noise sample is at its peak.
35/// The full overlay is `perlin_amplitude * fbm * VOXEL_SCALE` —
36/// `VOXEL_SCALE = 8` means an `amplitude = 0.15` overlay shifts
37/// the air/solid boundary by up to ±1.2 voxels.
38const PERLIN_VOXEL_SCALE: f32 = 8.0;
39
40/// One Worley seed point.
41#[derive(Debug, Clone, Copy)]
42pub struct Seed {
43    /// World coordinates `(x, y, z)`.
44    pub pos: [f32; 3],
45    /// `true` for air-tagged seeds, `false` for solid-tagged.
46    pub is_air: bool,
47}
48
49/// Place `params.seed_count` deterministic random seeds across the
50/// `vsid × vsid × MAXZDIM` volume. The first `params.air_ratio ×
51/// params.seed_count` (rounded) are tagged air; the rest solid.
52#[must_use]
53#[allow(
54    clippy::cast_possible_truncation,
55    clippy::cast_precision_loss,
56    clippy::cast_sign_loss
57)]
58pub fn place_seeds(params: &CaveParams, vsid: u32) -> Vec<Seed> {
59    let mut rng = SplitMix64::new(params.seed);
60    let total = params.seed_count;
61    // air_ratio is clamped to [0.0, 1.0] by the caller; we trust it here.
62    let n_air = ((total as f32) * params.air_ratio).round() as usize;
63    let n_air = n_air.min(total);
64    let xy_max = vsid as f32;
65    let z_max = MAXZDIM as f32;
66    let mut seeds = Vec::with_capacity(total);
67    for i in 0..total {
68        let pos_x = rng.next_f32_unit() * xy_max;
69        let pos_y = rng.next_f32_unit() * xy_max;
70        let pos_z = rng.next_f32_unit() * z_max;
71        seeds.push(Seed {
72            pos: [pos_x, pos_y, pos_z],
73            is_air: i < n_air,
74        });
75    }
76    seeds
77}
78
79/// Classify a single voxel as solid (`true`) or air (`false`)
80/// against the seed set. The voxel position `(x, y, z)` is in
81/// integer voxel coordinates.
82///
83/// CD.5.3 will fold a Perlin overlay into `d_a` here.
84#[must_use]
85#[allow(clippy::cast_precision_loss)]
86pub fn classify_voxel(seeds: &[Seed], x: u32, y: u32, z: i32, anisotropy: f32) -> bool {
87    let p = [x as f32, y as f32, z as f32];
88    let mut d_air_sq = f32::INFINITY;
89    let mut d_solid_sq = f32::INFINITY;
90    for seed in seeds {
91        let d_sq = anisotropic_dist_sq(p, seed.pos, anisotropy);
92        if seed.is_air {
93            if d_sq < d_air_sq {
94                d_air_sq = d_sq;
95            }
96        } else if d_sq < d_solid_sq {
97            d_solid_sq = d_sq;
98        }
99    }
100    // d_a < d_s means closer to air seed → voxel is air.
101    // Comparing squared distances is fine since both are ≥ 0.
102    d_air_sq >= d_solid_sq
103}
104
105/// Like [`classify_voxel`] but adds a Perlin-noise overlay to the
106/// distance-to-air-seed term. Voxel is air iff
107/// `d_air + overlay < d_solid`, where
108/// `overlay = perlin_amplitude × fbm(x, y, z, octaves) × PERLIN_VOXEL_SCALE`.
109///
110/// `perlin_octaves = 0` or `perlin_amplitude = 0.0` is equivalent to
111/// [`classify_voxel`] (overlay short-circuits to 0).
112///
113/// Costs 2 extra `sqrt`s per voxel + 1 `fbm` call vs the plain
114/// Worley path. At `vsid = 256, seed_count = 128`, the overlay's
115/// per-voxel work is dwarfed by the seed-distance loop (128 squared
116/// dists), so this is essentially free.
117#[must_use]
118#[allow(clippy::cast_precision_loss, clippy::too_many_arguments)]
119pub fn classify_voxel_with_perlin(
120    seeds: &[Seed],
121    perlin: &PerlinNoise3D,
122    x: u32,
123    y: u32,
124    z: i32,
125    anisotropy: f32,
126    perlin_octaves: u32,
127    perlin_amplitude: f32,
128) -> bool {
129    let p = [x as f32, y as f32, z as f32];
130    let mut d_air_sq = f32::INFINITY;
131    let mut d_solid_sq = f32::INFINITY;
132    for seed in seeds {
133        let d_sq = anisotropic_dist_sq(p, seed.pos, anisotropy);
134        if seed.is_air {
135            if d_sq < d_air_sq {
136                d_air_sq = d_sq;
137            }
138        } else if d_sq < d_solid_sq {
139            d_solid_sq = d_sq;
140        }
141    }
142    if perlin_octaves == 0 || perlin_amplitude == 0.0 {
143        return d_air_sq >= d_solid_sq;
144    }
145    let d_air = d_air_sq.sqrt();
146    let d_solid = d_solid_sq.sqrt();
147    let overlay = perlin.fbm(p[0], p[1], p[2], perlin_octaves, PERLIN_LOWEST_FREQUENCY)
148        * perlin_amplitude
149        * PERLIN_VOXEL_SCALE;
150    (d_air + overlay) >= d_solid
151}
152
153/// Build the dense `(VSID × VSID × MAXZDIM)` solidness grid by
154/// classifying every voxel against the seed set. Output: 1 byte
155/// per voxel; non-zero = solid.
156///
157/// O(`vsid² × MAXZDIM × seed_count`). At `vsid = 256`,
158/// `seed_count = 128` this is ~2 G ops — minutes on a single core.
159/// CD.5.3+ will add spatial bucketing if perf bites.
160#[must_use]
161#[allow(clippy::cast_sign_loss)]
162pub fn worley_classify_grid(params: &CaveParams, vsid: u32) -> Vec<u8> {
163    let seeds = place_seeds(params, vsid);
164    // Different sub-seed for Perlin so its permutation table is
165    // decorrelated from the seed-placement RNG stream.
166    let perlin_active = params.perlin_octaves > 0 && params.perlin_amplitude > 0.0;
167    let perlin = if perlin_active {
168        Some(PerlinNoise3D::new(
169            params.seed.wrapping_mul(0x9E37_79B9_7F4A_7C15),
170        ))
171    } else {
172        None
173    };
174    let vsid_u = vsid as usize;
175    let maxzdim_u = MAXZDIM as usize;
176    let n_voxels = vsid_u * vsid_u * maxzdim_u;
177    let mut grid = vec![0u8; n_voxels];
178    for y in 0..vsid {
179        for x in 0..vsid {
180            for z in 0..MAXZDIM {
181                let idx = (y as usize * vsid_u + x as usize) * maxzdim_u + z as usize;
182                let solid = if let Some(ref p) = perlin {
183                    classify_voxel_with_perlin(
184                        &seeds,
185                        p,
186                        x,
187                        y,
188                        z,
189                        params.anisotropy,
190                        params.perlin_octaves,
191                        params.perlin_amplitude,
192                    )
193                } else {
194                    classify_voxel(&seeds, x, y, z, params.anisotropy)
195                };
196                if solid {
197                    grid[idx] = 1;
198                }
199            }
200        }
201    }
202    grid
203}
204
205#[inline]
206pub(crate) fn anisotropic_dist_sq(a: [f32; 3], b: [f32; 3], anisotropy: f32) -> f32 {
207    let dx = a[0] - b[0];
208    let dy = a[1] - b[1];
209    let dz = (a[2] - b[2]) * anisotropy;
210    dx * dx + dy * dy + dz * dz
211}
212
213#[cfg(test)]
214#[allow(
215    clippy::cast_possible_truncation,
216    clippy::cast_precision_loss,
217    clippy::cast_sign_loss
218)]
219mod tests {
220    use super::*;
221
222    fn test_params(seed: u64, seed_count: usize, air_ratio: f32) -> CaveParams {
223        CaveParams {
224            seed,
225            seed_count,
226            air_ratio,
227            anisotropy: 1.0,
228            perlin_octaves: 0,
229            perlin_amplitude: 0.0,
230        }
231    }
232
233    #[test]
234    fn place_seeds_deterministic_in_seed() {
235        let p = test_params(42, 16, 0.5);
236        let a = place_seeds(&p, 64);
237        let b = place_seeds(&p, 64);
238        assert_eq!(a.len(), b.len());
239        for (sa, sb) in a.iter().zip(b.iter()) {
240            assert_eq!(sa.pos[0].to_bits(), sb.pos[0].to_bits(), "x");
241            assert_eq!(sa.pos[1].to_bits(), sb.pos[1].to_bits(), "y");
242            assert_eq!(sa.pos[2].to_bits(), sb.pos[2].to_bits(), "z");
243            assert_eq!(sa.is_air, sb.is_air);
244        }
245    }
246
247    #[test]
248    fn place_seeds_different_seed_yields_different_seeds() {
249        let a = place_seeds(&test_params(1, 16, 0.5), 64);
250        let b = place_seeds(&test_params(2, 16, 0.5), 64);
251        // Should differ on every position; allow at most a quarter
252        // of accidental coincidences.
253        let same = a
254            .iter()
255            .zip(b.iter())
256            .filter(|(x, y)| x.pos[0].to_bits() == y.pos[0].to_bits())
257            .count();
258        assert!(same * 4 < a.len(), "too many coincident x positions");
259    }
260
261    #[test]
262    fn place_seeds_air_ratio_split() {
263        let p = test_params(7, 100, 0.4);
264        let seeds = place_seeds(&p, 64);
265        let n_air = seeds.iter().filter(|s| s.is_air).count();
266        assert_eq!(n_air, 40, "40% of 100 seeds tagged air");
267    }
268
269    #[test]
270    fn place_seeds_within_volume_bounds() {
271        let p = test_params(7, 256, 0.5);
272        let seeds = place_seeds(&p, 64);
273        for s in &seeds {
274            assert!((0.0..64.0).contains(&s.pos[0]), "x in bounds");
275            assert!((0.0..64.0).contains(&s.pos[1]), "y in bounds");
276            assert!((0.0..MAXZDIM as f32).contains(&s.pos[2]), "z in bounds");
277        }
278    }
279
280    #[test]
281    fn classify_at_air_seed_returns_air() {
282        // One air seed at (10, 20, 30); one solid seed far away. Voxel
283        // at (10, 20, 30) should be air.
284        let seeds = vec![
285            Seed {
286                pos: [10.0, 20.0, 30.0],
287                is_air: true,
288            },
289            Seed {
290                pos: [100.0, 100.0, 100.0],
291                is_air: false,
292            },
293        ];
294        assert!(!classify_voxel(&seeds, 10, 20, 30, 1.0), "should be air");
295    }
296
297    #[test]
298    fn classify_at_solid_seed_returns_solid() {
299        let seeds = vec![
300            Seed {
301                pos: [100.0, 100.0, 100.0],
302                is_air: true,
303            },
304            Seed {
305                pos: [10.0, 20.0, 30.0],
306                is_air: false,
307            },
308        ];
309        assert!(classify_voxel(&seeds, 10, 20, 30, 1.0), "should be solid");
310    }
311
312    #[test]
313    fn classify_anisotropy_squishes_caves_vertically() {
314        // One air seed at (10, 20, 30); one solid seed at (10, 20, 50).
315        // Voxel at (10, 20, 40) — equidistant in isotropic, so the
316        // tiebreak goes to solid (d_air >= d_solid → solid).
317        // With aniso=2, z distance is amplified, but still
318        // equidistant — same outcome.
319        let seeds = vec![
320            Seed {
321                pos: [10.0, 20.0, 30.0],
322                is_air: true,
323            },
324            Seed {
325                pos: [10.0, 20.0, 50.0],
326                is_air: false,
327            },
328        ];
329        // Move voxel slightly toward air seed.
330        assert!(
331            !classify_voxel(&seeds, 10, 20, 39, 1.0),
332            "isotropic — closer to air seed"
333        );
334        // Same with aniso=2 — z distance amplified equally for both.
335        assert!(
336            !classify_voxel(&seeds, 10, 20, 39, 2.0),
337            "aniso=2 — closer to air seed"
338        );
339    }
340
341    #[test]
342    #[allow(clippy::naive_bytecount)]
343    fn worley_classify_grid_air_ratio_roughly_matches() {
344        // Small world, count solid vs air. With air_ratio=0.5 we
345        // expect ~50% air.
346        let p = test_params(7, 32, 0.5);
347        let vsid = 16u32;
348        let grid = worley_classify_grid(&p, vsid);
349        let n_air = grid.iter().filter(|&&b| b == 0).count();
350        let total = grid.len();
351        let ratio = n_air as f32 / total as f32;
352        assert!(
353            (0.30..=0.70).contains(&ratio),
354            "expected ~50% air, got {:.2}",
355            ratio * 100.0
356        );
357    }
358
359    #[test]
360    fn worley_classify_grid_deterministic_in_seed() {
361        let p = test_params(1234, 32, 0.5);
362        let g1 = worley_classify_grid(&p, 16);
363        let g2 = worley_classify_grid(&p, 16);
364        assert_eq!(g1, g2, "same seed → byte-stable grid");
365    }
366
367    #[test]
368    fn perlin_overlay_perturbs_classification() {
369        // Same Worley seeds; with vs without Perlin overlay should
370        // disagree on at least *some* voxels (boundary shifts).
371        let no_perlin = test_params(99, 32, 0.5);
372        let with_perlin = CaveParams {
373            perlin_octaves: 3,
374            perlin_amplitude: 0.4, // amplified to guarantee divergence
375            ..no_perlin
376        };
377        let g1 = worley_classify_grid(&no_perlin, 16);
378        let g2 = worley_classify_grid(&with_perlin, 16);
379        let diffs = g1.iter().zip(g2.iter()).filter(|(a, b)| a != b).count();
380        assert!(
381            diffs > 0,
382            "Perlin overlay should perturb the air/solid boundary"
383        );
384        // But not so many — Perlin should shift the boundary, not
385        // randomise it. Expect <30% of voxels to flip.
386        let total = g1.len();
387        assert!(
388            diffs * 100 / total < 30,
389            "Perlin shifts boundary, doesn't randomise: {diffs} of {total} flipped"
390        );
391    }
392
393    #[test]
394    fn perlin_overlay_byte_stable() {
395        // Same seed + perlin params → byte-stable grid.
396        let p = CaveParams {
397            seed: 7,
398            seed_count: 32,
399            air_ratio: 0.5,
400            anisotropy: 1.0,
401            perlin_octaves: 3,
402            perlin_amplitude: 0.15,
403        };
404        let g1 = worley_classify_grid(&p, 16);
405        let g2 = worley_classify_grid(&p, 16);
406        assert_eq!(g1, g2);
407    }
408
409    #[test]
410    fn perlin_disabled_when_amplitude_zero() {
411        // amplitude = 0 should match the no-Perlin path exactly.
412        let no_perlin = test_params(11, 32, 0.5);
413        let zero_amplitude = CaveParams {
414            perlin_octaves: 3,
415            perlin_amplitude: 0.0,
416            ..no_perlin
417        };
418        let g1 = worley_classify_grid(&no_perlin, 16);
419        let g2 = worley_classify_grid(&zero_amplitude, 16);
420        assert_eq!(g1, g2, "amplitude=0 should disable overlay");
421    }
422}