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}