Skip to main content

roxlap_scene/
cavegen.rs

1//! Adapter wrapping `roxlap-cavegen`'s [`Generator`] presets as a
2//! [`crate::ChunkGenerator`] (S7.5).
3//!
4//! The cavegen crate generates one big `vsid × vsid × MAXZDIM`
5//! [`Vxl`] from a [`CaveParams`] seed. The scene-graph layer wants
6//! per-chunk generation: one [`Vxl`] of size
7//! `CHUNK_SIZE_XY × CHUNK_SIZE_XY × CHUNK_SIZE_Z` per `(chx, chy,
8//! chz)`. [`CaveChunkGenerator`] bridges the two by:
9//!
10//! 1. **Per-chunk seed derivation** (FNV-1a hash of base_seed +
11//!    chunk_idx.xy) — each chunk gets its own Worley cell layout.
12//! 2. **Single-z-slab cave** — caves live in `chz = 0` (world z in
13//!    `[0, 256)`). Other `chz` layers return an empty bedrock-only
14//!    [`Vxl`] (= implicit air).
15//!
16//! ## Known limitation: chunk-boundary seams
17//!
18//! Because each chunk gets an independent seed pool, the Worley
19//! cells don't line up across chunk boundaries — a corridor that
20//! cuts through chunk `(0, 0)` stops at the boundary of `(1, 0)`,
21//! where a fresh cave begins. The result still streams cleanly +
22//! looks "cave-like" everywhere, but the seams are visible.
23//!
24//! S7.5.b will refactor cavegen to support neighbour-aware seed
25//! pools (each chunk's classification reads seeds from a 3×3 pool
26//! of neighbours), erasing the seams. Deferred per [[s7-scope]]
27//! risk R-S7.4's "start with (a), refactor if needed" guidance.
28//!
29//! [`Vxl`]: roxlap_formats::vxl::Vxl
30//! [`Generator`]: roxlap_cavegen::Generator
31//! [`CaveParams`]: roxlap_cavegen::CaveParams
32
33use std::fmt;
34
35use glam::IVec3;
36use roxlap_cavegen::{CaveParams, Generator};
37use roxlap_formats::vxl::Vxl;
38
39use crate::chunks::empty_chunk_vxl;
40use crate::{ChunkGenerator, CHUNK_SIZE_XY};
41
42/// Wrap a [`Generator`] (typically
43/// [`roxlap_cavegen::BlueCaveGenerator`] or
44/// [`roxlap_cavegen::MagCaveGenerator`]) as a
45/// [`ChunkGenerator`]. Each `(chx, chy)` gets a chunk-derived
46/// seed; `chz != 0` is implicit air.
47///
48/// The inner generator is consumed at construction; the wrapper
49/// owns it for the lifetime of the adapter. Generators are
50/// expected to be cheap to clone (the cavegen presets are unit
51/// structs, so this is free).
52pub struct CaveChunkGenerator<G> {
53    inner: G,
54    base_params: CaveParams,
55}
56
57impl<G> fmt::Debug for CaveChunkGenerator<G>
58where
59    G: fmt::Debug,
60{
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.debug_struct("CaveChunkGenerator")
63            .field("inner", &self.inner)
64            .field("base_seed", &self.base_params.seed)
65            .field("seed_count", &self.base_params.seed_count)
66            .finish()
67    }
68}
69
70impl<G> CaveChunkGenerator<G> {
71    /// New adapter wrapping `inner` with `base_params` as the
72    /// template for per-chunk parameter derivation.
73    ///
74    /// The `base_params.seed` is XOR'd with the chunk index via
75    /// FNV-1a to produce the per-chunk seed; other fields
76    /// (`seed_count`, `air_ratio`, `anisotropy`, etc.) are passed
77    /// through unchanged.
78    pub fn new(inner: G, base_params: CaveParams) -> Self {
79        Self { inner, base_params }
80    }
81}
82
83/// Derive a deterministic per-chunk seed from a base seed + chunk
84/// index. FNV-1a 64-bit so the hash is well-distributed even for
85/// small chunk-index deltas (chunk `(0, 0)` vs `(1, 0)` produce
86/// completely different output).
87///
88/// Only `chunk_idx.x` and `chunk_idx.y` participate — `chz` is
89/// gated upstream (only `chz == 0` reaches the cave path).
90#[must_use]
91pub(crate) fn derive_chunk_seed(base_seed: u64, chunk_idx: IVec3) -> u64 {
92    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
93    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
94    let mut h = FNV_OFFSET ^ base_seed;
95    for byte in chunk_idx.x.to_le_bytes() {
96        h ^= u64::from(byte);
97        h = h.wrapping_mul(FNV_PRIME);
98    }
99    for byte in chunk_idx.y.to_le_bytes() {
100        h ^= u64::from(byte);
101        h = h.wrapping_mul(FNV_PRIME);
102    }
103    h
104}
105
106impl<G> ChunkGenerator for CaveChunkGenerator<G>
107where
108    G: Generator<Params = CaveParams> + fmt::Debug + Send + Sync + 'static,
109{
110    fn generate(&self, chunk_idx: IVec3) -> Vxl {
111        if chunk_idx.z != 0 {
112            // Cave lives in the chz = 0 z-slab; other layers are
113            // implicit air (just the bedrock placeholder, which
114            // the renderer treats as air via `treat_z_max_as_air`).
115            return empty_chunk_vxl();
116        }
117        let params = CaveParams {
118            seed: derive_chunk_seed(self.base_params.seed, chunk_idx),
119            ..self.base_params
120        };
121        self.inner.generate(&params, CHUNK_SIZE_XY)
122    }
123}
124
125/// Tuning for [`plant_crystals`] — extracted from the native cave demo
126/// (EV.4) so the web demo grows the same crystals from the same code.
127#[derive(Debug, Clone)]
128pub struct CrystalParams {
129    /// Crystal voxel colour (map it to a translucent+emissive material
130    /// on the renderer and to the audio/debris tables for the full
131    /// treatment).
132    pub color: crate::VoxColor,
133    /// Clusters to plant (a guaranteed one counts toward this).
134    pub count: usize,
135    /// Crystal blob radius, voxels ([`crate::Grid::set_sphere`]).
136    pub crystal_radius: u32,
137    /// Hard cutoff of a crystal's baked glow pool, voxels.
138    pub light_radius: f32,
139    /// Bake-light strength (byte gain × distance²; ~2000 = torch,
140    /// ~8000 = cavern beacon).
141    pub light_strength: f32,
142    /// Grow one guaranteed crystal downward from this air voxel (the
143    /// spawn bubble's floor — the player never spawns in the dark).
144    pub guaranteed: Option<IVec3>,
145    /// Seed salt so e.g. two colour presets place differently.
146    pub salt: u64,
147}
148
149/// EV.4 (shared home since PW.0b) — plant glowing crystal clusters on
150/// the cavity walls of a **single-chunk cave** at chunk `(0, 0, 0)`:
151/// deterministic (seed + salt) rejection sampling picks an air voxel,
152/// marches along a random axis to the nearest wall within reach, grows
153/// a crystal blob into it, and registers a [`crate::BakeLight`]
154/// floating just off the surface (so the glow pool spreads over the
155/// wall). Replaces (clears) any previous build's lights — pair with a
156/// [`crate::BakeMode::PointLights`] bake.
157#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
158pub fn plant_crystals(grid: &mut crate::Grid, seed: u64, params: &CrystalParams) {
159    grid.bake_lights.clear();
160    let side = CHUNK_SIZE_XY as i32;
161    let zmax = crate::CHUNK_SIZE_Z as i32;
162    // xorshift64* — deterministic per (seed, salt), decoupled from the
163    // generator's own noise seeding.
164    let mut state = seed
165        .wrapping_mul(0x9E37_79B9_7F4A_7C15)
166        .wrapping_add(params.salt)
167        | 1;
168    let mut next = move || {
169        state ^= state << 13;
170        state ^= state >> 7;
171        state ^= state << 17;
172        state.wrapping_mul(0x2545_F491_4F6C_DD1D)
173    };
174    // The six axis directions a crystal can grow along (into the wall).
175    const DIRS: [IVec3; 6] = [
176        IVec3::new(1, 0, 0),
177        IVec3::new(-1, 0, 0),
178        IVec3::new(0, 1, 0),
179        IVec3::new(0, -1, 0),
180        IVec3::new(0, 0, 1),
181        IVec3::new(0, 0, -1),
182    ];
183
184    let plant_at = |grid: &mut crate::Grid, air: IVec3, dir: IVec3| {
185        // March from the air voxel to the wall (≤ 14 voxels away).
186        for k in 1..=14 {
187            let p = air + dir * k;
188            if !(0..side).contains(&p.x)
189                || !(0..side).contains(&p.y)
190                || !(8..zmax - 1).contains(&p.z)
191            {
192                return false;
193            }
194            if grid.voxel_solid(p) {
195                // ANCHOR: bake_light
196                grid.set_sphere(p, params.crystal_radius, Some(params.color));
197                // The light floats in the air a few voxels off the
198                // surface so the pool spreads over the wall around it.
199                let lp = air + dir * (k - 3).max(0);
200                grid.bake_lights.push(crate::BakeLight {
201                    pos: glam::Vec3::new(lp.x as f32, lp.y as f32, lp.z as f32),
202                    radius: params.light_radius,
203                    strength: params.light_strength,
204                });
205                // ANCHOR_END: bake_light
206                return true;
207            }
208        }
209        false
210    };
211    let mut planted = 0;
212    if let Some(spawn) = params.guaranteed {
213        if plant_at(grid, spawn, IVec3::new(0, 0, 1)) {
214            planted += 1;
215        }
216    }
217
218    let mut attempts = 0;
219    while planted < params.count && attempts < 800 {
220        attempts += 1;
221        #[allow(clippy::cast_possible_wrap)]
222        let cand = IVec3::new(
223            (8 + next() % (side as u64 - 16)) as i32,
224            (8 + next() % (side as u64 - 16)) as i32,
225            (24 + next() % 200) as i32,
226        );
227        if grid.voxel_solid(cand) {
228            continue; // need an air pocket to glow into
229        }
230        // Spread the crystals out: reject candidates inside another
231        // light's pool core.
232        let too_close = grid.bake_lights.iter().any(|l| {
233            let d = glam::Vec3::new(cand.x as f32, cand.y as f32, cand.z as f32) - l.pos;
234            d.length_squared() < 24.0 * 24.0
235        });
236        if too_close {
237            continue;
238        }
239        let dir = DIRS[(next() % 6) as usize];
240        if plant_at(grid, cand, dir) {
241            planted += 1;
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::chunks::tests::voxel_is_solid;
250    use crate::{
251        Grid, GridTransform, Scene, StreamRadius, CHUNK_SIZE_XY as CXY, CHUNK_SIZE_Z as CZ,
252    };
253    use glam::DVec3;
254    use roxlap_cavegen::{BlueCaveGenerator, MagCaveGenerator};
255    use std::sync::Arc;
256
257    #[test]
258    fn derive_chunk_seed_distinct_for_neighbours() {
259        let s0 = derive_chunk_seed(7, IVec3::new(0, 0, 0));
260        let sx = derive_chunk_seed(7, IVec3::new(1, 0, 0));
261        let sy = derive_chunk_seed(7, IVec3::new(0, 1, 0));
262        let snx = derive_chunk_seed(7, IVec3::new(-1, 0, 0));
263        // All four must be pairwise different — a small chunk-idx
264        // delta with a 64-bit hash collides at probability ~2^-64.
265        assert_ne!(s0, sx);
266        assert_ne!(s0, sy);
267        assert_ne!(s0, snx);
268        assert_ne!(sx, sy);
269    }
270
271    #[test]
272    fn derive_chunk_seed_ignores_z() {
273        // chz isn't part of the cave layer; same xy + different z
274        // must produce the same hash (chz != 0 is short-circuited
275        // before we hit this hash anyway, but contract is explicit).
276        let a = derive_chunk_seed(7, IVec3::new(3, -2, 0));
277        let b = derive_chunk_seed(7, IVec3::new(3, -2, 5));
278        assert_eq!(a, b);
279    }
280
281    #[test]
282    fn adapter_returns_chunk_sized_vxl() {
283        let gen = CaveChunkGenerator::new(
284            BlueCaveGenerator,
285            CaveParams {
286                seed_count: 16,
287                ..BlueCaveGenerator::default_params()
288            },
289        );
290        let vxl = gen.generate(IVec3::ZERO);
291        assert_eq!(vxl.vsid, CXY, "adapter must return chunk-VSID output");
292    }
293
294    #[test]
295    fn adapter_is_deterministic_per_chunk_idx() {
296        let mk = || {
297            CaveChunkGenerator::new(
298                BlueCaveGenerator,
299                CaveParams {
300                    seed_count: 16,
301                    ..BlueCaveGenerator::default_params()
302                },
303            )
304        };
305        let g1 = mk();
306        let g2 = mk();
307        let a = g1.generate(IVec3::new(3, -2, 0));
308        let b = g2.generate(IVec3::new(3, -2, 0));
309        // Same chunk_idx + same base_params → byte-identical Vxl
310        // (cavegen's docs guarantee this).
311        assert_eq!(a.column_offset.as_ref(), b.column_offset.as_ref());
312        assert_eq!(a.data.as_ref(), b.data.as_ref());
313    }
314
315    #[test]
316    fn adapter_different_chunks_yield_different_output() {
317        let gen = CaveChunkGenerator::new(
318            BlueCaveGenerator,
319            CaveParams {
320                seed_count: 16,
321                ..BlueCaveGenerator::default_params()
322            },
323        );
324        let a = gen.generate(IVec3::new(0, 0, 0));
325        let b = gen.generate(IVec3::new(1, 0, 0));
326        // Different chunk seed → at least some columns differ.
327        let mut differing = 0;
328        for col in 0..(CXY * CXY) {
329            if a.column_data(col as usize) != b.column_data(col as usize) {
330                differing += 1;
331            }
332        }
333        assert!(
334            differing > 0,
335            "adjacent chunks should produce differing column data"
336        );
337    }
338
339    #[test]
340    fn adapter_chz_nonzero_returns_implicit_air() {
341        // chz != 0 → bedrock-only chunk (the standard scene
342        // empty-chunk shape). Verify by checking a sample of
343        // mid-z voxels are air; bedrock at z=255 is solid.
344        let gen = CaveChunkGenerator::new(BlueCaveGenerator, BlueCaveGenerator::default_params());
345        let vxl = gen.generate(IVec3::new(0, 0, 1));
346        for &(x, y, z) in &[(0u32, 0u32, 0u32), (50, 60, 100), (CXY - 1, CXY - 1, 200)] {
347            assert!(
348                !voxel_is_solid(&vxl, x, y, z),
349                "({x},{y},{z}) should be air for chz=1"
350            );
351        }
352        // Bedrock placeholder still present.
353        assert!(voxel_is_solid(&vxl, 0, 0, CZ - 1));
354
355        // Negative chz also implicit-air.
356        let vxl_neg = gen.generate(IVec3::new(0, 0, -3));
357        assert!(!voxel_is_solid(&vxl_neg, 50, 60, 100));
358    }
359
360    #[test]
361    fn adapter_chunks_have_mixed_air_and_solid_in_cave_layer() {
362        // chz=0 must produce real cave content — both air and
363        // solid voxels, not pathological all-one-or-the-other.
364        let gen = CaveChunkGenerator::new(
365            BlueCaveGenerator,
366            CaveParams {
367                seed_count: 32,
368                ..BlueCaveGenerator::default_params()
369            },
370        );
371        let vxl = gen.generate(IVec3::ZERO);
372        let mut any_air = false;
373        let mut any_solid_above_bedrock = false;
374        for y in (0..CXY).step_by(16) {
375            for x in (0..CXY).step_by(16) {
376                for z in (0..(CZ - 1)).step_by(16) {
377                    if voxel_is_solid(&vxl, x, y, z) {
378                        any_solid_above_bedrock = true;
379                    } else {
380                        any_air = true;
381                    }
382                }
383            }
384        }
385        assert!(any_air, "cave should contain air voxels");
386        assert!(
387            any_solid_above_bedrock,
388            "cave should contain solid voxels above bedrock"
389        );
390    }
391
392    #[test]
393    fn adapter_works_with_mag_preset() {
394        // Sanity that the generic wrapper accepts MagCaveGenerator
395        // identically — type-level check + a deterministic round-trip.
396        let gen = CaveChunkGenerator::new(
397            MagCaveGenerator,
398            CaveParams {
399                seed_count: 16,
400                ..MagCaveGenerator::default_params()
401            },
402        );
403        let a = gen.generate(IVec3::ZERO);
404        let b = gen.generate(IVec3::ZERO);
405        assert_eq!(a.column_offset.as_ref(), b.column_offset.as_ref());
406        assert_eq!(a.data.as_ref(), b.data.as_ref());
407    }
408
409    #[test]
410    fn adapter_integrates_with_pump_streaming_sync() {
411        // End-to-end: register an adapter on a Grid, set
412        // stream_radius, call pump_streaming_sync, verify chunks
413        // installed + their content is cavegen-shaped (mixed
414        // air/solid in chz=0; implicit-air in chz=1).
415        let mut scene = Scene::new();
416        let id = scene.add_grid(GridTransform::identity());
417        let adapter = CaveChunkGenerator::new(
418            BlueCaveGenerator,
419            CaveParams {
420                seed_count: 16,
421                ..BlueCaveGenerator::default_params()
422            },
423        );
424        let g: &mut Grid = scene.grid_mut(id).unwrap();
425        g.set_generator(Some(Arc::new(adapter)));
426        // r_active = 150 from camera at (64, 64, 100) → covers chunk
427        // (0, 0, 0) only on chz=0 (z=100 → chz=0; chz=1 face at z=256,
428        // dist=156 > 150; chz=-1 face at z=0... camera is at z=100,
429        // inside chz=0). So just one chunk streams.
430        g.stream_radius = StreamRadius::new(150.0, 300.0);
431        scene.pump_streaming_sync(DVec3::new(64.0, 64.0, 100.0));
432
433        let g = scene.grid(id).unwrap();
434        let vxl = g
435            .chunk(IVec3::ZERO)
436            .expect("chunk (0,0,0) should have streamed");
437        // Quick sanity: the streamed chunk has both air and solid.
438        let mut any_air = false;
439        let mut any_solid = false;
440        for &(x, y, z) in &[
441            (40_u32, 40, 50),
442            (80, 80, 100),
443            (20, 90, 150),
444            (100, 30, 200),
445        ] {
446            if voxel_is_solid(vxl, x, y, z) {
447                any_solid = true;
448            } else {
449                any_air = true;
450            }
451        }
452        assert!(any_air, "streamed cave should have air voxels");
453        assert!(any_solid, "streamed cave should have solid voxels");
454    }
455}