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/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..9a7b6496 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); @@ -39,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 00000000..00a69b72 Binary files /dev/null and b/assets/shaders/vulkan/lod_compact_terrain.frag.spv differ diff --git a/assets/shaders/vulkan/lod_compact_water.frag b/assets/shaders/vulkan/lod_compact_water.frag index 3e547bea..bbbffe5b 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) * @@ -49,10 +64,10 @@ void main() { base += global.sun_color.rgb * diffuse * global.params.w * 0.06; if (global.params.z > 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); } - 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/lod_compact_water.frag.spv b/assets/shaders/vulkan/lod_compact_water.frag.spv new file mode 100644 index 00000000..5a666caa Binary files /dev/null and b/assets/shaders/vulkan/lod_compact_water.frag.spv differ diff --git a/assets/shaders/vulkan/terrain.frag b/assets/shaders/vulkan/terrain.frag index b68c4ee2..a856600f 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); @@ -585,11 +597,6 @@ void main() { if (global.params.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 90b68ba9..d15544a4 100644 Binary files a/assets/shaders/vulkan/terrain.frag.spv and b/assets/shaders/vulkan/terrain.frag.spv differ diff --git a/assets/shaders/vulkan/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 ba315842..2b7fe0b6 100644 Binary files a/assets/shaders/vulkan/terrain.vert.spv and b/assets/shaders/vulkan/terrain.vert.spv differ diff --git a/assets/shaders/vulkan/water.frag b/assets/shaders/vulkan/water.frag index 912ad50e..b5ac4ed3 100644 --- a/assets/shaders/vulkan/water.frag +++ b/assets/shaders/vulkan/water.frag @@ -47,6 +47,8 @@ const vec3 WATER_SHALLOW = vec3(0.20, 0.58, 0.86); const vec3 WATER_MID = vec3(0.08, 0.34, 0.70); const vec3 WATER_DEEP = vec3(0.02, 0.12, 0.42); const float WATER_MAX_DEPTH = 14.0; +// Matches the two-chunk overlap reserved by LODConfig.calculateMaskRadius(). +const float LOD_MASK_BLEND_WIDTH = 32.0; const float WAVE_AMPLITUDE = 0.5; const float WAVE_FREQUENCY = 1.5; @@ -129,9 +131,19 @@ vec2 atlasUV(int tileID, vec2 texCoord) { } 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; + 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; @@ -216,7 +228,6 @@ void main() { if (global.params.z > 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); } @@ -226,5 +237,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/assets/shaders/vulkan/water.frag.spv b/assets/shaders/vulkan/water.frag.spv new file mode 100644 index 00000000..2ddc2be5 Binary files /dev/null and b/assets/shaders/vulkan/water.frag.spv differ diff --git a/assets/shaders/vulkan/water.vert b/assets/shaders/vulkan/water.vert index 9a0966f2..58262218 100644 --- a/assets/shaders/vulkan/water.vert +++ b/assets/shaders/vulkan/water.vert @@ -84,7 +84,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/water.vert.spv b/assets/shaders/vulkan/water.vert.spv new file mode 100644 index 00000000..ceb02c42 Binary files /dev/null and b/assets/shaders/vulkan/water.vert.spv differ diff --git a/build.zig b/build.zig index cae6bdf9..badffce2 100644 --- a/build.zig +++ b/build.zig @@ -1071,7 +1071,7 @@ fn defineBuildOptions(b: *std.Build, optimize: std.builtin.OptimizeMode) BuildOp const screenshot_delay_seconds = b.option(u32, "screenshot-delay-seconds", "Seconds to wait after screenshot target is ready before capture") orelse 0; options.addOption(u32, "screenshot_delay_seconds", screenshot_delay_seconds); - const phase5_visual_scene = b.option([]const u8, "phase5-visual-scene", "Deterministic production-world fixture/camera for the Phase 5 visual gate (seam, water, lod-handoff, lod-handoff-traversal, fog-rapid-turn, teleport-handoff, saved-world-create, saved-world-reload)") orelse ""; + const phase5_visual_scene = b.option([]const u8, "phase5-visual-scene", "Deterministic production-world fixture/camera for the Phase 5 visual gate (seam, water, lod-handoff, lod-aerial, lod-handoff-traversal, fog-rapid-turn, teleport-handoff, saved-world-create, saved-world-reload)") orelse ""; options.addOption([]const u8, "phase5_visual_scene", phase5_visual_scene); const phase5_visual_run_id = b.option([]const u8, "phase5-visual-run-id", "Fresh evidence scope identifier for a Phase 5 visual-gate invocation") orelse ""; options.addOption([]const u8, "phase5_visual_run_id", phase5_visual_run_id); diff --git a/docs/lod-quality-controls.md b/docs/lod-quality-controls.md index 5c0d8e42..46dfcb4e 100644 --- a/docs/lod-quality-controls.md +++ b/docs/lod-quality-controls.md @@ -1,6 +1,17 @@ # LOD Quality Controls -The render distance preset is the supported user-facing control for distant LOD quality. Presets intentionally expose a small set of stable knobs: +The render distance preset seeds distant LOD quality. The World settings also +expose `Render Distance` for full-detail chunks and `Distant LOD Limit` as the +outer terrain radius. Lowering the latter reduces generation pressure; +coarse regions fill concentrically so the engine does not render isolated +outer-horizon islands before nearby fallback terrain. + +The production `Distant LOD Limit` currently supports 256 or 512 chunks. +Larger radii remain benchmark/diagnostic-only: the five-level hierarchy has no +coarser level beyond LOD4, so a contiguous 1,024-chunk disk exceeds the normal +logical-memory and compact-pool qualification budgets. + +Presets intentionally expose a small set of stable knobs: - `lod_radii`: chunk radii for LOD0 through LOD4. - `horizon_radius`: the supported far-terrain horizon in chunks. @@ -8,16 +19,19 @@ The render distance preset is the supported user-facing control for distant LOD containers. The cache worker evicts the oldest containers after atomic writes and compacts live entries when sector growth reaches the cap. - `horizontal_detail`: target horizontal detail per LOD. This is used as a floor for QEM triangle targets when the experimental QEM mesh path is enabled. -- `sample_density`: source-grid density per LOD. Medium uses half density for - LOD4 so its initial 512-chunk horizon has 33x33 source grids instead of - 65x65 grids; finer LODs replace those 16-block cells as they stream in. +- `sample_density`: source-grid density per LOD. Every 512-chunk production + horizon uses half density for LOD4, giving the fallback 33x33 source grids + instead of 65x65 grids; finer LODs replace those 16-block cells as they + stream in. The 256-chunk Low horizon retains its denser LOD4 source grid. - `vertical_span_budget`: enables rich column/span source data when nonzero. The numeric values are reserved preset policy; current source allocation is bounded by the engine-wide `MAX_LOD_VERTICAL_SPANS` limit. - `mesh_path`: selects the rich `column_spans` path for near and mid-distance LODs. LOD3/LOD4 deliberately fall back to heightfields to bound far-horizon geometry and memory; `qem` remains available for controlled testing. -- `fog_start_percent`: controls the fade band for each LOD level. +- `fog_start_percent`: records the intended per-level fade-band policy. Terrain + and water currently use the shared atmospheric distance-fog curve so loaded + LODs do not turn into an opaque horizon-colored shelf near the player. - `memory_budget_mb` and `max_uploads_per_frame`: bound cache pressure and per-frame GPU upload work. ## Supported presets @@ -27,8 +41,8 @@ The render distance preset is the supported user-facing control for distant LOD | Low | 256 chunks | 33/33/33/65/65 | 2 | 128 MB | 512 MB | 4 | | Medium | 512 chunks | 33/49/49/65/33 | 2 | 256 MB | 1,024 MB | 8 | | High | 512 chunks | 33/65/65/97/97 | 3 | 384 MB | 1,536 MB | 8 | -| Ultra | 1,024 chunks | 33/65/65/129/129 | 4 | 512 MB | 3,072 MB | 12 | -| Extreme | 2,048 chunks | 33/65/65/129/129 | 4 | 1,024 MB | 4,096 MB | 16 | +| Ultra | 512 chunks | 33/65/65/129/129 | 4 | 512 MB | 3,072 MB | 12 | +| Extreme | 512 chunks | 33/65/65/129/129 | 4 | 1,024 MB | 4,096 MB | 16 | These values are policy inputs, not a promise that all hardware sustains the full horizon. The memory governor shrinks refinement radii under pressure but @@ -48,6 +62,9 @@ The benchmark SLOs and regression thresholds are maintained in - Parent regions remain visible until all four finer children are renderable and the transition window completes. Streaming delay therefore degrades to coarser terrain rather than a hierarchy hole. +- Expanded and compact LOD terrain and water use the same atmospheric fog + progression as their full-detail counterparts. LOD representation changes + must not introduce an additional fixed-distance fog floor. - Pause and large traversal changes invalidate queued and in-flight worker tokens. Per-region cancellation prevents a stale generation result from publishing after unpause or teleport. 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/engine-graphics/src/render_system.zig b/modules/engine-graphics/src/render_system.zig index 1607c4a6..f401da54 100644 --- a/modules/engine-graphics/src/render_system.zig +++ b/modules/engine-graphics/src/render_system.zig @@ -325,6 +325,11 @@ pub const RenderSystem = struct { self.rhi.renderContext().endFrame(); } + /// Discards the active frame without submitting it to the GPU. + pub fn abortFrame(self: *RenderSystem) void { + self.rhi.renderContext().abortFrame(); + } + pub fn waitIdle(self: *RenderSystem) void { self.rhi.query().waitIdle(); } diff --git a/modules/engine-graphics/src/rhi_tests.zig b/modules/engine-graphics/src/rhi_tests.zig index 7cd9a696..d37dfc13 100644 --- a/modules/engine-graphics/src/rhi_tests.zig +++ b/modules/engine-graphics/src/rhi_tests.zig @@ -636,6 +636,13 @@ test "IRenderContext getEncoder" { try testing.expectEqual(&MockContext.MOCK_STATE_VTABLE, state.vtable); } +test "indirect model uniforms use alpha sentinel without consuming mask sign" { + const uniforms = @import("vulkan/rhi_draw_submission.zig").indirectModelUniforms(); + + try testing.expect(uniforms.color[3] < 0.0); + try testing.expectEqual(@as(f32, 0.0), uniforms.mask_radius); +} + test "AtmosphereSystem.renderSky with null handles" { var mock = MockContext{}; const rhi_instance = rhi.RHI{ .ptr = &mock, .vtable = &MockContext.MOCK_VULKAN_RHI_VTABLE, .device = null }; diff --git a/modules/engine-graphics/src/rhi_vulkan.zig b/modules/engine-graphics/src/rhi_vulkan.zig index 4e465261..4d4639b7 100644 --- a/modules/engine-graphics/src/rhi_vulkan.zig +++ b/modules/engine-graphics/src/rhi_vulkan.zig @@ -137,11 +137,12 @@ fn abortFrame(ctx_ptr: *anyopaque) void { const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); if (!ctx.frames.frame_in_progress) return; - if (ctx.runtime.main_pass_active) endMainPass(ctx_ptr); - if (ctx.shadow_system.pass_active) endShadowPass(ctx_ptr); - if (ctx.runtime.g_pass_active) endGPass(ctx_ptr); - + // Reset both recording command buffers before any screen/world teardown. + // vkDeviceWaitIdle only covers submitted work and cannot make references in + // an unsubmitted recording command buffer safe to destroy. + ctx.resources.abortCurrentFrame(); ctx.frames.abortFrame(); + if (ctx.screenshot_capture.staging != null) screenshot.discardCapture(ctx); // Recreate semaphores const device = ctx.vulkan_device.vk_device; @@ -161,6 +162,13 @@ fn abortFrame(ctx_ptr: *anyopaque) void { ctx.shadow_system.pass_active = false; ctx.runtime.g_pass_active = false; ctx.runtime.ssao_pass_active = false; + ctx.water_system.pass_active = false; + ctx.post_process.pass_active = false; + ctx.fxaa.pass_active = false; + ctx.ui.ui_swapchain_pass_active = false; + ctx.ui.ui_using_swapchain = false; + ctx.ui.ui_swapchain_clears_output = false; + ctx.runtime.final_composed.clear(); ctx.draw.descriptors_updated = false; ctx.draw.bound_texture = 0; } diff --git a/modules/engine-graphics/src/vulkan/frame_manager.zig b/modules/engine-graphics/src/vulkan/frame_manager.zig index 386f88e1..8f4a221b 100644 --- a/modules/engine-graphics/src/vulkan/frame_manager.zig +++ b/modules/engine-graphics/src/vulkan/frame_manager.zig @@ -224,8 +224,25 @@ pub const FrameManager = struct { pub fn abortFrame(self: *FrameManager) void { if (!self.frame_in_progress) return; - // Wait for fence to be safe? No, just reset state. - // But we might have acquired an image. + const frame = self.current_frame; + const device = self.vulkan_device.vk_device; + + // Discard every recorded reference before world/session teardown can + // release its buffers. This command pool belongs exclusively to the + // current frame slot, whose fence was waited before beginFrame. + const reset_result = c.vkResetCommandPool(device, self.frame_command_pools[frame], 0); + if (reset_result != c.VK_SUCCESS) { + log.log.err("Failed to reset aborted graphics command pool: {d}", .{reset_result}); + } + + // beginFrame reset this fence, but an aborted frame has no graphics + // submission to signal it. Queue an empty submission so this slot can + // be reused without destroying/recreating synchronization objects. + var submit_info = std.mem.zeroes(c.VkSubmitInfo); + submit_info.sType = c.VK_STRUCTURE_TYPE_SUBMIT_INFO; + self.vulkan_device.submitGuarded(submit_info, self.in_flight_fences[frame]) catch |err| { + log.log.errWithTrace("Failed to retire aborted frame slot: {}", .{err}); + }; self.frame_in_progress = false; } diff --git a/modules/engine-graphics/src/vulkan/lod_culling_system.zig b/modules/engine-graphics/src/vulkan/lod_culling_system.zig index 7b674008..685c89da 100644 --- a/modules/engine-graphics/src/vulkan/lod_culling_system.zig +++ b/modules/engine-graphics/src/vulkan/lod_culling_system.zig @@ -319,6 +319,8 @@ const LODCullingSystem = struct { const compact = stream >= 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 25b02944..ab4a895a 100644 --- a/modules/game-core/src/session.zig +++ b/modules/game-core/src/session.zig @@ -81,6 +81,22 @@ const SpawnColumn = struct { info: @import("world-worldgen").ColumnInfo, }; +/// 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 { allocator: std.mem.Allocator, world: *World, @@ -149,7 +165,7 @@ 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) @@ -247,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)); @@ -523,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; @@ -736,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 }; @@ -782,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 3b525397..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 @@ -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 }, - } }, + .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/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-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 3d75ae90..5a3281ec 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.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")) { - 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)) { @@ -331,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", "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, "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 309df5b8..43dac3b0 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; @@ -289,28 +290,25 @@ 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); 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.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, 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 }, "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); } 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..ea1185bb 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; @@ -33,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, @@ -74,6 +88,7 @@ pub const WorldScreen = struct { .deinit = deinit, .update = update, .draw = draw, + .drawBackground = drawBackground, .onEnter = onEnter, .onExit = onExit, .getWorldStats = getWorldStatsIScreen, @@ -102,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(); @@ -161,16 +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) { - const preset = rhi_pkg.getPresetConfig(ctx.settings.render_distance_preset); - self.session.world.setLODChunkRenderRadiusLimit(preset.lod_radii[0]); + // 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 167f1302..3c8fc5ab 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. @@ -373,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. @@ -634,9 +635,17 @@ 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; + /// 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; + /// 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 +701,50 @@ pub const LODConfig = struct { } /// Expands a full-detail render distance into the default LOD radius ladder. - /// The farthest radius uses the default horizon distance. + /// 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, default_horizon_radius); + return radiiForDistances(distance, normalizeUserHorizonDistance(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 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, @max(render_distance, minimum_horizon_radius)); + } + + /// 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 = @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, maximum))); } /// 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); @@ -720,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 { @@ -973,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]); @@ -990,18 +1017,40 @@ test "LODConfig expands render distance into distant LOD horizon" { 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, 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]); try std.testing.expectEqual(@as(i32, 96), custom_horizon[1]); 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); + + 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" { 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()); @@ -1016,6 +1065,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" { @@ -1042,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 5276bc28..a61968aa 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, @@ -290,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. @@ -309,7 +319,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 +356,13 @@ 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); } /// 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`. @@ -367,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); } @@ -497,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); } @@ -527,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 e2af6f30..fbff8e81 100644 --- a/modules/world-lod/src/lod_manager_cache_ops.zig +++ b/modules/world-lod/src/lod_manager_cache_ops.zig @@ -2,8 +2,10 @@ 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"); const lod_cache = @import("lod_cache.zig"); const lod_store = @import("lod_store.zig"); const cache_io = @import("lod_cache_io.zig"); @@ -48,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); @@ -56,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 { @@ -332,6 +404,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..e3af0e7e 100644 --- a/modules/world-lod/src/lod_manager_context.zig +++ b/modules/world-lod/src/lod_manager_context.zig @@ -163,6 +163,16 @@ pub const PlayerChunkPos = struct { cz: i32, }; +/// 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, + 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..26cee4d4 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, @@ -335,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) { @@ -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..911bda79 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), @@ -658,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 aff3543d..f9d9032a 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); @@ -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 5db9b7b9..686ba069 100644 --- a/modules/world-lod/src/lod_renderer.zig +++ b/modules/world-lod/src/lod_renderer.zig @@ -68,8 +68,69 @@ 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 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) { @@ -199,6 +260,12 @@ pub fn LODRenderer(comptime RHI: type) type { instance_data: std.ArrayListUnmanaged(rhi_types.InstanceData), draw_list: std.ArrayListUnmanaged(*LODMesh), projection_regions: std.ArrayListUnmanaged(VisibleRegion), + 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, @@ -211,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, @@ -232,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. @@ -270,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)); @@ -295,6 +395,12 @@ pub fn LODRenderer(comptime RHI: type) type { .instance_data = .empty, .draw_list = .empty, .projection_regions = .empty, + .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, @@ -302,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), @@ -371,11 +479,13 @@ 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 { 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 @@ -386,7 +496,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 +509,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 +532,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 +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, 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; }; @@ -649,6 +760,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 +805,12 @@ 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(); + errdefer self.projection_regions.clearRetainingCapacity(); if (stats) |s| { s.drawn = [_]u32{0} ** LODLevel.count; s.instances = [_]u32{0} ** LODLevel.count; @@ -676,7 +823,9 @@ 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); var i = lod_chunk.activeLODCount(config); while (i > 0) { i -= 1; @@ -711,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 @@ -729,7 +877,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,7 +892,6 @@ 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; } } @@ -918,7 +1065,7 @@ 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, }; if (mesh.isCompact()) { @@ -933,7 +1080,7 @@ 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 { @@ -1095,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]; @@ -1176,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(); @@ -1196,7 +1344,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 +1370,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; } } } @@ -1480,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; @@ -1523,16 +1671,17 @@ 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)); @@ -1560,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; @@ -1600,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]); @@ -1653,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).?); @@ -1667,6 +1840,77 @@ 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, + 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; @@ -1702,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 @@ -1808,7 +2060,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 +2071,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 +2081,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 +2583,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 +2591,10 @@ 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); } test "LODRenderer skips coarse LOD when finer coverage is ready" { @@ -2829,8 +3081,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 +3092,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..e4ac8da8 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,47 +58,85 @@ 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; -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: i32) i32 { - const product = component * radius; - return @divTrunc(product + (if (product >= 0) @as(i32, 512) else -512), 1024); +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 initialHorizonSeedRank(rx: i32, rz: i32, player_rx: i32, player_rz: i32, region_radius: i32) ?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; +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] }; +} - 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; +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; } - return null; } pub fn priorityRank(lod: LODLevel, active_lod_count: usize) usize { @@ -128,7 +168,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 +183,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 +212,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); @@ -186,82 +227,82 @@ 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); // 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)]; + updateScanOrigin(state, player_rx, player_rz, radius, is_coarsest); + 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 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; + 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 candidate = Candidate{ + .key = key, + .encoded_priority = distance_priority, + .scan_state_before = scan_state_before, + }; + candidates.append(ctx.allocator, 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; @@ -272,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 @@ -304,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| { @@ -383,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; @@ -416,6 +502,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 +523,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, @@ -461,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; @@ -488,12 +576,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 +599,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 +609,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(); @@ -559,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; @@ -596,7 +689,9 @@ 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; const Coverage = struct { fn neverCovered(_: *anyopaque, _: LODChunk.WorldBounds, _: ChunkChecker, _: *anyopaque) bool { @@ -613,18 +708,37 @@ 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, .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_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..f7cca390 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,6 +179,7 @@ pub const LODRenderInterface = struct { chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, max_distance_chunks: ?i32, + detail_render_radius: i32, stats: ?*LODStats, profiling: ?*LODProfilingCollector, ) void = null, @@ -217,12 +219,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,8 +235,8 @@ 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 { 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..f3450bec 100644 --- a/modules/world-runtime/src/chunk_queue_coordinator.zig +++ b/modules/world-runtime/src/chunk_queue_coordinator.zig @@ -85,6 +85,18 @@ 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; + +/// 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, @@ -104,9 +116,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 +174,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 +258,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 +361,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 +396,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 +508,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 +608,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 +622,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 +673,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; } @@ -619,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); } } } @@ -633,6 +760,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 +774,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 +1061,29 @@ 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 "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); + + 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..52129943 100644 --- a/modules/world-runtime/src/world.zig +++ b/modules/world-runtime/src/world.zig @@ -653,11 +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 safe_render_distance: i32 = options.render_distance; + const safe_render_distance: i32 = @max(options.render_distance, 2); const streamer_render_distance: i32 = if (options.lod_config) |lod_config| - @min(safe_render_distance, lod_config.getChunkRenderRadius()) + effectiveChunkRenderRadius(safe_render_distance, lod_config.getChunkRenderRadius(), true) else - safe_render_distance; + effectiveChunkRenderRadius(safe_render_distance, safe_render_distance, false); const max_uploads: usize = if (strict_safe_mode) @as(usize, 4) else if (safe_mode) @@ -834,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); @@ -856,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); @@ -877,10 +905,11 @@ 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 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 != distance) { + 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 }); @@ -889,9 +918,9 @@ pub const World = struct { } } - /// 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. + /// 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; @@ -904,28 +933,34 @@ pub const World = struct { 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.activeCountForRenderDistance(self.render_distance)); } } 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; + 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. /// 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); + const radii = effectiveLODRadii(self.render_distance, self.lod_chunk_render_radius_limit, target); lod.setRadii(radii); - lod.setActiveLODCount(LODLevel.count); } } @@ -1079,7 +1114,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), 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 6e9d738f..3454d66e 100644 --- a/modules/world-runtime/src/world_facade_tests.zig +++ b/modules/world-runtime/src/world_facade_tests.zig @@ -7,7 +7,8 @@ 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; @@ -16,6 +17,26 @@ 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" { + 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 { @@ -284,6 +305,7 @@ 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, diff --git a/modules/world-runtime/src/world_renderer.zig b/modules/world-runtime/src/world_renderer.zig index 39d8f505..9f39332b 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,19 @@ 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| { + 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.isChunkRenderable, @ptrCast(self.storage), true, null, 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.isChunkRenderable, @ptrCast(self.storage), true, null, 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"); } } @@ -358,8 +371,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 +389,9 @@ pub const WorldRenderer = struct { var total_vertices: u64 = 0; for (self.visible_chunks.items) |data| { + // 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; } @@ -393,47 +408,61 @@ pub const WorldRenderer = struct { 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 +473,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 +499,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, layer); } fn drawChunkDirect(self: *WorldRenderer, data: *ChunkData, model: Mat4, layer: RenderLayer, count_vertices: bool) u64 { @@ -505,13 +528,14 @@ 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, 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 data = self.storage.chunks.get(.{ .x = cx, .z = cz }) orelse continue; var already_drawn = false; @@ -537,29 +561,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 +665,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 +677,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..07bf87f3 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,43 +618,97 @@ 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; 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 = key.x - pc.chunk_x; - const dz = key.z - 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 { @@ -658,15 +720,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 +748,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 +764,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/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 28872ef2..2cdcdf42 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 "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(@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 { if (!build_config.chunk_debug_mode) return false; diff --git a/src/integration_test.zig b/src/integration_test.zig index f400af22..448bd638 100644 --- a/src/integration_test.zig +++ b/src/integration_test.zig @@ -132,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, @@ -160,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; @@ -188,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); @@ -197,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) |_| { @@ -220,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; }, } 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());