From 97ac609316fea4c5686d831b427b885fc2b9cbc2 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 21 Dec 2025 00:42:34 +0000 Subject: [PATCH 1/6] feat: implement worm caves and noise cavities per cave-system.md - Add CaveSystem module with worm/tunnel cave generation - Implement 2D cave region mask to control cave distribution - Add seeded worm caves that cross chunk boundaries deterministically - Implement noise cavities for small chambers and pockets - Surface protection prevents caves within 10 blocks of surface - Caves prefer mid-depth ranges (Y 20-140) - Two-pass terrain generation: compute heights, then apply caves --- src/world/world.zig | 2 +- src/world/worldgen/caves.zig | 384 +++++++++++++++++++++++++++++++ src/world/worldgen/generator.zig | 174 +++++++++----- 3 files changed, 498 insertions(+), 62 deletions(-) create mode 100644 src/world/worldgen/caves.zig diff --git a/src/world/world.zig b/src/world/world.zig index 2915c09b..4d4bfb1d 100644 --- a/src/world/world.zig +++ b/src/world/world.zig @@ -92,7 +92,7 @@ pub const World = struct { .chunks_mutex = .{}, .allocator = allocator, .render_distance = render_distance, - .generator = TerrainGenerator.init(seed), + .generator = TerrainGenerator.init(seed, allocator), .last_render_stats = .{}, .gen_queue = gen_queue, .mesh_queue = mesh_queue, diff --git a/src/world/worldgen/caves.zig b/src/world/worldgen/caves.zig new file mode 100644 index 00000000..c91e139f --- /dev/null +++ b/src/world/worldgen/caves.zig @@ -0,0 +1,384 @@ +//! Cave system per cave-system.md spec +//! Implements worm/tunnel caves and noise cavities with proper surface protection. + +const std = @import("std"); +const noise_mod = @import("noise.zig"); +const Noise = noise_mod.Noise; +const smoothstep = noise_mod.smoothstep; + +const Chunk = @import("../chunk.zig").Chunk; +const CHUNK_SIZE_X = @import("../chunk.zig").CHUNK_SIZE_X; +const CHUNK_SIZE_Y = @import("../chunk.zig").CHUNK_SIZE_Y; +const CHUNK_SIZE_Z = @import("../chunk.zig").CHUNK_SIZE_Z; +const BlockType = @import("../block.zig").BlockType; + +/// Cave system parameters +pub const CaveParams = struct { + // Section 3: Cave Region Mask (2D) + region_scale: f32 = 1.0 / 1200.0, // Large scale for regional control + region_threshold: f32 = 0.55, // Below this = no caves + + // Section 4: Surface Protection + min_surface_depth: i32 = 10, // No caves within N blocks of surface + + // Section 5: Worm Caves + worms_per_chunk_min: u32 = 0, + worms_per_chunk_max: u32 = 2, + worm_y_min: i32 = 20, + worm_y_max: i32 = 100, + worm_radius_min: f32 = 2.5, + worm_radius_max: f32 = 4.5, + worm_length_min: u32 = 50, + worm_length_max: u32 = 120, + worm_step_size: f32 = 1.5, + worm_turn_strength: f32 = 0.15, + worm_branch_chance: f32 = 0.02, + + // Section 6: Noise Cavities + cavity_scale: f32 = 1.0 / 55.0, + cavity_y_scale: f32 = 1.0 / 45.0, // Slightly stretched vertically + cavity_threshold: f32 = 0.68, + cavity_y_min: i32 = 20, + cavity_y_max: i32 = 140, + + // Sea level for underwater cave handling + sea_level: i32 = 64, +}; + +/// Cave carving data for a chunk +/// Stores which blocks should be carved as air +pub const CaveCarveMap = struct { + data: []bool, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) !CaveCarveMap { + const size = CHUNK_SIZE_X * CHUNK_SIZE_Y * CHUNK_SIZE_Z; + const data = try allocator.alloc(bool, size); + @memset(data, false); + return .{ .data = data, .allocator = allocator }; + } + + pub fn deinit(self: *CaveCarveMap) void { + self.allocator.free(self.data); + } + + pub fn set(self: *CaveCarveMap, x: u32, y: u32, z: u32, val: bool) void { + if (x >= CHUNK_SIZE_X or y >= CHUNK_SIZE_Y or z >= CHUNK_SIZE_Z) return; + self.data[x + z * CHUNK_SIZE_X + y * CHUNK_SIZE_X * CHUNK_SIZE_Z] = val; + } + + pub fn get(self: *const CaveCarveMap, x: u32, y: u32, z: u32) bool { + if (x >= CHUNK_SIZE_X or y >= CHUNK_SIZE_Y or z >= CHUNK_SIZE_Z) return false; + return self.data[x + z * CHUNK_SIZE_X + y * CHUNK_SIZE_X * CHUNK_SIZE_Z]; + } +}; + +/// Cave system generator +pub const CaveSystem = struct { + // Noise generators + region_noise: Noise, // 2D cave region mask + worm_noise: Noise, // For worm direction perturbation + cavity_noise: Noise, // 3D noise cavities + + params: CaveParams, + seed: u64, + + pub fn init(seed: u64) CaveSystem { + var prng = std.Random.DefaultPrng.init(seed +% 0xCA7E5EED); + const random = prng.random(); + + return .{ + .region_noise = Noise.init(random.int(u64)), + .worm_noise = Noise.init(random.int(u64)), + .cavity_noise = Noise.init(random.int(u64)), + .params = .{}, + .seed = seed, + }; + } + + /// Check if caves are allowed at this XZ position (2D region mask) + pub fn getCaveRegionValue(self: *const CaveSystem, x: f32, z: f32) f32 { + const p = self.params; + // fBm normalized to [0,1] + return self.region_noise.fbm2DNormalized(x, z, 3, 2.0, 0.5, p.region_scale); + } + + /// Returns true if this XZ region allows caves + pub fn isCaveRegion(self: *const CaveSystem, x: f32, z: f32) bool { + return self.getCaveRegionValue(x, z) >= self.params.region_threshold; + } + + /// Generate worm caves for a chunk and surrounding area + /// This needs to check neighboring chunks too since worms cross boundaries + pub fn generateWormCaves( + self: *const CaveSystem, + chunk: *Chunk, + surface_heights: *const [CHUNK_SIZE_X * CHUNK_SIZE_Z]i32, + allocator: std.mem.Allocator, + ) !CaveCarveMap { + var carve_map = try CaveCarveMap.init(allocator); + + const chunk_x = chunk.chunk_x; + const chunk_z = chunk.chunk_z; + const world_x = chunk.getWorldX(); + const world_z = chunk.getWorldZ(); + + // Check this chunk and neighbors for worm spawns that might affect us + // Worms can travel ~120 blocks, so check a 2-chunk radius + const check_radius: i32 = 2; + + var cz = chunk_z - check_radius; + while (cz <= chunk_z + check_radius) : (cz += 1) { + var cx = chunk_x - check_radius; + while (cx <= chunk_x + check_radius) : (cx += 1) { + // Deterministic worm spawning for this chunk + self.spawnWormsForChunk( + cx, + cz, + world_x, + world_z, + surface_heights, + &carve_map, + ); + } + } + + return carve_map; + } + + /// Spawn worms originating from a specific chunk + fn spawnWormsForChunk( + self: *const CaveSystem, + source_chunk_x: i32, + source_chunk_z: i32, + target_world_x: i32, + target_world_z: i32, + surface_heights: *const [CHUNK_SIZE_X * CHUNK_SIZE_Z]i32, + carve_map: *CaveCarveMap, + ) void { + const p = self.params; + + // Check if this source chunk is in a cave region + const source_center_x: f32 = @floatFromInt(source_chunk_x * 16 + 8); + const source_center_z: f32 = @floatFromInt(source_chunk_z * 16 + 8); + if (!self.isCaveRegion(source_center_x, source_center_z)) return; + + // Seeded RNG for this chunk's worms + const chunk_seed = self.seed +% + @as(u64, @bitCast(@as(i64, source_chunk_x))) *% 341873128712 +% + @as(u64, @bitCast(@as(i64, source_chunk_z))) *% 132897987541; + var prng = std.Random.DefaultPrng.init(chunk_seed); + const random = prng.random(); + + // Determine number of worms (biased low) + const range = p.worms_per_chunk_max - p.worms_per_chunk_min + 1; + var num_worms = p.worms_per_chunk_min + random.uintLessThan(u32, range); + // Bias toward fewer worms + if (random.float(f32) < 0.4) num_worms = @max(num_worms, 1) - 1; + + for (0..num_worms) |_| { + self.carveWorm( + source_chunk_x, + source_chunk_z, + target_world_x, + target_world_z, + surface_heights, + carve_map, + random, + ); + } + } + + /// Carve a single worm tunnel + fn carveWorm( + self: *const CaveSystem, + source_chunk_x: i32, + source_chunk_z: i32, + target_world_x: i32, + target_world_z: i32, + surface_heights: *const [CHUNK_SIZE_X * CHUNK_SIZE_Z]i32, + carve_map: *CaveCarveMap, + random: std.Random, + ) void { + const p = self.params; + + // Starting position (within source chunk) + var pos_x: f32 = @floatFromInt(source_chunk_x * 16 + @as(i32, @intCast(random.uintLessThan(u32, 16)))); + var pos_y: f32 = @floatFromInt(p.worm_y_min + @as(i32, @intCast(random.uintLessThan(u32, @intCast(p.worm_y_max - p.worm_y_min))))); + var pos_z: f32 = @floatFromInt(source_chunk_z * 16 + @as(i32, @intCast(random.uintLessThan(u32, 16)))); + + // Random initial direction + var dir_x: f32 = random.float(f32) * 2.0 - 1.0; + var dir_y: f32 = (random.float(f32) * 2.0 - 1.0) * 0.3; // Bias horizontal + var dir_z: f32 = random.float(f32) * 2.0 - 1.0; + + // Normalize direction + const len = @sqrt(dir_x * dir_x + dir_y * dir_y + dir_z * dir_z); + if (len > 0.001) { + dir_x /= len; + dir_y /= len; + dir_z /= len; + } + + // Worm parameters + const length_range = p.worm_length_max - p.worm_length_min; + const worm_length = p.worm_length_min + random.uintLessThan(u32, length_range + 1); + var radius = p.worm_radius_min + random.float(f32) * (p.worm_radius_max - p.worm_radius_min); + + // Carve the worm + var step: u32 = 0; + while (step < worm_length) : (step += 1) { + // Carve sphere at current position + self.carveSphere( + pos_x, + pos_y, + pos_z, + radius, + target_world_x, + target_world_z, + surface_heights, + carve_map, + ); + + // Move forward + pos_x += dir_x * p.worm_step_size; + pos_y += dir_y * p.worm_step_size; + pos_z += dir_z * p.worm_step_size; + + // Perturb direction using noise + const noise_x = self.worm_noise.perlin3D(pos_x * 0.05, pos_y * 0.05, pos_z * 0.05); + const noise_y = self.worm_noise.perlin3D(pos_x * 0.05 + 100, pos_y * 0.05, pos_z * 0.05); + const noise_z = self.worm_noise.perlin3D(pos_x * 0.05, pos_y * 0.05 + 100, pos_z * 0.05); + + dir_x += noise_x * p.worm_turn_strength; + dir_y += noise_y * p.worm_turn_strength * 0.5; // Less vertical turning + dir_z += noise_z * p.worm_turn_strength; + + // Keep direction somewhat horizontal + dir_y *= 0.95; + + // Re-normalize + const new_len = @sqrt(dir_x * dir_x + dir_y * dir_y + dir_z * dir_z); + if (new_len > 0.001) { + dir_x /= new_len; + dir_y /= new_len; + dir_z /= new_len; + } + + // Occasionally vary radius + if (random.float(f32) < 0.1) { + radius += (random.float(f32) - 0.5) * 0.5; + radius = std.math.clamp(radius, p.worm_radius_min, p.worm_radius_max); + } + + // Keep worm in valid Y range + if (pos_y < @as(f32, @floatFromInt(p.worm_y_min))) { + dir_y = @abs(dir_y); + } + if (pos_y > @as(f32, @floatFromInt(p.worm_y_max))) { + dir_y = -@abs(dir_y); + } + } + } + + /// Carve a sphere at the given world position + fn carveSphere( + self: *const CaveSystem, + center_x: f32, + center_y: f32, + center_z: f32, + radius: f32, + target_world_x: i32, + target_world_z: i32, + surface_heights: *const [CHUNK_SIZE_X * CHUNK_SIZE_Z]i32, + carve_map: *CaveCarveMap, + ) void { + const p = self.params; + const r_ceil: i32 = @intFromFloat(@ceil(radius)); + + var dy: i32 = -r_ceil; + while (dy <= r_ceil) : (dy += 1) { + var dz: i32 = -r_ceil; + while (dz <= r_ceil) : (dz += 1) { + var dx: i32 = -r_ceil; + while (dx <= r_ceil) : (dx += 1) { + const dist_sq = @as(f32, @floatFromInt(dx * dx + dy * dy + dz * dz)); + if (dist_sq > radius * radius) continue; + + const world_xi: i32 = @as(i32, @intFromFloat(center_x)) + dx; + const world_yi: i32 = @as(i32, @intFromFloat(center_y)) + dy; + const world_zi: i32 = @as(i32, @intFromFloat(center_z)) + dz; + + // Check if within target chunk + const local_x = world_xi - target_world_x; + const local_z = world_zi - target_world_z; + + if (local_x < 0 or local_x >= CHUNK_SIZE_X) continue; + if (local_z < 0 or local_z >= CHUNK_SIZE_Z) continue; + if (world_yi < 1 or world_yi >= CHUNK_SIZE_Y) continue; // Protect bedrock + + // Surface protection + const surface_idx = @as(usize, @intCast(local_x)) + @as(usize, @intCast(local_z)) * CHUNK_SIZE_X; + const surface_height = surface_heights[surface_idx]; + if (world_yi > surface_height - p.min_surface_depth) continue; + + // Mark for carving + carve_map.set( + @intCast(local_x), + @intCast(world_yi), + @intCast(local_z), + true, + ); + } + } + } + } + + /// Check if a block should be carved by noise cavities + pub fn shouldCarveNoiseCavity( + self: *const CaveSystem, + world_x: f32, + world_y: f32, + world_z: f32, + surface_height: i32, + cave_region_value: f32, + ) bool { + const p = self.params; + const yi: i32 = @intFromFloat(world_y); + + // Region must allow caves + if (cave_region_value < p.region_threshold) return false; + + // Surface protection + if (yi > surface_height - p.min_surface_depth) return false; + + // Depth band (caves prefer mid-depths) + const band = smoothstep( + @floatFromInt(p.cavity_y_min), + @floatFromInt(p.cavity_y_min + 30), + world_y, + ) * (1.0 - smoothstep( + @floatFromInt(p.cavity_y_max - 20), + @floatFromInt(p.cavity_y_max), + world_y, + )); + if (band < 0.1) return false; + + // 3D cavity noise + const n = self.cavity_noise.fbm3D( + world_x * p.cavity_scale, + world_y * p.cavity_y_scale, + world_z * p.cavity_scale, + 4, + 2.0, + 0.5, + 1.0, + ); + + // Threshold adjusted by cave region strength and depth band + const region_factor = (cave_region_value - p.region_threshold) / (1.0 - p.region_threshold); + const threshold = p.cavity_threshold - region_factor * 0.1 * band; + + return n > threshold; + } +}; diff --git a/src/world/worldgen/generator.zig b/src/world/worldgen/generator.zig index cad1753d..da0abb1a 100644 --- a/src/world/worldgen/generator.zig +++ b/src/world/worldgen/generator.zig @@ -7,6 +7,7 @@ const noise_mod = @import("noise.zig"); const Noise = noise_mod.Noise; const smoothstep = noise_mod.smoothstep; const clamp01 = noise_mod.clamp01; +const CaveSystem = @import("caves.zig").CaveSystem; const Chunk = @import("../chunk.zig").Chunk; const CHUNK_SIZE_X = @import("../chunk.zig").CHUNK_SIZE_X; const CHUNK_SIZE_Y = @import("../chunk.zig").CHUNK_SIZE_Y; @@ -52,13 +53,6 @@ const Params = struct { river_min: f32 = 0.74, river_max: f32 = 0.84, river_depth_max: f32 = 12.0, - - // Section 10: Caves - cave_mask_scale: f32 = 1.0 / 1200.0, - cave_3d_scale: f32 = 0.025, - cave_y_scale: f32 = 0.035, // Vertically stretched caves - cave_threshold: f32 = 0.55, - cave_surface_protection: i32 = 8, // No caves within N blocks of surface }; pub const TerrainGenerator = struct { @@ -80,16 +74,16 @@ pub const TerrainGenerator = struct { seabed_noise: Noise, river_noise: Noise, - // Cave noise (2D mask + 3D carving) - cave_mask_noise: Noise, - cave_3d_noise: Noise, + // Cave system (worm caves + noise cavities) + cave_system: CaveSystem, // Filler depth variation filler_depth_noise: Noise, params: Params, + allocator: std.mem.Allocator, - pub fn init(seed: u64) TerrainGenerator { + pub fn init(seed: u64, allocator: std.mem.Allocator) TerrainGenerator { // Derive seeds for different layers to ensure they are independent var prng = std.Random.DefaultPrng.init(seed); const random = prng.random(); @@ -106,10 +100,10 @@ pub const TerrainGenerator = struct { .coast_jitter_noise = Noise.init(random.int(u64)), .seabed_noise = Noise.init(random.int(u64)), .river_noise = Noise.init(random.int(u64)), - .cave_mask_noise = Noise.init(random.int(u64)), - .cave_3d_noise = Noise.init(random.int(u64)), + .cave_system = CaveSystem.init(seed), .filler_depth_noise = Noise.init(random.int(u64)), .params = .{}, + .allocator = allocator, }; } @@ -120,10 +114,18 @@ pub const TerrainGenerator = struct { const p = self.params; const sea: f32 = @floatFromInt(p.sea_level); + // First pass: compute surface heights and basic terrain + var surface_heights: [CHUNK_SIZE_X * CHUNK_SIZE_Z]i32 = undefined; + var biomes: [CHUNK_SIZE_X * CHUNK_SIZE_Z]Biome = undefined; + var filler_depths: [CHUNK_SIZE_X * CHUNK_SIZE_Z]i32 = undefined; + var is_ocean_flags: [CHUNK_SIZE_X * CHUNK_SIZE_Z]bool = undefined; + var cave_region_values: [CHUNK_SIZE_X * CHUNK_SIZE_Z]f32 = undefined; + var local_z: u32 = 0; while (local_z < CHUNK_SIZE_Z) : (local_z += 1) { var local_x: u32 = 0; while (local_x < CHUNK_SIZE_X) : (local_x += 1) { + const idx = local_x + local_z * CHUNK_SIZE_X; const wx: f32 = @floatFromInt(world_x + @as(i32, @intCast(local_x))); const wz: f32 = @floatFromInt(world_z + @as(i32, @intCast(local_z))); @@ -133,9 +135,9 @@ pub const TerrainGenerator = struct { const zw = wz + warp.z; // === Section 4.2-4.5: Sample core 2D fields === - const c = self.getContinentalness(xw, zw); // [0,1] - const e = self.getErosion(xw, zw); // [0,1] - const pv = self.getPeaksValleys(xw, zw); // [0,1] ridged + const c = self.getContinentalness(xw, zw); + const e = self.getErosion(xw, zw); + const pv = self.getPeaksValleys(xw, zw); // === Section 6.1: Coastline jitter === const coast_jitter = self.coast_jitter_noise.fbm2D(xw, zw, 3, 2.0, 0.5, p.coast_jitter_scale) * 0.05; @@ -155,7 +157,6 @@ pub const TerrainGenerator = struct { var is_ocean = false; if (terrain_height < sea) { is_ocean = true; - // Apply seabed variation const deep_factor = 1.0 - smoothstep(p.deep_ocean_threshold, 0.5, c_jittered); const seabed_detail = self.seabed_noise.fbm2D(xw, zw, 5, 2.0, 0.5, p.seabed_scale) * p.seabed_amp; const base_seabed = sea - 18.0 - deep_factor * 35.0; @@ -174,20 +175,57 @@ pub const TerrainGenerator = struct { const mountain_mask = self.getMountainMask(pv, e); const biome = self.selectBiome(c_jittered, e, mountain_mask, terrain_height_i, temperature, humidity, river_mask); - // === Section 9: Surface layers === - const filler_depth = self.getFillerDepth(xw, zw, e, biome); + // Store for second pass + surface_heights[idx] = terrain_height_i; + biomes[idx] = biome; + filler_depths[idx] = self.getFillerDepth(xw, zw, e, biome); + is_ocean_flags[idx] = is_ocean; + cave_region_values[idx] = self.cave_system.getCaveRegionValue(wx, wz); + } + } - // === Section 10: Cave mask === - const cave_allowed = self.getCaveAllowed(xw, zw); + // Generate worm caves (crosses chunk boundaries) + var worm_carve_map = self.cave_system.generateWormCaves(chunk, &surface_heights, self.allocator) catch { + // If allocation fails, continue without worm caves + var empty_map: ?@import("caves.zig").CaveCarveMap = null; + _ = &empty_map; + return self.generateWithoutWormCaves(chunk, &surface_heights, &biomes, &filler_depths, &is_ocean_flags, &cave_region_values, sea); + }; + defer worm_carve_map.deinit(); + + // Second pass: fill blocks with cave carving + local_z = 0; + while (local_z < CHUNK_SIZE_Z) : (local_z += 1) { + var local_x: u32 = 0; + while (local_x < CHUNK_SIZE_X) : (local_x += 1) { + const idx = local_x + local_z * CHUNK_SIZE_X; + const terrain_height_i = surface_heights[idx]; + const biome = biomes[idx]; + const filler_depth = filler_depths[idx]; + const is_ocean = is_ocean_flags[idx]; + const cave_region = cave_region_values[idx]; + + const wx: f32 = @floatFromInt(world_x + @as(i32, @intCast(local_x))); + const wz: f32 = @floatFromInt(world_z + @as(i32, @intCast(local_z))); // Fill column var y: i32 = 0; while (y < CHUNK_SIZE_Y) : (y += 1) { var block = self.getBlockAt(y, terrain_height_i, biome, filler_depth, is_ocean, sea); - // Cave carving (Section 10) + // Cave carving (worm caves + noise cavities) if (block != .air and block != .water and block != .bedrock) { - if (self.shouldCarve(wx, @floatFromInt(y), wz, terrain_height_i, cave_allowed)) { + const wy: f32 = @floatFromInt(y); + const should_carve_worm = worm_carve_map.get(local_x, @intCast(y), local_z); + const should_carve_cavity = self.cave_system.shouldCarveNoiseCavity( + wx, + wy, + wz, + terrain_height_i, + cave_region, + ); + + if (should_carve_worm or should_carve_cavity) { block = if (y < p.sea_level) .water else .air; } } @@ -208,6 +246,58 @@ pub const TerrainGenerator = struct { chunk.dirty = true; } + /// Fallback generation without worm caves (if allocation fails) + fn generateWithoutWormCaves( + self: *const TerrainGenerator, + chunk: *Chunk, + surface_heights: *const [CHUNK_SIZE_X * CHUNK_SIZE_Z]i32, + biomes: *const [CHUNK_SIZE_X * CHUNK_SIZE_Z]Biome, + filler_depths: *const [CHUNK_SIZE_X * CHUNK_SIZE_Z]i32, + is_ocean_flags: *const [CHUNK_SIZE_X * CHUNK_SIZE_Z]bool, + cave_region_values: *const [CHUNK_SIZE_X * CHUNK_SIZE_Z]f32, + sea: f32, + ) void { + const world_x = chunk.getWorldX(); + const world_z = chunk.getWorldZ(); + const p = self.params; + + var local_z: u32 = 0; + while (local_z < CHUNK_SIZE_Z) : (local_z += 1) { + var local_x: u32 = 0; + while (local_x < CHUNK_SIZE_X) : (local_x += 1) { + const idx = local_x + local_z * CHUNK_SIZE_X; + const terrain_height_i = surface_heights[idx]; + const biome = biomes[idx]; + const filler_depth = filler_depths[idx]; + const is_ocean = is_ocean_flags[idx]; + const cave_region = cave_region_values[idx]; + + const wx: f32 = @floatFromInt(world_x + @as(i32, @intCast(local_x))); + const wz: f32 = @floatFromInt(world_z + @as(i32, @intCast(local_z))); + + var y: i32 = 0; + while (y < CHUNK_SIZE_Y) : (y += 1) { + var block = self.getBlockAt(y, terrain_height_i, biome, filler_depth, is_ocean, sea); + + // Only noise cavities (no worm caves) + if (block != .air and block != .water and block != .bedrock) { + const wy: f32 = @floatFromInt(y); + if (self.cave_system.shouldCarveNoiseCavity(wx, wy, wz, terrain_height_i, cave_region)) { + block = if (y < p.sea_level) .water else .air; + } + } + + chunk.setBlock(local_x, @intCast(y), local_z, block); + } + } + } + + chunk.generated = true; + self.generateOres(chunk); + self.generateFeatures(chunk); + chunk.dirty = true; + } + // ========== Section 4.1: Domain Warping ========== fn computeWarp(self: *const TerrainGenerator, x: f32, z: f32) struct { x: f32, z: f32 } { @@ -419,44 +509,6 @@ pub const TerrainGenerator = struct { return .stone; } - // ========== Section 10: Caves ========== - - fn getCaveAllowed(self: *const TerrainGenerator, x: f32, z: f32) f32 { - const p = self.params; - const mask_val = self.cave_mask_noise.fbm2DNormalized(x, z, 3, 2.0, 0.5, p.cave_mask_scale); - return smoothstep(0.58, 0.80, mask_val); - } - - fn shouldCarve(self: *const TerrainGenerator, x: f32, y: f32, z: f32, terrain_height: i32, cave_allowed: f32) bool { - const p = self.params; - - // No caves if region doesn't allow - if (cave_allowed < 0.1) return false; - - // Surface protection - const yi: i32 = @intFromFloat(y); - if (yi > terrain_height - p.cave_surface_protection) return false; - - // Depth band preference (caves prefer mid-depths) - const band = smoothstep(12, 60, y) * (1.0 - smoothstep(120, 180, y)); - if (band < 0.1) return false; - - // 3D carving noise - const n = self.cave_3d_noise.fbm3D( - x * p.cave_3d_scale, - y * p.cave_y_scale, - z * p.cave_3d_scale, - 4, - 2.0, - 0.5, - 1.0, - ); - - // Threshold with cave_allowed influence - const threshold = p.cave_threshold + (1.0 - cave_allowed) * 0.2; - return n > threshold; - } - // ========== Ores ========== fn generateOres(self: *const TerrainGenerator, chunk: *Chunk) void { From 2f4de145ab9b0b9806a9ab7c194ef943c31a1123 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 21 Dec 2025 00:48:42 +0000 Subject: [PATCH 2/6] tune: increase cave connectivity and frequency - Lower region threshold (0.55 -> 0.42) for more cave areas - Increase worms per chunk (0-2 -> 1-3) - Longer worms (50-120 -> 80-180 blocks) - Increase check radius (2 -> 3 chunks) for cross-boundary worms - Lower noise cavity threshold (0.68 -> 0.62) - Remove bias toward fewer worms - Smoother turns (0.15 -> 0.12 turn strength) --- src/world/worldgen/caves.zig | 44 +++++++++++++++++------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/world/worldgen/caves.zig b/src/world/worldgen/caves.zig index c91e139f..bb286a4d 100644 --- a/src/world/worldgen/caves.zig +++ b/src/world/worldgen/caves.zig @@ -15,30 +15,30 @@ const BlockType = @import("../block.zig").BlockType; /// Cave system parameters pub const CaveParams = struct { // Section 3: Cave Region Mask (2D) - region_scale: f32 = 1.0 / 1200.0, // Large scale for regional control - region_threshold: f32 = 0.55, // Below this = no caves + region_scale: f32 = 1.0 / 900.0, // Smaller scale = more variation + region_threshold: f32 = 0.42, // Lower = more areas have caves // Section 4: Surface Protection - min_surface_depth: i32 = 10, // No caves within N blocks of surface + min_surface_depth: i32 = 8, // No caves within N blocks of surface // Section 5: Worm Caves - worms_per_chunk_min: u32 = 0, - worms_per_chunk_max: u32 = 2, - worm_y_min: i32 = 20, - worm_y_max: i32 = 100, + worms_per_chunk_min: u32 = 1, + worms_per_chunk_max: u32 = 3, + worm_y_min: i32 = 15, + worm_y_max: i32 = 110, worm_radius_min: f32 = 2.5, - worm_radius_max: f32 = 4.5, - worm_length_min: u32 = 50, - worm_length_max: u32 = 120, - worm_step_size: f32 = 1.5, - worm_turn_strength: f32 = 0.15, - worm_branch_chance: f32 = 0.02, + worm_radius_max: f32 = 5.0, + worm_length_min: u32 = 80, + worm_length_max: u32 = 180, + worm_step_size: f32 = 1.2, + worm_turn_strength: f32 = 0.12, + worm_branch_chance: f32 = 0.03, // Section 6: Noise Cavities - cavity_scale: f32 = 1.0 / 55.0, - cavity_y_scale: f32 = 1.0 / 45.0, // Slightly stretched vertically - cavity_threshold: f32 = 0.68, - cavity_y_min: i32 = 20, + cavity_scale: f32 = 1.0 / 50.0, + cavity_y_scale: f32 = 1.0 / 40.0, // Slightly stretched vertically + cavity_threshold: f32 = 0.62, // Lower = more cavities + cavity_y_min: i32 = 15, cavity_y_max: i32 = 140, // Sea level for underwater cave handling @@ -124,8 +124,8 @@ pub const CaveSystem = struct { const world_z = chunk.getWorldZ(); // Check this chunk and neighbors for worm spawns that might affect us - // Worms can travel ~120 blocks, so check a 2-chunk radius - const check_radius: i32 = 2; + // Worms can travel ~180 blocks, so check a 3-chunk radius + const check_radius: i32 = 3; var cz = chunk_z - check_radius; while (cz <= chunk_z + check_radius) : (cz += 1) { @@ -170,11 +170,9 @@ pub const CaveSystem = struct { var prng = std.Random.DefaultPrng.init(chunk_seed); const random = prng.random(); - // Determine number of worms (biased low) + // Determine number of worms const range = p.worms_per_chunk_max - p.worms_per_chunk_min + 1; - var num_worms = p.worms_per_chunk_min + random.uintLessThan(u32, range); - // Bias toward fewer worms - if (random.float(f32) < 0.4) num_worms = @max(num_worms, 1) - 1; + const num_worms = p.worms_per_chunk_min + random.uintLessThan(u32, range); for (0..num_worms) |_| { self.carveWorm( From 9c63aaa20fe8530c39beaa4d3eefdfa4ba4c4267 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 21 Dec 2025 00:52:38 +0000 Subject: [PATCH 3/6] update --- cave-system.md | 292 ++++++++++++++++++++++++++++++ render-stability-investigation.md | 116 ++++++++++++ worldgen-spec2.md | 272 ++++++++++++++++++++++++++++ 3 files changed, 680 insertions(+) create mode 100644 cave-system.md create mode 100644 render-stability-investigation.md create mode 100644 worldgen-spec2.md diff --git a/cave-system.md b/cave-system.md new file mode 100644 index 00000000..6a91f085 --- /dev/null +++ b/cave-system.md @@ -0,0 +1,292 @@ +Below is a **clean, engine-ready cave system spec** you can hand to your agent. +It is designed to add **interesting caves without ruining the surface** and avoids the “too many holes” problem you hit earlier. + +--- + +````md +# cave-system.md — Controlled, Natural Cave Generation (Voxel Engine) + +This spec defines a **multi-style cave system** inspired by modern Minecraft + Minetest concepts, but simplified and controllable. + +Goals: +- Large, readable cave networks +- Minimal surface perforation +- Deterministic, seeded generation +- No “swiss cheese” terrain +- Easy to tune density, rarity, and depth + +--- + +## 1) Design Principles + +1. **Caves are volumetric, not heightmap-based** +2. **Surface protection is mandatory** +3. **Caves appear in regions, not everywhere** +4. **Multiple cave types create variety** +5. **Rarity > density** + +--- + +## 2) Cave Types (v1) + +Implement **two cave systems**, layered: + +### A) Worm / Tunnel Caves (Primary) +- Long, winding tunnels +- Large connected networks +- Main exploration caves + +### B) Noise Cavities (Secondary) +- Small chambers +- Occasional bubbles / pockets +- Adds texture, not structure + +(Do NOT start with ravines or mega-caverns yet.) + +--- + +## 3) Global Cave Mask (Stops “Too Many Holes”) + +Before carving ANY caves, compute a **2D cave region mask**. + +### 3.1 Cave Region Noise (2D) +```text +C2D(x,z) = fbm2(seed+C2D, x*s, z*s, oct=3) → [0..1] +```` + +Suggested params: + +* `s = 1/900 .. 1/1500` +* Region threshold: + + * `C2D < 0.55` → NO caves + * `C2D >= 0.55` → caves allowed + +This ensures: + +* Entire regions with caves +* Entire regions with none + +--- + +## 4) Surface Protection (Critical) + +Never carve caves too close to the surface. + +### Rule + +```text +if (surfaceHeight(x,z) - y < minSurfaceDepth) → DO NOT carve +``` + +Suggested: + +* `minSurfaceDepth = 8 .. 14` + +This single rule removes: + +* Holes everywhere +* Collapsing hills +* Ugly exposed cave ceilings + +--- + +## 5) Worm / Tunnel Caves (Main System) + +### 5.1 Seeded Cave Worms + +For each chunk: + +* Seed RNG with `(worldSeed, chunkX, chunkZ, CAVE_WORM)` +* Spawn `N` worms: + + * `N = 0..2` (biased low) + +### 5.2 Worm Parameters + +Each worm has: + +* start position `(x,y,z)` +* direction vector `dir` +* radius `r` +* length `L` + +Suggested ranges: + +* `y`: 20..120 +* `r`: 2..5 +* `L`: 40..120 blocks + +### 5.3 Worm Step Algorithm + +For each step: + +1. Carve a sphere at current position +2. Move forward +3. Slightly rotate direction using noise +4. Occasionally: + + * branch (rare) + * change radius slightly + +Pseudo: + +```cpp +for i in 0..L: + carveSphere(pos, r) + dir += noiseVec3(pos) * turnStrength + dir = normalize(dir) + pos += dir * stepSize +``` + +### 5.4 Carve Rule + +For each voxel in sphere: + +* Only carve if: + + * cave mask allows + * surface protection allows + +--- + +## 6) Noise Cavities (Secondary System) + +Used for: + +* Small pockets +* Side chambers +* Irregular cave shapes + +### 6.1 3D Density Noise + +```text +D(x,y,z) = fbm3(seed+C3D, x*s, y*sY, z*s, oct=4) +``` + +Suggested: + +* `s = 1/48 .. 1/70` +* `sY = same or slightly lower` +* `threshold = 0.65 .. 0.75` + +### 6.2 Vertical Bias + +Restrict cavities to depth bands: + +```text +band = smoothstep(20, 50, y) * (1 - smoothstep(120, 160, y)) +``` + +### 6.3 Final Carve Condition + +```text +if C2D >= 0.55 +and band > 0 +and D > threshold +and surfaceDepth >= minSurfaceDepth +→ carve +``` + +--- + +## 7) Connectivity Rules (Important) + +* Worm caves define the main network +* Noise cavities must: + + * either intersect a worm cave + * or be extremely rare + +This prevents: + +* Isolated 1×1 holes +* Random floating cavities + +--- + +## 8) Performance & Chunk Safety + +### 8.1 Chunk Boundary Safety + +* Worms may cross chunk borders +* Use world-space carving, not chunk-local +* Ensure neighbor chunks mark themselves dirty + +### 8.2 Determinism + +* All cave decisions must be: + + * seed-based + * chunk-coordinate based +* No frame-based randomness + +--- + +## 9) Debug Toggles (Highly Recommended) + +* `show_cave_mask` (visualize regions) +* `disable_noise_caves` +* `disable_worm_caves` +* `draw_cave_bounds` + +These dramatically speed tuning. + +--- + +## 10) Tuning Guide (Quick Fixes) + +If caves are too common: + +* Increase `C2D threshold` +* Increase `minSurfaceDepth` +* Increase 3D noise threshold + +If caves feel boring: + +* Increase worm length +* Increase branch chance slightly +* Increase turn strength + +If caves break the surface: + +* Increase `minSurfaceDepth` +* Lower worm start Y max + +--- + +## 11) Acceptance Criteria + +* Large cave networks exist underground +* Surface terrain remains intact +* Not every mountain has holes +* Caves are discoverable but not everywhere +* Different seeds produce different cave regions + +--- + +## 12) Future Extensions (Not v1) + +* Ravines (vertical worms) +* Mega caverns +* Aquifers / flooded caves +* Biome-specific cave materials +* Stalactites / stalagmites + +--- + +End of spec. + +``` + +--- + +If you want next: +- **Ore distribution tied to cave proximity** +- **Lighting rules for caves** +- **Water/lava cave interactions** +- **Debug visualisation shaders** + +Say which one. +``` + diff --git a/render-stability-investigation.md b/render-stability-investigation.md new file mode 100644 index 00000000..c2470864 --- /dev/null +++ b/render-stability-investigation.md @@ -0,0 +1,116 @@ +# render-stability-investigation.md +## Terrain Shimmering / Morphing at High Altitude & Large Render Distance + +This document is a **handoff spec for investigation and fixes** related to terrain appearing to *morph, shimmer, crawl, or lose smoothness* when flying high and increasing render distance. + +This is **not a worldgen logic bug**. It is almost certainly a **rendering precision + depth issue**, possibly compounded by meshing or shading choices. + +The goal is to **identify the exact cause(s)** and **implement industry-standard fixes** used by voxel engines (Minecraft, Minetest, etc.). + +--- + +## 1) Observed Symptoms + +- Terrain appears to subtly move or shimmer as the camera moves. +- Effect increases: + - with higher altitude + - with larger render distance / far plane +- Most visible on: + - large flat areas + - sloped terrain + - distant mountains +- Looks like “shader movement”, but geometry is static. + +--- + +## 2) Primary Root Causes (Ranked by Likelihood) + +### 2.1 Floating-Point Precision Loss (Very Likely) +**Problem** +- Rendering uses large absolute world-space coordinates. +- GPU uses 32-bit floats. +- Precision drops as values grow larger. +- Small vertex differences become unstable frame-to-frame. + +**Symptoms** +- Shimmering terrain +- “Crawling” edges +- Motion that looks like shader artifacts + +**Industry solution** +➡ **Floating Origin / Camera-relative rendering** + +--- + +### 2.2 Depth Buffer Precision Collapse (Very Likely) +**Problem** +- Large far plane (e.g. 20k–100k+ units) +- Standard depth buffer is non-linear +- Precision concentrated near camera +- Far geometry loses depth resolution + +**Symptoms** +- Z-fighting-like shimmer +- Surfaces flicker or lose smoothness +- Artifacts worsen as render distance increases + +**Industry solution** +➡ **Reverse-Z + floating-point depth buffer + sane near plane** + +--- + +### 2.3 Shader-side Noise or Continuous LOD (Possible) +**Problem** +- Terrain noise or displacement sampled in shaders +- Or continuous LOD morphing without snapping +- Small camera movements alter sampled values + +**Symptoms** +- Terrain shape subtly changes as camera moves +- Adjacent chunks disagree slightly + +**Rule** +➡ Terrain noise must be **CPU-only**, baked into meshes. + +--- + +### 2.4 Normal / Lighting Instability (Possible) +**Problem** +- Greedy meshing + averaged normals +- Or normals reconstructed in shader +- Interpolation causes lighting shifts + +**Symptoms** +- Brightness changes with camera movement +- Looks like surface “rippling” + +**Fix** +➡ Flat shading or strict per-face normals. + +--- + +### 2.5 Aggressive Frustum Culling (Lower probability) +**Problem** +- Precision errors near frustum edges +- Chunks popping in/out rapidly + +**Fix** +➡ Conservative chunk AABBs, chunk-level culling only. + +--- + +## 3) Mandatory Fixes to Implement + +### 3.1 Floating Origin (Required) + +**Rule** +- Never send large absolute world coordinates to the GPU. + +**Implementation** +- Keep camera near `(0,0,0)` +- All chunk/world positions are computed relative to camera + +**Example** +```cpp +vec3 relativePos = worldPos - cameraWorldPos; + diff --git a/worldgen-spec2.md b/worldgen-spec2.md new file mode 100644 index 00000000..30bb849f --- /dev/null +++ b/worldgen-spec2.md @@ -0,0 +1,272 @@ +This spec replaces the earlier heightmap-only approach with a **layered noise stack** closer in spirit to modern Minecraft-style generation: multiple large-scale fields (continentalness, erosion, peaks/valleys) plus climate-driven biome placement, separate ocean shaping, and controlled 3D carving to avoid “too many holes”. + +It does **not** claim Mojang’s exact implementation (that changes over versions and is complex), but it **does** mirror the key ideas Minecraft exposes via its multi-noise biome parameters and noise settings pipeline. :contentReference[oaicite:0]{index=0} + +--- + +## 0) The real problem you’re seeing (and the fixes) + +### Symptoms +- “Worlds look samey” → too few distinct low-frequency controls; no domain warping; biome transitions too uniform. +- “Oceans too flat / fake” → using one height function for everything; seabed not varied; coastlines too smooth. +- “Too many holes” → 3D density threshold carving without constraints; caves breaking the surface too often; no cave masking near surface. + +### Fixes (high level) +1. Use **separate fields** for continents vs mountains vs erosion (not just one fBm height). +2. Use **domain warping** so patterns aren’t obviously “noise bands”. +3. Give oceans their own treatment: **coastline shaping + seabed noise**, not “sea level clamp”. +4. Make caves controlled: **cave mask** + **surface protection** + **rarity**. + +--- + +## 1) Determinism & Seeds + +- Accept `seed_string` or `seed_u64`. +- Convert string → u64 using stable hash (FNV-1a 64-bit is fine). +- Use deterministic PRNG (SplitMix64/PCG32). +- All noise samplers are seeded from `(seed_u64, salt)`. + +--- + +## 2) Chunk Inputs/Outputs + +- Terrain is defined per (x,z) column + 3D density for caves. +- Chunk generation outputs: + - block IDs + - biome ID (per column, or per 4×4 cell like MC-style) + - optional: heightmap cache + +--- + +## 3) Noise types to implement + +### 3.1 Primary noise (recommended) +- **OpenSimplex2** (2D + 3D) or classic Perlin/Simplex. +- Build **fBm** (octaves), **ridged** variant, and **domain warp** utility. + +### 3.2 Why this matches Minecraft/Minetest style +- Modern Minecraft uses multiple “multi-noise” parameters for biome decisions (temperature, humidity, continentalness, erosion, weirdness, etc.). :contentReference[oaicite:1]{index=1} +- Noise settings are configurable in datapacks; these parameters primarily drive biome placement and tie into terrain/aquifer logic in that pipeline. :contentReference[oaicite:2]{index=2} +- Minetest mapgen v7 uses a combination of 2D and 3D Perlin noise and is notable for large rivers and cave differences (useful inspiration for “less flat” water + controlled caves). :contentReference[oaicite:3]{index=3} + +--- + +## 4) Core 2D Fields (computed per column) + +All fields are sampled in **world-space** with domain warping applied first. + +### 4.1 Domain warping (anti-samey) +Compute a warp offset from low-frequency noise: +- `warp = vec2( noise2(seed+W0, x*sW, z*sW), noise2(seed+W1, x*sW, z*sW) ) * warpAmp` +- Use warped coords for subsequent sampling: +- `Xw = x + warp.x`, `Zw = z + warp.y` + +Suggested: +- `sW = 1/900` to `1/1400` +- `warpAmp = 30` to `80` blocks + +### 4.2 Continentalness C (landmass) +Purpose: big continents + ocean basins. +- `C = fbm2(seed+C0, Xw*sC, Zw*sC, oct=4)` +- Normalize to [0..1]. + +Suggested: +- `sC = 1/2200` to `1/3200` +- thresholds: + - `C < 0.35` deep ocean + - `0.35..0.46` coast / shelf + - `> 0.46` land + +### 4.3 Erosion E (cliffs vs rolling) +Purpose: places where terrain should be “sharper” vs “soft”. +- `E = fbm2(seed+E0, Xw*sE, Zw*sE, oct=4)` → [0..1] + +Suggested: +- `sE = 1/900` to `1/1400` + +Interpretation: +- low E → sharp, rugged, cliff-prone +- high E → smooth hills/plains + +### 4.4 Peaks & Valleys / Weirdness P (mountain rhythm) +Purpose: repeated large-scale mountain range rhythm but warped. +- Use ridged noise: + - `P = ridged2(seed+P0, Xw*sP, Zw*sP, oct=5)` → [0..1] + +Suggested: +- `sP = 1/700` to `1/1100` + +### 4.5 Climate: Temperature T and Humidity H +Purpose: biome variety independent of elevation bands. +- `T = fbm2(seed+T0, Xw*sT, Zw*sT, oct=3)` → [0..1] +- `H = fbm2(seed+H0, Xw*sH, Zw*sH, oct=3)` → [0..1] + +Suggested: +- `sT = 1/4000` to `1/6000` +- `sH = 1/3000` to `1/5000` + +Altitude adjustment: +- `T_adj = clamp01(T - (height / 512.0)*tempLapse)` +- `tempLapse = 0.20..0.35` + +--- + +## 5) Height Function (less flat, more structure) + +Let: +- `SEA = 64` + +### 5.1 Base land height from continentalness +Map C to a base elevation: +- `land = smoothstep(0.35, 0.75, C)` +- `baseHeight = lerp(SEA - 55, SEA + 70, land)` + +This creates: +- deep oceans +- broad continental plates +- varied inland elevation + +### 5.2 Mountains from Peaks/Valleys + low erosion +Mountains should occur where: +- peaks are high (P) AND erosion is low (rugged zones) + +Define mountain mask: +- `mMask = smoothstep(0.55, 0.85, P) * (1.0 - smoothstep(0.45, 0.80, E))` + +Mountain lift: +- `mount = pow(mMask, 1.7) * mountAmp` +- `mountAmp = 60..170` + +### 5.3 Hills / local detail +Add smaller variation: +- `detail = fbm2(seed+D0, Xw*sD, Zw*sD, oct=5) * detailAmp` +- `sD = 1/180..1/260` +- `detailAmp = 6..18` + +### 5.4 Final surface height (pre carving) +- `h0 = baseHeight + mount + detail` + +### 5.5 Cliff shaping (reduces “rounded noise blobs”) +Compute slope from sampled heights (or gradient of a noise field): +- `slope = max(|h0(x+1)-h0(x)|, |h0(z+1)-h0(z)|)` +Cliff factor: +- `cliff = smoothstep(3, 10, slope) * (1.0 - E)` +Apply: +- reduce topsoil thickness when `cliff` high +- optionally snap/terrace heights slightly in cliff regions: + - `h = mix(h0, round(h0 / step) * step, cliff * terraceStrength)` + - `step=3..6`, `terraceStrength=0.2..0.5` + +--- + +## 6) Oceans that don’t look fake + +### 6.1 Coastline roughness (prevents perfect curves) +Use a dedicated coastal noise: +- `coastJitter = fbm2(seed+OJ0, Xw*sOJ, Zw*sOJ, oct=3) * 0.05` +- Apply to the “ocean threshold”: + - effectively shift `C` by jitter near coasts +This makes shorelines irregular. + +Suggested: +- `sOJ = 1/500..1/800` + +### 6.2 Seabed / ocean floor variation +If column is ocean (final height below SEA): +- seabed height: + - `seabed = SEA - 18 - deepFactor(C)*35 + fbm2(seed+OF0, Xw*sOF, Zw*sOF, oct=5)*seabedAmp` + - `sOF=1/220..1/360`, `seabedAmp=3..10` +Where `deepFactor(C)` increases as C decreases (deep ocean basins). + +### 6.3 Waves are NOT geometry +Do not try to add “wave noise” to water surface blocks. +Keep water plane flat at SEA; make the seabed interesting. + +--- + +## 7) Rivers and Lakes (fewer “random holes”, more readable water) + +### 7.1 River mask (2D) +Use a ridged or “valley” field: +- `R = ridged2(seed+R0, Xw*sR, Zw*sR, oct=4)` → [0..1] +Rivers occur where ridges are LOW (valley lines). Convert: +- `river = 1.0 - R` +- `riverMask = smoothstep(riverMin, riverMax, river)` +Suggested: +- `sR=1/900..1/1500` +- `riverMin=0.72`, `riverMax=0.86` + +### 7.2 Carve rivers into terrain +Let `riverDepth = riverMask * riverDepthMax` +- `riverDepthMax = 6..18` +Carve: +- `h = min(h, h0 - riverDepth)` +Fill with water if `h < SEA-1`. + +--- + +## 8) Biomes (Minecraft-like multi-noise selection concept) + +Use (T_adj, H, C, E, P, altitude) to choose biome. +Minecraft exposes these types of parameters to place biomes in a “multi-noise” space. :contentReference[oaicite:4]{index=4} + +### 8.1 Biome set (v1) +- Deep Ocean, Ocean, Beach +- Plains, Forest +- Taiga (cold forest) +- Desert +- Snow/Tundra +- Mountains (high elevation + rugged) + +### 8.2 Simple decision approach (works well) +1. If `C < 0.35` → Deep Ocean +2. Else if `C < 0.46` and `abs(h-SEA) < 4` → Beach +3. Else land: + - if `altitude > SEA+95` or `mMask > 0.6` → Mountains (snow if cold) + - else pick by T/H: + - hot + dry → Desert + - temperate + humid → Forest + - temperate + dry → Plains + - cold → Taiga / Snow + +--- + +## 9) Materials & Surface Layers + +### 9.1 Top/filler logic +- Determine `topBlock` by biome. +- `fillerDepth` varies by erosion and detail: + - `fillerDepth = 3 + floor(fbm2(seed+FD0, Xw*sFD, Zw*sFD, oct=2) * 2)` +- On cliffs (`cliff > 0.6`) reduce filler to 0–1 and expose stone. + +### 9.2 Ocean floor materials +- Shallow: sand + gravel patches +- Deep: gravel + clay/silt (if you have it) + +--- + +## 10) Caves without “too many holes” + +If your current caves are “too holey”, it’s usually because: +- density threshold is too permissive +- caves are allowed near the surface +- cave noise frequency is too high +- no rarity gating + +### 10.1 Cave mask (rare + deeper) +Make a 2D cave “probability mask”: +- `Cave2 = fbm2(seed+CV2, Xw*sCV2, Zw*sCV2, oct=3)` → [0..1] +- `caveAllowed = smoothstep(0.58, 0.80, Cave2)` +This makes caves appear in regions, not everywhere. + +Suggested: +- `sCV2=1/900..1/1500` + +### 10.2 3D density field (carving) +Compute density: +- `n = fbm3(seed+CV3, x*sCV3, y*sY, z*sCV3, oct=4)` +- Add vertical bias so caves prefer certain bands: + - `band = smoothstep(12, 60, y) * (1.0 - smoothstep(120, 180, y))` +- Final carve condition: + - if `caveAllowed > 0` AND `band > 0` AN + From c904d0ddb017b702724b0505bc39e71b96ccf135 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 21 Dec 2025 00:57:37 +0000 Subject: [PATCH 4/6] safety: add comptime check for CaveCarveMap memory size Prevents compilation if chunk dimensions would exceed 1MB for the carve map, with suggestion to use sparse representation instead. --- src/world/worldgen/caves.zig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/world/worldgen/caves.zig b/src/world/worldgen/caves.zig index bb286a4d..dbddf883 100644 --- a/src/world/worldgen/caves.zig +++ b/src/world/worldgen/caves.zig @@ -47,7 +47,16 @@ pub const CaveParams = struct { /// Cave carving data for a chunk /// Stores which blocks should be carved as air +/// Memory usage: CHUNK_SIZE_X * CHUNK_SIZE_Y * CHUNK_SIZE_Z bytes (currently 65KB) pub const CaveCarveMap = struct { + // Comptime safety check: ensure carve map doesn't exceed reasonable memory + comptime { + const size = CHUNK_SIZE_X * CHUNK_SIZE_Y * CHUNK_SIZE_Z; + if (size > 1_000_000) { + @compileError("CaveCarveMap size exceeds 1MB - consider using a sparse representation"); + } + } + data: []bool, allocator: std.mem.Allocator, From 6b05cd7c2b5fa3ef5fb024f3155c64592bec6095 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 21 Dec 2025 01:00:42 +0000 Subject: [PATCH 5/6] fix: improve error handling and remove magic numbers - Add CHUNK_UNLOAD_BUFFER constant (replaces magic number 2) - Add error logging for mesh build failures instead of silent catch - Add debug logging for job queue allocation failures - Document thread safety with pin mechanism comments - Import log module in world.zig and job_system.zig --- src/engine/core/job_system.zig | 14 ++++++++++++-- src/world/world.zig | 19 +++++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/engine/core/job_system.zig b/src/engine/core/job_system.zig index 726c8052..cb66a10e 100644 --- a/src/engine/core/job_system.zig +++ b/src/engine/core/job_system.zig @@ -5,6 +5,7 @@ const Thread = std.Thread; const Mutex = Thread.Mutex; const Condition = Thread.Condition; const Chunk = @import("../../world/chunk.zig").Chunk; +const log = @import("log.zig"); pub const JobType = enum { generation, @@ -96,12 +97,21 @@ pub const JobQueue = struct { const dz = job.chunk_z - cz; var updated_job = job; updated_job.dist_sq = dx * dx + dz * dz; - temp.append(self.allocator, updated_job) catch continue; + temp.append(self.allocator, updated_job) catch { + // On allocation failure, job is dropped. This is acceptable as the chunk + // will be re-queued on next update cycle when player position changes. + log.log.debug("Job queue: dropped job during priority update (allocation failed)", .{}); + continue; + }; } // Re-add with updated priorities for (temp.items) |job| { - self.jobs.add(job) catch continue; + self.jobs.add(job) catch { + // Priority queue full or allocation failed - job dropped, will be re-queued + log.log.debug("Job queue: failed to re-add job after priority update", .{}); + continue; + }; } } diff --git a/src/world/world.zig b/src/world/world.zig index 4d4bfb1d..65be4943 100644 --- a/src/world/world.zig +++ b/src/world/world.zig @@ -15,6 +15,7 @@ const Mat4 = @import("../engine/math/mat4.zig").Mat4; const Vec3 = @import("../engine/math/vec3.zig").Vec3; const Frustum = @import("../engine/math/frustum.zig").Frustum; const Shader = @import("../engine/graphics/shader.zig").Shader; +const log = @import("../engine/core/log.zig"); const JobSystem = @import("../engine/core/job_system.zig"); const JobQueue = JobSystem.JobQueue; @@ -22,6 +23,10 @@ const WorkerPool = JobSystem.WorkerPool; const Job = JobSystem.Job; const JobType = JobSystem.JobType; +/// Buffer distance beyond render_distance for chunk unloading. +/// Prevents thrashing when player moves near chunk boundaries. +const CHUNK_UNLOAD_BUFFER: i32 = 2; + pub const ChunkKey = struct { x: i32, z: i32, @@ -144,7 +149,7 @@ pub const World = struct { // Skip if chunk is now too far from player (stale job) const dx = job.chunk_x - self.last_pc.x; const dz = job.chunk_z - self.last_pc.z; - const max_dist = self.render_distance + 2; + const max_dist = self.render_distance + CHUNK_UNLOAD_BUFFER; if (dx * dx + dz * dz > max_dist * max_dist) { // Reset state so it can be re-queued if player returns if (chunk_data.chunk.state == .generating) { @@ -154,6 +159,8 @@ pub const World = struct { return; } + // Pin chunk to prevent unloading during generation. + // The pin mechanism uses atomic refcounting to ensure thread-safe access. chunk_data.chunk.pin(); self.chunks_mutex.unlock(); @@ -178,7 +185,7 @@ pub const World = struct { // Skip if chunk is now too far from player (stale job) const dx = job.chunk_x - self.last_pc.x; const dz = job.chunk_z - self.last_pc.z; - const max_dist = self.render_distance + 2; + const max_dist = self.render_distance + CHUNK_UNLOAD_BUFFER; if (dx * dx + dz * dz > max_dist * max_dist) { if (chunk_data.chunk.state == .meshing) { chunk_data.chunk.state = .generated; @@ -187,6 +194,8 @@ pub const World = struct { return; } + // Pin chunk and neighbors to prevent unloading during mesh building. + // Uses atomic refcounting for thread-safe access across worker threads. chunk_data.chunk.pin(); const neighbors = NeighborChunks{ .north = if (self.chunks.get(ChunkKey{ .x = job.chunk_x, .z = job.chunk_z - 1 })) |d| d: { @@ -217,7 +226,9 @@ pub const World = struct { } if (chunk_data.chunk.state == .meshing and chunk_data.chunk.job_token == job.job_token) { - chunk_data.mesh.buildWithNeighbors(&chunk_data.chunk, neighbors) catch {}; + chunk_data.mesh.buildWithNeighbors(&chunk_data.chunk, neighbors) catch |err| { + log.log.err("Mesh build failed for chunk ({}, {}): {}", .{ job.chunk_x, job.chunk_z, err }); + }; chunk_data.chunk.state = .mesh_ready; } } @@ -353,7 +364,7 @@ pub const World = struct { uploads += 1; } - const unload_dist_sq = (self.render_distance + 2) * (self.render_distance + 2); + const unload_dist_sq = (self.render_distance + CHUNK_UNLOAD_BUFFER) * (self.render_distance + CHUNK_UNLOAD_BUFFER); self.chunks_mutex.lock(); var to_remove = std.ArrayListUnmanaged(ChunkKey).empty; defer to_remove.deinit(self.allocator); From 18cd3c17b87684f660db03d302688b859dcf1e1a Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Sun, 21 Dec 2025 01:06:06 +0000 Subject: [PATCH 6/6] docs: add detailed comments for cave algorithm and future TODOs - Document worm carving algorithm with step-by-step comments - Explain Perlin noise direction perturbation math - Add TODOs for biome-specific caves and debug visualization - Document CaveCarveMap memory usage and optimization options - Note alternative sparse representations for large worlds --- src/world/worldgen/caves.zig | 53 ++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/src/world/worldgen/caves.zig b/src/world/worldgen/caves.zig index dbddf883..ec4a806e 100644 --- a/src/world/worldgen/caves.zig +++ b/src/world/worldgen/caves.zig @@ -13,6 +13,12 @@ const CHUNK_SIZE_Z = @import("../chunk.zig").CHUNK_SIZE_Z; const BlockType = @import("../block.zig").BlockType; /// Cave system parameters +/// These values are tuned for natural-looking caves that don't overwhelm the terrain. +/// +/// Future enhancements: +/// - TODO: Make worm_branch_chance configurable per biome (more caves in mountains) +/// - TODO: Add debug visualization toggles (show cave regions, worm paths) +/// - TODO: Consider sparse representation for CaveCarveMap in very large worlds pub const CaveParams = struct { // Section 3: Cave Region Mask (2D) region_scale: f32 = 1.0 / 900.0, // Smaller scale = more variation @@ -45,9 +51,16 @@ pub const CaveParams = struct { sea_level: i32 = 64, }; -/// Cave carving data for a chunk -/// Stores which blocks should be carved as air -/// Memory usage: CHUNK_SIZE_X * CHUNK_SIZE_Y * CHUNK_SIZE_Z bytes (currently 65KB) +/// Cave carving data for a chunk. +/// Stores a boolean per block indicating whether it should be carved as air. +/// +/// Memory usage: CHUNK_SIZE_X * CHUNK_SIZE_Y * CHUNK_SIZE_Z bytes (currently 65KB per chunk) +/// This is allocated per-chunk during generation and freed immediately after. +/// +/// For very large worlds with bigger chunks, consider: +/// - Sparse representation (hashmap of carved positions) +/// - Run-length encoding for vertical spans +/// - Bitpacking (8 blocks per byte) pub const CaveCarveMap = struct { // Comptime safety check: ensure carve map doesn't exceed reasonable memory comptime { @@ -196,7 +209,9 @@ pub const CaveSystem = struct { } } - /// Carve a single worm tunnel + /// Carve a single worm tunnel using sphere-marching algorithm. + /// The worm moves forward while its direction is perturbed by 3D Perlin noise, + /// creating natural, winding cave tunnels. fn carveWorm( self: *const CaveSystem, source_chunk_x: i32, @@ -209,17 +224,18 @@ pub const CaveSystem = struct { ) void { const p = self.params; - // Starting position (within source chunk) + // Starting position (random point within source chunk) var pos_x: f32 = @floatFromInt(source_chunk_x * 16 + @as(i32, @intCast(random.uintLessThan(u32, 16)))); var pos_y: f32 = @floatFromInt(p.worm_y_min + @as(i32, @intCast(random.uintLessThan(u32, @intCast(p.worm_y_max - p.worm_y_min))))); var pos_z: f32 = @floatFromInt(source_chunk_z * 16 + @as(i32, @intCast(random.uintLessThan(u32, 16)))); - // Random initial direction + // Random initial direction vector + // Y component scaled by 0.3 to bias toward horizontal movement var dir_x: f32 = random.float(f32) * 2.0 - 1.0; - var dir_y: f32 = (random.float(f32) * 2.0 - 1.0) * 0.3; // Bias horizontal + var dir_y: f32 = (random.float(f32) * 2.0 - 1.0) * 0.3; var dir_z: f32 = random.float(f32) * 2.0 - 1.0; - // Normalize direction + // Normalize to unit vector const len = @sqrt(dir_x * dir_x + dir_y * dir_y + dir_z * dir_z); if (len > 0.001) { dir_x /= len; @@ -227,15 +243,15 @@ pub const CaveSystem = struct { dir_z /= len; } - // Worm parameters + // Randomize worm length and initial radius within configured ranges const length_range = p.worm_length_max - p.worm_length_min; const worm_length = p.worm_length_min + random.uintLessThan(u32, length_range + 1); var radius = p.worm_radius_min + random.float(f32) * (p.worm_radius_max - p.worm_radius_min); - // Carve the worm + // Main worm carving loop - each step carves a sphere and moves forward var step: u32 = 0; while (step < worm_length) : (step += 1) { - // Carve sphere at current position + // Carve spherical cavity at current position self.carveSphere( pos_x, pos_y, @@ -247,24 +263,27 @@ pub const CaveSystem = struct { carve_map, ); - // Move forward + // Advance position along direction vector pos_x += dir_x * p.worm_step_size; pos_y += dir_y * p.worm_step_size; pos_z += dir_z * p.worm_step_size; - // Perturb direction using noise + // === Direction Perturbation using 3D Perlin Noise === + // Sample noise at current position (scaled by 0.05 for smooth, large-scale curves) + // Offset samples by 100 units to get uncorrelated values for each axis const noise_x = self.worm_noise.perlin3D(pos_x * 0.05, pos_y * 0.05, pos_z * 0.05); const noise_y = self.worm_noise.perlin3D(pos_x * 0.05 + 100, pos_y * 0.05, pos_z * 0.05); const noise_z = self.worm_noise.perlin3D(pos_x * 0.05, pos_y * 0.05 + 100, pos_z * 0.05); + // Apply noise to direction (Y scaled by 0.5 for less vertical wandering) dir_x += noise_x * p.worm_turn_strength; - dir_y += noise_y * p.worm_turn_strength * 0.5; // Less vertical turning + dir_y += noise_y * p.worm_turn_strength * 0.5; dir_z += noise_z * p.worm_turn_strength; - // Keep direction somewhat horizontal + // Dampen vertical component to keep caves mostly horizontal dir_y *= 0.95; - // Re-normalize + // Re-normalize direction to unit length const new_len = @sqrt(dir_x * dir_x + dir_y * dir_y + dir_z * dir_z); if (new_len > 0.001) { dir_x /= new_len; @@ -272,7 +291,7 @@ pub const CaveSystem = struct { dir_z /= new_len; } - // Occasionally vary radius + // Occasionally vary tunnel radius for natural width variation if (random.float(f32) < 0.1) { radius += (random.float(f32) - 0.5) * 0.5; radius = std.math.clamp(radius, p.worm_radius_min, p.worm_radius_max);