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#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::chunks::tests::voxel_is_solid;
129    use crate::{
130        Grid, GridTransform, Scene, StreamRadius, CHUNK_SIZE_XY as CXY, CHUNK_SIZE_Z as CZ,
131    };
132    use glam::DVec3;
133    use roxlap_cavegen::{BlueCaveGenerator, MagCaveGenerator};
134    use std::sync::Arc;
135
136    #[test]
137    fn derive_chunk_seed_distinct_for_neighbours() {
138        let s0 = derive_chunk_seed(7, IVec3::new(0, 0, 0));
139        let sx = derive_chunk_seed(7, IVec3::new(1, 0, 0));
140        let sy = derive_chunk_seed(7, IVec3::new(0, 1, 0));
141        let snx = derive_chunk_seed(7, IVec3::new(-1, 0, 0));
142        // All four must be pairwise different — a small chunk-idx
143        // delta with a 64-bit hash collides at probability ~2^-64.
144        assert_ne!(s0, sx);
145        assert_ne!(s0, sy);
146        assert_ne!(s0, snx);
147        assert_ne!(sx, sy);
148    }
149
150    #[test]
151    fn derive_chunk_seed_ignores_z() {
152        // chz isn't part of the cave layer; same xy + different z
153        // must produce the same hash (chz != 0 is short-circuited
154        // before we hit this hash anyway, but contract is explicit).
155        let a = derive_chunk_seed(7, IVec3::new(3, -2, 0));
156        let b = derive_chunk_seed(7, IVec3::new(3, -2, 5));
157        assert_eq!(a, b);
158    }
159
160    #[test]
161    fn adapter_returns_chunk_sized_vxl() {
162        let gen = CaveChunkGenerator::new(
163            BlueCaveGenerator,
164            CaveParams {
165                seed_count: 16,
166                ..BlueCaveGenerator::default_params()
167            },
168        );
169        let vxl = gen.generate(IVec3::ZERO);
170        assert_eq!(vxl.vsid, CXY, "adapter must return chunk-VSID output");
171    }
172
173    #[test]
174    fn adapter_is_deterministic_per_chunk_idx() {
175        let mk = || {
176            CaveChunkGenerator::new(
177                BlueCaveGenerator,
178                CaveParams {
179                    seed_count: 16,
180                    ..BlueCaveGenerator::default_params()
181                },
182            )
183        };
184        let g1 = mk();
185        let g2 = mk();
186        let a = g1.generate(IVec3::new(3, -2, 0));
187        let b = g2.generate(IVec3::new(3, -2, 0));
188        // Same chunk_idx + same base_params → byte-identical Vxl
189        // (cavegen's docs guarantee this).
190        assert_eq!(a.column_offset.as_ref(), b.column_offset.as_ref());
191        assert_eq!(a.data.as_ref(), b.data.as_ref());
192    }
193
194    #[test]
195    fn adapter_different_chunks_yield_different_output() {
196        let gen = CaveChunkGenerator::new(
197            BlueCaveGenerator,
198            CaveParams {
199                seed_count: 16,
200                ..BlueCaveGenerator::default_params()
201            },
202        );
203        let a = gen.generate(IVec3::new(0, 0, 0));
204        let b = gen.generate(IVec3::new(1, 0, 0));
205        // Different chunk seed → at least some columns differ.
206        let mut differing = 0;
207        for col in 0..(CXY * CXY) {
208            if a.column_data(col as usize) != b.column_data(col as usize) {
209                differing += 1;
210            }
211        }
212        assert!(
213            differing > 0,
214            "adjacent chunks should produce differing column data"
215        );
216    }
217
218    #[test]
219    fn adapter_chz_nonzero_returns_implicit_air() {
220        // chz != 0 → bedrock-only chunk (the standard scene
221        // empty-chunk shape). Verify by checking a sample of
222        // mid-z voxels are air; bedrock at z=255 is solid.
223        let gen = CaveChunkGenerator::new(BlueCaveGenerator, BlueCaveGenerator::default_params());
224        let vxl = gen.generate(IVec3::new(0, 0, 1));
225        for &(x, y, z) in &[(0u32, 0u32, 0u32), (50, 60, 100), (CXY - 1, CXY - 1, 200)] {
226            assert!(
227                !voxel_is_solid(&vxl, x, y, z),
228                "({x},{y},{z}) should be air for chz=1"
229            );
230        }
231        // Bedrock placeholder still present.
232        assert!(voxel_is_solid(&vxl, 0, 0, CZ - 1));
233
234        // Negative chz also implicit-air.
235        let vxl_neg = gen.generate(IVec3::new(0, 0, -3));
236        assert!(!voxel_is_solid(&vxl_neg, 50, 60, 100));
237    }
238
239    #[test]
240    fn adapter_chunks_have_mixed_air_and_solid_in_cave_layer() {
241        // chz=0 must produce real cave content — both air and
242        // solid voxels, not pathological all-one-or-the-other.
243        let gen = CaveChunkGenerator::new(
244            BlueCaveGenerator,
245            CaveParams {
246                seed_count: 32,
247                ..BlueCaveGenerator::default_params()
248            },
249        );
250        let vxl = gen.generate(IVec3::ZERO);
251        let mut any_air = false;
252        let mut any_solid_above_bedrock = false;
253        for y in (0..CXY).step_by(16) {
254            for x in (0..CXY).step_by(16) {
255                for z in (0..(CZ - 1)).step_by(16) {
256                    if voxel_is_solid(&vxl, x, y, z) {
257                        any_solid_above_bedrock = true;
258                    } else {
259                        any_air = true;
260                    }
261                }
262            }
263        }
264        assert!(any_air, "cave should contain air voxels");
265        assert!(
266            any_solid_above_bedrock,
267            "cave should contain solid voxels above bedrock"
268        );
269    }
270
271    #[test]
272    fn adapter_works_with_mag_preset() {
273        // Sanity that the generic wrapper accepts MagCaveGenerator
274        // identically — type-level check + a deterministic round-trip.
275        let gen = CaveChunkGenerator::new(
276            MagCaveGenerator,
277            CaveParams {
278                seed_count: 16,
279                ..MagCaveGenerator::default_params()
280            },
281        );
282        let a = gen.generate(IVec3::ZERO);
283        let b = gen.generate(IVec3::ZERO);
284        assert_eq!(a.column_offset.as_ref(), b.column_offset.as_ref());
285        assert_eq!(a.data.as_ref(), b.data.as_ref());
286    }
287
288    #[test]
289    fn adapter_integrates_with_pump_streaming_sync() {
290        // End-to-end: register an adapter on a Grid, set
291        // stream_radius, call pump_streaming_sync, verify chunks
292        // installed + their content is cavegen-shaped (mixed
293        // air/solid in chz=0; implicit-air in chz=1).
294        let mut scene = Scene::new();
295        let id = scene.add_grid(GridTransform::identity());
296        let adapter = CaveChunkGenerator::new(
297            BlueCaveGenerator,
298            CaveParams {
299                seed_count: 16,
300                ..BlueCaveGenerator::default_params()
301            },
302        );
303        let g: &mut Grid = scene.grid_mut(id).unwrap();
304        g.set_generator(Some(Arc::new(adapter)));
305        // r_active = 150 from camera at (64, 64, 100) → covers chunk
306        // (0, 0, 0) only on chz=0 (z=100 → chz=0; chz=1 face at z=256,
307        // dist=156 > 150; chz=-1 face at z=0... camera is at z=100,
308        // inside chz=0). So just one chunk streams.
309        g.stream_radius = StreamRadius::new(150.0, 300.0);
310        scene.pump_streaming_sync(DVec3::new(64.0, 64.0, 100.0));
311
312        let g = scene.grid(id).unwrap();
313        let vxl = g
314            .chunk(IVec3::ZERO)
315            .expect("chunk (0,0,0) should have streamed");
316        // Quick sanity: the streamed chunk has both air and solid.
317        let mut any_air = false;
318        let mut any_solid = false;
319        for &(x, y, z) in &[
320            (40_u32, 40, 50),
321            (80, 80, 100),
322            (20, 90, 150),
323            (100, 30, 200),
324        ] {
325            if voxel_is_solid(vxl, x, y, z) {
326                any_solid = true;
327            } else {
328                any_air = true;
329            }
330        }
331        assert!(any_air, "streamed cave should have air voxels");
332        assert!(any_solid, "streamed cave should have solid voxels");
333    }
334}