Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions assets/shaders/vulkan/terrain.frag
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}

Expand Down
Binary file modified assets/shaders/vulkan/terrain.frag.spv
Binary file not shown.
4 changes: 2 additions & 2 deletions docs/lighting-architecture.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/shaders/spirv-sizes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 19 additions & 1 deletion modules/engine-core/src/job_system.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion modules/engine-graphics/src/resource_pack.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
Expand Down Expand Up @@ -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"));
}
11 changes: 9 additions & 2 deletions modules/world-core/src/block_registry.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion modules/world-core/src/block_registry_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 1 addition & 2 deletions modules/world-core/src/chunk.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions modules/world-core/src/chunk_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions modules/world-meshing/src/chunk_mesh_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions modules/world-meshing/src/chunk_storage.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand All @@ -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,
};
Expand Down
8 changes: 4 additions & 4 deletions modules/world-meshing/src/meshing/boundary.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
4 changes: 2 additions & 2 deletions modules/world-meshing/src/meshing/boundary_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
11 changes: 9 additions & 2 deletions modules/world-meshing/src/meshing/greedy_mesher.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion modules/world-meshing/src/meshing/tall_cross_mesher.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
89 changes: 83 additions & 6 deletions modules/world-runtime/src/chunk_queue_coordinator.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 });
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
Loading
Loading