Skip to main content

roxlap_cavegen/
presets.rs

1//! Preset cave generators matching Ken + Tom's reference
2//! screenshots from the 2003 "Justfly" demo.
3//!
4//! Each preset hard-codes a colour palette + per-voxel intensity
5//! variation (via a dedicated Perlin sampler) on top of the shared
6//! [`crate::worley_classify_grid`] cave-shape pipeline. Param
7//! defaults differ per preset (see [`BlueCaveGenerator::default_params`]
8//! and CD.7's [`MagCaveGenerator`] equivalent) so the same `seed`
9//! produces visually distinct caves between presets.
10
11use crate::pack::pack_dense_grid_to_vxl;
12use crate::perlin::PerlinNoise3D;
13use crate::worley::{anisotropic_dist_sq, place_seeds, worley_classify_grid, Seed};
14use crate::{CaveParams, Generator, Vxl, MAXZDIM};
15
16/// Frequency of the colour-Perlin sampler in voxel units. Lower
17/// values give larger colour patches.
18const COLOR_PERLIN_FREQUENCY: f32 = 1.0 / 8.0;
19
20/// Sub-seed offset applied to `params.seed` for the colour Perlin
21/// sampler so its permutation table is decorrelated from the
22/// cave-shape seed stream.
23const COLOR_SEED_OFFSET: u64 = 0xDEAD_BEEF_CAFE_F00D;
24
25/// Blue-cave preset matching Ken's `caveblue2m.jpg`.
26///
27/// Stone-grey base, mossy green near the top (sky-facing), dim
28/// orange near the floor. Per-voxel intensity wobbles ±20% via a
29/// dedicated colour Perlin sampler.
30#[derive(Debug, Default, Clone, Copy)]
31pub struct BlueCaveGenerator;
32
33impl BlueCaveGenerator {
34    /// Default cave parameters tuned to match `caveblue2m.jpg` —
35    /// `seed = 7` was Justfly's `run3.bat` setup.
36    #[must_use]
37    pub fn default_params() -> CaveParams {
38        CaveParams {
39            seed: 7,
40            seed_count: 128,
41            air_ratio: 0.5,
42            anisotropy: 1.0,
43            perlin_octaves: 3,
44            perlin_amplitude: 0.15,
45        }
46    }
47}
48
49impl Generator for BlueCaveGenerator {
50    type Params = CaveParams;
51
52    fn generate(&self, params: &Self::Params, vsid: u32) -> Vxl {
53        let grid = worley_classify_grid(params, vsid);
54        let color = build_blue_color_grid(params, vsid, &grid);
55        pack_dense_grid_to_vxl(&grid, &color, vsid)
56    }
57}
58
59/// Build the per-voxel colour grid for the blue preset. Only solid
60/// voxels (`grid[i] != 0`) get a meaningful colour; air voxels are
61/// left as 0.
62#[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
63fn build_blue_color_grid(params: &CaveParams, vsid: u32, grid: &[u8]) -> Vec<u32> {
64    let perlin = PerlinNoise3D::new(params.seed.wrapping_add(COLOR_SEED_OFFSET));
65    let vsid_u = vsid as usize;
66    let maxzdim_u = MAXZDIM as usize;
67    let mut color = vec![0u32; grid.len()];
68    for y in 0..vsid {
69        for x in 0..vsid {
70            for z in 0..MAXZDIM {
71                let idx = (y as usize * vsid_u + x as usize) * maxzdim_u + z as usize;
72                if grid[idx] != 0 {
73                    color[idx] = blue_cave_color(x, y, z, &perlin);
74                }
75            }
76        }
77    }
78    color
79}
80
81/// Stone grey at mid depth, mossy green at the top, dim orange at
82/// the floor. Per-voxel intensity perturbed ±20% via colour Perlin.
83#[allow(clippy::cast_precision_loss)]
84fn blue_cave_color(x: u32, y: u32, z: i32, perlin: &PerlinNoise3D) -> u32 {
85    /// Voxlap 32-bit colour encoding: `(brightness << 24) | (R << 16) | (G << 8) | B`.
86    /// Brightness `0x80` is voxlap's "neutral" — matches the engine's
87    /// default lighting amplitude.
88    const BASE: u32 = 0x80_70_78_80; // stone grey
89    const UPPER: u32 = 0x80_60_80_60; // mossy green
90    const LOWER: u32 = 0x80_60_40_30; // dim orange
91    const INTENSITY_AMPLITUDE: f32 = 0.20;
92
93    let z_norm = (z as f32) / (MAXZDIM as f32);
94    let base = if z_norm < 0.5 {
95        // Top half: blend from upper (z=0) to base (z=MAXZDIM/2).
96        lerp_rgb(UPPER, BASE, z_norm * 2.0)
97    } else {
98        // Bottom half: blend from base to lower (z=MAXZDIM-1).
99        lerp_rgb(BASE, LOWER, (z_norm - 0.5) * 2.0)
100    };
101    let perlin_val = perlin.sample(
102        (x as f32) * COLOR_PERLIN_FREQUENCY,
103        (y as f32) * COLOR_PERLIN_FREQUENCY,
104        (z as f32) * COLOR_PERLIN_FREQUENCY,
105    );
106    let intensity = 1.0 + INTENSITY_AMPLITUDE * perlin_val;
107    apply_intensity(base, intensity)
108}
109
110/// Linearly interpolate per-channel between two voxlap-format
111/// colours. Brightness byte is taken from `a` (the "from" colour).
112#[allow(
113    clippy::cast_possible_truncation,
114    clippy::cast_sign_loss,
115    clippy::cast_precision_loss,
116    clippy::many_single_char_names
117)]
118fn lerp_rgb(a: u32, b: u32, t: f32) -> u32 {
119    let (ar, ag, ab) = unpack_rgb(a);
120    let (br, bg, bb) = unpack_rgb(b);
121    let brightness = (a >> 24) & 0xff;
122    let r = (f32::from(ar) + (f32::from(br) - f32::from(ar)) * t).round() as u32;
123    let g = (f32::from(ag) + (f32::from(bg) - f32::from(ag)) * t).round() as u32;
124    let blu = (f32::from(ab) + (f32::from(bb) - f32::from(ab)) * t).round() as u32;
125    (brightness << 24) | (r << 16) | (g << 8) | blu
126}
127
128/// Multiply the RGB channels of a voxlap-format colour by `factor`,
129/// clamping to `0..=255`. Brightness byte is preserved.
130#[allow(
131    clippy::cast_possible_truncation,
132    clippy::cast_sign_loss,
133    clippy::cast_precision_loss
134)]
135fn apply_intensity(color: u32, factor: f32) -> u32 {
136    let (r, g, b) = unpack_rgb(color);
137    let brightness = (color >> 24) & 0xff;
138    let scaled = |c: u8| (f32::from(c) * factor).clamp(0.0, 255.0).round() as u32;
139    (brightness << 24) | (scaled(r) << 16) | (scaled(g) << 8) | scaled(b)
140}
141
142// ====================================================================
143// MagCaveGenerator (CD.7) — magenta base + yellow-green edge highlight.
144// ====================================================================
145
146/// Magenta-cave preset matching Ken's `cavemag3m.jpg`.
147///
148/// Magenta base across the cave surfaces; voxels whose distance to
149/// the nearest air seed roughly equals the distance to the nearest
150/// solid seed (i.e. right on the Worley boundary) get a yellow-green
151/// edge highlight blended in. Per-voxel intensity wobbles ±25% via
152/// a dedicated colour Perlin sampler.
153#[derive(Debug, Default, Clone, Copy)]
154pub struct MagCaveGenerator;
155
156impl MagCaveGenerator {
157    /// Default cave parameters tuned to match `cavemag3m.jpg`.
158    /// Higher anisotropy (1.5) + lower air ratio (0.4) + extra
159    /// Perlin octaves give the magenta variant more sinuous,
160    /// densely-walled corridors than the blue variant.
161    #[must_use]
162    pub fn default_params() -> CaveParams {
163        CaveParams {
164            seed: 7,
165            seed_count: 128,
166            air_ratio: 0.4,
167            anisotropy: 1.5,
168            perlin_octaves: 4,
169            perlin_amplitude: 0.25,
170        }
171    }
172}
173
174impl Generator for MagCaveGenerator {
175    type Params = CaveParams;
176
177    fn generate(&self, params: &Self::Params, vsid: u32) -> Vxl {
178        // Mag's colour function needs the Worley seeds (the edge
179        // highlight is keyed on `|d_a - d_s|`), so we place seeds
180        // here and reuse them for both classify + colour.
181        let shape_seeds = place_seeds(params, vsid);
182        let grid = worley_classify_grid(params, vsid);
183        let color = build_mag_color_grid(params, vsid, &grid, &shape_seeds);
184        pack_dense_grid_to_vxl(&grid, &color, vsid)
185    }
186}
187
188/// Build the per-voxel colour grid for the mag preset. Reuses the
189/// shape-pass seeds for the edge-highlight distance check, plus a
190/// dedicated Perlin sampler for intensity wobble.
191#[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
192fn build_mag_color_grid(params: &CaveParams, vsid: u32, grid: &[u8], seeds: &[Seed]) -> Vec<u32> {
193    let perlin = PerlinNoise3D::new(params.seed.wrapping_add(COLOR_SEED_OFFSET));
194    let vsid_u = vsid as usize;
195    let maxzdim_u = MAXZDIM as usize;
196    let mut color = vec![0u32; grid.len()];
197    for y in 0..vsid {
198        for x in 0..vsid {
199            for z in 0..MAXZDIM {
200                let idx = (y as usize * vsid_u + x as usize) * maxzdim_u + z as usize;
201                if grid[idx] != 0 {
202                    color[idx] = mag_cave_color(x, y, z, &perlin, seeds, params.anisotropy);
203                }
204            }
205        }
206    }
207    color
208}
209
210/// Magenta base; voxels close to the Worley boundary
211/// (`|d_a − d_s|` small) blend toward yellow-green. Per-voxel
212/// intensity perturbed ±25% via colour Perlin.
213#[allow(clippy::cast_precision_loss)]
214fn mag_cave_color(
215    x: u32,
216    y: u32,
217    z: i32,
218    perlin: &PerlinNoise3D,
219    seeds: &[Seed],
220    anisotropy: f32,
221) -> u32 {
222    const MAGENTA: u32 = 0x80_a0_40_a0;
223    const YELLOW_GREEN: u32 = 0x80_b0_b0_20;
224    /// Edge-highlight reach in voxel-distance units. Voxels with
225    /// `|d_a − d_s|` ≤ this get full highlight; voxels well past
226    /// it get pure magenta. Tuned for `seed_count = 128` at
227    /// `vsid = 256` (typical `d_a ≈ 16`).
228    const EDGE_THRESHOLD: f32 = 4.0;
229    const INTENSITY_AMPLITUDE: f32 = 0.25;
230
231    let p = [x as f32, y as f32, z as f32];
232    let mut d_air_sq = f32::INFINITY;
233    let mut d_solid_sq = f32::INFINITY;
234    for seed in seeds {
235        let d_sq = anisotropic_dist_sq(p, seed.pos, anisotropy);
236        if seed.is_air {
237            if d_sq < d_air_sq {
238                d_air_sq = d_sq;
239            }
240        } else if d_sq < d_solid_sq {
241            d_solid_sq = d_sq;
242        }
243    }
244    let d_air = d_air_sq.sqrt();
245    let d_solid = d_solid_sq.sqrt();
246    let edge_factor = (1.0 - (d_air - d_solid).abs() / EDGE_THRESHOLD).clamp(0.0, 1.0);
247    let base = lerp_rgb(MAGENTA, YELLOW_GREEN, edge_factor);
248
249    let perlin_val = perlin.sample(
250        (x as f32) * COLOR_PERLIN_FREQUENCY,
251        (y as f32) * COLOR_PERLIN_FREQUENCY,
252        (z as f32) * COLOR_PERLIN_FREQUENCY,
253    );
254    let intensity = 1.0 + INTENSITY_AMPLITUDE * perlin_val;
255    apply_intensity(base, intensity)
256}
257
258#[inline]
259fn unpack_rgb(color: u32) -> (u8, u8, u8) {
260    #[allow(clippy::cast_possible_truncation)]
261    let r = ((color >> 16) & 0xff) as u8;
262    #[allow(clippy::cast_possible_truncation)]
263    let g = ((color >> 8) & 0xff) as u8;
264    #[allow(clippy::cast_possible_truncation)]
265    let b = (color & 0xff) as u8;
266    (r, g, b)
267}
268
269#[cfg(test)]
270#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn blue_default_params_match_plan() {
276        let p = BlueCaveGenerator::default_params();
277        assert_eq!(p.seed, 7);
278        assert_eq!(p.seed_count, 128);
279        assert!((p.air_ratio - 0.5).abs() < 1e-6);
280        assert!((p.anisotropy - 1.0).abs() < 1e-6);
281        assert_eq!(p.perlin_octaves, 3);
282        assert!((p.perlin_amplitude - 0.15).abs() < 1e-6);
283    }
284
285    #[test]
286    fn blue_generate_byte_stable_in_seed() {
287        // Cheap-VSID world; same seed → byte-equal Vxl.
288        let p = CaveParams {
289            seed_count: 16,
290            ..BlueCaveGenerator::default_params()
291        };
292        let a = BlueCaveGenerator.generate(&p, 16);
293        let b = BlueCaveGenerator.generate(&p, 16);
294        assert_eq!(a.vsid, b.vsid);
295        assert_eq!(a.column_offset.as_ref(), b.column_offset.as_ref());
296        assert_eq!(a.data.as_ref(), b.data.as_ref());
297    }
298
299    #[test]
300    fn blue_generate_yields_mixed_air_and_solid() {
301        // Cave should have both air and solid (not pathological all-
302        // air or all-solid).
303        let p = CaveParams {
304            seed_count: 16,
305            ..BlueCaveGenerator::default_params()
306        };
307        let vxl = BlueCaveGenerator.generate(&p, 16);
308        // Sample a few columns; each should expandrle to a non-trivial
309        // b2 (at least one air gap somewhere).
310        let mut total_runs = 0;
311        for idx in 0..(16 * 16) {
312            let mut b2 = vec![0i32; 256];
313            roxlap_formats::edit::expandrle(vxl.column_data(idx), &mut b2);
314            let mut i = 0;
315            while b2[i + 1] < MAXZDIM {
316                i += 2;
317            }
318            // i+2 entries ≥ sentinel; (i+2)/2 = number of solid runs.
319            total_runs += (i + 2) / 2;
320        }
321        // 16x16 = 256 columns. Even pathological "every column is one
322        // run" would give 256. Cave with carved air gaps should have
323        // strictly more.
324        assert!(
325            total_runs > 256,
326            "expected multi-run columns from cave gen; got {total_runs} total runs"
327        );
328    }
329
330    #[test]
331    fn lerp_rgb_endpoints_match() {
332        let a = 0x80_aa_bb_cc;
333        let b = 0x80_11_22_33;
334        assert_eq!(lerp_rgb(a, b, 0.0), a);
335        assert_eq!(lerp_rgb(a, b, 1.0), b & 0x00_ff_ff_ff | (a & 0xff00_0000));
336    }
337
338    #[test]
339    fn lerp_rgb_midpoint() {
340        // Halfway between (R=0, G=0, B=0) and (R=200, G=100, B=50)
341        // → (R=100, G=50, B=25). Brightness from a.
342        let a = 0x8000_0000u32;
343        let b = 0x40c8_6432u32; // brightness ignored for b, RGB = (200, 100, 50)
344        let mid = lerp_rgb(a, b, 0.5);
345        let (r, g, blu) = unpack_rgb(mid);
346        assert_eq!(r, 100, "red midpoint");
347        assert_eq!(g, 50, "green midpoint");
348        assert_eq!(blu, 25, "blue midpoint");
349        // Brightness stays at 0x80.
350        assert_eq!((mid >> 24) & 0xff, 0x80);
351    }
352
353    #[test]
354    fn apply_intensity_clamps_to_255() {
355        // Intensity > 1 saturates channels at 255.
356        let c = 0x80_80_80_80; // brightness=0x80, RGB=(0x80,0x80,0x80)
357        let scaled = apply_intensity(c, 2.5);
358        let (r, g, b) = unpack_rgb(scaled);
359        assert_eq!(r, 255, "red clamped");
360        assert_eq!(g, 255, "green clamped");
361        assert_eq!(b, 255, "blue clamped");
362    }
363
364    #[test]
365    fn apply_intensity_preserves_brightness_byte() {
366        let c = 0x80_80_80_80;
367        let scaled = apply_intensity(c, 0.5);
368        assert_eq!((scaled >> 24) & 0xff, 0x80, "brightness preserved");
369    }
370
371    #[test]
372    fn blue_cave_color_top_skews_green() {
373        // At z=0 (sky-facing top), colour blends fully toward UPPER
374        // (mossy green). G channel should dominate over R, B.
375        let perlin = PerlinNoise3D::new(0);
376        // Sample ignoring perlin perturbation: zero-out perlin by
377        // making coords land on integer grid (Perlin is exactly 0
378        // there).
379        let c = blue_cave_color(0, 0, 0, &perlin);
380        let (r, g, b) = unpack_rgb(c);
381        // UPPER = 0x80_60_80_60 → R=0x60, G=0x80, B=0x60.
382        // At z=0 the lerp gives exactly UPPER.
383        assert_eq!(r, 0x60);
384        assert_eq!(g, 0x80);
385        assert_eq!(b, 0x60);
386    }
387
388    #[test]
389    fn blue_cave_color_bottom_skews_orange() {
390        // At z=MAXZDIM-1 (floor), colour blends fully toward LOWER
391        // (orange). R should dominate.
392        let perlin = PerlinNoise3D::new(0);
393        // z=MAXZDIM-1 means z_norm ≈ 1, lerp(BASE, LOWER, 1) = LOWER.
394        // But Perlin at x=0,y=0,z=255 might not be exactly 0 — use
395        // an integer grid coord that's safe.
396        let c = blue_cave_color(0, 0, MAXZDIM - 1, &perlin);
397        let (r, g, b) = unpack_rgb(c);
398        // LOWER = 0x80_60_40_30 → R=0x60, G=0x40, B=0x30.
399        // The Perlin perturbation at integer points is ~0 so colour
400        // should be exactly LOWER (modulo intensity float math).
401        // Allow ±2 per channel for f32 rounding noise.
402        assert!(
403            (i32::from(r) - 0x60).abs() <= 2,
404            "R close to 0x60: got {r:#04x}"
405        );
406        assert!(
407            (i32::from(g) - 0x40).abs() <= 2,
408            "G close to 0x40: got {g:#04x}"
409        );
410        assert!(
411            (i32::from(b) - 0x30).abs() <= 2,
412            "B close to 0x30: got {b:#04x}"
413        );
414    }
415
416    // ---- MagCaveGenerator (CD.7) -------------------------------------
417
418    #[test]
419    fn mag_default_params_match_plan() {
420        let p = MagCaveGenerator::default_params();
421        assert_eq!(p.seed_count, 128);
422        assert!((p.air_ratio - 0.4).abs() < 1e-6, "air_ratio");
423        assert!((p.anisotropy - 1.5).abs() < 1e-6, "anisotropy");
424        assert_eq!(p.perlin_octaves, 4);
425        assert!((p.perlin_amplitude - 0.25).abs() < 1e-6, "amplitude");
426    }
427
428    #[test]
429    fn mag_generate_byte_stable_in_seed() {
430        let p = CaveParams {
431            seed_count: 16,
432            ..MagCaveGenerator::default_params()
433        };
434        let a = MagCaveGenerator.generate(&p, 16);
435        let b = MagCaveGenerator.generate(&p, 16);
436        assert_eq!(a.column_offset.as_ref(), b.column_offset.as_ref());
437        assert_eq!(a.data.as_ref(), b.data.as_ref());
438    }
439
440    #[test]
441    fn mag_generate_yields_mixed_air_and_solid() {
442        let p = CaveParams {
443            seed_count: 16,
444            ..MagCaveGenerator::default_params()
445        };
446        let vxl = MagCaveGenerator.generate(&p, 16);
447        let mut total_runs = 0;
448        for idx in 0..(16 * 16) {
449            let mut b2 = vec![0i32; 256];
450            roxlap_formats::edit::expandrle(vxl.column_data(idx), &mut b2);
451            let mut i = 0;
452            while b2[i + 1] < MAXZDIM {
453                i += 2;
454            }
455            total_runs += (i + 2) / 2;
456        }
457        assert!(
458            total_runs > 256,
459            "expected multi-run columns from cave gen; got {total_runs} total runs"
460        );
461    }
462
463    #[test]
464    fn mag_far_from_boundary_skews_magenta() {
465        // Voxel deep inside a solid region (close to a solid seed,
466        // far from any air seed) → |d_a - d_s| large → no edge
467        // highlight → magenta base.
468        // Use synthetic seeds: solid at origin, air far away.
469        let seeds = vec![
470            Seed {
471                pos: [0.0, 0.0, 0.0],
472                is_air: false,
473            },
474            Seed {
475                pos: [100.0, 100.0, 100.0],
476                is_air: true,
477            },
478        ];
479        let perlin = PerlinNoise3D::new(0);
480        // Voxel at integer coords (Perlin == 0 there) close to the
481        // solid seed → magenta survives.
482        let c = mag_cave_color(1, 0, 0, &perlin, &seeds, 1.0);
483        let (r, g, b) = unpack_rgb(c);
484        // MAGENTA = 0x80_a0_40_a0 → R=0xa0, G=0x40, B=0xa0.
485        // Allow ±2 per channel for f32 round.
486        assert!(
487            (i32::from(r) - 0xa0).abs() <= 2,
488            "R magenta-ish: got {r:#04x}"
489        );
490        assert!(
491            (i32::from(g) - 0x40).abs() <= 2,
492            "G magenta-ish: got {g:#04x}"
493        );
494        assert!(
495            (i32::from(b) - 0xa0).abs() <= 2,
496            "B magenta-ish: got {b:#04x}"
497        );
498    }
499
500    #[test]
501    fn mag_at_boundary_skews_yellow_green() {
502        // Voxel equidistant from one air + one solid seed → full
503        // edge-highlight → yellow-green.
504        let seeds = vec![
505            Seed {
506                pos: [0.0, 0.0, 0.0],
507                is_air: false,
508            },
509            Seed {
510                pos: [2.0, 0.0, 0.0],
511                is_air: true,
512            },
513        ];
514        let perlin = PerlinNoise3D::new(0);
515        // Voxel at (1, 0, 0): equidistant from both seeds (d=1.0 each
516        // → |d_a - d_s| = 0 → full highlight).
517        let c = mag_cave_color(1, 0, 0, &perlin, &seeds, 1.0);
518        let (r, g, b) = unpack_rgb(c);
519        // YELLOW_GREEN = 0x80_b0_b0_20 → R=0xb0, G=0xb0, B=0x20.
520        assert!(
521            (i32::from(r) - 0xb0).abs() <= 2,
522            "R yellow-green-ish: got {r:#04x}"
523        );
524        assert!(
525            (i32::from(g) - 0xb0).abs() <= 2,
526            "G yellow-green-ish: got {g:#04x}"
527        );
528        assert!(
529            (i32::from(b) - 0x20).abs() <= 2,
530            "B yellow-green-ish: got {b:#04x}"
531        );
532    }
533
534    #[test]
535    fn mag_and_blue_diverge_in_byte_output() {
536        // Same seed + vsid; the two presets produce different Vxls.
537        // (Mag uses different defaults for air_ratio / anisotropy /
538        // perlin_octaves / amplitude, so cave shape differs even
539        // before colour.)
540        let p = CaveParams {
541            seed_count: 16,
542            ..BlueCaveGenerator::default_params()
543        };
544        let blue = BlueCaveGenerator.generate(&p, 16);
545        let q = CaveParams {
546            seed_count: 16,
547            ..MagCaveGenerator::default_params()
548        };
549        let mag = MagCaveGenerator.generate(&q, 16);
550        // Shape differs: their grid structure should disagree on at
551        // least some columns.
552        let mut differing = 0;
553        for idx in 0..(16 * 16) {
554            if blue.column_data(idx) != mag.column_data(idx) {
555                differing += 1;
556            }
557        }
558        assert!(
559            differing > 0,
560            "Blue and Mag presets should produce different output"
561        );
562    }
563}