From d2f178af7e36487abb6a6d5afbe8ffd983b6154f Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Fri, 17 Jul 2026 02:42:38 +0100 Subject: [PATCH 1/2] fix: stabilize uncapped LOD streaming Signed-off-by: MichaelFisher1997 --- assets/shaders/vulkan/g_pass.frag | 19 +- .../shaders/vulkan/lod_compact_terrain.frag | 15 +- assets/shaders/vulkan/lod_compact_water.frag | 19 +- assets/shaders/vulkan/terrain.frag | 26 +- assets/shaders/vulkan/terrain.frag.spv | Bin 62156 -> 63268 bytes assets/shaders/vulkan/water.frag | 20 +- docs/shaders/spirv-sizes.json | 4 +- modules/game-core/src/session.zig | 25 +- modules/game-core/src/settings/data.zig | 9 +- .../game-core/src/settings/json_presets.zig | 4 +- modules/game-ui/src/screens/rml_settings.zig | 25 +- modules/game-ui/src/screens/settings.zig | 21 +- modules/game-ui/src/screens/world.zig | 4 +- modules/world-lod/src/lod_chunk.zig | 61 +++- modules/world-lod/src/lod_manager.zig | 19 +- .../world-lod/src/lod_manager_cache_ops.zig | 2 + modules/world-lod/src/lod_manager_context.zig | 13 + .../world-lod/src/lod_manager_core_ops.zig | 12 +- .../src/lod_manager_eviction_ops.zig | 9 +- .../src/lod_manager_generation_ops.zig | 4 + modules/world-lod/src/lod_manager_tests.zig | 4 +- modules/world-lod/src/lod_renderer.zig | 335 ++++++++++++++++-- modules/world-lod/src/lod_scheduler.zig | 260 +++++++++----- .../src/lod_streaming_coordinator.zig | 47 ++- modules/world-lod/src/lod_upload_queue.zig | 17 +- modules/world-lod/src/tests.zig | 2 + modules/world-meshing/src/chunk_storage.zig | 17 + .../src/chunk_queue_coordinator.zig | 242 ++++++++++--- modules/world-runtime/src/world.zig | 47 +-- .../world-runtime/src/world_facade_tests.zig | 27 +- modules/world-runtime/src/world_renderer.zig | 152 +++++--- modules/world-runtime/src/world_streamer.zig | 66 ++-- src/game/session_tests.zig | 18 + src/integration_test.zig | 3 - src/world_inline_tests.zig | 24 ++ 35 files changed, 1178 insertions(+), 394 deletions(-) diff --git a/assets/shaders/vulkan/g_pass.frag b/assets/shaders/vulkan/g_pass.frag index ba74bdcf..14d1fa36 100644 --- a/assets/shaders/vulkan/g_pass.frag +++ b/assets/shaders/vulkan/g_pass.frag @@ -36,11 +36,22 @@ layout(set = 0, binding = 0) uniform GlobalUniforms { vec4 lpv_origin; } global; +const float LOD_CHUNK_SIZE = 16.0; + +bool shouldDiscardLODFragment(float encodedMaskRadius, vec2 cameraRelativeXZ) { + float maskRadius = abs(encodedMaskRadius); + if (maskRadius < 1.0) return false; + + bool readyDiskMask = encodedMaskRadius < 0.0; + vec2 cameraChunkLocal = mod(global.cam_pos.xz, LOD_CHUNK_SIZE); + vec2 chunkDelta = floor((cameraRelativeXZ + cameraChunkLocal) / LOD_CHUNK_SIZE); + float detailRadiusChunks = floor(maskRadius / LOD_CHUNK_SIZE) + (readyDiskMask ? 0.0 : 2.0); + return dot(chunkDelta, chunkDelta) <= detailRadiusChunks * detailRadiusChunks; +} + void main() { - bool isLOD = vTileID < 0 || vMaskRadius > 0.0; - if (vMaskRadius >= 1.0) { - if (length(vFragPosWorld.xz) < vMaskRadius) discard; - } + bool isLOD = vTileID < 0 || abs(vMaskRadius) > 0.0; + if (shouldDiscardLODFragment(vMaskRadius, vFragPosWorld.xz)) discard; vec3 N = normalize(vNormal); if (!isLOD) { diff --git a/assets/shaders/vulkan/lod_compact_terrain.frag b/assets/shaders/vulkan/lod_compact_terrain.frag index 4e8fe289..6db0ddf7 100644 --- a/assets/shaders/vulkan/lod_compact_terrain.frag +++ b/assets/shaders/vulkan/lod_compact_terrain.frag @@ -30,8 +30,21 @@ layout(set = 0, binding = 0) uniform Global { vec4 lpv_origin; } global; +const float LOD_CHUNK_SIZE = 16.0; + +bool shouldDiscardLODFragment(float encodedMaskRadius, vec2 cameraRelativeXZ) { + float maskRadius = abs(encodedMaskRadius); + if (maskRadius < 1.0) return false; + + bool readyDiskMask = encodedMaskRadius < 0.0; + vec2 cameraChunkLocal = mod(global.cam_pos.xz, LOD_CHUNK_SIZE); + vec2 chunkDelta = floor((cameraRelativeXZ + cameraChunkLocal) / LOD_CHUNK_SIZE); + float detailRadiusChunks = floor(maskRadius / LOD_CHUNK_SIZE) + (readyDiskMask ? 0.0 : 2.0); + return dot(chunkDelta, chunkDelta) <= detailRadiusChunks * detailRadiusChunks; +} + void main() { - if (vMaskRadius >= 1.0 && length(vFragPosWorld.xz) < vMaskRadius) discard; + if (shouldDiscardLODFragment(vMaskRadius, vFragPosWorld.xz)) discard; vec3 normal = normalize(vNormal); vec3 light_dir = normalize(global.sun_dir.xyz); float diffuse = max(dot(normal, light_dir), 0.0); diff --git a/assets/shaders/vulkan/lod_compact_water.frag b/assets/shaders/vulkan/lod_compact_water.frag index 3e547bea..ff361c2f 100644 --- a/assets/shaders/vulkan/lod_compact_water.frag +++ b/assets/shaders/vulkan/lod_compact_water.frag @@ -15,6 +15,9 @@ layout(location = 11) in float vLODFade; layout(location = 0) out vec4 outColor; +// Matches the two-chunk overlap reserved by LODConfig.calculateMaskRadius(). +const float LOD_MASK_BLEND_WIDTH = 32.0; + layout(set = 0, binding = 0) uniform Global { mat4 view_proj; mat4 view_proj_prev; @@ -35,7 +38,19 @@ layout(set = 0, binding = 0) uniform Global { } global; void main() { - if (vMaskRadius >= 1.0 && length(vFragPosWorld.xz) < vMaskRadius) discard; + float lodMaskAlpha = 1.0; + if (abs(vMaskRadius) >= 1.0) { + // A negative radius carries the outer edge of the ready detail disk; + // begin water's translucent handoff two chunks inside that edge. + bool readyDiskMask = vMaskRadius < 0.0; + float maskRadius = abs(vMaskRadius); + if (readyDiskMask) maskRadius = max(maskRadius - LOD_MASK_BLEND_WIDTH, 0.0); + float maskDistance = length(vFragPosWorld.xz); + if (maskDistance < maskRadius) discard; + // Fade the translucent LOD underlay in across the detailed-water + // overlap instead of changing its contribution at a hard circle. + lodMaskAlpha = smoothstep(maskRadius, maskRadius + LOD_MASK_BLEND_WIDTH, maskDistance); + } // Far water deliberately avoids scene-depth, reflection, SSR, atlas, and // thickness reads. It uses stable low-frequency waves and atmospheric fog. float wave = sin(vFragPosWorld.x * 0.012 + global.params.x * 0.55) * @@ -54,5 +69,5 @@ void main() { base = mix(base, global.fog_color.rgb, fog); } - outColor = vec4(base, 0.78 * clamp(vLODFade, 0.0, 1.0)); + outColor = vec4(base, 0.78 * clamp(vLODFade, 0.0, 1.0) * lodMaskAlpha); } diff --git a/assets/shaders/vulkan/terrain.frag b/assets/shaders/vulkan/terrain.frag index b68c4ee2..b2e25f02 100644 --- a/assets/shaders/vulkan/terrain.frag +++ b/assets/shaders/vulkan/terrain.frag @@ -41,6 +41,20 @@ layout(set = 0, binding = 0) uniform GlobalUniforms { // Constants const float PI = 3.14159265359; +const float LOD_CHUNK_SIZE = 16.0; + +bool shouldDiscardLODFragment(float encodedMaskRadius, vec2 cameraRelativeXZ) { + float maskRadius = abs(encodedMaskRadius); + if (maskRadius < 1.0) return false; + + bool readyDiskMask = encodedMaskRadius < 0.0; + vec2 cameraChunkLocal = mod(global.cam_pos.xz, LOD_CHUNK_SIZE); + vec2 chunkDelta = floor((cameraRelativeXZ + cameraChunkLocal) / LOD_CHUNK_SIZE); + // Streaming gives the contiguous ready disk to detail and the outer + // annulus to LOD. Legacy integral masks retain the two-chunk overlap. + float detailRadiusChunks = floor(maskRadius / LOD_CHUNK_SIZE) + (readyDiskMask ? 0.0 : 2.0); + return dot(chunkDelta, chunkDelta) <= detailRadiusChunks * detailRadiusChunks; +} float saturate(float v) { return clamp(v, 0.0, 1.0); @@ -264,7 +278,7 @@ float computeShadowFactor(vec3 fragPosWorld, vec3 N, vec3 L, int layer) { // receiver reference moves slightly closer to the light (higher depth) to // avoid self-shadowing on coplanar surfaces. float biasTexels = 0.35 + 0.2 * min(tanTheta, 5.0); - if (vTileID < 0 || vMaskRadius > 0.0) biasTexels = max(biasTexels, 0.45); + if (vTileID < 0 || abs(vMaskRadius) > 0.0) biasTexels = max(biasTexels, 0.45); float bias = worldTexelSize * biasTexels / depthSpan; float compareDepth = min(currentDepth + bias, 1.0); @@ -509,17 +523,15 @@ void main() { const float TEXTURE_FADE_START = 32.0; const float TEXTURE_FADE_END = 128.0; float viewDistance = length(vFragPosWorld); - bool isLOD = vTileID < 0 || vMaskRadius > 0.0; + bool isLOD = vTileID < 0 || abs(vMaskRadius) > 0.0; float textureDetail = 1.0 - smoothstep(TEXTURE_FADE_START, TEXTURE_FADE_END, viewDistance); if (isLOD) { textureDetail = 0.0; } - if (vMaskRadius >= 1.0) { - // 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; - } + // Full-detail chunks own this area. Dithering the handoff creates a + // camera-following grid of holes at the chunk/LOD boundary. + if (shouldDiscardLODFragment(vMaskRadius, vFragPosWorld.xz)) discard; vec2 tileBase = vec2(mod(float(vTileID), 16.0), floor(float(vTileID) / 16.0)) * (1.0 / 16.0); vec2 tiledUV = fract(vTexCoord); diff --git a/assets/shaders/vulkan/terrain.frag.spv b/assets/shaders/vulkan/terrain.frag.spv index 90b68ba969040dbde0fc33b43db633d7e1e02da6..4856b59781d1f7379b585e245d6637bf0631d14f 100644 GIT binary patch literal 63268 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 -}Nnp!r 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 literal 62156 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

4xZu!3!T>FOw*Zxr(|5w5F@8}xd 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 0.0; - if (vMaskRadius >= 1.0) { - if (length(vFragPosWorld.xz) < vMaskRadius) discard; + bool isLOD = vTileID < 0 || abs(vMaskRadius) > 0.0; + float lodMaskAlpha = 1.0; + if (abs(vMaskRadius) >= 1.0) { + // A negative radius carries the outer edge of the ready detail disk; + // begin water's translucent handoff two chunks inside that edge. + bool readyDiskMask = vMaskRadius < 0.0; + float maskRadius = abs(vMaskRadius); + if (readyDiskMask) maskRadius = max(maskRadius - LOD_MASK_BLEND_WIDTH, 0.0); + float maskDistance = length(vFragPosWorld.xz); + if (maskDistance < maskRadius) discard; + // Fade the translucent LOD underlay in across the detailed-water + // overlap instead of changing its contribution at a hard circle. + lodMaskAlpha = smoothstep(maskRadius, maskRadius + LOD_MASK_BLEND_WIDTH, maskDistance); } float time = global.params.x; @@ -226,5 +238,5 @@ void main() { if (isLOD) alpha = max(alpha, 0.93); alpha = clamp(alpha, 0.56, 0.96); - FragColor = vec4(waterColor, alpha); + FragColor = vec4(waterColor, alpha * lodMaskAlpha); } diff --git a/docs/shaders/spirv-sizes.json b/docs/shaders/spirv-sizes.json index c73e73ab..f2287b9c 100644 --- a/docs/shaders/spirv-sizes.json +++ b/docs/shaders/spirv-sizes.json @@ -12,9 +12,9 @@ "assets/shaders/vulkan/fxaa.frag": 5916, "assets/shaders/vulkan/fxaa.vert": 1160, "assets/shaders/vulkan/g_pass.frag": 6912, - "assets/shaders/vulkan/lod_compact_terrain.frag": 4296, + "assets/shaders/vulkan/lod_compact_terrain.frag": 5660, "assets/shaders/vulkan/lod_compact_terrain.vert": 20656, - "assets/shaders/vulkan/lod_compact_water.frag": 5364, + "assets/shaders/vulkan/lod_compact_water.frag": 5912, "assets/shaders/vulkan/lod_compact_water.vert": 13332, "assets/shaders/vulkan/lod_culling.comp": 14044, "assets/shaders/vulkan/lpv_inject.comp": 4844, diff --git a/modules/game-core/src/session.zig b/modules/game-core/src/session.zig index 25b02944..20773593 100644 --- a/modules/game-core/src/session.zig +++ b/modules/game-core/src/session.zig @@ -81,6 +81,13 @@ const SpawnColumn = struct { info: @import("world-worldgen").ColumnInfo, }; +/// The explicit numeric render-distance setting owns full-detail reach. +/// Quality and safe-mode profiles tune implementation budgets without silently +/// reducing the player's requested value. +pub fn fullDetailRenderDistance(render_distance: i32) i32 { + return World.effectiveChunkRenderRadius(render_distance); +} + pub const GameSession = struct { allocator: std.mem.Allocator, world: *World, @@ -131,7 +138,7 @@ pub const GameSession = struct { const safe_mode = runtime_env.safeModeEnabled(); const strict_safe_mode = runtime_env.strictSafeModeEnabled(); - const effective_render_distance: i32 = render_distance; + const effective_render_distance: i32 = @max(render_distance, 2); const chunk_debug_restore_lod = chunkDebugRestoreEnabled(build_config, "lod"); const effective_lod_enabled = if (build_config.chunk_debug_mode) chunk_debug_restore_lod @@ -149,22 +156,20 @@ pub const GameSession = struct { const preset_cfg = render_settings.getPresetConfig(render_distance_preset); - const effective_horizon_distance = @max(horizon_distance, effective_render_distance); + const effective_horizon_distance = LODConfig.normalizeHorizonDistance(effective_render_distance, horizon_distance); const manual_distance_expanded = effective_render_distance > preset_cfg.lod_radii[0] or effective_horizon_distance != preset_cfg.horizon_radius; - const chunk_render_radius = if (strict_safe_mode) - @min(effective_render_distance, 8) - else if (effective_lod_enabled and manual_distance_expanded) - @min(effective_render_distance, preset_cfg.lod_radii[0]) - else - effective_render_distance; + const chunk_render_radius = fullDetailRenderDistance(effective_render_distance); var preset_radii = if (strict_safe_mode) LODConfig.radiiForDistances(chunk_render_radius, @max(effective_horizon_distance, 64)) else if (effective_lod_enabled and manual_distance_expanded) - LODConfig.radiiForDistances(chunk_render_radius, effective_horizon_distance) + LODConfig.radiiForDistances(effective_render_distance, effective_horizon_distance) else preset_cfg.lod_radii; - const active_count = preset_cfg.active_lod_count; + const active_count = if (effective_lod_enabled and manual_distance_expanded) + LODConfig.activeCountForRadii(preset_radii) + else + 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/game-core/src/settings/data.zig b/modules/game-core/src/settings/data.zig index 3b525397..a9f80627 100644 --- a/modules/game-core/src/settings/data.zig +++ b/modules/game-core/src/settings/data.zig @@ -198,15 +198,12 @@ pub const Settings = struct { pub const metadata = struct { pub const render_distance = SettingMetadata{ .label = "RENDER DISTANCE", - .kind = .{ .int_range = .{ .min = 2, .max = 32, .step = 1 } }, + .kind = .{ .int_range = .{ .min = 2, .max = std.math.maxInt(i32), .step = 1 } }, }; pub const horizon_distance = SettingMetadata{ .label = "HORIZON DISTANCE", - .description = "Coarsest LOD radius in chunks, independent of full-detail render distance", - .kind = .{ .choice = .{ - .labels = &[_][]const u8{ "256 CHUNKS", "512 CHUNKS", "1024 CHUNKS", "2048 CHUNKS" }, - .values = &[_]u32{ 256, 512, 1024, 2048 }, - } }, + .description = "Coarsest LOD radius; scales with full-detail render distance", + .kind = .{ .int_range = .{ .min = 256, .max = std.math.maxInt(i32), .step = 2 } }, }; pub const mouse_sensitivity = SettingMetadata{ .label = "SENSITIVITY", diff --git a/modules/game-core/src/settings/json_presets.zig b/modules/game-core/src/settings/json_presets.zig index f0560ba7..7c3aa912 100644 --- a/modules/game-core/src/settings/json_presets.zig +++ b/modules/game-core/src/settings/json_presets.zig @@ -118,12 +118,12 @@ pub fn initPresets(allocator: std.mem.Allocator) !void { log.log.warn("Skipping preset '{s}': invalid lpv_propagation_iterations {}", .{ p.name, p.lpv_propagation_iterations }); continue; } - if (p.render_distance < 2 or p.render_distance > 32) { + if (p.render_distance < 2) { log.log.warn("Skipping preset '{s}': invalid render_distance {}", .{ p.name, p.render_distance }); continue; } if (p.horizon_distance) |horizon_distance| { - if (horizon_distance != 256 and horizon_distance != 512 and horizon_distance != 1024 and horizon_distance != 2048) { + if (horizon_distance < 1) { log.log.warn("Skipping preset '{s}': invalid horizon_distance {}", .{ p.name, horizon_distance }); continue; } diff --git a/modules/game-ui/src/screens/rml_settings.zig b/modules/game-ui/src/screens/rml_settings.zig index 3d75ae90..032df2ac 100644 --- a/modules/game-ui/src/screens/rml_settings.zig +++ b/modules/game-ui/src/screens/rml_settings.zig @@ -17,6 +17,7 @@ const apply_logic = settings_pkg.apply_logic; const Settings = settings_pkg.Settings; const render_settings_mod = @import("engine-rhi").render_settings; const RenderDistancePreset = render_settings_mod.RenderDistancePreset; +const LODConfig = @import("world-lod").lod_chunk.LODConfig; const SettingsTab = enum { display, camera, world, rendering }; const SettingAction = enum { previous, next, toggle }; @@ -160,21 +161,17 @@ pub const RmlSettingsScreen = struct { fn handleWorldAction(self: *@This(), id: []const u8) void { const settings = self.context.settings; - if (std.mem.eql(u8, id, "render-distance-prev") and settings.render_distance > 1) { + if (std.mem.eql(u8, id, "render-distance-prev") and settings.render_distance > 2) { settings.render_distance -= 1; - } else if (std.mem.eql(u8, id, "render-distance-next")) { + } else if (std.mem.eql(u8, id, "render-distance-next") and settings.render_distance < std.math.maxInt(i32)) { settings.render_distance += 1; + settings.horizon_distance = LODConfig.normalizeHorizonDistance(settings.render_distance, settings.horizon_distance); } else if (std.mem.eql(u8, id, "horizon-distance-prev") or std.mem.eql(u8, id, "horizon-distance-next")) { - const values = [_]i32{ 256, 512, 1024, 2048 }; - var index: usize = 1; - for (values, 0..) |value, i| { - if (settings.horizon_distance == value) index = i; - } - index = if (std.mem.eql(u8, id, "horizon-distance-prev")) - if (index == 0) values.len - 1 else index - 1 - else - (index + 1) % values.len; - settings.horizon_distance = values[index]; + settings.horizon_distance = LODConfig.stepHorizonDistance( + settings.render_distance, + settings.horizon_distance, + std.mem.eql(u8, id, "horizon-distance-next"), + ); } else if (std.mem.eql(u8, id, "lod-toggle")) { settings.lod_enabled = !settings.lod_enabled; if (settings_pkg.sanitizeRuntimeConflicts(settings)) { @@ -333,9 +330,9 @@ pub const RmlSettingsScreen = struct { const settings = self.context.settings; try appendSection(out, self.context.allocator, "DISTANCE"); const render_distance = try std.fmt.bufPrint(&buffer, "{} CHUNKS", .{settings.render_distance}); - try appendStepperRow(out, self.context.allocator, "RENDER DISTANCE", "Near-field chunk budget.", render_distance, "render-distance"); + try appendStepperRow(out, self.context.allocator, "RENDER DISTANCE", "Full-detail chunk radius.", render_distance, "render-distance"); const horizon_distance = try std.fmt.bufPrint(&buffer, "{} CHUNKS", .{settings.horizon_distance}); - try appendStepperRow(out, self.context.allocator, "HORIZON DISTANCE", "Coarsest LOD radius, independent of near chunks.", horizon_distance, "horizon-distance"); + try appendStepperRow(out, self.context.allocator, "HORIZON DISTANCE", "Coarsest LOD radius; scales with full-detail distance.", horizon_distance, "horizon-distance"); try appendSection(out, self.context.allocator, "STREAMING"); try appendToggleRow(out, self.context.allocator, "LOD SYSTEM", "Distance terrain streaming.", settings.lod_enabled, "lod"); } diff --git a/modules/game-ui/src/screens/settings.zig b/modules/game-ui/src/screens/settings.zig index 309df5b8..73e53151 100644 --- a/modules/game-ui/src/screens/settings.zig +++ b/modules/game-ui/src/screens/settings.zig @@ -13,6 +13,7 @@ const apply_logic = settings_pkg.apply_logic; const Settings = settings_pkg.Settings; const render_settings_mod = @import("engine-rhi").render_settings; const RenderDistancePreset = render_settings_mod.RenderDistancePreset; +const LODConfig = @import("world-lod").lod_chunk.LODConfig; const PANEL_WIDTH_MAX = 1360.0; const PANEL_HEIGHT_MAX = 820.0; @@ -295,22 +296,18 @@ fn drawWorldTab(ui: *UISystem, settings: anytype, rs: anytype, layout: ColumnLay y_left += 28.0 * scale; const render_distance_label = std.fmt.bufPrint(&num_buf, "{} CHUNKS", .{settings.render_distance}) catch "?"; - if (drawStepperRow(ui, .{ .x = layout.left_x, .y = y_left, .width = layout.col_w, .height = row_h }, "RENDER DISTANCE", "Near-field chunk budget.", render_distance_label, label_scale, value_scale, button_scale, mouse_x, mouse_y, mouse_clicked, scale)) |step| { - if (step == .previous and settings.render_distance > 1) settings.render_distance -= 1; - if (step == .next) settings.render_distance += 1; + if (drawStepperRow(ui, .{ .x = layout.left_x, .y = y_left, .width = layout.col_w, .height = row_h }, "RENDER DISTANCE", "Full-detail chunk radius.", render_distance_label, label_scale, value_scale, button_scale, mouse_x, mouse_y, mouse_clicked, scale)) |step| { + if (step == .previous and settings.render_distance > 2) settings.render_distance -= 1; + if (step == .next and settings.render_distance < std.math.maxInt(i32)) { + settings.render_distance += 1; + settings.horizon_distance = LODConfig.normalizeHorizonDistance(settings.render_distance, settings.horizon_distance); + } } y_left += row_h + 8.0 * scale; const horizon_distance_label = std.fmt.bufPrint(&num_buf, "{} CHUNKS", .{settings.horizon_distance}) catch "?"; - if (drawStepperRow(ui, .{ .x = layout.left_x, .y = y_left, .width = layout.col_w, .height = row_h }, "HORIZON DISTANCE", "Coarsest LOD radius, independent of near chunks.", horizon_distance_label, label_scale, value_scale, button_scale, mouse_x, mouse_y, mouse_clicked, scale)) |step| { - const values = [_]i32{ 256, 512, 1024, 2048 }; - var current_idx: usize = 1; - for (values, 0..) |value, i| { - if (settings.horizon_distance == value) current_idx = i; - } - if (step == .previous) current_idx = if (current_idx == 0) values.len - 1 else current_idx - 1; - if (step == .next) current_idx = (current_idx + 1) % values.len; - settings.horizon_distance = values[current_idx]; + if (drawStepperRow(ui, .{ .x = layout.left_x, .y = y_left, .width = layout.col_w, .height = row_h }, "HORIZON DISTANCE", "Coarsest LOD radius; scales with full-detail distance.", horizon_distance_label, label_scale, value_scale, button_scale, mouse_x, mouse_y, mouse_clicked, scale)) |step| { + settings.horizon_distance = LODConfig.stepHorizonDistance(settings.render_distance, settings.horizon_distance, step == .next); } var y_right = if (layout.two_column) layout.top_y else y_left + row_h + 22.0 * scale; diff --git a/modules/game-ui/src/screens/world.zig b/modules/game-ui/src/screens/world.zig index 5444a32e..487a1ebe 100644 --- a/modules/game-ui/src/screens/world.zig +++ b/modules/game-ui/src/screens/world.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const LODConfig = @import("world-lod").lod_chunk.LODConfig; const UISystem = @import("engine-ui").UISystem; const Screen = @import("../screen.zig"); const IScreen = Screen.IScreen; @@ -169,8 +170,7 @@ pub const WorldScreen = struct { const world_telemetry = self.world.telemetry(); if (!self.menu_preview) { - const preset = rhi_pkg.getPresetConfig(ctx.settings.render_distance_preset); - self.session.world.setLODChunkRenderRadiusLimit(preset.lod_radii[0]); + ctx.settings.horizon_distance = LODConfig.normalizeHorizonDistance(ctx.settings.render_distance, ctx.settings.horizon_distance); if (world_telemetry.getRenderDistance() != ctx.settings.render_distance) { world_telemetry.setRenderDistance(ctx.settings.render_distance); } diff --git a/modules/world-lod/src/lod_chunk.zig b/modules/world-lod/src/lod_chunk.zig index 167f1302..bd21b9c5 100644 --- a/modules/world-lod/src/lod_chunk.zig +++ b/modules/world-lod/src/lod_chunk.zig @@ -109,7 +109,8 @@ pub const ChunkBounds = struct { pub fn distanceSquaredToPoint(self: ChunkBounds, point_x: i32, point_z: i32) i64 { const dx = axisDistance(point_x, self.min_x, self.max_x); const dz = axisDistance(point_z, self.min_z, self.max_z); - return dx * dx + dz * dz; + const distance_sq = @as(i128, dx) * dx + @as(i128, dz) * dz; + return @intCast(@min(distance_sq, std.math.maxInt(i64))); } /// Tests whether this chunk bounds intersects a radius around a chunk-coordinate center. @@ -634,9 +635,13 @@ pub fn activeLODCount(config: ILODConfig) usize { pub const LODConfig = struct { pub const default_chunk_render_radius: i32 = 16; pub const default_horizon_radius: i32 = 512; + pub const minimum_horizon_radius: i32 = 256; pub const target_lod1_radius: i32 = 96; // keep 2-block cells visible farther out. pub const target_lod2_radius: i32 = 256; pub const target_lod3_radius: i32 = 512; + /// Keep enough horizon beyond full detail for the complete LOD ladder to + /// remain useful as users raise render distance. + pub const horizon_render_distance_scale: i64 = 32; /// Radius of real full-detail chunks. LOD0 is a separate 1-block-column /// LOD ring that extends beyond this radius. @@ -692,16 +697,40 @@ pub const LODConfig = struct { } /// Expands a full-detail render distance into the default LOD radius ladder. - /// The farthest radius uses the default horizon distance. + /// The farthest radius scales with full-detail distance while retaining the + /// default horizon for ordinary settings. pub fn radiiForRenderDistance(distance: i32) [LODLevel.count]i32 { - return radiiForDistances(distance, default_horizon_radius); + return radiiForDistances(distance, normalizeHorizonDistance(distance, default_horizon_radius)); + } + + /// Returns the minimum useful distant-LOD horizon for a full-detail radius. + /// Arithmetic saturates at the coordinate representation limit rather than + /// introducing an arbitrary settings cap. + pub fn recommendedHorizonDistance(distance: i32) i32 { + const requested = @as(i64, @max(distance, 1)); + const scaled = @min(requested * horizon_render_distance_scale, @as(i64, std.math.maxInt(i32))); + return @intCast(@max(@as(i64, minimum_horizon_radius), scaled)); + } + + /// Normalizes an explicit horizon so it never collapses distant LOD bands. + pub fn normalizeHorizonDistance(render_distance: i32, horizon_distance: i32) i32 { + return @max(horizon_distance, recommendedHorizonDistance(render_distance)); + } + + /// Steps an uncapped horizon geometrically while respecting the dynamic + /// minimum required by the full-detail render distance. + pub fn stepHorizonDistance(render_distance: i32, horizon_distance: i32, increase: bool) i32 { + const minimum = recommendedHorizonDistance(render_distance); + const current = @max(horizon_distance, minimum); + if (!increase) return @max(minimum, @divFloor(current, 2)); + return @intCast(@min(@as(i64, current) * 2, @as(i64, std.math.maxInt(i32)))); } /// Expands full-detail and horizon distances into monotonically increasing LOD radii. /// Radii are expressed in chunks and are clamped so they do not exceed the horizon. pub fn radiiForDistances(distance: i32, horizon_distance: i32) [LODLevel.count]i32 { const requested = @max(distance, 1); - const lod0_target = @max(@as(i64, requested) * 3, @as(i64, requested + 16)); + const lod0_target = @max(@as(i64, requested) * 3, @as(i64, requested) + 16); const lod0 = @as(i32, @intCast(@min(lod0_target, @as(i64, @max(horizon_distance, requested))))); const horizon = @max(horizon_distance, lod0); const max_radius_i64 = @as(i64, horizon); @@ -987,8 +1016,13 @@ test "LODConfig expands render distance into distant LOD horizon" { try std.testing.expectEqual(@as(i32, 96), radii[0]); try std.testing.expectEqual(@as(i32, 192), radii[1]); try std.testing.expectEqual(@as(i32, 384), radii[2]); - try std.testing.expectEqual(@as(i32, 512), radii[3]); - try std.testing.expectEqual(@as(i32, 512), radii[4]); + try std.testing.expectEqual(@as(i32, 768), radii[3]); + try std.testing.expectEqual(@as(i32, 1024), radii[4]); + + try std.testing.expectEqual(@as(i32, 256), LODConfig.recommendedHorizonDistance(8)); + try std.testing.expectEqual(@as(i32, 131_072), LODConfig.recommendedHorizonDistance(4096)); + try std.testing.expectEqual(std.math.maxInt(i32), LODConfig.recommendedHorizonDistance(std.math.maxInt(i32))); + try std.testing.expectEqual(@as(i32, 131_072), LODConfig.normalizeHorizonDistance(4096, 2048)); const custom_horizon = LODConfig.radiiForDistances(12, 1024); try std.testing.expectEqual(@as(i32, 36), custom_horizon[0]); @@ -996,6 +1030,13 @@ test "LODConfig expands render distance into distant LOD horizon" { try std.testing.expectEqual(@as(i32, 256), custom_horizon[2]); try std.testing.expectEqual(@as(i32, 512), custom_horizon[3]); try std.testing.expectEqual(@as(i32, 1024), custom_horizon[4]); + + const beyond_horizon = LODConfig.radiiForDistances(4096, 2048); + try std.testing.expectEqual([_]i32{4096} ** LODLevel.count, beyond_horizon); + try std.testing.expectEqual(@as(u32, 1), LODConfig.activeCountForRadii(beyond_horizon)); + + const integer_limit = LODConfig.radiiForDistances(std.math.maxInt(i32), 2048); + try std.testing.expectEqual([_]i32{std.math.maxInt(i32)} ** LODLevel.count, integer_limit); } test "LODConfig keeps the coarse fallback when tail radii match" { @@ -1016,6 +1057,14 @@ test "ChunkBounds intersects radius radially" { const diagonal_region = ChunkBounds{ .min_x = 16, .min_z = 16, .max_x = 31, .max_z = 31 }; try std.testing.expect(!diagonal_region.intersectsRadius(0, 0, 16)); try std.testing.expectEqual(@as(i64, 16 * 16 + 16 * 16), diagonal_region.distanceSquaredToPoint(0, 0)); + + const extreme_region = ChunkBounds{ + .min_x = std.math.maxInt(i32), + .min_z = std.math.maxInt(i32), + .max_x = std.math.maxInt(i32), + .max_z = std.math.maxInt(i32), + }; + try std.testing.expectEqual(std.math.maxInt(i64), extreme_region.distanceSquaredToPoint(std.math.minInt(i32), std.math.minInt(i32))); } test "ILODConfig.calculateMaskRadius" { diff --git a/modules/world-lod/src/lod_manager.zig b/modules/world-lod/src/lod_manager.zig index 5276bc28..c131b61e 100644 --- a/modules/world-lod/src/lod_manager.zig +++ b/modules/world-lod/src/lod_manager.zig @@ -82,6 +82,7 @@ const ChunkCoordSet = std.HashMap(ChunkCoordKey, void, ChunkCoordKeyContext, std const PendingIngestion = lod_manager_context.PendingIngestion; const PlayerChunkPos = lod_manager_context.PlayerChunkPos; const LifecycleQueue = lod_manager_context.LifecycleQueue; +const LODScanState = lod_manager_context.LODScanState; pub const ChunkResolver = lod_manager_context.ChunkResolver; const MAX_LOD_REGIONS = lod_manager_context.MAX_LOD_REGIONS; @@ -169,6 +170,7 @@ pub const LODManager = struct { // Current player position (chunk coords), read by worker threads for stale-job checks. player_cx: std.atomic.Value(i32), player_cz: std.atomic.Value(i32), + scan_states: [LODLevel.count]LODScanState, // Stats stats: LODStats, @@ -309,7 +311,7 @@ pub const LODManager = struct { const center_z = @as(i64, chunk.region_z) * scale + @divFloor(scale, 2); const dx = center_x - player_cx; const dz = center_z - player_cz; - if (dx * dx + dz * dz <= radius * radius) return true; + if (@as(i128, dx) * dx + @as(i128, dz) * dz <= @as(i128, radius) * radius) return true; } return false; } @@ -346,13 +348,20 @@ pub const LODManager = struct { /// Renders a frame-aware LOD layer. The monotonic WorldRenderer serial /// allows terrain and water to share one visibility projection. - pub fn renderFrame(self: *Self, frame_serial: u64, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, use_frustum: bool, max_distance_chunks: ?i32, layer: LODRenderLayer) void { - return lod_manager_core.renderFrame(self, frame_serial, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer); + pub fn renderFrame(self: *Self, frame_serial: u64, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, use_frustum: bool, max_distance_chunks: ?i32, detail_render_radius: i32, layer: LODRenderLayer) void { + return lod_manager_core.renderFrame(self, frame_serial, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, detail_render_radius, layer); + } + + /// Returns the immutable projection decision produced by the current LOD + /// frame. The main-thread full-detail pass consumes it immediately after + /// LOD rendering, so no manager lock is needed or acquired here. + pub fn suppressesDetailChunk(self: *const Self, chunk_x: i32, chunk_z: i32) bool { + return self.renderer.suppressesDetailChunk(chunk_x, chunk_z); } /// Prepares same-frame GPU LOD culling before active graphics passes. - pub fn prepareFrame(self: *Self, frame_serial: u64, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, max_distance_chunks: ?i32) void { - return lod_manager_core.prepareFrame(self, frame_serial, view_proj, camera_pos, chunk_checker, checker_ctx, max_distance_chunks); + pub fn prepareFrame(self: *Self, frame_serial: u64, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, max_distance_chunks: ?i32, detail_render_radius: i32) void { + return lod_manager_core.prepareFrame(self, frame_serial, view_proj, camera_pos, chunk_checker, checker_ctx, max_distance_chunks, detail_render_radius); } /// Enables persistent source-data caching for LOD regions below `save_dir_path`. diff --git a/modules/world-lod/src/lod_manager_cache_ops.zig b/modules/world-lod/src/lod_manager_cache_ops.zig index e2af6f30..30b79767 100644 --- a/modules/world-lod/src/lod_manager_cache_ops.zig +++ b/modules/world-lod/src/lod_manager_cache_ops.zig @@ -4,6 +4,7 @@ const Self = @import("lod_manager.zig").LODManager; const LODRegionKey = @import("lod_chunk.zig").LODRegionKey; const LODSimplifiedData = @import("lod_chunk.zig").LODSimplifiedData; const lod_chunk = @import("lod_chunk.zig"); +const manager_ctx = @import("lod_manager_context.zig"); const lod_cache = @import("lod_cache.zig"); const lod_store = @import("lod_store.zig"); const cache_io = @import("lod_cache_io.zig"); @@ -332,6 +333,7 @@ pub fn initCacheTestManager(allocator: std.mem.Allocator, cache_dir_path: []cons .transition_queue = .empty, .player_cx = std.atomic.Value(i32).init(0), .player_cz = std.atomic.Value(i32).init(0), + .scan_states = [_]manager_ctx.LODScanState{manager_ctx.LODScanState{}} ** lod_chunk.LODLevel.count, .stats = .{}, .profiling = .init(false), .cache_hits = 0, diff --git a/modules/world-lod/src/lod_manager_context.zig b/modules/world-lod/src/lod_manager_context.zig index 8b89b8af..bcf3faf6 100644 --- a/modules/world-lod/src/lod_manager_context.zig +++ b/modules/world-lod/src/lod_manager_context.zig @@ -163,6 +163,19 @@ pub const PlayerChunkPos = struct { cz: i32, }; +/// Persistent bounded-scan cursor for one LOD level. Coordinates are generated +/// relative to the current player region, so ordinary movement does not discard +/// progress through the configured horizon. +pub const LODScanState = struct { + player_rx: i32 = 0, + player_rz: i32 = 0, + effective_radius: i32 = -1, + next_ring: i64 = 0, + ring_index: i64 = 0, + seed_index: usize = 0, + last_examined: usize = 0, +}; + pub const ChunkResolver = struct { ptr: *anyopaque, resolve_fn: *const fn (ptr: *anyopaque, cx: i32, cz: i32) ?*const Chunk, diff --git a/modules/world-lod/src/lod_manager_core_ops.zig b/modules/world-lod/src/lod_manager_core_ops.zig index 561c49a2..4ea53490 100644 --- a/modules/world-lod/src/lod_manager_core_ops.zig +++ b/modules/world-lod/src/lod_manager_core_ops.zig @@ -133,6 +133,7 @@ pub fn init(allocator: std.mem.Allocator, config: ILODConfig, gpu_bridge: LODGPU .transition_queue = .empty, .player_cx = std.atomic.Value(i32).init(0), .player_cz = std.atomic.Value(i32).init(0), + .scan_states = [_]manager_ctx.LODScanState{manager_ctx.LODScanState{}} ** LODLevel.count, .stats = .{}, .profiling = .init(engine_core.envFlag("ZIGCRAFT_LOD_PROFILE", false) or lod_options.benchmark_lod_profile), .cache_hits = 0, @@ -545,7 +546,7 @@ pub fn render(self: *Self, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?Ch /// Renders a layer using a WorldRenderer-monotonic frame serial. The concrete /// LOD renderer projects visibility once for a serial and reuses safe value /// snapshots for the terrain and water submissions. -pub fn renderFrame(self: *Self, frame_serial: u64, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, use_frustum: bool, max_distance_chunks: ?i32, layer: LODRenderLayer) void { +pub fn renderFrame(self: *Self, frame_serial: u64, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, use_frustum: bool, max_distance_chunks: ?i32, detail_render_radius: i32, layer: LODRenderLayer) void { const lock_wait_timer = self.profiling.begin(); self.mutex.lockShared(); self.profiling.end(.manager_lock_wait, lock_wait_timer); @@ -553,21 +554,22 @@ pub fn renderFrame(self: *Self, frame_serial: u64, view_proj: Mat4, camera_pos: defer self.profiling.end(.manager_lock_hold, lock_hold_timer); defer self.mutex.unlockShared(); - self.renderer.renderFrame(frame_serial, &self.meshes, &self.regions, self.config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer, &self.stats, if (self.profiling.enabled) &self.profiling else null); + self.renderer.renderFrame(frame_serial, &self.meshes, &self.regions, self.config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, detail_render_radius, layer, &self.stats, if (self.profiling.enabled) &self.profiling else null); } -pub fn prepareFrame(self: *Self, frame_serial: u64, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, max_distance_chunks: ?i32) void { +pub fn prepareFrame(self: *Self, frame_serial: u64, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, max_distance_chunks: ?i32, detail_render_radius: i32) void { const lock_wait_timer = self.profiling.begin(); self.mutex.lockShared(); self.profiling.end(.manager_lock_wait, lock_wait_timer); const lock_hold_timer = self.profiling.begin(); defer self.profiling.end(.manager_lock_hold, lock_hold_timer); defer self.mutex.unlockShared(); - self.renderer.prepareFrame(frame_serial, &self.meshes, &self.regions, self.config, view_proj, camera_pos, chunk_checker, checker_ctx, max_distance_chunks, &self.stats, if (self.profiling.enabled) &self.profiling else null); + self.renderer.prepareFrame(frame_serial, &self.meshes, &self.regions, self.config, view_proj, camera_pos, chunk_checker, checker_ctx, max_distance_chunks, detail_render_radius, &self.stats, if (self.profiling.enabled) &self.profiling else null); } pub fn pointDistanceSquared(x0: i32, z0: i32, x1: i32, z1: i32) i64 { const dx = @as(i64, x0) - @as(i64, x1); const dz = @as(i64, z0) - @as(i64, z1); - return dx * dx + dz * dz; + const distance_sq = @as(i128, dx) * dx + @as(i128, dz) * dz; + return @intCast(@min(distance_sq, std.math.maxInt(i64))); } diff --git a/modules/world-lod/src/lod_manager_eviction_ops.zig b/modules/world-lod/src/lod_manager_eviction_ops.zig index 5791bf1b..2efe776d 100644 --- a/modules/world-lod/src/lod_manager_eviction_ops.zig +++ b/modules/world-lod/src/lod_manager_eviction_ops.zig @@ -305,6 +305,7 @@ pub fn updateStats(self: *Self) void { var deferred_deletion_gpu_bytes: usize = 0; var deferred_deletion_cpu_bytes: usize = 0; var resident_region_count: usize = 0; + var unmaterialized_region_count: usize = 0; const lock_wait_timer = self.profiling.begin(); self.mutex.lockShared(); @@ -325,7 +326,8 @@ pub fn updateStats(self: *Self) void { .simplified => |*s| { source_data_cpu_bytes += s.totalMemoryBytes(); }, - else => {}, + .empty => unmaterialized_region_count += 1, + .full => {}, } } @@ -368,7 +370,10 @@ pub fn updateStats(self: *Self) void { deferred_deletion_cpu_bytes; const budget_bytes = @as(usize, self.config.getMemoryBudgetMB()) * 1024 * 1024; const reservation_per_region = if (budget_bytes == 0) 0 else @min(budget_bytes, LOGICAL_LOD_REGION_RESERVATION_BYTES); - const admission_reservation_bytes = std.math.mul(usize, resident_region_count, reservation_per_region) catch std.math.maxInt(usize); + // Reserve conservatively only for regions that do not have measurable + // source data yet. Materialized regions are governed by their actual CPU + // and GPU footprint instead of a permanent per-region distance cap. + const admission_reservation_bytes = std.math.mul(usize, unmaterialized_region_count, reservation_per_region) catch std.math.maxInt(usize); const logical_admission_bytes = std.math.add(usize, known_memory_bytes, admission_reservation_bytes) catch std.math.maxInt(usize); self.stats.addMemory(known_memory_bytes); self.stats.pool_gpu_capacity_bytes = @intCast(pool_memory.pool_gpu_capacity_bytes); diff --git a/modules/world-lod/src/lod_manager_generation_ops.zig b/modules/world-lod/src/lod_manager_generation_ops.zig index e66c25d5..df0de202 100644 --- a/modules/world-lod/src/lod_manager_generation_ops.zig +++ b/modules/world-lod/src/lod_manager_generation_ops.zig @@ -96,6 +96,7 @@ pub fn queueLODRegions(self: *Self, lod: LODLevel, velocity: Vec3, chunk_checker .mutex = &self.mutex, .player_cx = player.cx, .player_cz = player.cz, + .scan_states = &self.scan_states, .next_job_token = &self.job_dispatcher.next_token, .cleanup_covered_regions = self.cleanup_covered_regions, .coverage_ptr = self, @@ -105,6 +106,9 @@ pub fn queueLODRegions(self: *Self, lod: LODLevel, velocity: Vec3, chunk_checker // not persistent LOD caching is enabled. .defer_generation_dispatch = true, .pending_regions = &self.pending_region_count, + // Distance is not bounded by a fixed region count. The logical-memory + // reservation below provides the actual resource-based backpressure. + .resident_region_limit = std.math.maxInt(usize), .logical_memory_limit_bytes = if (memory_budget_bytes == 0) std.math.maxInt(usize) else memory_budget_bytes, .logical_memory_bytes = &self.memory_governor.logical_admission_bytes, .logical_region_reservation_bytes = if (memory_budget_bytes == 0) 0 else @min(memory_budget_bytes, LOGICAL_LOD_REGION_RESERVATION_BYTES), diff --git a/modules/world-lod/src/lod_manager_tests.zig b/modules/world-lod/src/lod_manager_tests.zig index aff3543d..6f2c009c 100644 --- a/modules/world-lod/src/lod_manager_tests.zig +++ b/modules/world-lod/src/lod_manager_tests.zig @@ -114,7 +114,7 @@ test "LODManager initialization" { fn f(_: *anyopaque, _: *const [LODLevel.count]MeshMap, _: *const [LODLevel.count]RegionMap, _: ILODConfig, _: Mat4, _: Vec3, _: ?LODManager.ChunkChecker, _: ?*anyopaque, _: bool, _: ?i32, _: lod_gpu.LODRenderLayer, _: ?*LODStats, _: ?*LODProfilingCollector) void {} }.f, .prepare_frame_fn = struct { - fn f(ctx: *anyopaque, _: u64, _: *const [LODLevel.count]MeshMap, _: *const [LODLevel.count]RegionMap, _: ILODConfig, _: Mat4, _: Vec3, _: ?LODManager.ChunkChecker, _: ?*anyopaque, _: ?i32, stats: ?*LODStats, profiling: ?*LODProfilingCollector) void { + fn f(ctx: *anyopaque, _: u64, _: *const [LODLevel.count]MeshMap, _: *const [LODLevel.count]RegionMap, _: ILODConfig, _: Mat4, _: Vec3, _: ?LODManager.ChunkChecker, _: ?*anyopaque, _: ?i32, _: i32, stats: ?*LODStats, profiling: ?*LODProfilingCollector) void { const state: *MockState = @ptrCast(@alignCast(ctx)); state.prepare_saw_stats = stats != null; state.prepare_saw_profiling = profiling != null; @@ -146,7 +146,7 @@ test "LODManager initialization" { try std.testing.expectEqual(@as(u32, 0), stats.totalLoaded()); try std.testing.expectEqual(@as(u32, 0), stats.totalGenerating()); mgr.profiling.enabled = true; - mgr.prepareFrame(1, Mat4.identity, Vec3.zero, null, null, null); + mgr.prepareFrame(1, Mat4.identity, Vec3.zero, null, null, null, mgr.config.getChunkRenderRadius()); try std.testing.expect(mock_state.prepare_saw_stats); try std.testing.expect(mock_state.prepare_saw_profiling); diff --git a/modules/world-lod/src/lod_renderer.zig b/modules/world-lod/src/lod_renderer.zig index 5db9b7b9..5b03cb4c 100644 --- a/modules/world-lod/src/lod_renderer.zig +++ b/modules/world-lod/src/lod_renderer.zig @@ -68,8 +68,75 @@ const ILODCullingSystem = rhi_types.ILODCullingSystem; const CHUNK_COVERAGE_PADDING: i32 = 1; const LOD_UNMASKED_SENTINEL: f32 = 0.5; +// Positive radii retain the legacy two-chunk overlap. A negative radius marks +// an exact contiguous ready-detail disk without consuming float precision in a +// fractional tag at large render distances. const COMPACT_GRID_WIDTHS = [_]u32{ 5, 9, 17, 33, 65, 129 }; +fn conservativeChunkDiskMaskRadius(mask_radius: f32) f32 { + return if (mask_radius >= 1.0) -mask_radius else LOD_UNMASKED_SENTINEL; +} + +fn readyDiskMaskRadius(ready_radius: i32) f32 { + if (ready_radius < 0) return LOD_UNMASKED_SENTINEL; + const radius_blocks = @as(f32, @floatFromInt(@as(i64, ready_radius) * CHUNK_SIZE_X)); + return -@max(radius_blocks, 1.0); +} + +fn contiguousReadyDiskRadius(checker: ?ChunkChecker, checker_ctx: ?*anyopaque, camera_chunk_x: i32, camera_chunk_z: i32, max_radius: i32) i32 { + return expandContiguousReadyDiskRadius(checker, checker_ctx, camera_chunk_x, camera_chunk_z, -1, max_radius); +} + +fn readyDetailChunkAtOffset(check: ChunkChecker, ctx: *anyopaque, camera_chunk_x: i32, camera_chunk_z: i32, dx: i64, dz: i64) bool { + const cx = @as(i64, camera_chunk_x) + dx; + const cz = @as(i64, camera_chunk_z) + dz; + if (cx < std.math.minInt(i32) or cx > std.math.maxInt(i32) or cz < std.math.minInt(i32) or cz > std.math.maxInt(i32)) return false; + return check(@intCast(cx), @intCast(cz), ctx); +} + +fn expandContiguousReadyDiskRadius(checker: ?ChunkChecker, checker_ctx: ?*anyopaque, camera_chunk_x: i32, camera_chunk_z: i32, known_ready_radius: i32, max_radius: i32) i32 { + const check = checker orelse return -1; + const ctx = checker_ctx orelse return -1; + if (known_ready_radius >= max_radius) return max_radius; + + var radius = @as(i64, @max(known_ready_radius + 1, 0)); + const max_radius_i64 = @as(i64, @max(max_radius, 0)); + while (radius <= max_radius_i64) : (radius += 1) { + const radius_sq = radius * radius; + const previous_radius = radius - 1; + const previous_sq = previous_radius * previous_radius; + var max_dx = radius; + var previous_max_dx = previous_radius; + var abs_dz: i64 = 0; + while (abs_dz <= radius) : (abs_dz += 1) { + while (max_dx * max_dx + abs_dz * abs_dz > radius_sq) max_dx -= 1; + if (abs_dz <= previous_radius) { + while (previous_max_dx * previous_max_dx + abs_dz * abs_dz > previous_sq) previous_max_dx -= 1; + } else { + previous_max_dx = -1; + } + + const first_new_x = previous_max_dx + 1; + var abs_dx = first_new_x; + while (abs_dx <= max_dx) : (abs_dx += 1) { + if (!readyDetailChunkAtOffset(check, ctx, camera_chunk_x, camera_chunk_z, abs_dx, abs_dz)) return @intCast(radius - 1); + if (abs_dx > 0 and !readyDetailChunkAtOffset(check, ctx, camera_chunk_x, camera_chunk_z, -abs_dx, abs_dz)) return @intCast(radius - 1); + if (abs_dz > 0) { + if (!readyDetailChunkAtOffset(check, ctx, camera_chunk_x, camera_chunk_z, abs_dx, -abs_dz)) return @intCast(radius - 1); + if (abs_dx > 0 and !readyDetailChunkAtOffset(check, ctx, camera_chunk_x, camera_chunk_z, -abs_dx, -abs_dz)) return @intCast(radius - 1); + } + } + } + } + return @max(max_radius, 0); +} + +fn detailChunkKey(chunk_x: i32, chunk_z: i32) u64 { + const x_bits: u32 = @bitCast(chunk_x); + const z_bits: u32 = @bitCast(chunk_z); + return (@as(u64, x_bits) << 32) | @as(u64, z_bits); +} + fn selectLODDescriptorStream(render_ctx: anytype, layer: LODRenderLayer, compact: bool, gpu: bool) void { if (comptime !@hasDecl(@TypeOf(render_ctx), "setLODDescriptorStream")) return; const stream: rhi_types.LODDescriptorStream = switch (layer) { @@ -177,6 +244,7 @@ const VisibleRegion = struct { model: Mat4, mask_radius: f32, lod_fade: f32, + suppresses_detail: bool = false, }; const MAX_LOD_MDI_REGIONS: usize = 2048; @@ -199,6 +267,21 @@ pub fn LODRenderer(comptime RHI: type) type { instance_data: std.ArrayListUnmanaged(rhi_types.InstanceData), draw_list: std.ArrayListUnmanaged(*LODMesh), projection_regions: std.ArrayListUnmanaged(VisibleRegion), + /// Number of projected LOD terrain regions that currently cover each + /// detailed chunk. Counts let a failed region release only its own + /// provisional ownership without flashing unrelated chunks back on. + suppressed_detail_chunks: std.AutoHashMapUnmanaged(u64, u16), + suppression_camera_chunk_x: i32, + suppression_camera_chunk_z: i32, + suppression_detail_radius: i32, + suppression_ready_radius: i32, + suppression_failed: bool, + cached_ready_disk_camera_x: i32, + cached_ready_disk_camera_z: i32, + cached_ready_disk_max_radius: i32, + cached_ready_disk_radius: i32, + cached_ready_disk_checker: ?ChunkChecker, + cached_ready_disk_checker_ctx: ?*anyopaque, projection_frame: ?u64, draw_commands: [LODLevel.count]std.ArrayListUnmanaged(rhi_types.DrawIndirectCommand), instance_buffers: [rhi_types.MAX_FRAMES_IN_FLIGHT]rhi_types.BufferHandle, @@ -295,6 +378,18 @@ pub fn LODRenderer(comptime RHI: type) type { .instance_data = .empty, .draw_list = .empty, .projection_regions = .empty, + .suppressed_detail_chunks = .empty, + .suppression_camera_chunk_x = 0, + .suppression_camera_chunk_z = 0, + .suppression_detail_radius = 0, + .suppression_ready_radius = -1, + .suppression_failed = false, + .cached_ready_disk_camera_x = 0, + .cached_ready_disk_camera_z = 0, + .cached_ready_disk_max_radius = -1, + .cached_ready_disk_radius = -1, + .cached_ready_disk_checker = null, + .cached_ready_disk_checker_ctx = null, .projection_frame = null, .draw_commands = draw_commands, .instance_buffers = instance_buffers, @@ -345,6 +440,7 @@ pub fn LODRenderer(comptime RHI: type) type { self.instance_data.deinit(self.allocator); self.draw_list.deinit(self.allocator); self.projection_regions.deinit(self.allocator); + self.suppressed_detail_chunks.deinit(self.allocator); self.gpu_candidates.deinit(self.allocator); self.gpu_candidate_keys.deinit(self.allocator); self.compact_fallback_regions.deinit(self.allocator); @@ -371,6 +467,7 @@ pub fn LODRenderer(comptime RHI: type) type { checker_ctx: ?*anyopaque, use_frustum: bool, max_distance_chunks: ?i32, + detail_render_radius: i32, layer: LODRenderLayer, stats: ?*LODStats, profiling: ?*LODProfilingCollector, @@ -386,7 +483,7 @@ pub fn LODRenderer(comptime RHI: type) type { if (self.projection_frame == null or self.projection_frame.? != frame_serial) { const timer = if (profiling) |profile| profile.begin() else null; defer if (profiling) |profile| profile.end(.visibility, timer); - self.buildVisibilityProjection(meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, stats, profiling) catch |err| { + self.buildVisibilityProjection(meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, detail_render_radius, stats, profiling) catch |err| { log.log.errWithTrace("Failed to project LOD visibility: {}", .{err}); return; }; @@ -399,7 +496,7 @@ pub fn LODRenderer(comptime RHI: type) type { self.gpu_culling_ready_frame = null; const timer = if (profiling) |profile| profile.begin() else null; defer if (profiling) |profile| profile.end(.visibility, timer); - self.buildVisibilityProjection(meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, stats, profiling) catch |err| { + self.buildVisibilityProjection(meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, detail_render_radius, stats, profiling) catch |err| { log.log.errWithTrace("Failed to rebuild CPU LOD visibility: {}", .{err}); self.projection_frame = null; return; @@ -422,6 +519,7 @@ pub fn LODRenderer(comptime RHI: type) type { chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, max_distance_chunks: ?i32, + detail_render_radius: i32, stats: ?*LODStats, profiling: ?*LODProfilingCollector, ) void { @@ -430,7 +528,7 @@ pub fn LODRenderer(comptime RHI: type) type { if (!self.gpu_culling_requested or self.projection_frame == frame_serial) return; const visibility_timer = if (profiling) |profile| profile.begin() else null; defer if (profiling) |profile| profile.end(.visibility, visibility_timer); - self.buildVisibilityProjection(meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, false, null, stats, profiling) catch |err| { + self.buildVisibilityProjection(meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, false, null, detail_render_radius, stats, profiling) catch |err| { log.log.err("LOD GPU culling projection failed: {}", .{err}); return; }; @@ -649,6 +747,40 @@ pub fn LODRenderer(comptime RHI: type) type { return true; } + fn readyDiskRadiusForProjection(self: *Self, checker: ?ChunkChecker, checker_ctx: ?*anyopaque, camera_chunk_x: i32, camera_chunk_z: i32, max_radius: i32) i32 { + const safe_max_radius = @max(max_radius, 0); + const cache_source_matches = self.cached_ready_disk_max_radius >= 0 and + self.cached_ready_disk_checker == checker and + self.cached_ready_disk_checker_ctx == checker_ctx; + const cache_matches = cache_source_matches and + self.cached_ready_disk_camera_x == camera_chunk_x and + self.cached_ready_disk_camera_z == camera_chunk_z; + + const ready_radius = if (!cache_matches) + if (cache_source_matches) shifted: { + // A disk reduced by the camera's Manhattan displacement is + // guaranteed to remain inside the previously verified disk. + // Expand only the newly exposed shells instead of rescanning + // the full area whenever the player crosses a chunk edge. + const shift_x: i64 = @intCast(@abs(@as(i64, camera_chunk_x) - @as(i64, self.cached_ready_disk_camera_x))); + const shift_z: i64 = @intCast(@abs(@as(i64, camera_chunk_z) - @as(i64, self.cached_ready_disk_camera_z))); + const retained_radius: i32 = @intCast(@max(@as(i64, self.cached_ready_disk_radius) - shift_x - shift_z, -1)); + break :shifted expandContiguousReadyDiskRadius(checker, checker_ctx, camera_chunk_x, camera_chunk_z, retained_radius, safe_max_radius); + } else contiguousReadyDiskRadius(checker, checker_ctx, camera_chunk_x, camera_chunk_z, safe_max_radius) + else if (self.cached_ready_disk_radius >= safe_max_radius) + safe_max_radius + else + expandContiguousReadyDiskRadius(checker, checker_ctx, camera_chunk_x, camera_chunk_z, self.cached_ready_disk_radius, safe_max_radius); + + self.cached_ready_disk_camera_x = camera_chunk_x; + self.cached_ready_disk_camera_z = camera_chunk_z; + self.cached_ready_disk_max_radius = safe_max_radius; + self.cached_ready_disk_radius = ready_radius; + self.cached_ready_disk_checker = checker; + self.cached_ready_disk_checker_ctx = checker_ctx; + return ready_radius; + } + fn buildVisibilityProjection( self: *Self, all_meshes: *const [LODLevel.count]MeshMap, @@ -660,10 +792,16 @@ pub fn LODRenderer(comptime RHI: type) type { checker_ctx: ?*anyopaque, use_frustum: bool, max_distance_chunks: ?i32, + detail_render_radius: i32, stats: ?*LODStats, profiling: ?*LODProfilingCollector, ) !void { self.projection_regions.clearRetainingCapacity(); + self.suppressed_detail_chunks.clearRetainingCapacity(); + errdefer { + self.projection_regions.clearRetainingCapacity(); + self.suppressed_detail_chunks.clearRetainingCapacity(); + } if (stats) |s| { s.drawn = [_]u32{0} ** LODLevel.count; s.instances = [_]u32{0} ** LODLevel.count; @@ -676,7 +814,13 @@ pub fn LODRenderer(comptime RHI: type) type { const frustum = Frustum.fromViewProj(view_proj); const disable_frustum = engine_core.envFlag("ZIGCRAFT_LOD_DISABLE_FRUSTUM", false); const camera_chunk = worldToChunkFromFloat(camera_pos.x, camera_pos.z); - const chunk_radius = config.getChunkRenderRadius(); + const chunk_radius = @max(detail_render_radius, 0); + const ready_detail_radius = self.readyDiskRadiusForProjection(chunk_checker, checker_ctx, camera_chunk.chunk_x, camera_chunk.chunk_z, chunk_radius); + const handoff_mask_radius = readyDiskMaskRadius(ready_detail_radius); + self.suppression_camera_chunk_x = camera_chunk.chunk_x; + self.suppression_camera_chunk_z = camera_chunk.chunk_z; + self.suppression_detail_radius = chunk_radius; + self.suppression_ready_radius = ready_detail_radius; var i = lod_chunk.activeLODCount(config); while (i > 0) { i -= 1; @@ -729,7 +873,7 @@ pub fn LODRenderer(comptime RHI: type) type { continue; } - var mask_radius = config.calculateMaskRadius(); + const mask_radius = handoff_mask_radius; if (chunk_checker) |checker| { if (checker_ctx) |ctx_ptr| { const coverage_timer = if (profiling) |profile| profile.begin() else null; @@ -744,15 +888,16 @@ pub fn LODRenderer(comptime RHI: type) type { if (profiling) |profile| profile.addRejected(); continue; } - if (cov.missing_chunk_in_radius and !cov.has_chunk_coverage_in_radius) mask_radius = LOD_UNMASKED_SENTINEL; } } + const suppresses_detail = mesh.drawRange(.terrain) != null; try self.projection_regions.append(self.allocator, .{ .key = entry.key_ptr.*, .model = Mat4.translate(Vec3.init(@as(f32, @floatFromInt(bounds.min_x)) - camera_pos.x, -camera_pos.y, @as(f32, @floatFromInt(bounds.min_z)) - camera_pos.z)), .mask_radius = mask_radius, .lod_fade = chunk.transitionFadeProgress(), + .suppresses_detail = suppresses_detail, }); telemetry.accepted += 1; if (profiling) |profile| profile.addVisible(); @@ -761,9 +906,73 @@ pub fn LODRenderer(comptime RHI: type) type { } } + fn addPartialDetailSuppression(self: *Self, region_key: LODRegionKey) void { + self.adjustPartialDetailSuppression(region_key, true); + } + + fn resetProjectedDetailSuppression(self: *Self) void { + self.suppressed_detail_chunks.clearRetainingCapacity(); + self.suppression_failed = false; + for (self.projection_regions.items) |visible| { + if (visible.suppresses_detail) self.addPartialDetailSuppression(visible.key); + } + } + + fn releasePartialDetailSuppression(self: *Self, visible: VisibleRegion) void { + if (!visible.suppresses_detail) return; + self.adjustPartialDetailSuppression(visible.key, false); + } + + fn adjustPartialDetailSuppression(self: *Self, region_key: LODRegionKey, add: bool) void { + if (self.suppression_failed) return; + const bounds = region_key.chunkBounds(); + const detail_radius = @as(i64, self.suppression_ready_radius); + const detail_radius_sq = if (detail_radius >= 0) detail_radius * detail_radius else -1; + const chunk_radius_sq = @as(i64, self.suppression_detail_radius) * @as(i64, self.suppression_detail_radius); + + var cz = bounds.min_z; + while (cz <= bounds.max_z) : (cz += 1) { + var cx = bounds.min_x; + while (cx <= bounds.max_x) : (cx += 1) { + const dx = @as(i64, cx) - @as(i64, self.suppression_camera_chunk_x); + const dz = @as(i64, cz) - @as(i64, self.suppression_camera_chunk_z); + const dist_sq = dx * dx + dz * dz; + if (dist_sq > chunk_radius_sq or dist_sq <= detail_radius_sq) continue; + const key = detailChunkKey(cx, cz); + if (add) { + if (self.suppressed_detail_chunks.getPtr(key)) |count| { + count.* +|= 1; + } else { + self.suppressed_detail_chunks.put(self.allocator, key, 1) catch { + // Ownership bookkeeping must fail open: render + // detail rather than leaving a terrain hole. + self.suppressed_detail_chunks.clearRetainingCapacity(); + self.suppression_failed = true; + return; + }; + } + } else if (self.suppressed_detail_chunks.getPtr(key)) |count| { + if (count.* > 1) { + count.* -= 1; + } else { + _ = self.suppressed_detail_chunks.remove(key); + } + } + } + } + } + + pub fn suppressesDetailChunk(self: *const Self, chunk_x: i32, chunk_z: i32) bool { + return self.suppressed_detail_chunks.contains(detailChunkKey(chunk_x, chunk_z)); + } + /// Returns false only when a prepared GPU frame could not be submitted; /// callers must rebuild the CPU projection with normal culling first. fn renderProjectedLayer(self: *Self, all_meshes: *const [LODLevel.count]MeshMap, layer: LODRenderLayer, stats: ?*LODStats, profiling: ?*LODProfilingCollector) bool { + // A frame can render terrain more than once (for example G-pass and + // opaque). Rebuild provisional ownership for each terrain pass so a + // failure in one pass cannot make a later pass inherit stale releases. + if (layer == .terrain) self.resetProjectedDetailSuppression(); const query = if (@hasDecl(RHI, "query")) self.rhi.query() else self.rhi; const render_ctx = if (@hasDecl(RHI, "renderContext")) self.rhi.renderContext() else self.rhi; self.frame_index = query.getFrameIndex(); @@ -804,9 +1013,18 @@ pub fn LODRenderer(comptime RHI: type) type { defer if (compact_timing_started and @hasDecl(RHI, "timing")) self.rhi.timing().endPassTiming(compact_timing_name); for (self.projection_regions.items) |visible| { const lod_idx = @intFromEnum(visible.key.lod); - const mesh = all_meshes[lod_idx].get(visible.key) orelse continue; - const range = mesh.drawRange(layer) orelse continue; - if (!mesh.isReady() or range.count == 0) continue; + const mesh = all_meshes[lod_idx].get(visible.key) orelse { + if (layer == .terrain) self.releasePartialDetailSuppression(visible); + continue; + }; + const range = mesh.drawRange(layer) orelse { + if (layer == .terrain) self.releasePartialDetailSuppression(visible); + continue; + }; + if (!mesh.isReady() or range.count == 0) { + if (layer == .terrain) self.releasePartialDetailSuppression(visible); + continue; + } if (gpu_submitted and self.gpuCandidateDraws(visible.key, layer)) continue; if (mesh.isCompact()) { if (!compact_timing_started and @hasDecl(RHI, "timing")) { @@ -828,16 +1046,22 @@ pub fn LODRenderer(comptime RHI: type) type { // Draw parent fallbacks only after the compact pass // scope closes so expanded draws do not contaminate // compact GPU timing. - self.compact_fallback_regions.append(self.allocator, visible) catch {}; + self.compact_fallback_regions.append(self.allocator, visible) catch { + if (layer == .terrain) self.releasePartialDetailSuppression(visible); + }; } continue; } const lod_y_offset: f32 = if (layer == .fluid) 0.0 else -0.05; var instance = rhi_types.InstanceData{ .model = visible.model, .mask_radius = visible.mask_radius, .lod_fade = visible.lod_fade, .padding = .{ 0, 0 } }; instance.model.data[3][1] += lod_y_offset; - self.instance_data.append(self.allocator, instance) catch continue; + self.instance_data.append(self.allocator, instance) catch { + if (layer == .terrain) self.releasePartialDetailSuppression(visible); + continue; + }; self.draw_list.append(self.allocator, mesh) catch { _ = self.instance_data.pop(); + if (layer == .terrain) self.releasePartialDetailSuppression(visible); continue; }; if (mesh.isPooled()) { @@ -849,6 +1073,7 @@ pub fn LODRenderer(comptime RHI: type) type { }) catch { _ = self.draw_list.pop(); _ = self.instance_data.pop(); + if (layer == .terrain) self.releasePartialDetailSuppression(visible); continue; }; } @@ -864,7 +1089,8 @@ pub fn LODRenderer(comptime RHI: type) type { } } for (self.compact_fallback_regions.items) |visible| { - _ = self.renderParentFallback(all_meshes, visible, layer, render_ctx, profiling); + const fallback_drawn = self.renderParentFallback(all_meshes, visible, layer, render_ctx, profiling); + if (!fallback_drawn and layer == .terrain) self.releasePartialDetailSuppression(visible); } if (self.instance_data.items.len == 0) return true; const indirect_drawn = self.enable_mdi and self.renderIndirectBatches(render_ctx, query); @@ -918,13 +1144,15 @@ pub fn LODRenderer(comptime RHI: type) type { const parent_visible = VisibleRegion{ .key = parent_key, .model = child_model, - .mask_radius = LOD_UNMASKED_SENTINEL, + .mask_radius = child.mask_radius, .lod_fade = 1.0, + .suppresses_detail = child.suppresses_detail, }; if (mesh.isCompact()) { const result = self.renderCompactMesh(render_ctx, parent_visible, mesh, layer); if (result == .drawn) { if (profiling) |profile| profile.addCompactSubmission(); + if (layer == .terrain) self.addPartialDetailSuppression(parent_key); return true; } self.noteCompactRenderFailure(mesh, result, profiling); @@ -933,12 +1161,13 @@ pub fn LODRenderer(comptime RHI: type) type { } selectLODDescriptorStream(render_ctx, layer, false, false); render_ctx.setLODInstanceBuffer(self.instance_buffers[self.frame_index]); - render_ctx.setModelMatrix(child_model, Vec3.one, LOD_UNMASKED_SENTINEL); + render_ctx.setModelMatrix(child_model, Vec3.one, child.mask_radius); if (@hasDecl(@TypeOf(render_ctx), "drawOffset")) { render_ctx.drawOffset(mesh.bufferHandle(), range.count, .triangles, mesh.vertexOffset() + range.offset); } else { render_ctx.draw(mesh.bufferHandle(), range.count, .triangles); } + if (layer == .terrain) self.addPartialDetailSuppression(parent_key); return true; } return false; @@ -1196,7 +1425,8 @@ pub fn LODRenderer(comptime RHI: type) type { } } - var mask_radius = config.calculateMaskRadius(); + const exact_mask_radius = config.calculateMaskRadius(); + var mask_radius = conservativeChunkDiskMaskRadius(exact_mask_radius); if (chunk_checker) |checker| { if (checker_ctx) |ctx_ptr| { const camera_chunk = worldToChunkFromFloat(camera_pos.x, camera_pos.z); @@ -1221,11 +1451,13 @@ pub fn LODRenderer(comptime RHI: type) type { 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; + // chunks while this boundary region is incomplete. Keep + // the conservative inner chunk disk until all of its detail + // cells are ready, then switch to exact chunk ownership. + if (cov.missing_chunk_in_radius) { + if (!cov.has_chunk_coverage_in_radius) mask_radius = LOD_UNMASKED_SENTINEL; + } else { + mask_radius = exact_mask_radius; } } } @@ -1523,21 +1755,26 @@ pub fn LODRenderer(comptime RHI: type) type { checker_ctx: ?*anyopaque, use_frustum: bool, max_distance_chunks: ?i32, + detail_render_radius: i32, layer: LODRenderLayer, stats: ?*LODStats, profiling: ?*LODProfilingCollector, ) void { const renderer: *Self = @ptrCast(@alignCast(self_ptr)); - renderer.renderFrame(frame_serial, meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer, stats, profiling); + renderer.renderFrame(frame_serial, meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, detail_render_radius, layer, stats, profiling); } - fn prepareFrameFn(self_ptr: *anyopaque, frame_serial: u64, meshes: *const [LODLevel.count]MeshMap, regions: *const [LODLevel.count]RegionMap, config: ILODConfig, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, max_distance_chunks: ?i32, stats: ?*LODStats, profiling: ?*LODProfilingCollector) void { + fn prepareFrameFn(self_ptr: *anyopaque, frame_serial: u64, meshes: *const [LODLevel.count]MeshMap, regions: *const [LODLevel.count]RegionMap, config: ILODConfig, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, max_distance_chunks: ?i32, detail_render_radius: i32, stats: ?*LODStats, profiling: ?*LODProfilingCollector) void { const renderer: *Self = @ptrCast(@alignCast(self_ptr)); - renderer.prepareFrame(frame_serial, meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, max_distance_chunks, stats, profiling); + renderer.prepareFrame(frame_serial, meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, max_distance_chunks, detail_render_radius, stats, profiling); } fn memoryStatsFn(self_ptr: *anyopaque) LODRendererMemoryStats { const renderer: *Self = @ptrCast(@alignCast(self_ptr)); return renderer.memoryStats(); } + fn suppressesDetailChunkFn(self_ptr: *anyopaque, chunk_x: i32, chunk_z: i32) bool { + const renderer: *Self = @ptrCast(@alignCast(self_ptr)); + return renderer.suppressesDetailChunk(chunk_x, chunk_z); + } fn deinitFn(self_ptr: *anyopaque) void { const renderer: *Self = @ptrCast(@alignCast(self_ptr)); renderer.deinit(); @@ -1547,6 +1784,7 @@ pub fn LODRenderer(comptime RHI: type) type { .render_fn = Wrapper.renderFn, .render_frame_fn = Wrapper.renderFrameFn, .prepare_frame_fn = Wrapper.prepareFrameFn, + .suppresses_detail_chunk_fn = Wrapper.suppressesDetailChunkFn, .memory_stats_fn = Wrapper.memoryStatsFn, .deinit_fn = Wrapper.deinitFn, .ptr = self, @@ -1667,6 +1905,36 @@ test "compact grid variants retain exact decimated topology" { try std.testing.expect(compactGridVariant(7) == null); } +test "ready detail disk stops at the first incomplete chunk ring" { + const CheckerState = struct { + missing_x: i32, + missing_z: i32, + + fn isLoaded(cx: i32, cz: i32, ctx: *anyopaque) bool { + const state: *@This() = @ptrCast(@alignCast(ctx)); + return cx != state.missing_x or cz != state.missing_z; + } + }; + + try std.testing.expectEqual(@as(i32, -1), contiguousReadyDiskRadius(null, null, 0, 0, 4)); + + var state = CheckerState{ .missing_x = -7, .missing_z = 3 }; + try std.testing.expectEqual(@as(i32, -1), contiguousReadyDiskRadius(CheckerState.isLoaded, &state, -7, 3, 4)); + + state = .{ .missing_x = -5, .missing_z = 3 }; + try std.testing.expectEqual(@as(i32, 1), contiguousReadyDiskRadius(CheckerState.isLoaded, &state, -7, 3, 4)); + + state = .{ .missing_x = 100, .missing_z = 100 }; + try std.testing.expectEqual(@as(i32, 4), contiguousReadyDiskRadius(CheckerState.isLoaded, &state, -7, 3, 4)); +} + +test "ready detail disk mask uses sign encoding" { + try std.testing.expectEqual(@as(f32, 0.5), readyDiskMaskRadius(-1)); + try std.testing.expectEqual(@as(f32, -1.0), readyDiskMaskRadius(0)); + try std.testing.expectEqual(@as(f32, -16.0), readyDiskMaskRadius(1)); + try std.testing.expectEqual(@as(f32, -64.0), readyDiskMaskRadius(4)); +} + test "LODRenderer init/deinit lifecycle" { const allocator = std.testing.allocator; @@ -1808,7 +2076,7 @@ test "LODRenderer batches pooled meshes into per-LOD indirect draws" { var mock_config = LODConfig{ .radii = .{ 16, 128, 256, 512, 1024 } }; var profiling = LODProfilingCollector.init(true); - renderer.renderFrame(99, &meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null, &profiling); + renderer.renderFrame(99, &meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, mock_config.chunk_render_radius, .terrain, null, &profiling); try std.testing.expectEqual(@as(u32, 2), mock_state.draw_indirect_calls); try std.testing.expectEqual(@as(u32, 2), mock_state.last_draw_count); @@ -1819,7 +2087,7 @@ test "LODRenderer batches pooled meshes into per-LOD indirect draws" { mesh_lod1.water_vertex_count = 6; mesh_lod2.water_vertex_offset = 18 * @sizeOf(rhi_types.Vertex); mesh_lod2.water_vertex_count = 9; - renderer.renderFrame(99, &meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .fluid, null, &profiling); + renderer.renderFrame(99, &meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, mock_config.chunk_render_radius, .fluid, null, &profiling); try std.testing.expectEqual(@as(u32, 4), mock_state.draw_indirect_calls); try std.testing.expectEqual(@as(u32, 0), mock_state.direct_draw_calls); const projection = profiling.snapshot().visibility_levels; @@ -1829,7 +2097,7 @@ test "LODRenderer batches pooled meshes into per-LOD indirect draws" { // A direct-only mesh must not disable indirect submission for its pooled // sibling. This is the upload-transition fallback used in production. mesh_lod2.pooled = false; - renderer.renderFrame(99, &meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null, &profiling); + renderer.renderFrame(99, &meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, mock_config.chunk_render_radius, .terrain, null, &profiling); try std.testing.expectEqual(@as(u32, 5), mock_state.draw_indirect_calls); try std.testing.expectEqual(@as(u32, 1), mock_state.direct_draw_calls); } @@ -2331,7 +2599,7 @@ test "LODRenderer keeps mask for partially covered chunk regions" { try meshes[1].put(key, &mesh); try regions[1].put(key, &chunk); - var mock_config = LODConfig{ .radii = .{ 16, 32, 64, 100, 256 } }; + var mock_config = LODConfig{ .chunk_render_radius = 4, .radii = .{ 16, 32, 64, 100, 256 } }; var checker_ctx: u8 = 0; const Checker = struct { fn partiallyLoaded(cx: i32, cz: i32, _: *anyopaque) bool { @@ -2339,10 +2607,13 @@ test "LODRenderer keeps mask for partially covered chunk regions" { } }; - renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, Checker.partiallyLoaded, &checker_ctx, false, null, .terrain, null, null); + renderer.renderFrame(1, &meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, Checker.partiallyLoaded, &checker_ctx, false, null, mock_config.chunk_render_radius, .terrain, null, null); try std.testing.expectEqual(@as(u32, 1), mock_state.draw_calls); - try std.testing.expectEqual(mock_config.interface().calculateMaskRadius(), mock_state.last_mask_radius); + try std.testing.expectEqual(readyDiskMaskRadius(0), mock_state.last_mask_radius); + try std.testing.expect(!renderer.suppressesDetailChunk(0, 0)); + try std.testing.expect(renderer.suppressesDetailChunk(2, 0)); + try std.testing.expect(renderer.suppressesDetailChunk(3, 0)); } test "LODRenderer skips coarse LOD when finer coverage is ready" { @@ -2829,8 +3100,8 @@ test "LODRenderer renderFrame times confirmed compact direct terrain and water s var config = LODConfig{ .radii = .{ 16, 32, 64, 128, 256 } }; var profiling = LODProfilingCollector.init(true); - renderer.renderFrame(1, &meshes, ®ions, config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null, &profiling); - renderer.renderFrame(1, &meshes, ®ions, config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .fluid, null, &profiling); + renderer.renderFrame(1, &meshes, ®ions, config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, config.chunk_render_radius, .terrain, null, &profiling); + renderer.renderFrame(1, &meshes, ®ions, config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, config.chunk_render_radius, .fluid, null, &profiling); try std.testing.expectEqual(@as(u32, 2), state.compact_draws); try std.testing.expectEqual(state.terrain_timing_begins, state.terrain_timing_ends); @@ -2840,7 +3111,7 @@ test "LODRenderer renderFrame times confirmed compact direct terrain and water s try std.testing.expectEqual(@as(u64, 2), profiling.snapshot().compact_submissions); state.compact_draw_succeeds = false; - renderer.renderFrame(2, &meshes, ®ions, config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null, &profiling); + renderer.renderFrame(2, &meshes, ®ions, config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, config.chunk_render_radius, .terrain, null, &profiling); try std.testing.expectEqual(state.terrain_timing_begins, state.terrain_timing_ends); try std.testing.expectEqual(@as(u32, 2), state.terrain_timing_begins); try std.testing.expectEqual(@as(u64, 2), profiling.snapshot().compact_submissions); diff --git a/modules/world-lod/src/lod_scheduler.zig b/modules/world-lod/src/lod_scheduler.zig index ef802770..e81e58ec 100644 --- a/modules/world-lod/src/lod_scheduler.zig +++ b/modules/world-lod/src/lod_scheduler.zig @@ -16,6 +16,7 @@ const ChunkChecker = lod_gpu.ChunkChecker; const RegionMap = lod_gpu.RegionMap; const LifecycleQueue = @import("lod_manager_context.zig").LifecycleQueue; const LifecycleToken = @import("lod_manager_context.zig").LifecycleToken; +const LODScanState = @import("lod_manager_context.zig").LODScanState; pub const CoverageFn = *const fn (ptr: *anyopaque, bounds: LODChunk.WorldBounds, checker: ChunkChecker, ctx: *anyopaque) bool; @@ -29,6 +30,7 @@ pub const SchedulerContext = struct { mutex: *sync.RwLock, player_cx: i32, player_cz: i32, + scan_states: *[LODLevel.count]LODScanState, next_job_token: *u32, cleanup_covered_regions: bool, coverage_ptr: *anyopaque, @@ -56,18 +58,21 @@ pub const SchedulerContext = struct { const std = @import("std"); const QueueDiag = struct { - considered: u32 = 0, - outside_radius: u32 = 0, - covered_chunks: u32 = 0, - existing: u32 = 0, - candidates: u32 = 0, - queued: u32 = 0, + considered: u64 = 0, + outside_radius: u64 = 0, + covered_chunks: u64 = 0, + existing: u64 = 0, + candidates: u64 = 0, + queued: u64 = 0, }; const LOD0_QUEUE_CANDIDATE_LIMIT: usize = 96; const LOD1_QUEUE_CANDIDATE_LIMIT: usize = 64; const HORIZON_QUEUE_CANDIDATE_LIMIT: usize = 64; const REFINEMENT_QUEUE_CANDIDATE_LIMIT: usize = 48; +/// Hard per-update work budget. Large configured horizons advance through a +/// persistent ring cursor instead of blocking a frame on a full-area scan. +pub const MAX_LOD_SCAN_STEPS: usize = 512; const MAX_PENDING_LOD_REGIONS = @import("lod_manager_context.zig").MAX_PENDING_LOD_REGIONS; const MAX_LOD_REGIONS = @import("lod_manager_context.zig").MAX_LOD_REGIONS; @@ -78,27 +83,79 @@ const HORIZON_SEED_DIRECTIONS = [_][2]i32{ .{ 0, -1024 }, .{ 392, -946 }, .{ 724, -724 }, .{ 946, -392 }, }; -fn scaledSeedOffset(component: i32, radius: i32) i32 { - const product = component * radius; - return @divTrunc(product + (if (product >= 0) @as(i32, 512) else -512), 1024); +fn scaledSeedOffset(component: i32, radius: i64) i64 { + const product = @as(i64, component) * radius; + return @divTrunc(product + (if (product >= 0) @as(i64, 512) else -512), 1024); } -fn initialHorizonSeedRank(rx: i32, rz: i32, player_rx: i32, player_rz: i32, region_radius: i32) ?usize { +fn initialHorizonSeedRank(rx: i32, rz: i32, player_rx: i32, player_rz: i32, region_radius: i64) ?usize { const outer_radius = @max(1, region_radius - 1); for (HORIZON_SEED_DIRECTIONS, 0..) |dir, i| { - if (rx == player_rx + scaledSeedOffset(dir[0], outer_radius) and - rz == player_rz + scaledSeedOffset(dir[1], outer_radius)) return i; + if (@as(i64, rx) == @as(i64, player_rx) + scaledSeedOffset(dir[0], outer_radius) and + @as(i64, rz) == @as(i64, player_rz) + scaledSeedOffset(dir[1], outer_radius)) return i; } const middle_radius = @max(1, @divFloor(outer_radius, 2)); for (0..8) |i| { const dir = HORIZON_SEED_DIRECTIONS[i * 2]; - if (rx == player_rx + scaledSeedOffset(dir[0], middle_radius) and - rz == player_rz + scaledSeedOffset(dir[1], middle_radius)) return HORIZON_SEED_DIRECTIONS.len + i; + if (@as(i64, rx) == @as(i64, player_rx) + scaledSeedOffset(dir[0], middle_radius) and + @as(i64, rz) == @as(i64, player_rz) + scaledSeedOffset(dir[1], middle_radius)) return HORIZON_SEED_DIRECTIONS.len + i; } return null; } +fn regionCoordinateRepresentable(region: i64, scale: i32) bool { + const min_chunk = region * @as(i64, scale); + const max_chunk = min_chunk + @as(i64, scale) - 1; + return min_chunk >= std.math.minInt(i32) and max_chunk <= std.math.maxInt(i32); +} + +fn horizonSeedCoordinate(index: usize, player_rx: i32, player_rz: i32, region_radius: i64) [2]i64 { + const outer_radius = @max(1, region_radius - 1); + const middle_radius = @max(1, @divFloor(outer_radius, 2)); + const direction = if (index < HORIZON_SEED_DIRECTIONS.len) + HORIZON_SEED_DIRECTIONS[index] + else + HORIZON_SEED_DIRECTIONS[(index - HORIZON_SEED_DIRECTIONS.len) * 2]; + const radius = if (index < HORIZON_SEED_DIRECTIONS.len) outer_radius else middle_radius; + return .{ + @as(i64, player_rx) + scaledSeedOffset(direction[0], radius), + @as(i64, player_rz) + scaledSeedOffset(direction[1], radius), + }; +} + +fn nextRingCoordinate(state: *LODScanState, player_rx: i32, player_rz: i32, region_radius: i64) [2]i64 { + if (state.next_ring > region_radius) { + state.next_ring = 0; + state.ring_index = 0; + } + if (state.next_ring == 0) { + state.next_ring = 1; + state.ring_index = 0; + return .{ player_rx, player_rz }; + } + + const ring = state.next_ring; + const side_length = ring * 2; + const perimeter_length = side_length * 4; + const index = state.ring_index; + const side = @divFloor(index, side_length); + const offset = @mod(index, side_length); + const relative = switch (side) { + 0 => [2]i64{ -ring + offset, -ring }, + 1 => [2]i64{ ring, -ring + offset }, + 2 => [2]i64{ ring - offset, ring }, + else => [2]i64{ -ring, ring - offset }, + }; + + state.ring_index += 1; + if (state.ring_index >= perimeter_length) { + state.next_ring += 1; + state.ring_index = 0; + } + return .{ @as(i64, player_rx) + relative[0], @as(i64, player_rz) + relative[1] }; +} + pub fn priorityRank(lod: LODLevel, active_lod_count: usize) usize { const lod_idx: usize = @intFromEnum(lod); const coarsest_idx = if (active_lod_count == 0) 0 else active_lod_count - 1; @@ -128,7 +185,7 @@ fn maxQueueCandidatesForLOD(lod: LODLevel, active_lod_count: usize) usize { return REFINEMENT_QUEUE_CANDIDATE_LIMIT; } -pub fn priorityWeightForVelocity(velocity: Vec3, chunk_dx: i32, chunk_dz: i32) f32 { +pub fn priorityWeightForVelocity(velocity: Vec3, chunk_dx: i64, chunk_dz: i64) f32 { const speed = @sqrt(velocity.x * velocity.x + velocity.z * velocity.z); if (speed < 2.0) return 1.0; @@ -143,9 +200,10 @@ pub fn priorityWeightForVelocity(velocity: Vec3, chunk_dx: i32, chunk_dz: i32) f return 1.0 - dot * 0.5; } -pub fn encodePriority(lod: LODLevel, chunk_dx: i32, chunk_dz: i32, velocity: Vec3, active_lod_count: usize) i32 { - const dist_sq = @as(i64, chunk_dx) * @as(i64, chunk_dx) + @as(i64, chunk_dz) * @as(i64, chunk_dz); - const weighted = @as(f64, @floatFromInt(dist_sq)) * @as(f64, priorityWeightForVelocity(velocity, chunk_dx, chunk_dz)); +pub fn encodePriority(lod: LODLevel, chunk_dx: i64, chunk_dz: i64, velocity: Vec3, active_lod_count: usize) i32 { + const dx: f64 = @floatFromInt(chunk_dx); + const dz: f64 = @floatFromInt(chunk_dz); + const weighted = (dx * dx + dz * dz) * @as(f64, priorityWeightForVelocity(velocity, chunk_dx, chunk_dz)); const priority: i32 = @intFromFloat(@min(weighted, @as(f64, @floatFromInt(@as(i32, 0x0FFFFFFF))))); return (priority & 0x0FFFFFFF) | lodPriorityBias(lod, active_lod_count); } @@ -171,7 +229,7 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu const radius = if (is_coarsest) radii[idx] else @max(0, radii[idx] - ctx.radius_reduction[idx]); const scale: i32 = @intCast(lod.chunksPerSide()); - const region_radius = @divFloor(radius, scale) + 1; + const region_radius = @divFloor(@as(i64, radius), @as(i64, scale)) + 1; const player_rx = @divFloor(ctx.player_cx, scale); const player_rz = @divFloor(ctx.player_cz, scale); @@ -192,76 +250,106 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu defer candidates.deinit(ctx.allocator); // Existing active regions must not consume the bounded candidate window. - // Repeatedly selecting the same nearest horizon regions otherwise prevents - // the coarsest band from progressing beyond its first batch. - ctx.mutex.lockShared(); + // A persistent concentric-ring cursor guarantees bounded frame work while + // eventually visiting every coordinate in the configured horizon. + ctx.mutex.lock(); const candidate_storage = &ctx.regions[@intFromEnum(lod)]; - const seed_initial_horizon = is_coarsest and candidate_storage.count() == 0; - - var rz = player_rz - region_radius; - while (rz <= player_rz + region_radius) : (rz += 1) { - var rx = player_rx - region_radius; - while (rx <= player_rx + region_radius) : (rx += 1) { - diag.considered += 1; - const key = LODRegionKey{ .rx = rx, .rz = rz, .lod = lod }; - const chunk_bounds = key.chunkBounds(); - if (!chunk_bounds.intersectsRadius(ctx.player_cx, ctx.player_cz, radius)) { - diag.outside_radius += 1; - continue; - } + const state = &ctx.scan_states[@intFromEnum(lod)]; + const moved_rx = @as(i64, player_rx) - @as(i64, state.player_rx); + const moved_rz = @as(i64, player_rz) - @as(i64, state.player_rz); + const radius_changed = state.effective_radius != radius; + if (radius_changed) { + state.* = .{ + .player_rx = player_rx, + .player_rz = player_rz, + .effective_radius = radius, + }; + } else if (player_rx != state.player_rx or player_rz != state.player_rz) { + state.player_rx = player_rx; + state.player_rz = player_rz; + if (is_coarsest) state.seed_index = 0; + // Preserve outward progress during ordinary traversal, but restart near + // the player after a teleport so the new location receives fallback. + if (@max(@abs(moved_rx), @abs(moved_rz)) > 8) { + state.next_ring = 0; + state.ring_index = 0; + } + } + if (state.next_ring > region_radius) { + state.next_ring = 0; + state.ring_index = 0; + } - if (ctx.cleanup_covered_regions) { - if (chunk_checker) |checker| { - const temp_chunk = LODChunk.init(rx, rz, lod); - if (ctx.are_all_chunks_loaded(ctx.coverage_ptr, temp_chunk.worldBounds(), checker, checker_ctx.?)) { - diag.covered_chunks += 1; - continue; - } - } - } + var examined: usize = 0; + while (examined < MAX_LOD_SCAN_STEPS and candidates.items.len < max_candidates) : (examined += 1) { + const coordinate = if (is_coarsest and state.seed_index < HORIZON_SEED_DIRECTIONS.len + 8) blk: { + const seed = horizonSeedCoordinate(state.seed_index, player_rx, player_rz, region_radius); + state.seed_index += 1; + break :blk seed; + } else nextRingCoordinate(state, player_rx, player_rz, region_radius); + const rx = coordinate[0]; + const rz = coordinate[1]; + diag.considered += 1; + if (!regionCoordinateRepresentable(rx, scale) or !regionCoordinateRepresentable(rz, scale)) continue; + const key = LODRegionKey{ .rx = @intCast(rx), .rz = @intCast(rz), .lod = lod }; + const chunk_bounds = key.chunkBounds(); + if (!chunk_bounds.intersectsRadius(ctx.player_cx, ctx.player_cz, radius)) { + diag.outside_radius += 1; + continue; + } - if (candidate_storage.get(key)) |chunk| { - diag.existing += 1; - if (chunk.getState() != .missing or chunk.isPinned()) continue; + var duplicate = false; + for (candidates.items) |candidate| { + if (candidate.key.eql(key)) { + duplicate = true; + break; } + } + if (duplicate) continue; - const center_cx = key.rx * scale + @divFloor(scale, 2); - const center_cz = key.rz * scale + @divFloor(scale, 2); - const distance_priority = encodePriority(lod, center_cx - ctx.player_cx, center_cz - ctx.player_cz, velocity, active_lod_count); - const seed_rank = if (seed_initial_horizon) initialHorizonSeedRank(rx, rz, player_rx, player_rz, region_radius) else null; - // Preserve the spatial seed order in the worker queue as well as - // candidate admission; otherwise distance reprioritization makes - // the newly admitted outer shell wait behind nearby coarse tiles. - const encoded_priority = if (seed_rank) |rank| - lodPriorityBias(lod, active_lod_count) | @as(i32, @intCast(rank)) - else - distance_priority; - const selection_priority: i64 = if (seed_initial_horizon) - if (seed_rank) |rank| - @intCast(rank) - else - @as(i64, 1_000_000_000) + @as(i64, distance_priority & 0x0FFFFFFF) - else - distance_priority; - const candidate = Candidate{ - .key = key, - .encoded_priority = encoded_priority, - .selection_priority = selection_priority, - .preserve_priority = seed_rank != null, - }; - var insert_at: usize = 0; - while (insert_at < candidates.items.len and candidates.items[insert_at].selection_priority <= selection_priority) : (insert_at += 1) {} - if (insert_at < max_candidates) { - candidates.insert(ctx.allocator, insert_at, candidate) catch |err| { - ctx.mutex.unlockShared(); - return err; - }; - if (candidates.items.len > max_candidates) _ = candidates.pop(); + if (candidate_storage.get(key)) |chunk| { + diag.existing += 1; + if (chunk.getState() != .missing or chunk.isPinned()) continue; + } + + if (ctx.cleanup_covered_regions) { + if (chunk_checker) |checker| { + const temp_chunk = LODChunk.init(key.rx, key.rz, lod); + if (ctx.are_all_chunks_loaded(ctx.coverage_ptr, temp_chunk.worldBounds(), checker, checker_ctx.?)) { + diag.covered_chunks += 1; + continue; + } } - diag.candidates += 1; } + + const center_cx = @as(i64, key.rx) * @as(i64, scale) + @divFloor(scale, 2); + const center_cz = @as(i64, key.rz) * @as(i64, scale) + @divFloor(scale, 2); + const distance_priority = encodePriority(lod, center_cx - @as(i64, ctx.player_cx), center_cz - @as(i64, ctx.player_cz), velocity, active_lod_count); + const seed_rank = if (is_coarsest) initialHorizonSeedRank(key.rx, key.rz, player_rx, player_rz, region_radius) else null; + // Preserve the spatial seed order in the worker queue as well as + // candidate admission; otherwise distance reprioritization makes + // the newly admitted outer shell wait behind nearby coarse tiles. + const encoded_priority = if (seed_rank) |rank| + lodPriorityBias(lod, active_lod_count) | @as(i32, @intCast(rank)) + else + distance_priority; + const selection_priority: i64 = if (seed_rank) |rank| @intCast(rank) else distance_priority; + const candidate = Candidate{ + .key = key, + .encoded_priority = encoded_priority, + .selection_priority = selection_priority, + .preserve_priority = seed_rank != null, + }; + var insert_at: usize = 0; + while (insert_at < candidates.items.len and candidates.items[insert_at].selection_priority <= selection_priority) : (insert_at += 1) {} + candidates.insert(ctx.allocator, insert_at, candidate) catch |err| { + ctx.mutex.unlock(); + return err; + }; + diag.candidates += 1; } - ctx.mutex.unlockShared(); + state.last_examined = examined; + ctx.mutex.unlock(); var queued_count: usize = 0; @@ -416,6 +504,7 @@ test "LOD scheduling caps resident regions and logical admission memory" { var mutex: sync.RwLock = .{}; var next_job_token: u32 = 1; var radius_reduction = [_]i32{0} ** LODLevel.count; + var scan_states = [_]LODScanState{LODScanState{}} ** LODLevel.count; var pending_regions: usize = 0; var logical_memory_bytes: usize = 0; const reservation_bytes: usize = 1024; @@ -436,6 +525,7 @@ test "LOD scheduling caps resident regions and logical admission memory" { .mutex = &mutex, .player_cx = 0, .player_cz = 0, + .scan_states = &scan_states, .next_job_token = &next_job_token, .cleanup_covered_regions = false, .coverage_ptr = &coverage_ctx, @@ -488,12 +578,13 @@ test "LOD scheduling caps LOD0 flood while still queuing horizon jobs" { var config = LODConfig{ .chunk_render_radius = 16, - .radii = .{ 64, 128, 256, 384, 512 }, + .radii = .{ 4096, 8192, 16_384, 32_768, 131_072 }, }; const config_iface = config.interface(); var mutex: sync.RwLock = .{}; var next_job_token: u32 = 1; var radius_reduction = [_]i32{0} ** LODLevel.count; + var scan_states = [_]LODScanState{LODScanState{}} ** LODLevel.count; var coverage_ctx: u8 = 0; const Coverage = struct { fn neverCovered(_: *anyopaque, _: LODChunk.WorldBounds, _: ChunkChecker, _: *anyopaque) bool { @@ -510,6 +601,7 @@ test "LOD scheduling caps LOD0 flood while still queuing horizon jobs" { .mutex = &mutex, .player_cx = 0, .player_cz = 0, + .scan_states = &scan_states, .next_job_token = &next_job_token, .cleanup_covered_regions = false, .coverage_ptr = &coverage_ctx, @@ -519,6 +611,8 @@ test "LOD scheduling caps LOD0 flood while still queuing horizon jobs" { try queueLODRegions(ctx, .lod0, Vec3.zero, null, null); try queueLODRegions(ctx, .lod4, Vec3.zero, null, null); + try std.testing.expect(scan_states[@intFromEnum(LODLevel.lod0)].last_examined <= MAX_LOD_SCAN_STEPS); + try std.testing.expect(scan_states[@intFromEnum(LODLevel.lod4)].last_examined <= MAX_LOD_SCAN_STEPS); const queue = queue_ptrs[LODLevel.count - 1]; const total = queue.count(); @@ -597,6 +691,7 @@ test "LOD scheduling advances horizon beyond existing nearest batch" { var mutex: sync.RwLock = .{}; var next_job_token: u32 = 1; var radius_reduction = [_]i32{0} ** LODLevel.count; + var scan_states = [_]LODScanState{LODScanState{}} ** LODLevel.count; var coverage_ctx: u8 = 0; const Coverage = struct { fn neverCovered(_: *anyopaque, _: LODChunk.WorldBounds, _: ChunkChecker, _: *anyopaque) bool { @@ -613,6 +708,7 @@ test "LOD scheduling advances horizon beyond existing nearest batch" { .mutex = &mutex, .player_cx = 0, .player_cz = 0, + .scan_states = &scan_states, .next_job_token = &next_job_token, .cleanup_covered_regions = false, .coverage_ptr = &coverage_ctx, diff --git a/modules/world-lod/src/lod_streaming_coordinator.zig b/modules/world-lod/src/lod_streaming_coordinator.zig index a99e06b9..1a99d4f8 100644 --- a/modules/world-lod/src/lod_streaming_coordinator.zig +++ b/modules/world-lod/src/lod_streaming_coordinator.zig @@ -91,6 +91,7 @@ pub const LODStreamingCoordinator = struct { const STARTUP_RADIUS_STEP = 2; const STARTUP_PREFETCH_RINGS = 2; const STARTUP_RADIUS_CHECK_PERIOD = 10; + const STARTUP_READINESS_GRID_RADIUS: i64 = 4; pub fn init(render_distance: i32) LODStreamingCoordinator { return .{ @@ -102,9 +103,13 @@ pub const LODStreamingCoordinator = struct { pub fn setRenderDistance(self: *LODStreamingCoordinator, distance: i32) bool { if (self.render_distance == distance) return false; + const previous_active = self.getActiveRenderDistance(); self.render_distance = distance; - self.startup_stream_radius = @min(distance, STARTUP_RADIUS_INITIAL); - self.effective_render_dist = 0; + // Runtime increases grow outward from the currently visible radius + // instead of collapsing back to the three-chunk startup disk. + // Decreases clamp immediately so out-of-range chunks stop rendering. + self.startup_stream_radius = @min(distance, @max(previous_active, STARTUP_RADIUS_INITIAL)); + self.effective_render_dist = self.startup_stream_radius; self.startup_mesh_finalized = false; self.horizon_bootstrap_ready = false; self.forceRescan(); @@ -210,22 +215,24 @@ pub const LODStreamingCoordinator = struct { if (frame_counter % STARTUP_RADIUS_CHECK_PERIOD != 0) return; - var total_in_radius: u32 = 0; - var ready_in_radius: u32 = 0; + var total_in_radius: u64 = 0; + var ready_in_radius: u64 = 0; storage.chunks_mutex.lockShared(); defer storage.chunks_mutex.unlockShared(); - var cz = pc_z - self.startup_stream_radius; - while (cz <= pc_z + self.startup_stream_radius) : (cz += 1) { - var cx = pc_x - self.startup_stream_radius; - while (cx <= pc_x + self.startup_stream_radius) : (cx += 1) { - const dx = cx - pc_x; - const dz = cz - pc_z; - if (dx * dx + dz * dz > self.startup_stream_radius * self.startup_stream_radius) continue; + const radius = @as(i64, self.startup_stream_radius); + var sample_z = -STARTUP_READINESS_GRID_RADIUS; + while (sample_z <= STARTUP_READINESS_GRID_RADIUS) : (sample_z += 1) { + var sample_x = -STARTUP_READINESS_GRID_RADIUS; + while (sample_x <= STARTUP_READINESS_GRID_RADIUS) : (sample_x += 1) { + if (sample_x * sample_x + sample_z * sample_z > STARTUP_READINESS_GRID_RADIUS * STARTUP_READINESS_GRID_RADIUS) continue; + const cx = @as(i64, pc_x) + @divTrunc(sample_x * radius, STARTUP_READINESS_GRID_RADIUS); + const cz = @as(i64, pc_z) + @divTrunc(sample_z * radius, STARTUP_READINESS_GRID_RADIUS); + if (cx < std.math.minInt(i32) or cx > std.math.maxInt(i32) or cz < std.math.minInt(i32) or cz > std.math.maxInt(i32)) continue; total_in_radius += 1; - if (storage.chunks.get(.{ .x = cx, .z = cz })) |data| { + if (storage.chunks.get(.{ .x = @intCast(cx), .z = @intCast(cz) })) |data| { if (data.chunk.state == .renderable or data.render.mesh.solid_allocation != null or data.render.mesh.cutout_allocation != null or data.render.mesh.fluid_allocation != null) { ready_in_radius += 1; } @@ -234,7 +241,7 @@ pub const LODStreamingCoordinator = struct { } if (total_in_radius == 0) return; - if (ready_in_radius * 100 < total_in_radius * 85) return; + if (@as(u128, ready_in_radius) * 100 < @as(u128, total_in_radius) * 85) return; self.startup_stream_radius = @min(target_render_dist, self.startup_stream_radius + STARTUP_RADIUS_STEP); log.log.info("STARTUP_STREAM_RADIUS: expanded to {} / {}", .{ self.startup_stream_radius, target_render_dist }); @@ -253,3 +260,17 @@ test "startup streaming prefetches two rings beyond visible radius" { try std.testing.expectEqual(@as(i32, 3), render_dist); try std.testing.expectEqual(@as(i32, 7), stream_dist); } + +test "runtime render-distance changes preserve or clamp the active radius" { + var coordinator = LODStreamingCoordinator.init(12); + coordinator.startup_stream_radius = 12; + coordinator.effective_render_dist = 12; + + try std.testing.expect(coordinator.setRenderDistance(4096)); + try std.testing.expectEqual(@as(i32, 12), coordinator.getActiveRenderDistance()); + try std.testing.expectEqual(@as(i32, 12), coordinator.startup_stream_radius); + + try std.testing.expect(coordinator.setRenderDistance(8)); + try std.testing.expectEqual(@as(i32, 8), coordinator.getActiveRenderDistance()); + try std.testing.expectEqual(@as(i32, 8), coordinator.startup_stream_radius); +} diff --git a/modules/world-lod/src/lod_upload_queue.zig b/modules/world-lod/src/lod_upload_queue.zig index 220d8851..412f06fb 100644 --- a/modules/world-lod/src/lod_upload_queue.zig +++ b/modules/world-lod/src/lod_upload_queue.zig @@ -163,6 +163,7 @@ pub const LODRenderInterface = struct { checker_ctx: ?*anyopaque, use_frustum: bool, max_distance_chunks: ?i32, + detail_render_radius: i32, layer: LODRenderLayer, stats: ?*LODStats, profiling: ?*LODProfilingCollector, @@ -178,9 +179,13 @@ pub const LODRenderInterface = struct { chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, max_distance_chunks: ?i32, + detail_render_radius: i32, stats: ?*LODStats, profiling: ?*LODProfilingCollector, ) void = null, + /// Frame-stable terrain ownership query. A true result means a visible LOD + /// region owns this chunk until the contiguous detail disk reaches it. + suppresses_detail_chunk_fn: ?*const fn (self_ptr: *anyopaque, chunk_x: i32, chunk_z: i32) bool = null, memory_stats_fn: ?*const fn (self_ptr: *anyopaque) LODRendererMemoryStats = null, /// Destroy renderer resources. deinit_fn: *const fn (self_ptr: *anyopaque) void, @@ -217,12 +222,13 @@ pub const LODRenderInterface = struct { checker_ctx: ?*anyopaque, use_frustum: bool, max_distance_chunks: ?i32, + detail_render_radius: i32, layer: LODRenderLayer, stats: ?*LODStats, profiling: ?*LODProfilingCollector, ) void { if (self.render_frame_fn) |render_frame| { - render_frame(self.ptr, frame_serial, meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer, stats, profiling); + render_frame(self.ptr, frame_serial, meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, detail_render_radius, layer, stats, profiling); } else { self.render(meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer, stats, profiling); } @@ -232,12 +238,17 @@ pub const LODRenderInterface = struct { self.deinit_fn(self.ptr); } - pub fn prepareFrame(self: LODRenderInterface, frame_serial: u64, meshes: *const [LODLevel.count]MeshMap, regions: *const [LODLevel.count]RegionMap, config: ILODConfig, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, max_distance_chunks: ?i32, stats: ?*LODStats, profiling: ?*LODProfilingCollector) void { - if (self.prepare_frame_fn) |prepare| prepare(self.ptr, frame_serial, meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, max_distance_chunks, stats, profiling); + pub fn prepareFrame(self: LODRenderInterface, frame_serial: u64, meshes: *const [LODLevel.count]MeshMap, regions: *const [LODLevel.count]RegionMap, config: ILODConfig, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, max_distance_chunks: ?i32, detail_render_radius: i32, stats: ?*LODStats, profiling: ?*LODProfilingCollector) void { + if (self.prepare_frame_fn) |prepare| prepare(self.ptr, frame_serial, meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, max_distance_chunks, detail_render_radius, stats, profiling); } pub fn memoryStats(self: LODRenderInterface) LODRendererMemoryStats { if (self.memory_stats_fn) |memory_stats| return memory_stats(self.ptr); return .{}; } + + pub fn suppressesDetailChunk(self: LODRenderInterface, chunk_x: i32, chunk_z: i32) bool { + const query = self.suppresses_detail_chunk_fn orelse return false; + return query(self.ptr, chunk_x, chunk_z); + } }; diff --git a/modules/world-lod/src/tests.zig b/modules/world-lod/src/tests.zig index 0605156d..e1483112 100644 --- a/modules/world-lod/src/tests.zig +++ b/modules/world-lod/src/tests.zig @@ -9,4 +9,6 @@ test { _ = @import("lod_mesh.zig"); _ = @import("lod_vertex_pool.zig"); _ = @import("lod_store.zig"); + _ = @import("lod_streaming_coordinator.zig"); + _ = @import("lod_scheduler.zig"); } diff --git a/modules/world-meshing/src/chunk_storage.zig b/modules/world-meshing/src/chunk_storage.zig index 18607812..90145068 100644 --- a/modules/world-meshing/src/chunk_storage.zig +++ b/modules/world-meshing/src/chunk_storage.zig @@ -264,6 +264,23 @@ pub const ChunkStorage = struct { return false; } + /// Returns whether detailed terrain is authoritative for LOD handoff. + /// A ready empty mesh intentionally replaces coarse terrain with empty + /// space. Existing terrain allocations remain authoritative while a chunk + /// is remeshed, preventing transient states from flashing detail off/on. + pub fn isChunkTerrainReadyForHandoff(cx: i32, cz: i32, ctx: *anyopaque) bool { + const self: *ChunkStorage = @ptrCast(@alignCast(ctx)); + self.chunks_mutex.lockShared(); + defer self.chunks_mutex.unlockShared(); + + if (self.chunks.get(.{ .x = cx, .z = cz })) |data| { + return data.render.mesh.ready or + data.render.mesh.solid_allocation != null or + data.render.mesh.cutout_allocation != null; + } + return false; + } + /// Diagnostic: get chunk state as a string for logging (not for hot path). pub fn getChunkState(cx: i32, cz: i32, ctx: *anyopaque) ?Chunk.State { const self: *ChunkStorage = @ptrCast(@alignCast(ctx)); diff --git a/modules/world-runtime/src/chunk_queue_coordinator.zig b/modules/world-runtime/src/chunk_queue_coordinator.zig index e3e2a612..645181e6 100644 --- a/modules/world-runtime/src/chunk_queue_coordinator.zig +++ b/modules/world-runtime/src/chunk_queue_coordinator.zig @@ -85,6 +85,7 @@ const MeshInputRevisions = struct { /// every frame by the pending queues; the scan only catches stuck chunks and /// any state that was reset outside the worker path (e.g. resetPausedChunks). const RECOVERY_SCAN_PERIOD: u64 = 60; +const MAX_MISSING_SCAN_STEPS: usize = 1024; pub const ChunkQueueCoordinator = struct { allocator: std.mem.Allocator, @@ -104,9 +105,18 @@ pub const ChunkQueueCoordinator = struct { chunks_generated_total: std.atomic.Value(u64) = .init(0), chunks_meshed_total: std.atomic.Value(u64) = .init(0), chunks_uploaded_total: std.atomic.Value(u64) = .init(0), + generation_jobs_in_flight: std.atomic.Value(u32) = .init(0), + mesh_jobs_in_flight: std.atomic.Value(u32) = .init(0), last_pc_x: std.atomic.Value(i32) = .init(0), last_pc_z: std.atomic.Value(i32) = .init(0), effective_render_dist: std.atomic.Value(i32) = .init(0), + missing_scan_initialized: bool = false, + missing_scan_player_x: i32 = 0, + missing_scan_player_z: i32 = 0, + missing_scan_radius: i32 = -1, + missing_scan_ring: i64 = 0, + missing_scan_ring_index: i64 = 0, + missing_rescan_requested: std.atomic.Value(bool) = .init(false), // Pending transition queues. Workers append under the respective mutex // when they flip a chunk into `.generated` or `.mesh_ready`; the main @@ -153,11 +163,75 @@ pub const ChunkQueueCoordinator = struct { self.effective_render_dist.store(render_dist, .release); } + pub fn takeMissingRescanRequest(self: *ChunkQueueCoordinator) bool { + return self.missing_rescan_requested.swap(false, .acq_rel); + } + + pub fn hasInFlightWork(self: *const ChunkQueueCoordinator) bool { + return self.generation_jobs_in_flight.load(.acquire) > 0 or self.mesh_jobs_in_flight.load(.acquire) > 0; + } + + pub fn restartMissingScan(self: *ChunkQueueCoordinator) void { + self.missing_scan_initialized = false; + } + fn weightedDistanceSq(dist_sq: i32, movement: anytype, dx: i32, dz: i32) i32 { const weighted = @as(f32, @floatFromInt(dist_sq)) * movement.priorityWeight(dx, dz); return @max(0, @as(i32, @intFromFloat(@min(weighted, @as(f32, @floatFromInt(std.math.maxInt(i32))))))); } + fn isWithinDistance(dx: i64, dz: i64, radius: i64) bool { + const wide_dx: i128 = dx; + const wide_dz: i128 = dz; + const safe_radius: i128 = @max(radius, 0); + return wide_dx * wide_dx + wide_dz * wide_dz <= safe_radius * safe_radius; + } + + fn clampedDistanceSquared(dx: i64, dz: i64) i32 { + const wide_dx: i128 = dx; + const wide_dz: i128 = dz; + const distance_sq = wide_dx * wide_dx + wide_dz * wide_dz; + return @intCast(@min(distance_sq, std.math.maxInt(i32))); + } + + fn resetMissingScan(self: *ChunkQueueCoordinator, pc_x: i32, pc_z: i32, radius: i32) void { + self.missing_scan_initialized = true; + self.missing_scan_player_x = pc_x; + self.missing_scan_player_z = pc_z; + self.missing_scan_radius = radius; + self.missing_scan_ring = 0; + self.missing_scan_ring_index = 0; + } + + fn nextMissingScanCoordinate(self: *ChunkQueueCoordinator, pc_x: i32, pc_z: i32, radius: i64) ?[2]i64 { + if (self.missing_scan_ring > radius) return null; + if (self.missing_scan_ring == 0) { + self.missing_scan_ring = 1; + self.missing_scan_ring_index = 0; + return .{ pc_x, pc_z }; + } + + const ring = self.missing_scan_ring; + const side_length = ring * 2; + const perimeter_length = side_length * 4; + const index = self.missing_scan_ring_index; + const side = @divFloor(index, side_length); + const offset = @mod(index, side_length); + const relative = switch (side) { + 0 => [2]i64{ -ring + offset, -ring }, + 1 => [2]i64{ ring, -ring + offset }, + 2 => [2]i64{ ring - offset, ring }, + else => [2]i64{ -ring, ring - offset }, + }; + + self.missing_scan_ring_index += 1; + if (self.missing_scan_ring_index >= perimeter_length) { + self.missing_scan_ring += 1; + self.missing_scan_ring_index = 0; + } + return .{ @as(i64, pc_x) + relative[0], @as(i64, pc_z) + relative[1] }; + } + pub fn resetPausedChunks(self: *ChunkQueueCoordinator) void { self.storage.chunks_mutex.lock(); defer self.storage.chunks_mutex.unlock(); @@ -173,41 +247,80 @@ pub const ChunkQueueCoordinator = struct { } } - pub fn scanForMissingChunks(self: *ChunkQueueCoordinator, pc_x: i32, pc_z: i32, render_dist: i32, movement: anytype) !void { + /// Scans a bounded portion of the full-detail disk. Returns true after one + /// complete pass; false asks the streamer to continue on the next frame. + pub fn scanForMissingChunks(self: *ChunkQueueCoordinator, pc_x: i32, pc_z: i32, render_dist: i32, movement: anytype) !bool { self.storage.chunks_mutex.lock(); defer self.storage.chunks_mutex.unlock(); - var cz: i32 = pc_z - render_dist; - while (cz <= pc_z + render_dist) : (cz += 1) { - var cx: i32 = pc_x - render_dist; - while (cx <= pc_x + render_dist) : (cx += 1) { - const dx = cx - pc_x; - const dz = cz - pc_z; - const dist_sq = dx * dx + dz * dz; - - if (dist_sq > render_dist * render_dist) continue; - - const key = ChunkKey{ .x = cx, .z = cz }; - const data = self.storage.chunks.get(key) orelse data: { - const created = try self.storage.createChunkDataUnlocked(cx, cz); - try self.storage.chunks.put(key, created); - break :data created; - }; - - switch (data.chunk.state) { - .missing => { - const priority_dist_sq = weightedDistanceSq(dist_sq, movement, dx, dz); - self.gen_queue.push(.{ - .type = .chunk_generation, - .dist_sq = priority_dist_sq, - .data = .{ .chunk = .{ .x = cx, .z = cz, .job_token = data.chunk.job_token } }, - }) catch continue; - data.chunk.state = .queued_for_generation; - }, - else => {}, + const safe_radius = @max(render_dist, 0); + const radius = @as(i64, safe_radius); + if (!self.missing_scan_initialized) { + self.resetMissingScan(pc_x, pc_z, safe_radius); + } else { + const previous_radius = self.missing_scan_radius; + const previous_pass_complete = self.missing_scan_ring > previous_radius; + if (safe_radius < previous_radius) { + self.resetMissingScan(pc_x, pc_z, safe_radius); + } else { + self.missing_scan_radius = safe_radius; + if (pc_x != self.missing_scan_player_x or pc_z != self.missing_scan_player_z) { + const moved_x = @abs(@as(i64, pc_x) - @as(i64, self.missing_scan_player_x)); + const moved_z = @abs(@as(i64, pc_z) - @as(i64, self.missing_scan_player_z)); + self.missing_scan_player_x = pc_x; + self.missing_scan_player_z = pc_z; + if (previous_pass_complete or @max(moved_x, moved_z) > 8) { + self.missing_scan_ring = 0; + self.missing_scan_ring_index = 0; + } + } else if (safe_radius == previous_radius and previous_pass_complete) { + // Same-radius calls after a completed pass are periodic + // recovery scans; start a fresh bounded traversal. + self.missing_scan_ring = 0; + self.missing_scan_ring_index = 0; } } } + + var examined: usize = 0; + while (examined < MAX_MISSING_SCAN_STEPS) : (examined += 1) { + const coordinate = self.nextMissingScanCoordinate(pc_x, pc_z, radius) orelse { + return true; + }; + const cx = coordinate[0]; + const cz = coordinate[1]; + const dx = cx - @as(i64, pc_x); + const dz = cz - @as(i64, pc_z); + if (!isWithinDistance(dx, dz, radius)) continue; + if (cx < std.math.minInt(i32) or cx > std.math.maxInt(i32) or cz < std.math.minInt(i32) or cz > std.math.maxInt(i32)) continue; + const chunk_x: i32 = @intCast(cx); + const chunk_z: i32 = @intCast(cz); + const dist_sq = clampedDistanceSquared(dx, dz); + + const key = ChunkKey{ .x = chunk_x, .z = chunk_z }; + const data = self.storage.chunks.get(key) orelse data: { + const created = try self.storage.createChunkDataUnlocked(chunk_x, chunk_z); + try self.storage.chunks.put(key, created); + break :data created; + }; + + switch (data.chunk.state) { + .missing => { + const priority_dist_sq = weightedDistanceSq(dist_sq, movement, @intCast(dx), @intCast(dz)); + self.gen_queue.push(.{ + .type = .chunk_generation, + .dist_sq = priority_dist_sq, + .data = .{ .chunk = .{ .x = chunk_x, .z = chunk_z, .job_token = data.chunk.job_token } }, + }) catch { + self.missing_rescan_requested.store(true, .release); + continue; + }; + data.chunk.state = .queued_for_generation; + }, + else => {}, + } + } + return false; } pub fn processChunkStates(self: *ChunkQueueCoordinator, pc_x: i32, pc_z: i32, render_dist: i32, frame_counter: u64) void { @@ -237,12 +350,12 @@ pub const ChunkQueueCoordinator = struct { if (data.chunk.state == .generated) { // Safety net in case a worker's pending-mesh notification was // lost (e.g. allocation failure on append). - const dx = data.chunk.chunk_x - pc_x; - const dz = data.chunk.chunk_z - pc_z; - if (dx * dx + dz * dz <= render_dist * render_dist) { + const dx = @as(i64, data.chunk.chunk_x) - @as(i64, pc_x); + const dz = @as(i64, data.chunk.chunk_z) - @as(i64, pc_z); + if (isWithinDistance(dx, dz, render_dist)) { self.mesh_queue.push(.{ .type = .chunk_meshing, - .dist_sq = dx * dx + dz * dz, + .dist_sq = clampedDistanceSquared(dx, dz), .data = .{ .chunk = .{ .x = data.chunk.chunk_x, .z = data.chunk.chunk_z, .job_token = data.chunk.job_token } }, }) catch continue; data.chunk.state = .queued_for_mesh; @@ -272,18 +385,18 @@ pub const ChunkQueueCoordinator = struct { } } } else if (data.chunk.state == .generating and !data.chunk.isPinned() and frame_counter % 120 == 0) { - const dx = data.chunk.chunk_x - pc_x; - const dz = data.chunk.chunk_z - pc_z; - const max_dist = render_dist + CHUNK_UNLOAD_BUFFER; - if (dx * dx + dz * dz <= max_dist * max_dist) { + const dx = @as(i64, data.chunk.chunk_x) - @as(i64, pc_x); + const dz = @as(i64, data.chunk.chunk_z) - @as(i64, pc_z); + const max_dist = @as(i64, render_dist) + CHUNK_UNLOAD_BUFFER; + if (isWithinDistance(dx, dz, max_dist)) { data.chunk.job_token += 1; data.chunk.state = .missing; log.log.warn("CHUNK_STUCK: ({},{}) in generating state too long, resetting to missing", .{ data.chunk.chunk_x, data.chunk.chunk_z }); } } else if (data.chunk.state == .uploading and frame_counter % 60 == 0) { - const dx = data.chunk.chunk_x - pc_x; - const dz = data.chunk.chunk_z - pc_z; - if (dx * dx + dz * dz <= render_dist * render_dist) { + const dx = @as(i64, data.chunk.chunk_x) - @as(i64, pc_x); + const dz = @as(i64, data.chunk.chunk_z) - @as(i64, pc_z); + if (isWithinDistance(dx, dz, render_dist)) { data.chunk.mesh_attempts +|= 1; if (data.chunk.mesh_attempts < 3) { log.log.warn("CHUNK_UPLOAD_STUCK: ({},{}) in uploading state too long, resetting to generated (attempt {})", .{ data.chunk.chunk_x, data.chunk.chunk_z, data.chunk.mesh_attempts }); @@ -384,10 +497,10 @@ pub const ChunkQueueCoordinator = struct { for (local.items) |ref| { const data = self.storage.chunks.get(.{ .x = ref.x, .z = ref.z }) orelse continue; if (data.chunk.state != .generated or data.chunk.job_token != ref.job_token) continue; - const dx = ref.x - pc_x; - const dz = ref.z - pc_z; - const dist_sq = dx * dx + dz * dz; - if (dist_sq > render_dist * render_dist) continue; + const dx = @as(i64, ref.x) - @as(i64, pc_x); + const dz = @as(i64, ref.z) - @as(i64, pc_z); + if (!isWithinDistance(dx, dz, render_dist)) continue; + const dist_sq = clampedDistanceSquared(dx, dz); self.mesh_queue.push(.{ .type = .chunk_meshing, .dist_sq = dist_sq, @@ -484,6 +597,8 @@ pub const ChunkQueueCoordinator = struct { pub fn processGenJob(ctx: *anyopaque, job: Job) void { const self: *ChunkQueueCoordinator = @ptrCast(@alignCast(ctx)); + _ = self.generation_jobs_in_flight.fetchAdd(1, .acq_rel); + defer _ = self.generation_jobs_in_flight.fetchSub(1, .acq_rel); const cx = job.data.chunk.x; const cz = job.data.chunk.z; @@ -496,10 +611,10 @@ pub const ChunkQueueCoordinator = struct { const pc_x = self.last_pc_x.load(.acquire); const pc_z = self.last_pc_z.load(.acquire); const render_dist = self.effective_render_dist.load(.acquire); - const dx = cx - pc_x; - const dz = cz - pc_z; - const max_dist = render_dist + CHUNK_UNLOAD_BUFFER; - if (dx * dx + dz * dz > max_dist * max_dist) { + const dx = @as(i64, cx) - @as(i64, pc_x); + const dz = @as(i64, cz) - @as(i64, pc_z); + const max_dist = @as(i64, render_dist) + CHUNK_UNLOAD_BUFFER; + if (!isWithinDistance(dx, dz, max_dist)) { self.storage.chunks_mutex.unlockShared(); self.storage.chunks_mutex.lock(); @@ -547,12 +662,14 @@ pub const ChunkQueueCoordinator = struct { self.storage.chunks_mutex.lock(); chunk_data.chunk.state = .missing; chunk_data.chunk.generated = false; + self.missing_rescan_requested.store(true, .release); self.storage.chunks_mutex.unlock(); return; }; if (self.gen_queue.abort_worker) { self.storage.chunks_mutex.lock(); chunk_data.chunk.state = .missing; + self.missing_rescan_requested.store(true, .release); self.storage.chunks_mutex.unlock(); return; } @@ -633,6 +750,8 @@ pub const ChunkQueueCoordinator = struct { pub fn processMeshJob(ctx: *anyopaque, job: Job) void { const self: *ChunkQueueCoordinator = @ptrCast(@alignCast(ctx)); + _ = self.mesh_jobs_in_flight.fetchAdd(1, .acq_rel); + defer _ = self.mesh_jobs_in_flight.fetchSub(1, .acq_rel); const cx = job.data.chunk.x; const cz = job.data.chunk.z; @@ -645,10 +764,10 @@ pub const ChunkQueueCoordinator = struct { const pc_x = self.last_pc_x.load(.acquire); const pc_z = self.last_pc_z.load(.acquire); const render_dist = self.effective_render_dist.load(.acquire); - const dx = cx - pc_x; - const dz = cz - pc_z; - const max_dist = render_dist + CHUNK_UNLOAD_BUFFER; - if (dx * dx + dz * dz > max_dist * max_dist) { + const dx = @as(i64, cx) - @as(i64, pc_x); + const dz = @as(i64, cz) - @as(i64, pc_z); + const max_dist = @as(i64, render_dist) + CHUNK_UNLOAD_BUFFER; + if (!isWithinDistance(dx, dz, max_dist)) { self.storage.chunks_mutex.unlockShared(); self.storage.chunks_mutex.lock(); @@ -932,3 +1051,22 @@ test "runtime edits enqueue dirty renderable chunks immediately" { try testing.expectEqual(Chunk.State.generated, data.chunk.state); try testing.expectEqual(@as(usize, 1), coordinator.pending_mesh_incoming.items.len); } + +test "missing chunk scan cursor covers concentric square rings without duplicates" { + var coordinator: ChunkQueueCoordinator = undefined; + coordinator.resetMissingScan(10, -5, 2); + + var coordinates: [25][2]i64 = undefined; + var count: usize = 0; + while (coordinator.nextMissingScanCoordinate(10, -5, 2)) |coordinate| { + try std.testing.expect(count < coordinates.len); + for (coordinates[0..count]) |previous| { + try std.testing.expect(previous[0] != coordinate[0] or previous[1] != coordinate[1]); + } + coordinates[count] = coordinate; + count += 1; + } + + try std.testing.expectEqual(coordinates.len, count); + try std.testing.expect(MAX_MISSING_SCAN_STEPS < @as(usize, 4096) * 4096); +} diff --git a/modules/world-runtime/src/world.zig b/modules/world-runtime/src/world.zig index e3840f5d..a6f87df1 100644 --- a/modules/world-runtime/src/world.zig +++ b/modules/world-runtime/src/world.zig @@ -619,7 +619,6 @@ pub const World = struct { allocator: std.mem.Allocator, generator: Generator, render_distance: i32, - lod_chunk_render_radius_limit: i32, horizon_distance: i32, rhi: RHI, paused: bool = false, @@ -653,11 +652,8 @@ pub const World = struct { const storage = ChunkStorage.init(allocator); const safe_mode = runtime_env.safeModeEnabled(); const strict_safe_mode = runtime_env.strictSafeModeEnabled(); - const safe_render_distance: i32 = options.render_distance; - const streamer_render_distance: i32 = if (options.lod_config) |lod_config| - @min(safe_render_distance, lod_config.getChunkRenderRadius()) - else - safe_render_distance; + const requested_render_distance: i32 = @max(options.render_distance, 2); + const streamer_render_distance = effectiveChunkRenderRadius(requested_render_distance); const max_uploads: usize = if (strict_safe_mode) @as(usize, 4) else if (safe_mode) @@ -673,8 +669,7 @@ pub const World = struct { .streamer = undefined, .renderer = undefined, .allocator = allocator, - .render_distance = safe_render_distance, - .lod_chunk_render_radius_limit = streamer_render_distance, + .render_distance = requested_render_distance, .horizon_distance = if (options.lod_config) |lod_config| lod_config.getRadii()[LODLevel.count - 1] else LODConfig.default_horizon_radius, .generator = try registry.createGenerator(options.generator_index, options.seed, allocator), .rhi = options.rhi, @@ -715,12 +710,13 @@ pub const World = struct { world.renderer.getGpuMesher() != null, ); - log.log.info("World.init: initializing WorldStreamer (render_distance={}, requested={})", .{ streamer_render_distance, safe_render_distance }); + log.log.info("World.init: initializing WorldStreamer (render_distance={}, requested={})", .{ streamer_render_distance, requested_render_distance }); 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| { world.lod = try WorldLOD.init(allocator, options.rhi, lod_config, lodGeneratorFromGenerator(world.generator), options.atlas); + world.lod.?.setChunkRenderRadius(streamer_render_distance); world.lod_enabled = true; world.streamer.setLODManager(world.lod.?.manager); } @@ -877,55 +873,43 @@ pub const World = struct { /// Set render distance and trigger chunk loading/unloading update pub fn setRenderDistance(self: *World, distance: i32) void { - const target = if (self.safe_mode) @min(distance, self.safe_render_distance) else distance; + const target = @max(distance, 2); if (self.render_distance != target) { - if (self.safe_mode and target != distance) { - log.log.warn("ZIGCRAFT_SAFE_MODE clamped render distance {} -> {}", .{ distance, target }); - } log.log.info("Render distance changed: {} -> {}", .{ self.render_distance, target }); self.render_distance = target; self.applyRenderDistance(); } } - /// Updates the preset-owned full-detail radius cap. This is separate from - /// the user-facing distance so manual values above a preset's LOD0 radius - /// still expand the horizon rather than flooding full-detail chunks. - pub fn setLODChunkRenderRadiusLimit(self: *World, limit: i32) void { - const target = @max(limit, 1); - if (self.lod_chunk_render_radius_limit == target) return; - self.lod_chunk_render_radius_limit = target; - self.applyRenderDistance(); - } - fn applyRenderDistance(self: *World) void { - const chunk_render_radius = effectiveChunkRenderRadius(self.render_distance, self.lod_chunk_render_radius_limit, self.lod != null); - self.streamer.setRenderDistance(chunk_render_radius); + const chunk_render_radius = effectiveChunkRenderRadius(self.render_distance); + self.horizon_distance = LODConfig.normalizeHorizonDistance(self.render_distance, self.horizon_distance); if (self.lod) |lod| { const radii = LODConfig.radiiForDistances(self.render_distance, self.horizon_distance); lod.setChunkRenderRadius(chunk_render_radius); lod.setRadii(radii); - lod.setActiveLODCount(LODConfig.activeCountForRenderDistance(self.render_distance)); + lod.setActiveLODCount(LODConfig.activeCountForRadii(radii)); } + self.streamer.setRenderDistance(chunk_render_radius); } - pub fn effectiveChunkRenderRadius(render_distance: i32, preset_limit: i32, lod_enabled: bool) i32 { - return if (lod_enabled) @min(render_distance, preset_limit) else render_distance; + pub fn effectiveChunkRenderRadius(render_distance: i32) i32 { + return @max(render_distance, 2); } /// Changes the distant-terrain horizon distance. /// LOD queues and visibility update on subsequent world ticks. pub fn setHorizonDistance(self: *World, distance: i32) void { - const target = @max(distance, self.render_distance); + const target = LODConfig.normalizeHorizonDistance(self.render_distance, distance); if (self.horizon_distance == target) return; log.log.info("Horizon distance changed: {} -> {}", .{ self.horizon_distance, target }); self.horizon_distance = target; if (self.lod) |lod| { const radii = LODConfig.radiiForDistances(self.render_distance, target); lod.setRadii(radii); - lod.setActiveLODCount(LODLevel.count); + lod.setActiveLODCount(LODConfig.activeCountForRadii(radii)); } } @@ -1079,7 +1063,8 @@ pub const World = struct { /// render pass becomes active. Normal rendering remains a CPU fallback. pub fn prepareLODCulling(self: *World, view_proj: Mat4, camera_pos: Vec3) void { if (self.lod) |lod| { - lod.manager.prepareFrame(self.renderer.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkRenderable, @ptrCast(&self.storage), null); + const detail_render_radius = @min(self.streamer.getActiveRenderDistance(), lod.manager.config.getChunkRenderRadius()); + lod.manager.prepareFrame(self.renderer.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkTerrainReadyForHandoff, @ptrCast(&self.storage), null, detail_render_radius); } } diff --git a/modules/world-runtime/src/world_facade_tests.zig b/modules/world-runtime/src/world_facade_tests.zig index 6e9d738f..2eecac86 100644 --- a/modules/world-runtime/src/world_facade_tests.zig +++ b/modules/world-runtime/src/world_facade_tests.zig @@ -7,15 +7,30 @@ const world_meshing = @import("world-meshing"); const worldgen = @import("world-worldgen"); const math = @import("engine-math"); const LpvGridBuilder = @import("lpv_grid_builder.zig").LpvGridBuilder; -const RenderLayer = @import("world_renderer.zig").RenderLayer; +const world_renderer = @import("world_renderer.zig"); +const RenderLayer = world_renderer.RenderLayer; const WorldMutationCoordinator = @import("world_mutation.zig").WorldMutationCoordinator; const SaveManager = @import("world-persistence").SaveManager; const World = world_mod.World; -test "full-detail radius follows active preset cap" { - try testing.expectEqual(@as(i32, 12), World.effectiveChunkRenderRadius(16, 12, true)); - try testing.expectEqual(@as(i32, 16), World.effectiveChunkRenderRadius(16, 16, true)); - try testing.expectEqual(@as(i32, 22), World.effectiveChunkRenderRadius(22, 10, false)); +test "explicit render distance controls full-detail reach" { + try testing.expectEqual(@as(i32, 22), World.effectiveChunkRenderRadius(22)); + try testing.expectEqual(@as(i32, 4096), World.effectiveChunkRenderRadius(4096)); + try testing.expectEqual(std.math.maxInt(i32), World.effectiveChunkRenderRadius(std.math.maxInt(i32))); + try testing.expectEqual(@as(i32, 6), World.effectiveChunkRenderRadius(6)); +} + +test "full-detail render candidates use the streaming disk" { + try testing.expect(world_renderer.isWithinChunkRenderRadius(10, 0, 0, 0, 10)); + try testing.expect(world_renderer.isWithinChunkRenderRadius(-10, 0, 0, 0, 10)); + try testing.expect(!world_renderer.isWithinChunkRenderRadius(10, 10, 0, 0, 10)); + try testing.expect(!world_renderer.isWithinChunkRenderRadius(-11, 0, 0, 0, 10)); +} + +test "full-detail MDI overflow falls back before truncating visibility" { + try testing.expect(world_renderer.hasMdiCapacity(16_383, 49_149, 3)); + try testing.expect(!world_renderer.hasMdiCapacity(16_384, 0, 1)); + try testing.expect(!world_renderer.hasMdiCapacity(1, 49_151, 2)); } const MockWorld = struct { @@ -287,8 +302,6 @@ fn makeStorageOnlyWorld(allocator: std.mem.Allocator) world_mod.World { .horizon_distance = 512, .rhi = undefined, .paused = false, - .safe_mode = false, - .safe_render_distance = 8, .lod = null, .lod_enabled = false, .save_manager = null, diff --git a/modules/world-runtime/src/world_renderer.zig b/modules/world-runtime/src/world_renderer.zig index 39d8f505..3d02c657 100644 --- a/modules/world-runtime/src/world_renderer.zig +++ b/modules/world-runtime/src/world_renderer.zig @@ -62,6 +62,17 @@ fn gpuBlockCapacityForBudgetMb(budget_mb: usize) usize { return @min(MAX_MDI_CHUNKS, max_by_budget); } +pub fn isWithinChunkRenderRadius(chunk_x: i64, chunk_z: i64, player_chunk_x: i64, player_chunk_z: i64, radius: i64) bool { + const dx = @as(i128, chunk_x) - @as(i128, player_chunk_x); + const dz = @as(i128, chunk_z) - @as(i128, player_chunk_z); + const safe_radius = @as(i128, @max(radius, 0)); + return dx * dx + dz * dz <= safe_radius * safe_radius; +} + +pub fn hasMdiCapacity(instance_count: usize, command_count: usize, additional_commands: usize) bool { + return instance_count < MAX_MDI_CHUNKS and command_count <= MAX_MDI_CHUNKS * 3 and additional_commands <= MAX_MDI_CHUNKS * 3 - command_count; +} + pub const RenderStats = struct { chunks_total: u32 = 0, chunks_rendered: u32 = 0, @@ -326,17 +337,18 @@ pub const WorldRenderer = struct { if (layer != .fluid) { self.last_render_stats = .{ .gpu_culling = self.use_gpu_culling }; } + const detail_render_radius = if (lod_manager) |mgr| @min(render_distance, mgr.config.getChunkRenderRadius()) else render_distance; if (render_lod) { if (lod_manager) |lod_mgr| { if (layer != .fluid) { self.timing.beginPassTiming("LODTerrainPass"); - lod_mgr.renderFrame(self.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkRenderable, @ptrCast(self.storage), true, null, LODRenderLayer.terrain); + lod_mgr.renderFrame(self.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkTerrainReadyForHandoff, @ptrCast(self.storage), true, null, detail_render_radius, LODRenderLayer.terrain); self.timing.endPassTiming("LODTerrainPass"); } if (layer != .terrain and parseEnabledEnv(getenv("ZIGCRAFT_LOD_WATER"), true)) { self.timing.beginPassTiming("LODWaterPass"); - lod_mgr.renderFrame(self.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkRenderable, @ptrCast(self.storage), true, null, LODRenderLayer.fluid); + lod_mgr.renderFrame(self.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkTerrainReadyForHandoff, @ptrCast(self.storage), true, null, detail_render_radius, LODRenderLayer.fluid); self.timing.endPassTiming("LODWaterPass"); } } @@ -358,8 +370,7 @@ pub const WorldRenderer = struct { const pc_x: i64 = pc.chunk_x; const pc_z: i64 = pc.chunk_z; - const r_dist_val: i32 = if (lod_manager) |mgr| @min(render_distance, mgr.config.getChunkRenderRadius()) else render_distance; - const r_dist: i64 = @as(i64, @intCast(r_dist_val)); + const r_dist: i64 = @as(i64, @intCast(detail_render_radius)); const count_stats = layer != .fluid; if (self.use_gpu_culling) { @@ -377,6 +388,8 @@ pub const WorldRenderer = struct { var total_vertices: u64 = 0; for (self.visible_chunks.items) |data| { + const suppress_terrain = render_lod and layer != .fluid and if (lod_manager) |mgr| mgr.suppressesDetailChunk(data.chunk.chunk_x, data.chunk.chunk_z) else false; + if (suppress_terrain and layer == .terrain) continue; if (layer != .fluid) { self.last_render_stats.chunks_rendered += 1; } @@ -387,53 +400,72 @@ pub const WorldRenderer = struct { const rel_y = -camera_pos.y; const model = Mat4.translate(Vec3.init(rel_x, rel_y, rel_z)); + if (suppress_terrain) { + total_vertices += self.drawChunkDirect(data, model, .fluid, true); + continue; + } + const is_camera_neighborhood = @abs(data.chunk.chunk_x - @as(i32, @intCast(pc_x))) <= 1 and @abs(data.chunk.chunk_z - @as(i32, @intCast(pc_z))) <= 1; if (!supports_indirect_first_instance or force_mdi_fallback or is_camera_neighborhood) { total_vertices += self.drawChunkDirect(data, model, layer, true); continue; } + const chunk_command_count = @as(usize, if (layer != .fluid and data.render.mesh.solid_allocation != null) 1 else 0) + + @as(usize, if (layer != .fluid and data.render.mesh.cutout_allocation != null) 1 else 0) + + @as(usize, if (layer != .terrain and data.render.mesh.fluid_allocation != null) 1 else 0); + if (chunk_command_count == 0) continue; + if (!hasMdiCapacity(self.instance_data.items.len, self.draw_commands.items.len, chunk_command_count)) { + total_vertices += self.drawChunkDirect(data, model, layer, true); + continue; + } + self.instance_data.ensureUnusedCapacity(self.allocator, 1) catch { + total_vertices += self.drawChunkDirect(data, model, layer, true); + continue; + }; + self.draw_commands.ensureUnusedCapacity(self.allocator, chunk_command_count) catch { + total_vertices += self.drawChunkDirect(data, model, layer, true); + continue; + }; + const instance_idx: u32 = @intCast(self.instance_data.items.len); - self.instance_data.append(self.allocator, .{ + self.instance_data.appendAssumeCapacity(.{ .model = model, .mask_radius = 0, .lod_fade = 1.0, .padding = .{ 0, 0 }, - }) catch |err| { - log.log.debug("MDI: instance append failed: {}", .{err}); - continue; - }; + }); if (layer != .fluid) { if (data.render.mesh.solid_allocation) |alloc| { self.last_render_stats.vertices_rendered += alloc.count; - self.draw_commands.append(self.allocator, .{ + self.draw_commands.appendAssumeCapacity(.{ .vertexCount = alloc.count, .instanceCount = 1, .firstVertex = @intCast(alloc.offset / vertex_size), .firstInstance = instance_idx, - }) catch |err| log.log.debug("MDI: solid cmd append failed: {}", .{err}); + }); } if (data.render.mesh.cutout_allocation) |alloc| { self.last_render_stats.vertices_rendered += alloc.count; - self.draw_commands.append(self.allocator, .{ + self.draw_commands.appendAssumeCapacity(.{ .vertexCount = alloc.count, .instanceCount = 1, .firstVertex = @intCast(alloc.offset / vertex_size), .firstInstance = instance_idx, - }) catch |err| log.log.debug("MDI: cutout cmd append failed: {}", .{err}); + }); } } if (layer != .terrain) { if (data.render.mesh.fluid_allocation) |alloc| { self.last_render_stats.vertices_rendered += alloc.count; - self.draw_commands.append(self.allocator, .{ + self.draw_commands.appendAssumeCapacity(.{ .vertexCount = alloc.count, .instanceCount = 1, .firstVertex = @intCast(alloc.offset / vertex_size), .firstInstance = instance_idx, - }) catch |err| log.log.debug("MDI: fluid cmd append failed: {}", .{err}); + }); } } } @@ -444,14 +476,8 @@ pub const WorldRenderer = struct { const max_instances: usize = MAX_MDI_CHUNKS; const max_commands: usize = MAX_MDI_CHUNKS * 3; - if (self.instance_data.items.len > max_instances) { - log.log.warn("MDI: instance overflow ({} > {}), truncating", .{ self.instance_data.items.len, max_instances }); - self.instance_data.shrinkRetainingCapacity(max_instances); - } - if (self.draw_commands.items.len > max_commands) { - log.log.warn("MDI: command overflow ({} > {}), truncating", .{ self.draw_commands.items.len, max_commands }); - self.draw_commands.shrinkRetainingCapacity(max_commands); - } + std.debug.assert(self.instance_data.items.len <= max_instances); + std.debug.assert(self.draw_commands.items.len <= max_commands); const instance_bytes = std.mem.sliceAsBytes(self.instance_data.items); self.rm.updateBuffer(self.instance_buffers[fi], 0, instance_bytes) catch |err| { @@ -476,7 +502,7 @@ pub const WorldRenderer = struct { ); } - self.drawGuaranteedNearChunks(@intCast(pc_x), @intCast(pc_z), camera_pos, layer); + self.drawGuaranteedNearChunks(@intCast(pc_x), @intCast(pc_z), r_dist, camera_pos, lod_manager, render_lod, layer); } fn drawChunkDirect(self: *WorldRenderer, data: *ChunkData, model: Mat4, layer: RenderLayer, count_vertices: bool) u64 { @@ -505,13 +531,16 @@ pub const WorldRenderer = struct { return total_vertices; } - fn drawGuaranteedNearChunks(self: *WorldRenderer, pc_x: i32, pc_z: i32, camera_pos: Vec3, layer: RenderLayer) void { + fn drawGuaranteedNearChunks(self: *WorldRenderer, pc_x: i32, pc_z: i32, render_radius: i64, camera_pos: Vec3, lod_manager: ?*LODManager, render_lod: bool, layer: RenderLayer) void { var dz: i32 = -1; while (dz <= 1) : (dz += 1) { var dx: i32 = -1; while (dx <= 1) : (dx += 1) { const cx = pc_x + dx; const cz = pc_z + dz; + if (!isWithinChunkRenderRadius(@as(i64, cx), @as(i64, cz), @as(i64, pc_x), @as(i64, pc_z), render_radius)) continue; + const suppress_terrain = render_lod and layer != .fluid and if (lod_manager) |mgr| mgr.suppressesDetailChunk(cx, cz) else false; + if (suppress_terrain and layer == .terrain) continue; const data = self.storage.chunks.get(.{ .x = cx, .z = cz }) orelse continue; var already_drawn = false; @@ -526,7 +555,7 @@ pub const WorldRenderer = struct { const chunk_world_x: f32 = @floatFromInt(cx * CHUNK_SIZE_X); const chunk_world_z: f32 = @floatFromInt(cz * CHUNK_SIZE_Z); const model = Mat4.translate(Vec3.init(chunk_world_x - camera_pos.x, -camera_pos.y, chunk_world_z - camera_pos.z)); - _ = self.drawChunkDirect(data, model, layer, false); + _ = self.drawChunkDirect(data, model, if (suppress_terrain) .fluid else layer, false); } } } @@ -537,29 +566,27 @@ pub const WorldRenderer = struct { var diagnostics = CpuCullDiagnostics.init(); - var cz = pc_z - r_dist; - while (cz <= pc_z + r_dist) : (cz += 1) { - var cx = pc_x - r_dist; - while (cx <= pc_x + r_dist) : (cx += 1) { - const dx = cx - pc_x; - const dz = cz - pc_z; - const dist_sq = dx * dx + dz * dz; - if (self.storage.chunks.get(.{ .x = @as(i32, @intCast(cx)), .z = @as(i32, @intCast(cz)) })) |data| { - if (data.chunk.state == .renderable or data.render.mesh.solid_allocation != null or data.render.mesh.cutout_allocation != null or data.render.mesh.fluid_allocation != null) { - const is_camera_neighborhood = @abs(cx - pc_x) <= 1 and @abs(cz - pc_z) <= 1; - if (!is_camera_neighborhood and !frustum.intersectsChunkRelative(@as(i32, @intCast(cx)), @as(i32, @intCast(cz)), camera_pos.x, camera_pos.y, camera_pos.z)) { - diagnostics.recordFrustumCulled(); - if (count_stats) self.last_render_stats.chunks_culled += 1; - continue; - } - self.visible_chunks.append(self.allocator, data) catch {}; - diagnostics.recordVisible(cx, cz, data); - } else { - diagnostics.recordNotRenderable(cx, cz, dist_sq, r_dist); - } - } else { - diagnostics.recordNotInStorage(cx, cz, dist_sq, r_dist); + var chunk_iter = self.storage.iteratorUnsafe(); + while (chunk_iter.next()) |entry| { + const key = entry.key_ptr.*; + const data = entry.value_ptr.*; + const cx = @as(i64, key.x); + const cz = @as(i64, key.z); + const dx = cx - pc_x; + const dz = cz - pc_z; + const dist_sq = dx * dx + dz * dz; + if (!isWithinChunkRenderRadius(cx, cz, pc_x, pc_z, r_dist)) continue; + if (data.chunk.state == .renderable or data.render.mesh.solid_allocation != null or data.render.mesh.cutout_allocation != null or data.render.mesh.fluid_allocation != null) { + const is_camera_neighborhood = @abs(cx - pc_x) <= 1 and @abs(cz - pc_z) <= 1; + if (!is_camera_neighborhood and !frustum.intersectsChunkRelative(key.x, key.z, camera_pos.x, camera_pos.y, camera_pos.z)) { + diagnostics.recordFrustumCulled(); + if (count_stats) self.last_render_stats.chunks_culled += 1; + continue; } + self.visible_chunks.append(self.allocator, data) catch {}; + diagnostics.recordVisible(cx, cz, data); + } else { + diagnostics.recordNotRenderable(cx, cz, dist_sq, r_dist); } } diagnostics.logFrame(self.storage, self.visible_chunks.items.len, pc_x, pc_z, r_dist, self.render_frame_count, build_options.startup_diagnostic_seconds); @@ -643,7 +670,9 @@ pub const WorldRenderer = struct { const limit = @min(@as(usize, @intCast(prev_visible_count)), self.gpu_visible_indices.items.len); for (self.gpu_visible_indices.items[0..limit]) |idx| { if (idx < self.chunk_lookup[prev_fi].items.len) { - self.visible_chunks.append(self.allocator, self.chunk_lookup[prev_fi].items[idx]) catch continue; + const data = self.chunk_lookup[prev_fi].items[idx]; + if (!isWithinChunkRenderRadius(@as(i64, data.chunk.chunk_x), @as(i64, data.chunk.chunk_z), pc_x, pc_z, r_dist)) continue; + self.visible_chunks.append(self.allocator, data) catch continue; } } } @@ -653,19 +682,24 @@ pub const WorldRenderer = struct { self.aabb_data.clearRetainingCapacity(); self.chunk_lookup[fi].clearRetainingCapacity(); - var cz = pc_z - r_dist; - while (cz <= pc_z + r_dist) : (cz += 1) { - var cx = pc_x - r_dist; - while (cx <= pc_x + r_dist) : (cx += 1) { - if (self.storage.chunks.get(.{ .x = @as(i32, @intCast(cx)), .z = @as(i32, @intCast(cz)) })) |data| { - if (data.chunk.state == .renderable or data.render.mesh.solid_allocation != null or data.render.mesh.cutout_allocation != null or data.render.mesh.fluid_allocation != null) { - self.aabb_data.append(self.allocator, chunkAABB(data.chunk.chunk_x, data.chunk.chunk_z, camera_pos)) catch continue; - self.chunk_lookup[fi].append(self.allocator, data) catch continue; - } - } + var chunk_iter = self.storage.iteratorUnsafe(); + while (chunk_iter.next()) |entry| { + const key = entry.key_ptr.*; + const data = entry.value_ptr.*; + if (!isWithinChunkRenderRadius(key.x, key.z, pc_x, pc_z, r_dist)) continue; + if (data.chunk.state == .renderable or data.render.mesh.solid_allocation != null or data.render.mesh.cutout_allocation != null or data.render.mesh.fluid_allocation != null) { + self.aabb_data.append(self.allocator, chunkAABB(data.chunk.chunk_x, data.chunk.chunk_z, camera_pos)) catch continue; + self.chunk_lookup[fi].append(self.allocator, data) catch continue; } } + if (self.aabb_data.items.len > MAX_MDI_CHUNKS) { + log.log.warn("GPU chunk culling capacity exceeded ({} > {}); switching to uncapped CPU culling", .{ self.aabb_data.items.len, MAX_MDI_CHUNKS }); + self.use_gpu_culling = false; + self.visible_chunks.clearRetainingCapacity(); + return self.renderCpuCull(view_proj, camera_pos, pc_x, pc_z, r_dist, count_stats); + } + const chunk_count: u32 = @intCast(self.aabb_data.items.len); if (chunk_count == 0) return; diff --git a/modules/world-runtime/src/world_streamer.zig b/modules/world-runtime/src/world_streamer.zig index 67c55087..4b7f79c9 100644 --- a/modules/world-runtime/src/world_streamer.zig +++ b/modules/world-runtime/src/world_streamer.zig @@ -238,22 +238,27 @@ pub const WorldStreamer = struct { pub fn isStartupBusy(self: *WorldStreamer, target_render_dist: i32) bool { if (self.lod_coordinator.isStartupBusy(self.getStats(), target_render_dist)) return true; + if (self.queue_coordinator.hasInFlightWork()) return true; + if (!self.has_scanned_missing_chunks) return true; - const radius = @min(target_render_dist, self.lod_coordinator.targetRenderDistance()); const pc_x = self.lod_coordinator.last_pc.x; const pc_z = self.lod_coordinator.last_pc.z; self.storage.chunks_mutex.lockShared(); defer self.storage.chunks_mutex.unlockShared(); - var cz = pc_z - radius; - while (cz <= pc_z + radius) : (cz += 1) { - var cx = pc_x - radius; - while (cx <= pc_x + radius) : (cx += 1) { - const dx: i64 = @as(i64, cx) - pc_x; - const dz: i64 = @as(i64, cz) - pc_z; - const radius_i64: i64 = radius; + // Startup finalization only remeshes the camera neighborhood. The + // bounded missing-chunk scan plus empty queues above proves the wider + // disk has drained without rescanning millions of coordinates here. + const radius_i64: i64 = 1; + var cz = @as(i64, pc_z) - radius_i64; + while (cz <= @as(i64, pc_z) + radius_i64) : (cz += 1) { + var cx = @as(i64, pc_x) - radius_i64; + while (cx <= @as(i64, pc_x) + radius_i64) : (cx += 1) { + const dx = cx - @as(i64, pc_x); + const dz = cz - @as(i64, pc_z); if (dx * dx + dz * dz > radius_i64 * radius_i64) continue; - const data = self.storage.chunks.get(.{ .x = cx, .z = cz }) orelse return true; + if (cx < std.math.minInt(i32) or cx > std.math.maxInt(i32) or cz < std.math.minInt(i32) or cz > std.math.maxInt(i32)) continue; + const data = self.storage.chunks.get(.{ .x = @intCast(cx), .z = @intCast(cz) }) orelse return true; if (data.chunk.state != .renderable or !data.render.mesh.ready) return true; } } @@ -504,16 +509,19 @@ pub const WorldStreamer = struct { // 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 + const missing_rescan_requested = self.queue_coordinator.takeMissingRescanRequest(); + if (missing_rescan_requested) self.queue_coordinator.restartMissingScan(); + const needs_missing_scan = missing_rescan_requested or + !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.stream_dist or self.frame_counter % 60 == 0; if (needs_missing_scan) { - self.queue_coordinator.scanForMissingChunks(frame.pc_x, frame.pc_z, frame.stream_dist, frame.movement) catch |err| { + self.has_scanned_missing_chunks = self.queue_coordinator.scanForMissingChunks(frame.pc_x, frame.pc_z, frame.stream_dist, frame.movement) catch |err| result: { log.log.warn("scanForMissingChunks error (non-fatal): {}", .{err}); + break :result false; }; - 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.stream_dist; @@ -610,7 +618,8 @@ pub const WorldStreamer = struct { fn processUnloads(self: *WorldStreamer, player_pos: Vec3) !void { const pc = worldToChunkFromFloat(player_pos.x, player_pos.z); const render_dist_unload = self.lod_coordinator.targetRenderDistance(); - const unload_dist_sq = (render_dist_unload + CHUNK_UNLOAD_BUFFER) * (render_dist_unload + CHUNK_UNLOAD_BUFFER); + const unload_distance = @as(i128, render_dist_unload) + CHUNK_UNLOAD_BUFFER; + const unload_dist_sq = unload_distance * unload_distance; self.storage.chunks_mutex.lock(); var to_remove = std.ArrayListUnmanaged(ChunkKey).empty; @@ -620,8 +629,8 @@ pub const WorldStreamer = struct { while (unload_iter.next()) |entry| { const key = entry.key_ptr.*; const data = entry.value_ptr.*; - const dx = key.x - pc.chunk_x; - const dz = key.z - pc.chunk_z; + const dx = @as(i128, key.x) - @as(i128, pc.chunk_x); + const dz = @as(i128, key.z) - @as(i128, pc.chunk_z); if (dx * dx + dz * dz > unload_dist_sq) { if (data.chunk.state != .generating and data.chunk.state != .meshing and data.chunk.state != .uploading and @@ -658,15 +667,20 @@ pub const WorldStreamer = struct { defer missing_keys.deinit(self.allocator); self.storage.chunks_mutex.lockShared(); - var cz: i32 = pc_z - render_dist; - while (cz <= pc_z + render_dist) : (cz += 1) { - var cx: i32 = pc_x - render_dist; - while (cx <= pc_x + render_dist) : (cx += 1) { - const dx = cx - pc_x; - const dz = cz - pc_z; - if (dx * dx + dz * dz > render_dist * render_dist) continue; - - if (self.storage.chunks.get(.{ .x = cx, .z = cz })) |data| { + const radius = @as(i64, render_dist); + const diagnostic_grid_radius: i64 = 8; + var sample_z = -diagnostic_grid_radius; + while (sample_z <= diagnostic_grid_radius) : (sample_z += 1) { + var sample_x = -diagnostic_grid_radius; + while (sample_x <= diagnostic_grid_radius) : (sample_x += 1) { + if (sample_x * sample_x + sample_z * sample_z > diagnostic_grid_radius * diagnostic_grid_radius) continue; + const cx = @as(i64, pc_x) + @divTrunc(sample_x * radius, diagnostic_grid_radius); + const cz = @as(i64, pc_z) + @divTrunc(sample_z * radius, diagnostic_grid_radius); + if (cx < std.math.minInt(i32) or cx > std.math.maxInt(i32) or cz < std.math.minInt(i32) or cz > std.math.maxInt(i32)) continue; + const chunk_x: i32 = @intCast(cx); + const chunk_z: i32 = @intCast(cz); + + if (self.storage.chunks.get(.{ .x = chunk_x, .z = chunk_z })) |data| { switch (data.chunk.state) { .missing => counts[0] += 1, .queued_for_generation => counts[1] += 1, @@ -681,7 +695,7 @@ pub const WorldStreamer = struct { } } else { counts[0] += 1; - missing_keys.append(self.allocator, .{ .x = cx, .z = cz }) catch {}; + missing_keys.append(self.allocator, .{ .x = chunk_x, .z = chunk_z }) catch {}; } } } @@ -697,7 +711,7 @@ pub const WorldStreamer = struct { self.last_diag_meshed = meshed_total; self.last_diag_uploaded = uploaded_total; - log.log.info("CHUNK_DIAG [frame={}] pc=({},{}) rd={}/{} | missing={} qgen={} gen={} gentd={} qmesh={} mesh={} mready={} upload={} render={} unload={} | not_in_storage={} | throughput gen={}/{} mesh={}/{} upload={}/{}", .{ + log.log.info("CHUNK_DIAG_SAMPLE [frame={}] pc=({},{}) rd={}/{} | missing={} qgen={} gen={} gentd={} qmesh={} mesh={} mready={} upload={} render={} unload={} | not_in_storage={} | throughput gen={}/{} mesh={}/{} upload={}/{}", .{ self.frame_counter, pc_x, pc_z, render_dist, target_render_dist, counts[0], counts[1], counts[2], counts[3], counts[4], counts[5], counts[6], counts[7], counts[8], counts[9], diff --git a/src/game/session_tests.zig b/src/game/session_tests.zig index 28872ef2..b7c9e909 100644 --- a/src/game/session_tests.zig +++ b/src/game/session_tests.zig @@ -2,6 +2,24 @@ const std = @import("std"); const testing = std.testing; const session_module = @import("game-core").session; const BuildConfig = session_module.BuildConfig; +const Settings = @import("game-core").Settings; + +test "explicit render distance controls full-detail radius" { + try testing.expectEqual(@as(i32, 22), session_module.fullDetailRenderDistance(22)); + try testing.expectEqual(@as(i32, 4096), session_module.fullDetailRenderDistance(4096)); + try testing.expectEqual(std.math.maxInt(i32), session_module.fullDetailRenderDistance(std.math.maxInt(i32))); + try testing.expectEqual(@as(i32, 6), session_module.fullDetailRenderDistance(6)); +} + +test "render distance metadata has no arbitrary upper cap" { + const range = Settings.metadata.render_distance.kind.int_range; + try testing.expectEqual(@as(i32, 2), range.min); + try testing.expectEqual(std.math.maxInt(i32), range.max); + + const horizon_range = Settings.metadata.horizon_distance.kind.int_range; + try testing.expectEqual(@as(i32, 256), horizon_range.min); + try testing.expectEqual(std.math.maxInt(i32), horizon_range.max); +} fn chunkDebugRestoreEnabled(build_config: BuildConfig, name: []const u8) bool { if (!build_config.chunk_debug_mode) return false; diff --git a/src/integration_test.zig b/src/integration_test.zig index f400af22..797243d1 100644 --- a/src/integration_test.zig +++ b/src/integration_test.zig @@ -104,12 +104,9 @@ fn initStorageOnlyPersistenceWorld(allocator: std.mem.Allocator) world_runtime.W .allocator = allocator, .generator = undefined, .render_distance = 8, - .lod_chunk_render_radius_limit = 8, .horizon_distance = 512, .rhi = undefined, .paused = false, - .safe_mode = false, - .safe_render_distance = 8, .lod = null, .lod_enabled = false, .save_manager = null, diff --git a/src/world_inline_tests.zig b/src/world_inline_tests.zig index ff157711..b49e230d 100644 --- a/src/world_inline_tests.zig +++ b/src/world_inline_tests.zig @@ -13,6 +13,7 @@ const worldToLocal = world_core.worldToLocal; const BlockType = world_core.BlockType; const block_registry = world_core.block_registry; const ChunkMesh = @import("world-meshing").ChunkMesh; +const ChunkStorage = @import("world-meshing").ChunkStorage; const NeighborChunks = @import("world-meshing").NeighborChunks; const TextureAtlas = @import("engine-assets").TextureAtlas; const ao_calculator = @import("world-meshing").meshing.ao_calculator; @@ -22,6 +23,29 @@ const boundary = @import("world-meshing").meshing.boundary; pub const std_options: std.Options = .{ .log_level = .err }; +test "ChunkStorage terrain handoff remains ready while an existing allocation is remeshed" { + var storage = ChunkStorage.init(testing.allocator); + defer storage.deinitWithoutRHI(); + const data = try storage.getOrCreate(-2, 3); + + try testing.expect(!ChunkStorage.isChunkTerrainReadyForHandoff(-2, 3, &storage)); + + data.render.mesh.solid_allocation = .{ .offset = 0, .count = 12, .handle = 1 }; + try testing.expect(ChunkStorage.isChunkTerrainReadyForHandoff(-2, 3, &storage)); + + data.render.mesh.solid_allocation = null; + data.render.mesh.cutout_allocation = .{ .offset = 12, .count = 6, .handle = 1 }; + try testing.expect(ChunkStorage.isChunkTerrainReadyForHandoff(-2, 3, &storage)); + + data.render.mesh.cutout_allocation = null; + data.render.mesh.fluid_allocation = .{ .offset = 18, .count = 6, .handle = 1 }; + try testing.expect(!ChunkStorage.isChunkTerrainReadyForHandoff(-2, 3, &storage)); + + data.chunk.state = .renderable; + data.render.mesh.ready = true; + try testing.expect(ChunkStorage.isChunkTerrainReadyForHandoff(-2, 3, &storage)); +} + test "PackedLight init and accessors" { const light = PackedLight.init(15, 10); try testing.expectEqual(@as(u4, 15), light.getSkyLight()); From 1c270154ffa7a071aabeffa1de747f19cb192ffb Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Thu, 23 Jul 2026 00:25:27 +0100 Subject: [PATCH 2/2] fix: stabilize distant LOD and screen transitions Signed-off-by: MichaelFisher1997 --- .gitignore | 4 + .../shaders/vulkan/lod_compact_terrain.frag | 4 +- .../vulkan/lod_compact_terrain.frag.spv | Bin 0 -> 5356 bytes assets/shaders/vulkan/lod_compact_water.frag | 4 +- .../shaders/vulkan/lod_compact_water.frag.spv | Bin 0 -> 5940 bytes assets/shaders/vulkan/terrain.frag | 5 - assets/shaders/vulkan/terrain.frag.spv | Bin 63268 -> 62660 bytes assets/shaders/vulkan/terrain.vert | 4 +- assets/shaders/vulkan/terrain.vert.spv | Bin 10500 -> 10504 bytes assets/shaders/vulkan/water.frag | 1 - assets/shaders/vulkan/water.frag.spv | Bin 0 -> 20796 bytes assets/shaders/vulkan/water.vert | 4 +- assets/shaders/vulkan/water.vert.spv | Bin 0 -> 8800 bytes build.zig | 2 +- docs/lod-quality-controls.md | 31 +- modules/engine-graphics/src/render_system.zig | 5 + modules/engine-graphics/src/rhi_tests.zig | 7 + modules/engine-graphics/src/rhi_vulkan.zig | 16 +- .../src/vulkan/frame_manager.zig | 21 +- .../src/vulkan/lod_culling_system.zig | 6 +- .../src/vulkan/resource_manager.zig | 4 + .../src/vulkan/rhi_draw_submission.zig | 19 +- .../src/vulkan/rhi_state_control.zig | 12 +- .../src/vulkan/transfer_queue.zig | 43 +++ modules/engine-graphics/src/vulkan_device.zig | 35 ++- .../src/vulkan_device_internal_tests.zig | 2 +- modules/engine-input/src/input.zig | 17 +- modules/engine-input/src/input_tests.zig | 13 + modules/engine-rhi/src/render_settings.zig | 23 +- modules/game-core/src/session.zig | 41 ++- modules/game-core/src/settings/data.zig | 8 +- modules/game-core/src/settings/tests.zig | 4 + modules/game-ui/src/screen.zig | 183 ++++++++--- modules/game-ui/src/screens/paused.zig | 29 +- modules/game-ui/src/screens/rml_paused.zig | 34 ++- modules/game-ui/src/screens/rml_settings.zig | 5 +- modules/game-ui/src/screens/settings.zig | 5 +- modules/game-ui/src/screens/world.zig | 77 +++-- modules/world-lod/src/lod_chunk.zig | 89 +++--- modules/world-lod/src/lod_geometry.zig | 44 ++- modules/world-lod/src/lod_manager.zig | 65 +++- .../world-lod/src/lod_manager_cache_ops.zig | 75 ++++- modules/world-lod/src/lod_manager_context.zig | 5 +- .../world-lod/src/lod_manager_core_ops.zig | 4 +- .../src/lod_manager_generation_ops.zig | 15 +- .../src/lod_manager_ingestion_ops.zig | 121 ++++++-- .../src/lod_manager_internal_tests.zig | 193 +++++++++++- modules/world-lod/src/lod_manager_tests.zig | 124 ++++++++ .../world-lod/src/lod_manager_upload_ops.zig | 31 +- modules/world-lod/src/lod_mesh.zig | 19 +- modules/world-lod/src/lod_renderer.zig | 289 ++++++++---------- modules/world-lod/src/lod_scheduler.zig | 212 +++++++------ modules/world-lod/src/lod_upload_queue.zig | 8 - .../src/chunk_queue_coordinator.zig | 29 +- modules/world-runtime/src/world.zig | 83 ++++- .../world-runtime/src/world_facade_tests.zig | 19 +- modules/world-runtime/src/world_renderer.zig | 23 +- modules/world-runtime/src/world_streamer.zig | 95 ++++-- .../src/lod_sampling.zig | 10 +- modules/worldgen-overworld-v2/src/root.zig | 27 +- .../src/overworld_generator.zig | 101 +++++- scripts/run_phase5_visual_smoke.sh | 17 +- src/game/app.zig | 39 ++- src/game/screen_tests.zig | 104 +++++++ src/game/session_tests.zig | 18 +- src/integration_test.zig | 115 ++++++- src/integration_test_robustness.zig | 18 +- 67 files changed, 2061 insertions(+), 599 deletions(-) create mode 100644 assets/shaders/vulkan/lod_compact_terrain.frag.spv create mode 100644 assets/shaders/vulkan/lod_compact_water.frag.spv create mode 100644 assets/shaders/vulkan/water.frag.spv create mode 100644 assets/shaders/vulkan/water.vert.spv diff --git a/.gitignore b/.gitignore index c3f5dccd..4f5cf621 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ zigcraft-minidumps/ *.spv !assets/shaders/vulkan/lpv_inject.comp.spv !assets/shaders/vulkan/lpv_propagate.comp.spv +!assets/shaders/vulkan/lod_compact_terrain.frag.spv +!assets/shaders/vulkan/lod_compact_water.frag.spv +!assets/shaders/vulkan/water.frag.spv +!assets/shaders/vulkan/water.vert.spv wiki/ *.exr *.hdr diff --git a/assets/shaders/vulkan/lod_compact_terrain.frag b/assets/shaders/vulkan/lod_compact_terrain.frag index 6db0ddf7..9a7b6496 100644 --- a/assets/shaders/vulkan/lod_compact_terrain.frag +++ b/assets/shaders/vulkan/lod_compact_terrain.frag @@ -52,8 +52,8 @@ void main() { float illumination = clamp(max(vSkyLight * global.lighting.x, block_light) + diffuse * global.params.w * 0.45, 0.18, 1.15); vec3 color = vColor * illumination * mix(0.72, 1.0, clamp(vAO, 0.0, 1.0)); if (global.params.z > 0.5) { - float fog = clamp(1.0 - exp(-vDistance * global.params.y), 0.0, 1.0); - fog = max(fog, smoothstep(300.0, 1200.0, vDistance) * 0.62); + float rawFog = clamp(1.0 - exp(-vDistance * global.params.y), 0.0, 1.0); + float fog = rawFog * rawFog * 0.72; color = mix(color, global.fog_color.rgb, fog); } outColor = vec4(color, 1.0); diff --git a/assets/shaders/vulkan/lod_compact_terrain.frag.spv b/assets/shaders/vulkan/lod_compact_terrain.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..00a69b72f462dd1144d55c0ff9cdd2f5d3ceb972 GIT binary patch literal 5356 zcmb7`+m~Eb5r+@S^dvDu5+FbpXy|}KBpL%D5Qro*BxE8CWD-;mFGHvM%%qu~K4W)J z$KZtk1`{O_1ysC7<1OCt%5wSO@-OgD@X041eDku@<@cRF)j2CZxo7>dtA16xcJ11= zPiOhEn^q*rWy$JfW%7D57*{0AU=p~|e4dHC1M?ux-lI$H`Khd4Ye5lZ zB)O4!vXvc?8Rbvi(Ql^5W)?fylgOjwB5w_cJjS%It&-cU2YbvcX5ArY<-1GmnMSh% zH;8SmmUbTL7vU#{)Zoh@1)(d(@z^-Xzb@bEY>=;1&S3hR>HNKb4Pp4_8cPo zAn#SkowVIZJ2SJb+MMMb+TMWNJz8sIoVExzjJF!w;*rkKFiPK92k)$(wU!prUZ+_f znujsHH|w;Rb$T=1=2P}n0R!LKd&tkaycGrgNP!_5_U80*ZVf(bk=<|>$Do^?)4yZUVuV>=o0-s=KFsaD{cNdML>sK}ThRAC*BUON#>PEM66{6ccR4k_VjzP#QFB*&XqX#llxhT^UcZqzQmb_ z+#gGv?@jJcCC;}d_tz5V8fVRCeOC6j$JEz>tN5kyem9ZRa%SagkleePVkfk--PUN-zu}~J=X^Q-Nali^o_}t`CE_+ zUfchVk^G)L+l^os`*`o(x7^j}Z(**2%Yb>R-wdK2w+!Wr=hy<5Bqt`~T=(R9GVZze zBOA-Jxu5!E!Flcjh0XiX|6PSmUVrrm3QqgGk*#CdZjY7oeH`6*vEH83?)he!tH5Et z>T|hvQGXIW;%Q$1o2I!Zz$~Me*sj0`TH#!+{NI$ z>b|k4r}2GbuD_F2p4Fap=<|0NW0|XGeh@gTuVVJMF?8?J-^Q};Z(-=p{tlM)#wEJH zcVRc)sgf@5?^f8I&lmc=cb#X_b~h1y z-_u|XFrK!*;d7YT@lRrFKLq5gm-4@;sIpw_|0uG(Jw{yfc??+h`++w1wtn&xRQ(p_ z_XFRvZ@@TJ<_`e*gPg)RA1pX|hcP`%?DL_5b02-($9s{F+@3&o@6c<=`n*gY*7XQ@ z0?3b1Ke;+^ugyTa-#=q}$J#uD{dE+iU=A4HwM`%&KJ&=>_*TP*|6V2X;nPOeX90vy zhAbaBEh0xwuGb#650`B2udn&qgW4iy57}?R1eyB&j{)ys36#(9k-~PesL^qB*UCq% zr;v>wIeZj3^3z{?_?bAgce-&9S_Wv4k>|gHXufuDLJNpK*Yvp~X*7F>&HrE5~ z&lP?3O=N3y4n!Tlg&cL%9(A-PucB*@oWG4M7y5S!J#zUjx<2y3eGggQJ22*XW;tVc z*WU-$KE6{wK=#}hfId~`9|HOKPW`ChDT6@Gi zjH_?-gZ6(AIquIn^8Pupc^@ij`wL__M{vJHmUEBbepPVhJI(j6%KU5Kdi}L~N9OPv zv$lT|VJGl+)t*}e9IL@y_^n_bE96zomjnB4Epz)W}O+2PyKb`6m8EXx03-cacB z5%-PA<`Qu?BFov!=HXf8qQ^EP+haEaV^o>n3gqKA{nmn$cMR^UlK4Li*IrceKaNFVZ)n@IDeFw1i%G%xE z`;(6v-HB}NjpH}sE+A+8xWk>u5l6dg?YFyuHN3z|*X}Af>w6FI4D#;;#?WpK_KaMd z`Cer63VnB>$NvrX 0.5) { - float fog = clamp(1.0 - exp(-vDistance * global.params.y), 0.0, 1.0); - fog = max(fog, smoothstep(280.0, 1100.0, vDistance) * 0.62); + float rawFog = clamp(1.0 - exp(-vDistance * global.params.y), 0.0, 1.0); + float fog = rawFog * rawFog * 0.65; base = mix(base, global.fog_color.rgb, fog); } diff --git a/assets/shaders/vulkan/lod_compact_water.frag.spv b/assets/shaders/vulkan/lod_compact_water.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..5a666caa7e7abf2ece9afc7c981e0a1dd6b5ca39 GIT binary patch literal 5940 zcmb7`U6@s66~|97Ga-(M3Tjz22pWYEg_;G-3@|cKql`joVU345XNDtZW>4qL9E2z# zq7O3BvJCBm66~8yv&=%L>ARq>8*jSks_Sn0eCm1n{r1`Gu%m9;&+|X;TK~1)^{#il z>)rdzp0(t~g~E#p=NIM_ephHd7Zql~6yQ42v43Fez>4~0X~nhIt<+;)VRq`M&xM7H z3vG;ItXLV>a6VY74iaz}oB*f5H24KL4SofF4Soye6Vn(ZgEX5A3WXUKXotADTsTZ^U2R2`l?qVbJYb&fp@2@61iq!%ucceO0p;0OC z9-62ncO!SOL;b8>i^o`QXlqYx?tQp8HZ+mcTbvcIPmK?iDmA!fZ0jO7oK%wEvL_~X)LI5l(K>X3MpB&`D^J!c!!2`by$iHczlo$aIaIIg zwb-%GdC1j?Mr%^=7iN4?tBl%__D~PpXm&R0x1ot?7ui_$d}Dnz8Q#@oU2h-Ujv|3lpSJW)G&VF3PWy5A zT5U0=^pmq^x(Img>Ps`- ze(EP*&(~hwgiYC}9cyhm~m(uZ+;4K6cSr6IInH|{_op0Z-Q;{3M9$V!ZhnsQj>vW8IPZnr z${go?kn7EH-UYcqIM2v?uo(D+eq~clb^F;fb^qG!Wp($iemK(^QvF1xTSM*krT#wZ z(~Row%bu_w%~PAQHd0Gq4#(Rl7d-Cm2ldy%_<`!erd#&y8jIe$H)zUCTryAk=! z>CT?m=jO~O>;vd?Us$px^g;B$Ec<=8@!pDT>?=Xo??9gW!eeVg-;RD{W-8Ttk?#U= zE=6SXY1X3I58e&)k8|>Fs2k67@EaVu``8Ost$M0E)_V{7nbW6R`MwvO{9E>W(H}W_ zwEG&WVBY(IbM(^|aSkBgd1R(F?|taR>4|v<(cPc+_x+)6ykBPWRheDg_et2#WO6T~^VNN)L_G8NyBGex&zrWUzq;=j zx$D`5yb@iV9~!!&|3CBk;&XKWTZY|U_Ww#=AIj)`&iJ;%;{|M{)a=G?y$=ORX* zf8eQYK9I9k$`OA7vhknE;#`U>w-A_*wUZ0~%aQZ>yaYY+(dHcMEAJe0dnquEdA*F$ z$GqgP0CMJ~9P7Ls**FKu%YDBB$Sne$oQ7N%aP4J4d*tlAF7S6^Mr=o8d$+F!OTiZ6 z*t4(9IC&rYE9z}**H{nqbAO8&PI%Fwm6SX$nL>j z*B<%38QJ_cgUIhK$nvqqO~`TsAo9Bz`G4iN1zuasy#-lr5JXSjifl}6k>6Hi^Rw@@ z$NX)`=C0j(`;L)|TyIB~3;(wv%iRIO=k3UOpF7dzwu3?HZEwE=IM?@pcJq>dConJT ztB*POUX!+?|7S9c@pBOiBv7qW4e0qt@3N0GHf&Bl?(K=jE3asu*w{4R9)@Tq4$ zHO7cDi7X#JQ<+b`-x_f8k>hUU$kF-Q!*);3=KA`EZBNc-{`$rpes^YD&ZysB;8|PO zDx>zu`TfY&BYNrs$a053^veg4qVhl@3g;XHjK|0Brm>3;gaoE}JJ{wFa;9&*vYA49f(9|Xp*e?Jc7qkkXD zIC&q}w|`^JPas>fSo4#}&XJFI#;1^7XB~JL#N4CE+G6e_$j+A!?onj(jvc z9mw|inT*pn_9++Ze-=5`&-dTw;I%zVoOz6&2Xe=N-<`)8ef;id`vQ>jTcaHNcpUiz z@J@Fyei6vU`d>nh_49Y+%kbKw-@bzET={rEeihl8UjekoyYy?w=K<}}>t9FKcRA3$ z5a?q~jx%bHoWFrA7y1*K9=UuIT_5@2op2;|QAM?-m*LTrfC+^0x$Z~ 0.5) { float rawFog = clamp(1.0 - exp(-viewDistance * global.params.y), 0.0, 1.0); float fogFactor = rawFog * rawFog * 0.72 * atmosphericVisibility; - if (isLOD) { - float lodEdgeFog = smoothstep(0.65, 1.0, vLODFade) * rawFog * atmosphericVisibility; - float lodHorizonFog = smoothstep(420.0, 1400.0, viewDistance) * atmosphericVisibility; - fogFactor = max(fogFactor, max(lodEdgeFog * 0.9, lodHorizonFog * 0.82)); - } color = mix(color, global.fog_color.rgb, fogFactor); } diff --git a/assets/shaders/vulkan/terrain.frag.spv b/assets/shaders/vulkan/terrain.frag.spv index 4856b59781d1f7379b585e245d6637bf0631d14f..d15544a4ee550537b484f86872d66131339c5ba2 100644 GIT binary patch delta 7575 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 RF68^Pt83>SdCHPg{s-Ko1%dzo delta 8192 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 GPx&A9sTQ&T diff --git a/assets/shaders/vulkan/terrain.vert b/assets/shaders/vulkan/terrain.vert index 95fe35ca..d926f844 100644 --- a/assets/shaders/vulkan/terrain.vert +++ b/assets/shaders/vulkan/terrain.vert @@ -89,7 +89,9 @@ void main() { float lod_fade; vec3 color_override; - if (model_data.mask_radius < 0.0) { + // Color alpha is reserved as the indirect-draw sentinel. Signed mask + // radii encode the dynamic ready-detail disk and are valid direct values. + if (model_data.color_override.w < 0.0) { InstanceData inst = instance_buf.instances[gl_InstanceIndex]; model = inst.model; mask_radius = inst.mask_radius; diff --git a/assets/shaders/vulkan/terrain.vert.spv b/assets/shaders/vulkan/terrain.vert.spv index ba3158423774560f48a390650475572e43f06975..2b7fe0b6ca6be47e5c4482a55ee7ba5391183675 100644 GIT binary patch delta 3773 zcmZ9N+iz4=6voe)8z3}L2QtD)OGO_D8mw9qBqeGzK-5ss_#lbXLuXpXnRbRL1qF2| z7p)?fy1fGeUZ|i5IHDk;MFlU!ME(F1dE`x>yd)%kzcXjk6Hc?b*Y~Zj&l+6{jCiJ(;?2V*kZ;7PlV~Yre04jTYgAQ}DAFfO^8&3x4(j*{5N@HhaNbiX^A^%CbcHyuPm7 z{R7?IIURu7dQC$#HN7M&yJ5|&9OxF*uH&4FX^*DoZxymT#>EeTz5YU`;$`y#{bJ(I zHgUyLHq-58b5g}5oSkMknrho4w60VsW;;s#bxX|eoEO3eW#}sA%TPp)nBP^^2=02; z=5y;Z5l60Nv3jn!EZwD^OjoH` z;{NK^!B}b1nem(67>WHWOswaynme*!Vm3&XWuYTW$#Pt+QD?JSj{XA-hA!#!X*<4s6b$-pRMd04VYsQwx_ zPKS@94x<>AES3$O=LQLxEhe64kvthr?Bqgkkx;kAH%aFaUg&VI6D4!&ggGDT(D8YTtYtPsZp<9EB|%;3ai zl~XXeSUO~({Y-6(gva8s>xeo`XuaEUU&oIsc&NZmFq~3e<%ofcj&UYAsnH#+-7;xn zbPld_wS7XX7r4z5UMZd9)0QS(&MLEHa=MxqqIrB~VGCMUNbm~FOl`-kVsDbpEP~0b zx70rf+=2izixf&F8Ledr5B{sQQlHK`Qd|W za<13s_3V^I)sKWIbwq*}_)x&H%i%z0*BgaD*GljC32aii3<5jCVMB=fLMuncPF^sd zGdS$0>iRub%RL+nQ z%=W3x?hbQg>Qeo`GId^+lnYcSA@`~2E46k>o|JS-JW0DsrFU5gwv&2G7i!G~T)Wo4 z)utY;JpaibTeoCb@{c-;P99zgek_@L?N{}Oen5aXhqnbIj=ThGCD@i}!xdWB1)LTI z$Nm02Bb}ZSyHG`d@gs(!*GtHwGx{;~c!PwPPQ8eJ5Co1k=vnCmF%fJ;(DM?4cmvr8 zjGv8BDnBNw)y51;CuXCB7%+Y|W|Ix#m9;@zq!YARLJ)Y9#Gd#C>DW0jouV@@O5pKl zqQExaOVaW0l-R3$Svq``07eeN2D~Dj08VNHwo8Y{pOFNkGmd;y{#CEIf9{$)Cl{X; zBVm@p#l9Bsim&?BLJ+|4y)Gdjy!jgeZ;J)nLfWJg%Qx#S39+3L3g_$bwgi5urh)I( zI{^ppUj~E0J=@dZ5Izm3vTfcgo%VuBwqLSOV(;@nz-hV4y%%uoWBuL_xCr;(p_h+J zKah}s030~kuug{ULkXMXL!rF&aGdnoAfj3;iNTTeBjB+=LgREpOnr%>~V_zeVR*aw zIq7gV@sHApIWMt!f07PweSVgHFs6T=IWVJn4%?Cjw_haGJF4}9#D@GT{iwM-W43$L zG^Fp0)7L9VZ2^lq zvdJb$y`l($xKKc4vsJN*#RUb4iTvP?Ch`Z6=ua*QiQmtgcj2Y4`OZD(dzO3dx#zuZ zTfTYuo~J5S2LgdZ!so*HndExM$(Yq;v8s%8p6+}qozoi8U&b6Pi^*PJ7MqVfC?tB5 zsYcfx4#0&x?mn$yX<4n=p1yu9(j$Q`H+vVV9)I>OH+vV^tFTYZ-esRt$QxS^K#abqGB)b#6ii;=`j`Q~o^dwT*f;e(53zc$(W$Dak z5*_(mUdQ*W8$z)v;wA~b$wIPQRhHsTgkrO?Oc4s{o>aP!=*%TE?kP%jTn)vYVRJnc z({EL{*7dHCHx>8h3;l_{>>%y%>Ilc&s;7y`^{z?e3)xIot>?aviOb~j?MYn}{XA)g z!qMtcmrb;$3s0vD{pod5-CU#QTsSrxtK)g(vpxM!mNt{eq`;{8D;#%5O+_SL!Hn|I z+3}I4$mOz1J4m!-u?$`2%;adZW#_o=o<%a7z&#|m-dbrr+#{AV$tlqy85m_Oj|+>W zt>I^>*naTIoIi@@iBr7tfAx*Z^^#Y30b+$&1eC3l96) zlKkk6a>qO)i3q@9+~eq5MDhi}UL5*G&+hta&g7%-K$MT>Y13V5yeEY|A z46|WlUY1M@uP8C-_$j97ma{<$K_S5gZIVpTMu8x7{IGMxEdq9qNNZ@tD*`KH-V%tlCtV?VpT{p%zo$rk+vA*l zrhaC$Nc~X$`Z^qVv5uNaNg+qe9^}`;gsC+6N5AVF?7_ zz*&ZL7K|SXY%VAFti054ob|ZYkA!1_zp)>CJjIgZ6M;CJ^Mqu4;OXBHrU23=Jtdi> zgM#hAY02<5=^4rXq@PO12cD#SUEr+$7m|+%?7?UEOM!1Sg|`$H0D%+l|CrXV1nbZ! znM9Mde(l+co_@~bY}_}}{c#inhaa0aXgx1b%sm2mizNP5I4@YI?MZxnir)E{nWq 0.5) { float rawFog = clamp(1.0 - exp(-vDistance * global.params.y), 0.0, 1.0); float fogBlend = max(rawFog * rawFog * 0.65, water_mass * 0.28); - if (isLOD) fogBlend = max(fogBlend, smoothstep(260.0, 1000.0, vDistance) * 0.56); waterColor = mix(waterColor, global.fog_color.rgb, fogBlend); } diff --git a/assets/shaders/vulkan/water.frag.spv b/assets/shaders/vulkan/water.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..2ddc2be5f36c3ace2a09ac24558e82dca4fc82e4 GIT binary patch literal 20796 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 MAX_LOD_LEVELS * 2; const water = stream % (MAX_LOD_LEVELS * 2) >= MAX_LOD_LEVELS; const command = if (water) candidate.water_command else candidate.terrain_command; + var expected_model = candidate.model; + if (!water) expected_model.data[3][1] -= 0.05; if (!compact) { const command_offset = (if (water) self.validation_layout.water_commands_offset else self.validation_layout.terrain_commands_offset) + output_index * @sizeOf(rhi.DrawIndirectCommand); const actual_command: *const rhi.DrawIndirectCommand = @ptrCast(@alignCast(bytes + command_offset)); @@ -332,7 +334,7 @@ const LODCullingSystem = struct { const instance_offset = (if (water) self.validation_layout.water_instances_offset else self.validation_layout.terrain_instances_offset) + output_index * @sizeOf(rhi.InstanceData); const actual_instance: *const rhi.InstanceData = @ptrCast(@alignCast(bytes + instance_offset)); const expected_instance = rhi.InstanceData{ - .model = candidate.model, + .model = expected_model, .mask_radius = candidate.instance_params[0], .lod_fade = candidate.instance_params[1], .padding = .{ candidate.instance_params[2], candidate.instance_params[3] }, @@ -352,7 +354,7 @@ const LODCullingSystem = struct { const instance_offset = (if (water) self.validation_layout.compact_water_instances_offset else self.validation_layout.compact_terrain_instances_offset) + output_index * @sizeOf(rhi.CompactLODInstance); const actual_instance: *const rhi.CompactLODInstance = @ptrCast(@alignCast(bytes + instance_offset)); const expected_instance = rhi.CompactLODInstance{ - .model = candidate.model, + .model = expected_model, .params = candidate.instance_params, .words = candidate.compact_words, }; diff --git a/modules/engine-graphics/src/vulkan/resource_manager.zig b/modules/engine-graphics/src/vulkan/resource_manager.zig index c0064857..ba3390b0 100644 --- a/modules/engine-graphics/src/vulkan/resource_manager.zig +++ b/modules/engine-graphics/src/vulkan/resource_manager.zig @@ -209,6 +209,10 @@ pub const ResourceManager = struct { self.transfer.resetTransferState(); } + pub fn abortCurrentFrame(self: *ResourceManager) void { + self.transfer.abortCurrentFrame(self.vulkan_device.vk_device); + } + pub fn prepareTransfer(self: *ResourceManager) !c.VkCommandBuffer { return self.transfer.prepareTransfer(); } diff --git a/modules/engine-graphics/src/vulkan/rhi_draw_submission.zig b/modules/engine-graphics/src/vulkan/rhi_draw_submission.zig index 5b7ab64d..ebf89a34 100644 --- a/modules/engine-graphics/src/vulkan/rhi_draw_submission.zig +++ b/modules/engine-graphics/src/vulkan/rhi_draw_submission.zig @@ -17,6 +17,17 @@ const ModelUniforms = extern struct { mask_radius: f32, }; +/// Push constants for an indirect draw that must fetch instance data from the +/// bound instance buffer. Alpha is the shader's indirect sentinel so signed +/// LOD handoff masks remain valid direct-draw values. +pub fn indirectModelUniforms() ModelUniforms { + return .{ + .model = Mat4.identity, + .color = .{ 1.0, 1.0, 1.0, -1.0 }, + .mask_radius = 0.0, + }; +} + const ShadowModelUniforms = extern struct { mvp: Mat4, bias_params: [4]f32, @@ -222,11 +233,7 @@ pub fn drawIndirect(ctx: anytype, handle: rhi.BufferHandle, command_buffer: rhi. }; c.vkCmdPushConstants(cb, ctx.pipeline_manager.pipeline_layout, c.VK_SHADER_STAGE_VERTEX_BIT | c.VK_SHADER_STAGE_FRAGMENT_BIT, 0, @sizeOf(ShadowModelUniforms), &shadow_uniforms); } else { - const uniforms = ModelUniforms{ - .model = Mat4.identity, - .color = .{ 1.0, 1.0, 1.0, 1.0 }, - .mask_radius = -1.0, - }; + const uniforms = indirectModelUniforms(); c.vkCmdPushConstants(cb, ctx.pipeline_manager.pipeline_layout, c.VK_SHADER_STAGE_VERTEX_BIT | c.VK_SHADER_STAGE_FRAGMENT_BIT, 0, @sizeOf(ModelUniforms), &uniforms); } @@ -302,7 +309,7 @@ pub fn drawIndirectCount(ctx: anytype, handle: rhi.BufferHandle, command_buffer: } const descriptor_set = if (ctx.draw.lod_mode) lodDescriptorSet(ctx) else ctx.descriptors.descriptor_sets[ctx.frames.current_frame]; c.vkCmdBindDescriptorSets(cb, c.VK_PIPELINE_BIND_POINT_GRAPHICS, ctx.pipeline_manager.pipeline_layout, 0, 1, &descriptor_set, 0, null); - const uniforms = ModelUniforms{ .model = Mat4.identity, .color = .{ 1.0, 1.0, 1.0, 1.0 }, .mask_radius = -1.0 }; + const uniforms = indirectModelUniforms(); c.vkCmdPushConstants(cb, ctx.pipeline_manager.pipeline_layout, c.VK_SHADER_STAGE_VERTEX_BIT | c.VK_SHADER_STAGE_FRAGMENT_BIT, 0, @sizeOf(ModelUniforms), &uniforms); const offsets = [_]c.VkDeviceSize{0}; c.vkCmdBindVertexBuffers(cb, 0, 1, &vbo.buffer, &offsets); diff --git a/modules/engine-graphics/src/vulkan/rhi_state_control.zig b/modules/engine-graphics/src/vulkan/rhi_state_control.zig index e8cfe86a..484ca230 100644 --- a/modules/engine-graphics/src/vulkan/rhi_state_control.zig +++ b/modules/engine-graphics/src/vulkan/rhi_state_control.zig @@ -107,7 +107,17 @@ pub fn recover(ctx: anytype) !void { ctx.draw.descriptors_updated = false; ctx.draw.bound_texture = 0; - _ = c.vkDeviceWaitIdle(ctx.vulkan_device.vk_device); + const idle_result = c.vkDeviceWaitIdle(ctx.vulkan_device.vk_device); + if (idle_result != c.VK_SUCCESS) { + // VK_ERROR_DEVICE_LOST is terminal for this logical device. Recreating + // only swapchain resources on it is invalid and previously produced a + // misleading second "recovery failed" error. Full device/resource + // reconstruction must happen through a clean application restart. + log.log.err("RHI: Lost logical device cannot be recovered in place (vkDeviceWaitIdle={d}). Restart required.", .{idle_result}); + ctx.vulkan_device.recovery_fail_count += 1; + ctx.runtime.gpu_fault_detected = true; + return error.GpuLost; + } ctx.runtime.gpu_fault_detected = false; ctx.mutex.lock(); diff --git a/modules/engine-graphics/src/vulkan/transfer_queue.zig b/modules/engine-graphics/src/vulkan/transfer_queue.zig index 060f31e9..3b81a281 100644 --- a/modules/engine-graphics/src/vulkan/transfer_queue.zig +++ b/modules/engine-graphics/src/vulkan/transfer_queue.zig @@ -306,6 +306,31 @@ pub const TransferQueue = struct { self.transfer_submitted[self.current_frame] = false; } + fn discardPendingState(self: *TransferQueue, frame_index: usize) void { + self.pending_copy_count[frame_index] = 0; + self.pending_staging_buffer[frame_index] = null; + self.pending_dst_access_mask[frame_index] = 0; + self.transfer_ready[frame_index] = false; + self.transfer_submitted[frame_index] = false; + } + + /// Discards transfer commands recorded for a graphics frame that will not + /// be submitted. The staging allocation remains owned by the frame slot and + /// is reclaimed normally when that slot's fence boundary is reused. + pub fn abortCurrentFrame(self: *TransferQueue, vk_device: c.VkDevice) void { + const frame_index = self.current_frame; + if (self.transfer_submitted[frame_index] and self.is_dedicated) { + self.waitForFrameFence(vk_device, frame_index); + } + if (self.transfer_ready[frame_index]) { + const result = c.vkResetCommandBuffer(self.command_buffers[frame_index], 0); + if (result != c.VK_SUCCESS) { + log.log.err("Failed to reset aborted transfer command buffer: {d}", .{result}); + } + } + self.discardPendingState(frame_index); + } + pub fn endTransferCommandBuffer(self: *TransferQueue) !void { if (!self.transfer_ready[self.current_frame]) return; const cb = self.command_buffers[self.current_frame]; @@ -372,6 +397,24 @@ test "staging ring distinguishes full from empty" { try std.testing.expectEqual(@as(u64, 0), ring.allocated()); } +test "aborted transfer state drops pending copies without reclaiming staging ownership" { + var transfer = std.mem.zeroes(TransferQueue); + transfer.current_frame = 1; + transfer.pending_copy_count[1] = 7; + transfer.pending_staging_buffer[1] = @ptrFromInt(1); + transfer.pending_dst_access_mask[1] = c.VK_ACCESS_INDEX_READ_BIT; + transfer.transfer_ready[1] = true; + transfer.transfer_submitted[1] = true; + + transfer.discardPendingState(1); + + try std.testing.expectEqual(@as(usize, 0), transfer.pending_copy_count[1]); + try std.testing.expect(transfer.pending_staging_buffer[1] == null); + try std.testing.expectEqual(@as(c.VkAccessFlags, 0), transfer.pending_dst_access_mask[1]); + try std.testing.expect(!transfer.transfer_ready[1]); + try std.testing.expect(!transfer.transfer_submitted[1]); +} + test "staging ring reclaims wrapped frame regions" { var memory: [1024]u8 = undefined; var ring = StagingRing{ diff --git a/modules/engine-graphics/src/vulkan_device.zig b/modules/engine-graphics/src/vulkan_device.zig index 32f8f337..83993dec 100644 --- a/modules/engine-graphics/src/vulkan_device.zig +++ b/modules/engine-graphics/src/vulkan_device.zig @@ -63,7 +63,8 @@ pub const VulkanDevice = struct { // Extension function pointers vkGetDeviceFaultInfoEXT: ?*const fn ( device: c.VkDevice, - pFaultInfo: *c.VkDeviceFaultInfoEXT, + pFaultCounts: *c.VkDeviceFaultCountsEXT, + pFaultInfo: ?*c.VkDeviceFaultInfoEXT, ) callconv(.c) c.VkResult = null, fault_count: u32 = 0, @@ -493,7 +494,7 @@ pub const VulkanDevice = struct { if (result == c.VK_ERROR_DEVICE_LOST) { self.fault_count += 1; - log.log.err("GPU reset triggered voluntarily (VK_ERROR_DEVICE_LOST). Total faults: {d}", .{self.fault_count}); + log.log.err("Vulkan reported VK_ERROR_DEVICE_LOST during queue submission. Total faults: {d}", .{self.fault_count}); self.logDeviceFaults(); return error.GpuLost; } @@ -510,13 +511,37 @@ pub const VulkanDevice = struct { log.log.info("Querying VK_EXT_device_fault for detailed hang info...", .{}); + var fault_counts = std.mem.zeroes(c.VkDeviceFaultCountsEXT); + fault_counts.sType = c.VK_STRUCTURE_TYPE_DEVICE_FAULT_COUNTS_EXT; + const count_result = func(self.vk_device, &fault_counts, null); + if (count_result != c.VK_SUCCESS) { + log.log.warn("Failed to query device fault record counts: {d}", .{count_result}); + return; + } + + const address_infos = self.allocator.alloc(c.VkDeviceFaultAddressInfoEXT, fault_counts.addressInfoCount) catch { + log.log.warn("Failed to allocate device fault address records.", .{}); + return; + }; + defer self.allocator.free(address_infos); + const vendor_infos = self.allocator.alloc(c.VkDeviceFaultVendorInfoEXT, fault_counts.vendorInfoCount) catch { + log.log.warn("Failed to allocate device fault vendor records.", .{}); + return; + }; + defer self.allocator.free(vendor_infos); + + // Vendor binaries can be arbitrarily large and are intended for + // external crash-dump tooling. Keep runtime fault reporting bounded. + fault_counts.vendorBinarySize = 0; var fault_info = std.mem.zeroes(c.VkDeviceFaultInfoEXT); fault_info.sType = c.VK_STRUCTURE_TYPE_DEVICE_FAULT_INFO_EXT; + fault_info.pAddressInfos = if (address_infos.len == 0) null else address_infos.ptr; + fault_info.pVendorInfos = if (vendor_infos.len == 0) null else vendor_infos.ptr; - const result = func(self.vk_device, &fault_info); - if (result == c.VK_SUCCESS) { + const result = func(self.vk_device, &fault_counts, &fault_info); + if (result == c.VK_SUCCESS or result == c.VK_INCOMPLETE) { const desc: [*:0]const u8 = @ptrCast(&fault_info.description); - log.log.err("GPU Fault Detected: {s}", .{desc}); + log.log.err("GPU Fault Detected: {s} (addresses: {d}, vendor records: {d})", .{ desc, fault_counts.addressInfoCount, fault_counts.vendorInfoCount }); } else { log.log.warn("Failed to retrieve device fault info: {d}", .{result}); } diff --git a/modules/engine-graphics/src/vulkan_device_internal_tests.zig b/modules/engine-graphics/src/vulkan_device_internal_tests.zig index f047625f..999dd67c 100644 --- a/modules/engine-graphics/src/vulkan_device_internal_tests.zig +++ b/modules/engine-graphics/src/vulkan_device_internal_tests.zig @@ -169,7 +169,7 @@ test "VulkanDevice vkGetDeviceFaultInfoEXT defaults to null" { .allocator = testing.allocator, }; - try testing.expectEqual(@as(?*const fn (c.VkDevice, *c.VkDeviceFaultInfoEXT) callconv(.c) c.VkResult, null), device.vkGetDeviceFaultInfoEXT); + try testing.expectEqual(@as(?*const fn (c.VkDevice, *c.VkDeviceFaultCountsEXT, ?*c.VkDeviceFaultInfoEXT) callconv(.c) c.VkResult, null), device.vkGetDeviceFaultInfoEXT); } test "VulkanDevice fault_count defaults to zero" { diff --git a/modules/engine-input/src/input.zig b/modules/engine-input/src/input.zig index e34a791d..11eccea4 100644 --- a/modules/engine-input/src/input.zig +++ b/modules/engine-input/src/input.zig @@ -89,6 +89,13 @@ pub const Input = struct { pub fn pollEvents(self: *Input) void { var event: c.SDL_Event = undefined; while (c.SDL_PollEvent(&event)) { + // Quit-class events belong to the application, not an optional UI + // event sink. In particular, RmlUi must not be able to consume a + // compositor window-close request before game input observes it. + if (isQuitEvent(event.type)) { + self.should_quit = true; + continue; + } if (self.raw_event_processor) |processor| { if (processor.process(processor.context, &event)) continue; } @@ -107,9 +114,11 @@ pub const Input = struct { return false; } - fn processEvent(self: *Input, event: c.SDL_Event) void { + /// Updates input state from one SDL event. Public for focused tests and + /// platforms that forward SDL events instead of using `pollEvents`. + pub fn processEvent(self: *Input, event: c.SDL_Event) void { switch (event.type) { - c.SDL_EVENT_QUIT => { + c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => { self.should_quit = true; }, c.SDL_EVENT_KEY_DOWN => { @@ -160,6 +169,10 @@ pub const Input = struct { } } + fn isQuitEvent(event_type: u32) bool { + return event_type == c.SDL_EVENT_QUIT or event_type == c.SDL_EVENT_WINDOW_CLOSE_REQUESTED; + } + /// Record a key press, keeping `keys_pressed` and `keys_down` consistent. /// `keys_pressed` is updated first; if it fails the press is dropped entirely. /// If `keys_down` then fails, `keys_pressed` is rolled back so `isKeyDown` diff --git a/modules/engine-input/src/input_tests.zig b/modules/engine-input/src/input_tests.zig index 51441d8f..8a299bec 100644 --- a/modules/engine-input/src/input_tests.zig +++ b/modules/engine-input/src/input_tests.zig @@ -117,3 +117,16 @@ test "raw event processor retains its explicit context" { try testing.expect(!input.dispatchRawEvent(&event)); try testing.expectEqual(@as(u32, 1), receiver.calls); } + +test "quit and window close events request shutdown" { + var input = Input.init(testing.allocator); + defer input.deinit(); + + inline for (.{ c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED }) |event_type| { + input.should_quit = false; + var event = std.mem.zeroes(c.SDL_Event); + event.type = event_type; + input.processEvent(event); + try testing.expect(input.interface().shouldQuit()); + } +} diff --git a/modules/engine-rhi/src/render_settings.zig b/modules/engine-rhi/src/render_settings.zig index 3b25b502..62052671 100644 --- a/modules/engine-rhi/src/render_settings.zig +++ b/modules/engine-rhi/src/render_settings.zig @@ -93,7 +93,7 @@ pub const RENDER_DISTANCE_PRESETS = [_]RenderDistancePresetConfig{ .horizon_radius = 512, .lod_store_size_cap_mb = 1536, .horizontal_detail = .{ 33, 65, 65, 97, 97 }, - .sample_density = .{ 1.0, 1.0, 1.0, 0.5, 1.0 }, + .sample_density = .{ 1.0, 1.0, 1.0, 0.5, 0.5 }, .vertical_span_budget = 3, .mesh_path = .column_spans, .fog_start_percent = .{ 0.5, 0.5, 0.4, 0.3, 0.22 }, @@ -106,11 +106,11 @@ pub const RENDER_DISTANCE_PRESETS = [_]RenderDistancePresetConfig{ .show_warning = false, }, .{ - .lod_radii = .{ 14, 64, 156, 375, 1024 }, - .horizon_radius = 1024, + .lod_radii = .{ 14, 64, 156, 375, 512 }, + .horizon_radius = 512, .lod_store_size_cap_mb = 3072, .horizontal_detail = .{ 33, 65, 65, 129, 129 }, - .sample_density = .{ 1.0, 1.0, 1.0, 0.5, 1.0 }, + .sample_density = .{ 1.0, 1.0, 1.0, 0.5, 0.5 }, .vertical_span_budget = 4, .mesh_path = .column_spans, .fog_start_percent = .{ 0.5, 0.5, 0.4, 0.3, 0.2 }, @@ -123,11 +123,11 @@ pub const RENDER_DISTANCE_PRESETS = [_]RenderDistancePresetConfig{ .show_warning = false, }, .{ - .lod_radii = .{ 16, 64, 156, 375, 2048 }, - .horizon_radius = 2048, + .lod_radii = .{ 16, 64, 156, 375, 512 }, + .horizon_radius = 512, .lod_store_size_cap_mb = 4096, .horizontal_detail = .{ 33, 65, 65, 129, 129 }, - .sample_density = .{ 1.0, 1.0, 1.0, 0.5, 1.0 }, + .sample_density = .{ 1.0, 1.0, 1.0, 0.5, 0.5 }, .vertical_span_budget = 4, .mesh_path = .column_spans, .fog_start_percent = .{ 0.5, 0.5, 0.4, 0.3, 0.18 }, @@ -145,6 +145,15 @@ pub fn getPresetConfig(preset: RenderDistancePreset) RenderDistancePresetConfig return RENDER_DISTANCE_PRESETS[@intFromEnum(preset)]; } +test "512 chunk presets use a budget-feasible coarse fallback grid" { + const std = @import("std"); + for (RENDER_DISTANCE_PRESETS[1..]) |preset| { + try std.testing.expectEqual(@as(i32, 512), preset.horizon_radius); + try std.testing.expectEqual(@as(f32, 0.5), preset.sample_density[@intFromEnum(LODLevel.lod4)]); + } + try std.testing.expectEqual(@as(f32, 1.0), RENDER_DISTANCE_PRESETS[0].sample_density[@intFromEnum(LODLevel.lod4)]); +} + pub const RenderSettingsAdapter = struct { rhi: *RHI, diff --git a/modules/game-core/src/session.zig b/modules/game-core/src/session.zig index 20773593..ab4a895a 100644 --- a/modules/game-core/src/session.zig +++ b/modules/game-core/src/session.zig @@ -81,11 +81,20 @@ const SpawnColumn = struct { info: @import("world-worldgen").ColumnInfo, }; -/// The explicit numeric render-distance setting owns full-detail reach. -/// Quality and safe-mode profiles tune implementation budgets without silently -/// reducing the player's requested value. -pub fn fullDetailRenderDistance(render_distance: i32) i32 { - return World.effectiveChunkRenderRadius(render_distance); +/// Keeps the projection volume large enough for the configured coarsest LOD +/// radius plus one outer-region margin. A fixed 10,000-block far plane clips a +/// 1,024-chunk horizon at roughly 625 chunks even when those regions are loaded. +pub fn cameraFarPlaneForHorizon(horizon_distance_chunks: i32) f32 { + const horizon_chunks: i64 = @max(horizon_distance_chunks, 1); + const horizon_blocks = horizon_chunks * 16; + const outer_region_margin: i64 = 1024; + return @floatFromInt(@max(horizon_blocks + outer_region_margin, 10_000)); +} + +/// Uses the effective horizon so a manually lowered horizon setting cannot +/// clip a larger full-detail render distance. +pub fn cameraFarPlaneForDistances(render_distance_chunks: i32, horizon_distance_chunks: i32) f32 { + return cameraFarPlaneForHorizon(@max(render_distance_chunks, horizon_distance_chunks)); } pub const GameSession = struct { @@ -138,7 +147,7 @@ pub const GameSession = struct { const safe_mode = runtime_env.safeModeEnabled(); const strict_safe_mode = runtime_env.strictSafeModeEnabled(); - const effective_render_distance: i32 = @max(render_distance, 2); + const effective_render_distance: i32 = render_distance; const chunk_debug_restore_lod = chunkDebugRestoreEnabled(build_config, "lod"); const effective_lod_enabled = if (build_config.chunk_debug_mode) chunk_debug_restore_lod @@ -158,18 +167,20 @@ pub const GameSession = struct { const effective_horizon_distance = LODConfig.normalizeHorizonDistance(effective_render_distance, horizon_distance); const manual_distance_expanded = effective_render_distance > preset_cfg.lod_radii[0] or effective_horizon_distance != preset_cfg.horizon_radius; - const chunk_render_radius = fullDetailRenderDistance(effective_render_distance); + const chunk_render_radius = if (strict_safe_mode) + @min(effective_render_distance, 8) + else if (effective_lod_enabled and manual_distance_expanded) + @min(effective_render_distance, preset_cfg.lod_radii[0]) + else + effective_render_distance; var preset_radii = if (strict_safe_mode) LODConfig.radiiForDistances(chunk_render_radius, @max(effective_horizon_distance, 64)) else if (effective_lod_enabled and manual_distance_expanded) - LODConfig.radiiForDistances(effective_render_distance, effective_horizon_distance) + LODConfig.radiiForDistances(chunk_render_radius, effective_horizon_distance) else preset_cfg.lod_radii; - const active_count = if (effective_lod_enabled 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) { @@ -252,6 +263,7 @@ pub const GameSession = struct { const spawn = findActualSpawnColumn(world_sim, seed_spawn.x, seed_spawn.z) orelse seed_spawn; const spawn_y: f32 = @floatFromInt(spawn.info.height + 16); var player = Player.init(Vec3.init(@floatFromInt(spawn.x), spawn_y, @floatFromInt(spawn.z)), true); + player.camera.far = cameraFarPlaneForDistances(effective_render_distance, effective_horizon_distance); // Aim toward the terrain so the first frame shows the ground. player.camera.setYawPitch(player.camera.yaw, -std.math.degreesToRadians(35.0)); @@ -528,7 +540,7 @@ pub const GameSession = struct { for (20..31) |x| for (0..10) |z| { try world_sim.setBlock(@intCast(x), 65, @intCast(z), .water); }; - } else if (std.ascii.eqlIgnoreCase(scene, "lod-handoff") or std.ascii.eqlIgnoreCase(scene, "lod-handoff-traversal") or std.ascii.eqlIgnoreCase(scene, "teleport-handoff")) { + } else if (std.ascii.eqlIgnoreCase(scene, "lod-handoff") or std.ascii.eqlIgnoreCase(scene, "lod-aerial") or std.ascii.eqlIgnoreCase(scene, "lod-handoff-traversal") or std.ascii.eqlIgnoreCase(scene, "teleport-handoff")) { const motion_scene = std.ascii.eqlIgnoreCase(scene, "lod-handoff-traversal") or std.ascii.eqlIgnoreCase(scene, "teleport-handoff"); const base_x: i32 = if (motion_scene) -130 else -2; const base_z: i32 = if (motion_scene) -24 else 8; @@ -741,6 +753,7 @@ pub fn parsePhase5VisualScene(name: []const u8) ?Phase5VisualScene { if (std.ascii.eqlIgnoreCase(name, "seam")) return .{ .position = Vec3.init(16.0, 74.0, -18.0), .yaw = forward_z, .pitch = -std.math.degreesToRadians(15.0) }; if (std.ascii.eqlIgnoreCase(name, "water")) return .{ .position = Vec3.init(25.0, 75.0, -16.0), .yaw = forward_z, .pitch = -std.math.degreesToRadians(17.0) }; if (std.ascii.eqlIgnoreCase(name, "lod-handoff")) return .{ .position = Vec3.init(0.0, 110.0, -80.0), .yaw = forward_z, .pitch = -std.math.degreesToRadians(13.0) }; + if (std.ascii.eqlIgnoreCase(name, "lod-aerial")) return .{ .position = Vec3.init(0.0, 900.0, -100.0), .yaw = forward_z, .pitch = -std.math.degreesToRadians(60.0) }; if (std.ascii.eqlIgnoreCase(name, "saved-world-create") or std.ascii.eqlIgnoreCase(name, "saved-world-reload")) return .{ .position = Vec3.init(8.0, 78.0, -88.0), .yaw = forward_z, .pitch = -std.math.degreesToRadians(18.0) }; if (std.ascii.eqlIgnoreCase(name, "lod-handoff-traversal")) return .{ .position = Vec3.init(-128.0, 110.0, -32.0), .yaw = 0.0, .pitch = -std.math.degreesToRadians(13.0), .motion = .lod_handoff_traversal }; if (std.ascii.eqlIgnoreCase(name, "fog-rapid-turn")) return .{ .position = Vec3.init(0.0, 110.0, 0.0), .yaw = -std.math.pi, .pitch = -std.math.degreesToRadians(10.0), .motion = .fog_rapid_turn }; @@ -787,12 +800,14 @@ fn phase5MotionEvidence(motion: Phase5VisualMotion) struct { distance: f32, yaw_ } test "Phase 5 visual scene parser exposes bounded motion poses" { + const aerial = parsePhase5VisualScene("lod-aerial").?; const traversal = parsePhase5VisualScene("lod-handoff-traversal").?; const turn = parsePhase5VisualScene("fog-rapid-turn").?; const teleport = parsePhase5VisualScene("teleport-handoff").?; try std.testing.expectEqual(Phase5VisualMotion.lod_handoff_traversal, traversal.motion); try std.testing.expectEqual(Phase5VisualMotion.fog_rapid_turn, turn.motion); try std.testing.expectEqual(Phase5VisualMotion.teleport_handoff, teleport.motion); + try std.testing.expect(aerial.position.y >= 900.0); try std.testing.expect(parsePhase5VisualScene("unbounded-motion") == null); const traversal_end = phase5VisualPoseAtFrame(traversal, phase5MotionFrameTarget(traversal.motion)); diff --git a/modules/game-core/src/settings/data.zig b/modules/game-core/src/settings/data.zig index a9f80627..302686ea 100644 --- a/modules/game-core/src/settings/data.zig +++ b/modules/game-core/src/settings/data.zig @@ -117,7 +117,7 @@ pub const Settings = struct { ui_scale: f32 = 1.0, // Manual UI scale multiplier (0.5 to 2.0) window_width: u32 = 1920, window_height: u32 = 1080, - lod_enabled: bool = false, + lod_enabled: bool = true, render_distance_preset: RenderDistancePreset = .high, texture_pack: []const u8 = "default", environment_map: []const u8 = "default", // "default" or filename.exr/hdr @@ -201,9 +201,9 @@ pub const Settings = struct { .kind = .{ .int_range = .{ .min = 2, .max = std.math.maxInt(i32), .step = 1 } }, }; pub const horizon_distance = SettingMetadata{ - .label = "HORIZON DISTANCE", - .description = "Coarsest LOD radius; scales with full-detail render distance", - .kind = .{ .int_range = .{ .min = 256, .max = std.math.maxInt(i32), .step = 2 } }, + .label = "DISTANT LOD LIMIT", + .description = "Maximum distant-terrain radius from the player", + .kind = .{ .int_range = .{ .min = 256, .max = 512, .step = 2 } }, }; pub const mouse_sensitivity = SettingMetadata{ .label = "SENSITIVITY", diff --git a/modules/game-core/src/settings/tests.zig b/modules/game-core/src/settings/tests.zig index f501f215..7b640320 100644 --- a/modules/game-core/src/settings/tests.zig +++ b/modules/game-core/src/settings/tests.zig @@ -5,6 +5,10 @@ const presets = @import("json_presets.zig"); const persistence = @import("persistence.zig"); const RenderDistancePreset = @import("engine-rhi").RenderDistancePreset; +test "distance terrain is enabled by default" { + try std.testing.expect((Settings{}).lod_enabled); +} + test "Persistence Roundtrip" { const allocator = std.testing.allocator; _ = allocator; diff --git a/modules/game-ui/src/screen.zig b/modules/game-ui/src/screen.zig index 280a7bc6..3406fc5e 100644 --- a/modules/game-ui/src/screen.zig +++ b/modules/game-ui/src/screen.zig @@ -253,14 +253,67 @@ pub const IScreen = struct { } }; +/// Owns the copied inputs needed to construct a screen at a GPU frame boundary. +/// Screen constructors may allocate RmlUi documents, textures, buffers, or a +/// complete world, so input and draw callbacks must queue one of these instead +/// of constructing the replacement while command recording is active. +pub const ScreenFactory = struct { + ptr: *anyopaque, + construct_fn: *const fn (ptr: *anyopaque) anyerror!IScreen, + deinit_fn: *const fn (ptr: *anyopaque) void, + + pub fn construct(self: ScreenFactory) !IScreen { + return self.construct_fn(self.ptr); + } + + pub fn deinit(self: ScreenFactory) void { + self.deinit_fn(self.ptr); + } +}; + +/// Allocates an owned factory payload. `T.construct` must copy everything the +/// returned screen retains; the payload is destroyed immediately after the +/// boundary-time constructor returns. +pub fn makeScreenFactory(comptime T: type, allocator: std.mem.Allocator, payload: T) !ScreenFactory { + const Owned = struct { + allocator: std.mem.Allocator, + payload: T, + }; + const owned = try allocator.create(Owned); + owned.* = .{ .allocator = allocator, .payload = payload }; + + const Adapter = struct { + fn construct(ptr: *anyopaque) anyerror!IScreen { + const self: *Owned = @ptrCast(@alignCast(ptr)); + return self.payload.construct(); + } + + fn deinit(ptr: *anyopaque) void { + const self: *Owned = @ptrCast(@alignCast(ptr)); + if (@hasDecl(T, "deinit")) self.payload.deinit(); + self.allocator.destroy(self); + } + }; + + return .{ + .ptr = owned, + .construct_fn = Adapter.construct, + .deinit_fn = Adapter.deinit, + }; +} + pub const ScreenManager = struct { - allocator: std.mem.Allocator, - stack: std.ArrayListUnmanaged(IScreen), - next_screen: ?union(enum) { + const PendingTransition = union(enum) { push: IScreen, + push_factory: ScreenFactory, pop: void, replace: IScreen, - } = null, + replace_factory: ScreenFactory, + }; + + allocator: std.mem.Allocator, + stack: std.ArrayListUnmanaged(IScreen), + next_screen: ?PendingTransition = null, pub fn init(allocator: std.mem.Allocator) ScreenManager { return .{ @@ -276,59 +329,69 @@ pub const ScreenManager = struct { screen.deinit(); } if (self.next_screen) |next| { - switch (next) { - .push => |s| s.deinit(), - .replace => |s| s.deinit(), - .pop => {}, - } + discardPendingTransition(next); } self.stack.deinit(self.allocator); } pub fn pushScreen(self: *ScreenManager, screen: IScreen) void { - if (self.next_screen) |next| { - switch (next) { - .push => |s| s.deinit(), - .replace => |s| s.deinit(), - .pop => {}, - } - } + self.discardPending(); self.next_screen = .{ .push = screen }; } + pub fn pushScreenFactory(self: *ScreenManager, factory: ScreenFactory) void { + self.discardPending(); + self.next_screen = .{ .push_factory = factory }; + } + pub fn popScreen(self: *ScreenManager) void { - if (self.next_screen) |next| { - switch (next) { - .push => |s| s.deinit(), - .replace => |s| s.deinit(), - .pop => {}, - } - } + self.discardPending(); self.next_screen = .pop; } pub fn setScreen(self: *ScreenManager, screen: IScreen) void { - if (self.next_screen) |next| { - switch (next) { - .push => |s| s.deinit(), - .replace => |s| s.deinit(), - .pop => {}, - } - } + self.discardPending(); self.next_screen = .{ .replace = screen }; } - pub fn update(self: *ScreenManager, dt: f32) !void { + pub fn setScreenFactory(self: *ScreenManager, factory: ScreenFactory) void { + self.discardPending(); + self.next_screen = .{ .replace_factory = factory }; + } + + pub fn hasPendingTransition(self: *const ScreenManager) bool { + return self.next_screen != null; + } + + fn discardPending(self: *ScreenManager) void { + if (self.next_screen) |next| discardPendingTransition(next); + self.next_screen = null; + } + + fn discardPendingTransition(next: PendingTransition) void { + switch (next) { + .push, .replace => |screen| screen.deinit(), + .push_factory, .replace_factory => |factory| factory.deinit(), + .pop => {}, + } + } + + /// Applies ownership-changing screen transitions. Call this only outside a + /// recording frame. If screens own GPU resources, the caller must also wait + /// for submitted work to complete before constructors or destructors run. + pub fn applyPendingTransitions(self: *ScreenManager) !void { while (self.next_screen != null) { const next = self.next_screen.?; self.next_screen = null; switch (next) { .push => |screen| { - if (self.stack.items.len > 0) { - self.stack.items[self.stack.items.len - 1].onExit(); - } - try self.stack.append(self.allocator, screen); - screen.onEnter(); + try self.applyPush(screen); + }, + .push_factory => |factory| { + defer factory.deinit(); + const screen = try factory.construct(); + errdefer screen.deinit(); + try self.applyPush(screen); }, .pop => { if (self.stack.items.len > 0) { @@ -341,22 +404,56 @@ pub const ScreenManager = struct { } }, .replace => |screen| { - while (self.stack.items.len > 0) { - const s = self.stack.pop().?; - s.onExit(); - s.deinit(); - } - try self.stack.append(self.allocator, screen); - screen.onEnter(); + try self.applyReplace(screen); + }, + .replace_factory => |factory| { + defer factory.deinit(); + // Replacement factories commonly create a complete world. + // The caller has already drained GPU work, so release the + // old stack first instead of temporarily retaining two + // worlds and two sets of RmlUi resources. + self.clearStack(); + const screen = try factory.construct(); + errdefer screen.deinit(); + try self.applyPush(screen); }, } } + } + fn applyPush(self: *ScreenManager, screen: IScreen) !void { + if (self.stack.items.len > 0) { + self.stack.items[self.stack.items.len - 1].onExit(); + } + try self.stack.append(self.allocator, screen); + screen.onEnter(); + } + + fn applyReplace(self: *ScreenManager, screen: IScreen) !void { + self.clearStack(); + try self.stack.append(self.allocator, screen); + screen.onEnter(); + } + + fn clearStack(self: *ScreenManager) void { + while (self.stack.items.len > 0) { + const current = self.stack.pop().?; + current.onExit(); + current.deinit(); + } + } + + pub fn updateCurrent(self: *ScreenManager, dt: f32) !void { if (self.stack.items.len > 0) { try self.stack.items[self.stack.items.len - 1].update(dt); } } + pub fn update(self: *ScreenManager, dt: f32) !void { + try self.applyPendingTransitions(); + try self.updateCurrent(dt); + } + pub fn draw(self: *ScreenManager, ui: *UISystem) !void { if (self.stack.items.len > 0) { try self.stack.items[self.stack.items.len - 1].draw(ui); diff --git a/modules/game-ui/src/screens/paused.zig b/modules/game-ui/src/screens/paused.zig index 73510e2f..101dcd26 100644 --- a/modules/game-ui/src/screens/paused.zig +++ b/modules/game-ui/src/screens/paused.zig @@ -49,7 +49,7 @@ pub const PausedScreen = struct { pub fn draw(ptr: *anyopaque, ui: *UISystem) !void { const self: *@This() = @ptrCast(@alignCast(ptr)); const ctx = self.context; - try ctx.screen_manager.drawParentScreen(ptr, ui); + try ctx.screen_manager.drawBackgroundFor(ptr, ui); ui.begin(); defer ui.end(); @@ -81,13 +81,13 @@ pub const PausedScreen = struct { if (Theme.drawButtonFocused(ui, .{ .x = bx, .y = by, .width = bw, .height = bh }, "RESUME", btn_scale, mouse_x, mouse_y, mouse_clicked, .primary, self.focused_action == 0, ui_scale) or (confirm and self.focused_action == 0)) ctx.screen_manager.popScreen(); by += bh + gap; if (Theme.drawButtonFocused(ui, .{ .x = bx, .y = by, .width = bw, .height = bh }, "SETTINGS", btn_scale, mouse_x, mouse_y, mouse_clicked, .secondary, self.focused_action == 1, ui_scale) or (confirm and self.focused_action == 1)) { - const settings_screen = try SettingsScreen.init(ctx.allocator, ctx); - errdefer settings_screen.deinit(settings_screen); - ctx.screen_manager.pushScreen(settings_screen.screen()); + const factory = try Screen.makeScreenFactory(SettingsScreenFactory, ctx.allocator, .{ .context = ctx }); + ctx.screen_manager.pushScreenFactory(factory); } by += bh + gap; if (Theme.drawButtonFocused(ui, .{ .x = bx, .y = by, .width = bw, .height = bh }, "QUIT TO TITLE", btn_scale, mouse_x, mouse_y, mouse_clicked, .ghost, self.focused_action == 2, ui_scale) or (confirm and self.focused_action == 2)) { - ctx.screen_manager.setScreen(try createHomeScreen(ctx)); + const factory = try Screen.makeScreenFactory(HomeScreenFactory, ctx.allocator, .{ .context = ctx }); + ctx.screen_manager.setScreenFactory(factory); } Font.drawTextCentered(ui, "ESC / BACK TO RESUME", panel_x + panel_w * 0.5, shell.footer_y + 12.0 * ui_scale, 0.86 * ui_scale, Theme.muted); } @@ -99,7 +99,7 @@ pub const PausedScreen = struct { fn drawBackground(ptr: *anyopaque, ui: *UISystem) !void { const self: *@This() = @ptrCast(@alignCast(ptr)); - try self.context.screen_manager.drawParentScreen(ptr, ui); + try self.context.screen_manager.drawBackgroundFor(ptr, ui); } pub fn onExit(ptr: *anyopaque) void { @@ -119,3 +119,20 @@ fn createHomeScreen(ctx: EngineContext) !IScreen { const screen = try HomeScreen.init(ctx.allocator, ctx); return screen.screen(); } + +const SettingsScreenFactory = struct { + context: EngineContext, + + pub fn construct(self: *@This()) !IScreen { + const settings_screen = try SettingsScreen.init(self.context.allocator, self.context); + return settings_screen.screen(); + } +}; + +const HomeScreenFactory = struct { + context: EngineContext, + + pub fn construct(self: *@This()) !IScreen { + return createHomeScreen(self.context); + } +}; diff --git a/modules/game-ui/src/screens/rml_paused.zig b/modules/game-ui/src/screens/rml_paused.zig index 797c21d3..c973d750 100644 --- a/modules/game-ui/src/screens/rml_paused.zig +++ b/modules/game-ui/src/screens/rml_paused.zig @@ -47,13 +47,13 @@ pub const RmlPausedScreen = struct { pub fn draw(ptr: *anyopaque, ui: *UISystem) !void { const self: *@This() = @ptrCast(@alignCast(ptr)); - try self.context.screen_manager.drawParentScreen(ptr, ui); + try self.context.screen_manager.drawBackgroundFor(ptr, ui); self.page.draw(ui); } fn drawBackground(ptr: *anyopaque, ui: *UISystem) !void { const self: *@This() = @ptrCast(@alignCast(ptr)); - try self.context.screen_manager.drawParentScreen(ptr, ui); + try self.context.screen_manager.drawBackgroundFor(ptr, ui); } pub fn onEnter(ptr: *anyopaque) void { @@ -72,17 +72,17 @@ pub const RmlPausedScreen = struct { if (std.mem.eql(u8, target_id, "resume")) { self.context.screen_manager.popScreen(); } else if (std.mem.eql(u8, target_id, "settings")) { - const settings_screen = RmlSettingsScreen.init(self.context.allocator, self.context) catch |err| { - log.log.err("RmlUi pause Settings action failed: {}", .{err}); + const factory = Screen.makeScreenFactory(SettingsScreenFactory, self.context.allocator, .{ .context = self.context }) catch |err| { + log.log.err("RmlUi pause Settings request failed: {}", .{err}); return; }; - self.context.screen_manager.pushScreen(settings_screen.screen()); + self.context.screen_manager.pushScreenFactory(factory); } else if (std.mem.eql(u8, target_id, "quit-to-title")) { - const home_screen = RmlHomeScreen.init(self.context.allocator, self.context) catch |err| { - log.log.err("RmlUi pause Quit to Title action failed: {}", .{err}); + const factory = Screen.makeScreenFactory(HomeScreenFactory, self.context.allocator, .{ .context = self.context }) catch |err| { + log.log.err("RmlUi pause Quit to Title request failed: {}", .{err}); return; }; - self.context.screen_manager.setScreen(home_screen.screen()); + self.context.screen_manager.setScreenFactory(factory); } } @@ -90,3 +90,21 @@ pub const RmlPausedScreen = struct { return Screen.makeScreen(@This(), self); } }; + +const SettingsScreenFactory = struct { + context: EngineContext, + + pub fn construct(self: *@This()) !IScreen { + const settings_screen = try RmlSettingsScreen.init(self.context.allocator, self.context); + return settings_screen.screen(); + } +}; + +const HomeScreenFactory = struct { + context: EngineContext, + + pub fn construct(self: *@This()) !IScreen { + const home_screen = try RmlHomeScreen.init(self.context.allocator, self.context); + return home_screen.screen(); + } +}; diff --git a/modules/game-ui/src/screens/rml_settings.zig b/modules/game-ui/src/screens/rml_settings.zig index 032df2ac..5a3281ec 100644 --- a/modules/game-ui/src/screens/rml_settings.zig +++ b/modules/game-ui/src/screens/rml_settings.zig @@ -165,7 +165,7 @@ pub const RmlSettingsScreen = struct { settings.render_distance -= 1; } else if (std.mem.eql(u8, id, "render-distance-next") and settings.render_distance < std.math.maxInt(i32)) { settings.render_distance += 1; - settings.horizon_distance = LODConfig.normalizeHorizonDistance(settings.render_distance, settings.horizon_distance); + settings.horizon_distance = LODConfig.normalizeUserHorizonDistance(settings.render_distance, settings.horizon_distance); } else if (std.mem.eql(u8, id, "horizon-distance-prev") or std.mem.eql(u8, id, "horizon-distance-next")) { settings.horizon_distance = LODConfig.stepHorizonDistance( settings.render_distance, @@ -328,11 +328,12 @@ pub const RmlSettingsScreen = struct { fn appendWorldRows(self: *@This(), out: *std.ArrayList(u8)) !void { var buffer: [32]u8 = undefined; const settings = self.context.settings; + settings.horizon_distance = LODConfig.normalizeUserHorizonDistance(settings.render_distance, settings.horizon_distance); try appendSection(out, self.context.allocator, "DISTANCE"); const render_distance = try std.fmt.bufPrint(&buffer, "{} CHUNKS", .{settings.render_distance}); try appendStepperRow(out, self.context.allocator, "RENDER DISTANCE", "Full-detail chunk radius.", render_distance, "render-distance"); const horizon_distance = try std.fmt.bufPrint(&buffer, "{} CHUNKS", .{settings.horizon_distance}); - try appendStepperRow(out, self.context.allocator, "HORIZON DISTANCE", "Coarsest LOD radius; scales with full-detail distance.", horizon_distance, "horizon-distance"); + try appendStepperRow(out, self.context.allocator, "DISTANT LOD LIMIT", "Maximum distant-terrain radius from the player.", horizon_distance, "horizon-distance"); try appendSection(out, self.context.allocator, "STREAMING"); try appendToggleRow(out, self.context.allocator, "LOD SYSTEM", "Distance terrain streaming.", settings.lod_enabled, "lod"); } diff --git a/modules/game-ui/src/screens/settings.zig b/modules/game-ui/src/screens/settings.zig index 73e53151..43dac3b0 100644 --- a/modules/game-ui/src/screens/settings.zig +++ b/modules/game-ui/src/screens/settings.zig @@ -290,6 +290,7 @@ fn drawCameraTab(ui: *UISystem, settings: anytype, layout: ColumnLayout, row_h: fn drawWorldTab(ui: *UISystem, settings: anytype, rs: anytype, layout: ColumnLayout, row_h: f32, label_scale: f32, value_scale: f32, button_scale: f32, mouse_x: f32, mouse_y: f32, mouse_clicked: bool, scale: f32) void { var num_buf: [32]u8 = undefined; + settings.horizon_distance = LODConfig.normalizeUserHorizonDistance(settings.render_distance, settings.horizon_distance); var y_left = layout.top_y; Theme.drawSectionLabel(ui, layout.left_x, y_left, "DISTANCE", scale); @@ -300,13 +301,13 @@ fn drawWorldTab(ui: *UISystem, settings: anytype, rs: anytype, layout: ColumnLay if (step == .previous and settings.render_distance > 2) settings.render_distance -= 1; if (step == .next and settings.render_distance < std.math.maxInt(i32)) { settings.render_distance += 1; - settings.horizon_distance = LODConfig.normalizeHorizonDistance(settings.render_distance, settings.horizon_distance); + settings.horizon_distance = LODConfig.normalizeUserHorizonDistance(settings.render_distance, settings.horizon_distance); } } y_left += row_h + 8.0 * scale; const horizon_distance_label = std.fmt.bufPrint(&num_buf, "{} CHUNKS", .{settings.horizon_distance}) catch "?"; - if (drawStepperRow(ui, .{ .x = layout.left_x, .y = y_left, .width = layout.col_w, .height = row_h }, "HORIZON DISTANCE", "Coarsest LOD radius; scales with full-detail distance.", horizon_distance_label, label_scale, value_scale, button_scale, mouse_x, mouse_y, mouse_clicked, scale)) |step| { + if (drawStepperRow(ui, .{ .x = layout.left_x, .y = y_left, .width = layout.col_w, .height = row_h }, "DISTANT LOD LIMIT", "Maximum distant-terrain radius from the player.", horizon_distance_label, label_scale, value_scale, button_scale, mouse_x, mouse_y, mouse_clicked, scale)) |step| { settings.horizon_distance = LODConfig.stepHorizonDistance(settings.render_distance, settings.horizon_distance, step == .next); } diff --git a/modules/game-ui/src/screens/world.zig b/modules/game-ui/src/screens/world.zig index 487a1ebe..ea1185bb 100644 --- a/modules/game-ui/src/screens/world.zig +++ b/modules/game-ui/src/screens/world.zig @@ -34,6 +34,19 @@ const settings_data = @import("game-core").settings.data; const world_debug = @import("world_debug.zig"); const world_frame_params = @import("world_frame_params.zig"); +const PauseScreenFactory = struct { + context: EngineContext, + + pub fn construct(self: *@This()) !IScreen { + if (rmlui.available and self.context.ui_manager.getRmlUi() != null) { + const paused_screen = try RmlPausedScreen.init(self.context.allocator, self.context); + return paused_screen.screen(); + } + const paused_screen = try PausedScreen.init(self.context.allocator, self.context); + return paused_screen.screen(); + } +}; + const ShadowProbeInfo = struct { block_x: i32, block_y: i32, @@ -75,6 +88,7 @@ pub const WorldScreen = struct { .deinit = deinit, .update = update, .draw = draw, + .drawBackground = drawBackground, .onEnter = onEnter, .onExit = onExit, .getWorldStats = getWorldStatsIScreen, @@ -103,7 +117,12 @@ pub const WorldScreen = struct { fn initWithDistance(allocator: std.mem.Allocator, context: EngineContext, seed: u64, generator_index: usize, render_distance: i32, horizon_distance: i32, lod_enabled: bool, compact_tiles_enabled: bool, menu_preview: bool) !*WorldScreen { const render_system = context.render_system; - const session = try GameSession.init(allocator, render_system.getRHI(), render_system.getAtlas(), seed, render_distance, horizon_distance, lod_enabled, compact_tiles_enabled, generator_index, context.settings.render_distance_preset, context.build_config); + const diagnostic_horizon = context.benchmark_runner != null or context.build_config.phase5_visual_scene.len > 0 or context.build_config.benchmark_fixture.len > 0; + const effective_horizon_distance = if (diagnostic_horizon) + LODConfig.normalizeHorizonDistance(render_distance, horizon_distance) + else + LODConfig.normalizeUserHorizonDistance(render_distance, horizon_distance); + const session = try GameSession.init(allocator, render_system.getRHI(), render_system.getAtlas(), seed, render_distance, effective_horizon_distance, lod_enabled, compact_tiles_enabled, generator_index, context.settings.render_distance_preset, context.build_config); errdefer session.deinit(); const world = session.world.interface(); @@ -162,15 +181,21 @@ pub const WorldScreen = struct { } const cam = &self.session.player.camera; - ctx.audio_system.setListener(cam.position, cam.forward, cam.up); - - try self.session.update(dt, ctx.time.elapsed, ctx.input, ctx.input_mapper, render_system.getAtlas(), ctx.window_manager.window, false, ctx.skip_world_update, benchmark_mode or automated_capture or self.menu_preview); - if (self.menu_preview) self.applyMenuCamera(); - render_system.getCloudSystem().step(dt); - - const world_telemetry = self.world.telemetry(); if (!self.menu_preview) { - ctx.settings.horizon_distance = LODConfig.normalizeHorizonDistance(ctx.settings.render_distance, ctx.settings.horizon_distance); + // Keep persisted/manual values aligned with the supported UI range + // so the displayed Distant LOD Limit matches the runtime radius. + const diagnostic_horizon = benchmark_mode or ctx.build_config.phase5_visual_scene.len > 0 or ctx.build_config.benchmark_fixture.len > 0; + ctx.settings.horizon_distance = if (diagnostic_horizon) + LODConfig.normalizeHorizonDistance(ctx.settings.render_distance, ctx.settings.horizon_distance) + else + LODConfig.normalizeUserHorizonDistance(ctx.settings.render_distance, ctx.settings.horizon_distance); + // The World settings control is explicitly the full-detail chunk + // radius. Presets seed startup budgets, but a live manual value + // must be allowed to raise or lower that radius after the menu + // closes instead of remaining silently capped by the preset. + self.session.world.setLODChunkRenderRadiusLimit(ctx.settings.render_distance); + cam.far = @import("game-core").session.cameraFarPlaneForDistances(ctx.settings.render_distance, ctx.settings.horizon_distance); + const world_telemetry = self.world.telemetry(); if (world_telemetry.getRenderDistance() != ctx.settings.render_distance) { world_telemetry.setRenderDistance(ctx.settings.render_distance); } @@ -178,6 +203,11 @@ pub const WorldScreen = struct { world_telemetry.setHorizonDistance(ctx.settings.horizon_distance); } } + ctx.audio_system.setListener(cam.position, cam.forward, cam.up); + + try self.session.update(dt, ctx.time.elapsed, ctx.input, ctx.input_mapper, render_system.getAtlas(), ctx.window_manager.window, false, ctx.skip_world_update, benchmark_mode or automated_capture or self.menu_preview); + if (self.menu_preview) self.applyMenuCamera(); + render_system.getCloudSystem().step(dt); self.maybeLogStartupDiagnostic(now); } @@ -208,15 +238,8 @@ pub const WorldScreen = struct { } if (ctx.input_mapper.isActionPressed(ctx.input, .ui_back)) { - if (rmlui.available and ctx.ui_manager.getRmlUi() != null) { - const paused_screen = try RmlPausedScreen.init(ctx.allocator, self.parent_context); - errdefer paused_screen.deinit(paused_screen); - ctx.screen_manager.pushScreen(paused_screen.screen()); - } else { - const paused_screen = try PausedScreen.init(ctx.allocator, self.parent_context); - errdefer paused_screen.deinit(paused_screen); - ctx.screen_manager.pushScreen(paused_screen.screen()); - } + const factory = try Screen.makeScreenFactory(PauseScreenFactory, ctx.allocator, .{ .context = self.parent_context }); + ctx.screen_manager.pushScreenFactory(factory); return true; } @@ -663,6 +686,24 @@ pub const WorldScreen = struct { } } + fn drawBackground(ptr: *anyopaque, ui: *UISystem) !void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + const telemetry = self.world.telemetry(); + const restore_lod = telemetry.isLODRenderingEnabled(); + + // Compact vertex-pulling draws beneath a retained menu overlay can make + // RADV on RDNA1 reject the combined command stream. The nearby + // full-detail world remains a useful pause backdrop, so omit distant LOD + // only while this screen is rendered as another screen's background and + // restore the user's setting before leaving the draw call. + if (restore_lod) _ = telemetry.toggleLODRendering(); + defer { + if (restore_lod) _ = telemetry.toggleLODRendering(); + } + + try draw(ptr, ui); + } + pub fn onEnter(ptr: *anyopaque) void { const self: *@This() = @ptrCast(@alignCast(ptr)); self.context.input.setMouseCapture(self.context.window_manager.window, true); diff --git a/modules/world-lod/src/lod_chunk.zig b/modules/world-lod/src/lod_chunk.zig index bd21b9c5..3c8fc5ab 100644 --- a/modules/world-lod/src/lod_chunk.zig +++ b/modules/world-lod/src/lod_chunk.zig @@ -374,13 +374,13 @@ pub const LODChunk = struct { if (self.transition_frames_remaining > 0) self.transition_frames_remaining -= 1; } - /// Reports whether finer renderable children cover this region sufficiently to hide the parent. - /// `fallback_missing_child_threshold` controls how much missing child coverage is tolerated. + /// Reports whether all four direct finer children cover this region. + /// Partial child coverage must never hide the parent: quality thresholds + /// may tune transitions, but cannot create a terrain hole. pub fn isCoveredByFinerLOD(self: *const LODChunk, fallback_missing_child_threshold: f32) bool { + _ = fallback_missing_child_threshold; if (self.lod_level == .lod0) return false; - const missing_children = 4 - @min(self.ready_children, 4); - const missing_fraction = @as(f32, @floatFromInt(missing_children)) / 4.0; - return missing_fraction <= fallback_missing_child_threshold and self.transition_frames_remaining == 0; + return self.ready_children >= 4 and self.transition_frames_remaining == 0; } /// Marks source data dirty after chunk-derived ingestion or edits. @@ -636,6 +636,10 @@ pub const LODConfig = struct { pub const default_chunk_render_radius: i32 = 16; pub const default_horizon_radius: i32 = 512; pub const minimum_horizon_radius: i32 = 256; + /// Production user setting limit. Larger horizons remain available only to + /// explicit benchmark/diagnostic configurations until additional coarse + /// levels remove the current logical-memory and compact-pool pressure. + pub const maximum_user_horizon_radius: i32 = 512; pub const target_lod1_radius: i32 = 96; // keep 2-block cells visible farther out. pub const target_lod2_radius: i32 = 256; pub const target_lod3_radius: i32 = 512; @@ -697,10 +701,10 @@ pub const LODConfig = struct { } /// Expands a full-detail render distance into the default LOD radius ladder. - /// The farthest radius scales with full-detail distance while retaining the - /// default horizon for ordinary settings. + /// Ordinary settings use the qualified user horizon; benchmarks and + /// diagnostics request larger ladders explicitly through radiiForDistances. pub fn radiiForRenderDistance(distance: i32) [LODLevel.count]i32 { - return radiiForDistances(distance, normalizeHorizonDistance(distance, default_horizon_radius)); + return radiiForDistances(distance, normalizeUserHorizonDistance(distance, default_horizon_radius)); } /// Returns the minimum useful distant-LOD horizon for a full-detail radius. @@ -712,18 +716,28 @@ pub const LODConfig = struct { return @intCast(@max(@as(i64, minimum_horizon_radius), scaled)); } - /// Normalizes an explicit horizon so it never collapses distant LOD bands. + /// Normalizes the explicit outer LOD limit without silently expanding it + /// to the recommended long-distance profile. pub fn normalizeHorizonDistance(render_distance: i32, horizon_distance: i32) i32 { - return @max(horizon_distance, recommendedHorizonDistance(render_distance)); + return @max(horizon_distance, @max(render_distance, minimum_horizon_radius)); } - /// Steps an uncapped horizon geometrically while respecting the dynamic - /// minimum required by the full-detail render distance. + /// Clamps the normal user-facing distant terrain control to the currently + /// qualified production range. Benchmark configs use the uncapped helper. + pub fn normalizeUserHorizonDistance(render_distance: i32, horizon_distance: i32) i32 { + return @min(normalizeHorizonDistance(render_distance, horizon_distance), @max(render_distance, maximum_user_horizon_radius)); + } + + /// Steps the explicit outer LOD limit geometrically. The recommendation is + /// a preset default, not a mandatory minimum, so users can trade reach for + /// contiguous fill and lower generation pressure. pub fn stepHorizonDistance(render_distance: i32, horizon_distance: i32, increase: bool) i32 { - const minimum = recommendedHorizonDistance(render_distance); - const current = @max(horizon_distance, minimum); + const minimum = @max(render_distance, minimum_horizon_radius); + const maximum = @max(render_distance, maximum_user_horizon_radius); + if (horizon_distance > maximum) return maximum; + const current = std.math.clamp(horizon_distance, minimum, maximum); if (!increase) return @max(minimum, @divFloor(current, 2)); - return @intCast(@min(@as(i64, current) * 2, @as(i64, std.math.maxInt(i32)))); + return @intCast(@min(@as(i64, current) * 2, @as(i64, maximum))); } /// Expands full-detail and horizon distances into monotonically increasing LOD radii. @@ -749,20 +763,6 @@ pub const LODConfig = struct { return LODLevel.count; } - /// Returns the number of useful LOD bands in a radius ladder. - /// When a short horizon collapses several coarser levels to the same radius, - /// keeping them active only duplicates scheduling and draw work. - pub fn activeCountForRadii(radii: [LODLevel.count]i32) u32 { - var count: u32 = 1; - var last = radii[0]; - for (radii[1..]) |radius| { - if (radius <= last) continue; - count += 1; - last = radius; - } - return std.math.clamp(count, 1, LODLevel.count); - } - /// Returns the coarsest supported LOD level. /// Use as a fallback when a distance exceeds all configured active radii. pub fn coarsestLOD() LODLevel { @@ -1002,8 +1002,6 @@ test "ILODConfig exposes clamped active LOD count" { test "LODConfig expands render distance into distant LOD horizon" { try std.testing.expectEqual(@as(u32, LODLevel.count), LODConfig.activeCountForRenderDistance(8)); try std.testing.expectEqual(@as(u32, LODLevel.count), LODConfig.activeCountForRenderDistance(32)); - try std.testing.expectEqual(@as(u32, 1), LODConfig.activeCountForRadii(.{ 22, 22, 22, 22, 22 })); - try std.testing.expectEqual(@as(u32, 4), LODConfig.activeCountForRadii(.{ 30, 96, 256, 512, 512 })); const low_radii = LODConfig.radiiForRenderDistance(8); try std.testing.expectEqual(@as(i32, 24), low_radii[0]); @@ -1016,13 +1014,20 @@ test "LODConfig expands render distance into distant LOD horizon" { try std.testing.expectEqual(@as(i32, 96), radii[0]); try std.testing.expectEqual(@as(i32, 192), radii[1]); try std.testing.expectEqual(@as(i32, 384), radii[2]); - try std.testing.expectEqual(@as(i32, 768), radii[3]); - try std.testing.expectEqual(@as(i32, 1024), radii[4]); + try std.testing.expectEqual(@as(i32, 512), radii[3]); + try std.testing.expectEqual(@as(i32, 512), radii[4]); try std.testing.expectEqual(@as(i32, 256), LODConfig.recommendedHorizonDistance(8)); try std.testing.expectEqual(@as(i32, 131_072), LODConfig.recommendedHorizonDistance(4096)); try std.testing.expectEqual(std.math.maxInt(i32), LODConfig.recommendedHorizonDistance(std.math.maxInt(i32))); - try std.testing.expectEqual(@as(i32, 131_072), LODConfig.normalizeHorizonDistance(4096, 2048)); + try std.testing.expectEqual(@as(i32, 4096), LODConfig.normalizeHorizonDistance(4096, 2048)); + try std.testing.expectEqual(@as(i32, 512), LODConfig.stepHorizonDistance(32, 1024, false)); + try std.testing.expectEqual(@as(i32, 256), LODConfig.stepHorizonDistance(32, 512, false)); + try std.testing.expectEqual(@as(i32, 256), LODConfig.stepHorizonDistance(32, 256, false)); + try std.testing.expectEqual(@as(i32, 512), LODConfig.stepHorizonDistance(32, 512, true)); + try std.testing.expectEqual(@as(i32, 256), LODConfig.normalizeUserHorizonDistance(32, 128)); + try std.testing.expectEqual(@as(i32, 512), LODConfig.normalizeUserHorizonDistance(32, 1024)); + try std.testing.expectEqual(@as(i32, 600), LODConfig.normalizeUserHorizonDistance(600, 512)); const custom_horizon = LODConfig.radiiForDistances(12, 1024); try std.testing.expectEqual(@as(i32, 36), custom_horizon[0]); @@ -1033,7 +1038,6 @@ test "LODConfig expands render distance into distant LOD horizon" { const beyond_horizon = LODConfig.radiiForDistances(4096, 2048); try std.testing.expectEqual([_]i32{4096} ** LODLevel.count, beyond_horizon); - try std.testing.expectEqual(@as(u32, 1), LODConfig.activeCountForRadii(beyond_horizon)); const integer_limit = LODConfig.radiiForDistances(std.math.maxInt(i32), 2048); try std.testing.expectEqual([_]i32{std.math.maxInt(i32)} ** LODLevel.count, integer_limit); @@ -1043,6 +1047,10 @@ test "LODConfig keeps the coarse fallback when tail radii match" { var config = LODConfig{ .active_lod_count = LODLevel.count }; const interface = config.interface(); + interface.setRadii(.{ 96, 192, 256, 256, 256 }); + try std.testing.expectEqual(@as(u32, LODLevel.count), interface.getActiveLODCount()); + try std.testing.expectEqual(@as(i32, 256), interface.getRadii()[@intFromEnum(LODLevel.lod4)]); + interface.setRadii(.{ 30, 96, 256, 512, 512 }); try std.testing.expectEqual(@as(u32, LODLevel.count), interface.getActiveLODCount()); @@ -1091,6 +1099,17 @@ test "ILODConfig exposes fallback missing child threshold" { try std.testing.expectEqual(@as(f32, 1.0), interface.getFallbackMissingChildThreshold()); } +test "LOD parent remains visible until all direct children are ready" { + var parent = LODChunk.init(0, 0, .lod4); + parent.state = .renderable; + parent.ready_children = 3; + parent.transition_frames_remaining = 0; + + try std.testing.expect(!parent.isCoveredByFinerLOD(1.0)); + parent.ready_children = 4; + try std.testing.expect(parent.isCoveredByFinerLOD(0.0)); +} + test "ILODConfig exposes LOD quality tuning controls" { var config = LODConfig{ .horizontal_detail = .{ 16, 24, 32, 40, 24 }, diff --git a/modules/world-lod/src/lod_geometry.zig b/modules/world-lod/src/lod_geometry.zig index 4c43c92f..8e0e9624 100644 --- a/modules/world-lod/src/lod_geometry.zig +++ b/modules/world-lod/src/lod_geometry.zig @@ -612,8 +612,9 @@ pub fn collectColumnSpans(data: *const LODSimplifiedData, gx: u32, gz: u32, lod_ }); } - const water = data.water[idx]; - if (!has_water_span and shouldEmitWaterSpanForLOD(data, gx, gz, lod_level, water) and count < out.len) { + const representative_water = representativeWaterStateForLOD(data, gx, gz, lod_level); + if (!has_water_span and representative_water != null and count < out.len) { + const water = representative_water.?; has_water_span = true; insertColumnSpan(out, &count, .{ .min_height = water.surface_height - water.depth, @@ -1138,10 +1139,45 @@ pub fn waterCoverageStats(data: *const LODSimplifiedData, gx: u32, gz: u32) Wate pub fn shouldEmitWaterSpanForLOD(data: *const LODSimplifiedData, gx: u32, gz: u32, lod_level: LODLevel, water: world_core.LODWaterState) bool { if (!water.is_surface or water.coverage <= 0.0 or water.depth <= 0.01) return false; if (isFineSampleLOD(lod_level)) return true; - if (water.coverage >= 0.35) return true; + return isLODWaterCellForLOD(data, gx, gz, lod_level); +} + +/// Returns the canonical water surface for a rendered LOD cell. Coarse cells +/// use the same 2x2 coverage decision as terrain meshing, preventing one wet +/// corner from creating a full-cell water span over otherwise dry terrain. +pub fn representativeWaterStateForLOD(data: *const LODSimplifiedData, gx: u32, gz: u32, lod_level: LODLevel) ?world_core.LODWaterState { + if (isFineSampleLOD(lod_level)) { + const idx = cellIndex(data, gx, gz); + const water = data.water[idx]; + if (!water.is_surface or water.coverage <= 0.0 or water.depth <= 0.01) return null; + var result = water; + result.surface_height = normalizedWaterSurfaceHeight(data, idx, water); + return result; + } + if (!isLODWaterCellForLOD(data, gx, gz, lod_level)) return null; + const surface_height = representativeWaterSurfaceHeightForCell(data, gx, gz, lod_level) orelse return null; const stats = waterCoverageStats(data, gx, gz); - return stats.wet_samples >= 2 and stats.average_coverage >= 0.25 and stats.representative_depth >= 1.5; + return .{ + .is_surface = true, + .surface_height = surface_height, + .depth = stats.representative_depth, + .coverage = stats.average_coverage, + }; +} + +test "coarse representative water ignores one fully wet corner" { + var data = try LODSimplifiedData.init(std.testing.allocator, .lod2); + defer data.deinit(); + + data.water[0] = .{ + .is_surface = true, + .surface_height = 63.0, + .depth = 8.0, + .coverage = 1.0, + }; + + try std.testing.expect(representativeWaterStateForLOD(&data, 0, 0, .lod2) == null); } // Helper functions for unpacking colors diff --git a/modules/world-lod/src/lod_manager.zig b/modules/world-lod/src/lod_manager.zig index c131b61e..a61968aa 100644 --- a/modules/world-lod/src/lod_manager.zig +++ b/modules/world-lod/src/lod_manager.zig @@ -292,6 +292,14 @@ pub const LODManager = struct { return lod_manager_core.getStats(self); } + /// Returns the configured outer radius of the coarsest active LOD band. + pub fn getHorizonRenderRadius(self: *Self) i32 { + self.mutex.lockShared(); + defer self.mutex.unlockShared(); + const active_count = lod_chunk.activeLODCount(self.config); + return self.config.getRadii()[active_count - 1]; + } + /// Returns whether the coarsest active level has produced drawable fallback /// terrain within the current horizon. Scoping this to the player prevents /// stale regions after a teleport from releasing foreground prefetch early. @@ -352,13 +360,6 @@ pub const LODManager = struct { return lod_manager_core.renderFrame(self, frame_serial, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, detail_render_radius, layer); } - /// Returns the immutable projection decision produced by the current LOD - /// frame. The main-thread full-detail pass consumes it immediately after - /// LOD rendering, so no manager lock is needed or acquired here. - pub fn suppressesDetailChunk(self: *const Self, chunk_x: i32, chunk_z: i32) bool { - return self.renderer.suppressesDetailChunk(chunk_x, chunk_z); - } - /// Prepares same-frame GPU LOD culling before active graphics passes. pub fn prepareFrame(self: *Self, frame_serial: u64, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, max_distance_chunks: ?i32, detail_render_radius: i32) void { return lod_manager_core.prepareFrame(self, frame_serial, view_proj, camera_pos, chunk_checker, checker_ctx, max_distance_chunks, detail_render_radius); @@ -376,8 +377,21 @@ pub const LODManager = struct { return lod_manager_cache_ops.flushDirtyStores(self); } - /// Explicitly waits for accepted cache I/O and applies its completions. - /// This is for shutdown/tests; frame updates never block on cache I/O. + /// Settles older writes, then queues and waits for every current dirty + /// source snapshot. Used by explicit save points. + pub fn flushDirtyStoresNow(self: *Self) void { + return lod_manager_cache_ops.flushDirtyStoresNow(self); + } + + /// Deletes settled cache payloads for edited source updates that are still + /// blocked on missing or in-flight regions. The pending ingestion remains + /// queued and will write a fresh snapshot after it can be applied. + pub fn invalidatePendingEditedStoresNow(self: *Self) void { + return lod_manager_cache_ops.invalidatePendingEditedStoresNow(self); + } + + /// Flushes completed cache IO work and applies read/write completions. + /// Call from the main thread when synchronous cache progress is required. pub fn flushCacheIO(self: *Self) void { return lod_manager_cache_ops.flushCacheIO(self); } @@ -506,15 +520,24 @@ pub const LODManager = struct { return lod_manager_ingestion_ops.markChunkEdited(self, cx, cz); } + /// Applies queued edit provenance before full-detail storage unloads the + /// resolver-owned chunk. Returns the LOD-level mask still waiting for a + /// source region and optionally retains that work for a later retry. + pub fn flushEditedChunkForUnload(self: *Self, cx: i32, cz: i32, chunk: *const Chunk, retain_pending: bool) u8 { + if (self.benchmark_fixture_active) return 0; + return lod_manager_ingestion_ops.flushEditedChunkForUnload(self, cx, cz, chunk, retain_pending); + } + /// Applies chunk-derived source samples to currently loaded LOD regions. - /// Returns a bitmask describing which LOD levels accepted the update. + /// Returns a bitmask describing which LOD levels still need the update. pub fn applyIngestionToRegions(self: *Self, cx: i32, cz: i32, chunk: *const Chunk, provenance: LODColumnProvenance) u8 { return lod_manager_ingestion_ops.applyIngestionToRegions(self, cx, cz, chunk, provenance); } /// Records a deferred ingestion request while the ingestion mutex is already held. - /// `mask` tracks which LOD levels still need the chunk once regions become available. - pub fn recordPendingLocked(self: *Self, cx: i32, cz: i32, provenance: LODColumnProvenance, mask: u8) void { + /// `mask` tracks which LOD levels still need the chunk once regions become + /// available. Returns false when bounded queue admission fails. + pub fn recordPendingLocked(self: *Self, cx: i32, cz: i32, provenance: LODColumnProvenance, mask: u8) bool { return lod_manager_ingestion_ops.recordPendingLocked(self, cx, cz, provenance, mask); } @@ -536,12 +559,30 @@ pub const LODManager = struct { return lod_manager_ingestion_ops.drainPendingIngestions(self); } + /// Immediately attempts every currently deferred ingestion once. Entries + /// that are still unavailable remain queued for later update ticks. + pub fn drainPendingIngestionsNow(self: *Self) void { + return lod_manager_ingestion_ops.drainPendingIngestionsNow(self); + } + /// Converts debounced edited-chunk coordinates into ingestion requests. /// Clears the dirty-edit set once requests have been queued or applied. pub fn flushEditedChunks(self: *Self) void { return lod_manager_ingestion_ops.flushEditedChunks(self); } + /// Immediately applies pending edited chunks without waiting for the + /// coalescing cooldown. Used by explicit save points. + pub fn flushEditedChunksNow(self: *Self) void { + return lod_manager_ingestion_ops.flushEditedChunksNow(self); + } + + /// Immediately consumes at most the ordinary per-frame edit budget. + /// Intended for autosave paths that must not synchronously drain all edits. + pub fn flushEditedChunksBounded(self: *Self) void { + return lod_manager_ingestion_ops.flushEditedChunksBounded(self); + } + /// Queues missing or dirty regions for generation at one LOD level. /// Errors report allocation or job-queue failures; call from the world update thread. pub fn queueLODRegions(self: *Self, lod: LODLevel, velocity: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque) !void { diff --git a/modules/world-lod/src/lod_manager_cache_ops.zig b/modules/world-lod/src/lod_manager_cache_ops.zig index 30b79767..fbff8e81 100644 --- a/modules/world-lod/src/lod_manager_cache_ops.zig +++ b/modules/world-lod/src/lod_manager_cache_ops.zig @@ -2,6 +2,7 @@ const std = @import("std"); const fs = @import("fs"); const Self = @import("lod_manager.zig").LODManager; const LODRegionKey = @import("lod_chunk.zig").LODRegionKey; +const LODRegionKeyContext = @import("lod_chunk.zig").LODRegionKeyContext; const LODSimplifiedData = @import("lod_chunk.zig").LODSimplifiedData; const lod_chunk = @import("lod_chunk.zig"); const manager_ctx = @import("lod_manager_context.zig"); @@ -49,6 +50,64 @@ pub fn flushDirtyStores(self: *Self) void { _ = queueDirtyStores(self, 1); } +/// Settles older writes, then drains every currently eligible dirty source +/// snapshot in bounded batches. Explicit save points use this so an edited +/// snapshot cannot be skipped behind a stale worldgen write. +pub fn flushDirtyStoresNow(self: *Self) void { + flushAllDirtyStores(self); +} + +/// Removes known-stale payloads for edited ingestions that could not be +/// applied synchronously. Call only after `flushDirtyStoresNow`, which settles +/// older asynchronous writes before these payloads are deleted. +pub fn invalidatePendingEditedStoresNow(self: *Self) void { + const path = self.cacheDirPathSnapshot() orelse return; + defer self.allocator.free(path); + + var stale_keys = std.HashMap(LODRegionKey, void, LODRegionKeyContext, std.hash_map.default_max_load_percentage).init(self.allocator); + defer stale_keys.deinit(); + + self.ingestion_queue.mutex.lock(); + for (self.ingestion_queue.pending_ingestions.items) |pending| { + if (pending.provenance != .edited) continue; + var level: usize = 1; + while (level < lod_chunk.LODLevel.count) : (level += 1) { + const level_mask = @as(u8, 1) << @intCast(level); + if (pending.pending_levels & level_mask == 0) continue; + const lod: lod_chunk.LODLevel = @enumFromInt(@as(u3, @intCast(level))); + stale_keys.put(LODRegionKey.fromChunkCoords(pending.cx, pending.cz, lod), {}) catch { + log.log.warn("Failed to track stale LOD{} store payload for invalidation", .{level}); + }; + } + } + // Queue saturation can leave an edited coordinate in `edit_dirty` rather + // than `pending_ingestions`. Its full active LOD ladder is equally stale. + var dirty_iter = self.ingestion_queue.edit_dirty.keyIterator(); + while (dirty_iter.next()) |dirty| { + var level: usize = 1; + while (level < lod_chunk.activeLODCount(self.config)) : (level += 1) { + const lod: lod_chunk.LODLevel = @enumFromInt(@as(u3, @intCast(level))); + stale_keys.put(LODRegionKey.fromChunkCoords(dirty.cx, dirty.cz, lod), {}) catch { + log.log.warn("Failed to track dirty LOD{} store payload for invalidation", .{level}); + }; + } + } + self.ingestion_queue.mutex.unlock(); + + self.cache_store.store_mutex.lock(); + defer self.cache_store.store_mutex.unlock(); + var iter = stale_keys.keyIterator(); + while (iter.next()) |key| { + const cache_key = self.cacheKey(key.*); + lod_store.deletePayload(self.allocator, path, cache_key); + const legacy_path = self.legacyCacheFilePath(path, cache_key) catch continue; + fs.cwd().deleteFile(legacy_path) catch |err| { + if (err != error.FileNotFound) log.log.warn("Failed to invalidate stale legacy LOD cache '{s}': {}", .{ legacy_path, err }); + }; + self.allocator.free(legacy_path); + } +} + pub fn flushCacheIO(self: *Self) void { self.cache_io.waitUntilIdle(); drainCacheCompletions(self); @@ -57,14 +116,26 @@ pub fn flushCacheIO(self: *Self) void { /// Deinit-only flushing. Accepted work may block here; normal updates must use /// `flushDirtyStores` and never wait for I/O. pub fn shutdownCacheIO(self: *Self) void { + flushAllDirtyStores(self); +} + +fn flushAllDirtyStores(self: *Self) void { + // An older write can occupy a region's queued flag. Apply its completion + // before scanning, otherwise a stale completion may hide the newer dirty + // revision from the first (and only) batch. + self.flushCacheIO(); + var attempts: usize = 0; while (attempts < 2048) : (attempts += 1) { const queued = queueDirtyStores(self, cache_io.MAX_PENDING_TASKS); if (queued == 0) break; - self.cache_io.waitUntilIdle(); - drainCacheCompletions(self); + self.flushCacheIO(); } self.flushCacheIO(); + + if (attempts == 2048) { + log.log.warn("LOD source-store flush stopped after {} batches; dirty snapshots remain eligible for retry", .{attempts}); + } } pub fn drainCacheCompletions(self: *Self) void { diff --git a/modules/world-lod/src/lod_manager_context.zig b/modules/world-lod/src/lod_manager_context.zig index bcf3faf6..e3af0e7e 100644 --- a/modules/world-lod/src/lod_manager_context.zig +++ b/modules/world-lod/src/lod_manager_context.zig @@ -163,16 +163,13 @@ pub const PlayerChunkPos = struct { cz: i32, }; -/// Persistent bounded-scan cursor for one LOD level. Coordinates are generated -/// relative to the current player region, so ordinary movement does not discard -/// progress through the configured horizon. +/// Persistent bounded concentric-scan cursor for one LOD level. pub const LODScanState = struct { player_rx: i32 = 0, player_rz: i32 = 0, effective_radius: i32 = -1, next_ring: i64 = 0, ring_index: i64 = 0, - seed_index: usize = 0, last_examined: usize = 0, }; diff --git a/modules/world-lod/src/lod_manager_core_ops.zig b/modules/world-lod/src/lod_manager_core_ops.zig index 4ea53490..26cee4d4 100644 --- a/modules/world-lod/src/lod_manager_core_ops.zig +++ b/modules/world-lod/src/lod_manager_core_ops.zig @@ -336,8 +336,8 @@ pub fn update(self: *Self, player_pos: Vec3, player_velocity: Vec3, chunk_checke const active_lod_count = lod_chunk.activeLODCount(self.config); self.mutex.unlock(); - // Queue a small horizon seed first so something appears quickly, then - // let LOD0/LOD1/LOD2 refinements replace the coarse fallback. + // Queue the coarsest concentric fallback first, then let LOD0/LOD1/LOD2 + // refinements fill and replace it without creating outer-horizon islands. const scheduling_timer = self.profiling.begin(); var order_idx: usize = 0; while (order_idx < active_lod_count) : (order_idx += 1) { diff --git a/modules/world-lod/src/lod_manager_generation_ops.zig b/modules/world-lod/src/lod_manager_generation_ops.zig index df0de202..911bda79 100644 --- a/modules/world-lod/src/lod_manager_generation_ops.zig +++ b/modules/world-lod/src/lod_manager_generation_ops.zig @@ -662,11 +662,18 @@ pub fn processLODJob(ctx: *anyopaque, job: Job) void { return; } - // Acquire lock to update chunk data + // Acquire lock to update chunk data. A cache read or forced + // save-time edit may have published source while this worker + // was generating. Never let stale worldgen replace that newer + // authoritative snapshot. self.mutex.lock(); - chunk.data = .{ .simplified = data }; - chunk.updateHeightBoundsFromData(); - chunk.markSourceDirty(); + if (chunk.data == .simplified) { + data.deinit(); + } else { + chunk.data = .{ .simplified = data }; + chunk.updateHeightBoundsFromData(); + chunk.markSourceDirty(); + } self.mutex.unlock(); } success = true; diff --git a/modules/world-lod/src/lod_manager_ingestion_ops.zig b/modules/world-lod/src/lod_manager_ingestion_ops.zig index b1fe7d41..ef558189 100644 --- a/modules/world-lod/src/lod_manager_ingestion_ops.zig +++ b/modules/world-lod/src/lod_manager_ingestion_ops.zig @@ -76,10 +76,15 @@ pub fn setChunkResolver(self: *Self, resolver: ChunkResolver) void { /// `update()`. Safe to call from the generation worker thread; the caller /// must pin the chunk for the duration of the call. pub fn ingestChunk(self: *Self, cx: i32, cz: i32, chunk: *const Chunk, provenance: LODColumnProvenance) void { - const pending_mask = self.applyIngestionToRegions(cx, cz, chunk, provenance); + const pending_mask = applyIngestionToRegionsMask(self, cx, cz, chunk, provenance, activeIngestionMask(self)); if (pending_mask != 0) { self.ingestion_queue.mutex.lock(); - self.recordPendingLocked(cx, cz, provenance, pending_mask); + const recorded = self.recordPendingLocked(cx, cz, provenance, pending_mask); + if (!recorded and provenance == .edited) { + // Preserve discoverability for persistence invalidation when the + // bounded pending queue is saturated entirely by edited work. + self.ingestion_queue.edit_dirty.put(.{ .cx = cx, .cz = cz }, {}) catch {}; + } self.ingestion_queue.mutex.unlock(); } } @@ -90,11 +95,10 @@ pub fn ingestChunk(self: *Self, cx: i32, cz: i32, chunk: *const Chunk, provenanc pub fn requestIngestion(self: *Self, cx: i32, cz: i32, provenance: LODColumnProvenance) void { self.ingestion_queue.mutex.lock(); defer self.ingestion_queue.mutex.unlock(); - var mask: u8 = 0; - const active = lod_chunk.activeLODCount(self.config); - var i: usize = 1; - while (i < active) : (i += 1) mask |= @as(u8, 1) << @intCast(i); - self.recordPendingLocked(cx, cz, provenance, mask); + const recorded = self.recordPendingLocked(cx, cz, provenance, activeIngestionMask(self)); + if (!recorded and provenance == .edited) { + self.ingestion_queue.edit_dirty.put(.{ .cx = cx, .cz = cz }, {}) catch {}; + } } /// Notify the LOD system that a block edit affected chunk (cx, cz). @@ -108,11 +112,51 @@ pub fn markChunkEdited(self: *Self, cx: i32, cz: i32) void { }; } +/// Consumes any queued edit work for a chunk that is about to leave full-detail +/// storage, then applies its final authoritative snapshot to every currently +/// available LOD region. Deferred entries are removed because their resolver +/// would become invalid as soon as the caller completes the unload. +pub fn flushEditedChunkForUnload(self: *Self, cx: i32, cz: i32, chunk: *const Chunk, retain_pending: bool) u8 { + var requested_mask: u8 = 0; + self.ingestion_queue.mutex.lock(); + if (self.ingestion_queue.edit_dirty.remove(.{ .cx = cx, .cz = cz })) { + requested_mask = activeIngestionMask(self); + } + + var index: usize = 0; + while (index < self.ingestion_queue.pending_ingestions.items.len) { + const pending = self.ingestion_queue.pending_ingestions.items[index]; + if (pending.cx == cx and pending.cz == cz and pending.provenance == .edited) { + requested_mask |= pending.pending_levels; + _ = self.ingestion_queue.pending_ingestions.orderedRemove(index); + continue; + } + index += 1; + } + self.ingestion_queue.mutex.unlock(); + + if (requested_mask == 0) return 0; + const pending_mask = applyIngestionToRegionsMask(self, cx, cz, chunk, .edited, requested_mask); + if (pending_mask != 0 and retain_pending) { + self.ingestion_queue.mutex.lock(); + const recorded = self.recordPendingLocked(cx, cz, .edited, pending_mask); + if (!recorded) { + self.ingestion_queue.edit_dirty.put(.{ .cx = cx, .cz = cz }, {}) catch {}; + } + self.ingestion_queue.mutex.unlock(); + } + return pending_mask; +} + /// Apply one chunk's contribution to every LOD region that already has /// source data and is not in-flight. Returns a bitmask of LOD levels that /// could not be applied (region missing, not yet generated, or meshing) /// so the caller can record them as pending. pub fn applyIngestionToRegions(self: *Self, cx: i32, cz: i32, chunk: *const Chunk, provenance: LODColumnProvenance) u8 { + return applyIngestionToRegionsMask(self, cx, cz, chunk, provenance, activeIngestionMask(self)); +} + +fn applyIngestionToRegionsMask(self: *Self, cx: i32, cz: i32, chunk: *const Chunk, provenance: LODColumnProvenance, requested_mask: u8) u8 { var pending_mask: u8 = 0; const active = lod_chunk.activeLODCount(self.config); @@ -121,10 +165,12 @@ pub fn applyIngestionToRegions(self: *Self, cx: i32, cz: i32, chunk: *const Chun var i: usize = 1; while (i < active) : (i += 1) { + const level_mask = @as(u8, 1) << @intCast(i); + if (requested_mask & level_mask == 0) continue; const lod: LODLevel = @enumFromInt(@as(u3, @intCast(i))); const key = LODRegionKey.fromChunkCoords(cx, cz, lod); const lod_chunk_ptr = self.regions[i].get(key) orelse { - pending_mask |= @as(u8, 1) << @intCast(i); + pending_mask |= level_mask; continue; }; switch (lod_chunk_ptr.data) { @@ -137,7 +183,7 @@ pub fn applyIngestionToRegions(self: *Self, cx: i32, cz: i32, chunk: *const Chun lod_chunk_ptr.getState() == .meshing or lod_chunk_ptr.getState() == .uploading) { - pending_mask |= @as(u8, 1) << @intCast(i); + pending_mask |= level_mask; continue; } const region_size: i32 = @intCast(world_core.regionSizeBlocks(lod)); @@ -153,27 +199,35 @@ pub fn applyIngestionToRegions(self: *Self, cx: i32, cz: i32, chunk: *const Chun }, else => { // Region exists but has no source data yet (not generated). - pending_mask |= @as(u8, 1) << @intCast(i); + pending_mask |= level_mask; }, } } return pending_mask; } +fn activeIngestionMask(self: *Self) u8 { + var mask: u8 = 0; + const active = lod_chunk.activeLODCount(self.config); + var i: usize = 1; + while (i < active) : (i += 1) mask |= @as(u8, 1) << @intCast(i); + return mask; +} + /// Assumes `ingestion_mutex` held. Coalesces by coordinate, keeping the /// most authoritative provenance and the union of pending level bits. /// Deferred work is deliberately durable: a player edit can outlive many /// unload/reload or teleport cycles before its source chunk becomes resident. -pub fn recordPendingLocked(self: *Self, cx: i32, cz: i32, provenance: LODColumnProvenance, mask: u8) void { +pub fn recordPendingLocked(self: *Self, cx: i32, cz: i32, provenance: LODColumnProvenance, mask: u8) bool { for (self.ingestion_queue.pending_ingestions.items) |*entry| { if (entry.cx == cx and entry.cz == cz) { entry.pending_levels |= mask; entry.ttl = 0; if (provenance.canOverwrite(entry.provenance)) entry.provenance = provenance; - return; + return true; } } - if (!makePendingRoomLocked(self, cx, cz, provenance)) return; + if (!makePendingRoomLocked(self, cx, cz, provenance)) return false; self.ingestion_queue.pending_ingestions.append(self.allocator, .{ .cx = cx, .cz = cz, @@ -182,7 +236,9 @@ pub fn recordPendingLocked(self: *Self, cx: i32, cz: i32, provenance: LODColumnP .ttl = 0, }) catch |err| { log.log.warn("Failed to defer LOD ingestion for chunk ({}, {}): {}", .{ cx, cz, err }); + return false; }; + return true; } /// Re-record a pending entry from outside the lock. Coalesces with any @@ -230,6 +286,17 @@ pub fn decayPendingLocked(self: *Self) void { /// lock, resolve each chunk, and re-apply. Unresolved or still-in-flight /// levels remain queued until they apply or manager teardown. pub fn drainPendingIngestions(self: *Self) void { + drainPendingIngestionsWithLimit(self, self.ingestion_queue.drain_per_frame); +} + +/// Makes one immediate attempt to apply every currently deferred ingestion. +/// Requests that still cannot resolve or target in-flight regions remain +/// queued for later updates. +pub fn drainPendingIngestionsNow(self: *Self) void { + drainPendingIngestionsWithLimit(self, std.math.maxInt(usize)); +} + +fn drainPendingIngestionsWithLimit(self: *Self, max_count: usize) void { var snapshot = std.ArrayListUnmanaged(PendingIngestion).empty; { self.ingestion_queue.mutex.lock(); @@ -244,7 +311,7 @@ pub fn drainPendingIngestions(self: *Self) void { defer snapshot.deinit(self.allocator); const resolver = self.ingestion_queue.chunk_resolver; - const limit = @min(snapshot.items.len, self.ingestion_queue.drain_per_frame); + const limit = @min(snapshot.items.len, max_count); // Process the head of the snapshot and retain the tail for a later frame. var i: usize = 0; @@ -257,7 +324,7 @@ pub fn drainPendingIngestions(self: *Self) void { } const chunk = if (resolver) |r| r.resolve(entry.cx, entry.cz) else null; if (chunk) |c| { - const remaining = self.applyIngestionToRegions(entry.cx, entry.cz, c, entry.provenance); + const remaining = applyIngestionToRegionsMask(self, entry.cx, entry.cz, c, entry.provenance, entry.pending_levels); if (remaining != 0) { self.rerecordPending(entry.cx, entry.cz, entry.provenance, remaining, 0); } @@ -285,12 +352,31 @@ fn makePendingRoomLocked(self: *Self, cx: i32, cz: i32, provenance: LODColumnPro return false; } +/// Immediately applies pending edited chunks, bypassing the ordinary +/// coalescing cooldown. Used by explicit save points that must persist the +/// corresponding LOD source snapshot in the same transaction. +pub fn flushEditedChunksNow(self: *Self) void { + self.ingestion_queue.edit_cooldown = 0.0; + flushEditedChunksWithLimit(self, std.math.maxInt(usize)); +} + +/// Bypasses the cooldown but consumes only the ordinary per-frame ingestion +/// budget. Autosave uses this to start persistence without a large edit burst. +pub fn flushEditedChunksBounded(self: *Self) void { + self.ingestion_queue.edit_cooldown = 0.0; + flushEditedChunksWithLimit(self, self.ingestion_queue.drain_per_frame); +} + /// Flush debounced player edits: re-ingest edited chunks with the `edited` /// provenance. Runs on a cooldown so rapid edits coalesce into one rebuild. pub fn flushEditedChunks(self: *Self) void { self.ingestion_queue.edit_cooldown -= LOD_FRAME_DT_APPROX; if (self.ingestion_queue.edit_cooldown > 0.0) return; + flushEditedChunksWithLimit(self, std.math.maxInt(usize)); +} + +fn flushEditedChunksWithLimit(self: *Self, max_count: usize) void { var snapshot = std.ArrayListUnmanaged(ChunkCoordKey).empty; { self.ingestion_queue.mutex.lock(); @@ -298,9 +384,10 @@ pub fn flushEditedChunks(self: *Self) void { if (self.ingestion_queue.edit_dirty.count() == 0) return; var it = self.ingestion_queue.edit_dirty.keyIterator(); while (it.next()) |k| { + if (snapshot.items.len >= max_count) break; snapshot.append(self.allocator, k.*) catch break; } - self.ingestion_queue.edit_dirty.clearRetainingCapacity(); + for (snapshot.items) |key| _ = self.ingestion_queue.edit_dirty.remove(key); } defer snapshot.deinit(self.allocator); @@ -308,7 +395,7 @@ pub fn flushEditedChunks(self: *Self) void { for (snapshot.items) |k| { if (resolver) |r| { if (r.resolve(k.cx, k.cz)) |chunk| { - _ = self.applyIngestionToRegions(k.cx, k.cz, chunk, .edited); + self.ingestChunk(k.cx, k.cz, chunk, .edited); continue; } } diff --git a/modules/world-lod/src/lod_manager_internal_tests.zig b/modules/world-lod/src/lod_manager_internal_tests.zig index 2bb84b46..b9c1ce9f 100644 --- a/modules/world-lod/src/lod_manager_internal_tests.zig +++ b/modules/world-lod/src/lod_manager_internal_tests.zig @@ -23,6 +23,7 @@ const LODGPUBridge = lod_gpu.LODGPUBridge; const MeshMap = lod_gpu.MeshMap; const RegionMap = lod_gpu.RegionMap; const lod_cache = @import("lod_cache.zig"); +const cache_io = @import("lod_cache_io.zig"); const lod_store = @import("lod_store.zig"); const manager_mod = @import("lod_manager.zig"); const LODManager = manager_mod.LODManager; @@ -71,6 +72,119 @@ test "LODManager cache helpers save and reload source data" { try testing.expectEqual(data.material_layers[idx].foundation, loaded.material_layers[idx].foundation); } +test "flushDirtyStoresNow persists the latest edited source snapshot" { + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const dir = fs.Dir{ .inner = tmp_dir.dir }; + var path_buf: [fs.max_path_bytes]u8 = undefined; + const save_dir_path = try dir.realpath(".", &path_buf); + + var config = LODConfig{}; + var manager = try initEvictionTestManager(testing.allocator, &config); + defer deinitEvictionTestManager(&manager); + try manager.enableCache(save_dir_path); + defer if (manager.cache_store.cache_dir_path) |path| testing.allocator.free(path); + + const key = LODRegionKey{ .rx = 0, .rz = -1, .lod = .lod4 }; + const chunk = try putTestRegion(&manager, key, .generated); + chunk.data = .{ .simplified = try LODSimplifiedData.init(testing.allocator, .lod4) }; + chunk.data.simplified.setColumn(1, 1, 64.0, .plains, .{ .surface = .sand, .subsurface = .sand, .foundation = .stone }, 0xc2b280, .{ + .is_surface = true, + .surface_height = 65.0, + .depth = 1.0, + .coverage = 0.5, + }, .daylight, .empty); + chunk.data.simplified.setColumnProvenance(1, 1, .edited); + chunk.markSourceDirty(); + + manager.flushDirtyStoresNow(); + + var loaded = manager.loadCachedSourceData(key) orelse return error.ExpectedCacheHit; + defer loaded.deinit(); + const idx = 1 + loaded.width; + try testing.expect(loaded.water[idx].is_surface); + try testing.expectEqual(@as(f32, 0.5), loaded.water[idx].coverage); + try testing.expectEqual(LODColumnProvenance.edited, loaded.provenance[idx]); +} + +test "explicit persistence invalidates blocked edited store payloads" { + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const dir = fs.Dir{ .inner = tmp_dir.dir }; + var path_buf: [fs.max_path_bytes]u8 = undefined; + const save_dir_path = try dir.realpath(".", &path_buf); + + var config = LODConfig{}; + var manager = try initEvictionTestManager(testing.allocator, &config); + defer deinitEvictionTestManager(&manager); + defer manager.ingestion_queue.pending_ingestions.deinit(testing.allocator); + try manager.enableCache(save_dir_path); + defer if (manager.cache_store.cache_dir_path) |path| testing.allocator.free(path); + + const key = LODRegionKey.fromChunkCoords(0, 0, .lod2); + var stale = try LODSimplifiedData.init(testing.allocator, key.lod); + defer stale.deinit(); + stale.setColumn(0, 0, 40.0, .plains, .{ .surface = .stone, .subsurface = .stone, .foundation = .stone }, 0x808080, .empty, .daylight, .empty); + manager.saveCachedSourceData(key, &stale); + manager.flushCacheIO(); + var initially_loaded = manager.loadCachedSourceData(key) orelse return error.ExpectedCacheHit; + initially_loaded.deinit(); + + manager.ingestion_queue.mutex.lock(); + const recorded = manager.recordPendingLocked(0, 0, .edited, @as(u8, 1) << @intFromEnum(LODLevel.lod2)); + manager.ingestion_queue.mutex.unlock(); + try testing.expect(recorded); + + manager.flushDirtyStoresNow(); + manager.invalidatePendingEditedStoresNow(); + try testing.expect(manager.loadCachedSourceData(key) == null); + + // A saturated pending queue re-retains an edit in edit_dirty. Explicit + // persistence must invalidate that coordinate's full active LOD ladder too. + manager.saveCachedSourceData(key, &stale); + manager.flushCacheIO(); + try manager.ingestion_queue.edit_dirty.put(.{ .cx = 0, .cz = 0 }, {}); + manager.invalidatePendingEditedStoresNow(); + try testing.expect(manager.loadCachedSourceData(key) == null); +} + +test "flushDirtyStoresNow drains more than one cache pipeline batch" { + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const dir = fs.Dir{ .inner = tmp_dir.dir }; + var path_buf: [fs.max_path_bytes]u8 = undefined; + const save_dir_path = try dir.realpath(".", &path_buf); + + var config = LODConfig{}; + var manager = try initEvictionTestManager(testing.allocator, &config); + defer deinitEvictionTestManager(&manager); + try manager.enableCache(save_dir_path); + defer if (manager.cache_store.cache_dir_path) |path| testing.allocator.free(path); + + const region_count = cache_io.MAX_PENDING_TASKS + 1; + for (0..region_count) |i| { + const key = LODRegionKey{ .rx = @intCast(i), .rz = -2, .lod = .lod4 }; + const chunk = try putTestRegion(&manager, key, .generated); + chunk.data = .{ .simplified = try LODSimplifiedData.init(testing.allocator, key.lod) }; + chunk.data.simplified.setColumn(0, 0, @floatFromInt(40 + i), .plains, .{ .surface = .stone, .subsurface = .stone, .foundation = .stone }, 0x808080, .empty, .daylight, .empty); + chunk.data.simplified.setColumnProvenance(0, 0, .edited); + chunk.markSourceDirty(); + } + + manager.flushDirtyStoresNow(); + + for (0..region_count) |i| { + const key = LODRegionKey{ .rx = @intCast(i), .rz = -2, .lod = .lod4 }; + var loaded = manager.loadCachedSourceData(key) orelse return error.ExpectedCacheHit; + defer loaded.deinit(); + try testing.expectEqual(@as(f32, @floatFromInt(40 + i)), loaded.getHeight(0, 0)); + try testing.expectEqual(LODColumnProvenance.edited, loaded.getColumnProvenance(0, 0)); + } +} + test "LODManager cache helpers delete corrupt cache files" { var tmp_dir = testing.tmpDir(.{}); defer tmp_dir.cleanup(); @@ -689,7 +803,7 @@ test "LODManager ignores stale compact draw failures and retries on source chang try testing.expectEqual(@as(u32, 0), counter.calls); } -test "LODManager meshes reduced LOD3 and full-density horizon grids through compact and expanded paths" { +test "LODManager meshes reduced far grids through compact and expanded paths" { const Cases = [_]struct { lod: LODLevel, width: u32, compact_capable: bool }{ .{ .lod = .lod3, .width = 65, .compact_capable = true }, .{ .lod = .lod4, .width = 65, .compact_capable = true }, @@ -763,6 +877,57 @@ test "LODManager meshes reduced LOD3 and full-density horizon grids through comp } } +test "stale generation completion preserves edited source published in flight" { + var config = LODConfig{}; + var manager = try initEvictionTestManager(testing.allocator, &config); + defer deinitEvictionTestManager(&manager); + + const key = LODRegionKey{ .rx = 0, .rz = 0, .lod = .lod4 }; + const chunk = try putTestRegion(&manager, key, .generating); + chunk.job_token = 1; + + const RaceContext = struct { + manager: *LODManager, + key: LODRegionKey, + }; + var context = RaceContext{ .manager = &manager, .key = key }; + manager.generator = .{ + .ptr = &context, + .generate_heightmap_only = struct { + fn generate(ptr: *anyopaque, data: *LODSimplifiedData, _: i32, _: i32, _: LODLevel, _: ?*const std.atomic.Value(bool)) void { + const race: *RaceContext = @ptrCast(@alignCast(ptr)); + for (0..data.width) |z| for (0..data.width) |x| { + data.setGeneratedColumn(@intCast(x), @intCast(z), 64.0, .plains, .{ .surface = .grass, .subsurface = .dirt, .foundation = .stone }, 0x4f8c45, .empty, .daylight, .empty); + }; + + var edited = LODSimplifiedData.init(testing.allocator, .lod4) catch unreachable; + edited.setGeneratedColumn(0, 0, 20.0, .plains, .{ .surface = .stone, .subsurface = .stone, .foundation = .stone }, 0x808080, .empty, .daylight, .empty); + edited.setColumnProvenance(0, 0, .edited); + + race.manager.mutex.lock(); + defer race.manager.mutex.unlock(); + const region = race.manager.regions[@intFromEnum(race.key.lod)].get(race.key).?; + region.data = .{ .simplified = edited }; + region.markSourceDirty(); + } + }.generate, + .maybe_recenter_cache = struct { + fn recenter(_: *anyopaque, _: i32, _: i32) bool { + return false; + } + }.recenter, + .seed = 1, + .identity_hash = 1, + .version = 1, + }; + + generation_ops.processLODJob(&manager, .{ .type = .chunk_generation, .data = .{ .chunk = .{ .x = key.rx, .z = key.rz, .job_token = chunk.job_token, .lod_level = @intFromEnum(key.lod), .coord_scale = @intCast(key.lod.chunksPerSide()), .lod_radius = 4096 } } }); + + try testing.expectEqual(LODState.generated, chunk.getState()); + try testing.expectEqual(@as(f32, 20.0), chunk.data.simplified.getHeight(0, 0)); + try testing.expectEqual(LODColumnProvenance.edited, chunk.data.simplified.getColumnProvenance(0, 0)); +} + fn putTestRegion(manager: *LODManager, key: LODRegionKey, state: LODState) !*LODChunk { const chunk = try manager.allocator.create(LODChunk); chunk.* = LODChunk.init(key.rx, key.rz, key.lod); @@ -888,7 +1053,7 @@ test "LODManager upload budget defers remaining queued meshes" { try testing.expectEqual(@as(usize, 1), manager.upload_queues[1].count()); } -test "LODManager upload budget defers an oversized first mesh" { +test "LODManager upload budget admits one oversized mesh to guarantee progress" { var config = LODConfig{ .max_uploads_per_frame = 8 }; var manager = try initEvictionTestManager(testing.allocator, &config); defer deinitEvictionTestManager(&manager); @@ -896,15 +1061,20 @@ test "LODManager upload budget defers an oversized first mesh" { var mock = UploadMock{ .allocator = testing.allocator }; manager.gpu_bridge = mock.bridge(); - const key = LODRegionKey{ .rx = 0, .rz = 0, .lod = .lod1 }; - const chunk = try putTestRegion(&manager, key, .uploading); - _ = try putTestPendingMesh(&manager, key, 1); - try manager.upload_queues[1].push(chunk); + const first_key = LODRegionKey{ .rx = 0, .rz = 0, .lod = .lod1 }; + const second_key = LODRegionKey{ .rx = 1, .rz = 0, .lod = .lod1 }; + const first = try putTestRegion(&manager, first_key, .uploading); + const second = try putTestRegion(&manager, second_key, .uploading); + _ = try putTestPendingMesh(&manager, first_key, 1); + _ = try putTestPendingMesh(&manager, second_key, 1); + try manager.upload_queues[1].push(first); + try manager.upload_queues[1].push(second); manager.processUploadsWithBudget(@sizeOf(Vertex) - 1); - try testing.expectEqual(@as(u32, 0), mock.calls); - try testing.expectEqual(LODState.uploading, chunk.state); + try testing.expectEqual(@as(u32, 1), mock.calls); + try testing.expectEqual(LODState.renderable, first.state); + try testing.expectEqual(LODState.uploading, second.state); try testing.expectEqual(@as(usize, 1), manager.upload_queues[1].count()); } @@ -933,6 +1103,13 @@ test "LODManager upload budget lets a near upload bypass a far pool migration" { try testing.expectEqual(LODState.uploading, far.state); try testing.expectEqual(LODState.renderable, near.state); try testing.expectEqual(@as(usize, 1), manager.upload_queues[@intFromEnum(far_key.lod)].count()); + + // On the next frame no smaller work remains, so one over-budget pool + // migration must be allowed through instead of starving forever. + manager.processUploadsWithBudget(2 * @sizeOf(Vertex)); + try testing.expectEqual(@as(u32, 2), mock.calls); + try testing.expectEqual(LODState.renderable, far.state); + try testing.expectEqual(@as(usize, 0), manager.upload_queues[@intFromEnum(far_key.lod)].count()); } test "LODManager routine upload and eviction record no streaming device waits" { diff --git a/modules/world-lod/src/lod_manager_tests.zig b/modules/world-lod/src/lod_manager_tests.zig index 6f2c009c..f9d9032a 100644 --- a/modules/world-lod/src/lod_manager_tests.zig +++ b/modules/world-lod/src/lod_manager_tests.zig @@ -464,6 +464,29 @@ test "ingestChunk defers while a cancelled worker still pins source data" { try std.testing.expect(mgr.ingestion_queue.pending_ingestions.items.len > 0); } +test "ingestChunk into generated source invalidates stale mesh transition" { + const allocator = std.testing.allocator; + var config = LODConfig{ .radii = .{ 2, 4, 8, 16, 32 } }; + const mgr = try buildIngestionManager(allocator, &config); + defer mgr.deinit(); + + const lchunk = try placeSimplifiedRegion(mgr, allocator, 0, 0, .lod1); + lchunk.state = .generated; + const old_token = lchunk.job_token; + + var chunk = Chunk.init(0, 0); + var y: u32 = 0; + while (y <= 64) : (y += 1) chunk.setBlock(0, y, 0, .stone); + + mgr.ingestChunk(0, 0, &chunk, .edited); + + try std.testing.expectEqual(old_token + 1, lchunk.job_token); + const token = mgr.transition_tokens.pop() orelse return error.ExpectedMeshTransition; + try std.testing.expectEqual(@import("lod_manager_context.zig").LifecycleStage.mesh, token.stage); + try std.testing.expectEqual(lchunk.job_token, token.job_token); + try std.testing.expectEqual(lchunk.source_revision, token.source_revision); +} + test "ingestChunk provenance authority: edited beats chunk_derived, worldgen cannot overwrite" { const allocator = std.testing.allocator; var config = LODConfig{ .radii = .{ 2, 4, 8, 16, 32 } }; @@ -532,6 +555,107 @@ test "markChunkEdited coalesces and re-ingests via the resolver on update" { try std.testing.expectEqual(@as(f32, 90.0), lchunk.data.simplified.getHeight(0, 0)); try std.testing.expectEqual(LODColumnProvenance.edited, lchunk.data.simplified.getColumnProvenance(0, 0)); + + // Explicit save points bypass the coalescing cooldown and persist edits in + // the same transaction rather than losing them to a later frame. + edited_chunk.setBlock(0, 90, 0, .air); + lchunk.setState(.renderable); + if (lchunk.isPinned()) lchunk.unpin(); + mgr.markChunkEdited(0, 0); + mgr.ingestion_queue.edit_cooldown = 1.0; + mgr.flushEditedChunksNow(); + try std.testing.expectEqual(@as(f32, 89.0), lchunk.data.simplified.getHeight(0, 0)); +} + +test "edited chunk unload consumes queued work before resolver removal" { + const allocator = std.testing.allocator; + var config = LODConfig{ .radii = .{ 2, 4, 8, 16, 32 }, .active_lod_count = 2 }; + const mgr = try buildIngestionManager(allocator, &config); + defer mgr.deinit(); + + const lchunk = try placeSimplifiedRegion(mgr, allocator, 0, 0, .lod1); + lchunk.data.simplified.setColumn(0, 0, 10.0, .plains, .{ .surface = .grass, .subsurface = .dirt, .foundation = .stone }, 0x4D8033, .empty, .daylight, .empty); + + var edited_chunk = Chunk.init(0, 0); + var y: u32 = 0; + while (y <= 72) : (y += 1) chunk_derived_setBlock(&edited_chunk, 0, y, 0, .stone); + mgr.markChunkEdited(0, 0); + + try std.testing.expectEqual(@as(u8, 0), mgr.flushEditedChunkForUnload(0, 0, &edited_chunk, false)); + try std.testing.expectEqual(@as(f32, 72.0), lchunk.data.simplified.getHeight(0, 0)); + try std.testing.expectEqual(LODColumnProvenance.edited, lchunk.data.simplified.getColumnProvenance(0, 0)); + try std.testing.expectEqual(@as(usize, 0), mgr.ingestion_queue.edit_dirty.count()); + try std.testing.expectEqual(@as(u8, 0), mgr.flushEditedChunkForUnload(0, 0, &edited_chunk, false)); +} + +test "edited chunk unload retains in-flight LOD work inside the horizon" { + const allocator = std.testing.allocator; + var config = LODConfig{ .radii = .{ 2, 4, 8, 16, 32 }, .active_lod_count = 2 }; + const mgr = try buildIngestionManager(allocator, &config); + defer mgr.deinit(); + + const lchunk = try placeSimplifiedRegion(mgr, allocator, 0, 0, .lod1); + lchunk.data.simplified.setColumn(0, 0, 10.0, .plains, .{ .surface = .grass, .subsurface = .dirt, .foundation = .stone }, 0x4D8033, .empty, .daylight, .empty); + lchunk.state = .meshing; + + var edited_chunk = Chunk.init(0, 0); + var y: u32 = 0; + while (y <= 72) : (y += 1) chunk_derived_setBlock(&edited_chunk, 0, y, 0, .stone); + mgr.markChunkEdited(0, 0); + + const lod1_mask = @as(u8, 1) << @intFromEnum(LODLevel.lod1); + try std.testing.expectEqual(lod1_mask, mgr.flushEditedChunkForUnload(0, 0, &edited_chunk, true)); + try std.testing.expectEqual(@as(usize, 1), mgr.ingestion_queue.pending_ingestions.items.len); + try std.testing.expectEqual(@as(f32, 10.0), lchunk.data.simplified.getHeight(0, 0)); + + lchunk.state = .renderable; + try std.testing.expectEqual(@as(u8, 0), mgr.flushEditedChunkForUnload(0, 0, &edited_chunk, true)); + try std.testing.expectEqual(@as(usize, 0), mgr.ingestion_queue.pending_ingestions.items.len); + try std.testing.expectEqual(@as(f32, 72.0), lchunk.data.simplified.getHeight(0, 0)); +} + +test "deferred edited ingestion retries only levels still pending" { + const allocator = std.testing.allocator; + var config = LODConfig{ .radii = .{ 2, 4, 8, 16, 32 }, .active_lod_count = 3 }; + const mgr = try buildIngestionManager(allocator, &config); + defer mgr.deinit(); + + const lod1 = try placeSimplifiedRegion(mgr, allocator, 0, 0, .lod1); + const lod2 = try placeSimplifiedRegion(mgr, allocator, 0, 0, .lod2); + lod1.data.simplified.setColumn(0, 0, 10.0, .plains, .{ .surface = .grass, .subsurface = .dirt, .foundation = .stone }, 0x4D8033, .empty, .daylight, .empty); + lod2.data.simplified.setColumn(0, 0, 10.0, .plains, .{ .surface = .grass, .subsurface = .dirt, .foundation = .stone }, 0x4D8033, .empty, .daylight, .empty); + lod2.state = .meshing; + + var edited_chunk = Chunk.init(0, 0); + var y: u32 = 0; + while (y <= 72) : (y += 1) chunk_derived_setBlock(&edited_chunk, 0, y, 0, .stone); + mgr.markChunkEdited(0, 0); + + const lod2_mask = @as(u8, 1) << @intFromEnum(LODLevel.lod2); + try std.testing.expectEqual(lod2_mask, mgr.flushEditedChunkForUnload(0, 0, &edited_chunk, true)); + const lod1_revision = lod1.source_revision; + try std.testing.expectEqual(@as(f32, 72.0), lod1.data.simplified.getHeight(0, 0)); + + lod2.state = .renderable; + try std.testing.expectEqual(@as(u8, 0), mgr.flushEditedChunkForUnload(0, 0, &edited_chunk, true)); + try std.testing.expectEqual(lod1_revision, lod1.source_revision); + try std.testing.expectEqual(@as(f32, 72.0), lod2.data.simplified.getHeight(0, 0)); +} + +test "bounded edited chunk flush preserves work beyond the frame budget" { + const allocator = std.testing.allocator; + var config = LODConfig{ .active_lod_count = 2 }; + const mgr = try buildIngestionManager(allocator, &config); + defer mgr.deinit(); + mgr.ingestion_queue.drain_per_frame = 2; + + mgr.markChunkEdited(0, 0); + mgr.markChunkEdited(1, 0); + mgr.markChunkEdited(2, 0); + mgr.flushEditedChunksBounded(); + + try std.testing.expectEqual(@as(usize, 1), mgr.ingestion_queue.edit_dirty.count()); + try std.testing.expectEqual(@as(usize, 2), mgr.ingestion_queue.pending_ingestions.items.len); } fn chunk_derived_setBlock(chunk: *Chunk, x: u32, y: u32, z: u32, block: world_core.BlockType) void { diff --git a/modules/world-lod/src/lod_manager_upload_ops.zig b/modules/world-lod/src/lod_manager_upload_ops.zig index 8d2a5bb1..b1fe578e 100644 --- a/modules/world-lod/src/lod_manager_upload_ops.zig +++ b/modules/world-lod/src/lod_manager_upload_ops.zig @@ -181,6 +181,7 @@ pub fn processUploadsWithBudget(self: *Self, upload_budget_bytes: usize) void { while (uploads < max_uploads) { const prep_timer = self.profiling.begin(); var task: ?UploadTask = null; + var oversized_fallback: ?UploadTask = null; var completed_without_upload = false; var made_progress = false; var deferred_for_budget = false; @@ -204,8 +205,22 @@ pub fn processUploadsWithBudget(self: *Self, upload_budget_bytes: usize) void { const staging_bytes = self.gpu_bridge.uploadCost(mesh).total(); if (wouldExceedUploadBudget(uploaded_bytes, staging_bytes, upload_budget_bytes)) { self.profiling.addStagingPressure(); - self.requeueUpload(i, chunk); deferred_for_budget = true; + // Preserve room for any smaller task later in the + // priority scan. If every queued task is oversized, + // admit one at the start of the frame so a pool + // migration cannot be deferred forever. + if (uploaded_bytes == 0 and oversized_fallback == null) { + oversized_fallback = .{ + .key = key, + .chunk = chunk, + .mesh = mesh, + .lod_idx = i, + .staging_bytes = staging_bytes, + }; + } else { + self.requeueUpload(i, chunk); + } continue; } @@ -224,6 +239,14 @@ pub fn processUploadsWithBudget(self: *Self, upload_budget_bytes: usize) void { } }; } + if (oversized_fallback) |fallback| { + if (task == null and !completed_without_upload and uploaded_bytes == 0) { + fallback.chunk.pin(); + task = fallback; + } else { + self.requeueUpload(fallback.lod_idx, fallback.chunk); + } + } self.mutex.unlock(); if (!made_progress) { @@ -286,7 +309,7 @@ pub fn processUploadsWithBudget(self: *Self, upload_budget_bytes: usize) void { }; self.profiling.end(.upload_submission, submission_timer); - uploaded_bytes += upload_task.staging_bytes; + uploaded_bytes = std.math.add(usize, uploaded_bytes, upload_task.staging_bytes) catch std.math.maxInt(usize); self.profiling.addUploadBytes(upload_task.staging_bytes); // Count only ownership that reached the GPU bridge successfully. A // requeued failure arrives here once on its eventual successful upload, @@ -384,7 +407,9 @@ pub fn demoteRegionForRemesh(self: *Self, key: LODRegionKey, chunk: *LODChunk) v chunk.setState(.generated); self.pending_region_count += 1; self.enqueueTransition(key, chunk, .mesh); - } else if (chunk.getState() == .mesh_ready) { + } else if (chunk.getState() == .mesh_ready or chunk.getState() == .generated) { + // A queued transition captured the pre-edit source revision. Invalidate + // it and publish a mesh transition for the authoritative edited data. chunk.job_token +%= 1; chunk.setState(.generated); self.enqueueTransition(key, chunk, .mesh); diff --git a/modules/world-lod/src/lod_mesh.zig b/modules/world-lod/src/lod_mesh.zig index 2c70c13c..7e85bcd8 100644 --- a/modules/world-lod/src/lod_mesh.zig +++ b/modules/world-lod/src/lod_mesh.zig @@ -25,6 +25,14 @@ const BufferHandle = rhi_types.BufferHandle; const RhiError = rhi_types.RhiError; const QuadricSimplifier = @import("world-meshing").meshing.quadric_simplifier.QuadricSimplifier; const log = @import("engine-core").log; + +/// Chunk-derived and edited source columns can contain cave and overhang spans +/// surrounded by worldgen-only samples. Rendering those partial underground +/// intervals at a streaming boundary exposes a giant terrain cross-section. +/// Their authoritative surface height remains safe for the heightfield path. +pub fn canBuildColumnSpans(data: *const LODSimplifiedData) bool { + return data.hasVerticalSpans() and !data.hasNonWorldgenColumns(); +} const lod_seam = @import("lod_seam.zig"); const resources_mod = @import("lod_mesh_resources.zig"); const geom = @import("lod_geometry.zig"); @@ -665,7 +673,7 @@ pub const LODMesh = struct { /// when spans are not available. This is intentionally exposed as a test/config hook. pub fn buildFromColumnSpans(self: *LODMesh, data: *const LODSimplifiedData, world_x: i32, world_z: i32, atlas: *const TextureAtlas) !void { if (data.width < 2) return error.EmptyData; - if (!data.hasVerticalSpans()) return self.buildFromSimplifiedData(data, world_x, world_z, atlas); + if (!canBuildColumnSpans(data)) return self.buildFromSimplifiedData(data, world_x, world_z, atlas); const region_size: f32 = @floatFromInt(lod_chunk.regionSizeBlocks(self.lod_level)); const cell_size = region_size / @as(f32, @floatFromInt(data.width - 1)); @@ -1025,6 +1033,15 @@ pub const LODMesh = struct { } }; +test "chunk-derived span sources use the stable heightfield fallback" { + var data = try LODSimplifiedData.initWithVerticalSpans(std.testing.allocator, .lod2); + defer data.deinit(); + + try std.testing.expect(canBuildColumnSpans(&data)); + data.setColumnProvenance(0, 0, .chunk_derived); + try std.testing.expect(!canBuildColumnSpans(&data)); +} + /// LOD Mesh Builder - builds meshes for LOD regions pub const LODMeshBuilder = struct { allocator: std.mem.Allocator, diff --git a/modules/world-lod/src/lod_renderer.zig b/modules/world-lod/src/lod_renderer.zig index 5b03cb4c..686ba069 100644 --- a/modules/world-lod/src/lod_renderer.zig +++ b/modules/world-lod/src/lod_renderer.zig @@ -131,12 +131,6 @@ fn expandContiguousReadyDiskRadius(checker: ?ChunkChecker, checker_ctx: ?*anyopa return @max(max_radius, 0); } -fn detailChunkKey(chunk_x: i32, chunk_z: i32) u64 { - const x_bits: u32 = @bitCast(chunk_x); - const z_bits: u32 = @bitCast(chunk_z); - return (@as(u64, x_bits) << 32) | @as(u64, z_bits); -} - fn selectLODDescriptorStream(render_ctx: anytype, layer: LODRenderLayer, compact: bool, gpu: bool) void { if (comptime !@hasDecl(@TypeOf(render_ctx), "setLODDescriptorStream")) return; const stream: rhi_types.LODDescriptorStream = switch (layer) { @@ -244,7 +238,6 @@ const VisibleRegion = struct { model: Mat4, mask_radius: f32, lod_fade: f32, - suppresses_detail: bool = false, }; const MAX_LOD_MDI_REGIONS: usize = 2048; @@ -267,15 +260,6 @@ pub fn LODRenderer(comptime RHI: type) type { instance_data: std.ArrayListUnmanaged(rhi_types.InstanceData), draw_list: std.ArrayListUnmanaged(*LODMesh), projection_regions: std.ArrayListUnmanaged(VisibleRegion), - /// Number of projected LOD terrain regions that currently cover each - /// detailed chunk. Counts let a failed region release only its own - /// provisional ownership without flashing unrelated chunks back on. - suppressed_detail_chunks: std.AutoHashMapUnmanaged(u64, u16), - suppression_camera_chunk_x: i32, - suppression_camera_chunk_z: i32, - suppression_detail_radius: i32, - suppression_ready_radius: i32, - suppression_failed: bool, cached_ready_disk_camera_x: i32, cached_ready_disk_camera_z: i32, cached_ready_disk_max_radius: i32, @@ -294,6 +278,10 @@ pub fn LODRenderer(comptime RHI: type) type { /// stride remains the wider grid's stride and can feed invalid vertex /// IDs to the water vertex-pulling path on RADV. compact_index_buffers: [2][2][COMPACT_GRID_WIDTHS.len]rhi_types.BufferHandle, + /// Static index uploads are recorded in the first LOD render frame. + /// They become drawable only after that frame has been submitted. + compact_index_upload_frame: ?u64, + compact_index_init_failed: bool, frame_index: usize, frame_serial: u64, enable_mdi: bool, @@ -315,13 +303,38 @@ pub fn LODRenderer(comptime RHI: type) type { /// streams are not reported as two independent culling submissions. gpu_culling_submitted_frame: ?u64, - fn createCompactIndexBuffer(allocator: std.mem.Allocator, resources: anytype, width: u32, include_skirts: bool) !rhi_types.BufferHandle { + fn uploadCompactIndexBuffer(allocator: std.mem.Allocator, resources: anytype, handle: rhi_types.BufferHandle, width: u32, include_skirts: bool) !void { const indices = try compactGridIndices(allocator, width, include_skirts); defer allocator.free(indices); - const handle = try resources.createBuffer(std.mem.sliceAsBytes(indices).len, .index); - errdefer resources.destroyBuffer(handle); try resources.uploadBuffer(handle, std.mem.sliceAsBytes(indices)); - return handle; + } + + /// Record static compact topology uploads only after a render frame has + /// opened its staging/transfer slot. Uploads queued during world setup + /// can otherwise be discarded when the first frame resets that slot. + fn ensureCompactIndexBuffers(self: *Self, frame_serial: u64) bool { + if (self.compact_index_upload_frame) |upload_frame| return frame_serial > upload_frame; + if (self.compact_index_init_failed) return false; + + const resources = if (@hasDecl(RHI, "resourceManager")) self.rhi.resourceManager() else self.rhi; + inline for (.{ LODLevel.lod3, LODLevel.lod4 }, 0..) |lod, idx| { + const max_width = @import("world-core").LODSimplifiedData.getGridSize(lod); + inline for (.{ true, false }, 0..) |include_skirts, layer_idx| for (COMPACT_GRID_WIDTHS, 0..) |width, width_idx| { + if (width > max_width) continue; + const handle = self.compact_index_buffers[idx][layer_idx][width_idx]; + if (handle == 0) { + self.compact_index_init_failed = true; + log.log.err("Compact LOD index topology is missing LOD{} width={} layer={}", .{ @intFromEnum(lod), width, layer_idx }); + return false; + } + uploadCompactIndexBuffer(self.allocator, resources, handle, width, include_skirts) catch |err| { + log.log.errWithTrace("Failed to upload compact LOD index topology: {}", .{err}); + return false; + }; + }; + } + self.compact_index_upload_frame = frame_serial; + return false; } /// Allocates LOD renderer GPU buffers and per-frame indirect draw resources. @@ -353,12 +366,16 @@ pub fn LODRenderer(comptime RHI: type) type { } var compact_index_buffers = std.mem.zeroes([2][2][COMPACT_GRID_WIDTHS.len]rhi_types.BufferHandle); errdefer for (&compact_index_buffers) |*lod_handles| for (lod_handles) |layer_handles| for (layer_handles) |handle| if (handle != 0) resources.destroyBuffer(handle); - inline for (.{ LODLevel.lod3, LODLevel.lod4 }, 0..) |lod, idx| { - const max_width = @import("world-core").LODSimplifiedData.getGridSize(lod); - inline for (.{ true, false }, 0..) |include_skirts, layer_idx| for (COMPACT_GRID_WIDTHS, 0..) |width, width_idx| { - if (width > max_width) continue; - compact_index_buffers[idx][layer_idx][width_idx] = try createCompactIndexBuffer(allocator, resources, width, include_skirts); - }; + if (comptime @hasDecl(RHI, "resourceManager") or @hasDecl(RHI, "uploadBuffer") or @hasDecl(RHI, "updateBuffer")) { + inline for (.{ LODLevel.lod3, LODLevel.lod4 }, 0..) |lod, idx| { + const max_width = @import("world-core").LODSimplifiedData.getGridSize(lod); + inline for (.{ true, false }, 0..) |include_skirts, layer_idx| for (COMPACT_GRID_WIDTHS, 0..) |width, width_idx| { + _ = include_skirts; + if (width > max_width) continue; + const byte_count = compactGridIndexCount(width, layer_idx == 0) * @sizeOf(u32); + compact_index_buffers[idx][layer_idx][width_idx] = try resources.createBuffer(byte_count, .index); + }; + } } const gpu_culling_requested = gpuCullingRequested(build_options.benchmark_gpu_culling, engine_core.envFlag("ZIGCRAFT_LOD_GPU_CULLING", false)); @@ -378,12 +395,6 @@ pub fn LODRenderer(comptime RHI: type) type { .instance_data = .empty, .draw_list = .empty, .projection_regions = .empty, - .suppressed_detail_chunks = .empty, - .suppression_camera_chunk_x = 0, - .suppression_camera_chunk_z = 0, - .suppression_detail_radius = 0, - .suppression_ready_radius = -1, - .suppression_failed = false, .cached_ready_disk_camera_x = 0, .cached_ready_disk_camera_z = 0, .cached_ready_disk_max_radius = -1, @@ -397,6 +408,8 @@ pub fn LODRenderer(comptime RHI: type) type { .vertex_pools = vertex_pools, .compact_pool = CompactLODPool.init(allocator), .compact_index_buffers = compact_index_buffers, + .compact_index_upload_frame = null, + .compact_index_init_failed = false, .frame_index = 0, .frame_serial = 0, .enable_mdi = !engine_core.envFlag("ZIGCRAFT_DISABLE_LOD_MDI", false), @@ -440,7 +453,6 @@ pub fn LODRenderer(comptime RHI: type) type { self.instance_data.deinit(self.allocator); self.draw_list.deinit(self.allocator); self.projection_regions.deinit(self.allocator); - self.suppressed_detail_chunks.deinit(self.allocator); self.gpu_candidates.deinit(self.allocator); self.gpu_candidate_keys.deinit(self.allocator); self.compact_fallback_regions.deinit(self.allocator); @@ -473,6 +485,7 @@ pub fn LODRenderer(comptime RHI: type) type { profiling: ?*LODProfilingCollector, ) void { self.frame_serial = frame_serial; + _ = self.ensureCompactIndexBuffers(frame_serial); const query = if (@hasDecl(RHI, "query")) self.rhi.query() else self.rhi; self.frame_index = query.getFrameIndex(); // Reusing a frame slot means the RHI has completed that slot's @@ -528,7 +541,7 @@ pub fn LODRenderer(comptime RHI: type) type { if (!self.gpu_culling_requested or self.projection_frame == frame_serial) return; const visibility_timer = if (profiling) |profile| profile.begin() else null; defer if (profiling) |profile| profile.end(.visibility, visibility_timer); - self.buildVisibilityProjection(meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, false, null, detail_render_radius, stats, profiling) catch |err| { + self.buildVisibilityProjection(meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, false, max_distance_chunks, detail_render_radius, stats, profiling) catch |err| { log.log.err("LOD GPU culling projection failed: {}", .{err}); return; }; @@ -797,11 +810,7 @@ pub fn LODRenderer(comptime RHI: type) type { profiling: ?*LODProfilingCollector, ) !void { self.projection_regions.clearRetainingCapacity(); - self.suppressed_detail_chunks.clearRetainingCapacity(); - errdefer { - self.projection_regions.clearRetainingCapacity(); - self.suppressed_detail_chunks.clearRetainingCapacity(); - } + errdefer self.projection_regions.clearRetainingCapacity(); if (stats) |s| { s.drawn = [_]u32{0} ** LODLevel.count; s.instances = [_]u32{0} ** LODLevel.count; @@ -817,10 +826,6 @@ pub fn LODRenderer(comptime RHI: type) type { const chunk_radius = @max(detail_render_radius, 0); const ready_detail_radius = self.readyDiskRadiusForProjection(chunk_checker, checker_ctx, camera_chunk.chunk_x, camera_chunk.chunk_z, chunk_radius); const handoff_mask_radius = readyDiskMaskRadius(ready_detail_radius); - self.suppression_camera_chunk_x = camera_chunk.chunk_x; - self.suppression_camera_chunk_z = camera_chunk.chunk_z; - self.suppression_detail_radius = chunk_radius; - self.suppression_ready_radius = ready_detail_radius; var i = lod_chunk.activeLODCount(config); while (i > 0) { i -= 1; @@ -855,7 +860,6 @@ pub fn LODRenderer(comptime RHI: type) type { if (profiling) |profile| profile.addRejected(); continue; } - const bounds = chunk.worldBounds(); const chunk_bounds = chunk.chunkBounds(); // Cheap radial and frustum tests intentionally precede the @@ -891,13 +895,11 @@ pub fn LODRenderer(comptime RHI: type) type { } } - const suppresses_detail = mesh.drawRange(.terrain) != null; try self.projection_regions.append(self.allocator, .{ .key = entry.key_ptr.*, .model = Mat4.translate(Vec3.init(@as(f32, @floatFromInt(bounds.min_x)) - camera_pos.x, -camera_pos.y, @as(f32, @floatFromInt(bounds.min_z)) - camera_pos.z)), .mask_radius = mask_radius, .lod_fade = chunk.transitionFadeProgress(), - .suppresses_detail = suppresses_detail, }); telemetry.accepted += 1; if (profiling) |profile| profile.addVisible(); @@ -906,73 +908,9 @@ pub fn LODRenderer(comptime RHI: type) type { } } - fn addPartialDetailSuppression(self: *Self, region_key: LODRegionKey) void { - self.adjustPartialDetailSuppression(region_key, true); - } - - fn resetProjectedDetailSuppression(self: *Self) void { - self.suppressed_detail_chunks.clearRetainingCapacity(); - self.suppression_failed = false; - for (self.projection_regions.items) |visible| { - if (visible.suppresses_detail) self.addPartialDetailSuppression(visible.key); - } - } - - fn releasePartialDetailSuppression(self: *Self, visible: VisibleRegion) void { - if (!visible.suppresses_detail) return; - self.adjustPartialDetailSuppression(visible.key, false); - } - - fn adjustPartialDetailSuppression(self: *Self, region_key: LODRegionKey, add: bool) void { - if (self.suppression_failed) return; - const bounds = region_key.chunkBounds(); - const detail_radius = @as(i64, self.suppression_ready_radius); - const detail_radius_sq = if (detail_radius >= 0) detail_radius * detail_radius else -1; - const chunk_radius_sq = @as(i64, self.suppression_detail_radius) * @as(i64, self.suppression_detail_radius); - - var cz = bounds.min_z; - while (cz <= bounds.max_z) : (cz += 1) { - var cx = bounds.min_x; - while (cx <= bounds.max_x) : (cx += 1) { - const dx = @as(i64, cx) - @as(i64, self.suppression_camera_chunk_x); - const dz = @as(i64, cz) - @as(i64, self.suppression_camera_chunk_z); - const dist_sq = dx * dx + dz * dz; - if (dist_sq > chunk_radius_sq or dist_sq <= detail_radius_sq) continue; - const key = detailChunkKey(cx, cz); - if (add) { - if (self.suppressed_detail_chunks.getPtr(key)) |count| { - count.* +|= 1; - } else { - self.suppressed_detail_chunks.put(self.allocator, key, 1) catch { - // Ownership bookkeeping must fail open: render - // detail rather than leaving a terrain hole. - self.suppressed_detail_chunks.clearRetainingCapacity(); - self.suppression_failed = true; - return; - }; - } - } else if (self.suppressed_detail_chunks.getPtr(key)) |count| { - if (count.* > 1) { - count.* -= 1; - } else { - _ = self.suppressed_detail_chunks.remove(key); - } - } - } - } - } - - pub fn suppressesDetailChunk(self: *const Self, chunk_x: i32, chunk_z: i32) bool { - return self.suppressed_detail_chunks.contains(detailChunkKey(chunk_x, chunk_z)); - } - /// Returns false only when a prepared GPU frame could not be submitted; /// callers must rebuild the CPU projection with normal culling first. fn renderProjectedLayer(self: *Self, all_meshes: *const [LODLevel.count]MeshMap, layer: LODRenderLayer, stats: ?*LODStats, profiling: ?*LODProfilingCollector) bool { - // A frame can render terrain more than once (for example G-pass and - // opaque). Rebuild provisional ownership for each terrain pass so a - // failure in one pass cannot make a later pass inherit stale releases. - if (layer == .terrain) self.resetProjectedDetailSuppression(); const query = if (@hasDecl(RHI, "query")) self.rhi.query() else self.rhi; const render_ctx = if (@hasDecl(RHI, "renderContext")) self.rhi.renderContext() else self.rhi; self.frame_index = query.getFrameIndex(); @@ -1013,18 +951,9 @@ pub fn LODRenderer(comptime RHI: type) type { defer if (compact_timing_started and @hasDecl(RHI, "timing")) self.rhi.timing().endPassTiming(compact_timing_name); for (self.projection_regions.items) |visible| { const lod_idx = @intFromEnum(visible.key.lod); - const mesh = all_meshes[lod_idx].get(visible.key) orelse { - if (layer == .terrain) self.releasePartialDetailSuppression(visible); - continue; - }; - const range = mesh.drawRange(layer) orelse { - if (layer == .terrain) self.releasePartialDetailSuppression(visible); - continue; - }; - if (!mesh.isReady() or range.count == 0) { - if (layer == .terrain) self.releasePartialDetailSuppression(visible); - continue; - } + const mesh = all_meshes[lod_idx].get(visible.key) orelse continue; + const range = mesh.drawRange(layer) orelse continue; + if (!mesh.isReady() or range.count == 0) continue; if (gpu_submitted and self.gpuCandidateDraws(visible.key, layer)) continue; if (mesh.isCompact()) { if (!compact_timing_started and @hasDecl(RHI, "timing")) { @@ -1046,22 +975,16 @@ pub fn LODRenderer(comptime RHI: type) type { // Draw parent fallbacks only after the compact pass // scope closes so expanded draws do not contaminate // compact GPU timing. - self.compact_fallback_regions.append(self.allocator, visible) catch { - if (layer == .terrain) self.releasePartialDetailSuppression(visible); - }; + self.compact_fallback_regions.append(self.allocator, visible) catch {}; } continue; } const lod_y_offset: f32 = if (layer == .fluid) 0.0 else -0.05; var instance = rhi_types.InstanceData{ .model = visible.model, .mask_radius = visible.mask_radius, .lod_fade = visible.lod_fade, .padding = .{ 0, 0 } }; instance.model.data[3][1] += lod_y_offset; - self.instance_data.append(self.allocator, instance) catch { - if (layer == .terrain) self.releasePartialDetailSuppression(visible); - continue; - }; + self.instance_data.append(self.allocator, instance) catch continue; self.draw_list.append(self.allocator, mesh) catch { _ = self.instance_data.pop(); - if (layer == .terrain) self.releasePartialDetailSuppression(visible); continue; }; if (mesh.isPooled()) { @@ -1073,7 +996,6 @@ pub fn LODRenderer(comptime RHI: type) type { }) catch { _ = self.draw_list.pop(); _ = self.instance_data.pop(); - if (layer == .terrain) self.releasePartialDetailSuppression(visible); continue; }; } @@ -1089,8 +1011,7 @@ pub fn LODRenderer(comptime RHI: type) type { } } for (self.compact_fallback_regions.items) |visible| { - const fallback_drawn = self.renderParentFallback(all_meshes, visible, layer, render_ctx, profiling); - if (!fallback_drawn and layer == .terrain) self.releasePartialDetailSuppression(visible); + _ = self.renderParentFallback(all_meshes, visible, layer, render_ctx, profiling); } if (self.instance_data.items.len == 0) return true; const indirect_drawn = self.enable_mdi and self.renderIndirectBatches(render_ctx, query); @@ -1146,13 +1067,11 @@ pub fn LODRenderer(comptime RHI: type) type { .model = child_model, .mask_radius = child.mask_radius, .lod_fade = 1.0, - .suppresses_detail = child.suppresses_detail, }; if (mesh.isCompact()) { const result = self.renderCompactMesh(render_ctx, parent_visible, mesh, layer); if (result == .drawn) { if (profiling) |profile| profile.addCompactSubmission(); - if (layer == .terrain) self.addPartialDetailSuppression(parent_key); return true; } self.noteCompactRenderFailure(mesh, result, profiling); @@ -1167,7 +1086,6 @@ pub fn LODRenderer(comptime RHI: type) type { } else { render_ctx.draw(mesh.bufferHandle(), range.count, .triangles); } - if (layer == .terrain) self.addPartialDetailSuppression(parent_key); return true; } return false; @@ -1324,6 +1242,8 @@ pub fn LODRenderer(comptime RHI: type) type { } fn compactIndexBuffer(self: *const Self, lod: LODLevel, width: u32, layer: LODRenderLayer) rhi_types.BufferHandle { + const upload_frame = self.compact_index_upload_frame orelse return 0; + if (self.frame_serial <= upload_frame) return 0; if (lod != .lod3 and lod != .lod4) return 0; const width_index = compactGridVariant(width) orelse return 0; return self.compact_index_buffers[@intFromEnum(lod) - @intFromEnum(LODLevel.lod3)][if (layer == .fluid) 1 else 0][width_index]; @@ -1405,7 +1325,6 @@ pub fn LODRenderer(comptime RHI: type) type { if (profiling) |profile| profile.addRejected(); continue; } - const bounds = chunk.worldBounds(); const chunk_bounds = chunk.chunkBounds(); @@ -1712,9 +1631,6 @@ pub fn LODRenderer(comptime RHI: type) type { result.pool_cpu_shadow_bytes += capacity; } const compact = self.compact_pool.memoryStats(); - result.pool_gpu_capacity_bytes += compact.capacity_bytes; - result.pool_gpu_allocated_bytes += compact.allocated_bytes; - result.pool_gpu_slack_bytes += compact.free_bytes; result.compact_pool_capacity_bytes = compact.capacity_bytes; result.compact_pool_allocated_bytes = compact.allocated_bytes; result.compact_pool_free_bytes = compact.free_bytes; @@ -1771,10 +1687,6 @@ pub fn LODRenderer(comptime RHI: type) type { const renderer: *Self = @ptrCast(@alignCast(self_ptr)); return renderer.memoryStats(); } - fn suppressesDetailChunkFn(self_ptr: *anyopaque, chunk_x: i32, chunk_z: i32) bool { - const renderer: *Self = @ptrCast(@alignCast(self_ptr)); - return renderer.suppressesDetailChunk(chunk_x, chunk_z); - } fn deinitFn(self_ptr: *anyopaque) void { const renderer: *Self = @ptrCast(@alignCast(self_ptr)); renderer.deinit(); @@ -1784,7 +1696,6 @@ pub fn LODRenderer(comptime RHI: type) type { .render_fn = Wrapper.renderFn, .render_frame_fn = Wrapper.renderFrameFn, .prepare_frame_fn = Wrapper.prepareFrameFn, - .suppresses_detail_chunk_fn = Wrapper.suppressesDetailChunkFn, .memory_stats_fn = Wrapper.memoryStatsFn, .deinit_fn = Wrapper.deinitFn, .ptr = self, @@ -1798,6 +1709,13 @@ fn isRegionInRange(bounds: ChunkBounds, camera_pos: Vec3, max_distance_chunks: i return bounds.intersectsRadius(camera_chunk.chunk_x, camera_chunk.chunk_z, max_distance_chunks); } +test "distant LOD render limit rejects disconnected resident regions" { + const near = ChunkBounds{ .min_x = 200, .min_z = -16, .max_x = 240, .max_z = 16 }; + const far = ChunkBounds{ .min_x = 300, .min_z = -16, .max_x = 340, .max_z = 16 }; + try std.testing.expect(isRegionInRange(near, Vec3.zero, 256)); + try std.testing.expect(!isRegionInRange(far, Vec3.zero, 256)); +} + fn calculateBandFade(config: ILODConfig, lod: LODLevel, bounds: ChunkBounds, camera_pos: Vec3) f32 { const lod_idx = @intFromEnum(lod); if (lod_idx == 0) return 1.0; @@ -1838,12 +1756,12 @@ fn cullCommandFor(mesh: *const LODMesh, range: ?LODMesh.DrawRange, compact: bool fn extractPlanes(view_proj: Mat4) [6][4]f32 { const m = view_proj.data; var planes = [6][4]f32{ - .{ m[3][0] + m[0][0], m[3][1] + m[0][1], m[3][2] + m[0][2], m[3][3] + m[0][3] }, - .{ m[3][0] - m[0][0], m[3][1] - m[0][1], m[3][2] - m[0][2], m[3][3] - m[0][3] }, - .{ m[3][0] - m[1][0], m[3][1] - m[1][1], m[3][2] - m[1][2], m[3][3] - m[1][3] }, - .{ m[3][0] + m[1][0], m[3][1] + m[1][1], m[3][2] + m[1][2], m[3][3] + m[1][3] }, - .{ m[3][0] + m[2][0], m[3][1] + m[2][1], m[3][2] + m[2][2], m[3][3] + m[2][3] }, - .{ m[3][0] - m[2][0], m[3][1] - m[2][1], m[3][2] - m[2][2], m[3][3] - m[2][3] }, + .{ m[0][3] + m[0][0], m[1][3] + m[1][0], m[2][3] + m[2][0], m[3][3] + m[3][0] }, + .{ m[0][3] - m[0][0], m[1][3] - m[1][0], m[2][3] - m[2][0], m[3][3] - m[3][0] }, + .{ m[0][3] + m[0][1], m[1][3] + m[1][1], m[2][3] + m[2][1], m[3][3] + m[3][1] }, + .{ m[0][3] - m[0][1], m[1][3] - m[1][1], m[2][3] - m[2][1], m[3][3] - m[3][1] }, + .{ m[0][2], m[1][2], m[2][2], m[3][2] }, + .{ m[0][3] - m[0][2], m[1][3] - m[1][2], m[2][3] - m[2][2], m[3][3] - m[3][2] }, }; for (&planes) |*plane| { const length = @sqrt(plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]); @@ -1891,6 +1809,23 @@ test "benchmark GPU-culling build option requests telemetry without environment" try std.testing.expect(!gpuCullingRequested(false, false)); } +test "GPU culling planes match the canonical high-altitude frustum" { + const camera = Vec3.init(0.0, 900.0, 0.0); + const target = Vec3.init(256.0, 64.0, -384.0); + const view = Mat4.lookAt(Vec3.zero, target.sub(camera), Vec3.init(0.0, 0.0, -1.0)); + const projection = Mat4.perspectiveReverseZ(std.math.pi / 3.0, 16.0 / 9.0, 0.5, 20_000.0); + const view_proj = projection.multiply(view); + const canonical = Frustum.fromViewProj(view_proj); + const gpu_planes = extractPlanes(view_proj); + + for (canonical.planes, gpu_planes) |expected, actual| { + try std.testing.expectApproxEqAbs(expected.normal.x, actual[0], 0.0001); + try std.testing.expectApproxEqAbs(expected.normal.y, actual[1], 0.0001); + try std.testing.expectApproxEqAbs(expected.normal.z, actual[2], 0.0001); + try std.testing.expectApproxEqAbs(expected.distance, actual[3], 0.0001); + } +} + test "compact grid variants retain exact decimated topology" { for (COMPACT_GRID_WIDTHS, 0..) |width, expected_variant| { try std.testing.expectEqual(expected_variant, compactGridVariant(width).?); @@ -1905,6 +1840,47 @@ test "compact grid variants retain exact decimated topology" { try std.testing.expect(compactGridVariant(7) == null); } +test "compact index topology uploads in the first render frame before becoming drawable" { + const MockState = struct { + next_handle: u32 = 1, + uploads: u32 = 0, + destroys: u32 = 0, + }; + const MockRHI = struct { + state: *MockState, + + pub fn createBuffer(self: @This(), _: usize, _: anytype) !u32 { + const handle = self.state.next_handle; + self.state.next_handle += 1; + return handle; + } + pub fn uploadBuffer(self: @This(), _: u32, data: []const u8) !void { + try std.testing.expect(data.len > 0); + self.state.uploads += 1; + } + pub fn destroyBuffer(self: @This(), _: u32) void { + self.state.destroys += 1; + } + pub fn waitIdle(_: @This()) void {} + }; + + var state = MockState{}; + const Renderer = LODRenderer(MockRHI); + const renderer = try Renderer.init(std.testing.allocator, .{ .state = &state }); + defer renderer.deinit(); + + try std.testing.expectEqual(@as(u32, 0), state.uploads); + renderer.frame_serial = 7; + try std.testing.expect(!renderer.ensureCompactIndexBuffers(7)); + try std.testing.expectEqual(@as(u32, 22), state.uploads); + try std.testing.expectEqual(@as(rhi_types.BufferHandle, 0), renderer.compactIndexBuffer(.lod4, 65, .terrain)); + + renderer.frame_serial = 8; + try std.testing.expect(renderer.ensureCompactIndexBuffers(8)); + try std.testing.expect(renderer.compactIndexBuffer(.lod4, 65, .terrain) != 0); + try std.testing.expectEqual(@as(u32, 22), state.uploads); +} + test "ready detail disk stops at the first incomplete chunk ring" { const CheckerState = struct { missing_x: i32, @@ -1970,6 +1946,14 @@ test "LODRenderer init/deinit lifecycle" { try std.testing.expectEqual(@as(u32, rhi_types.MAX_FRAMES_IN_FLIGHT * 2), mock_state.buffers_created); try std.testing.expectEqual(@as(u32, 0), mock_state.buffers_destroyed); + renderer.compact_pool.buffer_handle = 999; + const memory = renderer.memoryStats(); + try std.testing.expectEqual(@as(usize, 0), memory.pool_gpu_capacity_bytes); + try std.testing.expectEqual(@as(usize, 0), memory.pool_gpu_allocated_bytes); + try std.testing.expectEqual(@as(usize, 0), memory.pool_gpu_slack_bytes); + try std.testing.expectEqual(@as(usize, CompactLODPool.CAPACITY_BYTES), memory.compact_pool_capacity_bytes); + renderer.compact_pool.buffer_handle = 0; + renderer.deinit(); // Verify deinit destroyed all buffers @@ -2611,9 +2595,6 @@ test "LODRenderer keeps mask for partially covered chunk regions" { try std.testing.expectEqual(@as(u32, 1), mock_state.draw_calls); try std.testing.expectEqual(readyDiskMaskRadius(0), mock_state.last_mask_radius); - try std.testing.expect(!renderer.suppressesDetailChunk(0, 0)); - try std.testing.expect(renderer.suppressesDetailChunk(2, 0)); - try std.testing.expect(renderer.suppressesDetailChunk(3, 0)); } test "LODRenderer skips coarse LOD when finer coverage is ready" { diff --git a/modules/world-lod/src/lod_scheduler.zig b/modules/world-lod/src/lod_scheduler.zig index e81e58ec..e4ac8da8 100644 --- a/modules/world-lod/src/lod_scheduler.zig +++ b/modules/world-lod/src/lod_scheduler.zig @@ -76,54 +76,12 @@ pub const MAX_LOD_SCAN_STEPS: usize = 512; const MAX_PENDING_LOD_REGIONS = @import("lod_manager_context.zig").MAX_PENDING_LOD_REGIONS; const MAX_LOD_REGIONS = @import("lod_manager_context.zig").MAX_LOD_REGIONS; -const HORIZON_SEED_DIRECTIONS = [_][2]i32{ - .{ 1024, 0 }, .{ 946, 392 }, .{ 724, 724 }, .{ 392, 946 }, - .{ 0, 1024 }, .{ -392, 946 }, .{ -724, 724 }, .{ -946, 392 }, - .{ -1024, 0 }, .{ -946, -392 }, .{ -724, -724 }, .{ -392, -946 }, - .{ 0, -1024 }, .{ 392, -946 }, .{ 724, -724 }, .{ 946, -392 }, -}; - -fn scaledSeedOffset(component: i32, radius: i64) i64 { - const product = @as(i64, component) * radius; - return @divTrunc(product + (if (product >= 0) @as(i64, 512) else -512), 1024); -} - -fn initialHorizonSeedRank(rx: i32, rz: i32, player_rx: i32, player_rz: i32, region_radius: i64) ?usize { - const outer_radius = @max(1, region_radius - 1); - for (HORIZON_SEED_DIRECTIONS, 0..) |dir, i| { - if (@as(i64, rx) == @as(i64, player_rx) + scaledSeedOffset(dir[0], outer_radius) and - @as(i64, rz) == @as(i64, player_rz) + scaledSeedOffset(dir[1], outer_radius)) return i; - } - - const middle_radius = @max(1, @divFloor(outer_radius, 2)); - for (0..8) |i| { - const dir = HORIZON_SEED_DIRECTIONS[i * 2]; - if (@as(i64, rx) == @as(i64, player_rx) + scaledSeedOffset(dir[0], middle_radius) and - @as(i64, rz) == @as(i64, player_rz) + scaledSeedOffset(dir[1], middle_radius)) return HORIZON_SEED_DIRECTIONS.len + i; - } - return null; -} - fn regionCoordinateRepresentable(region: i64, scale: i32) bool { const min_chunk = region * @as(i64, scale); const max_chunk = min_chunk + @as(i64, scale) - 1; return min_chunk >= std.math.minInt(i32) and max_chunk <= std.math.maxInt(i32); } -fn horizonSeedCoordinate(index: usize, player_rx: i32, player_rz: i32, region_radius: i64) [2]i64 { - const outer_radius = @max(1, region_radius - 1); - const middle_radius = @max(1, @divFloor(outer_radius, 2)); - const direction = if (index < HORIZON_SEED_DIRECTIONS.len) - HORIZON_SEED_DIRECTIONS[index] - else - HORIZON_SEED_DIRECTIONS[(index - HORIZON_SEED_DIRECTIONS.len) * 2]; - const radius = if (index < HORIZON_SEED_DIRECTIONS.len) outer_radius else middle_radius; - return .{ - @as(i64, player_rx) + scaledSeedOffset(direction[0], radius), - @as(i64, player_rz) + scaledSeedOffset(direction[1], radius), - }; -} - fn nextRingCoordinate(state: *LODScanState, player_rx: i32, player_rz: i32, region_radius: i64) [2]i64 { if (state.next_ring > region_radius) { state.next_ring = 0; @@ -156,6 +114,31 @@ fn nextRingCoordinate(state: *LODScanState, player_rx: i32, player_rz: i32, regi return .{ @as(i64, player_rx) + relative[0], @as(i64, player_rz) + relative[1] }; } +fn updateScanOrigin(state: *LODScanState, player_rx: i32, player_rz: i32, effective_radius: i32, restart_on_move: bool) void { + const moved_rx = @as(i64, player_rx) - @as(i64, state.player_rx); + const moved_rz = @as(i64, player_rz) - @as(i64, state.player_rz); + if (state.effective_radius != effective_radius) { + state.* = .{ + .player_rx = player_rx, + .player_rz = player_rz, + .effective_radius = effective_radius, + }; + return; + } + if (player_rx == state.player_rx and player_rz == state.player_rz) return; + + state.player_rx = player_rx; + state.player_rz = player_rz; + // Preserve refinement progress during ordinary traversal. The coarsest + // level opts into restarting for every region-origin change so a moving + // player cannot leave an unvisited hole in the fallback disk. + if (restart_on_move or @max(@abs(moved_rx), @abs(moved_rz)) > 8) { + state.next_ring = 0; + state.ring_index = 0; + state.last_examined = 0; + } +} + pub fn priorityRank(lod: LODLevel, active_lod_count: usize) usize { const lod_idx: usize = @intFromEnum(lod); const coarsest_idx = if (active_lod_count == 0) 0 else active_lod_count - 1; @@ -244,7 +227,11 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu // Keep only the bounded best candidates while walking the horizon. This // avoids allocating/sorting an entry for every potential region. - const Candidate = struct { key: LODRegionKey, encoded_priority: i32, selection_priority: i64, preserve_priority: bool }; + const Candidate = struct { + key: LODRegionKey, + encoded_priority: i32, + scan_state_before: LODScanState, + }; const max_candidates = maxQueueCandidatesForLOD(lod, active_lod_count); var candidates = std.ArrayListUnmanaged(Candidate).empty; defer candidates.deinit(ctx.allocator); @@ -255,26 +242,7 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu ctx.mutex.lock(); const candidate_storage = &ctx.regions[@intFromEnum(lod)]; const state = &ctx.scan_states[@intFromEnum(lod)]; - const moved_rx = @as(i64, player_rx) - @as(i64, state.player_rx); - const moved_rz = @as(i64, player_rz) - @as(i64, state.player_rz); - const radius_changed = state.effective_radius != radius; - if (radius_changed) { - state.* = .{ - .player_rx = player_rx, - .player_rz = player_rz, - .effective_radius = radius, - }; - } else if (player_rx != state.player_rx or player_rz != state.player_rz) { - state.player_rx = player_rx; - state.player_rz = player_rz; - if (is_coarsest) state.seed_index = 0; - // Preserve outward progress during ordinary traversal, but restart near - // the player after a teleport so the new location receives fallback. - if (@max(@abs(moved_rx), @abs(moved_rz)) > 8) { - state.next_ring = 0; - state.ring_index = 0; - } - } + updateScanOrigin(state, player_rx, player_rz, radius, is_coarsest); if (state.next_ring > region_radius) { state.next_ring = 0; state.ring_index = 0; @@ -282,11 +250,8 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu var examined: usize = 0; while (examined < MAX_LOD_SCAN_STEPS and candidates.items.len < max_candidates) : (examined += 1) { - const coordinate = if (is_coarsest and state.seed_index < HORIZON_SEED_DIRECTIONS.len + 8) blk: { - const seed = horizonSeedCoordinate(state.seed_index, player_rx, player_rz, region_radius); - state.seed_index += 1; - break :blk seed; - } else nextRingCoordinate(state, player_rx, player_rz, region_radius); + const scan_state_before = state.*; + const coordinate = nextRingCoordinate(state, player_rx, player_rz, region_radius); const rx = coordinate[0]; const rz = coordinate[1]; diag.considered += 1; @@ -325,24 +290,12 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu const center_cx = @as(i64, key.rx) * @as(i64, scale) + @divFloor(scale, 2); const center_cz = @as(i64, key.rz) * @as(i64, scale) + @divFloor(scale, 2); const distance_priority = encodePriority(lod, center_cx - @as(i64, ctx.player_cx), center_cz - @as(i64, ctx.player_cz), velocity, active_lod_count); - const seed_rank = if (is_coarsest) initialHorizonSeedRank(key.rx, key.rz, player_rx, player_rz, region_radius) else null; - // Preserve the spatial seed order in the worker queue as well as - // candidate admission; otherwise distance reprioritization makes - // the newly admitted outer shell wait behind nearby coarse tiles. - const encoded_priority = if (seed_rank) |rank| - lodPriorityBias(lod, active_lod_count) | @as(i32, @intCast(rank)) - else - distance_priority; - const selection_priority: i64 = if (seed_rank) |rank| @intCast(rank) else distance_priority; const candidate = Candidate{ .key = key, - .encoded_priority = encoded_priority, - .selection_priority = selection_priority, - .preserve_priority = seed_rank != null, + .encoded_priority = distance_priority, + .scan_state_before = scan_state_before, }; - var insert_at: usize = 0; - while (insert_at < candidates.items.len and candidates.items[insert_at].selection_priority <= selection_priority) : (insert_at += 1) {} - candidates.insert(ctx.allocator, insert_at, candidate) catch |err| { + candidates.append(ctx.allocator, candidate) catch |err| { ctx.mutex.unlock(); return err; }; @@ -360,16 +313,32 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu var resident_regions: usize = 0; for (ctx.regions) |region_map| resident_regions += region_map.count(); for (candidates.items) |cand| { - if (queued_count >= max_candidates) break; + // Candidate discovery advances the persistent scan cursor. If bounded + // admission cannot accept this coordinate, rewind to it so the next + // update resumes at the first actual coverage hole instead of skipping + // the remainder of a ring and producing directional strips. + if (queued_count >= max_candidates) { + state.* = cand.scan_state_before; + break; + } if (ctx.pending_regions) |pending| { - if (pending.* >= MAX_PENDING_LOD_REGIONS) break; + if (pending.* >= MAX_PENDING_LOD_REGIONS) { + state.* = cand.scan_state_before; + break; + } } const existing = storage.get(cand.key); - if (existing == null and resident_regions >= ctx.resident_region_limit) break; + if (existing == null and resident_regions >= ctx.resident_region_limit) { + state.* = cand.scan_state_before; + break; + } if (existing == null) if (ctx.logical_memory_bytes) |logical| { const reservation = ctx.logical_region_reservation_bytes; - if (reservation > ctx.logical_memory_limit_bytes -| logical.*) break; + if (reservation > ctx.logical_memory_limit_bytes -| logical.*) { + state.* = cand.scan_state_before; + break; + } }; // A cancelled worker keeps the region pinned until it observes its // cancellation signal. Do not reset that signal by dispatching a new @@ -392,7 +361,7 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu chunk.job_token = ctx.next_job_token.*; ctx.next_job_token.* += 1; chunk.job_priority = cand.encoded_priority; - chunk.preserve_job_priority = cand.preserve_priority; + chunk.preserve_job_priority = false; if (ctx.defer_generation_dispatch) { chunk.setState(.queued_for_generation); if (ctx.generation_tokens) |tokens| { @@ -471,6 +440,35 @@ test "LOD scheduling seeds horizon before detailed refinements" { try std.testing.expectEqual(@as(usize, 3), priorityLevelIndex(4, LODLevel.count)); } +test "LOD scheduling preserves ring progress during ordinary movement" { + var state = LODScanState{ + .player_rx = 12, + .player_rz = -4, + .effective_radius = 1024, + .next_ring = 9, + .ring_index = 17, + .last_examined = MAX_LOD_SCAN_STEPS, + }; + + updateScanOrigin(&state, 13, -4, 1024, false); + + try std.testing.expectEqual(@as(i32, 13), state.player_rx); + try std.testing.expectEqual(@as(i32, -4), state.player_rz); + try std.testing.expectEqual(@as(i64, 9), state.next_ring); + try std.testing.expectEqual(@as(i64, 17), state.ring_index); + try std.testing.expectEqual(MAX_LOD_SCAN_STEPS, state.last_examined); + + updateScanOrigin(&state, 30, -4, 1024, false); + try std.testing.expectEqual(@as(i64, 0), state.next_ring); + try std.testing.expectEqual(@as(i64, 0), state.ring_index); + try std.testing.expectEqual(@as(usize, 0), state.last_examined); + + state = .{ .player_rx = 12, .player_rz = -4, .effective_radius = 1024, .next_ring = 9, .ring_index = 17 }; + updateScanOrigin(&state, 13, -4, 1024, true); + try std.testing.expectEqual(@as(i64, 0), state.next_ring); + try std.testing.expectEqual(@as(i64, 0), state.ring_index); +} + test "LOD scheduling caps resident regions and logical admission memory" { const allocator = std.testing.allocator; @@ -551,7 +549,7 @@ test "LOD scheduling caps resident regions and logical admission memory" { try std.testing.expectEqual(lod_chunk.LODState.generating, chunk.state); } -test "LOD scheduling caps LOD0 flood while still queuing horizon jobs" { +test "LOD scheduling fills nearby horizon fallback before distant regions" { const allocator = std.testing.allocator; var regions: [LODLevel.count]RegionMap = undefined; @@ -653,12 +651,13 @@ test "LOD scheduling caps LOD0 flood while still queuing horizon jobs" { try std.testing.expectEqual(LOD0_QUEUE_CANDIDATE_LIMIT, lod0_count); try std.testing.expectEqual(HORIZON_QUEUE_CANDIDATE_LIMIT, horizon_count); - // The bootstrap batch includes azimuthally distributed outer-horizon - // seeds instead of spending every admission near the player. - try std.testing.expect(max_horizon_dist_sq >= 400 * 400); + // Coarsest fallback advances concentrically. A cold start must not spend + // its bounded admission budget on disconnected outer-horizon islands. + const nearby_limit_chunks: i64 = 6 * @as(i64, @intCast(LODLevel.lod4.chunksPerSide())); + try std.testing.expect(max_horizon_dist_sq <= nearby_limit_chunks * nearby_limit_chunks); } -test "LOD scheduling advances horizon beyond existing nearest batch" { +test "LOD scheduling does not skip horizon coordinates with one admission slot" { const allocator = std.testing.allocator; var regions: [LODLevel.count]RegionMap = undefined; @@ -690,6 +689,7 @@ test "LOD scheduling advances horizon beyond existing nearest batch" { const config_iface = config.interface(); var mutex: sync.RwLock = .{}; var next_job_token: u32 = 1; + var pending_regions: usize = MAX_PENDING_LOD_REGIONS - 1; var radius_reduction = [_]i32{0} ** LODLevel.count; var scan_states = [_]LODScanState{LODScanState{}} ** LODLevel.count; var coverage_ctx: u8 = 0; @@ -714,13 +714,31 @@ test "LOD scheduling advances horizon beyond existing nearest batch" { .coverage_ptr = &coverage_ctx, .are_all_chunks_loaded = Coverage.neverCovered, .radius_reduction = &radius_reduction, + .pending_regions = &pending_regions, }; - try queueLODRegions(ctx, .lod4, Vec3.zero, null, null); - try std.testing.expectEqual(HORIZON_QUEUE_CANDIDATE_LIMIT, queue_ptrs[LODLevel.count - 1].count()); + // Repeatedly free exactly one pipeline slot. The scheduler must resume at + // the first unadmitted coordinate rather than advancing 64 candidates and + // leaving a directionally biased set of holes behind. + for (0..HORIZON_QUEUE_CANDIDATE_LIMIT) |_| { + try queueLODRegions(ctx, .lod4, Vec3.zero, null, null); + try std.testing.expectEqual(@as(usize, 1), queue_ptrs[LODLevel.count - 1].count()); + _ = queue_ptrs[LODLevel.count - 1].pop().?; + pending_regions = MAX_PENDING_LOD_REGIONS - 1; + } - try queueLODRegions(ctx, .lod4, Vec3.zero, null, null); - try std.testing.expectEqual(HORIZON_QUEUE_CANDIDATE_LIMIT * 2, queue_ptrs[LODLevel.count - 1].count()); + try std.testing.expectEqual(HORIZON_QUEUE_CANDIDATE_LIMIT, regions[@intFromEnum(LODLevel.lod4)].count()); + var z: i32 = -3; + while (z <= 3) : (z += 1) { + var x: i32 = -3; + while (x <= 3) : (x += 1) { + try std.testing.expect(regions[@intFromEnum(LODLevel.lod4)].contains(.{ .rx = x, .rz = z, .lod = .lod4 })); + } + } + var iter = regions[@intFromEnum(LODLevel.lod4)].keyIterator(); + while (iter.next()) |key| { + try std.testing.expect(@max(@abs(key.rx), @abs(key.rz)) <= 4); + } } test "LOD scheduling biases priorities toward movement direction" { diff --git a/modules/world-lod/src/lod_upload_queue.zig b/modules/world-lod/src/lod_upload_queue.zig index 412f06fb..f7cca390 100644 --- a/modules/world-lod/src/lod_upload_queue.zig +++ b/modules/world-lod/src/lod_upload_queue.zig @@ -183,9 +183,6 @@ pub const LODRenderInterface = struct { stats: ?*LODStats, profiling: ?*LODProfilingCollector, ) void = null, - /// Frame-stable terrain ownership query. A true result means a visible LOD - /// region owns this chunk until the contiguous detail disk reaches it. - suppresses_detail_chunk_fn: ?*const fn (self_ptr: *anyopaque, chunk_x: i32, chunk_z: i32) bool = null, memory_stats_fn: ?*const fn (self_ptr: *anyopaque) LODRendererMemoryStats = null, /// Destroy renderer resources. deinit_fn: *const fn (self_ptr: *anyopaque) void, @@ -246,9 +243,4 @@ pub const LODRenderInterface = struct { if (self.memory_stats_fn) |memory_stats| return memory_stats(self.ptr); return .{}; } - - pub fn suppressesDetailChunk(self: LODRenderInterface, chunk_x: i32, chunk_z: i32) bool { - const query = self.suppresses_detail_chunk_fn orelse return false; - return query(self.ptr, chunk_x, chunk_z); - } }; diff --git a/modules/world-runtime/src/chunk_queue_coordinator.zig b/modules/world-runtime/src/chunk_queue_coordinator.zig index 645181e6..f3450bec 100644 --- a/modules/world-runtime/src/chunk_queue_coordinator.zig +++ b/modules/world-runtime/src/chunk_queue_coordinator.zig @@ -87,6 +87,17 @@ const MeshInputRevisions = struct { const RECOVERY_SCAN_PERIOD: u64 = 60; const MAX_MISSING_SCAN_STEPS: usize = 1024; +/// Persisted chunks are authoritative over advisory LOD source caches, even +/// when an older cache snapshot already carries edited provenance. Freshly +/// generated chunks retain the opt-in ingestion path until it is qualified for +/// the default streaming workload. +fn lodIngestionProvenance(load_result: LoadResult, ingest_generated_chunks: bool) ?LODColumnProvenance { + return switch (load_result) { + .success, .success_relight_required => .edited, + else => if (ingest_generated_chunks) .chunk_derived else null, + }; +} + pub const ChunkQueueCoordinator = struct { allocator: std.mem.Allocator, storage: *ChunkStorage, @@ -736,12 +747,11 @@ pub const ChunkQueueCoordinator = struct { if (chunk_data.chunk.state == .generated and chunk_data.chunk.job_token == job.data.chunk.job_token) { self.markNeighborsForRemesh(cx, cz); self.enqueueReadyNeighborhood(cx, cz); - // Feed the real chunk into the LOD system so distant terrain is - // derived from actual blocks (chunk_derived provenance) instead - // of worldgen sampling. The chunk is pinned for this call. - if (engine_core.envFlag("ZIGCRAFT_LOD_CHUNK_INGEST", false)) { - if (self.lod_manager) |mgr| { - mgr.ingestChunk(cx, cz, &chunk_data.chunk, .chunk_derived); + // Saved chunks always override advisory LOD cache data. Fresh + // generated chunks keep the separately qualified opt-in path. + if (self.lod_manager) |mgr| { + if (lodIngestionProvenance(load_result, engine_core.envFlag("ZIGCRAFT_LOD_CHUNK_INGEST", false))) |provenance| { + mgr.ingestChunk(cx, cz, &chunk_data.chunk, provenance); } } } @@ -1052,6 +1062,13 @@ test "runtime edits enqueue dirty renderable chunks immediately" { try testing.expectEqual(@as(usize, 1), coordinator.pending_mesh_incoming.items.len); } +test "saved chunks always override advisory LOD source snapshots" { + try std.testing.expectEqual(LODColumnProvenance.edited, lodIngestionProvenance(.success, false).?); + try std.testing.expectEqual(LODColumnProvenance.edited, lodIngestionProvenance(.success_relight_required, false).?); + try std.testing.expectEqual(@as(?LODColumnProvenance, null), lodIngestionProvenance(.not_found, false)); + try std.testing.expectEqual(LODColumnProvenance.chunk_derived, lodIngestionProvenance(.not_found, true).?); +} + test "missing chunk scan cursor covers concentric square rings without duplicates" { var coordinator: ChunkQueueCoordinator = undefined; coordinator.resetMissingScan(10, -5, 2); diff --git a/modules/world-runtime/src/world.zig b/modules/world-runtime/src/world.zig index a6f87df1..52129943 100644 --- a/modules/world-runtime/src/world.zig +++ b/modules/world-runtime/src/world.zig @@ -619,6 +619,7 @@ pub const World = struct { allocator: std.mem.Allocator, generator: Generator, render_distance: i32, + lod_chunk_render_radius_limit: i32, horizon_distance: i32, rhi: RHI, paused: bool = false, @@ -652,8 +653,11 @@ pub const World = struct { const storage = ChunkStorage.init(allocator); const safe_mode = runtime_env.safeModeEnabled(); const strict_safe_mode = runtime_env.strictSafeModeEnabled(); - const requested_render_distance: i32 = @max(options.render_distance, 2); - const streamer_render_distance = effectiveChunkRenderRadius(requested_render_distance); + const safe_render_distance: i32 = @max(options.render_distance, 2); + const streamer_render_distance: i32 = if (options.lod_config) |lod_config| + effectiveChunkRenderRadius(safe_render_distance, lod_config.getChunkRenderRadius(), true) + else + effectiveChunkRenderRadius(safe_render_distance, safe_render_distance, false); const max_uploads: usize = if (strict_safe_mode) @as(usize, 4) else if (safe_mode) @@ -669,7 +673,8 @@ pub const World = struct { .streamer = undefined, .renderer = undefined, .allocator = allocator, - .render_distance = requested_render_distance, + .render_distance = safe_render_distance, + .lod_chunk_render_radius_limit = streamer_render_distance, .horizon_distance = if (options.lod_config) |lod_config| lod_config.getRadii()[LODLevel.count - 1] else LODConfig.default_horizon_radius, .generator = try registry.createGenerator(options.generator_index, options.seed, allocator), .rhi = options.rhi, @@ -710,13 +715,12 @@ pub const World = struct { world.renderer.getGpuMesher() != null, ); - log.log.info("World.init: initializing WorldStreamer (render_distance={}, requested={})", .{ streamer_render_distance, requested_render_distance }); + 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, 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| { world.lod = try WorldLOD.init(allocator, options.rhi, lod_config, lodGeneratorFromGenerator(world.generator), options.atlas); - world.lod.?.setChunkRenderRadius(streamer_render_distance); world.lod_enabled = true; world.streamer.setLODManager(world.lod.?.manager); } @@ -830,11 +834,37 @@ pub const World = struct { self.storage.chunks_mutex.unlock(); } + /// Applies pending block edits to LOD source data and waits for the + /// corresponding source-store writes. Full-detail save points call this + /// while resident chunks are still available to the ingestion resolver. + fn flushLODEditsForPersistence(self: *World) void { + const lod = self.lod orelse return; + lod.manager.flushEditedChunksNow(); + lod.manager.drainPendingIngestionsNow(); + lod.manager.flushDirtyStoresNow(); + // In-flight or currently missing target regions cannot accept the + // authoritative edit yet. Remove their settled old payloads so reload + // regenerates them instead of briefly displaying stale distant terrain. + lod.manager.invalidatePendingEditedStoresNow(); + } + + /// Starts bounded LOD persistence work without waiting for cache storage. + /// Autosave uses this path to avoid turning a slow source-store write into + /// an unbounded frame stall; explicit saves still use the full barrier. + fn queueLODEditsForPersistence(self: *World) void { + const lod = self.lod orelse return; + lod.manager.flushEditedChunksBounded(); + lod.manager.drainPendingIngestions(); + lod.manager.flushDirtyStores(); + } + /// Synchronously saves chunks marked dirty by mutations or streaming. /// Returns errors from persistence and leaves unsaved chunks dirty for later retry. pub fn saveAllModifiedChunks(self: *World) void { const sm = self.save_manager orelse return; + self.flushLODEditsForPersistence(); + var dirty_keys = self.enqueueModifiedChunks(sm); defer dirty_keys.deinit(self.allocator); @@ -852,6 +882,8 @@ pub const World = struct { const sm = self.save_manager orelse return; if (!sm.shouldAutoSave()) return; + self.queueLODEditsForPersistence(); + var dirty_keys = self.enqueueModifiedChunks(sm); defer dirty_keys.deinit(self.allocator); @@ -873,30 +905,50 @@ pub const World = struct { /// Set render distance and trigger chunk loading/unloading update pub fn setRenderDistance(self: *World, distance: i32) void { - const target = @max(distance, 2); + const requested = @max(distance, 2); + const target = if (self.safe_mode) @min(requested, self.safe_render_distance) else requested; if (self.render_distance != target) { + if (self.safe_mode and target != requested) { + log.log.warn("ZIGCRAFT_SAFE_MODE clamped render distance {} -> {}", .{ distance, target }); + } log.log.info("Render distance changed: {} -> {}", .{ self.render_distance, target }); self.render_distance = target; self.applyRenderDistance(); } } + /// Updates the full-detail streaming radius limit. Presets seed this value + /// during startup; the live World setting then synchronizes it to the + /// explicitly requested full-detail radius. + pub fn setLODChunkRenderRadiusLimit(self: *World, limit: i32) void { + const target = @max(limit, 1); + if (self.lod_chunk_render_radius_limit == target) return; + self.lod_chunk_render_radius_limit = target; + self.applyRenderDistance(); + } + fn applyRenderDistance(self: *World) void { - const chunk_render_radius = effectiveChunkRenderRadius(self.render_distance); - self.horizon_distance = LODConfig.normalizeHorizonDistance(self.render_distance, self.horizon_distance); + const chunk_render_radius = effectiveChunkRenderRadius(self.render_distance, self.lod_chunk_render_radius_limit, self.lod != null); + self.streamer.setRenderDistance(chunk_render_radius); if (self.lod) |lod| { - const radii = LODConfig.radiiForDistances(self.render_distance, self.horizon_distance); + const radii = effectiveLODRadii(self.render_distance, self.lod_chunk_render_radius_limit, self.horizon_distance); lod.setChunkRenderRadius(chunk_render_radius); lod.setRadii(radii); - lod.setActiveLODCount(LODConfig.activeCountForRadii(radii)); } - self.streamer.setRenderDistance(chunk_render_radius); } - pub fn effectiveChunkRenderRadius(render_distance: i32) i32 { - return @max(render_distance, 2); + pub fn effectiveChunkRenderRadius(render_distance: i32, preset_limit: i32, lod_enabled: bool) i32 { + const requested = @max(render_distance, 2); + return if (lod_enabled) @max(@min(requested, preset_limit), 2) else requested; + } + + /// Builds the live LOD ladder from the same capped near-detail radius used + /// by chunk streaming. The requested horizon remains independent. + pub fn effectiveLODRadii(render_distance: i32, preset_limit: i32, horizon_distance: i32) [LODLevel.count]i32 { + const chunk_render_radius = effectiveChunkRenderRadius(render_distance, preset_limit, true); + return LODConfig.radiiForDistances(chunk_render_radius, horizon_distance); } /// Changes the distant-terrain horizon distance. @@ -907,9 +959,8 @@ pub const World = struct { log.log.info("Horizon distance changed: {} -> {}", .{ self.horizon_distance, target }); self.horizon_distance = target; if (self.lod) |lod| { - const radii = LODConfig.radiiForDistances(self.render_distance, target); + const radii = effectiveLODRadii(self.render_distance, self.lod_chunk_render_radius_limit, target); lod.setRadii(radii); - lod.setActiveLODCount(LODConfig.activeCountForRadii(radii)); } } @@ -1064,7 +1115,7 @@ pub const World = struct { pub fn prepareLODCulling(self: *World, view_proj: Mat4, camera_pos: Vec3) void { if (self.lod) |lod| { const detail_render_radius = @min(self.streamer.getActiveRenderDistance(), lod.manager.config.getChunkRenderRadius()); - lod.manager.prepareFrame(self.renderer.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkTerrainReadyForHandoff, @ptrCast(&self.storage), null, detail_render_radius); + lod.manager.prepareFrame(self.renderer.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkTerrainReadyForHandoff, @ptrCast(&self.storage), lod.manager.getHorizonRenderRadius(), detail_render_radius); } } diff --git a/modules/world-runtime/src/world_facade_tests.zig b/modules/world-runtime/src/world_facade_tests.zig index 2eecac86..3454d66e 100644 --- a/modules/world-runtime/src/world_facade_tests.zig +++ b/modules/world-runtime/src/world_facade_tests.zig @@ -13,11 +13,17 @@ const WorldMutationCoordinator = @import("world_mutation.zig").WorldMutationCoor const SaveManager = @import("world-persistence").SaveManager; const World = world_mod.World; -test "explicit render distance controls full-detail reach" { - try testing.expectEqual(@as(i32, 22), World.effectiveChunkRenderRadius(22)); - try testing.expectEqual(@as(i32, 4096), World.effectiveChunkRenderRadius(4096)); - try testing.expectEqual(std.math.maxInt(i32), World.effectiveChunkRenderRadius(std.math.maxInt(i32))); - try testing.expectEqual(@as(i32, 6), World.effectiveChunkRenderRadius(6)); +test "full-detail radius follows active preset cap" { + try testing.expectEqual(@as(i32, 12), World.effectiveChunkRenderRadius(16, 12, true)); + try testing.expectEqual(@as(i32, 16), World.effectiveChunkRenderRadius(16, 16, true)); + try testing.expectEqual(@as(i32, 22), World.effectiveChunkRenderRadius(22, 10, false)); + try testing.expectEqual(@as(i32, 2), World.effectiveChunkRenderRadius(0, 12, true)); + try testing.expectEqual(@as(i32, 2), World.effectiveChunkRenderRadius(-8, 12, false)); +} + +test "live LOD radii follow the active full-detail preset cap" { + const expected = @import("world-lod").LODConfig.radiiForDistances(10, 1024); + try testing.expectEqual(expected, World.effectiveLODRadii(18, 10, 1024)); } test "full-detail render candidates use the streaming disk" { @@ -299,9 +305,12 @@ fn makeStorageOnlyWorld(allocator: std.mem.Allocator) world_mod.World { .allocator = allocator, .generator = undefined, .render_distance = 8, + .lod_chunk_render_radius_limit = 8, .horizon_distance = 512, .rhi = undefined, .paused = false, + .safe_mode = false, + .safe_render_distance = 8, .lod = null, .lod_enabled = false, .save_manager = null, diff --git a/modules/world-runtime/src/world_renderer.zig b/modules/world-runtime/src/world_renderer.zig index 3d02c657..9f39332b 100644 --- a/modules/world-runtime/src/world_renderer.zig +++ b/modules/world-runtime/src/world_renderer.zig @@ -341,14 +341,15 @@ pub const WorldRenderer = struct { if (render_lod) { if (lod_manager) |lod_mgr| { + const lod_render_limit = lod_mgr.getHorizonRenderRadius(); if (layer != .fluid) { self.timing.beginPassTiming("LODTerrainPass"); - lod_mgr.renderFrame(self.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkTerrainReadyForHandoff, @ptrCast(self.storage), true, null, detail_render_radius, LODRenderLayer.terrain); + lod_mgr.renderFrame(self.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkTerrainReadyForHandoff, @ptrCast(self.storage), true, lod_render_limit, detail_render_radius, LODRenderLayer.terrain); self.timing.endPassTiming("LODTerrainPass"); } if (layer != .terrain and parseEnabledEnv(getenv("ZIGCRAFT_LOD_WATER"), true)) { self.timing.beginPassTiming("LODWaterPass"); - lod_mgr.renderFrame(self.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkTerrainReadyForHandoff, @ptrCast(self.storage), true, null, detail_render_radius, LODRenderLayer.fluid); + lod_mgr.renderFrame(self.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkTerrainReadyForHandoff, @ptrCast(self.storage), true, lod_render_limit, detail_render_radius, LODRenderLayer.fluid); self.timing.endPassTiming("LODWaterPass"); } } @@ -388,8 +389,9 @@ pub const WorldRenderer = struct { var total_vertices: u64 = 0; for (self.visible_chunks.items) |data| { - const suppress_terrain = render_lod and layer != .fluid and if (lod_manager) |mgr| mgr.suppressesDetailChunk(data.chunk.chunk_x, data.chunk.chunk_z) else false; - if (suppress_terrain and layer == .terrain) continue; + // LOD projection is not proof that GPU culling emitted replacement + // geometry. Keep detail as the fail-open fallback; the LOD shader + // mask owns overlap with the contiguous detailed area. if (layer != .fluid) { self.last_render_stats.chunks_rendered += 1; } @@ -400,11 +402,6 @@ pub const WorldRenderer = struct { const rel_y = -camera_pos.y; const model = Mat4.translate(Vec3.init(rel_x, rel_y, rel_z)); - if (suppress_terrain) { - total_vertices += self.drawChunkDirect(data, model, .fluid, true); - continue; - } - const is_camera_neighborhood = @abs(data.chunk.chunk_x - @as(i32, @intCast(pc_x))) <= 1 and @abs(data.chunk.chunk_z - @as(i32, @intCast(pc_z))) <= 1; if (!supports_indirect_first_instance or force_mdi_fallback or is_camera_neighborhood) { total_vertices += self.drawChunkDirect(data, model, layer, true); @@ -502,7 +499,7 @@ pub const WorldRenderer = struct { ); } - self.drawGuaranteedNearChunks(@intCast(pc_x), @intCast(pc_z), r_dist, camera_pos, lod_manager, render_lod, layer); + self.drawGuaranteedNearChunks(@intCast(pc_x), @intCast(pc_z), r_dist, camera_pos, layer); } fn drawChunkDirect(self: *WorldRenderer, data: *ChunkData, model: Mat4, layer: RenderLayer, count_vertices: bool) u64 { @@ -531,7 +528,7 @@ pub const WorldRenderer = struct { return total_vertices; } - fn drawGuaranteedNearChunks(self: *WorldRenderer, pc_x: i32, pc_z: i32, render_radius: i64, camera_pos: Vec3, lod_manager: ?*LODManager, render_lod: bool, layer: RenderLayer) void { + fn drawGuaranteedNearChunks(self: *WorldRenderer, pc_x: i32, pc_z: i32, render_radius: i64, camera_pos: Vec3, layer: RenderLayer) void { var dz: i32 = -1; while (dz <= 1) : (dz += 1) { var dx: i32 = -1; @@ -539,8 +536,6 @@ pub const WorldRenderer = struct { const cx = pc_x + dx; const cz = pc_z + dz; if (!isWithinChunkRenderRadius(@as(i64, cx), @as(i64, cz), @as(i64, pc_x), @as(i64, pc_z), render_radius)) continue; - const suppress_terrain = render_lod and layer != .fluid and if (lod_manager) |mgr| mgr.suppressesDetailChunk(cx, cz) else false; - if (suppress_terrain and layer == .terrain) continue; const data = self.storage.chunks.get(.{ .x = cx, .z = cz }) orelse continue; var already_drawn = false; @@ -555,7 +550,7 @@ pub const WorldRenderer = struct { const chunk_world_x: f32 = @floatFromInt(cx * CHUNK_SIZE_X); const chunk_world_z: f32 = @floatFromInt(cz * CHUNK_SIZE_Z); const model = Mat4.translate(Vec3.init(chunk_world_x - camera_pos.x, -camera_pos.y, chunk_world_z - camera_pos.z)); - _ = self.drawChunkDirect(data, model, if (suppress_terrain) .fluid else layer, false); + _ = self.drawChunkDirect(data, model, layer, false); } } } diff --git a/modules/world-runtime/src/world_streamer.zig b/modules/world-runtime/src/world_streamer.zig index 4b7f79c9..07bf87f3 100644 --- a/modules/world-runtime/src/world_streamer.zig +++ b/modules/world-runtime/src/world_streamer.zig @@ -621,41 +621,94 @@ pub const WorldStreamer = struct { const unload_distance = @as(i128, render_dist_unload) + CHUNK_UNLOAD_BUFFER; const unload_dist_sq = unload_distance * unload_distance; - self.storage.chunks_mutex.lock(); var to_remove = std.ArrayListUnmanaged(ChunkKey).empty; defer to_remove.deinit(self.allocator); - var unload_iter = self.storage.iteratorUnsafe(); - while (unload_iter.next()) |entry| { - const key = entry.key_ptr.*; - const data = entry.value_ptr.*; - const dx = @as(i128, key.x) - @as(i128, pc.chunk_x); - const dz = @as(i128, key.z) - @as(i128, pc.chunk_z); - if (dx * dx + dz * dz > unload_dist_sq) { - if (data.chunk.state != .generating and data.chunk.state != .meshing and - data.chunk.state != .uploading and - !data.chunk.isPinned()) - { - try to_remove.append(self.allocator, key); + { + self.storage.chunks_mutex.lock(); + defer self.storage.chunks_mutex.unlock(); + + var unload_iter = self.storage.iteratorUnsafe(); + while (unload_iter.next()) |entry| { + const key = entry.key_ptr.*; + const data = entry.value_ptr.*; + const dx = @as(i128, key.x) - @as(i128, pc.chunk_x); + const dz = @as(i128, key.z) - @as(i128, pc.chunk_z); + if (dx * dx + dz * dz > unload_dist_sq) { + if (data.chunk.state != .generating and data.chunk.state != .meshing and + data.chunk.state != .uploading and + !data.chunk.isPinned()) + { + try to_remove.append(self.allocator, key); + } } } } for (to_remove.items) |key| { - if (self.save_manager) |sm| { + const unload_candidate = blk: { + self.storage.chunks_mutex.lock(); + defer self.storage.chunks_mutex.unlock(); + + const data = self.storage.chunks.get(key) orelse continue; + if (data.chunk.state == .generating or data.chunk.state == .meshing or + data.chunk.state == .uploading or data.chunk.isPinned()) + { + continue; + } + const previous_state = data.chunk.state; + data.chunk.pin(); + data.chunk.state = .unloading; + break :blk .{ .chunk = &data.chunk, .previous_state = previous_state }; + }; + const chunk = unload_candidate.chunk; + + // Do not acquire the LOD manager while holding chunks_mutex: LOD + // visibility takes the locks in the opposite order. The pin keeps + // this authoritative snapshot alive through edit ingestion/save. + var defer_unload = false; + if (chunk.generated) { + if (self.lod_coordinator.lod_manager) |manager| { + const retain_pending = manager.isInRange(key.x, key.z); + const pending_mask = manager.flushEditedChunkForUnload(key.x, key.z, chunk, retain_pending); + defer_unload = retain_pending and pending_mask != 0; + } + } + + // Keep an edited full-detail source resident while visible LOD + // levels are still in flight. Once the player leaves the LOD + // horizon, the persisted chunk becomes the durable repair source. + if (defer_unload) { + self.storage.chunks_mutex.lock(); if (self.storage.chunks.get(key)) |data| { - if (data.chunk.modified and data.chunk.generated) { - data.chunk.pin(); - sm.enqueueSave(&data.chunk); - data.chunk.modified = false; - data.chunk.unpin(); + if (&data.chunk == chunk and data.chunk.state == .unloading) { + data.chunk.state = unload_candidate.previous_state; } } + chunk.unpin(); + self.storage.chunks_mutex.unlock(); + continue; } + + const save_enqueued = chunk.modified and chunk.generated and self.save_manager != null; + if (save_enqueued) self.save_manager.?.enqueueSave(chunk); + self.gpu_acceleration.freeChunk(key.x, key.z); - _ = self.storage.removeUnlocked(key.x, key.z, self.vertex_allocator); + + self.storage.chunks_mutex.lock(); + if (self.storage.chunks.get(key)) |data| { + if (&data.chunk == chunk and data.chunk.state == .unloading) { + if (save_enqueued) data.chunk.modified = false; + data.chunk.unpin(); + _ = self.storage.removeUnlocked(key.x, key.z, self.vertex_allocator); + } else { + chunk.unpin(); + } + } else { + chunk.unpin(); + } + self.storage.chunks_mutex.unlock(); } - self.storage.chunks_mutex.unlock(); } fn logMissingChunkDiagnostic(self: *WorldStreamer, pc_x: i32, pc_z: i32) void { diff --git a/modules/worldgen-overworld-v2/src/lod_sampling.zig b/modules/worldgen-overworld-v2/src/lod_sampling.zig index e38fd9c6..4f911b00 100644 --- a/modules/worldgen-overworld-v2/src/lod_sampling.zig +++ b/modules/worldgen-overworld-v2/src/lod_sampling.zig @@ -172,7 +172,7 @@ fn classifyLODSample(self: anytype, wx: f32, wz: f32) ClassifiedLODSample { fn sampleLODColumn(self: anytype, wx: i32, wz: i32) ColumnSample { const base_height = util.floorToI32(terrain_shape.baseTerrainLevelAtPoint(self, wx, wz)); - const terrain_height = terrain_shape.estimateGroundedTerrainHeight(self, wx, wz, base_height); + const terrain_height = sampleTerrainHeightForLOD(self, wx, wz); const climate_sample = climate.sampleClimate(self, wx, wz); const river = terrain_shape.isRiverColumn(self, wx, wz) and terrain_height >= self.params.sea_level - 18 and terrain_height <= self.params.sea_level + 1; const biome = biomes.selectBiome(self, wx, wz, terrain_height, river, climate_sample.temperature, climate_sample.humidity); @@ -189,6 +189,14 @@ fn sampleLODColumn(self: anytype, wx: i32, wz: i32) ColumnSample { }; } +/// Uses the same highest-solid terrain estimate as full chunk generation. +/// The grounded-only estimate stops at the first air gap and can miss elevated +/// mountain terrain above an underwater base, turning distant islands into sea. +pub fn sampleTerrainHeightForLOD(self: anytype, wx: i32, wz: i32) i32 { + const base_height = util.floorToI32(terrain_shape.baseTerrainLevelAtPoint(self, wx, wz)); + return terrain_shape.estimateTerrainHeight(self, wx, wz, base_height); +} + 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; diff --git a/modules/worldgen-overworld-v2/src/root.zig b/modules/worldgen-overworld-v2/src/root.zig index 639fa727..4359fc10 100644 --- a/modules/worldgen-overworld-v2/src/root.zig +++ b/modules/worldgen-overworld-v2/src/root.zig @@ -61,7 +61,9 @@ pub const OverworldV2Generator = struct { pub const INFO = GeneratorInfo{ .name = "Overworld V2", .description = "Luanti v7-style terrain with ridges, mountains, rivers, and cave noise.", - .version = 2, + // Version 3 invalidates LOD source caches generated with the grounded + // height estimator, which could classify elevated islands as ocean. + .version = 3, }; pub const Params = struct { @@ -501,6 +503,29 @@ test "overworld-v2 generates representative LOD data" { try std.testing.expect(material_columns > 0); } +test "overworld-v2 LOD height sampling retains elevated terrain above underwater bases" { + var gen = OverworldV2Generator.init(12345, std.testing.allocator); + const sea_level = gen.params.sea_level; + var found_elevated_island = false; + + var wz: i32 = -2048; + scan: while (wz <= 2048) : (wz += 32) { + var wx: i32 = -2048; + while (wx <= 2048) : (wx += 32) { + const base_height = util.floorToI32(terrain_shape.baseTerrainLevelAtPoint(&gen, wx, wz)); + const grounded_height = terrain_shape.estimateGroundedTerrainHeight(&gen, wx, wz, base_height); + const full_height = terrain_shape.estimateTerrainHeight(&gen, wx, wz, base_height); + if (grounded_height < sea_level and full_height >= sea_level) { + try std.testing.expectEqual(full_height, lod_sampling.sampleTerrainHeightForLOD(&gen, wx, wz)); + found_elevated_island = true; + break :scan; + } + } + } + + try std.testing.expect(found_elevated_island); +} + test "overworld-v2 LOD tree density covers forest variants" { try std.testing.expect(trees.treeDensityForBiome(.forest) > 0.5); try std.testing.expect(trees.treeDensityForBiome(.birch_forest) > 0.5); diff --git a/modules/worldgen-overworld/src/overworld_generator.zig b/modules/worldgen-overworld/src/overworld_generator.zig index 6e0cf980..4e66d113 100644 --- a/modules/worldgen-overworld/src/overworld_generator.zig +++ b/modules/worldgen-overworld/src/overworld_generator.zig @@ -48,7 +48,9 @@ pub const OverworldGenerator = struct { pub const INFO = GeneratorInfo{ .name = "Overworld", .description = "Standard terrain with diverse biomes and caves.", - .version = 1, + // Version 3 invalidates LOD source caches whose blended controls did + // not match the chunk-local controls used by full-detail generation. + .version = 3, }; allocator: std.mem.Allocator, @@ -305,13 +307,6 @@ pub const OverworldGenerator = struct { const world_x = region_x * region_size_i; const world_z = region_z * region_size_i; const sea_level = self.terrain_shape.getSeaLevel(); - const controls = region_pkg.RegionControlCorners.init( - self.terrain_shape.getRegionSeed(), - world_x, - world_z, - world_x + region_size_i, - world_z + region_size_i, - ); // Kept allocated (cheap: empty HashMap, no heap use until first put) so // tree hints can be re-enabled per-level in sampleRepresentativeLODColumn // without a signature change. Currently unused since compute_tree_hints @@ -326,7 +321,7 @@ pub const OverworldGenerator = struct { while (gx < data.width) : (gx += 1) { const wx = @as(f32, @floatFromInt(world_x)) + (@as(f32, @floatFromInt(gx)) / grid_max) * region_size_f; const wz = @as(f32, @floatFromInt(world_z)) + (@as(f32, @floatFromInt(gz)) / grid_max) * region_size_f; - const sample = self.sampleRepresentativeLODColumn(wx, wz, region_size_f / grid_max, sea_level, controls, &tree_hint_cache, lod_level); + const sample = self.sampleRepresentativeLODColumn(wx, wz, region_size_f / grid_max, sea_level, &tree_hint_cache, lod_level); data.setGeneratedColumn(gx, gz, sample.height, sample.biome, sample.layers, sample.color, sample.water, sample.lighting, sample.vegetation); } } @@ -355,7 +350,7 @@ pub const OverworldGenerator = struct { const TreeHintChunk = tree_hints.TreeHintChunk; const TreeHintCache = std.AutoHashMap(u64, TreeHintChunk); - fn sampleRepresentativeLODColumn(self: *const OverworldGenerator, wx: f32, wz: f32, cell_span: f32, sea_level: i32, controls: region_pkg.RegionControlCorners, tree_hint_cache: *TreeHintCache, lod_level: LODLevel) RepresentativeLODColumn { + fn sampleRepresentativeLODColumn(self: *const OverworldGenerator, wx: f32, wz: f32, cell_span: f32, sea_level: i32, tree_hint_cache: *TreeHintCache, lod_level: LODLevel) RepresentativeLODColumn { // Single center sample. The previous 3x3 (9-sample) grid sampled a // sub-block neighborhood (sample_radius ~= cell_span/2 ~= 0.5-1.3 // blocks), so 8 of 9 samples were nearly co-located and returned @@ -389,7 +384,7 @@ pub const OverworldGenerator = struct { for (sample_offsets) |oz| { for (sample_offsets) |ox| { - const sample = self.classifyLODSample(wx + ox * sample_radius, wz + oz * sample_radius, sea_level, controls); + const sample = self.classifyLODSample(wx + ox * sample_radius, wz + oz * sample_radius, sea_level); const block_index = @intFromEnum(sample.surface_block); if (block_index < block_counts.len) block_counts[block_index] += 1; biome_counts[@intFromEnum(sample.biome)] += 1; @@ -551,7 +546,8 @@ pub const OverworldGenerator = struct { fn classifyTreeHintSample(context: *const anyopaque, wx: f32, wz: f32, sea_level: i32, controls: region_pkg.RegionControlCorners) tree_hints.ClassifiedSample { const self: *const OverworldGenerator = @ptrCast(@alignCast(context)); - const sample = self.classifyLODSample(wx, wz, sea_level, controls); + _ = controls; + const sample = self.classifyLODSample(wx, wz, sea_level); return .{ .biome = sample.biome, .surface_block = sample.surface_block, @@ -559,11 +555,11 @@ pub const OverworldGenerator = struct { }; } - fn classifyLODSample(self: *const OverworldGenerator, wx: f32, wz: f32, sea_level: i32, controls: region_pkg.RegionControlCorners) ClassifiedLODSample { + fn classifyLODSample(self: *const OverworldGenerator, wx: f32, wz: f32, sea_level: i32) ClassifiedLODSample { const wx_i: i32 = @intFromFloat(@floor(wx)); const wz_i: i32 = @intFromFloat(@floor(wz)); - const column = self.terrain_shape.sampleColumnDataWithControls(wx, wz, 0, controls.sample(wx_i, wz_i)); - const render_water_surface = column.terrain_height_i < sea_level and (column.is_ocean or self.isInlandWater(wx, wz, column.terrain_height_i)); + const column = self.sampleFullDetailColumnData(wx, wz, wx_i, wz_i); + const render_water_surface = column.terrain_height_i < sea_level; if (self.getCachedClassification(wx_i, wz_i)) |cached| { return .{ @@ -606,6 +602,22 @@ pub const OverworldGenerator = struct { }; } + /// Samples terrain with the same chunk-local region controls as + /// `prepareChunkPhaseData`. Canonical blended controls can select a very + /// different terrain height and place an LOD surface above the real chunk. + fn sampleFullDetailColumnData(self: *const OverworldGenerator, wx: f32, wz: f32, wx_i: i32, wz_i: i32) terrain_shape_mod.ColumnData { + const chunk_x = @divFloor(wx_i, CHUNK_SIZE_X) * CHUNK_SIZE_X; + const chunk_z = @divFloor(wz_i, CHUNK_SIZE_Z) * CHUNK_SIZE_Z; + const controls = region_pkg.RegionControlCorners.init( + self.terrain_shape.getRegionSeed(), + chunk_x, + chunk_z, + chunk_x + CHUNK_SIZE_X - 1, + chunk_z + CHUNK_SIZE_Z - 1, + ); + return self.terrain_shape.sampleColumnDataWithControls(wx, wz, 0, controls.sample(wx_i, wz_i)); + } + fn dominantBlock(counts: [world_core.MAX_BLOCK_TYPES]u32) BlockType { var best_index: usize = @intFromEnum(BlockType.grass); var best_count: u32 = 0; @@ -868,6 +880,65 @@ test "LOD cached water surfaces resolve to seabed block" { try std.testing.expectEqual(BlockType.water, OverworldGenerator.surfaceTypeToBlock(undefined, .water_deep)); } +test "LOD classification matches full-detail chunk controls and sea-level water" { + var gen = OverworldGenerator.initWithParams(12345, std.testing.allocator, testDecorationProvider(), .{ + .terrain_shape = .{ .disable_caves = true }, + .basic_chunks_only = true, + }); + defer gen.deinit(); + + const sea_level = gen.terrain_shape.getSeaLevel(); + const positions = [_][2]i32{ + .{ -1025, -1025 }, + .{ -513, 511 }, + .{ -1, 0 }, + .{ 0, 0 }, + .{ 511, 513 }, + .{ 1025, -1025 }, + }; + for (positions) |position| { + const wx: f32 = @floatFromInt(position[0]); + const wz: f32 = @floatFromInt(position[1]); + const chunk_x = @divFloor(position[0], CHUNK_SIZE_X) * CHUNK_SIZE_X; + const chunk_z = @divFloor(position[1], CHUNK_SIZE_Z) * CHUNK_SIZE_Z; + const controls = region_pkg.RegionControlCorners.init( + gen.terrain_shape.getRegionSeed(), + chunk_x, + chunk_z, + chunk_x + CHUNK_SIZE_X - 1, + chunk_z + CHUNK_SIZE_Z - 1, + ); + const column = gen.terrain_shape.sampleColumnDataWithControls(wx, wz, 0, controls.sample(position[0], position[1])); + const lod_sample = gen.classifyLODSample(wx, wz, sea_level); + try std.testing.expectEqual(column.terrain_height_i, lod_sample.terrain_height_i); + try std.testing.expectEqual(column.terrain_height_i < sea_level, lod_sample.render_water_surface); + } +} + +test "LOD surface height matches generated full-detail terrain at chunk origin" { + var gen = OverworldGenerator.initWithParams(12345, std.testing.allocator, testDecorationProvider(), .{ + .terrain_shape = .{ .disable_caves = true }, + .basic_chunks_only = true, + }); + defer gen.deinit(); + + var chunk = Chunk.init(0, 0); + try gen.generate(&chunk, null); + + var top_solid_y: i32 = 0; + var y: i32 = CHUNK_SIZE_Y - 1; + while (y >= 0) : (y -= 1) { + const block = chunk.getBlock(0, @intCast(y), 0); + if (block != .air and block != .water) { + top_solid_y = y; + break; + } + } + + const lod_sample = gen.classifyLODSample(0.0, 0.0, gen.terrain_shape.getSeaLevel()); + try std.testing.expectApproxEqAbs(@as(f32, @floatFromInt(top_solid_y)), lod_sample.terrain_height, 1.0); +} + fn testDecorationProvider() DecorationProvider { const NoopProvider = struct { fn decorate(_: ?*anyopaque, _: DecorationProvider.DecorationContext) void {} diff --git a/scripts/run_phase5_visual_smoke.sh b/scripts/run_phase5_visual_smoke.sh index 59e3ab31..9b95fbfb 100644 --- a/scripts/run_phase5_visual_smoke.sh +++ b/scripts/run_phase5_visual_smoke.sh @@ -28,6 +28,7 @@ capture() { local disable_lod_mdi=1 local scene_frame="$frame" local scene_delay="$delay" + local timeout_budget="$capture_timeout" local save_environment=() if [[ ( "$scene" == "lod-handoff" || "$scene" == "lod-handoff-traversal" || "$scene" == "fog-rapid-turn" || "$scene" == "teleport-handoff" || "$scene" == "saved-world-reload" ) && "$mode" == "auto" ]]; then gpu_culling=1 @@ -37,10 +38,16 @@ capture() { save_environment+=("ZIGCRAFT_SAVE_DIR=$save_dir") fi # Motion completes after 180 rendered frames, then streaming at the final - # pose must drain and remain stable for another 180 frames. Give real world - # generation wall-clock time to settle instead of racing a fast GPU's frame - # counter and failing the readiness assertion at frame 900. - if [[ "$scene" == "lod-handoff-traversal" || "$scene" == "fog-rapid-turn" || "$scene" == "teleport-handoff" ]]; then + # pose must drain and remain stable for another 180 frames. Saved-world + # create/reload also waits for persistence, cache ingestion, and GPU + # validation before capture. Give these paths real wall-clock time to + # settle instead of racing a fast GPU's frame counter and failing the + # readiness assertion at frame 900. + if [[ "$scene" == saved-world-* ]]; then + scene_frame="${PHASE5_VISUAL_SAVED_SCREENSHOT_FRAME:-4800}" + scene_delay="${PHASE5_VISUAL_SAVED_SCREENSHOT_DELAY_SECONDS:-30}" + timeout_budget="${PHASE5_VISUAL_SAVED_CAPTURE_TIMEOUT:-180s}" + elif [[ "$scene" == "lod-handoff-traversal" || "$scene" == "fog-rapid-turn" || "$scene" == "teleport-handoff" ]]; then scene_frame="${PHASE5_VISUAL_MOTION_SCREENSHOT_FRAME:-2400}" scene_delay="${PHASE5_VISUAL_MOTION_SCREENSHOT_DELAY_SECONDS:-15}" fi @@ -53,7 +60,7 @@ capture() { ZIGCRAFT_LOD_GPU_CULLING_VALIDATE="$gpu_culling" \ ZIGCRAFT_DISABLE_LOD_MDI="$disable_lod_mdi" \ ZIGCRAFT_PHASE5_SETTLE_FRAMES="${PHASE5_VISUAL_SETTLE_FRAMES:-180}" \ - timeout --preserve-status "$capture_timeout" nix develop --command zig build run \ + timeout --preserve-status "$timeout_budget" nix develop --command zig build run \ -Dskip-present \ -Dauto-preset=low \ -Dauto-world=flat \ diff --git a/src/game/app.zig b/src/game/app.zig index b5542dcc..7f16341b 100644 --- a/src/game/app.zig +++ b/src/game/app.zig @@ -392,6 +392,18 @@ pub const App = struct { return screen.screen(); } + fn applyPendingScreenTransitions(self: *App) !void { + if (!self.screen_manager.hasPendingTransition()) return; + + // Screen factories and destructors can load/close RmlUi documents and + // create/destroy complete world render resources. A submitted frame is + // still allowed to reference those resources after endFrame returns, so + // a command-recording boundary alone is insufficient. Drain in-flight + // GPU work before resolving any ownership-changing transition. + self.render_system.waitIdle(); + try self.screen_manager.applyPendingTransitions(); + } + pub fn runSingleFrame(self: *App) !void { self.frame_start_counter = c.SDL_GetPerformanceCounter(); self.time.update(); @@ -401,6 +413,15 @@ pub const App = struct { self.input.beginFrame(); self.input.pollEvents(); + // Do not record and submit one more frame after SDL reports that the + // window is closing. On some WSI/driver paths that final submission + // races surface teardown and returns VK_ERROR_DEVICE_LOST. + if (self.input.interface().shouldQuit()) return; + + // Screen replacement destroys the old world/session. Keep that + // ownership change outside a recording Vulkan frame; a device idle wait + // cannot sanitize unsubmitted command buffers. + try self.applyPendingScreenTransitions(); const swapchain_extent = self.render_system.getRHI().renderContext().getNativeSwapchainExtent(); if (build_options.skip_present and swapchain_extent[0] > 0 and swapchain_extent[1] > 0) { @@ -440,7 +461,8 @@ pub const App = struct { self.render_system.setViewport(window_width, window_height); self.render_system.beginFrame(); - errdefer self.render_system.endFrame(); + var frame_open = true; + defer if (frame_open) self.render_system.abortFrame(); try self.render_system.updateGlobalUniforms(.{ .view_proj = Mat4.identity, @@ -478,10 +500,17 @@ pub const App = struct { .lpv_origin = Vec3.zero, }); - try self.screen_manager.update(self.time.delta_time); + try self.screen_manager.updateCurrent(self.time.delta_time); + + // Screen updates can request shutdown (for example, a bounded startup + // diagnostic). Discard commands recorded so far instead of submitting + // a final frame after shutdown has begun. + if (self.input.interface().shouldQuit()) return; if (self.screen_manager.stack.items.len == 0) { self.render_system.endFrame(); + frame_open = false; + try self.applyPendingScreenTransitions(); return; } @@ -489,6 +518,10 @@ pub const App = struct { const cpu_ms = self.time.delta_time * 1000.0; try self.ui_manager.draw(&self.screen_manager, self.render_system.getRHI(), world_stats, cpu_ms, self.time.fps); + // The legacy immediate-mode menu resolves its Exit action while + // drawing. Never submit that frame after the action requests quit. + if (self.input.interface().shouldQuit()) return; + // Capture is recorded before endFrame so Vulkan appends its copy after // the UI pass, but before normal presentation releases the image. var finish_screenshot_run = false; @@ -563,6 +596,8 @@ pub const App = struct { // buffer is still open. Vulkan records the readback after its final // output pass and before submission/presentation. self.render_system.endFrame(); + frame_open = false; + try self.applyPendingScreenTransitions(); self.revealMenuWindowWhenReady(); if (build_options.benchmark) { diff --git a/src/game/screen_tests.zig b/src/game/screen_tests.zig index 81f5e566..09ec74f8 100644 --- a/src/game/screen_tests.zig +++ b/src/game/screen_tests.zig @@ -60,6 +60,26 @@ const MockScreen = struct { } }; +const MockFactoryPayload = struct { + screen: *MockScreen, + construct_count: *usize, + deinit_count: *usize, + replaced_state: ?*MockState = null, + constructed_after_replace_deinit: ?*bool = null, + + pub fn construct(self: *@This()) !IScreen { + self.construct_count.* += 1; + if (self.replaced_state) |state| { + if (self.constructed_after_replace_deinit) |result| result.* = state.deinit_count == 1; + } + return self.screen.make(); + } + + pub fn deinit(self: *@This()) void { + self.deinit_count.* += 1; + } +}; + test "ScreenManager.init creates empty manager" { const allocator = testing.allocator; const manager = ScreenManager.init(allocator); @@ -186,6 +206,90 @@ test "ScreenManager.update processes replace" { manager.deinit(); } +test "ScreenManager applies destructive replacement separately from screen update" { + var manager = ScreenManager.init(testing.allocator); + + var old_state = MockState{}; + var new_state = MockState{}; + var old_screen: MockScreen = .{ .state = &old_state }; + var new_screen: MockScreen = .{ .state = &new_state }; + + manager.pushScreen(old_screen.make()); + try manager.applyPendingTransitions(); + try manager.updateCurrent(0.016); + manager.setScreen(new_screen.make()); + + // Scheduling a frame-boundary replacement must not destroy the active + // world while its current update/render frame is still in progress. + try testing.expectEqual(@as(usize, 0), old_state.deinit_count); + try testing.expectEqual(@as(usize, 1), old_state.update_count); + + try manager.applyPendingTransitions(); + try testing.expectEqual(@as(usize, 1), old_state.exit_count); + try testing.expectEqual(@as(usize, 1), old_state.deinit_count); + try testing.expectEqual(@as(usize, 1), new_state.enter_count); + try testing.expectEqual(@as(usize, 0), new_state.update_count); + + manager.deinit(); +} + +test "ScreenManager constructs replacement factory only at transition boundary" { + var manager = ScreenManager.init(testing.allocator); + + var old_state = MockState{}; + var new_state = MockState{}; + var old_screen: MockScreen = .{ .state = &old_state }; + var new_screen: MockScreen = .{ .state = &new_state }; + var construct_count: usize = 0; + var factory_deinit_count: usize = 0; + var constructed_after_replace_deinit = false; + + manager.pushScreen(old_screen.make()); + try manager.applyPendingTransitions(); + const factory = try screen_module.makeScreenFactory(MockFactoryPayload, testing.allocator, .{ + .screen = &new_screen, + .construct_count = &construct_count, + .deinit_count = &factory_deinit_count, + .replaced_state = &old_state, + .constructed_after_replace_deinit = &constructed_after_replace_deinit, + }); + manager.setScreenFactory(factory); + + try testing.expectEqual(@as(usize, 0), construct_count); + try testing.expectEqual(@as(usize, 0), factory_deinit_count); + try testing.expectEqual(@as(usize, 0), old_state.deinit_count); + + try manager.applyPendingTransitions(); + try testing.expectEqual(@as(usize, 1), construct_count); + try testing.expectEqual(@as(usize, 1), factory_deinit_count); + try testing.expectEqual(@as(usize, 1), old_state.deinit_count); + try testing.expect(constructed_after_replace_deinit); + try testing.expectEqual(@as(usize, 1), new_state.enter_count); + + manager.deinit(); +} + +test "ScreenManager destroys a cancelled factory without constructing it" { + var manager = ScreenManager.init(testing.allocator); + defer manager.deinit(); + + var state = MockState{}; + var screen: MockScreen = .{ .state = &state }; + var construct_count: usize = 0; + var factory_deinit_count: usize = 0; + const factory = try screen_module.makeScreenFactory(MockFactoryPayload, testing.allocator, .{ + .screen = &screen, + .construct_count = &construct_count, + .deinit_count = &factory_deinit_count, + }); + + manager.pushScreenFactory(factory); + manager.popScreen(); + + try testing.expectEqual(@as(usize, 0), construct_count); + try testing.expectEqual(@as(usize, 1), factory_deinit_count); +} + test "ScreenManager.update calls update on current screen" { const allocator = testing.allocator; var manager = ScreenManager.init(allocator); diff --git a/src/game/session_tests.zig b/src/game/session_tests.zig index b7c9e909..2cdcdf42 100644 --- a/src/game/session_tests.zig +++ b/src/game/session_tests.zig @@ -4,21 +4,21 @@ const session_module = @import("game-core").session; const BuildConfig = session_module.BuildConfig; const Settings = @import("game-core").Settings; -test "explicit render distance controls full-detail radius" { - try testing.expectEqual(@as(i32, 22), session_module.fullDetailRenderDistance(22)); - try testing.expectEqual(@as(i32, 4096), session_module.fullDetailRenderDistance(4096)); - try testing.expectEqual(std.math.maxInt(i32), session_module.fullDetailRenderDistance(std.math.maxInt(i32))); - try testing.expectEqual(@as(i32, 6), session_module.fullDetailRenderDistance(6)); -} - -test "render distance metadata has no arbitrary upper cap" { +test "distance metadata keeps render distance uncapped and bounds the user LOD horizon" { const range = Settings.metadata.render_distance.kind.int_range; try testing.expectEqual(@as(i32, 2), range.min); try testing.expectEqual(std.math.maxInt(i32), range.max); const horizon_range = Settings.metadata.horizon_distance.kind.int_range; try testing.expectEqual(@as(i32, 256), horizon_range.min); - try testing.expectEqual(std.math.maxInt(i32), horizon_range.max); + try testing.expectEqual(@as(i32, 512), horizon_range.max); +} + +test "camera far plane covers the configured LOD horizon" { + try testing.expectEqual(@as(f32, 10_000.0), session_module.cameraFarPlaneForHorizon(256)); + try testing.expectEqual(@as(f32, 17_408.0), session_module.cameraFarPlaneForHorizon(1024)); + try testing.expectEqual(@as(f32, 17_408.0), session_module.cameraFarPlaneForDistances(1024, 256)); + try testing.expect(session_module.cameraFarPlaneForHorizon(std.math.maxInt(i32)) > 34_000_000_000.0); } fn chunkDebugRestoreEnabled(build_config: BuildConfig, name: []const u8) bool { diff --git a/src/integration_test.zig b/src/integration_test.zig index 797243d1..448bd638 100644 --- a/src/integration_test.zig +++ b/src/integration_test.zig @@ -104,9 +104,12 @@ fn initStorageOnlyPersistenceWorld(allocator: std.mem.Allocator) world_runtime.W .allocator = allocator, .generator = undefined, .render_distance = 8, + .lod_chunk_render_radius_limit = 8, .horizon_distance = 512, .rhi = undefined, .paused = false, + .safe_mode = false, + .safe_render_distance = 8, .lod = null, .lod_enabled = false, .save_manager = null, @@ -129,6 +132,7 @@ const UploadScreen = struct { buffer: rhi.BufferHandle, payload: [64]u8 = [_]u8{0} ** 64, tick: u8 = 0, + quit_on_draw: bool = false, pub const vtable = IScreen.VTable{ .deinit = deinit, @@ -157,15 +161,69 @@ const UploadScreen = struct { try self.context.render_system.getRHI().resourceManager().updateBuffer(self.buffer, 0, self.payload[0..]); } - fn draw(_: *anyopaque, ui: *UISystem) !void { + fn draw(ptr: *anyopaque, ui: *UISystem) !void { + const self: *UploadScreen = @ptrCast(@alignCast(ptr)); ui.begin(); ui.end(); + if (self.quit_on_draw) self.context.input.setShouldQuit(true); } pub fn screen(self: *UploadScreen) IScreen { return Screen.makeScreen(@This(), self); } }; +const UploadScreenFactory = struct { + context: EngineContext, + result: *?*UploadScreen, + + pub fn construct(self: *@This()) !IScreen { + const screen = try UploadScreen.init(self.context.allocator, self.context); + self.result.* = screen; + return screen.screen(); + } +}; + +/// Pause-menu analogue: render the world parent, then request a destructive +/// replacement factory during UI drawing. App must submit that frame before it +/// constructs the replacement or destroys the world and its Vulkan resources. +const ReplaceDuringDrawScreen = struct { + context: EngineContext, + replacement: ?Screen.ScreenFactory, + + pub const vtable = IScreen.VTable{ + .deinit = deinit, + .update = update, + .draw = draw, + }; + + pub fn init(allocator: std.mem.Allocator, context: EngineContext, replacement: Screen.ScreenFactory) !*ReplaceDuringDrawScreen { + const result = try allocator.create(ReplaceDuringDrawScreen); + result.* = .{ .context = context, .replacement = replacement }; + return result; + } + + fn deinit(ptr: *anyopaque) void { + const self: *ReplaceDuringDrawScreen = @ptrCast(@alignCast(ptr)); + if (self.replacement) |replacement| replacement.deinit(); + self.context.allocator.destroy(self); + } + + fn update(_: *anyopaque, _: f32) !void {} + + fn draw(ptr: *anyopaque, ui: *UISystem) !void { + const self: *ReplaceDuringDrawScreen = @ptrCast(@alignCast(ptr)); + try self.context.screen_manager.drawBackgroundFor(ptr, ui); + if (self.replacement) |replacement| { + self.replacement = null; + self.context.screen_manager.setScreenFactory(replacement); + } + } + + pub fn screen(self: *ReplaceDuringDrawScreen) IScreen { + return Screen.makeScreen(@This(), self); + } +}; + test "smoke test: launch, generate, render, exit" { const test_allocator = testing.allocator; @@ -185,8 +243,7 @@ test "smoke test: launch, generate, render, exit" { try app.runSingleFrame(); - // The screen manager handles the screen transition in the next update/draw cycle - // In our implementation, setScreen sets next_screen, and update() consumes it. + // The app consumes the pending transition at the next GPU frame boundary. try testing.expect(app.screen_manager.stack.items.len > 0); @@ -194,8 +251,35 @@ test "smoke test: launch, generate, render, exit" { try testing.expect(stats.chunks_loaded > 0); - const upload_screen = try UploadScreen.init(test_allocator, app.engineContext()); - app.screen_manager.setScreen(upload_screen.screen()); + // Runtime World settings are literal live controls, not display-only + // values capped by the startup preset. Decrease by one so this remains + // inexpensive even when the local test settings use a large radius. + const settings = app.engineContext().settings; + const requested_detail = if (settings.render_distance > 2) settings.render_distance - 1 else settings.render_distance + 1; + const requested_horizon = if (settings.horizon_distance > requested_detail) settings.horizon_distance - 1 else settings.horizon_distance + 1; + settings.render_distance = requested_detail; + settings.horizon_distance = requested_horizon; + try app.runSingleFrame(); + try testing.expectEqual(requested_detail, world_screen.session.world.streamer.lod_coordinator.targetRenderDistance()); + try testing.expectEqual(world_lod.lod_chunk.LODConfig.normalizeUserHorizonDistance(requested_detail, requested_horizon), world_screen.session.world.horizon_distance); + + var upload_screen: ?*UploadScreen = null; + const upload_factory = try Screen.makeScreenFactory(UploadScreenFactory, test_allocator, .{ .context = app.engineContext(), .result = &upload_screen }); + const replace_during_draw = try ReplaceDuringDrawScreen.init(test_allocator, app.engineContext(), upload_factory); + app.screen_manager.pushScreen(replace_during_draw.screen()); + try testing.expect(upload_screen == null); + + // The overlay draws the world's menu-safe background, requests replacement + // during draw, and App applies it only after endFrame. This is the real + // Quit-to-Title ordering, including suppression of distant LOD beneath the + // retained pause overlay, a GPU idle drain, and boundary-time construction + // of the replacement's Vulkan resources. + const fault_count_before_replace = app.render_system.getRHI().query().getFaultCount(); + try app.runSingleFrame(); + const active_upload_screen = upload_screen.?; + try testing.expectEqual(@as(usize, 1), app.screen_manager.stack.items.len); + try testing.expect(app.screen_manager.stack.items[0].ptr == @as(*anyopaque, @ptrCast(active_upload_screen))); + try testing.expectEqual(fault_count_before_replace, app.render_system.getRHI().query().getFaultCount()); const frame_count = rhi.MAX_FRAMES_IN_FLIGHT + 2; for (0..frame_count) |_| { @@ -217,6 +301,27 @@ test "smoke test: launch, generate, render, exit" { try testing.expectEqual(@as(u32, @intCast(actual_h)), extent[1]); } + // A quit event must stop the frame before beginFrame/endFrame. Submitting + // one final frame while the window system is closing can report device + // loss on otherwise healthy Vulkan devices. + const fault_count_before_quit = app.render_system.getRHI().query().getFaultCount(); + var quit_event = std.mem.zeroes(c.SDL_Event); + quit_event.type = c.SDL_EVENT_WINDOW_CLOSE_REQUESTED; + _ = c.SDL_PushEvent(&quit_event); + try app.runSingleFrame(); + try testing.expect(app.input.interface().shouldQuit()); + try testing.expectEqual(fault_count_before_quit, app.render_system.getRHI().query().getFaultCount()); + app.input.interface().setShouldQuit(false); + + // A quit requested after beginFrame must discard both graphics commands and + // this screen's pending transfer upload. Teardown immediately follows and + // must not leave recording command buffers referencing destroyed resources. + active_upload_screen.quit_on_draw = true; + const fault_count_before_late_quit = app.render_system.getRHI().query().getFaultCount(); + try app.runSingleFrame(); + try testing.expect(app.input.interface().shouldQuit()); + try testing.expectEqual(fault_count_before_late_quit, app.render_system.getRHI().query().getFaultCount()); + const val_count = app.render_system.getRHI().query().getValidationErrorCount(); if (val_count > 0) { std.debug.print("Integration test finished with {} Vulkan validation errors\n", .{val_count}); diff --git a/src/integration_test_robustness.zig b/src/integration_test_robustness.zig index fa287669..71a48b9a 100644 --- a/src/integration_test_robustness.zig +++ b/src/integration_test_robustness.zig @@ -15,9 +15,22 @@ pub fn main(init: std.process.Init) !void { std.debug.print("Found robust-demo at: {s}\n", .{robust_demo_path}); + var argv_buffer: [2][]const u8 = undefined; + const argv: []const []const u8 = if (init.environ_map.get("ZIGCRAFT_DYNAMIC_LINKER")) |dynamic_linker| blk: { + if (dynamic_linker.len == 0) { + argv_buffer[0] = robust_demo_path; + break :blk argv_buffer[0..1]; + } + argv_buffer = .{ dynamic_linker, robust_demo_path }; + break :blk &argv_buffer; + } else blk: { + argv_buffer[0] = robust_demo_path; + break :blk argv_buffer[0..1]; + }; + // Run the demo const run_result = try std.process.run(allocator, init.io, .{ - .argv = &[_][]const u8{robust_demo_path}, + .argv = argv, .stdout_limit = .limited(4096), .stderr_limit = .limited(4096), }); @@ -38,7 +51,8 @@ pub fn main(init: std.process.Init) !void { } }, else => { - std.debug.print("robust-demo crashed or was signaled\n", .{}); + std.debug.print("robust-demo terminated unexpectedly: {any}\n", .{result}); + std.debug.print("stdout:\n{s}\nstderr:\n{s}\n", .{ stdout, stderr }); return error.DemoCrashed; }, }