diff --git a/assets/shaders/vulkan/g_pass.frag b/assets/shaders/vulkan/g_pass.frag index 24895573..1d412c29 100644 --- a/assets/shaders/vulkan/g_pass.frag +++ b/assets/shaders/vulkan/g_pass.frag @@ -46,7 +46,7 @@ float lodTransitionNoise(vec2 worldXZ) { } void main() { - const float LOD_TRANSITION_WIDTH = 24.0; + const float LOD_TRANSITION_WIDTH = 32.0; bool isLOD = vTileID < 0 || vMaskRadius > 0.0; if (vMaskRadius >= 1.0) { float distFromMask = length(vFragPosWorld.xz) - vMaskRadius; diff --git a/assets/shaders/vulkan/terrain.frag b/assets/shaders/vulkan/terrain.frag index 4f0520f3..a8319ed2 100644 --- a/assets/shaders/vulkan/terrain.frag +++ b/assets/shaders/vulkan/terrain.frag @@ -672,7 +672,7 @@ void main() { float debugSkyFill = 0.0; float debugBlockLight = clamp(max(vBlockLight.r, max(vBlockLight.g, vBlockLight.b)), 0.0, 1.0); float debugOutdoor = baselineOutdoorFactor(vSkyLight); - const float LOD_TRANSITION_WIDTH = 24.0; + const float LOD_TRANSITION_WIDTH = 32.0; const float AO_FADE_DISTANCE = 128.0; const float TEXTURE_FADE_START = 32.0; const float TEXTURE_FADE_END = 128.0; @@ -686,12 +686,11 @@ void main() { } if (vMaskRadius >= 1.0) { - const float CHUNK_SIZE = 16.0; vec2 worldXZ = vFragPosWorld.xz + global.cam_pos.xz; - vec2 fragChunk = floor(worldXZ / CHUNK_SIZE); - vec2 cameraChunk = floor(global.cam_pos.xz / CHUNK_SIZE); - float maskChunks = floor(vMaskRadius / CHUNK_SIZE + 0.5); - if (length(fragChunk - cameraChunk) <= maskChunks) discard; + float distFromMask = length(vFragPosWorld.xz) - vMaskRadius; + float fade = clamp(distFromMask / LOD_TRANSITION_WIDTH, 0.0, 1.0); + float ditherThreshold = lodTransitionNoise(worldXZ); + if (fade < ditherThreshold) discard; } vec2 tileBase = vec2(mod(float(vTileID), 16.0), floor(float(vTileID) / 16.0)) * (1.0 / 16.0); diff --git a/assets/shaders/vulkan/terrain.frag.spv b/assets/shaders/vulkan/terrain.frag.spv index d9e35bcb..ced24a4c 100644 Binary files a/assets/shaders/vulkan/terrain.frag.spv and b/assets/shaders/vulkan/terrain.frag.spv differ diff --git a/modules/world-lod/src/lod_chunk.zig b/modules/world-lod/src/lod_chunk.zig index a1650751..9dfe8f7b 100644 --- a/modules/world-lod/src/lod_chunk.zig +++ b/modules/world-lod/src/lod_chunk.zig @@ -285,7 +285,7 @@ pub const LODConfig = struct { /// Fog start position as percentage of LOD radius (0.0-1.0) where fog begins. /// Values closer to 0.0 start fog near the player; 1.0 disables fog for that level. - fog_start_percent: [LODLevel.count]f32 = .{ 0.5, 0.5, 0.4, 0.3 }, + fog_start_percent: [LODLevel.count]f32 = .{ 0.55, 0.48, 0.38, 0.28 }, qem_triangle_targets: [LODLevel.count]u32 = .{ 0, 2000, 800, 200 }, @@ -393,7 +393,7 @@ pub const LODConfig = struct { const self: *LODConfig = @ptrCast(@alignCast(ptr)); // Keep a small overlap so the chunk ring and LOD ring blend instead of // leaving a camera-centered dead zone between them. - const overlap_chunks = @max(self.radii[0] - 1, 0); + const overlap_chunks = @max(self.radii[0] - 2, 0); return @as(f32, @floatFromInt(overlap_chunks)) * @as(f32, @floatFromInt(CHUNK_SIZE_X)); } fn getQEMTargetWrapper(ptr: *anyopaque, lod: LODLevel) u32 { @@ -498,8 +498,8 @@ test "ILODConfig.calculateMaskRadius" { .radii = .{ 16, 40, 80, 160 }, }; const interface = config.interface(); - try std.testing.expectEqual(@as(f32, 240.0), interface.calculateMaskRadius()); + try std.testing.expectEqual(@as(f32, 224.0), interface.calculateMaskRadius()); config.radii[0] = 32; - try std.testing.expectEqual(@as(f32, 496.0), interface.calculateMaskRadius()); + try std.testing.expectEqual(@as(f32, 480.0), interface.calculateMaskRadius()); } diff --git a/modules/world-lod/src/lod_mesh.zig b/modules/world-lod/src/lod_mesh.zig index dfca8624..cc8e2ecd 100644 --- a/modules/world-lod/src/lod_mesh.zig +++ b/modules/world-lod/src/lod_mesh.zig @@ -32,6 +32,7 @@ const encodeNormal = rhi_types.encodeNormal; const encodeMeta = rhi_types.encodeMeta; const encodeBlocklight = rhi_types.encodeBlocklight; const QuadricSimplifier = @import("world-meshing").meshing.quadric_simplifier.QuadricSimplifier; +const engine_core = @import("engine-core"); const log = @import("engine-core").log; const lod_seam = @import("lod_seam.zig"); @@ -197,15 +198,16 @@ pub const LODMesh = struct { var vertices = std.ArrayListUnmanaged(Vertex).empty; defer vertices.deinit(self.allocator); + const diag_enabled = engine_core.envFlag("ZIGCRAFT_LOD_DIAG", false); var gz: u32 = 0; while (gz + 1 < data.width) : (gz += 1) { var gx: u32 = 0; while (gx + 1 < data.width) : (gx += 1) { - const h00 = data.heightmap[gx + gz * data.width]; - const h10 = data.heightmap[(gx + 1) + gz * data.width]; - const h01 = data.heightmap[gx + (gz + 1) * data.width]; - const h11 = data.heightmap[(gx + 1) + (gz + 1) * data.width]; + const h00 = stitchedHeight(data, gx, gz); + const h10 = stitchedHeight(data, gx + 1, gz); + const h01 = stitchedHeight(data, gx, gz + 1); + const h11 = stitchedHeight(data, gx + 1, gz + 1); const c00 = data.colors[gx + gz * data.width]; const c10 = data.colors[(gx + 1) + gz * data.width]; @@ -247,6 +249,13 @@ pub const LODMesh = struct { } } + if (diag_enabled) { + const max_adjust = maxStitchedHeightAdjustment(data); + if (max_adjust > 0.25) { + log.log.info("LOD_SEAM_DIAG lod={} origin=({}, {}) max_edge_adjust={d:.2}", .{ @intFromEnum(self.lod_level), world_x, world_z, max_adjust }); + } + } + // Store pending vertices self.mutex.lock(); defer self.mutex.unlock(); @@ -705,10 +714,69 @@ fn blockForLODCell(data: *const LODSimplifiedData, gx: u32, gz: u32) BlockType { } fn blockForLODQuad(data: *const LODSimplifiedData, gx: u32, gz: u32) BlockType { - if (averageWaterCoverage(data, gx, gz) >= 0.25) return .water; + const water_coverage = averageWaterCoverage(data, gx, gz); + if (water_coverage >= 0.35) return .water; + if (water_coverage > 0.0 and representativeWaterDepth(data, gx, gz) >= 1.5) return .water; + return representativeSurfaceBlock(data, gx, gz); +} + +fn representativeSurfaceBlock(data: *const LODSimplifiedData, gx: u32, gz: u32) BlockType { + const x0 = @min(gx, data.width - 1); + const z0 = @min(gz, data.width - 1); + const x1 = @min(gx + 1, data.width - 1); + const z1 = @min(gz + 1, data.width - 1); + const indices = [_]u32{ + x0 + z0 * data.width, + x1 + z0 * data.width, + x0 + z1 * data.width, + x1 + z1 * data.width, + }; + + var best_block: BlockType = .air; + var best_count: u32 = 0; + for (indices) |idx| { + const block = if (data.material_layers[idx].surface != .air) data.material_layers[idx].surface else if (data.top_blocks[idx] != .air) data.top_blocks[idx] else data.biomes[idx].getSurfaceBlock(); + if (block == .air or block == .water) continue; + + var count: u32 = 0; + for (indices) |other_idx| { + const other = if (data.material_layers[other_idx].surface != .air) data.material_layers[other_idx].surface else if (data.top_blocks[other_idx] != .air) data.top_blocks[other_idx] else data.biomes[other_idx].getSurfaceBlock(); + if (other == block) count += 1; + } + if (count > best_count) { + best_block = block; + best_count = count; + } + } + + if (best_block != .air) return best_block; return blockForLODCell(data, gx, gz); } +fn representativeWaterDepth(data: *const LODSimplifiedData, gx: u32, gz: u32) f32 { + const x0 = @min(gx, data.width - 1); + const z0 = @min(gz, data.width - 1); + const x1 = @min(gx + 1, data.width - 1); + const z1 = @min(gz + 1, data.width - 1); + const indices = [_]u32{ + x0 + z0 * data.width, + x1 + z0 * data.width, + x0 + z1 * data.width, + x1 + z1 * data.width, + }; + + var weighted_depth: f32 = 0.0; + var coverage: f32 = 0.0; + for (indices) |idx| { + const water = data.water[idx]; + if (!water.is_surface) continue; + weighted_depth += water.depth * water.coverage; + coverage += water.coverage; + } + if (coverage <= 0.001) return 0.0; + return weighted_depth / coverage; +} + fn selectCellMaterial(data: *const LODSimplifiedData, atlas: *const TextureAtlas, gx: u32, gz: u32) TextureAtlas.BlockTiles { const top_block = blockForLODQuad(data, gx, gz); const side_block = sideBlockForLODQuad(data, gx, gz, top_block); @@ -793,6 +861,37 @@ fn averageWaterCoverage(data: *const LODSimplifiedData, gx: u32, gz: u32) f32 { return (c00 + c10 + c01 + c11) * 0.25; } +fn stitchedHeight(data: *const LODSimplifiedData, gx: u32, gz: u32) f32 { + const height = data.getHeight(gx, gz); + if (data.width < 5) return height; + + const blend_cells: u32 = 2; + const max_idx = data.width - 1; + const edge_dist = @min(@min(gx, gz), @min(max_idx - gx, max_idx - gz)); + if (edge_dist >= blend_cells) return height; + + const coarse_x = @min(((gx + 1) / 2) * 2, max_idx); + const coarse_z = @min(((gz + 1) / 2) * 2, max_idx); + const coarse_height = data.getHeight(coarse_x, coarse_z); + const edge_weight = 1.0 - (@as(f32, @floatFromInt(edge_dist)) / @as(f32, @floatFromInt(blend_cells))); + const blend = edge_weight * 0.35; + return height * (1.0 - blend) + coarse_height * blend; +} + +fn maxStitchedHeightAdjustment(data: *const LODSimplifiedData) f32 { + if (data.width < 5) return 0.0; + + var max_adjust: f32 = 0.0; + var i: u32 = 0; + while (i < data.width) : (i += 1) { + max_adjust = @max(max_adjust, @abs(data.getHeight(i, 0) - stitchedHeight(data, i, 0))); + max_adjust = @max(max_adjust, @abs(data.getHeight(i, data.width - 1) - stitchedHeight(data, i, data.width - 1))); + max_adjust = @max(max_adjust, @abs(data.getHeight(0, i) - stitchedHeight(data, 0, i))); + max_adjust = @max(max_adjust, @abs(data.getHeight(data.width - 1, i) - stitchedHeight(data, data.width - 1, i))); + } + return max_adjust; +} + // Helper functions for unpacking colors fn unpackR(color: u32) f32 { return @as(f32, @floatFromInt((color >> 16) & 0xFF)) / 255.0; @@ -1545,6 +1644,42 @@ test "buildFromSimplifiedData promotes mixed water cells to water material" { try std.testing.expect(found_floor_side); } +test "blockForLODQuad uses representative non-water surface" { + const allocator = std.testing.allocator; + var data = try LODSimplifiedData.init(allocator, .lod1); + defer data.deinit(); + + for (0..data.width * data.width) |i| { + data.biomes[i] = .plains; + data.top_blocks[i] = .grass; + data.material_layers[i] = .{ + .surface = .grass, + .subsurface = .dirt, + .foundation = .stone, + }; + } + + data.material_layers[1].surface = .stone; + data.material_layers[data.width].surface = .stone; + data.material_layers[data.width + 1].surface = .stone; + + try std.testing.expectEqual(BlockType.stone, blockForLODQuad(&data, 0, 0)); +} + +test "stitchedHeight blends boundary points toward coarse grid" { + const allocator = std.testing.allocator; + var data = try LODSimplifiedData.init(allocator, .lod1); + defer data.deinit(); + + for (0..data.width * data.width) |i| { + data.heightmap[i] = 10.0; + } + data.setHeight(0, 1, 100.0); + + try std.testing.expect(stitchedHeight(&data, 0, 1) < 100.0); + try std.testing.expectEqual(@as(f32, 10.0), stitchedHeight(&data, 4, 4)); +} + test "buildFromSimplifiedData uses averaged color tile for far LOD tops" { const allocator = std.testing.allocator; const MAX_BLOCK_TYPES = world_core.MAX_BLOCK_TYPES;