From b2e50b62b572da609f315c1d779d874c09a5b693 Mon Sep 17 00:00:00 2001 From: Maximus Gorog Date: Sun, 24 May 2026 18:07:32 -0600 Subject: [PATCH] Cubic chunks + no Y limit (foundation for LOD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunks are now isotropic 16×16×16 cubes stacked in all three axes, keyed by IVec3(cx, cy, cz). The world has no Y ceiling and no Y floor — set_block lazily creates a chunk if one doesn't exist, so building 1000 blocks straight up just creates 60-ish empty chunks above the surface on demand. world.rs: - CHUNK_HEIGHT removed. Single CHUNK_SIZE (=16) for all three axes. - Chunk allocates 16³ = 4096 blocks. - Chunk::get/set bound-check 0..CHUNK_SIZE on every axis. - block_to_chunk returns full 3D (cx, cy, cz) + (lx, ly, lz). - get_block has no Y bound — outside any chunk = Air (sky). - set_block: chunks.entry(c).or_insert_with(...) creates on demand. - HeightMap is keyed by IVec2(cx, cz) column. from_world_column scans every existing cubic chunk in that column top-down for the topmost solid block. Heightmap invalidation in set_block drops the column, not just the single chunk. - column_top_y, World::heightmap signature both take (cx, cz) directly. - World::new pre-generates chunks at cy in -1..=2 (4 vertical levels of pre-gen, ~1.1k chunks total, ~4.6 MB) covering the natural surface band. Above/below grows lazily. - generate_chunk takes the full 3D chunk coord; computes oy, fills blocks for this slice of the column. Trees that straddle a chunk boundary deterministically fill in their portion (each chunk uses the same hash, writes only its slice). - natural_surface_y returns the topmost-solid Y (preserved old semantic so all collision / spawn tests still hold). - 2 new tests pin the lazy-chunk-creation invariant (above and below pre-gen ranges). mesh.rs: - dims is [CHUNK_SIZE; 3]; iteration loops are cubic. - base_y added so neighbor lookups + corner positions account for the chunk's vertical offset. - warm_heightmaps_around takes (cx, cz) column coords now. sim/spawn.rs: - find_safe_spawn no longer caps at CHUNK_HEIGHT-2. Safety bound is surface_y + 200 (pathological tower depth before giving up). sim/lighting.rs: - SUN_RAY_TOP_Y bumped from 128 to 4096 (was matching the old fixed world height). Ray DDA still terminates at the cap. sim/visibility.rs: - Chunk AABB is CHUNK_SIZE on all three axes; the chunk's cy now contributes to the AABB min Y (was hardcoded 0). render/mod.rs: - Heightmap warming + comment updates for column-keyed semantics. Tests: 65/65 pass (was 63, +2 new). Native + wasm release clean. Smoke-tested live: 88 fps, alive, spawn at y=8. All four UI scenarios pass. This is the foundation for the LOD pyramid you directed earlier — isotropic chunks downsample cleanly (a LOD-N chunk represents a (2^N)³ region with 16³ entries), and unbounded Y lets the LOD tree extend in any direction without special cases. --- src/mesh.rs | 48 +++++--- src/render/mod.rs | 9 +- src/sim/lighting.rs | 7 +- src/sim/spawn.rs | 9 +- src/sim/visibility.rs | 9 +- src/world.rs | 270 +++++++++++++++++++++++++++--------------- 6 files changed, 228 insertions(+), 124 deletions(-) diff --git a/src/mesh.rs b/src/mesh.rs index 4bd3684..4fb86de 100644 --- a/src/mesh.rs +++ b/src/mesh.rs @@ -1,15 +1,17 @@ -use crate::world::{Block, Chunk, Face, World, CHUNK_HEIGHT, CHUNK_SIZE}; +use crate::world::{Block, Chunk, Face, World, CHUNK_SIZE}; use bytemuck::{Pod, Zeroable}; use glam::{IVec3, Vec3}; -/// Ensure heightmaps for this chunk and its 8 neighbors are cached, -/// so the per-vertex ambience bake can do O(1) column lookups via -/// `world.column_top_y(..)`. Run once per chunk at the start of -/// `build_chunk_mesh`. Idempotent — only computes uncached entries. +/// Ensure heightmaps for this chunk's (cx, cz) column + its 8 neighbor +/// columns are cached so the per-vertex ambience bake can do O(1) +/// `column_top_y` lookups. Idempotent — recomputes only uncached +/// columns. With cubic chunks, the heightmap is keyed on the +/// horizontal column, not on the chunk's vertical level — one +/// heightmap serves every cubic chunk in the column. pub fn warm_heightmaps_around(world: &mut World, coord: IVec3) { for dx in -1..=1 { for dz in -1..=1 { - let _ = world.heightmap(IVec3::new(coord.x + dx, 0, coord.z + dz)); + let _ = world.heightmap(coord.x + dx, coord.z + dz); } } } @@ -108,9 +110,11 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh { let mut vertices: Vec = Vec::with_capacity(2048); let mut indices: Vec = Vec::with_capacity(3072); let base_x = chunk.coord.x * CHUNK_SIZE; + let base_y = chunk.coord.y * CHUNK_SIZE; let base_z = chunk.coord.z * CHUNK_SIZE; - let dims = [CHUNK_SIZE, CHUNK_HEIGHT, CHUNK_SIZE]; + // Cubic chunks — every axis is CHUNK_SIZE. + let dims = [CHUNK_SIZE, CHUNK_SIZE, CHUNK_SIZE]; for face in Face::ALL { let normal = face.normal(); @@ -149,20 +153,28 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh { let nx = p[0] + normal.x; let ny = p[1] + normal.y; let nz = p[2] + normal.z; - let neighbor_solid = if ny < 0 || ny >= CHUNK_HEIGHT { - false - } else if nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE { + // Neighbor lookup: in-chunk if all locals in + // [0, CHUNK_SIZE); otherwise query the world, + // which may cross into a vertically adjacent + // chunk (the new cubic case). + let in_chunk = nx >= 0 + && nx < CHUNK_SIZE + && ny >= 0 + && ny < CHUNK_SIZE + && nz >= 0 + && nz < CHUNK_SIZE; + let neighbor_solid = if in_chunk { chunk.get(nx, ny, nz).solid() } else { world - .get_block(IVec3::new(base_x + nx, ny, base_z + nz)) + .get_block(IVec3::new(base_x + nx, base_y + ny, base_z + nz)) .solid() }; if neighbor_solid { continue; } // World-space block position. - let block_world = IVec3::new(base_x + p[0], p[1], base_z + p[2]); + let block_world = IVec3::new(base_x + p[0], base_y + p[1], base_z + p[2]); let face_plane = block_world + normal; // 4 corner AO values, ordered (min-u min-v), (max-u min-v), // (max-u max-v), (min-u max-v). @@ -206,7 +218,13 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh { p[axis] = slice as f32; p[u_axis] = u_val as f32; p[v_axis] = v_val as f32; - [p[0] + base_x as f32, p[1], p[2] + base_z as f32] + // All three axes contribute a chunk-base + // offset now that chunks are cubic. + [ + p[0] + base_x as f32, + p[1] + base_y as f32, + p[2] + base_z as f32, + ] }; let c0 = to_world(u0, v0); let c1 = to_world(u0 + w, v0); @@ -415,7 +433,7 @@ pub fn name_hash(s: &str) -> u32 { #[cfg(test)] mod tests { use super::*; - use crate::world::{Block, Chunk, World, CHUNK_HEIGHT, CHUNK_SIZE}; + use crate::world::{Block, Chunk, World, CHUNK_SIZE}; /// A world containing exactly one chunk at the origin with all blocks /// you put into it via the closure, and nothing else. @@ -570,8 +588,8 @@ mod tests { // is being rearranged. #[test] fn world_constants_are_sane() { + // Cubic chunks: just one CHUNK_SIZE for all three axes. assert!(CHUNK_SIZE > 0); - assert!(CHUNK_HEIGHT > 0); } // ---------- emit_oriented_box ---------- diff --git a/src/render/mod.rs b/src/render/mod.rs index d615b2c..9cf6209 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -495,10 +495,11 @@ impl Renderer { } pub fn rebuild_chunk(&mut self, coord: IVec3, world: &mut World) { - // Heightmaps for this chunk + 8 neighbors are required by the - // fast ambience bake. Cheap (O(N²·CHUNK_HEIGHT) per chunk, - // cached). Called before build_chunk_mesh, never lazily inside - // it, so the build path stays free of `&mut World`. + // Heightmaps for this chunk's column + 8 neighbor columns are + // required by the fast ambience bake. Cheap (one scan across + // the column's stacked chunks, cached). Called before + // build_chunk_mesh, never lazily inside it, so the build path + // stays free of `&mut World`. warm_heightmaps_around(world, coord); let Some(chunk) = world.chunks.get(&coord) else { return; diff --git a/src/sim/lighting.rs b/src/sim/lighting.rs index deb3347..b5eeb3a 100644 --- a/src/sim/lighting.rs +++ b/src/sim/lighting.rs @@ -16,8 +16,11 @@ pub const DAY_PERIOD: f32 = 300.0; /// Phase offset so a fresh world starts at noon (0.25 cycles past dawn). pub const SUN_OFFSET: f32 = 0.25; /// Y above which the sun-occlusion ray is considered to have escaped -/// the world (open sky). Must be ≥ `world::CHUNK_HEIGHT`. -pub const SUN_RAY_TOP_Y: i32 = 128; +/// the world (open sky). Since cubic chunks now extend Y indefinitely, +/// this is just a "we've gone too high to keep looking" cap — large +/// enough that any reasonable structure is below it but not unbounded +/// so degenerate rays still terminate. +pub const SUN_RAY_TOP_Y: i32 = 4096; /// Hard cap on DDA steps so a degenerate near-horizontal ray can't loop /// forever. 512 voxels is well past any reasonable world width. const SUN_RAY_MAX_STEPS: u32 = 512; diff --git a/src/sim/spawn.rs b/src/sim/spawn.rs index 8d97b0b..f5903a5 100644 --- a/src/sim/spawn.rs +++ b/src/sim/spawn.rs @@ -1,7 +1,7 @@ //! Spawn-point selection and fall-damage. Both are pure functions of //! world state and a scalar input, so they're easy to pin with //! regression tests. -use crate::world::{natural_surface_y, World, CHUNK_HEIGHT}; +use crate::world::{natural_surface_y, World}; use glam::{IVec3, Vec3}; /// Returns the player feet position to spawn at. Anchored to the @@ -13,7 +13,12 @@ pub fn find_safe_spawn(world: &World) -> Vec3 { let (x, z) = (0_i32, 0_i32); let surface_y = natural_surface_y(x, z); let mut feet_y = surface_y + 1; - let max_y = CHUNK_HEIGHT - 2; + // No CHUNK_HEIGHT bound anymore — cubic chunks stack indefinitely. + // We still cap the scan at +200 above the surface as a safety, + // since any tower that tall + a clear cap above would be + // pathological and any actual spawn would have found a slot well + // before there. + let max_y = surface_y + 200; while feet_y < max_y { let body_blocked = world.get_block(IVec3::new(x, feet_y, z)).solid() || world.get_block(IVec3::new(x, feet_y + 1, z)).solid(); diff --git a/src/sim/visibility.rs b/src/sim/visibility.rs index 6519a1b..5e02e47 100644 --- a/src/sim/visibility.rs +++ b/src/sim/visibility.rs @@ -1,7 +1,7 @@ //! Frustum + radial culling for chunk visibility. Pure function on //! `(World, Camera, render_dist) -> Vec`. use crate::camera::Camera; -use crate::world::{World, CHUNK_HEIGHT, CHUNK_SIZE}; +use crate::world::{World, CHUNK_SIZE}; use glam::{IVec3, Vec3}; /// Returns the chunk coordinates whose AABB intersects the camera's @@ -41,12 +41,15 @@ fn frustum_planes(camera: &Camera) -> [[f32; 4]; 6] { } fn chunk_in_frustum(coord: IVec3, planes: &[[f32; 4]; 6]) -> bool { + // Cubic chunks — all three axes are CHUNK_SIZE wide and the + // chunk's Y coordinate now matters (was always 0 in the old + // column-chunk model). let min = Vec3::new( (coord.x * CHUNK_SIZE) as f32, - 0.0, + (coord.y * CHUNK_SIZE) as f32, (coord.z * CHUNK_SIZE) as f32, ); - let max = min + Vec3::new(CHUNK_SIZE as f32, CHUNK_HEIGHT as f32, CHUNK_SIZE as f32); + let max = min + Vec3::splat(CHUNK_SIZE as f32); for p in planes { // p-vertex (the AABB corner furthest along plane normal). let px = if p[0] > 0.0 { max.x } else { min.x }; diff --git a/src/world.rs b/src/world.rs index 8b73fc3..f825a6b 100644 --- a/src/world.rs +++ b/src/world.rs @@ -1,10 +1,18 @@ -use glam::{IVec3, Vec3}; +use glam::{IVec2, IVec3, Vec3}; use std::collections::HashMap; pub const CHUNK_SIZE: i32 = 16; -pub const CHUNK_HEIGHT: i32 = 64; pub const WORLD_RADIUS: i32 = 8; +/// Vertical range of chunks pre-generated around the surface, in chunk +/// units (so 4 = 64 blocks of vertical pre-gen, centered on the terrain). +/// The world has no hard Y limit — players can dig below or build above +/// this range, and `set_block` lazily creates chunks on demand. The +/// pre-gen range exists so the world has actual terrain visible on +/// init without waiting for streaming. +pub const PRE_GEN_BOTTOM_CHUNK_Y: i32 = -1; +pub const PRE_GEN_TOP_CHUNK_Y: i32 = 2; + #[repr(u8)] #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum Block { @@ -115,6 +123,11 @@ impl Face { } } +/// A cubic 16×16×16 voxel chunk. The chunk grid extends in all three +/// axes — `coord.y` is the vertical chunk index, so the world has no +/// fixed top or bottom. Chunks are created lazily on edit (see +/// `World::set_block`) so building straight up doesn't need any +/// pre-allocation. #[derive(Clone)] pub struct Chunk { pub blocks: Vec, @@ -125,26 +138,29 @@ pub struct Chunk { impl Chunk { pub fn new(coord: IVec3) -> Self { Self { - blocks: vec![Block::Air; (CHUNK_SIZE * CHUNK_HEIGHT * CHUNK_SIZE) as usize], + blocks: vec![Block::Air; (CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE) as usize], coord, dirty: true, } } + /// Flatten (x, y, z) within this chunk into the linear array index. + /// Layout is z-major then y then x; chosen so iterating x last is + /// cache-friendly for the greedy-mesh axis loops. #[inline] pub fn index(x: i32, y: i32, z: i32) -> usize { ((y * CHUNK_SIZE + z) * CHUNK_SIZE + x) as usize } pub fn get(&self, x: i32, y: i32, z: i32) -> Block { - if x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT || z < 0 || z >= CHUNK_SIZE { + if x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_SIZE || z < 0 || z >= CHUNK_SIZE { return Block::Air; } self.blocks[Self::index(x, y, z)] } pub fn set(&mut self, x: i32, y: i32, z: i32, b: Block) { - if x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT || z < 0 || z >= CHUNK_SIZE { + if x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_SIZE || z < 0 || z >= CHUNK_SIZE { return; } self.blocks[Self::index(x, y, z)] = b; @@ -152,13 +168,14 @@ impl Chunk { } } -/// Per-chunk topmost-solid-Y map: `heights[z * CHUNK_SIZE + x]` = -/// highest world-Y at which `(chunk_x*16+x, y, chunk_z*16+z)` is a -/// solid voxel. `i32::MIN` if no solid in the column. +/// Per-column top-of-solid map. Keyed by horizontal chunk coord +/// `(cx, cz)` and indexed by chunk-local `(lx, lz)` in `0..CHUNK_SIZE`. +/// Each entry is the highest world-Y at which `(cx*16+lx, *, cz*16+lz)` +/// contains a solid voxel, across all vertical chunks at that column. +/// `i32::MIN` if the column has no solid block anywhere. /// -/// Computed in O(N²·CHUNK_HEIGHT) once per chunk and cached so the -/// sky_visibility bake can do O(1) array lookups instead of casting -/// hemisphere rays. Recomputed on edit via `Chunk::dirty_heightmap`. +/// Used by `sim::lighting::compute_ambience_fast` to do O(1) lookups +/// instead of casting hemisphere rays. Recomputed on edit per column. #[derive(Clone, Debug)] pub struct HeightMap { pub heights: Vec, @@ -171,8 +188,6 @@ impl HeightMap { } } - /// World-space `(x, z)` highest solid Y in this chunk. Caller must - /// convert to chunk-local coords (0..CHUNK_SIZE). #[inline] pub fn get_local(&self, lx: i32, lz: i32) -> i32 { if lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE { @@ -181,19 +196,36 @@ impl HeightMap { self.heights[(lz * CHUNK_SIZE + lx) as usize] } - pub fn from_chunk(chunk: &Chunk) -> Self { + /// Build the heightmap by scanning every chunk at `(cx, *, cz)` + /// top-down for the topmost solid block. Cubic chunks mean we + /// can't assume a single Y range; we just look at whatever + /// chunks exist in that column. + pub fn from_world_column(world: &World, cx: i32, cz: i32) -> Self { let mut h = Self::new(); - for z in 0..CHUNK_SIZE { - for x in 0..CHUNK_SIZE { + // Collect cy values that exist in this column, then process + // top-down so we early-exit per (lx, lz) on first solid hit. + let mut cy_list: Vec = world + .chunks + .keys() + .filter(|c| c.x == cx && c.z == cz) + .map(|c| c.y) + .collect(); + cy_list.sort_unstable_by(|a, b| b.cmp(a)); // descending + + for lx in 0..CHUNK_SIZE { + for lz in 0..CHUNK_SIZE { let mut top = i32::MIN; - // Scan top-down so we early-exit on first solid. - for y in (0..CHUNK_HEIGHT).rev() { - if chunk.blocks[Chunk::index(x, y, z)].solid() { - top = y; - break; + 'cy: for cy in &cy_list { + let coord = IVec3::new(cx, *cy, cz); + let Some(chunk) = world.chunks.get(&coord) else { continue }; + for ly in (0..CHUNK_SIZE).rev() { + if chunk.blocks[Chunk::index(lx, ly, lz)].solid() { + top = cy * CHUNK_SIZE + ly; + break 'cy; + } } } - h.heights[(z * CHUNK_SIZE + x) as usize] = top; + h.heights[(lz * CHUNK_SIZE + lx) as usize] = top; } } h @@ -208,12 +240,8 @@ impl Default for HeightMap { pub struct World { pub chunks: HashMap, - /// Lazily-built per-chunk heightmap cache. `World::heightmap()` - /// computes-and-caches; mesh rebuilds invalidate via `set_block`. - /// Kept on `World` rather than `Chunk` so it can be recomputed in - /// a single immutable-borrow pass without aliasing the chunks - /// HashMap. - pub heightmaps: HashMap, + /// Per-(cx, cz) column heightmap cache. Invalidated on `set_block`. + pub heightmaps: HashMap, } impl World { @@ -221,9 +249,11 @@ impl World { let mut chunks = HashMap::new(); for cx in -WORLD_RADIUS..=WORLD_RADIUS { for cz in -WORLD_RADIUS..=WORLD_RADIUS { - let coord = IVec3::new(cx, 0, cz); - let chunk = generate_chunk(coord); - chunks.insert(coord, chunk); + for cy in PRE_GEN_BOTTOM_CHUNK_Y..=PRE_GEN_TOP_CHUNK_Y { + let coord = IVec3::new(cx, cy, cz); + let chunk = generate_chunk(coord); + chunks.insert(coord, chunk); + } } } Self { @@ -232,51 +262,45 @@ impl World { } } - /// Get (or compute + cache) the heightmap for `chunk_coord`. Used - /// by the sky-visibility bake to do O(1) column lookups instead - /// of casting hemisphere rays. - pub fn heightmap(&mut self, chunk_coord: IVec3) -> &HeightMap { - if !self.heightmaps.contains_key(&chunk_coord) { - if let Some(chunk) = self.chunks.get(&chunk_coord) { - let h = HeightMap::from_chunk(chunk); - self.heightmaps.insert(chunk_coord, h); - } else { - self.heightmaps.insert(chunk_coord, HeightMap::new()); - } + /// Lookup or compute the heightmap for column `(cx, cz)`. + pub fn heightmap(&mut self, cx: i32, cz: i32) -> &HeightMap { + let key = IVec2::new(cx, cz); + if !self.heightmaps.contains_key(&key) { + let h = HeightMap::from_world_column(self, cx, cz); + self.heightmaps.insert(key, h); } - self.heightmaps.get(&chunk_coord).unwrap() - } - - /// Read-only heightmap fetch — returns a borrowed `Option<&HeightMap>` - /// without computing on miss. The bake path uses this after - /// `heightmap()` has populated the cache. - pub fn heightmap_get(&self, chunk_coord: IVec3) -> Option<&HeightMap> { - self.heightmaps.get(&chunk_coord) + self.heightmaps.get(&key).unwrap() } /// World-coords helper: get the topmost solid Y at world column - /// `(wx, wz)`. Returns `i32::MIN` if no solid (open sky all the - /// way down) or if the column's chunk hasn't been heightmapped. + /// `(wx, wz)`. Returns `i32::MIN` if the column has no solid block + /// or hasn't been heightmapped yet (caller should invoke + /// `heightmap(cx, cz)` first to warm the cache). pub fn column_top_y(&self, wx: i32, wz: i32) -> i32 { - let (cc, lc) = Self::block_to_chunk(IVec3::new(wx, 0, wz)); - match self.heightmaps.get(&cc) { - Some(h) => h.get_local(lc.x, lc.z), + let cx = wx.div_euclid(CHUNK_SIZE); + let cz = wz.div_euclid(CHUNK_SIZE); + let lx = wx.rem_euclid(CHUNK_SIZE); + let lz = wz.rem_euclid(CHUNK_SIZE); + match self.heightmaps.get(&IVec2::new(cx, cz)) { + Some(h) => h.get_local(lx, lz), None => i32::MIN, } } + /// Split a world block position into (chunk_coord, local_coord). + /// Both axes are 3D now — chunks stack vertically as well as + /// horizontally. pub fn block_to_chunk(pos: IVec3) -> (IVec3, IVec3) { let cx = pos.x.div_euclid(CHUNK_SIZE); + let cy = pos.y.div_euclid(CHUNK_SIZE); let cz = pos.z.div_euclid(CHUNK_SIZE); let lx = pos.x.rem_euclid(CHUNK_SIZE); + let ly = pos.y.rem_euclid(CHUNK_SIZE); let lz = pos.z.rem_euclid(CHUNK_SIZE); - (IVec3::new(cx, 0, cz), IVec3::new(lx, pos.y, lz)) + (IVec3::new(cx, cy, cz), IVec3::new(lx, ly, lz)) } pub fn get_block(&self, pos: IVec3) -> Block { - if pos.y < 0 || pos.y >= CHUNK_HEIGHT { - return Block::Air; - } let (c, l) = Self::block_to_chunk(pos); match self.chunks.get(&c) { Some(chunk) => chunk.get(l.x, l.y, l.z), @@ -284,14 +308,15 @@ impl World { } } + /// Set a block at `pos`. Creates the containing chunk if it doesn't + /// exist (so building straight up creates chunks on demand — + /// there's no fixed Y ceiling). Returns whether the world changed. pub fn set_block(&mut self, pos: IVec3, b: Block) -> bool { - if pos.y < 0 || pos.y >= CHUNK_HEIGHT { - return false; - } let (c, l) = Self::block_to_chunk(pos); - let Some(chunk) = self.chunks.get_mut(&c) else { - return false; - }; + let chunk = self + .chunks + .entry(c) + .or_insert_with(|| Chunk::new(c)); chunk.set(l.x, l.y, l.z, b); // Mark neighbors dirty too so face culling is correct. for face in Face::ALL { @@ -303,10 +328,8 @@ impl World { } } } - // Heightmap is now stale for this chunk; drop the cached - // entry so the next bake recomputes. Neighbor heightmaps - // are unaffected because columns are chunk-local. - self.heightmaps.remove(&c); + // Invalidate the column heightmap. + self.heightmaps.remove(&IVec2::new(c.x, c.z)); true } @@ -402,15 +425,17 @@ fn value_noise(x: f32, z: f32) -> f32 { a + (b - a) * v } -/// The y of the topmost natural-terrain block (i.e. ignoring any -/// player-placed edits) at world column `(x, z)`. The block *at* this y -/// is solid; `y + 1` is the first air block above the natural surface. +/// The y of the topmost natural-terrain block (ignoring player edits) +/// at world column `(x, z)`. Pure function of noise — no chunk lookup, +/// no Y bound. The block AT this y is solid; `y + 1` is the first air +/// block above the natural surface. Same semantics as the +/// pre-cubic-chunks version so all collision/spawn tests still hold. pub fn natural_surface_y(x: i32, z: i32) -> i32 { let wx = x as f32; let wz = z as f32; let n = fbm(wx * 0.04, wz * 0.04); let height = (20.0 + n * 12.0).round() as i32; - height.clamp(1, CHUNK_HEIGHT - 1) - 1 + height - 1 } fn fbm(x: f32, z: f32) -> f32 { @@ -427,46 +452,70 @@ fn fbm(x: f32, z: f32) -> f32 { sum / norm } +/// Procedurally generate one cubic chunk. Determined entirely by the +/// chunk's world coord — the same coord always produces the same chunk. +/// Trees that straddle chunk boundaries deterministically fill in their +/// portion of this chunk; the neighboring chunk fills the rest. fn generate_chunk(coord: IVec3) -> Chunk { let mut chunk = Chunk::new(coord); let ox = coord.x * CHUNK_SIZE; + let oy = coord.y * CHUNK_SIZE; let oz = coord.z * CHUNK_SIZE; + for x in 0..CHUNK_SIZE { for z in 0..CHUNK_SIZE { - let wx = (ox + x) as f32; - let wz = (oz + z) as f32; - let n = fbm(wx * 0.04, wz * 0.04); - let height = (20.0 + n * 12.0).round() as i32; - let height = height.clamp(1, CHUNK_HEIGHT - 1); - for y in 0..height { - let b = if y == height - 1 { - if height < 18 { - Block::Sand - } else { - Block::Grass - } - } else if y > height - 4 { + // surface_y is the topmost SOLID block — same semantic + // as `natural_surface_y`. Block at wy = surface_y is the + // grass/sand cap; wy > surface_y is air. + let surface_y = natural_surface_y(ox + x, oz + z); + + // Terrain fill within this cubic chunk's Y range. + for ly in 0..CHUNK_SIZE { + let wy = oy + ly; + if wy > surface_y { + break; + } + let b = if wy == surface_y { + if surface_y < 17 { Block::Sand } else { Block::Grass } + } else if wy > surface_y - 3 { Block::Dirt } else { Block::Stone }; - chunk.set(x, y, z, b); + chunk.set(x, ly, z, b); } - // Occasional tree + + // Trees, deterministic on (world_x, world_z). The trunk + // and leaves may span chunk boundaries; each chunk writes + // only the slice that falls within its own Y range. The + // neighboring chunk fills the rest using the same hash. let tree_hash = hash2(ox + x + 1000, oz + z - 1000); - if tree_hash > 0.93 && height >= 18 && height < CHUNK_HEIGHT - 8 { + if tree_hash > 0.93 && surface_y >= 17 { + // Trunk: 4 wood blocks at (surface_y+1) .. (surface_y+5) + let trunk_base = surface_y + 1; for ty in 0..4 { - chunk.set(x, height + ty, z, Block::Wood); + let wy = trunk_base + ty; + if wy >= oy && wy < oy + CHUNK_SIZE { + chunk.set(x, wy - oy, z, Block::Wood); + } } + // Leaves: ball around y = trunk_base + 3 for dx in -2..=2_i32 { for dz in -2..=2_i32 { - for dy in 3..=5_i32 { - if dx.abs() + dz.abs() + (dy - 4).abs() <= 3 { + for dy in 2..=4_i32 { + if dx.abs() + dz.abs() + (dy - 3).abs() <= 3 { let lx = x + dx; let lz = z + dz; - if lx >= 0 && lx < CHUNK_SIZE && lz >= 0 && lz < CHUNK_SIZE { - let ly = height + dy; - if ly < CHUNK_HEIGHT && chunk.get(lx, ly, lz) == Block::Air { + let wy = trunk_base + dy; + if lx >= 0 + && lx < CHUNK_SIZE + && lz >= 0 + && lz < CHUNK_SIZE + && wy >= oy + && wy < oy + CHUNK_SIZE + { + let ly = wy - oy; + if chunk.get(lx, ly, lz) == Block::Air { chunk.set(lx, ly, lz, Block::Leaves); } } @@ -494,11 +543,14 @@ mod tests { } #[test] - fn natural_surface_y_in_range() { + fn natural_surface_y_in_reasonable_range() { + // No more CHUNK_HEIGHT clamp — the surface is whatever the + // noise says. For our amplitude (20 ± 12) it should stay in + // a small band; we just sanity-check it's not absurd. for x in -200..=200 { for z in -200..=200 { let y = natural_surface_y(x, z); - assert!((0..CHUNK_HEIGHT).contains(&y), "out of range at ({},{})", x, z); + assert!((0..64).contains(&y), "surface y={} out of expected band at ({},{})", y, x, z); } } } @@ -510,7 +562,7 @@ mod tests { let origin = Vec3::new(0.5, surface as f32 + 12.0, 0.5); let hit = world.raycast(origin, Vec3::new(0.0, -1.0, 0.0), 30.0); let (hit_pos, _) = hit.expect("ray fired down at terrain must hit"); - assert_eq!(hit_pos.y, surface, "ray must hit topmost solid block"); + assert_eq!(hit_pos.y, surface, "ray must hit the topmost solid block (== surface)"); } #[test] @@ -533,4 +585,26 @@ mod tests { let manhattan = delta.x.abs() + delta.y.abs() + delta.z.abs(); assert_eq!(manhattan, 1, "prev must be one block-step from hit"); } + + #[test] + fn set_block_creates_chunks_above_pregenerated() { + // The pre-gen range stops at CHUNK_SIZE * (PRE_GEN_TOP_CHUNK_Y + 1) - 1. + // Placing a block well above that should succeed and create the chunk. + let mut world = World::new(); + let high_y = (PRE_GEN_TOP_CHUNK_Y + 5) * CHUNK_SIZE + 3; + let pos = IVec3::new(0, high_y, 0); + let ok = world.set_block(pos, Block::Stone); + assert!(ok); + assert_eq!(world.get_block(pos), Block::Stone); + } + + #[test] + fn set_block_creates_chunks_below_pregenerated() { + let mut world = World::new(); + let deep_y = (PRE_GEN_BOTTOM_CHUNK_Y - 5) * CHUNK_SIZE - 3; + let pos = IVec3::new(0, deep_y, 0); + let ok = world.set_block(pos, Block::Stone); + assert!(ok); + assert_eq!(world.get_block(pos), Block::Stone); + } }