diff --git a/assets/shaders/vulkan/terrain.frag b/assets/shaders/vulkan/terrain.frag index 71a0530c..19ba6202 100644 --- a/assets/shaders/vulkan/terrain.frag +++ b/assets/shaders/vulkan/terrain.frag @@ -425,12 +425,15 @@ float debugOutdoorFactor(float skyLight) { } vec3 computeTerrainLighting(vec3 albedo, vec3 N, vec3 V, vec3 L, float roughness, float totalShadow, float skyLight, float skyVisibility, vec3 blockLight, float ao, float ssao, out float directKeyOut, out float skyFillOut, out float blockLightOut, out float outdoorOut) { - float atmosphere = skyVisibilityFactor(skyVisibility); + float outdoor = baselineOutdoorFactor(skyVisibility); float nDotL = max(dot(N, L), 0.0); vec3 sunRadiance = global.sun_color.rgb * global.params.w * SUN_RADIANCE_TO_IRRADIANCE / PI; - vec3 direct = computeBRDF(albedo, N, V, L, roughness) * sunRadiance * nDotL * (1.0 - totalShadow); - vec3 skyIrradiance = min(computeIBLAmbient(N, roughness), IBL_CLAMP) * skyLight * atmosphere; - vec3 groundBounce = vec3(0.018) * skyLight * max(-N.y, 0.0); + vec3 direct = computeBRDF(albedo, N, V, L, roughness) * sunRadiance * nDotL * (1.0 - totalShadow) * outdoor; + float indirectSky = sqrt(clamp(skyLight, 0.0, 1.0)) * clamp(skyVisibility, 0.0, 1.0); + vec3 outdoorIrradiance = min(computeIBLAmbient(N, roughness), IBL_CLAMP); + vec3 tunnelIrradiance = vec3(0.42); + vec3 skyIrradiance = mix(tunnelIrradiance, outdoorIrradiance, outdoor) * indirectSky; + vec3 groundBounce = vec3(0.018) * indirectSky * max(-N.y, 0.0) * outdoor; vec3 propagated = sampleLPVAtlas(absoluteWorldPos(vFragPosWorld), N); vec3 indirect = albedo * (skyIrradiance + groundBounce + propagated + blockLight); // AO is an indirect-diffuse visibility term: do not darken sunlight or specular. @@ -441,7 +444,7 @@ vec3 computeTerrainLighting(vec3 albedo, vec3 N, vec3 V, vec3 L, float roughness directKeyOut = clamp(max(max(direct.r, direct.g), direct.b), 0.0, 1.0); skyFillOut = clamp(max(max((albedo * (skyIrradiance + groundBounce + propagated)).r, (albedo * (skyIrradiance + groundBounce + propagated)).g), (albedo * (skyIrradiance + groundBounce + propagated)).b), 0.0, 1.0); blockLightOut = clamp(max(blockLight.r, max(blockLight.g, blockLight.b)), 0.0, 1.0); - outdoorOut = debugOutdoorFactor(skyVisibility); + outdoorOut = outdoor; return direct + indirect; } diff --git a/assets/shaders/vulkan/terrain.frag.spv b/assets/shaders/vulkan/terrain.frag.spv index c71a14a6..7677b5b5 100644 Binary files a/assets/shaders/vulkan/terrain.frag.spv and b/assets/shaders/vulkan/terrain.frag.spv differ diff --git a/docs/lighting-architecture.md b/docs/lighting-architecture.md index 0f9e2834..40d1d2d2 100644 --- a/docs/lighting-architecture.md +++ b/docs/lighting-architecture.md @@ -1,8 +1,8 @@ # Lighting Architecture -Terrain uses one production lighting model: skylight and RGB block light stored in `PackedLight` and propagated by `WorldLightingEngine` across each connected loaded chunk component. Chunk arrival and block mutations trigger reconciliation; unloaded chunks are a propagation frontier. +Terrain uses one production lighting model: skylight and RGB block light stored in `PackedLight`. World generators seed new chunks locally; `WorldLightingEngine` propagates only mismatched boundary light when adjacent chunks arrive and reserves bounded 3x3 rebuilding for persisted lighting migrations. -World generation may seed local light for initial chunk data. Runtime reconciliation is authoritative and removes stale values before meshing. Entrance-bounce storage, directional packing, and its mesh/shader path have been retired; save files contain only `PackedLight`, so no chunk migration is needed. +Saved chunks marked with outdated lighting are reconciled before meshing. Runtime lighting updates run on the bounded mesh worker pool and queue affected chunks for remeshing when propagation finishes. Entrance-bounce storage, directional packing, and its mesh/shader path have been retired; save files contain only `PackedLight`, so no storage-format migration is needed. Terrain vertices carry normalized skylight, RGB block light, and AO. The terrain shader combines these with CSM direct lighting, IBL sky fill, and optional volumetric lighting. Cloud vertices use the packed block-light alpha byte solely as an internal cloud marker. diff --git a/docs/shaders/spirv-sizes.json b/docs/shaders/spirv-sizes.json index 104e944d..05a7c6c7 100644 --- a/docs/shaders/spirv-sizes.json +++ b/docs/shaders/spirv-sizes.json @@ -27,7 +27,7 @@ "assets/shaders/vulkan/taa.frag": 9760, "assets/shaders/vulkan/taa.vert": 1160, "assets/shaders/vulkan/terrain_debug.frag": 1336, - "assets/shaders/vulkan/terrain.frag": 63108, + "assets/shaders/vulkan/terrain.frag": 63336, "assets/shaders/vulkan/terrain.vert": 10500, "assets/shaders/vulkan/ui.frag": 744, "assets/shaders/vulkan/ui_tex.frag": 1408, diff --git a/modules/engine-core/src/job_system.zig b/modules/engine-core/src/job_system.zig index 902db453..facce546 100644 --- a/modules/engine-core/src/job_system.zig +++ b/modules/engine-core/src/job_system.zig @@ -188,11 +188,17 @@ pub const JobQueue = struct { } pub fn push(self: *JobQueue, job: Job) !void { + _ = try self.tryPush(job); + } + + /// Attempts to enqueue a job and reports whether it was accepted. + pub fn tryPush(self: *JobQueue, job: Job) !bool { self.mutex.lock(); defer self.mutex.unlock(); - if (self.stopped or self.paused) return; + if (self.stopped or self.paused) return false; try self.jobs.push(self.allocator, job); self.cond.signal(); + return true; } pub fn count(self: *JobQueue) usize { @@ -575,6 +581,18 @@ test "JobQueue.setPaused true calls cleanup on generic jobs" { try testing.expectEqual(@as(usize, 3), cleanup_count); } +test "JobQueue.tryPush reports paused submissions" { + var queue = JobQueue.init(testing.allocator); + defer queue.deinit(); + + queue.setPaused(true); + try testing.expect(!(try queue.tryPush(.{ + .type = .chunk_meshing, + .data = .{ .chunk = .{ .x = 0, .z = 0, .job_token = 1 } }, + }))); + try testing.expectEqual(@as(usize, 0), queue.count()); +} + test "JobQueue.clear with mixed job types" { var cleanup_count: usize = 0; var queue = JobQueue.init(testing.allocator); diff --git a/modules/engine-graphics/src/resource_pack.zig b/modules/engine-graphics/src/resource_pack.zig index f4c349d6..cc22bb03 100644 --- a/modules/engine-graphics/src/resource_pack.zig +++ b/modules/engine-graphics/src/resource_pack.zig @@ -99,7 +99,7 @@ pub const BLOCK_TEXTURES = [_]TextureMapping{ .{ .name = "dead_bush", .files = &.{ "dead_bush.png", "deadbush.png" } }, .{ .name = "seagrass", .files = &.{ "seagrass.png", "short_grass.png", "tall_grass.png" } }, .{ .name = "tall_seagrass", .files = &.{ "seagrass.png", "tall_grass_top.png", "tall_grass_bottom.png", "tall_grass.png" } }, - .{ .name = "kelp", .files = &.{ "kelp.png", "seagrass.png", "dried_kelp_side.png" } }, + .{ .name = "kelp", .files = &.{ "kelp.png", "seagrass.png", "dried_kelp_side.png", "vine.png", "tall_grass.png" } }, .{ .name = "seaweed", .files = &.{ "seagrass.png", "kelp.png", "tall_grass.png" } }, .{ .name = "coral_block", .files = &.{ "brain_coral_block.png", "bubble_coral_block.png", "fire_coral_block.png", "tube_coral_block.png", "horn_coral_block.png" } }, .{ .name = "coral_fan", .files = &.{ "brain_coral_fan.png", "bubble_coral_fan.png", "fire_coral_fan.png", "tube_coral_fan.png", "horn_coral_fan.png" } }, @@ -561,3 +561,9 @@ pub fn defaultPackHasAnyFile(mapping: TextureMapping) bool { } return false; } + +test "default pack provides a transparent kelp texture fallback" { + const mapping = getTextureMapping("kelp") orelse return error.MissingKelpTextureMapping; + try std.testing.expect(defaultPackHasAnyFile(mapping)); + try std.testing.expect(std.mem.eql(u8, mapping.files[mapping.files.len - 2], "vine.png")); +} diff --git a/modules/world-core/src/block_registry.zig b/modules/world-core/src/block_registry.zig index 75d68ae8..ca20ff9f 100644 --- a/modules/world-core/src/block_registry.zig +++ b/modules/world-core/src/block_registry.zig @@ -49,6 +49,13 @@ pub fn lightAttenuation(block: BlockType) u4 { return if (block == .water) 2 else 1; } +/// Direct skylight does not decay through air, but water needs enough residual +/// light for the floor of ordinary lakes to remain readable. +pub fn attenuateVerticalSkylight(light: u4, block: BlockType) u4 { + if (block != .water) return light; + return @max(light -| 2, @as(u4, 6)); +} + pub const AttachmentFaces = packed struct { top: bool = false, bottom: bool = false, @@ -433,8 +440,8 @@ pub const BLOCK_REGISTRY = blk: { // 8. Render Shape def.render_shape = switch (id) { - .tall_grass, .tall_seagrass, .kelp => .tall_cross, - .flower_red, .flower_yellow, .dead_bush, .acacia_sapling, .bamboo, .torch, .seagrass, .seaweed => .cross, + .tall_grass, .tall_seagrass => .tall_cross, + .flower_red, .flower_yellow, .dead_bush, .acacia_sapling, .bamboo, .torch, .seagrass, .kelp, .seaweed => .cross, .coral_fan => .flat_quad, .snow_layer => .flat_quad, .vine => .wall_attached, diff --git a/modules/world-core/src/block_registry_tests.zig b/modules/world-core/src/block_registry_tests.zig index 31eec7f7..f6cb0caf 100644 --- a/modules/world-core/src/block_registry_tests.zig +++ b/modules/world-core/src/block_registry_tests.zig @@ -216,7 +216,7 @@ test "aquatic vegetation blocks use cutout shapes" { const kelp = block_registry.getBlockDefinition(.kelp); try testing.expect(!kelp.is_solid); - try testing.expectEqual(block_registry.RenderShape.tall_cross, kelp.render_shape); + try testing.expectEqual(block_registry.RenderShape.cross, kelp.render_shape); const coral_fan = block_registry.getBlockDefinition(.coral_fan); try testing.expect(!coral_fan.is_solid); diff --git a/modules/world-core/src/chunk.zig b/modules/world-core/src/chunk.zig index 124cbae7..ca49abb3 100644 --- a/modules/world-core/src/chunk.zig +++ b/modules/world-core/src/chunk.zig @@ -286,8 +286,7 @@ pub const Chunk = struct { if (block_registry.getBlockDefinition(block).isOpaque()) { sky_light = 0; } else { - const attenuation = block_registry.lightAttenuation(block); - if (attenuation > 1) sky_light = if (sky_light > attenuation) sky_light - attenuation else 0; + sky_light = block_registry.attenuateVerticalSkylight(sky_light, block); } } } diff --git a/modules/world-core/src/chunk_tests.zig b/modules/world-core/src/chunk_tests.zig index 877fcb6f..a533bb14 100644 --- a/modules/world-core/src/chunk_tests.zig +++ b/modules/world-core/src/chunk_tests.zig @@ -102,6 +102,15 @@ test "Chunk.updateSkylightColumn water reduces light below water" { try testing.expectEqual(@as(u4, MAX_LIGHT - 2), chunk.getSkyLight(8, 63, 8)); } +test "Chunk.updateSkylightColumn keeps deep water readable" { + var chunk = Chunk.init(0, 0); + for (64..80) |y| chunk.setBlock(8, @intCast(y), 8, .water); + + chunk.updateSkylightColumn(8, 8); + + try testing.expectEqual(@as(u4, 6), chunk.getSkyLight(8, 63, 8)); +} + test "Chunk.updateSkylightColumn light propagates through air" { var chunk = Chunk.init(0, 0); chunk.updateSkylightColumn(8, 8); diff --git a/modules/world-meshing/src/chunk_mesh_tests.zig b/modules/world-meshing/src/chunk_mesh_tests.zig index 95641e33..ced82e0f 100644 --- a/modules/world-meshing/src/chunk_mesh_tests.zig +++ b/modules/world-meshing/src/chunk_mesh_tests.zig @@ -80,6 +80,23 @@ test "ChunkMesh emits wall-attached fixture cutout quad" { try testing.expectEqual(null, mesh.pending_fluid); } +test "ChunkMesh does not emit water faces around seagrass" { + var chunk = world_core.Chunk.init(0, 0); + chunk.setBlock(1, 1, 1, .water); + chunk.setBlock(2, 1, 1, .seagrass); + + var atlas: TextureAtlas = undefined; + atlas.tile_mappings = [_]TextureAtlas.BlockTiles{TextureAtlas.BlockTiles.uniform(7)} ** 256; + + var mesh = ChunkMesh.init(testing.allocator); + defer mesh.deinitWithoutRHI(); + + try mesh.buildWithNeighbors(&chunk, NeighborChunks.empty, &atlas); + + // The water cube exposes five faces; its east face must not wrap the cutout plant. + try testing.expectEqual(@as(usize, 30), mesh.pending_fluid.?.len); +} + test "ChunkMesh emits custom slab fixture solid mesh" { var chunk = world_core.Chunk.init(0, 0); chunk.setBlock(1, 1, 1, .stone_slab); diff --git a/modules/world-meshing/src/chunk_storage.zig b/modules/world-meshing/src/chunk_storage.zig index a7dfde54..9b7bf4b5 100644 --- a/modules/world-meshing/src/chunk_storage.zig +++ b/modules/world-meshing/src/chunk_storage.zig @@ -70,6 +70,7 @@ pub const ChunkStorage = struct { chunks: std.HashMap(ChunkKey, *ChunkData, ChunkKeyContext, 80), free_list: std.ArrayListUnmanaged(*ChunkData), chunks_mutex: sync.RwLock, + lighting_mutex: sync.Mutex, allocator: std.mem.Allocator, next_job_token: u32, @@ -78,6 +79,7 @@ pub const ChunkStorage = struct { .chunks = std.HashMap(ChunkKey, *ChunkData, ChunkKeyContext, 80).init(allocator), .free_list = .empty, .chunks_mutex = .{}, + .lighting_mutex = .{}, .allocator = allocator, .next_job_token = 1, }; diff --git a/modules/world-meshing/src/meshing/boundary.zig b/modules/world-meshing/src/meshing/boundary.zig index 6f3fe280..fbe32be4 100644 --- a/modules/world-meshing/src/meshing/boundary.zig +++ b/modules/world-meshing/src/meshing/boundary.zig @@ -75,10 +75,10 @@ pub inline fn getLightCross(chunk: *const Chunk, neighbors: NeighborChunks, x: i if (y >= CHUNK_SIZE_Y) return PackedLight.init(MAX_LIGHT, 0); if (y < 0) return PackedLight.init(0, 0); - if (x < 0) return if (neighbors.west) |w| w.getLightSafe(CHUNK_SIZE_X - 1, y, z) else PackedLight.init(MAX_LIGHT, 0); - if (x >= CHUNK_SIZE_X) return if (neighbors.east) |e| e.getLightSafe(0, y, z) else PackedLight.init(MAX_LIGHT, 0); - if (z < 0) return if (neighbors.north) |n| n.getLightSafe(x, y, CHUNK_SIZE_Z - 1) else PackedLight.init(MAX_LIGHT, 0); - if (z >= CHUNK_SIZE_Z) return if (neighbors.south) |s| s.getLightSafe(x, y, 0) else PackedLight.init(MAX_LIGHT, 0); + if (x < 0) return if (neighbors.west) |w| w.getLightSafe(CHUNK_SIZE_X - 1, y, z) else PackedLight.init(0, 0); + if (x >= CHUNK_SIZE_X) return if (neighbors.east) |e| e.getLightSafe(0, y, z) else PackedLight.init(0, 0); + if (z < 0) return if (neighbors.north) |n| n.getLightSafe(x, y, CHUNK_SIZE_Z - 1) else PackedLight.init(0, 0); + if (z >= CHUNK_SIZE_Z) return if (neighbors.south) |s| s.getLightSafe(x, y, 0) else PackedLight.init(0, 0); return chunk.getLightSafe(x, y, z); } diff --git a/modules/world-meshing/src/meshing/boundary_tests.zig b/modules/world-meshing/src/meshing/boundary_tests.zig index 83405300..05d107bd 100644 --- a/modules/world-meshing/src/meshing/boundary_tests.zig +++ b/modules/world-meshing/src/meshing/boundary_tests.zig @@ -62,10 +62,10 @@ test "getLightCross cross-chunk south neighbor" { try testing.expectEqual(@as(u4, 11), light.getSkyLight()); } -test "getLightCross null neighbor returns max light" { +test "getLightCross null neighbor returns no underground light" { var chunk = Chunk.init(0, 0); const light = boundary.getLightCross(&chunk, .empty, 16, 64, 8); - try testing.expectEqual(@as(u4, MAX_LIGHT), light.getSkyLight()); + try testing.expectEqual(@as(u4, 0), light.getSkyLight()); } test "getBiomeAt within chunk" { diff --git a/modules/world-meshing/src/meshing/greedy_mesher.zig b/modules/world-meshing/src/meshing/greedy_mesher.zig index b4855137..7e4de2d1 100644 --- a/modules/world-meshing/src/meshing/greedy_mesher.zig +++ b/modules/world-meshing/src/meshing/greedy_mesher.zig @@ -81,8 +81,11 @@ pub fn meshSlice( const b1_def = block_registry.getBlockDefinition(b1); const b2_def = block_registry.getBlockDefinition(b2); - const b1_emits = b1_def.is_solid or (b1_def.is_fluid and !b2_def.is_fluid); - const b2_emits = b2_def.is_solid or (b2_def.is_fluid and !b1_def.is_fluid); + // Cutout blocks such as seagrass occupy a voxel but do not displace + // water. Emitting a fluid cube face against them creates visible + // water slabs around every underwater plant. + const b1_emits = b1_def.is_solid or (b1_def.is_fluid and fluidFaceVisible(b2, b2_def)); + const b2_emits = b2_def.is_solid or (b2_def.is_fluid and fluidFaceVisible(b1, b1_def)); const b1_cube = b1_def.render_shape == .cube; const b2_cube = b2_def.render_shape == .cube; @@ -154,6 +157,10 @@ pub fn meshSlice( } } +fn fluidFaceVisible(neighbor: BlockType, neighbor_def: *const block_registry.BlockDefinition) bool { + return neighbor == .air or neighbor_def.is_solid; +} + /// Generate 6 vertices (2 triangles) for a greedy-merged quad. /// Computes positions, UVs, normals, AO, lighting, and biome-tinted colors. fn addGreedyFace( diff --git a/modules/world-meshing/src/meshing/tall_cross_mesher.zig b/modules/world-meshing/src/meshing/tall_cross_mesher.zig index d66932f4..68c2ec15 100644 --- a/modules/world-meshing/src/meshing/tall_cross_mesher.zig +++ b/modules/world-meshing/src/meshing/tall_cross_mesher.zig @@ -5,7 +5,7 @@ //! //! Design note: unlike vanilla Minecraft which uses separate upper/lower block //! types for tall vegetation, this engine stores tall vegetation (tall_grass, -//! tall_seagrass, kelp) as a SINGLE block whose billboard quad spans two +//! tall_seagrass) as a SINGLE block whose billboard quad spans two //! vertical blocks (y to y+2). Each tall_cross block is therefore //! self-contained: it is found at its own Y position and renders the full //! 2-block height from there. There is no separate "top half" block to detect, diff --git a/modules/world-runtime/src/chunk_queue_coordinator.zig b/modules/world-runtime/src/chunk_queue_coordinator.zig index 3a926353..fcf98018 100644 --- a/modules/world-runtime/src/chunk_queue_coordinator.zig +++ b/modules/world-runtime/src/chunk_queue_coordinator.zig @@ -325,6 +325,43 @@ pub const ChunkQueueCoordinator = struct { self.pending_upload_incoming.append(self.allocator, .{ .x = cx, .z = cz }) catch return; } + /// Immediately schedules dirty chunks near a runtime edit instead of + /// waiting for the periodic recovery scan. + pub fn requestDirtyRemesh(self: *ChunkQueueCoordinator, center_cx: i32, center_cz: i32) void { + var enqueue_refs: [9]PendingMeshRef = undefined; + var enqueue_count: usize = 0; + + self.storage.chunks_mutex.lock(); + var dz: i32 = -1; + while (dz <= 1) : (dz += 1) { + var dx: i32 = -1; + while (dx <= 1) : (dx += 1) { + const data = self.storage.chunks.get(.{ .x = center_cx + dx, .z = center_cz + dz }) orelse continue; + if (!data.chunk.dirty or !data.chunk.generated) continue; + switch (data.chunk.state) { + .generated, .renderable, .mesh_ready, .uploading => { + // Invalidate in-flight GPU/CPU mesh output before the + // replacement job is queued. + data.chunk.job_token +%= 1; + data.chunk.state = .generated; + enqueue_refs[enqueue_count] = .{ + .x = data.chunk.chunk_x, + .z = data.chunk.chunk_z, + .job_token = data.chunk.job_token, + }; + enqueue_count += 1; + }, + else => {}, + } + } + } + self.storage.chunks_mutex.unlock(); + + for (enqueue_refs[0..enqueue_count]) |ref| { + self.enqueuePendingMesh(ref.x, ref.z, ref.job_token); + } + } + fn drainPendingMesh(self: *ChunkQueueCoordinator, pc_x: i32, pc_z: i32, render_dist: i32) void { // Swap-and-clear under the pending mutex, then process outside it so // worker appends are unblocked as quickly as possible. @@ -479,7 +516,8 @@ pub const ChunkQueueCoordinator = struct { break :blk sm.loadChunk(cx, cz, &chunk_data.chunk); }; - if (load_result != .success and load_result != .success_relight_required) { + const generated_new = load_result != .success and load_result != .success_relight_required; + if (generated_new) { if (load_result == .read_error or load_result == .corrupt_data) { log.log.warn("Save load failed for chunk ({}, {}): {}, regenerating", .{ cx, cz, load_result }); } @@ -497,14 +535,22 @@ pub const ChunkQueueCoordinator = struct { self.storage.chunks_mutex.unlock(); return; } + // World generators already compute chunk-local lighting. Mark it + // current instead of rebuilding a loaded chunk neighborhood. + chunk_data.chunk.lighting_valid = true; } var lighting = WorldLightingEngine.init(self.storage, self.allocator); - const relit = lighting.reconcileChunkArrival(cx, cz) catch |err| blk: { - log.log.warn("CHUNK_LIGHTING_ERROR: ({},{}) relight failed: {}", .{ cx, cz, err }); - break :blk false; - }; - _ = relit; + _ = if (load_result == .success_relight_required) + lighting.reconcileLegacyArea(cx, cz) catch |err| blk: { + log.log.warn("CHUNK_LIGHTING_ERROR: ({},{}) legacy relight failed: {}", .{ cx, cz, err }); + break :blk false; + } + else + lighting.reconcileChunkArrival(cx, cz) catch |err| blk: { + log.log.warn("CHUNK_LIGHTING_ERROR: ({},{}) boundary reconciliation failed: {}", .{ cx, cz, err }); + break :blk false; + }; self.storage.chunks_mutex.lock(); if (!chunk_data.chunk.generated) { @@ -767,3 +813,34 @@ test "stale mesh job resets chunk to generated" { try testing.expectEqual(Chunk.State.generated, data.chunk.state); } + +test "runtime edits enqueue dirty renderable chunks immediately" { + const testing = std.testing; + var storage = ChunkStorage.init(testing.allocator); + defer storage.deinitWithoutRHI(); + + const data = try storage.getOrCreate(0, 0); + data.chunk.generated = true; + data.chunk.state = .renderable; + data.chunk.dirty = true; + + var gpu = GpuAccelerationCoordinator.init(null, null); + var coordinator = ChunkQueueCoordinator{ + .allocator = testing.allocator, + .storage = &storage, + .generator = undefined, + .atlas = undefined, + .gen_queue = undefined, + .mesh_queue = undefined, + .upload_queue = try RingBuffer(ChunkKey).init(testing.allocator, 16), + .vertex_allocator = undefined, + .gpu = &gpu, + .max_uploads_per_frame = 8, + }; + defer coordinator.deinit(); + + coordinator.requestDirtyRemesh(0, 0); + + try testing.expectEqual(Chunk.State.generated, data.chunk.state); + try testing.expectEqual(@as(usize, 1), coordinator.pending_mesh_incoming.items.len); +} diff --git a/modules/world-runtime/src/lighting_engine.zig b/modules/world-runtime/src/lighting_engine.zig index 7fe4a6ba..c9ab9571 100644 --- a/modules/world-runtime/src/lighting_engine.zig +++ b/modules/world-runtime/src/lighting_engine.zig @@ -12,8 +12,7 @@ const block_registry = world_core.block_registry; const ChunkKey = world_core.ChunkKey; const ChunkStorage = @import("world-meshing").ChunkStorage; -/// Solves light for the connected loaded component containing a chunk. -/// Unloaded chunks are a frontier; their arrival triggers another reconciliation. +/// Reconciles legacy lighting in the local chunk window and propagates edits. pub const WorldLightingEngine = struct { storage: *ChunkStorage, allocator: std.mem.Allocator, @@ -23,14 +22,69 @@ pub const WorldLightingEngine = struct { } pub fn reconcileChunkArrival(self: *WorldLightingEngine, cx: i32, cz: i32) !bool { - return try self.relightLoadedComponent(cx, cz); + self.storage.lighting_mutex.lock(); + defer self.storage.lighting_mutex.unlock(); + + var component = ComponentChunks.init(self.allocator); + defer component.deinit(); + defer { + var chunks = component.valueIterator(); + while (chunks.next()) |chunk| chunk.*.unpin(); + } + + if (!try self.pinLoadedArea(&component, cx, cz, -1, 1, -1, 1)) return false; + + var sky_queue = std.ArrayListUnmanaged(SkyNode).empty; + defer sky_queue.deinit(self.allocator); + var rgb_queue = std.ArrayListUnmanaged(RgbNode).empty; + defer rgb_queue.deinit(self.allocator); + + const center = component.get(.{ .x = cx, .z = cz }).?; + if (component.get(.{ .x = cx - 1, .z = cz })) |west| { + try seedChunkInterface(center, west, .west, self.allocator, &sky_queue, &rgb_queue); + } + if (component.get(.{ .x = cx + 1, .z = cz })) |east| { + try seedChunkInterface(center, east, .east, self.allocator, &sky_queue, &rgb_queue); + } + if (component.get(.{ .x = cx, .z = cz - 1 })) |north| { + try seedChunkInterface(center, north, .north, self.allocator, &sky_queue, &rgb_queue); + } + if (component.get(.{ .x = cx, .z = cz + 1 })) |south| { + try seedChunkInterface(center, south, .south, self.allocator, &sky_queue, &rgb_queue); + } + + try spreadSkylight(&component, self.allocator, &sky_queue); + try spreadBlockLight(&component, self.allocator, &rgb_queue); + markLightingChanged(&component); + return true; } - pub fn afterBlockMutation(self: *WorldLightingEngine, cx: i32, cz: i32) !void { - _ = try self.relightLoadedComponent(cx, cz); + pub fn reconcileLegacyArea(self: *WorldLightingEngine, cx: i32, cz: i32) !bool { + self.storage.lighting_mutex.lock(); + defer self.storage.lighting_mutex.unlock(); + return try self.relightArea(cx, cz, -1, 1, -1, 1); } - fn relightLoadedComponent(self: *WorldLightingEngine, center_cx: i32, center_cz: i32) !bool { + pub fn recomputeArea(self: *WorldLightingEngine, center_cx: i32, center_cz: i32, local_x: u32, local_z: u32) !void { + self.storage.lighting_mutex.lock(); + defer self.storage.lighting_mutex.unlock(); + + _ = local_x; + _ = local_z; + // Light from a neighboring chunk can reach well into the edited chunk, + // so reset and reseed the complete local window on every recompute. + _ = try self.relightArea(center_cx, center_cz, -1, 1, -1, 1); + } + + pub fn afterBlockRemoval(self: *WorldLightingEngine, center_cx: i32, center_cz: i32, local_x: u32, local_y: u32, local_z: u32) !void { + self.storage.lighting_mutex.lock(); + defer self.storage.lighting_mutex.unlock(); + + const min_dx: i32 = if (local_x == 0) -1 else 0; + const max_dx: i32 = if (local_x == CHUNK_SIZE_X - 1) 1 else 0; + const min_dz: i32 = if (local_z == 0) -1 else 0; + const max_dz: i32 = if (local_z == CHUNK_SIZE_Z - 1) 1 else 0; + var component = ComponentChunks.init(self.allocator); defer component.deinit(); defer { @@ -38,37 +92,29 @@ pub const WorldLightingEngine = struct { while (chunks.next()) |chunk| chunk.*.unpin(); } - // Pin the complete component while the storage map is stable, then do - // the expensive solve without blocking unrelated storage operations. - self.storage.chunks_mutex.lockShared(); - var storage_locked = true; + if (!try self.pinLoadedArea(&component, center_cx, center_cz, min_dx, max_dx, min_dz, max_dz)) return; + + var sky_queue = std.ArrayListUnmanaged(SkyNode).empty; + defer sky_queue.deinit(self.allocator); + var rgb_queue = std.ArrayListUnmanaged(RgbNode).empty; + defer rgb_queue.deinit(self.allocator); + + try seedSunlightColumn(&component, self.allocator, &sky_queue, center_cx, center_cz, local_x, local_z); + try seedLightFromNeighbors(&component, self.allocator, &sky_queue, &rgb_queue, center_cx, center_cz, local_x, local_y, local_z); + try spreadSkylight(&component, self.allocator, &sky_queue); + try spreadBlockLight(&component, self.allocator, &rgb_queue); + markLightingChanged(&component); + } + + fn relightArea(self: *WorldLightingEngine, center_cx: i32, center_cz: i32, min_dx: i32, max_dx: i32, min_dz: i32, max_dz: i32) !bool { + var component = ComponentChunks.init(self.allocator); + defer component.deinit(); defer { - if (storage_locked) self.storage.chunks_mutex.unlockShared(); - } - const center = self.storage.chunks.get(.{ .x = center_cx, .z = center_cz }) orelse return false; - if (!center.chunk.generated) return false; - try component.put(.{ .x = center_cx, .z = center_cz }, ¢er.chunk); - center.chunk.pin(); - - var discovery = std.ArrayListUnmanaged(ChunkCoords).empty; - defer discovery.deinit(self.allocator); - try discovery.append(self.allocator, .{ .cx = center_cx, .cz = center_cz }); - - var index: usize = 0; - while (index < discovery.items.len) : (index += 1) { - const coords = discovery.items[index]; - for (CARDINAL_CHUNK_OFFSETS) |offset| { - const key = ChunkKey{ .x = coords.cx + offset[0], .z = coords.cz + offset[1] }; - if (component.contains(key)) continue; - const data = self.storage.chunks.get(key) orelse continue; - if (!data.chunk.generated) continue; - try component.put(key, &data.chunk); - data.chunk.pin(); - try discovery.append(self.allocator, .{ .cx = key.x, .cz = key.z }); - } + var chunks = component.valueIterator(); + while (chunks.next()) |chunk| chunk.*.unpin(); } - self.storage.chunks_mutex.unlockShared(); - storage_locked = false; + + if (!try self.pinLoadedArea(&component, center_cx, center_cz, min_dx, max_dx, min_dz, max_dz)) return false; var sky_queue = std.ArrayListUnmanaged(SkyNode).empty; defer sky_queue.deinit(self.allocator); @@ -90,14 +136,34 @@ pub const WorldLightingEngine = struct { try spreadBlockLight(&component, self.allocator, &rgb_queue); return true; } + + fn pinLoadedArea(self: *WorldLightingEngine, component: *ComponentChunks, center_cx: i32, center_cz: i32, min_dx: i32, max_dx: i32, min_dz: i32, max_dz: i32) !bool { + self.storage.chunks_mutex.lockShared(); + defer self.storage.chunks_mutex.unlockShared(); + + const center = self.storage.chunks.get(.{ .x = center_cx, .z = center_cz }) orelse return false; + if (!center.chunk.generated) return false; + + var dz = min_dz; + while (dz <= max_dz) : (dz += 1) { + var dx = min_dx; + while (dx <= max_dx) : (dx += 1) { + const key = ChunkKey{ .x = center_cx + dx, .z = center_cz + dz }; + const data = self.storage.chunks.get(key) orelse continue; + if (!data.chunk.generated) continue; + try component.put(key, &data.chunk); + data.chunk.pin(); + } + } + return true; + } }; -const ChunkCoords = struct { cx: i32, cz: i32 }; const ComponentChunks = std.AutoHashMap(ChunkKey, *Chunk); -const SkyNode = struct { cx: i32, cz: i32, x: u8, y: u16, z: u8, light: u4 }; -const RgbNode = struct { cx: i32, cz: i32, x: u8, y: u16, z: u8, r: u4, g: u4, b: u4 }; +const SkyNode = struct { chunk: *Chunk, x: u8, y: u16, z: u8, light: u4 }; +const RgbNode = struct { chunk: *Chunk, x: u8, y: u16, z: u8, r: u4, g: u4, b: u4 }; const LoadedStep = struct { cx: i32, cz: i32, x: u32, y: u32, z: u32, chunk: *Chunk }; -const CARDINAL_CHUNK_OFFSETS = [_][2]i32{ .{ 0, -1 }, .{ 0, 1 }, .{ 1, 0 }, .{ -1, 0 } }; +const ChunkInterface = enum { west, east, north, south }; const VOXEL_NEIGHBOR_OFFSETS = [_][3]i32{ .{ 1, 0, 0 }, .{ -1, 0, 0 }, .{ 0, 1, 0 }, .{ 0, -1, 0 }, .{ 0, 0, 1 }, .{ 0, 0, -1 } }; fn resetChunkLighting(chunk: *Chunk) void { @@ -116,9 +182,8 @@ fn seedChunkSunlight(chunk: *Chunk, allocator: std.mem.Allocator, queue: *std.Ar if (sunlit and block_registry.getBlockDefinition(block).isOpaque()) sunlit = false; if (!sunlit) continue; chunk.setSkyLight(@intCast(x), uy, @intCast(z), sky_light); - try queue.append(allocator, .{ .cx = chunk.chunk_x, .cz = chunk.chunk_z, .x = @intCast(x), .y = @intCast(uy), .z = @intCast(z), .light = sky_light }); - const attenuation = block_registry.lightAttenuation(block); - if (attenuation > 1) sky_light = if (sky_light > attenuation) sky_light - attenuation else 0; + try queue.append(allocator, skyNode(chunk, @intCast(x), uy, @intCast(z), sky_light)); + sky_light = block_registry.attenuateVerticalSkylight(sky_light, block); } } } @@ -130,17 +195,115 @@ fn seedChunkBlockLight(chunk: *Chunk, allocator: std.mem.Allocator, queue: *std. const emission = block_registry.getBlockDefinition(block).light_emission; if (emission[0] == 0 and emission[1] == 0 and emission[2] == 0) continue; chunk.setBlockLightRGB(@intCast(x), @intCast(y), @intCast(z), emission[0], emission[1], emission[2]); - try queue.append(allocator, .{ .cx = chunk.chunk_x, .cz = chunk.chunk_z, .x = @intCast(x), .y = @intCast(y), .z = @intCast(z), .r = emission[0], .g = emission[1], .b = emission[2] }); + try queue.append(allocator, rgbNode(chunk, @intCast(x), @intCast(y), @intCast(z), chunk.getLight(@intCast(x), @intCast(y), @intCast(z)))); }; } +fn seedChunkInterface(center: *Chunk, neighbor: *Chunk, interface: ChunkInterface, allocator: std.mem.Allocator, sky_queue: *std.ArrayListUnmanaged(SkyNode), rgb_queue: *std.ArrayListUnmanaged(RgbNode)) !void { + for (0..CHUNK_SIZE_X) |horizontal| { + const h: u32 = @intCast(horizontal); + const positions = switch (interface) { + .west => .{ .{ @as(u32, 0), h }, .{ @as(u32, CHUNK_SIZE_X - 1), h } }, + .east => .{ .{ @as(u32, CHUNK_SIZE_X - 1), h }, .{ @as(u32, 0), h } }, + .north => .{ .{ h, @as(u32, 0) }, .{ h, @as(u32, CHUNK_SIZE_Z - 1) } }, + .south => .{ .{ h, @as(u32, CHUNK_SIZE_Z - 1) }, .{ h, @as(u32, 0) } }, + }; + // Saved/player-placed emitters can sit above generated surface heights. + for (0..CHUNK_SIZE_Y) |y| { + try seedInterfacePair(center, positions[0][0], @intCast(y), positions[0][1], neighbor, positions[1][0], positions[1][1], allocator, sky_queue, rgb_queue); + } + } +} + +fn seedInterfacePair(a: *Chunk, ax: u32, y: u32, az: u32, b: *Chunk, bx: u32, bz: u32, allocator: std.mem.Allocator, sky_queue: *std.ArrayListUnmanaged(SkyNode), rgb_queue: *std.ArrayListUnmanaged(RgbNode)) !void { + const a_light = a.getLight(ax, y, az); + const b_light = b.getLight(bx, y, bz); + + if (a_light.getSkyLight() > b_light.getSkyLight()) { + try sky_queue.append(allocator, skyNode(a, ax, y, az, a_light.getSkyLight())); + } else if (b_light.getSkyLight() > a_light.getSkyLight()) { + try sky_queue.append(allocator, skyNode(b, bx, y, bz, b_light.getSkyLight())); + } + + const a_brighter = a_light.getBlockLightR() > b_light.getBlockLightR() or a_light.getBlockLightG() > b_light.getBlockLightG() or a_light.getBlockLightB() > b_light.getBlockLightB(); + const b_brighter = b_light.getBlockLightR() > a_light.getBlockLightR() or b_light.getBlockLightG() > a_light.getBlockLightG() or b_light.getBlockLightB() > a_light.getBlockLightB(); + if (a_brighter) try rgb_queue.append(allocator, rgbNode(a, ax, y, az, a_light)); + if (b_brighter) try rgb_queue.append(allocator, rgbNode(b, bx, y, bz, b_light)); +} + +fn skyNode(chunk: *Chunk, x: u32, y: u32, z: u32, light: u4) SkyNode { + return .{ .chunk = chunk, .x = @intCast(x), .y = @intCast(y), .z = @intCast(z), .light = light }; +} + +fn rgbNode(chunk: *Chunk, x: u32, y: u32, z: u32, light: PackedLight) RgbNode { + return .{ .chunk = chunk, .x = @intCast(x), .y = @intCast(y), .z = @intCast(z), .r = light.getBlockLightR(), .g = light.getBlockLightG(), .b = light.getBlockLightB() }; +} + +fn seedSunlightColumn(component: *const ComponentChunks, allocator: std.mem.Allocator, queue: *std.ArrayListUnmanaged(SkyNode), cx: i32, cz: i32, x: u32, z: u32) !void { + const chunk = component.get(.{ .x = cx, .z = cz }) orelse return; + var sunlit = true; + var sky_light: u4 = MAX_LIGHT; + var y: i32 = CHUNK_SIZE_Y - 1; + while (y >= 0) : (y -= 1) { + const uy: u32 = @intCast(y); + const block = chunk.getBlock(x, uy, z); + if (sunlit and block_registry.getBlockDefinition(block).isOpaque()) sunlit = false; + if (!sunlit) continue; + if (chunk.getSkyLight(x, uy, z) < sky_light) { + chunk.setSkyLight(x, uy, z, sky_light); + chunk.dirty = true; + try queue.append(allocator, skyNode(chunk, x, uy, z, sky_light)); + } + sky_light = block_registry.attenuateVerticalSkylight(sky_light, block); + } +} + +fn seedLightFromNeighbors(component: *const ComponentChunks, allocator: std.mem.Allocator, sky_queue: *std.ArrayListUnmanaged(SkyNode), block_queue: *std.ArrayListUnmanaged(RgbNode), cx: i32, cz: i32, x: u32, y: u32, z: u32) !void { + const chunk = component.get(.{ .x = cx, .z = cz }) orelse return; + if (block_registry.getBlockDefinition(chunk.getBlock(x, y, z)).isOpaque()) return; + + var best_sky = chunk.getSkyLight(x, y, z); + var best_r = chunk.getLight(x, y, z).getBlockLightR(); + var best_g = chunk.getLight(x, y, z).getBlockLightG(); + var best_b = chunk.getLight(x, y, z).getBlockLightB(); + + for (VOXEL_NEIGHBOR_OFFSETS) |offset| { + const pos = stepLoadedPos(component, chunk, @intCast(x), @intCast(y), @intCast(z), offset) orelse continue; + const light = pos.chunk.getLight(pos.x, pos.y, pos.z); + best_sky = @max(best_sky, if (light.getSkyLight() > 1) light.getSkyLight() - 1 else 0); + best_r = @max(best_r, if (light.getBlockLightR() > 1) light.getBlockLightR() - 1 else 0); + best_g = @max(best_g, if (light.getBlockLightG() > 1) light.getBlockLightG() - 1 else 0); + best_b = @max(best_b, if (light.getBlockLightB() > 1) light.getBlockLightB() - 1 else 0); + } + + if (best_sky > chunk.getSkyLight(x, y, z)) { + chunk.setSkyLight(x, y, z, best_sky); + chunk.dirty = true; + try sky_queue.append(allocator, skyNode(chunk, x, y, z, best_sky)); + } + + const current = chunk.getLight(x, y, z); + if (best_r > current.getBlockLightR() or best_g > current.getBlockLightG() or best_b > current.getBlockLightB()) { + chunk.setBlockLightRGB(x, y, z, best_r, best_g, best_b); + chunk.dirty = true; + try block_queue.append(allocator, rgbNode(chunk, x, y, z, chunk.getLight(x, y, z))); + } +} + +fn markLightingChanged(component: *ComponentChunks) void { + var chunks = component.valueIterator(); + while (chunks.next()) |chunk| { + if (chunk.*.dirty) chunk.*.markLightChanged(); + } +} + fn spreadSkylight(component: *const ComponentChunks, allocator: std.mem.Allocator, queue: *std.ArrayListUnmanaged(SkyNode)) !void { var head: usize = 0; while (head < queue.items.len) : (head += 1) { const node = queue.items[head]; if (node.light <= 1) continue; for (VOXEL_NEIGHBOR_OFFSETS) |offset| { - const pos = stepLoadedPos(component, node.cx, node.cz, node.x, node.y, node.z, offset) orelse continue; + const pos = stepLoadedPos(component, node.chunk, node.x, node.y, node.z, offset) orelse continue; const block = pos.chunk.getBlock(pos.x, pos.y, pos.z); if (block_registry.getBlockDefinition(block).isOpaque()) continue; const attenuation = block_registry.lightAttenuation(block); @@ -148,7 +311,7 @@ fn spreadSkylight(component: *const ComponentChunks, allocator: std.mem.Allocato if (next_light <= pos.chunk.getSkyLight(pos.x, pos.y, pos.z)) continue; pos.chunk.setSkyLight(pos.x, pos.y, pos.z, next_light); pos.chunk.dirty = true; - try queue.append(allocator, .{ .cx = pos.cx, .cz = pos.cz, .x = @intCast(pos.x), .y = @intCast(pos.y), .z = @intCast(pos.z), .light = next_light }); + try queue.append(allocator, skyNode(pos.chunk, pos.x, pos.y, pos.z, next_light)); } } } @@ -158,7 +321,7 @@ fn spreadBlockLight(component: *const ComponentChunks, allocator: std.mem.Alloca while (head < queue.items.len) : (head += 1) { const node = queue.items[head]; for (VOXEL_NEIGHBOR_OFFSETS) |offset| { - const pos = stepLoadedPos(component, node.cx, node.cz, node.x, node.y, node.z, offset) orelse continue; + const pos = stepLoadedPos(component, node.chunk, node.x, node.y, node.z, offset) orelse continue; if (block_registry.getBlockDefinition(pos.chunk.getBlock(pos.x, pos.y, pos.z)).isOpaque()) continue; const current = pos.chunk.getLight(pos.x, pos.y, pos.z); const next_r: u4 = if (node.r > 1) node.r - 1 else 0; @@ -170,14 +333,14 @@ fn spreadBlockLight(component: *const ComponentChunks, allocator: std.mem.Alloca const b = @max(next_b, current.getBlockLightB()); pos.chunk.setBlockLightRGB(pos.x, pos.y, pos.z, r, g, b); pos.chunk.dirty = true; - try queue.append(allocator, .{ .cx = pos.cx, .cz = pos.cz, .x = @intCast(pos.x), .y = @intCast(pos.y), .z = @intCast(pos.z), .r = r, .g = g, .b = b }); + try queue.append(allocator, rgbNode(pos.chunk, pos.x, pos.y, pos.z, pos.chunk.getLight(pos.x, pos.y, pos.z))); } } } -fn stepLoadedPos(component: *const ComponentChunks, cx: i32, cz: i32, x: u8, y: u16, z: u8, offset: [3]i32) ?LoadedStep { - var ncx = cx; - var ncz = cz; +fn stepLoadedPos(component: *const ComponentChunks, current_chunk: *Chunk, x: u8, y: u16, z: u8, offset: [3]i32) ?LoadedStep { + var ncx = current_chunk.chunk_x; + var ncz = current_chunk.chunk_z; var nx = @as(i32, x) + offset[0]; const ny = @as(i32, y) + offset[1]; var nz = @as(i32, z) + offset[2]; @@ -196,7 +359,10 @@ fn stepLoadedPos(component: *const ComponentChunks, cx: i32, cz: i32, x: u8, y: ncz += 1; nz = 0; } - const chunk = component.get(.{ .x = ncx, .z = ncz }) orelse return null; + const chunk = if (ncx == current_chunk.chunk_x and ncz == current_chunk.chunk_z) + current_chunk + else + component.get(.{ .x = ncx, .z = ncz }) orelse return null; return .{ .cx = ncx, .cz = ncz, .x = @intCast(nx), .y = @intCast(ny), .z = @intCast(nz), .chunk = chunk }; } @@ -208,13 +374,14 @@ test "WorldLightingEngine propagates RGB light through loaded chunk boundaries" const east = try storage.getOrCreate(1, 0); center.chunk.generated = true; east.chunk.generated = true; - center.chunk.setBlock(CHUNK_SIZE_X - 1, 4, 1, .torch); + center.chunk.setBlock(CHUNK_SIZE_X - 1, 200, 1, .torch); + center.chunk.setBlockLightRGB(CHUNK_SIZE_X - 1, 200, 1, 15, 11, 6); var lighting = WorldLightingEngine.init(&storage, testing.allocator); _ = try lighting.reconcileChunkArrival(0, 0); - try testing.expect(east.chunk.getLight(2, 4, 1).getBlockLightR() > 0); + try testing.expect(east.chunk.getLight(2, 200, 1).getBlockLightR() > 0); } -test "WorldLightingEngine removes stale light across the loaded component" { +test "WorldLightingEngine removes stale light across an affected chunk boundary" { const testing = std.testing; var storage = ChunkStorage.init(testing.allocator); defer storage.deinitWithoutRHI(); @@ -224,9 +391,44 @@ test "WorldLightingEngine removes stale light across the loaded component" { east.chunk.generated = true; center.chunk.setBlock(CHUNK_SIZE_X - 1, 4, 1, .torch); var lighting = WorldLightingEngine.init(&storage, testing.allocator); - try lighting.afterBlockMutation(0, 0); + try lighting.recomputeArea(0, 0, CHUNK_SIZE_X - 1, 1); try testing.expect(east.chunk.getLight(2, 4, 1).getBlockLightR() > 0); center.chunk.setBlock(CHUNK_SIZE_X - 1, 4, 1, .air); - try lighting.afterBlockMutation(0, 0); + try lighting.recomputeArea(0, 0, CHUNK_SIZE_X - 1, 1); try testing.expectEqual(@as(u4, 0), east.chunk.getLight(2, 4, 1).getBlockLightR()); } + +test "WorldLightingEngine keeps unaffected chunks out of local recomputes" { + const testing = std.testing; + var storage = ChunkStorage.init(testing.allocator); + defer storage.deinitWithoutRHI(); + + const center = try storage.getOrCreate(0, 0); + const distant = try storage.getOrCreate(2, 0); + center.chunk.generated = true; + distant.chunk.generated = true; + distant.chunk.setLight(1, 1, 1, PackedLight.init(0, 13)); + + var lighting = WorldLightingEngine.init(&storage, testing.allocator); + try lighting.recomputeArea(0, 0, 4, 4); + + try testing.expect(!distant.chunk.lighting_valid); + try testing.expectEqual(@as(u4, 13), distant.chunk.getLight(1, 1, 1).getBlockLightR()); +} + +test "WorldLightingEngine preserves neighbor light during interior recompute" { + const testing = std.testing; + var storage = ChunkStorage.init(testing.allocator); + defer storage.deinitWithoutRHI(); + + const center = try storage.getOrCreate(0, 0); + const east = try storage.getOrCreate(1, 0); + center.chunk.generated = true; + east.chunk.generated = true; + east.chunk.setBlock(0, 4, 1, .torch); + + var lighting = WorldLightingEngine.init(&storage, testing.allocator); + try lighting.recomputeArea(0, 0, 4, 4); + + try testing.expect(center.chunk.getLight(CHUNK_SIZE_X - 2, 4, 1).getBlockLightR() > 0); +} diff --git a/modules/world-runtime/src/world.zig b/modules/world-runtime/src/world.zig index b0f59091..346f93b4 100644 --- a/modules/world-runtime/src/world.zig +++ b/modules/world-runtime/src/world.zig @@ -949,7 +949,12 @@ pub const World = struct { /// Applies a block mutation at world-space coordinates. /// Updates chunk data and schedules affected mesh/render state refreshes. Propagates errors from streaming, persistence, meshing, or mutation subsystems. pub fn setBlock(self: *World, world_x: i32, world_y: i32, world_z: i32, block: BlockType) !void { - _ = try self.mutation.applyBlockMutation(world_x, world_y, world_z, block); + const result = (try self.mutation.applyBlockMutation(world_x, world_y, world_z, block)) orelse return; + self.streamer.enqueueMutationLighting(&self.mutation, result) catch |err| { + log.log.warn("Failed to enqueue block lighting update, applying synchronously: {}", .{err}); + try self.mutation.updateLighting(result); + self.streamer.requestDirtyRemesh(result.chunk_x, result.chunk_z); + }; // Notify the LOD system so distant terrain reflects player edits after // the player teleports away. Coalesced on a debounce inside LODManager. if (self.lod) |lod| { diff --git a/modules/world-runtime/src/world_facade_tests.zig b/modules/world-runtime/src/world_facade_tests.zig index 2d4f7e3c..e4940fcc 100644 --- a/modules/world-runtime/src/world_facade_tests.zig +++ b/modules/world-runtime/src/world_facade_tests.zig @@ -515,14 +515,14 @@ test "World storage facade returns air for unloaded and out-of-bounds blocks" { try testing.expectEqual(world_core.BlockType.air, world.getBlock(0, 256, 0)); } -test "World setBlock and getBlock round-trip through mutation coordinator" { +test "World setBlock ignores unloaded chunks" { var world = makeStorageOnlyWorld(testing.allocator); defer deinitStorageOnlyWorld(&world); try world.setBlock(17, 42, -1, .stone); - try testing.expectEqual(world_core.BlockType.stone, world.getBlock(17, 42, -1)); - try testing.expectEqual(@as(usize, 1), world.storage.count()); + try testing.expectEqual(world_core.BlockType.air, world.getBlock(17, 42, -1)); + try testing.expectEqual(@as(usize, 0), world.storage.count()); } test "World setBlock ignores out-of-bounds y without creating chunks" { diff --git a/modules/world-runtime/src/world_mutation.zig b/modules/world-runtime/src/world_mutation.zig index 2cda703b..ab97d41b 100644 --- a/modules/world-runtime/src/world_mutation.zig +++ b/modules/world-runtime/src/world_mutation.zig @@ -17,6 +17,7 @@ const worldToChunk = world_core.worldToChunk; const worldToLocal = world_core.worldToLocal; const CHUNK_SIZE_X = world_core.CHUNK_SIZE_X; const CHUNK_SIZE_Z = world_core.CHUNK_SIZE_Z; +const block_registry = world_core.block_registry; const ChunkStorage = @import("world-meshing").ChunkStorage; const ChunkData = @import("world-meshing").ChunkData; const GpuBlockBuffer = @import("world-meshing").GpuBlockBuffer; @@ -38,22 +39,31 @@ pub const WorldMutationCoordinator = struct { } pub const MutationResult = struct { + pub const LightingUpdate = enum { none, removal, recompute }; + chunk_data: *ChunkData, chunk_x: i32, chunk_z: i32, local_x: u32, local_y: u32, local_z: u32, + lighting_update: LightingUpdate, }; pub fn applyBlockMutation(self: *WorldMutationCoordinator, world_x: i32, world_y: i32, world_z: i32, block: BlockType) !?MutationResult { if (world_y < 0 or world_y >= 256) return null; + self.storage.lighting_mutex.lock(); + defer self.storage.lighting_mutex.unlock(); + const cp = worldToChunk(world_x, world_z); - const data = try self.storage.getOrCreate(cp.chunk_x, cp.chunk_z); + const data = self.storage.get(cp.chunk_x, cp.chunk_z) orelse return null; + if (!data.chunk.generated) return null; const local = worldToLocal(world_x, world_z); const local_y: u32 = @intCast(world_y); + const old_block = data.chunk.getBlock(local.x, local_y, local.z); + if (old_block == block) return null; data.chunk.setBlock(local.x, local_y, local.z, block); if (self.gpu_mesher_active) { @@ -64,8 +74,16 @@ pub const WorldMutationCoordinator = struct { } } - var lighting = WorldLightingEngine.init(self.storage, self.allocator); - try lighting.afterBlockMutation(cp.chunk_x, cp.chunk_z); + const old_def = block_registry.getBlockDefinition(old_block); + const new_def = block_registry.getBlockDefinition(block); + const old_emission = old_def.getLightEmissionLevel(); + const new_emission = new_def.getLightEmissionLevel(); + const lighting_update: MutationResult.LightingUpdate = if (block == .air and old_def.isOpaque() and old_emission == 0) + .removal + else if (old_emission != new_emission or old_def.isOpaque() != new_def.isOpaque() or block_registry.lightAttenuation(old_block) != block_registry.lightAttenuation(block)) + .recompute + else + .none; self.invalidateNeighbors(cp.chunk_x, cp.chunk_z, local.x, local.z); @@ -76,9 +94,19 @@ pub const WorldMutationCoordinator = struct { .local_x = local.x, .local_y = local_y, .local_z = local.z, + .lighting_update = lighting_update, }; } + pub fn updateLighting(self: *WorldMutationCoordinator, result: MutationResult) !void { + var lighting = WorldLightingEngine.init(self.storage, self.allocator); + switch (result.lighting_update) { + .none => {}, + .removal => try lighting.afterBlockRemoval(result.chunk_x, result.chunk_z, result.local_x, result.local_y, result.local_z), + .recompute => try lighting.recomputeArea(result.chunk_x, result.chunk_z, result.local_x, result.local_z), + } + } + fn invalidateNeighbors(self: *WorldMutationCoordinator, cx: i32, cz: i32, local_x: u32, local_z: u32) void { if (local_x == 0) { if (self.storage.get(cx - 1, cz)) |neighbor| { @@ -109,6 +137,8 @@ test "WorldMutationCoordinator places block within bounds" { var storage = ChunkStorage.init(testing.allocator); defer storage.deinitWithoutRHI(); + const data = try storage.getOrCreate(0, 0); + data.chunk.generated = true; var mutation = WorldMutationCoordinator.init(&storage, testing.allocator, null, false); const result = (try mutation.applyBlockMutation(1, 64, 2, .stone)).?; @@ -143,6 +173,11 @@ test "WorldMutationCoordinator marks boundary neighbors dirty" { const east = try storage.getOrCreate(1, 0); const north = try storage.getOrCreate(0, -1); const south = try storage.getOrCreate(0, 1); + center.chunk.generated = true; + west.chunk.generated = true; + east.chunk.generated = true; + north.chunk.generated = true; + south.chunk.generated = true; center.chunk.dirty = false; west.chunk.dirty = false; east.chunk.dirty = false; @@ -163,15 +198,43 @@ test "WorldMutationCoordinator marks boundary neighbors dirty" { try testing.expect(south.chunk.dirty); } -test "WorldMutationCoordinator propagates allocation failure" { +test "WorldMutationCoordinator does not create missing chunks" { const testing = std.testing; - var failing = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = 0 }); - var storage = ChunkStorage.init(failing.allocator()); + var storage = ChunkStorage.init(testing.allocator); defer storage.deinitWithoutRHI(); - var mutation = WorldMutationCoordinator.init(&storage, failing.allocator(), null, false); - try testing.expectError(error.OutOfMemory, mutation.applyBlockMutation(1, 64, 2, .stone)); + var mutation = WorldMutationCoordinator.init(&storage, testing.allocator, null, false); + try testing.expect((try mutation.applyBlockMutation(1, 64, 2, .stone)) == null); + try testing.expectEqual(@as(usize, 0), storage.count()); +} + +test "WorldMutationCoordinator ignores no-op mutations" { + const testing = std.testing; + var storage = ChunkStorage.init(testing.allocator); + defer storage.deinitWithoutRHI(); + + const data = try storage.getOrCreate(0, 0); + data.chunk.generated = true; + data.chunk.setBlock(1, 64, 2, .stone); + data.chunk.dirty = false; + + var mutation = WorldMutationCoordinator.init(&storage, testing.allocator, null, false); + try testing.expect((try mutation.applyBlockMutation(1, 64, 2, .stone)) == null); + try testing.expect(!data.chunk.dirty); +} + +test "WorldMutationCoordinator relights water transitions" { + const testing = std.testing; + var storage = ChunkStorage.init(testing.allocator); + defer storage.deinitWithoutRHI(); + + const data = try storage.getOrCreate(0, 0); + data.chunk.generated = true; + var mutation = WorldMutationCoordinator.init(&storage, testing.allocator, null, false); + + const result = (try mutation.applyBlockMutation(1, 64, 2, .water)).?; + try testing.expectEqual(WorldMutationCoordinator.MutationResult.LightingUpdate.recompute, result.lighting_update); } test "WorldMutationCoordinator relights dug tunnel from skylight shaft" { @@ -181,20 +244,27 @@ test "WorldMutationCoordinator relights dug tunnel from skylight shaft" { defer storage.deinitWithoutRHI(); const data = try storage.getOrCreate(0, 0); + data.chunk.generated = true; for (0..CHUNK_SIZE_Z) |z| { for (0..CHUNK_SIZE_X) |x| { data.chunk.setBlock(@intCast(x), 5, @intCast(z), .stone); } } data.chunk.setBlock(1, 5, 1, .air); + data.chunk.setBlock(5, 4, 1, .stone); + + var lighting = WorldLightingEngine.init(&storage, testing.allocator); + try lighting.recomputeArea(0, 0, 1, 1); var mutation = WorldMutationCoordinator.init(&storage, testing.allocator, null, false); - _ = try mutation.applyBlockMutation(5, 4, 1, .air); + const tunnel_result = (try mutation.applyBlockMutation(5, 4, 1, .air)).?; + try mutation.updateLighting(tunnel_result); try testing.expectEqual(@as(u4, world_core.MAX_LIGHT), data.chunk.getSkyLight(1, 4, 1)); try testing.expect(data.chunk.getSkyLight(5, 4, 1) > 0); - _ = try mutation.applyBlockMutation(1, 5, 1, .stone); + const seal_result = (try mutation.applyBlockMutation(1, 5, 1, .stone)).?; + try mutation.updateLighting(seal_result); try testing.expectEqual(@as(u4, 0), data.chunk.getSkyLight(5, 4, 1)); } @@ -206,6 +276,8 @@ test "WorldMutationCoordinator propagates skylight across loaded chunk border" { const center = try storage.getOrCreate(0, 0); const east = try storage.getOrCreate(1, 0); + center.chunk.generated = true; + east.chunk.generated = true; for (0..CHUNK_SIZE_Z) |z| { for (0..CHUNK_SIZE_X) |x| { center.chunk.setBlock(@intCast(x), 5, @intCast(z), .stone); @@ -213,9 +285,14 @@ test "WorldMutationCoordinator propagates skylight across loaded chunk border" { } } center.chunk.setBlock(CHUNK_SIZE_X - 1, 5, 1, .air); + east.chunk.setBlock(0, 4, 1, .stone); + + var lighting = WorldLightingEngine.init(&storage, testing.allocator); + try lighting.recomputeArea(0, 0, CHUNK_SIZE_X - 1, 1); var mutation = WorldMutationCoordinator.init(&storage, testing.allocator, null, false); - _ = try mutation.applyBlockMutation(CHUNK_SIZE_X, 4, 1, .air); + const result = (try mutation.applyBlockMutation(CHUNK_SIZE_X, 4, 1, .air)).?; + try mutation.updateLighting(result); try testing.expect(east.chunk.getSkyLight(2, 4, 1) > 0); } @@ -227,11 +304,14 @@ test "WorldMutationCoordinator clears stale block light after emitter removal" { defer storage.deinitWithoutRHI(); const data = try storage.getOrCreate(0, 0); + data.chunk.generated = true; var mutation = WorldMutationCoordinator.init(&storage, testing.allocator, null, false); - _ = try mutation.applyBlockMutation(4, 4, 4, .torch); + const place_result = (try mutation.applyBlockMutation(4, 4, 4, .torch)).?; + try mutation.updateLighting(place_result); try testing.expect(data.chunk.getBlockLight(5, 4, 4) > 0); - _ = try mutation.applyBlockMutation(4, 4, 4, .air); + const remove_result = (try mutation.applyBlockMutation(4, 4, 4, .air)).?; + try mutation.updateLighting(remove_result); try testing.expectEqual(@as(u4, 0), data.chunk.getBlockLight(5, 4, 4)); } diff --git a/modules/world-runtime/src/world_streamer.zig b/modules/world-runtime/src/world_streamer.zig index 3c7eb759..db66ad6f 100644 --- a/modules/world-runtime/src/world_streamer.zig +++ b/modules/world-runtime/src/world_streamer.zig @@ -66,6 +66,7 @@ const log = @import("engine-core").log; const SaveManager = @import("world-persistence").SaveManager; const GpuBlockBuffer = world_meshing.GpuBlockBuffer; const GpuMesher = @import("gpu_mesher.zig").GpuMesher; +const WorldMutationCoordinator = @import("world_mutation.zig").WorldMutationCoordinator; const GpuAccelerationCoordinator = @import("gpu_acceleration_coordinator.zig").GpuAccelerationCoordinator; const ChunkQueueCoordinator = @import("chunk_queue_coordinator.zig").ChunkQueueCoordinator; const LODStreamingCoordinator = @import("world-lod").LODStreamingCoordinator; @@ -103,21 +104,19 @@ pub const WorldStreamer = struct { last_diag_uploaded: u64 = 0, const MIN_GEN_WORKERS = 2; + const MAX_GEN_WORKERS = 4; const MIN_MESH_WORKERS = 2; + const MAX_MESH_WORKERS = 6; pub fn init(allocator: std.mem.Allocator, storage: *ChunkStorage, generator: Generator, atlas: *const TextureAtlas, render_distance: i32, vertex_allocator: *GlobalVertexAllocator, max_uploads_per_frame: usize, gpu_block_buffer: ?*GpuBlockBuffer, gpu_mesher: ?*GpuMesher) !*WorldStreamer { const streamer = try allocator.create(WorldStreamer); errdefer allocator.destroy(streamer); - // Worker pool sizing. The historical caps (gen<=4, mesh<=6) left most - // cores idle on modern multi-core CPUs during the chunk-load burst. - // We now size both pools against the CPU count so the gen+mesh pipeline - // can saturate the machine, while leaving at least one core for the - // main thread and the Vulkan driver. Defaults split the budget 50/50; - // ZIGCRAFT_GEN_WORKERS / ZIGCRAFT_MESH_WORKERS override either side. const cpu_count = std.Thread.getCpuCount() catch MIN_GEN_WORKERS + MIN_MESH_WORKERS; - const total_budget = @max(@as(usize, 4), cpu_count -| 1); - const default_gen = @max(MIN_GEN_WORKERS, total_budget / 2); - const default_mesh = @max(MIN_MESH_WORKERS, total_budget - default_gen); + // Leave CPU capacity for the main thread and graphics driver while + // streaming. Explicit environment overrides remain available for + // throughput-oriented workloads. + const default_gen = std.math.clamp(cpu_count / 2, MIN_GEN_WORKERS, MAX_GEN_WORKERS); + const default_mesh = std.math.clamp(cpu_count / 2, MIN_MESH_WORKERS, MAX_MESH_WORKERS); const gen_worker_count = engine_core.envInt("ZIGCRAFT_GEN_WORKERS", default_gen); const mesh_worker_count = engine_core.envInt("ZIGCRAFT_MESH_WORKERS", default_mesh); @@ -285,6 +284,40 @@ pub const WorldStreamer = struct { self.queue_coordinator.setSaveManager(sm); } + pub fn requestDirtyRemesh(self: *WorldStreamer, center_cx: i32, center_cz: i32) void { + self.queue_coordinator.requestDirtyRemesh(center_cx, center_cz); + } + + pub fn enqueueMutationLighting(self: *WorldStreamer, mutation: *WorldMutationCoordinator, result: WorldMutationCoordinator.MutationResult) !void { + if (result.lighting_update == .none) { + self.requestDirtyRemesh(result.chunk_x, result.chunk_z); + return; + } + + const context = try self.allocator.create(MutationLightingJob); + errdefer self.allocator.destroy(context); + context.* = .{ + .allocator = self.allocator, + .mutation = mutation, + .queue_coordinator = &self.queue_coordinator, + .result = result, + }; + const queued = try self.mesh_queue.tryPush(.{ + .type = .generic, + .priority = -1, + .data = .{ .generic = .{ + .context = context, + .process_fn = processMutationLighting, + .cleanup_fn = cleanupMutationLighting, + } }, + }); + if (!queued) { + self.allocator.destroy(context); + try mutation.updateLighting(result); + self.requestDirtyRemesh(result.chunk_x, result.chunk_z); + } + } + pub fn updateFrame(self: *WorldStreamer, player_pos: Vec3, dt: f32) !void { if (self.paused) return; @@ -612,6 +645,29 @@ pub const WorldStreamer = struct { } }; +const MutationLightingJob = struct { + allocator: std.mem.Allocator, + mutation: *WorldMutationCoordinator, + queue_coordinator: *ChunkQueueCoordinator, + result: WorldMutationCoordinator.MutationResult, +}; + +fn processMutationLighting(raw_context: *anyopaque) void { + const context: *MutationLightingJob = @ptrCast(@alignCast(raw_context)); + context.mutation.updateLighting(context.result) catch |err| { + log.log.warn("BLOCK_LIGHTING_ERROR: ({},{}) update failed: {}", .{ context.result.chunk_x, context.result.chunk_z, err }); + }; + context.queue_coordinator.requestDirtyRemesh(context.result.chunk_x, context.result.chunk_z); + const allocator = context.allocator; + allocator.destroy(context); +} + +fn cleanupMutationLighting(raw_context: *anyopaque) void { + const context: *MutationLightingJob = @ptrCast(@alignCast(raw_context)); + const allocator = context.allocator; + allocator.destroy(context); +} + /// ChunkResolver callback: look up a resident, generated chunk by coordinate. /// The returned pointer is only valid for synchronous use on the main thread /// (it is not pinned); the LOD manager consumes it immediately within update(). diff --git a/modules/worldgen-common/src/lighting_computer.zig b/modules/worldgen-common/src/lighting_computer.zig index ac3c05f0..49925afe 100644 --- a/modules/worldgen-common/src/lighting_computer.zig +++ b/modules/worldgen-common/src/lighting_computer.zig @@ -51,8 +51,7 @@ pub const LightingComputer = struct { } chunk.setSkyLight(@intCast(x), uy, @intCast(z), sky_light); if (sky_light > 0) try queue.append(allocator, .{ .x = @intCast(x), .y = @intCast(uy), .z = @intCast(z), .light = sky_light }); - const attenuation = block_registry.lightAttenuation(block); - if (attenuation > 1) sky_light = if (sky_light > attenuation) sky_light - attenuation else 0; + sky_light = block_registry.attenuateVerticalSkylight(sky_light, block); } } } diff --git a/modules/worldgen-overworld-v2/src/vegetation.zig b/modules/worldgen-overworld-v2/src/vegetation.zig index b5c0ca67..3b0868e1 100644 --- a/modules/worldgen-overworld-v2/src/vegetation.zig +++ b/modules/worldgen-overworld-v2/src/vegetation.zig @@ -8,6 +8,10 @@ const CHUNK_SIZE_Z = world_core.CHUNK_SIZE_Z; const BlockType = world_core.BlockType; const BiomeId = world_core.BiomeId; +const KELP_COVERAGE = 0.025; +const TALL_SEAGRASS_COVERAGE = 0.065; +const AQUATIC_VEGETATION_COVERAGE = 0.14; + pub const GroundDecoration = enum { tall_grass, flower_red, @@ -89,11 +93,11 @@ fn placeAquaticVegetation(self: anytype, chunk: *Chunk, x: u32, surface_y: u32, return; } - if (water_depth >= 6 and scatter < 0.06) { + if (water_depth >= 6 and scatter < KELP_COVERAGE) { placeKelp(chunk, x, place_y, z, @min(water_depth - 1, 2 + @as(u8, @intFromFloat(variant * 7.0)))); - } else if (water_depth >= 4 and scatter < 0.13) { + } else if (water_depth >= 4 and scatter < TALL_SEAGRASS_COVERAGE) { setDecorationBlock(chunk, x, place_y, z, .tall_seagrass); - } else if (scatter < 0.24) { + } else if (scatter < AQUATIC_VEGETATION_COVERAGE) { setDecorationBlock(chunk, x, place_y, z, if (variant < 0.75) .seagrass else .seaweed); } } diff --git a/src/game/app.zig b/src/game/app.zig index 4d5feae7..770c655e 100644 --- a/src/game/app.zig +++ b/src/game/app.zig @@ -471,7 +471,10 @@ pub const App = struct { if (build_options.smoke_test or build_options.screenshot_path.len > 0) { self.smoke_test_frames += 1; const requires_world_ready = build_options.shadow_test_scene or build_options.auto_world.len > 0; - const world_ready = self.pending_world_launch == null and world_stats.chunks_rendered > 0 and world_stats.gen_queue == 0 and world_stats.mesh_queue == 0 and world_stats.upload_queue == 0; + const world_ready = if (world_stats) |stats| + self.pending_world_launch == null and stats.chunks_rendered > 0 and stats.gen_queue == 0 and stats.mesh_queue == 0 and stats.upload_queue == 0 + else + false; if (!requires_world_ready or world_ready) { self.screenshot_settle_frames += 1; } else {