Cubic chunks + no Y limit (foundation for LOD)

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.
This commit is contained in:
Maximus Gorog 2026-05-24 18:07:32 -06:00
parent c8def4ae45
commit b2e50b62b5
6 changed files with 228 additions and 124 deletions

View file

@ -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 bytemuck::{Pod, Zeroable};
use glam::{IVec3, Vec3}; use glam::{IVec3, Vec3};
/// Ensure heightmaps for this chunk and its 8 neighbors are cached, /// Ensure heightmaps for this chunk's (cx, cz) column + its 8 neighbor
/// so the per-vertex ambience bake can do O(1) column lookups via /// columns are cached so the per-vertex ambience bake can do O(1)
/// `world.column_top_y(..)`. Run once per chunk at the start of /// `column_top_y` lookups. Idempotent — recomputes only uncached
/// `build_chunk_mesh`. Idempotent — only computes uncached entries. /// 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) { pub fn warm_heightmaps_around(world: &mut World, coord: IVec3) {
for dx in -1..=1 { for dx in -1..=1 {
for dz 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<Vertex> = Vec::with_capacity(2048); let mut vertices: Vec<Vertex> = Vec::with_capacity(2048);
let mut indices: Vec<u32> = Vec::with_capacity(3072); let mut indices: Vec<u32> = Vec::with_capacity(3072);
let base_x = chunk.coord.x * CHUNK_SIZE; 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 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 { for face in Face::ALL {
let normal = face.normal(); 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 nx = p[0] + normal.x;
let ny = p[1] + normal.y; let ny = p[1] + normal.y;
let nz = p[2] + normal.z; let nz = p[2] + normal.z;
let neighbor_solid = if ny < 0 || ny >= CHUNK_HEIGHT { // Neighbor lookup: in-chunk if all locals in
false // [0, CHUNK_SIZE); otherwise query the world,
} else if nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE { // 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() chunk.get(nx, ny, nz).solid()
} else { } else {
world 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() .solid()
}; };
if neighbor_solid { if neighbor_solid {
continue; continue;
} }
// World-space block position. // 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; let face_plane = block_world + normal;
// 4 corner AO values, ordered (min-u min-v), (max-u min-v), // 4 corner AO values, ordered (min-u min-v), (max-u min-v),
// (max-u max-v), (min-u max-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[axis] = slice as f32;
p[u_axis] = u_val as f32; p[u_axis] = u_val as f32;
p[v_axis] = v_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 c0 = to_world(u0, v0);
let c1 = to_world(u0 + w, v0); let c1 = to_world(u0 + w, v0);
@ -415,7 +433,7 @@ pub fn name_hash(s: &str) -> u32 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 /// A world containing exactly one chunk at the origin with all blocks
/// you put into it via the closure, and nothing else. /// you put into it via the closure, and nothing else.
@ -570,8 +588,8 @@ mod tests {
// is being rearranged. // is being rearranged.
#[test] #[test]
fn world_constants_are_sane() { fn world_constants_are_sane() {
// Cubic chunks: just one CHUNK_SIZE for all three axes.
assert!(CHUNK_SIZE > 0); assert!(CHUNK_SIZE > 0);
assert!(CHUNK_HEIGHT > 0);
} }
// ---------- emit_oriented_box ---------- // ---------- emit_oriented_box ----------

View file

@ -495,10 +495,11 @@ impl Renderer {
} }
pub fn rebuild_chunk(&mut self, coord: IVec3, world: &mut World) { pub fn rebuild_chunk(&mut self, coord: IVec3, world: &mut World) {
// Heightmaps for this chunk + 8 neighbors are required by the // Heightmaps for this chunk's column + 8 neighbor columns are
// fast ambience bake. Cheap (O(N²·CHUNK_HEIGHT) per chunk, // required by the fast ambience bake. Cheap (one scan across
// cached). Called before build_chunk_mesh, never lazily inside // the column's stacked chunks, cached). Called before
// it, so the build path stays free of `&mut World`. // build_chunk_mesh, never lazily inside it, so the build path
// stays free of `&mut World`.
warm_heightmaps_around(world, coord); warm_heightmaps_around(world, coord);
let Some(chunk) = world.chunks.get(&coord) else { let Some(chunk) = world.chunks.get(&coord) else {
return; return;

View file

@ -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). /// Phase offset so a fresh world starts at noon (0.25 cycles past dawn).
pub const SUN_OFFSET: f32 = 0.25; pub const SUN_OFFSET: f32 = 0.25;
/// Y above which the sun-occlusion ray is considered to have escaped /// Y above which the sun-occlusion ray is considered to have escaped
/// the world (open sky). Must be ≥ `world::CHUNK_HEIGHT`. /// the world (open sky). Since cubic chunks now extend Y indefinitely,
pub const SUN_RAY_TOP_Y: i32 = 128; /// 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 /// Hard cap on DDA steps so a degenerate near-horizontal ray can't loop
/// forever. 512 voxels is well past any reasonable world width. /// forever. 512 voxels is well past any reasonable world width.
const SUN_RAY_MAX_STEPS: u32 = 512; const SUN_RAY_MAX_STEPS: u32 = 512;

View file

@ -1,7 +1,7 @@
//! Spawn-point selection and fall-damage. Both are pure functions of //! Spawn-point selection and fall-damage. Both are pure functions of
//! world state and a scalar input, so they're easy to pin with //! world state and a scalar input, so they're easy to pin with
//! regression tests. //! regression tests.
use crate::world::{natural_surface_y, World, CHUNK_HEIGHT}; use crate::world::{natural_surface_y, World};
use glam::{IVec3, Vec3}; use glam::{IVec3, Vec3};
/// Returns the player feet position to spawn at. Anchored to the /// 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 (x, z) = (0_i32, 0_i32);
let surface_y = natural_surface_y(x, z); let surface_y = natural_surface_y(x, z);
let mut feet_y = surface_y + 1; 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 { while feet_y < max_y {
let body_blocked = world.get_block(IVec3::new(x, feet_y, z)).solid() let body_blocked = world.get_block(IVec3::new(x, feet_y, z)).solid()
|| world.get_block(IVec3::new(x, feet_y + 1, z)).solid(); || world.get_block(IVec3::new(x, feet_y + 1, z)).solid();

View file

@ -1,7 +1,7 @@
//! Frustum + radial culling for chunk visibility. Pure function on //! Frustum + radial culling for chunk visibility. Pure function on
//! `(World, Camera, render_dist) -> Vec<ChunkCoord>`. //! `(World, Camera, render_dist) -> Vec<ChunkCoord>`.
use crate::camera::Camera; use crate::camera::Camera;
use crate::world::{World, CHUNK_HEIGHT, CHUNK_SIZE}; use crate::world::{World, CHUNK_SIZE};
use glam::{IVec3, Vec3}; use glam::{IVec3, Vec3};
/// Returns the chunk coordinates whose AABB intersects the camera's /// 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 { 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( let min = Vec3::new(
(coord.x * CHUNK_SIZE) as f32, (coord.x * CHUNK_SIZE) as f32,
0.0, (coord.y * CHUNK_SIZE) as f32,
(coord.z * 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 { for p in planes {
// p-vertex (the AABB corner furthest along plane normal). // p-vertex (the AABB corner furthest along plane normal).
let px = if p[0] > 0.0 { max.x } else { min.x }; let px = if p[0] > 0.0 { max.x } else { min.x };

View file

@ -1,10 +1,18 @@
use glam::{IVec3, Vec3}; use glam::{IVec2, IVec3, Vec3};
use std::collections::HashMap; use std::collections::HashMap;
pub const CHUNK_SIZE: i32 = 16; pub const CHUNK_SIZE: i32 = 16;
pub const CHUNK_HEIGHT: i32 = 64;
pub const WORLD_RADIUS: i32 = 8; 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)] #[repr(u8)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)] #[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Block { 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)] #[derive(Clone)]
pub struct Chunk { pub struct Chunk {
pub blocks: Vec<Block>, pub blocks: Vec<Block>,
@ -125,26 +138,29 @@ pub struct Chunk {
impl Chunk { impl Chunk {
pub fn new(coord: IVec3) -> Self { pub fn new(coord: IVec3) -> Self {
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, coord,
dirty: true, 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] #[inline]
pub fn index(x: i32, y: i32, z: i32) -> usize { pub fn index(x: i32, y: i32, z: i32) -> usize {
((y * CHUNK_SIZE + z) * CHUNK_SIZE + x) as usize ((y * CHUNK_SIZE + z) * CHUNK_SIZE + x) as usize
} }
pub fn get(&self, x: i32, y: i32, z: i32) -> Block { 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; return Block::Air;
} }
self.blocks[Self::index(x, y, z)] self.blocks[Self::index(x, y, z)]
} }
pub fn set(&mut self, x: i32, y: i32, z: i32, b: Block) { 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; return;
} }
self.blocks[Self::index(x, y, z)] = b; 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]` = /// Per-column top-of-solid map. Keyed by horizontal chunk coord
/// highest world-Y at which `(chunk_x*16+x, y, chunk_z*16+z)` is a /// `(cx, cz)` and indexed by chunk-local `(lx, lz)` in `0..CHUNK_SIZE`.
/// solid voxel. `i32::MIN` if no solid in the column. /// 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 /// Used by `sim::lighting::compute_ambience_fast` to do O(1) lookups
/// sky_visibility bake can do O(1) array lookups instead of casting /// instead of casting hemisphere rays. Recomputed on edit per column.
/// hemisphere rays. Recomputed on edit via `Chunk::dirty_heightmap`.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct HeightMap { pub struct HeightMap {
pub heights: Vec<i32>, pub heights: Vec<i32>,
@ -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] #[inline]
pub fn get_local(&self, lx: i32, lz: i32) -> i32 { pub fn get_local(&self, lx: i32, lz: i32) -> i32 {
if lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE { 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] 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(); let mut h = Self::new();
for z in 0..CHUNK_SIZE { // Collect cy values that exist in this column, then process
for x in 0..CHUNK_SIZE { // top-down so we early-exit per (lx, lz) on first solid hit.
let mut cy_list: Vec<i32> = 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; let mut top = i32::MIN;
// Scan top-down so we early-exit on first solid. 'cy: for cy in &cy_list {
for y in (0..CHUNK_HEIGHT).rev() { let coord = IVec3::new(cx, *cy, cz);
if chunk.blocks[Chunk::index(x, y, z)].solid() { let Some(chunk) = world.chunks.get(&coord) else { continue };
top = y; for ly in (0..CHUNK_SIZE).rev() {
break; 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 h
@ -208,12 +240,8 @@ impl Default for HeightMap {
pub struct World { pub struct World {
pub chunks: HashMap<IVec3, Chunk>, pub chunks: HashMap<IVec3, Chunk>,
/// Lazily-built per-chunk heightmap cache. `World::heightmap()` /// Per-(cx, cz) column heightmap cache. Invalidated on `set_block`.
/// computes-and-caches; mesh rebuilds invalidate via `set_block`. pub heightmaps: HashMap<IVec2, HeightMap>,
/// 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<IVec3, HeightMap>,
} }
impl World { impl World {
@ -221,62 +249,58 @@ impl World {
let mut chunks = HashMap::new(); let mut chunks = HashMap::new();
for cx in -WORLD_RADIUS..=WORLD_RADIUS { for cx in -WORLD_RADIUS..=WORLD_RADIUS {
for cz in -WORLD_RADIUS..=WORLD_RADIUS { for cz in -WORLD_RADIUS..=WORLD_RADIUS {
let coord = IVec3::new(cx, 0, cz); 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); let chunk = generate_chunk(coord);
chunks.insert(coord, chunk); chunks.insert(coord, chunk);
} }
} }
}
Self { Self {
chunks, chunks,
heightmaps: HashMap::new(), heightmaps: HashMap::new(),
} }
} }
/// Get (or compute + cache) the heightmap for `chunk_coord`. Used /// Lookup or compute the heightmap for column `(cx, cz)`.
/// by the sky-visibility bake to do O(1) column lookups instead pub fn heightmap(&mut self, cx: i32, cz: i32) -> &HeightMap {
/// of casting hemisphere rays. let key = IVec2::new(cx, cz);
pub fn heightmap(&mut self, chunk_coord: IVec3) -> &HeightMap { if !self.heightmaps.contains_key(&key) {
if !self.heightmaps.contains_key(&chunk_coord) { let h = HeightMap::from_world_column(self, cx, cz);
if let Some(chunk) = self.chunks.get(&chunk_coord) { self.heightmaps.insert(key, h);
let h = HeightMap::from_chunk(chunk);
self.heightmaps.insert(chunk_coord, h);
} else {
self.heightmaps.insert(chunk_coord, HeightMap::new());
} }
} self.heightmaps.get(&key).unwrap()
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)
} }
/// World-coords helper: get the topmost solid Y at world column /// World-coords helper: get the topmost solid Y at world column
/// `(wx, wz)`. Returns `i32::MIN` if no solid (open sky all the /// `(wx, wz)`. Returns `i32::MIN` if the column has no solid block
/// way down) or if the column's chunk hasn't been heightmapped. /// 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 { pub fn column_top_y(&self, wx: i32, wz: i32) -> i32 {
let (cc, lc) = Self::block_to_chunk(IVec3::new(wx, 0, wz)); let cx = wx.div_euclid(CHUNK_SIZE);
match self.heightmaps.get(&cc) { let cz = wz.div_euclid(CHUNK_SIZE);
Some(h) => h.get_local(lc.x, lc.z), 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, 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) { pub fn block_to_chunk(pos: IVec3) -> (IVec3, IVec3) {
let cx = pos.x.div_euclid(CHUNK_SIZE); 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 cz = pos.z.div_euclid(CHUNK_SIZE);
let lx = pos.x.rem_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); 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 { 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); let (c, l) = Self::block_to_chunk(pos);
match self.chunks.get(&c) { match self.chunks.get(&c) {
Some(chunk) => chunk.get(l.x, l.y, l.z), 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 { 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 (c, l) = Self::block_to_chunk(pos);
let Some(chunk) = self.chunks.get_mut(&c) else { let chunk = self
return false; .chunks
}; .entry(c)
.or_insert_with(|| Chunk::new(c));
chunk.set(l.x, l.y, l.z, b); chunk.set(l.x, l.y, l.z, b);
// Mark neighbors dirty too so face culling is correct. // Mark neighbors dirty too so face culling is correct.
for face in Face::ALL { for face in Face::ALL {
@ -303,10 +328,8 @@ impl World {
} }
} }
} }
// Heightmap is now stale for this chunk; drop the cached // Invalidate the column heightmap.
// entry so the next bake recomputes. Neighbor heightmaps self.heightmaps.remove(&IVec2::new(c.x, c.z));
// are unaffected because columns are chunk-local.
self.heightmaps.remove(&c);
true true
} }
@ -402,15 +425,17 @@ fn value_noise(x: f32, z: f32) -> f32 {
a + (b - a) * v a + (b - a) * v
} }
/// The y of the topmost natural-terrain block (i.e. ignoring any /// The y of the topmost natural-terrain block (ignoring player edits)
/// player-placed edits) at world column `(x, z)`. The block *at* this y /// at world column `(x, z)`. Pure function of noise — no chunk lookup,
/// is solid; `y + 1` is the first air block above the natural surface. /// 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 { pub fn natural_surface_y(x: i32, z: i32) -> i32 {
let wx = x as f32; let wx = x as f32;
let wz = z as f32; let wz = z as f32;
let n = fbm(wx * 0.04, wz * 0.04); let n = fbm(wx * 0.04, wz * 0.04);
let height = (20.0 + n * 12.0).round() as i32; 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 { fn fbm(x: f32, z: f32) -> f32 {
@ -427,46 +452,70 @@ fn fbm(x: f32, z: f32) -> f32 {
sum / norm 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 { fn generate_chunk(coord: IVec3) -> Chunk {
let mut chunk = Chunk::new(coord); let mut chunk = Chunk::new(coord);
let ox = coord.x * CHUNK_SIZE; let ox = coord.x * CHUNK_SIZE;
let oy = coord.y * CHUNK_SIZE;
let oz = coord.z * CHUNK_SIZE; let oz = coord.z * CHUNK_SIZE;
for x in 0..CHUNK_SIZE { for x in 0..CHUNK_SIZE {
for z in 0..CHUNK_SIZE { for z in 0..CHUNK_SIZE {
let wx = (ox + x) as f32; // surface_y is the topmost SOLID block — same semantic
let wz = (oz + z) as f32; // as `natural_surface_y`. Block at wy = surface_y is the
let n = fbm(wx * 0.04, wz * 0.04); // grass/sand cap; wy > surface_y is air.
let height = (20.0 + n * 12.0).round() as i32; let surface_y = natural_surface_y(ox + x, oz + z);
let height = height.clamp(1, CHUNK_HEIGHT - 1);
for y in 0..height { // Terrain fill within this cubic chunk's Y range.
let b = if y == height - 1 { for ly in 0..CHUNK_SIZE {
if height < 18 { let wy = oy + ly;
Block::Sand if wy > surface_y {
} else { break;
Block::Grass
} }
} else if y > height - 4 { let b = if wy == surface_y {
if surface_y < 17 { Block::Sand } else { Block::Grass }
} else if wy > surface_y - 3 {
Block::Dirt Block::Dirt
} else { } else {
Block::Stone 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); 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 { 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 dx in -2..=2_i32 {
for dz in -2..=2_i32 { for dz in -2..=2_i32 {
for dy in 3..=5_i32 { for dy in 2..=4_i32 {
if dx.abs() + dz.abs() + (dy - 4).abs() <= 3 { if dx.abs() + dz.abs() + (dy - 3).abs() <= 3 {
let lx = x + dx; let lx = x + dx;
let lz = z + dz; let lz = z + dz;
if lx >= 0 && lx < CHUNK_SIZE && lz >= 0 && lz < CHUNK_SIZE { let wy = trunk_base + dy;
let ly = height + dy; if lx >= 0
if ly < CHUNK_HEIGHT && chunk.get(lx, ly, lz) == Block::Air { && 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); chunk.set(lx, ly, lz, Block::Leaves);
} }
} }
@ -494,11 +543,14 @@ mod tests {
} }
#[test] #[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 x in -200..=200 {
for z in -200..=200 { for z in -200..=200 {
let y = natural_surface_y(x, z); 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 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 = 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"); 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] #[test]
@ -533,4 +585,26 @@ mod tests {
let manhattan = delta.x.abs() + delta.y.abs() + delta.z.abs(); let manhattan = delta.x.abs() + delta.y.abs() + delta.z.abs();
assert_eq!(manhattan, 1, "prev must be one block-step from hit"); 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);
}
} }