Skip to main content

concinnity_core/geometry/
chunk_gen.rs

1// src/geometry/chunk_gen.rs
2//
3// Deterministic procedural generation of voxel chunks for an infinite
4// `VoxelWorld`.
5//
6// A `ChunkGenerator` turns a `ChunkCoord` + a world seed into the dense block
7// array a chunk mesher consumes. Generation is a pure function of the seed and
8// the chunk coordinate, so a chunk that streams out and back in regenerates
9// byte-identically. Terrain height comes from multi-octave value noise keyed
10// on *world* block coordinates, so adjacent chunks line up seamlessly across
11// their shared edge.
12//
13// The lattice hash is integer arithmetic and interpolation is a polynomial
14// smoothstep, so generation involves no transcendentals at all.
15
16use crate::gfx::chunk_coord::ChunkCoord;
17use alloc::vec;
18use alloc::vec::Vec;
19
20/// One palette entry for the chunk mesher: solidity plus per-face atlas UVs.
21///
22/// The public, `geometry`-external counterpart of the private
23/// `voxel::PaletteSlot`. The streaming subsystem resolves a `VoxelWorld`'s
24/// `BlockType` palette into a `Vec<ChunkBlockType>` and hands it to
25/// [`super::build_chunk_mesh`].
26#[derive(Clone, Copy, Debug)]
27pub struct ChunkBlockType {
28    /// When false the block is air -- emits no geometry, occludes nothing.
29    pub solid: bool,
30    /// Atlas UV rect `[u_min, v_min, u_max, v_max]` for the +Y face.
31    pub uv_top: [f32; 4],
32    /// Atlas UV rect for the -Y face.
33    pub uv_bottom: [f32; 4],
34    /// Atlas UV rect for the four side faces.
35    pub uv_side: [f32; 4],
36}
37
38/// Deterministic terrain generator for one `VoxelWorld`.
39///
40/// Constructed once from the world's seed and chunk dimensions; `generate`
41/// produces the block array for any chunk on demand.
42pub struct ChunkGenerator {
43    seed: u64,
44    chunk_blocks: [u32; 3],
45    // Palette index emitted for the topmost solid block of each column.
46    surface_idx: u32,
47    // Palette index emitted for solid blocks below the surface.
48    subsurface_idx: u32,
49}
50
51// Feature size (in blocks) and weight of each value-noise octave. Larger
52// features give broad hills; smaller ones add detail. Weights are normalised
53// at evaluation time so the combined noise stays in [0, 1).
54const OCTAVES: [(i32, f32); 3] = [(64, 1.0), (32, 0.5), (16, 0.25)];
55
56impl ChunkGenerator {
57    /// A generator for a world with the given `seed`, chunk dimensions, and
58    /// palette length.
59    ///
60    /// By the `VoxelWorld` palette convention index 0 is air, 1 the surface
61    /// block, and 2 (when the palette has it) the subsurface block; a
62    /// shorter palette falls back to index 1 for subsurface.
63    pub fn new(seed: u64, chunk_blocks: [u32; 3], palette_len: u32) -> Self {
64        let surface_idx = if palette_len > 1 { 1 } else { 0 };
65        let subsurface_idx = if palette_len > 2 { 2 } else { surface_idx };
66        Self {
67            seed,
68            chunk_blocks: [
69                chunk_blocks[0].max(1),
70                chunk_blocks[1].max(1),
71                chunk_blocks[2].max(1),
72            ],
73            surface_idx,
74            subsurface_idx,
75        }
76    }
77
78    /// Generate the dense block array for chunk `coord`.
79    ///
80    /// The result has length `dx*dy*dz` with the layout
81    /// `index = x + y*dx + z*dx*dy`, exactly what the voxel mesher expects.
82    /// A column is solid up to its noise-derived surface height and air above.
83    pub fn generate(&self, coord: ChunkCoord) -> Vec<u32> {
84        let [dx, dy, dz] = [
85            self.chunk_blocks[0] as usize,
86            self.chunk_blocks[1] as usize,
87            self.chunk_blocks[2] as usize,
88        ];
89        let mut blocks = vec![0u32; dx * dy * dz];
90        // World block coordinate of this chunk's (0,0) corner.
91        let base_x = coord.x * self.chunk_blocks[0] as i32;
92        let base_z = coord.z * self.chunk_blocks[2] as i32;
93
94        for z in 0..dz {
95            for x in 0..dx {
96                let wx = base_x + x as i32;
97                let wz = base_z + z as i32;
98                let height = self.surface_height(wx, wz, dy as i32);
99                for y in 0..dy {
100                    let yi = y as i32;
101                    let id = if yi > height {
102                        0 // air
103                    } else if yi == height {
104                        self.surface_idx
105                    } else {
106                        self.subsurface_idx
107                    };
108                    blocks[x + y * dx + z * dx * dy] = id;
109                }
110            }
111        }
112        blocks
113    }
114
115    /// Surface block height (topmost solid block index) of the column at world
116    /// block coordinate `(wx, wz)`, clamped to `[0, chunk_height-1]`.
117    ///
118    /// Public so the distant-chunk impostor mesher can sample the terrain
119    /// surface on a coarse grid without paying for a full dense block array.
120    /// Because it keys on world coordinates (like [`generate`](Self::generate)),
121    /// two impostor chunks sample identical heights along their shared edge, so
122    /// their coarse surfaces meet watertight.
123    pub fn surface_height_world(&self, wx: i32, wz: i32) -> i32 {
124        self.surface_height(wx, wz, self.chunk_blocks[1] as i32)
125    }
126
127    /// Palette index of the surface (topmost) block: `1` when the palette has
128    /// a dedicated surface block, else `0`. The impostor mesher uses it to pick
129    /// the surface block's atlas UVs so impostors texture like the full chunks.
130    pub fn surface_palette_index(&self) -> u32 {
131        self.surface_idx
132    }
133
134    // Surface block height of the column at world coordinate `(wx, wz)`,
135    // clamped to `[0, dy-1]` so every column has at least one solid block and
136    // never overflows the chunk.
137    fn surface_height(&self, wx: i32, wz: i32, dy: i32) -> i32 {
138        let n = self.combined_noise(wx, wz); // [0, 1)
139        // Centre the terrain around 45% of the chunk height with a +/-30% swing.
140        let base = dy as f32 * 0.45;
141        let amplitude = dy as f32 * 0.30;
142        let h = base + (n - 0.5) * 2.0 * amplitude;
143        (h as i32).clamp(0, dy - 1)
144    }
145
146    // Multi-octave value noise at world block coordinate `(wx, wz)`, in
147    // `[0, 1)`. Octave seeds are offset so the octaves are independent.
148    fn combined_noise(&self, wx: i32, wz: i32) -> f32 {
149        let mut sum = 0.0;
150        let mut weight_sum = 0.0;
151        for (octave, &(feature, weight)) in OCTAVES.iter().enumerate() {
152            let octave_seed = self.seed.wrapping_add(octave as u64 * 0x9E37_79B9);
153            sum += weight * value_noise(octave_seed, wx, wz, feature);
154            weight_sum += weight;
155        }
156        if weight_sum > 0.0 {
157            sum / weight_sum
158        } else {
159            0.5
160        }
161    }
162}
163
164// Value noise sampled at world coordinate `(wx, wz)` on a lattice of spacing
165// `feature` blocks. Bilinear interpolation of four hashed lattice values with
166// a polynomial smoothstep; returns `[0, 1)`.
167fn value_noise(seed: u64, wx: i32, wz: i32, feature: i32) -> f32 {
168    let feature = feature.max(1);
169    // Floored lattice cell + fractional position within it. `div_euclid` /
170    // `rem_euclid` floor correctly for negative coordinates, unlike `/` `%`.
171    let cell_x = wx.div_euclid(feature);
172    let cell_z = wz.div_euclid(feature);
173    let tx = wx.rem_euclid(feature) as f32 / feature as f32;
174    let tz = wz.rem_euclid(feature) as f32 / feature as f32;
175
176    let v00 = hash01(seed, cell_x, cell_z);
177    let v10 = hash01(seed, cell_x + 1, cell_z);
178    let v01 = hash01(seed, cell_x, cell_z + 1);
179    let v11 = hash01(seed, cell_x + 1, cell_z + 1);
180
181    let sx = smoothstep(tx);
182    let sz = smoothstep(tz);
183    let a = v00 + (v10 - v00) * sx;
184    let b = v01 + (v11 - v01) * sx;
185    a + (b - a) * sz
186}
187
188// Hermite smoothstep `3t^2 - 2t^3`. Polynomial, so no `std`-only math.
189fn smoothstep(t: f32) -> f32 {
190    t * t * (3.0 - 2.0 * t)
191}
192
193// Hash an integer lattice point to a pseudo-random `f32` in `[0, 1)`.
194//
195// Integer-only mixing (a variant of the SplitMix64 finaliser) so the result
196// is deterministic and reproducible across platforms.
197fn hash01(seed: u64, x: i32, z: i32) -> f32 {
198    let mut h = seed;
199    h ^= (x as i64 as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
200    h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
201    h ^= (z as i64 as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F);
202    h ^= h >> 31;
203    h = h.wrapping_mul(0x94D0_49BB_1331_11EB);
204    h ^= h >> 31;
205    // Top 24 bits give a uniform [0, 1) without needing all 64.
206    (h >> 40) as f32 / (1u64 << 24) as f32
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn make_gen() -> ChunkGenerator {
214        ChunkGenerator::new(1234, [16, 24, 16], 3)
215    }
216
217    #[test]
218    fn generate_produces_the_expected_block_count() {
219        let blocks = make_gen().generate(ChunkCoord::new(0, 0));
220        assert_eq!(blocks.len(), 16 * 24 * 16);
221    }
222
223    #[test]
224    fn generation_is_deterministic() {
225        let a = make_gen().generate(ChunkCoord::new(3, -2));
226        let b = make_gen().generate(ChunkCoord::new(3, -2));
227        assert_eq!(a, b);
228    }
229
230    #[test]
231    fn different_seeds_produce_different_worlds() {
232        let a = ChunkGenerator::new(1, [16, 24, 16], 3).generate(ChunkCoord::new(0, 0));
233        let b = ChunkGenerator::new(2, [16, 24, 16], 3).generate(ChunkCoord::new(0, 0));
234        assert_ne!(a, b);
235    }
236
237    #[test]
238    fn columns_are_solid_below_the_surface_and_air_above() {
239        let g = make_gen();
240        let [dx, dy, dz] = [16usize, 24usize, 16usize];
241        let blocks = g.generate(ChunkCoord::new(0, 0));
242        for z in 0..dz {
243            for x in 0..dx {
244                // Find the topmost solid block in the column.
245                let mut top = None;
246                for y in (0..dy).rev() {
247                    if blocks[x + y * dx + z * dx * dy] != 0 {
248                        top = Some(y);
249                        break;
250                    }
251                }
252                let top = top.expect("every column has a solid block");
253                // Everything above is air; everything at/below is solid.
254                for y in 0..dy {
255                    let solid = blocks[x + y * dx + z * dx * dy] != 0;
256                    assert_eq!(solid, y <= top, "column ({x},{z}) y={y}");
257                }
258            }
259        }
260    }
261
262    #[test]
263    fn terrain_keys_on_world_coordinates() {
264        // Each chunk's column heights are exactly the world-coordinate height
265        // function sampled over its block range. Because the function depends
266        // only on world coords, adjacent chunks line up seamlessly across
267        // their shared edge with no per-chunk discontinuity.
268        let g = make_gen();
269        let [dx, dy, dz] = [16i32, 24i32, 16i32];
270        let chunk = ChunkCoord::new(1, -2);
271        let blocks = g.generate(chunk);
272        let base_x = chunk.x * dx;
273        let base_z = chunk.z * dz;
274        for z in 0..dz as usize {
275            for x in 0..dx as usize {
276                let mut top = 0;
277                for y in (0..dy as usize).rev() {
278                    if blocks[x + y * dx as usize + z * (dx * dy) as usize] != 0 {
279                        top = y as i32;
280                        break;
281                    }
282                }
283                let expected = g.surface_height(base_x + x as i32, base_z + z as i32, dy);
284                assert_eq!(top, expected, "column ({x},{z}) height");
285            }
286        }
287    }
288
289    #[test]
290    fn value_noise_stays_in_unit_range() {
291        for wx in -40..40 {
292            for wz in -40..40 {
293                let n = value_noise(99, wx, wz, 16);
294                assert!((0.0..1.0).contains(&n), "noise {n} out of range");
295            }
296        }
297    }
298
299    #[test]
300    fn short_palette_falls_back_to_the_surface_index() {
301        // palette_len 2 -> no dedicated subsurface index; below-surface uses 1.
302        let g = ChunkGenerator::new(5, [4, 8, 4], 2);
303        let blocks = g.generate(ChunkCoord::new(0, 0));
304        assert!(blocks.iter().all(|&b| b <= 1));
305        assert!(blocks.contains(&1));
306    }
307}