diff --git a/assets/shaders/vulkan/g_pass.frag b/assets/shaders/vulkan/g_pass.frag index 8db4a50d..ba74bdcf 100644 --- a/assets/shaders/vulkan/g_pass.frag +++ b/assets/shaders/vulkan/g_pass.frag @@ -36,24 +36,10 @@ layout(set = 0, binding = 0) uniform GlobalUniforms { vec4 lpv_origin; } global; -// World-space hash for LOD transition masking. -// Using world-space noise avoids a fixed screen-space dot pattern. -float lodTransitionNoise(vec2 worldXZ) { - vec2 p = floor(worldXZ * 0.25); - p = fract(p * vec2(0.1031, 0.1030)); - p += dot(p, p.yx + 33.33); - return fract((p.x + p.y) * p.x); -} - void main() { - const float LOD_TRANSITION_WIDTH = 32.0; bool isLOD = vTileID < 0 || vMaskRadius > 0.0; if (vMaskRadius >= 1.0) { - float distFromMask = length(vFragPosWorld.xz) - vMaskRadius; - float fade = clamp(distFromMask / LOD_TRANSITION_WIDTH, 0.0, 1.0); - vec2 worldXZ = vFragPosWorld.xz + global.cam_pos.xz; - float ditherThreshold = lodTransitionNoise(worldXZ); - if (fade < ditherThreshold) discard; + if (length(vFragPosWorld.xz) < vMaskRadius) discard; } vec3 N = normalize(vNormal); diff --git a/assets/shaders/vulkan/terrain.frag b/assets/shaders/vulkan/terrain.frag index 19ba6202..b68c4ee2 100644 --- a/assets/shaders/vulkan/terrain.frag +++ b/assets/shaders/vulkan/terrain.frag @@ -505,7 +505,6 @@ 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 = 32.0; const float AO_FADE_DISTANCE = 128.0; const float TEXTURE_FADE_START = 32.0; const float TEXTURE_FADE_END = 128.0; @@ -517,19 +516,11 @@ void main() { } if (vMaskRadius >= 1.0) { - vec2 worldXZ = vFragPosWorld.xz + global.cam_pos.xz; - 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; + // Full-detail chunks own this area. Dithering the handoff creates a + // camera-following grid of holes at the chunk/LOD boundary. + if (length(vFragPosWorld.xz) < vMaskRadius) discard; } - if (isLOD && vLODFade < 0.999) { - vec2 worldXZ = vFragPosWorld.xz + global.cam_pos.xz; - float ditherThreshold = lodTransitionNoise(worldXZ + vec2(19.37, 41.91)); - if (vLODFade < ditherThreshold) discard; - } - vec2 tileBase = vec2(mod(float(vTileID), 16.0), floor(float(vTileID) / 16.0)) * (1.0 / 16.0); vec2 tiledUV = fract(vTexCoord); tiledUV = clamp(tiledUV, 0.001, 0.999); diff --git a/assets/shaders/vulkan/terrain.frag.spv b/assets/shaders/vulkan/terrain.frag.spv index 7677b5b5..90b68ba9 100644 Binary files a/assets/shaders/vulkan/terrain.frag.spv and b/assets/shaders/vulkan/terrain.frag.spv differ diff --git a/assets/shaders/vulkan/water.frag b/assets/shaders/vulkan/water.frag index 29b6b578..912ad50e 100644 --- a/assets/shaders/vulkan/water.frag +++ b/assets/shaders/vulkan/water.frag @@ -129,19 +129,10 @@ vec2 atlasUV(int tileID, vec2 texCoord) { } void main() { - const float LOD_TRANSITION_WIDTH = 32.0; bool isLOD = vTileID < 0 || vMaskRadius > 0.0; if (vMaskRadius >= 1.0) { - vec2 worldXZ = vFragPosWorld.xz + global.cam_pos.xz; - float distFromMask = length(vFragPosWorld.xz) - vMaskRadius; - float fade = clamp(distFromMask / LOD_TRANSITION_WIDTH, 0.0, 1.0); - if (fade < lodTransitionNoise(worldXZ)) discard; + if (length(vFragPosWorld.xz) < vMaskRadius) discard; } - if (vMaskRadius > 0.0 && vLODFade < 0.999) { - vec2 worldXZ = vFragPosWorld.xz + global.cam_pos.xz; - if (vLODFade < lodTransitionNoise(worldXZ + vec2(19.37, 41.91))) discard; - } - float time = global.params.x; vec3 base_normal = normalize(vNormal); diff --git a/modules/game-core/src/session.zig b/modules/game-core/src/session.zig index 0820d535..7e68b776 100644 --- a/modules/game-core/src/session.zig +++ b/modules/game-core/src/session.zig @@ -129,12 +129,9 @@ pub const GameSession = struct { else if (effective_lod_enabled and manual_distance_expanded) LODConfig.radiiForDistances(chunk_render_radius, effective_horizon_distance) else - LODConfig.radiiForDistances(effective_render_distance, effective_horizon_distance); + preset_cfg.lod_radii; - const active_count = if (!strict_safe_mode and manual_distance_expanded) - LODConfig.activeCountForRadii(preset_radii) - else - preset_cfg.active_lod_count; + const active_count = preset_cfg.active_lod_count; if (active_count < LODLevel.count) { var i: usize = active_count; while (i < LODLevel.count) : (i += 1) { diff --git a/modules/world-core/src/lod_data.zig b/modules/world-core/src/lod_data.zig index 5a89b7a3..a1921e1d 100644 --- a/modules/world-core/src/lod_data.zig +++ b/modules/world-core/src/lod_data.zig @@ -135,14 +135,13 @@ pub const LODSimplifiedData = struct { pub fn getGridSize(lod_level: LODLevel) u32 { return switch (lod_level) { - // Use region_size / (width - 1). The far bands need a denser - // source grid so visible terrain doesn't collapse into 8/16-block - // slabs before fog hides it. + // LOD4 is the fast horizon fallback; LOD3 retains the dense grid + // and replaces it as refinement arrives. .lod0 => 33, .lod1 => 65, .lod2 => 65, .lod3 => 129, - .lod4 => 129, + .lod4 => 65, }; } diff --git a/modules/world-lod/src/lod_chunk.zig b/modules/world-lod/src/lod_chunk.zig index 08456536..0befea04 100644 --- a/modules/world-lod/src/lod_chunk.zig +++ b/modules/world-lod/src/lod_chunk.zig @@ -603,6 +603,8 @@ pub const LODConfig = struct { skip_lighting_lod3: bool = false, + // The coarsest level remains active even when it shares the horizon radius + // with its child: it is the fast, large-area fallback while that child loads. active_lod_count: u32 = LODLevel.count, /// Maximum fraction of direct finer child regions that may be missing before @@ -639,7 +641,6 @@ pub const LODConfig = struct { } /// Returns how many LOD levels should be active for a render-distance setting. - /// The current policy keeps all levels active regardless of near render distance. pub fn activeCountForRenderDistance(distance: i32) u32 { _ = distance; return LODLevel.count; @@ -903,6 +904,17 @@ test "LODConfig expands render distance into distant LOD horizon" { try std.testing.expectEqual(@as(i32, 1024), custom_horizon[4]); } +test "LODConfig keeps the coarse fallback when tail radii match" { + var config = LODConfig{ .active_lod_count = LODLevel.count }; + const interface = config.interface(); + + interface.setRadii(.{ 30, 96, 256, 512, 512 }); + try std.testing.expectEqual(@as(u32, LODLevel.count), interface.getActiveLODCount()); + + interface.setRadii(.{ 36, 96, 256, 512, 1024 }); + try std.testing.expectEqual(@as(u32, LODLevel.count), interface.getActiveLODCount()); +} + test "ChunkBounds intersects radius radially" { const axis_region = ChunkBounds{ .min_x = 16, .min_z = 0, .max_x = 31, .max_z = 15 }; try std.testing.expect(axis_region.intersectsRadius(0, 0, 16)); diff --git a/modules/world-lod/src/lod_geometry.zig b/modules/world-lod/src/lod_geometry.zig index d9222331..d6c5a905 100644 --- a/modules/world-lod/src/lod_geometry.zig +++ b/modules/world-lod/src/lod_geometry.zig @@ -278,12 +278,13 @@ pub fn terrainHeightForPoint(data: *const LODSimplifiedData, gx: u32, gz: u32) f return @min(height, floor_height); } -/// Returns the stitched terrain surface height at a sample-grid point. -/// Coordinates are clamped and then passed through seam stitching so region boundaries agree. +/// Returns the authoritative terrain surface height at a sample-grid point. +/// Cross-region stitching requires real neighbor samples; blending against +/// unrelated samples inside this region creates visible edge distortion. pub fn terrainSurfaceHeightForPoint(data: *const LODSimplifiedData, gx: u32, gz: u32) f32 { const clamped_x = @min(gx, data.width - 1); const clamped_z = @min(gz, data.width - 1); - return stitchedHeight(data, clamped_x, clamped_z); + return data.getHeight(clamped_x, clamped_z); } /// Returns the quantized solid terrain height for a sample-grid point. @@ -298,7 +299,7 @@ pub fn quantizedCellTerrainHeight(data: *const LODSimplifiedData, gx: u32, gz: u return quantizedHeight(terrainHeightForPoint(data, gx, gz)); } -/// Returns a quantized stitched surface height for an LOD cell. +/// Returns a quantized surface height for an LOD cell. /// Unlike terrain-floor height, this keeps dry columns at the visible terrain surface. pub fn quantizedCellSurfaceHeight(data: *const LODSimplifiedData, gx: u32, gz: u32) f32 { return quantizedHeight(terrainSurfaceHeightForPoint(data, gx, gz)); @@ -564,6 +565,7 @@ pub fn collectColumnSpans(data: *const LODSimplifiedData, gx: u32, gz: u32, lod_ var has_water_span = false; var has_solid_span = false; var has_canopy_span = false; + const is_water_cell = isLODWaterCellForLOD(data, gx, gz, lod_level); var i: u8 = 0; while (i < data.verticalSpanCount(gx, gz)) : (i += 1) { const raw = data.getVerticalSpan(gx, gz, i) orelse continue; @@ -576,6 +578,7 @@ pub fn collectColumnSpans(data: *const LODSimplifiedData, gx: u32, gz: u32, lod_ if (!shouldEmitWaterSpanForLOD(data, gx, gz, lod_level, raw.water)) continue; has_water_span = true; } else { + if (is_water_cell and isLeafBlock(block)) continue; has_solid_span = true; const terrain_height = data.getHeight(gx, gz); if (raw.vegetation.tree_coverage > 0.0 and max_height > terrain_height + 1.0) { @@ -594,7 +597,7 @@ pub fn collectColumnSpans(data: *const LODSimplifiedData, gx: u32, gz: u32, lod_ if (gx < data.width and gz < data.width) { const idx = gx + gz * data.width; const vegetation = data.vegetation[idx]; - if (!has_canopy_span and vegetation.tree_coverage >= LOD_TREE_COVERAGE_THRESHOLD and vegetation.avg_tree_height >= 2.0 and count < out.len) { + if (!is_water_cell and !has_canopy_span and vegetation.tree_coverage >= LOD_TREE_COVERAGE_THRESHOLD and vegetation.avg_tree_height >= 2.0 and count < out.len) { const leaves = if (vegetation.leaves == .air) BlockType.leaves else vegetation.leaves; const canopy = treeCanopyInterval(quantizedTerrainHeightForPoint(data, gx, gz), vegetation); insertColumnSpan(out, &count, .{ @@ -1129,41 +1132,6 @@ pub fn shouldEmitWaterSpanForLOD(data: *const LODSimplifiedData, gx: u32, gz: u3 return stats.wet_samples >= 2 and stats.average_coverage >= 0.25 and stats.representative_depth >= 1.5; } -/// Returns a heightmap sample adjusted to match neighboring LOD region boundaries. -/// Boundary samples may be clamped toward adjacent values to reduce visible cracks. -pub 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; -} - -/// Returns the maximum seam-height adjustment allowed for a point in the sample grid. -/// Interior points allow no adjustment; boundary points allow bounded correction. -pub 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 /// Extracts the red channel from a packed 0xRRGGBB color as a normalized float. /// The result is in `[0, 1]` for direct use in vertex color data. diff --git a/modules/world-lod/src/lod_ingest.zig b/modules/world-lod/src/lod_ingest.zig index c4067ea6..897ac892 100644 --- a/modules/world-lod/src/lod_ingest.zig +++ b/modules/world-lod/src/lod_ingest.zig @@ -227,7 +227,7 @@ pub fn writeIngestedColumn( .ambient_occlusion = 1.0, }; - const vegetation: LODVegetationHint = if (sample.has_tree) + const vegetation: LODVegetationHint = if (sample.has_tree and !water_state.is_surface) .{ .tree_coverage = 1.0, .avg_tree_height = sample.tree_height, @@ -616,6 +616,26 @@ test "downsampleChunkIntoRegion emits water state for a flooded column" { try testing.expectEqual(@as(f32, 5.0), data.water[0].depth); } +test "writeIngestedColumn clears vegetation from flooded samples" { + var data = try LODSimplifiedData.initWithVerticalSpans(testing.allocator, .lod1); + defer data.deinit(); + + try testing.expect(writeIngestedColumn(&data, 0, 0, .{ + .terrain_height = 58.0, + .biome = .forest, + .has_water = true, + .water_surface_height = 63.0, + .water_depth = 5.0, + .water_coverage = 1.0, + .has_tree = true, + .tree_height = 8.0, + }, .chunk_derived)); + + try testing.expect(data.water[0].is_surface); + try testing.expectEqual(@as(f32, 0.0), data.vegetation[0].tree_coverage); + try testing.expectEqual(@as(u8, 2), data.verticalSpanCount(0, 0)); +} + test "downsampleChunkIntoRegion keeps terrain surface above cave gaps" { var data = try LODSimplifiedData.initWithVerticalSpans(testing.allocator, .lod1); defer data.deinit(); diff --git a/modules/world-lod/src/lod_manager_context.zig b/modules/world-lod/src/lod_manager_context.zig index c5beec91..89c33ff7 100644 --- a/modules/world-lod/src/lod_manager_context.zig +++ b/modules/world-lod/src/lod_manager_context.zig @@ -82,9 +82,14 @@ pub const ChunkResolver = struct { }; pub const MAX_LOD_REGIONS = 2048; +// Bound queued and in-flight regions so a cold cache cannot bury the horizon +// fallback behind thousands of expensive generation jobs. +pub const MAX_PENDING_LOD_REGIONS: usize = 64; pub const CHUNK_COVERAGE_PADDING: i32 = 1; pub const LOD_UPDATE_DIVISOR: u32 = 2; -pub const MIN_LOD_WORKERS: usize = 4; +// WorldStreamer reserves these workers from its foreground pools whenever LOD +// is enabled, so horizon generation can be fast without oversubscribing CPUs. +pub const MIN_LOD_WORKERS: usize = 2; pub const MAX_LOD_WORKERS: usize = 6; pub const MAX_MEMORY_EVICTIONS_PER_UPDATE: usize = 32; pub const MAX_MESH_DELETIONS_PER_SWEEP: usize = 64; diff --git a/modules/world-lod/src/lod_manager_generation_ops.zig b/modules/world-lod/src/lod_manager_generation_ops.zig index c7c09f8f..2b8c00a2 100644 --- a/modules/world-lod/src/lod_manager_generation_ops.zig +++ b/modules/world-lod/src/lod_manager_generation_ops.zig @@ -73,6 +73,16 @@ pub fn queueLODRegions(self: *Self, lod: LODLevel, velocity: Vec3, chunk_checker const radii = self.config.getRadii(); const active_lod_count = lod_chunk.activeLODCount(self.config); const use_vertical_spans = self.config.getVerticalSpanBudget() > 0 and self.effectiveMeshPath(lod) == .column_spans; + var pending_regions: usize = 0; + for (0..active_lod_count) |i| { + var iter = self.regions[i].iterator(); + while (iter.next()) |entry| { + switch (entry.value_ptr.*.getState()) { + .missing, .renderable => {}, + else => pending_regions += 1, + } + } + } self.mutex.unlock(); const Coverage = struct { @@ -96,7 +106,10 @@ pub fn queueLODRegions(self: *Self, lod: LODLevel, velocity: Vec3, chunk_checker .coverage_ptr = self, .are_all_chunks_loaded = Coverage.areAllLoaded, .radius_reduction = &self.memory_governor.radius_shrink_chunks, - .defer_generation_dispatch = self.cacheEnabled(), + // Route every region through the bounded admission path, whether or + // not persistent LOD caching is enabled. + .defer_generation_dispatch = true, + .pending_regions = &pending_regions, .use_vertical_spans = use_vertical_spans, }, lod, velocity, chunk_checker, checker_ctx); } diff --git a/modules/world-lod/src/lod_mesh.zig b/modules/world-lod/src/lod_mesh.zig index 61e084ea..0b163b2c 100644 --- a/modules/world-lod/src/lod_mesh.zig +++ b/modules/world-lod/src/lod_mesh.zig @@ -24,7 +24,6 @@ const Vertex = rhi_types.Vertex; const BufferHandle = rhi_types.BufferHandle; const RhiError = rhi_types.RhiError; 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"); const resources_mod = @import("lod_mesh_resources.zig"); @@ -60,21 +59,17 @@ const foldedCanopyColumnForLOD = geom.foldedCanopyColumnForLOD; const getLodSideTile = geom.getLodSideTile; const getLodTopColor = geom.getLodTopColor; const getLodTopTile = geom.getLodTopTile; -const highestSolidSpanIndex = geom.highestSolidSpanIndex; const isLeafBlock = geom.isLeafBlock; const isLODWaterCellForLOD = geom.isLODWaterCellForLOD; const LODColumnSpan = geom.LODColumnSpan; const LOD_TREE_COVERAGE_THRESHOLD = geom.LOD_TREE_COVERAGE_THRESHOLD; -const maxStitchedHeightAdjustment = geom.maxStitchedHeightAdjustment; const packBlockDefaultColor = geom.packBlockDefaultColor; const quantizedCellVisualTerrainHeightForLOD = geom.quantizedCellVisualTerrainHeightForLOD; -const quantizedHeight = geom.quantizedHeight; const quantizedWaterSurfaceHeightForCell = geom.quantizedWaterSurfaceHeightForCell; const quantizedWaterSurfaceHeightForSpan = geom.quantizedWaterSurfaceHeightForSpan; const representativeVegetationForLOD = geom.representativeVegetationForLOD; const selectCellMaterial = geom.selectCellMaterial; const shouldRenderLODTree = geom.shouldRenderLODTree; -const stitchedHeight = geom.stitchedHeight; const terrainBlockForLODQuadForLOD = geom.terrainBlockForLODQuadForLOD; const tintColorForLodFace = geom.tintColorForLodFace; const unpackB = geom.unpackB; @@ -289,7 +284,6 @@ pub const LODMesh = struct { defer vertices.deinit(self.allocator); var water_vertices = std.ArrayListUnmanaged(Vertex).empty; defer water_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) { @@ -337,13 +331,6 @@ 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(); @@ -393,21 +380,6 @@ pub const LODMesh = struct { if (span_count == 0) continue; found_span = true; - // Snap the surface (top) span to the stitched boundary height at - // region/band borders so the span path matches the heightfield - // seam behavior (issue #752 Phase 5.5). - const on_boundary = (gx == 0) or (gz == 0) or - (gx + 1 >= data.width - 1) or (gz + 1 >= data.width - 1); - if (on_boundary) { - if (highestSolidSpanIndex(spans_buf[0..span_count])) |solid_idx| { - const stitched_top = quantizedHeight(stitchedHeight(data, gx, gz)); - spans_buf[solid_idx].max_height = if (isLeafBlock(spans_buf[solid_idx].block)) - @max(spans_buf[solid_idx].max_height, stitched_top) - else - stitched_top; - } - } - const wx = @as(f32, @floatFromInt(gx)) * cell_size; const wz = @as(f32, @floatFromInt(gz)) * cell_size; @@ -433,11 +405,12 @@ pub const LODMesh = struct { } if (isLeafBlock(span.block)) { + const tree_is_water_cell = isLODWaterCellForLOD(data, gx, gz, self.lod_level); + if (tree_is_water_cell) continue; var vegetation = representativeVegetationForLOD(data, gx, gz, self.lod_level); if (vegetation.leaves == .air) vegetation.leaves = span.block; if (vegetation.tree_coverage < LOD_TREE_COVERAGE_THRESHOLD) vegetation.tree_coverage = LOD_TREE_COVERAGE_THRESHOLD; if (vegetation.avg_tree_height < 2.0) vegetation.avg_tree_height = @max(2.0, span.max_height - span.min_height); - const tree_is_water_cell = isLODWaterCellForLOD(data, gx, gz, self.lod_level); const tree_base_height = quantizedCellVisualTerrainHeightForLOD(data, gx, gz, self.lod_level, tree_is_water_cell); try addTreeCanopyColumn(self.allocator, &vertices, data, gx, gz, self.lod_level, wx, wz, cell_size, tree_base_height, span.min_height, span.max_height, vegetation, atlas, world_x, world_z); continue; diff --git a/modules/world-lod/src/lod_mesh_tests.zig b/modules/world-lod/src/lod_mesh_tests.zig index c41fc641..6e74b254 100644 --- a/modules/world-lod/src/lod_mesh_tests.zig +++ b/modules/world-lod/src/lod_mesh_tests.zig @@ -34,10 +34,8 @@ const collectColumnSpans = geom.collectColumnSpans; const makeLODVertex = geom.makeLODVertex; const quantizedCellSurfaceHeight = geom.quantizedCellSurfaceHeight; const quantizedCellTerrainHeight = geom.quantizedCellTerrainHeight; -const quantizedHeight = geom.quantizedHeight; const quantizedWaterSurfaceHeightForCell = geom.quantizedWaterSurfaceHeightForCell; const representativeVegetationForLOD = geom.representativeVegetationForLOD; -const stitchedHeight = geom.stitchedHeight; // Tests fn testResources() LODMeshResources { @@ -597,34 +595,6 @@ test "buildFromColumnSpans sorts representative spans by height" { try std.testing.expect(found_upper_top); } -test "buildFromColumnSpans snaps boundary span tops to stitched height" { - const allocator = std.testing.allocator; - var atlas = testAtlas(allocator); - - var data = try LODSimplifiedData.initWithVerticalSpans(allocator, .lod2); - defer data.deinit(); - data.setHeight(0, 1, 100.0); - data.setHeight(0, 2, 10.0); - try std.testing.expect(data.setVerticalSpan(0, 1, 0, testSpan(0.0, 100.0, .grass, 0x3A7D42))); - - const expected_height = quantizedHeight(stitchedHeight(&data, 0, 1)); - try std.testing.expect(expected_height < 100.0); - - var mesh = LODMesh.init(allocator, .lod2); - defer mesh.deinit(testResources()); - try mesh.buildFromColumnSpans(&data, 0, 0, &atlas); - - const verts = mesh.pending_vertices orelse return error.TestExpectedEqual; - var found_stitched_top = false; - for (verts) |v| { - if (vertexTileId(v) == 23 and @abs(v.pos[1] - expected_height) <= 0.001) { - found_stitched_top = true; - break; - } - } - try std.testing.expect(found_stitched_top); -} - test "buildFromColumnSpans keeps LOD2 canopy spans detached" { const allocator = std.testing.allocator; var atlas = testAtlas(allocator); @@ -666,6 +636,34 @@ test "buildFromColumnSpans keeps LOD2 canopy spans detached" { try std.testing.expect(found_compact_canopy); } +test "buildFromColumnSpans omits canopy from water cells" { + const allocator = std.testing.allocator; + var atlas = testAtlas(allocator); + atlas.tile_mappings[@intFromEnum(BlockType.leaves)] = .{ .top = 70, .bottom = 70, .side = 70 }; + + var data = try LODSimplifiedData.initWithVerticalSpans(allocator, .lod2); + defer data.deinit(); + fillColumnSpanData(&data, .sand, 60.0, 0xD8C76D); + + const water_points = [_]u32{ 0, 1, data.width, data.width + 1 }; + for (water_points) |idx| { + data.water[idx] = .{ .is_surface = true, .surface_height = 63.0, .depth = 3.0, .coverage = 1.0 }; + } + data.clearVerticalSpans(0, 0); + try std.testing.expect(data.setVerticalSpan(0, 0, 0, testSpan(0.0, 60.0, .sand, 0xD8C76D))); + try std.testing.expect(data.setVerticalSpan(0, 0, 1, testSpan(62.0, 68.0, .leaves, 0x24941F))); + + var mesh = LODMesh.init(allocator, .lod2); + defer mesh.deinit(testResources()); + try mesh.buildFromColumnSpans(&data, 0, 0, &atlas); + + const verts = mesh.pending_vertices orelse return error.TestExpectedEqual; + try std.testing.expect(mesh.water_vertex_count > 0); + for (verts[0..mesh.opaque_vertex_count]) |v| { + try std.testing.expect(vertexTileId(v) != 70); + } +} + test "buildFromSimplifiedData renders LOD3 tree columns" { const allocator = std.testing.allocator; var atlas = testAtlas(allocator); @@ -976,20 +974,6 @@ test "blockForLODQuad uses representative non-water surface" { 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 "coarse cell terrain height uses source sample instead of corner mean" { const allocator = std.testing.allocator; var data = try LODSimplifiedData.init(allocator, .lod2); @@ -1004,6 +988,17 @@ test "coarse cell terrain height uses source sample instead of corner mean" { try std.testing.expectEqual(@as(f32, 64.0), quantizedCellSurfaceHeight(&data, 0, 0)); } +test "LOD boundary height retains its authoritative source sample" { + 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.expectEqual(@as(f32, 100.0), quantizedCellSurfaceHeight(&data, 0, 1)); +} + test "representativeVegetationForLOD preserves sparse coarse coverage" { const allocator = std.testing.allocator; var data = try LODSimplifiedData.init(allocator, .lod2); diff --git a/modules/world-lod/src/lod_renderer.zig b/modules/world-lod/src/lod_renderer.zig index 55f570f8..85f2b438 100644 --- a/modules/world-lod/src/lod_renderer.zig +++ b/modules/world-lod/src/lod_renderer.zig @@ -393,6 +393,10 @@ pub fn LODRenderer(comptime RHI: type) type { first_missing_cz = cov.missing_cz; first_missing_in_radius = cov.missing_chunk_in_radius; } + // A single LOD instance cannot exclude individual loaded + // chunks. Keep the radial mask while any full-detail chunk + // is present so LOD never phases through that foreground + // terrain. Only unmask when the entire area is missing. if (cov.missing_chunk_in_radius and !cov.has_chunk_coverage_in_radius) { mask_radius = LOD_UNMASKED_SENTINEL; } @@ -410,10 +414,10 @@ pub fn LODRenderer(comptime RHI: type) type { const model = Mat4.translate(Vec3.init(@as(f32, @floatFromInt(bounds.min_x)) - camera_pos.x, -camera_pos.y + lod_y_offset, @as(f32, @floatFromInt(bounds.min_z)) - camera_pos.z)); - // Keep coarser LODs visible until full-detail chunks cover them. - // Culling against inner LOD bands creates visible holes while finer - // LOD regions are still streaming in. - const fade = @min(calculateBandFade(config, lod, chunk_bounds, camera_pos), chunk.transitionFadeProgress()); + // Parent coverage, not camera distance, owns LOD handoff. A + // distance-band cutoff creates concentric visible rings while + // the parent already provides complete fallback terrain. + const fade = chunk.transitionFadeProgress(); try self.instance_data.append(self.allocator, .{ .model = model, .mask_radius = mask_radius, diff --git a/modules/world-lod/src/lod_scheduler.zig b/modules/world-lod/src/lod_scheduler.zig index 99193d5c..8b8f39f4 100644 --- a/modules/world-lod/src/lod_scheduler.zig +++ b/modules/world-lod/src/lod_scheduler.zig @@ -37,6 +37,9 @@ pub const SchedulerContext = struct { // When true, queueLODRegions marks chunks queued_for_generation and lets // LODManager perform main-thread cache reads before dispatching workers. defer_generation_dispatch: bool = false, + // Shared admission count for queued or in-flight regions. The manager + // supplies this during normal updates; isolated scheduler tests may omit it. + pending_regions: ?*usize = null, use_vertical_spans: bool = false, }; @@ -55,6 +58,7 @@ const LOD0_QUEUE_CANDIDATE_LIMIT: usize = 96; const LOD1_QUEUE_CANDIDATE_LIMIT: usize = 64; const HORIZON_QUEUE_CANDIDATE_LIMIT: usize = 24; const REFINEMENT_QUEUE_CANDIDATE_LIMIT: usize = 48; +const MAX_PENDING_LOD_REGIONS = @import("lod_manager_context.zig").MAX_PENDING_LOD_REGIONS; pub fn priorityRank(lod: LODLevel, active_lod_count: usize) usize { const lod_idx: usize = @intFromEnum(lod); @@ -130,14 +134,9 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu const queue = ctx.gen_queues[LODLevel.count - 1]; const lod_idx: u3 = @intFromEnum(lod); - // Collect candidates that need queuing, then sort nearest-first before - // pushing. This is essential because the shared priority queue is drained - // concurrently by worker threads: a corner-out row-major scan would push - // far candidates (produced first) long before near candidates, so workers - // process far terrain before near terrain is even inserted. Sorting by - // ascending encoded priority (lower = closer, within a single LOD level the - // bias bits are identical) ensures nearer regions enter — and thus leave — - // the heap first. + // Collect candidates and queue them nearest-first. Coarse regions must form + // contiguous fallback coverage; scattered horizon samples appear as + // isolated floating terrain islands. const Candidate = struct { key: LODRegionKey, encoded_priority: i32 }; var candidates = std.ArrayListUnmanaged(Candidate).empty; defer candidates.deinit(ctx.allocator); @@ -172,13 +171,13 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu } } + const max_candidates = maxQueueCandidatesForLOD(lod, active_lod_count); std.mem.sort(Candidate, candidates.items, {}, struct { fn lessThan(_: void, a: Candidate, b: Candidate) bool { return a.encoded_priority < b.encoded_priority; } }.lessThan); - const max_candidates = maxQueueCandidatesForLOD(lod, active_lod_count); var queued_count: usize = 0; ctx.mutex.lock(); @@ -187,6 +186,9 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu const storage = &ctx.regions[@intFromEnum(lod)]; for (candidates.items) |cand| { if (queued_count >= max_candidates) break; + if (ctx.pending_regions) |pending| { + if (pending.* >= MAX_PENDING_LOD_REGIONS) break; + } const existing = storage.get(cand.key); if (existing != null) diag.existing += 1; @@ -228,6 +230,7 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu } diag.queued += 1; queued_count += 1; + if (ctx.pending_regions) |pending| pending.* += 1; } if (diag_enabled) { @@ -401,20 +404,42 @@ test "LOD scheduling caps LOD0 flood while still queuing horizon jobs" { const total = queue.count(); try std.testing.expectEqual(LOD0_QUEUE_CANDIDATE_LIMIT + HORIZON_QUEUE_CANDIDATE_LIMIT, total); + // A cold cache must not keep admitting work once the global pipeline is + // full; completed regions free capacity on subsequent manager updates. + var pending_regions = MAX_PENDING_LOD_REGIONS; + var capped_ctx = ctx; + capped_ctx.pending_regions = &pending_regions; + try queueLODRegions(capped_ctx, .lod1, Vec3.zero, null, null); + try std.testing.expectEqual(total, queue.count()); + const first_job = queue.pop().?; try std.testing.expectEqual(@intFromEnum(LODLevel.lod4), first_job.data.chunk.lod_level); var lod0_count: usize = 0; var horizon_count: usize = 1; + var max_horizon_dist_sq: i64 = 0; + const recordHorizonDistance = struct { + fn record(max_dist_sq: *i64, job: engine_core.job_system.Job) void { + const scale: i32 = @intCast(LODLevel.lod4.chunksPerSide()); + const center_x: i64 = job.data.chunk.x * scale + @divFloor(scale, 2); + const center_z: i64 = job.data.chunk.z * scale + @divFloor(scale, 2); + max_dist_sq.* = @max(max_dist_sq.*, center_x * center_x + center_z * center_z); + } + }.record; + recordHorizonDistance(&max_horizon_dist_sq, first_job); var i: usize = 1; while (i < total) : (i += 1) { const job = queue.pop().?; if (job.data.chunk.lod_level == @intFromEnum(LODLevel.lod0)) lod0_count += 1; - if (job.data.chunk.lod_level == @intFromEnum(LODLevel.lod4)) horizon_count += 1; + if (job.data.chunk.lod_level == @intFromEnum(LODLevel.lod4)) { + horizon_count += 1; + recordHorizonDistance(&max_horizon_dist_sq, job); + } } try std.testing.expectEqual(LOD0_QUEUE_CANDIDATE_LIMIT, lod0_count); try std.testing.expectEqual(HORIZON_QUEUE_CANDIDATE_LIMIT, horizon_count); + try std.testing.expect(max_horizon_dist_sq <= 128 * 128); } test "LOD scheduling biases priorities toward movement direction" { diff --git a/modules/world-runtime/src/chunk_queue_coordinator.zig b/modules/world-runtime/src/chunk_queue_coordinator.zig index fcf98018..1d149180 100644 --- a/modules/world-runtime/src/chunk_queue_coordinator.zig +++ b/modules/world-runtime/src/chunk_queue_coordinator.zig @@ -540,17 +540,28 @@ pub const ChunkQueueCoordinator = struct { chunk_data.chunk.lighting_valid = true; } - var lighting = WorldLightingEngine.init(self.storage, self.allocator); - _ = if (load_result == .success_relight_required) - lighting.reconcileLegacyArea(cx, cz) catch |err| blk: { + if (load_result == .success_relight_required) { + // Legacy saved chunks have no trustworthy lighting, so rebuild + // the local loaded area once. + var lighting = WorldLightingEngine.init(self.storage, self.allocator); + _ = 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: { + }; + } else if (load_result == .success) { + // Loaded chunks need only interface reconciliation with their + // resident neighbors; fresh generation already lit itself. + var lighting = WorldLightingEngine.init(self.storage, self.allocator); + _ = lighting.reconcileChunkArrival(cx, cz) catch |err| blk: { log.log.warn("CHUNK_LIGHTING_ERROR: ({},{}) boundary reconciliation failed: {}", .{ cx, cz, err }); break :blk false; }; + } else { + // Generation has completed its own lighting pass, so persist it + // as current instead of forcing a full resident-world relight on + // every newly generated chunk. + chunk_data.chunk.lighting_valid = true; + } self.storage.chunks_mutex.lock(); if (!chunk_data.chunk.generated) { diff --git a/modules/world-runtime/src/world.zig b/modules/world-runtime/src/world.zig index 346f93b4..ef1700d3 100644 --- a/modules/world-runtime/src/world.zig +++ b/modules/world-runtime/src/world.zig @@ -711,7 +711,7 @@ pub const World = struct { ); log.log.info("World.init: initializing WorldStreamer (render_distance={}, requested={})", .{ streamer_render_distance, safe_render_distance }); - world.streamer = try WorldStreamer.init(allocator, &world.storage, world.generator, options.atlas, streamer_render_distance, world.renderer.vertex_allocator, max_uploads, world.gpu_block_buffer, world.renderer.getGpuMesher()); + world.streamer = try WorldStreamer.init(allocator, &world.storage, world.generator, options.atlas, streamer_render_distance, options.lod_config != null, world.renderer.vertex_allocator, max_uploads, world.gpu_block_buffer, world.renderer.getGpuMesher()); errdefer world.streamer.deinit(); if (options.lod_config) |lod_config| { @@ -899,7 +899,8 @@ pub const World = struct { log.log.info("Horizon distance changed: {} -> {}", .{ self.horizon_distance, target }); self.horizon_distance = target; if (self.lod) |lod| { - lod.setRadii(LODConfig.radiiForDistances(self.render_distance, target)); + const radii = LODConfig.radiiForDistances(self.render_distance, target); + lod.setRadii(radii); lod.setActiveLODCount(LODLevel.count); } } diff --git a/modules/world-runtime/src/world_streamer.zig b/modules/world-runtime/src/world_streamer.zig index db66ad6f..f1b9e1e0 100644 --- a/modules/world-runtime/src/world_streamer.zig +++ b/modules/world-runtime/src/world_streamer.zig @@ -99,6 +99,10 @@ pub const WorldStreamer = struct { save_manager: ?*SaveManager = null, frame_counter: u64 = 0, + has_scanned_missing_chunks: bool = false, + last_missing_scan_pc_x: i32 = 0, + last_missing_scan_pc_z: i32 = 0, + last_missing_scan_render_dist: i32 = 0, last_diag_generated: u64 = 0, last_diag_meshed: u64 = 0, last_diag_uploaded: u64 = 0, @@ -107,16 +111,28 @@ pub const WorldStreamer = struct { 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 MIN_LOD_WORKERS = 2; + const MAX_LOD_WORKERS = 6; + + pub fn init(allocator: std.mem.Allocator, storage: *ChunkStorage, generator: Generator, atlas: *const TextureAtlas, render_distance: i32, lod_enabled: bool, 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); + // Reserve LOD capacity from the same CPU budget as full-detail work + // while preserving the foreground pool caps. This leaves capacity for + // the main thread and graphics driver. + // This keeps horizon generation responsive without creating two pools + // that each try to saturate every core. const cpu_count = std.Thread.getCpuCount() catch MIN_GEN_WORKERS + MIN_MESH_WORKERS; - // 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 total_budget = @max(@as(usize, 4), cpu_count -| 1); + const requested_lod_workers = if (lod_enabled) + std.math.clamp(cpu_count / 2, MIN_LOD_WORKERS, MAX_LOD_WORKERS) + else + 0; + const lod_worker_reserve = @min(requested_lod_workers, total_budget -| (MIN_GEN_WORKERS + MIN_MESH_WORKERS)); + const foreground_budget = total_budget - lod_worker_reserve; + const default_gen = std.math.clamp(foreground_budget / 2, MIN_GEN_WORKERS, MAX_GEN_WORKERS); + const default_mesh = std.math.clamp(foreground_budget - default_gen, 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); @@ -155,7 +171,7 @@ pub const WorldStreamer = struct { streamer.queue_coordinator = try ChunkQueueCoordinator.init(allocator, storage, generator, atlas, gen_queue, mesh_queue, vertex_allocator, max_uploads_per_frame, &streamer.gpu_acceleration); errdefer streamer.queue_coordinator.deinit(); - log.log.info("WorldStreamer workers: gen={} mesh={} (cpu={})", .{ gen_worker_count, mesh_worker_count, cpu_count }); + log.log.info("WorldStreamer workers: gen={} mesh={} lod_reserve={} (cpu={})", .{ gen_worker_count, mesh_worker_count, lod_worker_reserve, cpu_count }); streamer.gen_pool = try WorkerPool.init(allocator, gen_worker_count, gen_queue, &streamer.queue_coordinator, ChunkQueueCoordinator.processGenJob); errdefer streamer.gen_pool.deinit(); @@ -428,12 +444,23 @@ pub const WorldStreamer = struct { self.logMissingChunkDiagnostic(frame.pc_x, frame.pc_z); } - // Keep the generation queue hot while moving or recovering stale jobs. - // A full scan is cheap relative to chunk generation and avoids leaving - // boundary chunks idle in `.missing` until the next periodic rescan. - self.queue_coordinator.scanForMissingChunks(frame.pc_x, frame.pc_z, frame.render_dist, frame.movement) catch |err| { - log.log.warn("scanForMissingChunks error (non-fatal): {}", .{err}); - }; + // The required chunk set changes only after crossing a chunk boundary or + // changing view distance. A periodic scan remains as a safety net for a + // failed queue insertion without taking the storage writer lock every frame. + const needs_missing_scan = !self.has_scanned_missing_chunks or + self.last_missing_scan_pc_x != frame.pc_x or + self.last_missing_scan_pc_z != frame.pc_z or + self.last_missing_scan_render_dist != frame.render_dist or + self.frame_counter % 60 == 0; + if (needs_missing_scan) { + self.queue_coordinator.scanForMissingChunks(frame.pc_x, frame.pc_z, frame.render_dist, frame.movement) catch |err| { + log.log.warn("scanForMissingChunks error (non-fatal): {}", .{err}); + }; + self.has_scanned_missing_chunks = true; + self.last_missing_scan_pc_x = frame.pc_x; + self.last_missing_scan_pc_z = frame.pc_z; + self.last_missing_scan_render_dist = frame.render_dist; + } self.queue_coordinator.processChunkStates(frame.pc_x, frame.pc_z, frame.render_dist, self.frame_counter); self.lod_coordinator.updateLOD(player_pos, self.storage); diff --git a/modules/worldgen-overworld-v2/src/lod_sampling.zig b/modules/worldgen-overworld-v2/src/lod_sampling.zig index db5ca71d..e38fd9c6 100644 --- a/modules/worldgen-overworld-v2/src/lod_sampling.zig +++ b/modules/worldgen-overworld-v2/src/lod_sampling.zig @@ -24,6 +24,8 @@ const ColumnSample = struct { }; const ClassifiedLODSample = struct { + world_x: i32, + world_z: i32, terrain_height: f32, terrain_height_i: i32, biome: BiomeId, @@ -64,9 +66,12 @@ pub fn generateHeightmapOnly(self: anytype, data: *LODSimplifiedData, region_x: } fn sampleRepresentativeLODColumn(self: anytype, wx: f32, wz: f32, cell_span: f32) RepresentativeLODColumn { - const sample_offsets = [_]f32{0.0}; + // Sample the full cell footprint so shorelines retain fractional coverage + // rather than snapping every coarse cell fully to land or water. + const sample_offsets = [_]f32{ -1.0, 0.0, 1.0 }; const sample_radius = @min(cell_span * 0.5, 48.0); - const center_sample = classifyLODSample(self, wx, wz); + var classified_samples: [sample_offsets.len * sample_offsets.len]ClassifiedLODSample = undefined; + var classified_count: usize = 0; var block_counts = [_]u32{0} ** world_core.MAX_BLOCK_TYPES; var biome_counts = [_]u32{0} ** 256; @@ -83,6 +88,8 @@ fn sampleRepresentativeLODColumn(self: anytype, wx: f32, wz: f32, cell_span: f32 for (sample_offsets) |oz| { for (sample_offsets) |ox| { const sample = classifyLODSample(self, wx + ox * sample_radius, wz + oz * sample_radius); + classified_samples[classified_count] = sample; + classified_count += 1; const block_index = @intFromEnum(sample.surface_block); if (block_index < block_counts.len) block_counts[block_index] += 1; biome_counts[@intFromEnum(sample.biome)] += 1; @@ -108,6 +115,7 @@ fn sampleRepresentativeLODColumn(self: anytype, wx: f32, wz: f32, cell_span: f32 const render_water_surface = water_coverage >= 0.45; const dominant_biome = dominantBiome(biome_counts); const dominant_block = dominantBlock(block_counts); + const center_sample = classified_samples[classified_samples.len / 2]; const surface_block: BlockType = if (render_water_surface) .water else if (center_sample.render_water_surface) dominant_block else center_sample.surface_block; const avg_height = terrain_height_sum / @as(f32, @floatFromInt(sample_count)); const terrain_range = @max(terrain_max - terrain_min, 0.0); @@ -117,7 +125,7 @@ fn sampleRepresentativeLODColumn(self: anytype, wx: f32, wz: f32, cell_span: f32 const vegetation = if (render_water_surface) world_core.LODVegetationHint.empty else - lodVegetationHintInArea(self, wx, wz, sample_radius, dominant_biome); + lodVegetationHintFromSamples(self, classified_samples[0..classified_count], wx, wz); const avg_color = packAverageColor(color_r, color_g, color_b, sample_count); return .{ @@ -152,6 +160,8 @@ fn classifyLODSample(self: anytype, wx: f32, wz: f32) ClassifiedLODSample { const surface_block: BlockType = if (render_water_surface) .water else block_colors.surfaceBlock(sample.biome, sample.terrain_height, self.params.sea_level, false); return .{ + .world_x = wx_i, + .world_z = wz_i, .terrain_height = @floatFromInt(sample.terrain_height), .terrain_height_i = sample.terrain_height, .biome = sample.biome, @@ -179,9 +189,7 @@ fn sampleLODColumn(self: anytype, wx: i32, wz: i32) ColumnSample { }; } -fn lodVegetationHintInArea(self: anytype, center_wx: f32, center_wz: f32, radius: f32, dominant_biome: BiomeId) world_core.LODVegetationHint { - const sample_offsets = [_]f32{ -0.5, 0.0, 0.5 }; - +fn lodVegetationHintFromSamples(self: anytype, samples: []const ClassifiedLODSample, center_wx: f32, center_wz: f32) world_core.LODVegetationHint { var tree_count: u32 = 0; var total_columns: u32 = 0; var height_sum: f32 = 0.0; @@ -189,25 +197,24 @@ fn lodVegetationHintInArea(self: anytype, center_wx: f32, center_wz: f32, radius var offset_z_sum: f32 = 0.0; var best_shape: ?trees.TreeShape = null; - for (sample_offsets) |oz| { - for (sample_offsets) |ox| { - const wx = util.floorToI32(center_wx + ox * radius); - const wz = util.floorToI32(center_wz + oz * radius); - total_columns += 1; - const shape = trees.treeForColumn(self, dominant_biome, wx, wz) orelse continue; - tree_count += 1; - height_sum += trees.treeHeightForShape(shape); - offset_x_sum += @as(f32, @floatFromInt(wx)) - center_wx; - offset_z_sum += @as(f32, @floatFromInt(wz)) - center_wz; - if (best_shape == null) best_shape = shape; - } + for (samples) |sample| { + total_columns += 1; + if (sample.terrain_height_i <= self.params.sea_level) continue; + if (sample.surface_block != .grass and sample.surface_block != .dirt and sample.surface_block != .snow_block and sample.surface_block != .sand) continue; + + const shape = trees.treeForColumn(self, sample.biome, sample.world_x, sample.world_z) orelse continue; + tree_count += 1; + height_sum += trees.treeHeightForShape(shape); + offset_x_sum += @as(f32, @floatFromInt(sample.world_x)) - center_wx; + offset_z_sum += @as(f32, @floatFromInt(sample.world_z)) - center_wz; + if (best_shape == null) best_shape = shape; } if (tree_count == 0) return world_core.LODVegetationHint.empty; const area = @as(f32, @floatFromInt(@max(total_columns, 1))); const sampled_coverage = std.math.clamp(@as(f32, @floatFromInt(tree_count)) / area, 0.0, 1.0); - const blocks = trees.treeBlocksForShape(best_shape orelse trees.defaultTreeShapeForBiome(dominant_biome)); + const blocks = trees.treeBlocksForShape(best_shape orelse .oak); return .{ .tree_coverage = sampled_coverage, diff --git a/modules/worldgen-overworld-v2/src/root.zig b/modules/worldgen-overworld-v2/src/root.zig index 6c11d76a..87c61c83 100644 --- a/modules/worldgen-overworld-v2/src/root.zig +++ b/modules/worldgen-overworld-v2/src/root.zig @@ -61,7 +61,7 @@ pub const OverworldV2Generator = struct { pub const INFO = GeneratorInfo{ .name = "Overworld V2", .description = "Luanti v7-style terrain with ridges, mountains, rivers, and cave noise.", - .version = 1, + .version = 2, }; pub const Params = struct {