From 6daa32be3c23453d97b76572bb03d55cfa7bc373 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Tue, 14 Jul 2026 01:02:50 +0100 Subject: [PATCH] feat: complete compact far-terrain rendering Signed-off-by: MichaelFisher1997 --- .github/workflows/build.yml | 18 +- .gitignore | 1 + AGENTS.md | 278 ++--- .../shaders/vulkan/lod_compact_terrain.frag | 47 + .../shaders/vulkan/lod_compact_terrain.vert | 205 ++++ assets/shaders/vulkan/lod_compact_water.frag | 58 + assets/shaders/vulkan/lod_compact_water.vert | 158 +++ assets/shaders/vulkan/lod_culling.comp | 85 ++ build.zig | 123 +- docs/benchmarks/README.md | 111 +- docs/decisions/917-compact-gpu-lod-tiles.md | 165 +++ docs/lod-quality-controls.md | 69 +- docs/shaders/spirv-sizes.json | 11 +- modules/engine-core/src/time.zig | 20 + modules/engine-graphics/src/cloud_system.zig | 10 +- modules/engine-graphics/src/render_graph.zig | 13 + modules/engine-graphics/src/render_system.zig | 3 + modules/engine-graphics/src/rhi_tests.zig | 11 + modules/engine-graphics/src/rhi_vulkan.zig | 47 + modules/engine-graphics/src/root.zig | 1 + .../src/vulkan/descriptor_bindings.zig | 2 + .../src/vulkan/descriptor_manager.zig | 32 + .../src/vulkan/fxaa_system.zig | 4 +- .../src/vulkan/lod_culling_system.zig | 435 +++++++ .../src/vulkan/pipeline_manager.zig | 8 + .../src/vulkan/pipeline_specialized.zig | 67 ++ .../src/vulkan/render_pass_manager.zig | 31 +- .../src/vulkan/resource_manager.zig | 14 +- .../src/vulkan/rhi_context_types.zig | 4 + .../src/vulkan/rhi_draw_submission.zig | 113 ++ .../src/vulkan/rhi_frame_orchestration.zig | 2 +- .../src/vulkan/rhi_init_deinit.zig | 2 +- .../src/vulkan/rhi_render_state.zig | 41 + .../src/vulkan/rhi_resource_setup.zig | 4 +- .../src/vulkan/rhi_state_control.zig | 4 + .../engine-graphics/src/vulkan/rhi_timing.zig | 23 +- .../src/vulkan/shader_registry.zig | 5 + .../src/vulkan/transfer_queue.zig | 57 +- modules/engine-graphics/src/vulkan/utils.zig | 6 +- modules/engine-graphics/src/vulkan_device.zig | 25 +- modules/engine-rhi/src/culling.zig | 89 ++ modules/engine-rhi/src/rhi.zig | 61 + modules/engine-rhi/src/rhi_types.zig | 160 +++ modules/engine-rhi/src/root.zig | 9 + modules/engine-rhi/src/world_contracts.zig | 5 + modules/engine-ui/src/root.zig | 3 + modules/engine-ui/src/timing_overlay.zig | 81 +- modules/game-core/src/benchmark.zig | 1056 ++++++++++++++++- modules/game-core/src/root.zig | 1 + modules/game-ui/src/screens/world.zig | 69 ++ modules/world-core/src/lod_data.zig | 4 +- modules/world-lod/src/lod_cache.zig | 123 +- modules/world-lod/src/lod_cache_io.zig | 296 +++++ modules/world-lod/src/lod_chunk.zig | 41 + modules/world-lod/src/lod_compact_pool.zig | 244 ++++ modules/world-lod/src/lod_ingest.zig | 8 +- modules/world-lod/src/lod_manager.zig | 56 +- .../world-lod/src/lod_manager_cache_ops.zig | 397 ++++--- modules/world-lod/src/lod_manager_context.zig | 4 +- .../world-lod/src/lod_manager_core_ops.zig | 156 ++- .../src/lod_manager_eviction_ops.zig | 122 +- .../src/lod_manager_generation_ops.zig | 310 +++-- .../src/lod_manager_ingestion_ops.zig | 6 +- .../src/lod_manager_internal_tests.zig | 271 ++++- modules/world-lod/src/lod_manager_tests.zig | 93 +- .../world-lod/src/lod_manager_upload_ops.zig | 50 +- modules/world-lod/src/lod_mesh.zig | 188 ++- modules/world-lod/src/lod_renderer.zig | 809 ++++++++++++- modules/world-lod/src/lod_scheduler.zig | 9 +- modules/world-lod/src/lod_stats.zig | 445 ++++++- modules/world-lod/src/lod_store.zig | 311 ++++- modules/world-lod/src/lod_tile.zig | 755 ++++++++++++ modules/world-lod/src/lod_upload_queue.zig | 90 +- modules/world-lod/src/lod_vertex_pool.zig | 184 ++- modules/world-lod/src/root.zig | 8 + modules/world-lod/src/tests.zig | 12 + modules/world-persistence/src/region_file.zig | 1 + modules/world-runtime/src/world.zig | 14 + .../world-runtime/src/world_facade_tests.zig | 2 + modules/world-runtime/src/world_renderer.zig | 21 +- phase5_benchmark_gate_tests.zig | 159 +++ scripts/check_phase5_benchmark_config.sh | 14 + scripts/check_phase5_visual_smoke.py | 162 +++ scripts/run_phase5_visual_smoke.sh | 35 + src/game/app.zig | 52 +- src/game/player_tests.zig | 3 +- src/integration_test_robustness.zig | 17 +- src/interface_mock_tests.zig | 4 +- src/tests.zig | 6 +- 89 files changed, 8583 insertions(+), 746 deletions(-) create mode 100644 assets/shaders/vulkan/lod_compact_terrain.frag create mode 100644 assets/shaders/vulkan/lod_compact_terrain.vert create mode 100644 assets/shaders/vulkan/lod_compact_water.frag create mode 100644 assets/shaders/vulkan/lod_compact_water.vert create mode 100644 assets/shaders/vulkan/lod_culling.comp create mode 100644 docs/decisions/917-compact-gpu-lod-tiles.md create mode 100644 modules/engine-graphics/src/vulkan/lod_culling_system.zig create mode 100644 modules/world-lod/src/lod_cache_io.zig create mode 100644 modules/world-lod/src/lod_compact_pool.zig create mode 100644 modules/world-lod/src/lod_tile.zig create mode 100644 modules/world-lod/src/tests.zig create mode 100644 phase5_benchmark_gate_tests.zig create mode 100644 scripts/check_phase5_benchmark_config.sh create mode 100644 scripts/check_phase5_visual_smoke.py create mode 100644 scripts/run_phase5_visual_smoke.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 86c232cb..4e4fd6f6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -191,6 +191,10 @@ jobs: log-file: unit-test-${{ matrix.optimize }}.log command: nix develop .#ci-unit --command zig build -Doptimize=${{ matrix.optimize }} test + - name: Run Phase 5 compact LOD gate + if: needs.changes.outputs.code_changes == 'true' && matrix.optimize == 'Debug' + run: nix develop .#ci-unit --command zig build phase5-gate + - name: Upload unit test log if: failure() uses: actions/upload-artifact@v7 @@ -221,7 +225,7 @@ jobs: contents: read id-token: write runs-on: blacksmith-2vcpu-ubuntu-2404 - timeout-minutes: 45 + timeout-minutes: 75 needs: [changes, unit-test] steps: - uses: actions/checkout@v4 @@ -274,6 +278,18 @@ jobs: ZIGCRAFT_SMOKE_FRAMES: "3" ZIGCRAFT_SAFE_MODE: "1" + - name: Run Phase 5 compact visual gate + if: needs.changes.outputs.code_changes == 'true' + uses: ./.github/actions/run-with-log + with: + name: Phase 5 Visual Gate + timeout: 20m + log-file: phase5-visual-gate.log + command: nix develop .#ci-graphics --command zig build phase5-visual-gate + env: + XDG_RUNTIME_DIR: /tmp/runtime-runner + WAYLAND_DISPLAY: headless + - name: Fail on Vulkan validation log errors if: needs.changes.outputs.code_changes == 'true' run: | diff --git a/.gitignore b/.gitignore index d98dbfe2..c3f5dccd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ blocks-temp/ libs/zig-image/ result node_modules/ +__pycache__/ .direnv/ *-profile* test_output.txt diff --git a/AGENTS.md b/AGENTS.md index 9b40aca1..092347f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,207 +1,71 @@ -# ZigCraft Agent Guidelines - -Essential instructions for agentic coding agents operating in this Zig voxel engine repository. - ---- - -## Development Environment - -Uses Nix for dependency management. **All build/test commands MUST be wrapped in `nix develop --command`**. - -### Build & Run -```bash -nix develop --command zig build # Debug build -nix develop --command zig build run # Run application -nix develop --command zig build -Doptimize=ReleaseFast # Release build -rm -rf zig-out/ .zig-cache/ # Clean artifacts -``` - -### Testing -```bash -nix develop --command zig build test # Unit tests + shader validation -nix develop --command zig build test -- --test-filter "Vec3 addition" # Single test -nix develop --command zig build test-integration # Window init smoke test -nix develop --command zig build test-robustness # GPU robustness test -``` - -### Headless Runtime Verification -Use project skills for bounded background runs: -- `headless-crash-test` for crash, hang, startup, and world-load checks -- `headless-screenshot` for offscreen screenshots and visual evidence -- `headless-benchmark` for JSON benchmark runs with full offscreen graphics rendering -- `headless-graphics-verification` for graphics/RHI/shader verification workflows - -When launching the game from an agent, prefer `-Dskip-present` unless the user explicitly requests a visible window. Always set a tool timeout for runtime, screenshot, and benchmark commands so the agent never gets stuck on a hung game process. - -### Formatting & Git Hooks -```bash -nix develop --command zig fmt src/ # Format code -./scripts/setup-hooks.sh # Enable pre-push checks -``` - -### Debug Build Options -```bash --Ddebug_shadows # Enable shadow debug visualization --Dsmoke-test # Auto-load world and exit (for automated testing) --Dskip-present # Full offscreen graphics mode (render without showing/presenting a window) --Dbenchmark-preset=low # Graphics preset for benchmark runs (low/medium/high/ultra/extreme) --Dauto-world=normal # Auto-open a generator directly (normal/overworld or flat) --Dmonitor-index=1 # Open the game window on a specific 0-based SDL display index --Dmonitor-name=DP-2 # Move the game window to a named Hyprland monitor --Dwindow-video-driver=x11 # Force SDL's video backend (useful when Wayland ignores window placement) --Dwindow-no-focus # Create the game window without taking keyboard focus --Dstartup-diagnostic-seconds=5 # Wait N seconds, log chunk/LOD counts, and exit --Dchunk-debug-mode # Disable LOD, water, caves, and decorations for isolation --Dchunk-debug-enable=lod,caves # Re-enable individual systems in chunk-debug-mode -``` - -`-Dchunk-debug-enable=` accepts a comma-separated list of: -- `lod` -- `water` -- `watergen` -- `waterrender` -- `caves` -- `decorations` - ---- - -## Git Workflow - -- **Default branch**: `dev` -- **All PRs target `dev`** (not `main`) -- **Never push directly to `dev`** unless the user explicitly instructs you to do so -- Code changes should land through a pull request targeting `dev` so they receive PR code review -- Branch naming: `feature/*`, `bug/*`, `hotfix/*`, `ci/*` -- Conventional commits: `feat:`, `fix:`, `refactor:`, `test:`, `docs:` - ---- - -## Project Structure - -``` -modules/ - engine-core/ # Window, time, logging, job system, shared interfaces - engine-graphics/ # Render system, Vulkan backend, shaders, textures, camera, shadows - engine-rhi/ # RHI contracts, resource manager, render settings, culling interfaces - engine-math/ # Vec3, Mat4, AABB, Frustum, ray/voxel helpers - engine-input/ # Input handling - engine-ui/ # Immediate-mode UI, fonts, widgets, overlays - world-core/ # Blocks, chunk data, coordinates, light packing - world-worldgen/ # Terrain generation, biomes, caves, decorations, generator registry - world-meshing/ # Chunk storage, chunk mesh generation, GPU block buffers, meshing helpers - world-lod/ # Distant terrain LOD chunks, meshes, scheduler, renderer, manager - world-runtime/ # World facade, streamer, renderer, mutation, GPU meshing runtime - world-persistence/# Level data, region files, chunk serialization, save manager -src/ - game/ # Application logic, state, menus - c.zig # Central C imports (@cImport for SDL3, Vulkan) - main.zig # Entry point - tests.zig # Unit test suite aggregator -libs/ # Local dependencies (zig-math, zig-noise) -assets/shaders/ # GLSL shaders (vulkan/ contains SPIR-V) -``` - ---- - -## Code Style - -### Naming Conventions -- **Types/Structs/Enums**: `PascalCase` (`RenderSystem`, `BufferHandle`, `BlockType`) -- **Functions**: `camelCase` following Zig stdlib convention (`initRenderer`, `meshQueue`, `chunkX`) -- **Variables**: `snake_case` (`mesh_queue`, `chunk_x`) -- **Constants/Globals**: `SCREAMING_SNAKE_CASE` (`MAX_CHUNKS`, `CHUNK_SIZE_X`) -- **Files**: `snake_case.zig` - -### Import Order -```zig -// 1. Standard library -const std = @import("std"); -const Allocator = std.mem.Allocator; - -// 2. C imports (always via c.zig) -const c = @import("../c.zig").c; - -// 3. Project modules by package name -const Vec3 = @import("engine-math").Vec3; -const log = @import("engine-core").log; -``` - -### Memory Management -- Functions allocating heap memory MUST accept `std.mem.Allocator` -- Use `defer`/`errdefer` for cleanup immediately after allocation -- Prefer `std.ArrayListUnmanaged` in structs that store the allocator elsewhere -- Use `extern struct` for GPU-shared data layouts (e.g., `Vertex`) - -### Error Handling -- Propagate errors with `try`; define subsystem-specific error sets (`RhiError`) -- Log errors via `@import("engine-core").log`: `log.log.err("msg: {}", .{err})` -- Use `//!` for module-level docs, `///` for public API documentation - -### Type Patterns -- GPU resource handles are opaque `u32` (`BufferHandle`, `TextureHandle`, `ShaderHandle`) -- Invalid handles are `0` (`InvalidBufferHandle`, etc.) -- Packed data uses `packed struct` (e.g., `PackedLight` for sky/block light) -- Chunk coordinates: `i32`; local block coordinates: `u32` (0-15 for X/Z, 0-255 for Y) - ---- - -## Coordinate Systems - -- **World**: Global (x, y, z) in blocks/meters -- **Chunk**: `(chunk_x, chunk_z)` via `@divFloor(world, 16)` -- **Local**: (x, y, z) within a chunk -- Use `worldToChunk()` and `worldToLocal()` from `@import("world-core")` - ---- - -## Architecture - -### Render Hardware Interface (RHI) -- All rendering uses the `RHI` interface exported by `engine-rhi` -- Vulkan is the only backend (`rhi_vulkan.zig`) -- Extend functionality by updating `RHI.VTable` and backend implementation - -### Job System & Concurrency -- Use `JobSystem` for heavy tasks (world gen, meshing, lighting) -- **Never call RHI or windowing from worker threads** -- Synchronize shared state with `std.Thread.Mutex` -- Use `chunk.pin()`/`chunk.unpin()` when passing chunks to background jobs - ---- - -## Common Tasks - -### Adding a New Block Type -1. Add entry to `BlockType` enum in `modules/world-core/src/block.zig` -2. Register properties in `modules/world-core/src/block_registry.zig` -3. Add textures to `modules/engine-graphics/src/texture_atlas.zig` -4. Standardize PBR textures using `./scripts/process_textures.sh` -5. Update `modules/world-meshing/src/chunk_mesh.zig` for special face/transparency logic - -### Modifying Shaders -1. GLSL sources in `assets/shaders/vulkan/` -2. Vulkan SPIR-V validated during `zig build test` via `glslangValidator` -3. Uniform names must match exactly between shader source and RHI backends - -### Adding Unit Tests -- Add tests alongside modules and expose them from the owning module root; aggregate broad suites from `src/tests.zig` -- Use `std.testing` assertions: `expectEqual`, `expectApproxEqAbs`, `expect` -- Test naming: descriptive, e.g., `test "Vec3 normalize"` - ---- - -## Verification Checklist - -Before committing: -- [ ] Run `zig fmt src/` to format code -- [ ] Run `zig build test` (includes shader validation) -- [ ] Run `zig build -Doptimize=ReleaseFast` for performance-critical changes -- [ ] Run `./scripts/process_textures.sh` for any new texture assets - ---- - -## Performance Notes - -- Chunk mesh building runs on worker threads; avoid allocations in hot paths -- Use packed structs for large arrays (e.g., light data) -- Profile before optimizing; use `ReleaseFast` for benchmarks +# ZigCraft Agent Guide + +## Toolchain and commands + +- Use Zig 0.16.0 through Nix. Wrap every Zig build/test/format command in `nix develop --command`; CI uses the narrower `.#ci-unit` and `.#ci-graphics` shells. +- Build/run: + ```bash + nix develop --command zig build + nix develop --command zig build run + nix develop --command zig build -Doptimize=ReleaseFast + ``` +- Format all Zig code, not only `src/` (the local hook is less strict than CI): + ```bash + nix develop --command zig fmt src/ modules/ + nix develop --command zig fmt --check src/ modules/ + ``` +- `zig build test` is the broad suite: aggregate/module tests, fuzz roots, shader compilation/validation, SPIR-V size checks, shadow ABI checks, and Phase 5 policy tests. + ```bash + nix develop --command zig build test + nix develop --command zig build test -Dtest-filter="name" + # Equivalent runtime-filter form: + nix develop --command zig build test -- --test-filter "name" + ``` +- Graphics/runtime verification is separate: + ```bash + nix develop --command zig build test-integration -Dskip-present=true + nix develop --command zig build test-robustness + nix develop --command zig build phase5-gate + nix develop --command zig build phase5-visual-gate + ``` +- `-Dskip-present=true` only suppresses presentation; SDL/Vulkan initialization still needs a display/compositor and driver. Use the repo skills `headless-crash-test`, `headless-screenshot`, `headless-benchmark`, or `headless-graphics-verification`, and always bound game/graphics commands with a timeout. +- For deterministic startup checks, combine `-Dskip-present`, `-Dauto-world=`, and `-Dstartup-diagnostic-seconds=N`. `-Dchunk-debug-mode` disables LOD, water, caves, and decorations; selectively restore `lod,water,watergen,waterrender,caves,decorations` with `-Dchunk-debug-enable=`. + +## Benchmarks and generated artifacts + +- The benchmark harness is a separate build step; do not pass benchmark presets to ordinary `run` and assume benchmarking is active: + ```bash + nix develop --command zig build benchmark -Doptimize=ReleaseFast \ + -Dbenchmark-preset=low -Dbenchmark-scenario=traversal \ + -Dbenchmark-duration=60 -Dbenchmark-output=zig-out/benchmark-low.json + ``` + Scenarios are `stationary`, `traversal`, `rapid-turn`, and `teleport-eviction`. Prefer the `headless-benchmark` skill for bounded runs. +- Focused CPU-only tools: `nix develop --command zig build worldgen-report` and `nix develop --command zig build lod-bench`. Pass climate snapshot arguments after `--`, e.g. `nix develop --command zig build worldgen-climate-snapshot -- --seed 42 ...`. +- Building/tests compile GLSL and write tracked `*.spv` files beside sources in `assets/shaders/vulkan/`. After intentional shader-size changes, run `./scripts/update_spirv_baseline.sh`; `docs/shaders/spirv-sizes.json` and shadow runtime SPIR-V parity are test-enforced. +- New/changed textures go through `./scripts/process_textures.sh 512`; preserve licensing/attribution for placeholder assets. + +## Architecture boundaries + +- `src/main.zig` and `src/game/app.zig` wire the executable. Reusable session/player/settings/benchmark logic belongs in `modules/game-core`; screens belong in `modules/game-ui`. +- `engine-rhi` owns rendering contracts and opaque handles; `engine-graphics` owns the Vulkan backend, render passes, resources, and pipelines. Vulkan is currently the only backend, so interface changes usually require both the RHI vtable/contracts and Vulkan implementation. +- `world-core` owns shared block/chunk/coordinate types; world generation is split across `world-worldgen-*` packages; `world-meshing` owns chunk mesh/storage; `world-lod` owns distant terrain; `world-runtime` coordinates streaming, lighting, GPU meshing, mutation, and rendering; `world-persistence` owns saves/regions. +- Never call RHI/SDL from worker jobs. Pin chunks while background jobs hold them and synchronize shared mesh/chunk state with the project mutex abstraction. +- Convert global coordinates with `worldToChunk`/`worldToLocal` from `world-core`; chunk coordinates are floor-divided, so ad-hoc truncating division is wrong for negative positions. +- GPU-shared structs and shader blocks are ABI contracts. Use explicit layouts (`extern`/packed as appropriate), assert sizes/offsets, and update CPU, GLSL, descriptors, and backend bindings together. + +## Graphics hazards + +- `ZIGCRAFT_LOD_COMPACT=auto` currently fails closed to expanded GPU LOD meshes. `force` is diagnostic only: compact/mixed wet-dry rendering has reproducibly caused RADV `VK_ERROR_DEVICE_LOST` with no validation-layer VUID. Do not restore compact-by-default based only on headless/generated-world tests; require visible testing of a real saved world on RADV plus validation, robustness, and Phase 5 gates. +- Dedicated transfer queues cannot upload exclusive graphics buffers safely with semaphore synchronization alone; queue-family release/acquire ownership transfers are required. Until implemented, keep these uploads on the graphics family. +- A descriptor set update affects all previously recorded commands that bind that set when execution happens. Terrain/water or direct/indirect streams that need different buffers require immutable/per-layer descriptor sets, not sequential updates to one set. + +## Verification and CI + +- Match verification to the change, but graphics/RHI/shader/runtime work normally requires: format check, `zig build test`, ReleaseFast build, integration/robustness as relevant, and the appropriate headless graphics skill. CI additionally runs Debug and ReleaseSafe tests, `phase5-gate`, Lavapipe/Weston integration smoke tests, and `phase5-visual-gate`. +- Install the repo pre-push hook with `./scripts/setup-hooks.sh`, but do not treat it as CI parity: it formats only `src/` and omits ReleaseSafe and graphics jobs. + +## Git workflow + +- Branch from and target PRs to `dev`; do not push directly to `dev` unless explicitly requested. Branch prefixes are `feature/`, `bug/`, `hotfix/`, and `ci/`. +- PR titles and commits use conventional types: `feat`, `fix`, `refactor`, `test`, `docs`, `ci`, `chore`, `perf`, `build`, `style`, or `revert`. +- Every non-merge PR commit needs a DCO trailer; create commits with `git commit -s`. diff --git a/assets/shaders/vulkan/lod_compact_terrain.frag b/assets/shaders/vulkan/lod_compact_terrain.frag new file mode 100644 index 00000000..4e8fe289 --- /dev/null +++ b/assets/shaders/vulkan/lod_compact_terrain.frag @@ -0,0 +1,47 @@ +#version 450 + +layout(location = 0) in vec3 vColor; +layout(location = 1) flat in vec3 vNormal; +layout(location = 4) in float vDistance; +layout(location = 5) in float vSkyLight; +layout(location = 6) in vec3 vBlockLight; +layout(location = 7) in vec3 vFragPosWorld; +layout(location = 11) in float vAO; +layout(location = 14) in float vMaskRadius; +layout(location = 16) in float vLODFade; +layout(location = 0) out vec4 outColor; + +layout(set = 0, binding = 0) uniform Global { + mat4 view_proj; + mat4 view_proj_prev; + vec4 cam_pos; + vec4 sun_dir; + vec4 sun_color; + vec4 fog_color; + vec4 reserved0; + vec4 params; + vec4 lighting; + vec4 render_flags; + vec4 shadow_params; + vec4 pbr_params; + vec4 volumetric_params; + vec4 viewport_size; + vec4 lpv_params; + vec4 lpv_origin; +} global; + +void main() { + if (vMaskRadius >= 1.0 && length(vFragPosWorld.xz) < vMaskRadius) discard; + vec3 normal = normalize(vNormal); + vec3 light_dir = normalize(global.sun_dir.xyz); + float diffuse = max(dot(normal, light_dir), 0.0); + float block_light = max(vBlockLight.r, max(vBlockLight.g, vBlockLight.b)); + 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); + color = mix(color, global.fog_color.rgb, fog); + } + outColor = vec4(color, 1.0); +} diff --git a/assets/shaders/vulkan/lod_compact_terrain.vert b/assets/shaders/vulkan/lod_compact_terrain.vert new file mode 100644 index 00000000..2ce93def --- /dev/null +++ b/assets/shaders/vulkan/lod_compact_terrain.vert @@ -0,0 +1,205 @@ +#version 450 + +layout(location = 0) out vec3 vColor; +layout(location = 1) flat out vec3 vNormal; +layout(location = 2) out vec2 vTexCoord; +layout(location = 3) flat out int vTileID; +layout(location = 4) out float vDistance; +layout(location = 5) out float vSkyLight; +layout(location = 6) out vec3 vBlockLight; +layout(location = 7) out vec3 vFragPosWorld; +layout(location = 8) out float vViewDepth; +layout(location = 9) out vec3 vTangent; +layout(location = 10) out vec3 vBitangent; +layout(location = 11) out float vAO; +layout(location = 12) out vec4 vClipPosCurrent; +layout(location = 13) out vec4 vClipPosPrev; +layout(location = 14) out float vMaskRadius; +layout(location = 15) out float vCloud; +layout(location = 16) out float vLODFade; + +layout(set = 0, binding = 0) uniform Global { + mat4 view_proj; + mat4 view_proj_prev; + vec4 cam_pos; + vec4 sun_dir; + vec4 sun_color; + vec4 fog_color; + vec4 reserved0; + vec4 params; + vec4 lighting; + vec4 render_flags; + vec4 shadow_params; + vec4 pbr_params; + vec4 volumetric_params; + vec4 viewport_size; + vec4 lpv_params; + vec4 lpv_origin; +} global; + +// One sample is the little-endian 128-bit CompactLODSample wire ABI. The +// uvec4 lanes are bits [0..31], [32..63], [64..95], and [96..127]. +layout(set = 0, binding = 16) readonly buffer Samples { + uvec4 sample_words[]; +} samples; +struct CompactInstance { mat4 model; vec4 params; uvec4 words; }; +layout(set = 0, binding = 17) readonly buffer CompactInstances { + CompactInstance items[]; +} compact_instances; + +// Keep this byte-for-byte identical to rhi.CompactLODDraw. The explicit tail +// reserves the std430 struct-alignment words at offsets 88 and 92. +layout(push_constant) uniform CompactDraw { + mat4 model; + float mask_radius; + float lod_fade; + uint sample_offset; + uint width; + float cell_size; + uint layer; + float skirt_depth; + uint _reserved; +} draw_data; + +// `layer == 2` is the indirect sentinel. Direct draws retain their existing +// push-constant ABI; indirect indexed draws address this SSBO via firstInstance. +bool indirectMode() { return draw_data.layer == 2u; } +mat4 tileModel() { return indirectMode() ? compact_instances.items[gl_InstanceIndex].model : draw_data.model; } +vec4 tileParams() { return indirectMode() ? compact_instances.items[gl_InstanceIndex].params : vec4(draw_data.mask_radius, draw_data.lod_fade, draw_data.cell_size, draw_data.skirt_depth); } +uvec4 tileWords() { return indirectMode() ? compact_instances.items[gl_InstanceIndex].words : uvec4(draw_data.sample_offset, draw_data.width, draw_data.layer, 0u); } + +int decodeSigned16(uint word, uint shift) { + uint raw = (word >> shift) & 0xffffu; + return raw >= 0x8000u ? int(raw) - 65536 : int(raw); +} + +uint sampleIndex(uint apron_x, uint apron_z) { + uvec4 words = tileWords(); + return words.x + apron_z * (words.y + 2u) + apron_x; +} + +uvec4 sampleAtApron(uint apron_x, uint apron_z) { + return samples.sample_words[sampleIndex(apron_x, apron_z)]; +} + +float terrainHeight(uvec4 packed) { + return float(decodeSigned16(packed.x, 0u)) * (1.0 / 8.0); +} + +uint surfaceMaterial(uvec4 packed) { + return (packed.y >> 16u) & 0x7fu; +} + +uint sampleColor(uvec4 packed) { + return (packed.z >> 5u) & 0x00ffffffu; +} + +uint skyLight(uvec4 packed) { + return ((packed.z >> 29u) & 0x7u) | ((packed.w & 0x1u) << 3u); +} + +uint blockLight(uvec4 packed) { + return (packed.w >> 1u) & 0x0fu; +} + +uint ambientOcclusion(uvec4 packed) { + return (packed.w >> 5u) & 0x3fu; +} + +vec3 fallbackMaterialColor(uint material) { + if (material == 3u) return vec3(0.32, 0.58, 0.20); // grass + if (material == 2u || material == 51u || material == 52u || material == 53u) return vec3(0.43, 0.29, 0.16); + if (material == 4u || material == 31u) return vec3(0.76, 0.66, 0.38); + if (material == 10u) return vec3(0.42, 0.41, 0.38); + if (material == 12u || material == 47u) return vec3(0.88, 0.92, 0.96); + if (material == 19u) return vec3(0.24, 0.20, 0.14); + return vec3(0.43, 0.45, 0.47); +} + +void main() { + uvec4 words = tileWords(); + vec4 params = tileParams(); + uint vertex_index = uint(gl_VertexIndex); + if (words.y < 2u) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + + uint top_vertex_count = words.y * words.y; + bool is_skirt = vertex_index >= top_vertex_count; + uint x; + uint z; + uint skirt_edge = 0u; + if (!is_skirt) { + x = vertex_index % words.y; + z = vertex_index / words.y; + } else { + uint skirt_vertex = vertex_index - top_vertex_count; + skirt_edge = skirt_vertex / words.y; + uint edge_position = skirt_vertex % words.y; + if (skirt_edge >= 4u) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + if (skirt_edge == 0u) { x = edge_position; z = 0u; } + else if (skirt_edge == 1u) { x = edge_position; z = words.y - 1u; } + else if (skirt_edge == 2u) { x = 0u; z = edge_position; } + else { x = words.y - 1u; z = edge_position; } + } + uvec4 packed = sampleAtApron(x + 1u, z + 1u); + float height = terrainHeight(packed); + float height_x0 = terrainHeight(sampleAtApron(x, z + 1u)); + float height_x1 = terrainHeight(sampleAtApron(x + 2u, z + 1u)); + float height_z0 = terrainHeight(sampleAtApron(x + 1u, z)); + float height_z1 = terrainHeight(sampleAtApron(x + 1u, z + 2u)); + // Until a same-level neighbor apron arrives, duplicated edge samples would + // flatten the boundary normal. Extrapolate the interior slope instead; + // conservative skirts below hide any geometric LOD transition gap. + if (x == 0u) height_x0 = 2.0 * height - height_x1; + if (x + 1u == words.y) height_x1 = 2.0 * height - height_x0; + if (z == 0u) height_z0 = 2.0 * height - height_z1; + if (z + 1u == words.y) height_z1 = 2.0 * height - height_z0; + + vec3 normal = normalize(vec3( + height_x0 - height_x1, + 2.0 * params.z, + height_z0 - height_z1 + )); + if (is_skirt) { + height -= params.w; + if (skirt_edge == 0u) normal = vec3(0.0, 0.0, -1.0); + else if (skirt_edge == 1u) normal = vec3(0.0, 0.0, 1.0); + else if (skirt_edge == 2u) normal = vec3(-1.0, 0.0, 0.0); + else normal = vec3(1.0, 0.0, 0.0); + } + vec3 local_pos = vec3(float(x) * params.z, height, float(z) * params.z); + vec4 world_pos = tileModel() * vec4(local_pos, 1.0); + vec4 clip_pos = global.view_proj * world_pos; + + gl_Position = clip_pos; + gl_Position.y = -gl_Position.y; + vClipPosCurrent = vec4(clip_pos.x, -clip_pos.y, clip_pos.z, clip_pos.w); + vec4 previous_clip_pos = global.view_proj_prev * world_pos; + vClipPosPrev = vec4(previous_clip_pos.x, -previous_clip_pos.y, previous_clip_pos.z, previous_clip_pos.w); + + uint color = sampleColor(packed); + vColor = vec3(color & 0xffu, (color >> 8u) & 0xffu, (color >> 16u) & 0xffu) / 255.0; + if (color == 0u) vColor = fallbackMaterialColor(surfaceMaterial(packed)); + if (is_skirt) vColor *= 0.82; + vNormal = normal; + vTexCoord = vec2(float(x), float(z)) * params.z; + // Compact samples store semantic materials, not texture-atlas tile IDs. + // The terrain fragment's color-only path is the stable far-distance ABI. + vTileID = -1; + vDistance = length(world_pos.xyz); + vSkyLight = float(skyLight(packed)) / 15.0; + vBlockLight = vec3(float(blockLight(packed)) / 15.0); + vFragPosWorld = world_pos.xyz; + vViewDepth = clip_pos.w; + vAO = float(ambientOcclusion(packed)) / 63.0; + vMaskRadius = params.x; + vCloud = 0.0; + vLODFade = clamp(params.y, 0.0, 1.0); + vTangent = normalize(vec3(1.0, 0.0, (height_x1 - height_x0) / (2.0 * params.z))); + vBitangent = normalize(cross(normal, vTangent)); +} diff --git a/assets/shaders/vulkan/lod_compact_water.frag b/assets/shaders/vulkan/lod_compact_water.frag new file mode 100644 index 00000000..3e547bea --- /dev/null +++ b/assets/shaders/vulkan/lod_compact_water.frag @@ -0,0 +1,58 @@ +#version 450 + +layout(location = 0) in vec3 vColor; +layout(location = 1) flat in vec3 vNormal; +layout(location = 2) in vec2 vTexCoord; +layout(location = 3) flat in int vTileID; +layout(location = 4) in float vDistance; +layout(location = 5) in float vSkyLight; +layout(location = 6) in vec3 vBlockLight; +layout(location = 7) in vec3 vFragPosWorld; +layout(location = 8) in float vViewDepth; +layout(location = 9) in vec4 vClipPos; +layout(location = 10) in float vMaskRadius; +layout(location = 11) in float vLODFade; + +layout(location = 0) out vec4 outColor; + +layout(set = 0, binding = 0) uniform Global { + mat4 view_proj; + mat4 view_proj_prev; + vec4 cam_pos; + vec4 sun_dir; + vec4 sun_color; + vec4 fog_color; + vec4 reserved0; + vec4 params; + vec4 lighting; + vec4 render_flags; + vec4 shadow_params; + vec4 pbr_params; + vec4 volumetric_params; + vec4 viewport_size; + vec4 lpv_params; + vec4 lpv_origin; +} global; + +void main() { + if (vMaskRadius >= 1.0 && length(vFragPosWorld.xz) < vMaskRadius) discard; + // 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) * + cos(vFragPosWorld.z * 0.010 - global.params.x * 0.43); + vec3 normal = normalize(vec3(wave * 0.08, 1.0, wave * 0.06)); + vec3 light_dir = normalize(-global.sun_dir.xyz); + float diffuse = max(dot(normal, light_dir), 0.0); + float light_level = clamp(max(vSkyLight, max(vBlockLight.r, max(vBlockLight.g, vBlockLight.b))), 0.35, 1.0); + vec3 base = mix(vec3(0.025, 0.16, 0.30), vec3(0.08, 0.38, 0.64), diffuse * 0.45); + base = mix(base, vColor, 0.12) * light_level; + 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); + base = mix(base, global.fog_color.rgb, fog); + } + + outColor = vec4(base, 0.78 * clamp(vLODFade, 0.0, 1.0)); +} diff --git a/assets/shaders/vulkan/lod_compact_water.vert b/assets/shaders/vulkan/lod_compact_water.vert new file mode 100644 index 00000000..e7fb9474 --- /dev/null +++ b/assets/shaders/vulkan/lod_compact_water.vert @@ -0,0 +1,158 @@ +#version 450 + +layout(location = 0) out vec3 vColor; +layout(location = 1) flat out vec3 vNormal; +layout(location = 2) out vec2 vTexCoord; +layout(location = 3) flat out int vTileID; +layout(location = 4) out float vDistance; +layout(location = 5) out float vSkyLight; +layout(location = 6) out vec3 vBlockLight; +layout(location = 7) out vec3 vFragPosWorld; +layout(location = 8) out float vViewDepth; +layout(location = 9) out vec4 vClipPos; +layout(location = 10) out float vMaskRadius; +layout(location = 11) out float vLODFade; + +layout(set = 0, binding = 0) uniform Global { + mat4 view_proj; + mat4 view_proj_prev; + vec4 cam_pos; + vec4 sun_dir; + vec4 sun_color; + vec4 fog_color; + vec4 reserved0; + vec4 params; + vec4 lighting; + vec4 render_flags; + vec4 shadow_params; + vec4 pbr_params; + vec4 volumetric_params; + vec4 viewport_size; + vec4 lpv_params; + vec4 lpv_origin; +} global; + +layout(set = 0, binding = 16) readonly buffer Samples { + uvec4 sample_words[]; +} samples; +struct CompactInstance { mat4 model; vec4 params; uvec4 words; }; +layout(set = 0, binding = 17) readonly buffer CompactInstances { + CompactInstance items[]; +} compact_instances; + +// Must remain byte-for-byte identical to rhi.CompactLODDraw. +layout(push_constant) uniform CompactDraw { + mat4 model; + float mask_radius; + float lod_fade; + uint sample_offset; + uint width; + float cell_size; + uint layer; + float skirt_depth; + uint _reserved; +} draw_data; + +bool indirectMode() { return draw_data.layer == 2u; } +mat4 tileModel() { return indirectMode() ? compact_instances.items[gl_InstanceIndex].model : draw_data.model; } +vec4 tileParams() { return indirectMode() ? compact_instances.items[gl_InstanceIndex].params : vec4(draw_data.mask_radius, draw_data.lod_fade, draw_data.cell_size, draw_data.skirt_depth); } +uvec4 tileWords() { return indirectMode() ? compact_instances.items[gl_InstanceIndex].words : uvec4(draw_data.sample_offset, draw_data.width, draw_data.layer, 0u); } + +int decodeSigned16(uint word, uint shift) { + uint raw = (word >> shift) & 0xffffu; + return raw >= 0x8000u ? int(raw) - 65536 : int(raw); +} + +uint sampleIndex(uint apron_x, uint apron_z) { + uvec4 words = tileWords(); + return words.x + apron_z * (words.y + 2u) + apron_x; +} + +uvec4 sampleAtApron(uint apron_x, uint apron_z) { + return samples.sample_words[sampleIndex(apron_x, apron_z)]; +} + +uint waterCoverage(uvec4 packed) { + return (packed.y >> 8u) & 0xffu; +} + +bool isFullyCoveredWater(uvec4 packed) { + return decodeSigned16(packed.x, 16u) != -32768 && waterCoverage(packed) == 255u; +} + +uint sampleColor(uvec4 packed) { + return (packed.z >> 5u) & 0x00ffffffu; +} + +uint skyLight(uvec4 packed) { + return ((packed.z >> 29u) & 0x7u) | ((packed.w & 0x1u) << 3u); +} + +uint blockLight(uvec4 packed) { + return (packed.w >> 1u) & 0x0fu; +} + +void collapsePrimitiveVertex() { + // All rejected vertices share one clipped point, so fully dry primitives + // are degenerate. Boundary water also collapses, avoiding wet/dry bridge + // triangles; partial-water tiles are intentionally CPU-meshed instead. + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + vColor = vec3(0.0); + vNormal = vec3(0.0, 1.0, 0.0); + vTexCoord = vec2(0.0); + vTileID = -1; + vDistance = 0.0; + vSkyLight = 0.0; + vBlockLight = vec3(0.0); + vFragPosWorld = vec3(0.0); + vViewDepth = 0.0; + vClipPos = gl_Position; + vMaskRadius = 0.0; + vLODFade = 0.0; +} + +void main() { + uvec4 words = tileWords(); + vec4 params = tileParams(); + if (words.y < 2u) { + collapsePrimitiveVertex(); + return; + } + + uint vertex_index = uint(gl_VertexIndex); + if (vertex_index / words.y >= words.y) { + collapsePrimitiveVertex(); + return; + } + + uint x = vertex_index % words.y; + uint z = vertex_index / words.y; + uvec4 packed = sampleAtApron(x + 1u, z + 1u); + // Compact generation only accepts uniformly wet or uniformly dry tiles, so + // a nine-sample neighborhood test is redundant and needlessly amplifies + // vertex-stage storage reads on drivers where this path is being diagnosed. + if (!isFullyCoveredWater(packed)) { + collapsePrimitiveVertex(); + return; + } + + float water_height = float(decodeSigned16(packed.x, 16u)) * (1.0 / 8.0); + vec4 world_pos = tileModel() * vec4(float(x) * params.z, water_height, float(z) * params.z, 1.0); + vec4 clip_pos = global.view_proj * world_pos; + gl_Position = clip_pos; + gl_Position.y = -gl_Position.y; + + uint color = sampleColor(packed); + vColor = vec3(color & 0xffu, (color >> 8u) & 0xffu, (color >> 16u) & 0xffu) / 255.0; + vNormal = vec3(0.0, 1.0, 0.0); + vTexCoord = vec2(float(x), float(z)) * params.z; + vTileID = -1; + vDistance = length(world_pos.xyz); + vSkyLight = float(skyLight(packed)) / 15.0; + vBlockLight = vec3(float(blockLight(packed)) / 15.0); + vFragPosWorld = world_pos.xyz; + vViewDepth = -clip_pos.w; + vClipPos = vec4(clip_pos.x, -clip_pos.y, clip_pos.z, clip_pos.w); + vMaskRadius = params.x; + vLODFade = clamp(params.y, 0.0, 1.0); +} diff --git a/assets/shaders/vulkan/lod_culling.comp b/assets/shaders/vulkan/lod_culling.comp new file mode 100644 index 00000000..604b13a1 --- /dev/null +++ b/assets/shaders/vulkan/lod_culling.comp @@ -0,0 +1,85 @@ +#version 450 +layout(local_size_x = 64) in; +layout(constant_id = 0) const uint MAX_LOD_LEVELS = 5u; + +struct Candidate { + vec4 min_point; + vec4 max_point; + mat4 model; + vec4 instance_params; + uvec4 compact_words; + vec4 compact_metrics; + uvec4 terrain_command_a; + uvec4 terrain_command_b; + uvec4 water_command_a; + uvec4 water_command_b; + uvec4 lod_and_padding; +}; +struct Instance { mat4 model; vec4 params; }; +struct CompactInstance { mat4 model; vec4 params; uvec4 words; }; +layout(std430, binding = 0) readonly buffer Candidates { Candidate items[]; } candidates; +layout(std430, binding = 1) writeonly buffer TerrainInstances { Instance items[]; } terrain_instances; +layout(std430, binding = 2) writeonly buffer WaterInstances { Instance items[]; } water_instances; +layout(std430, binding = 3) writeonly buffer TerrainCommands { uvec4 items[]; } terrain_commands; +layout(std430, binding = 4) writeonly buffer WaterCommands { uvec4 items[]; } water_commands; +layout(std430, binding = 5) writeonly buffer CompactTerrainInstances { CompactInstance items[]; } compact_terrain_instances; +layout(std430, binding = 6) writeonly buffer CompactWaterInstances { CompactInstance items[]; } compact_water_instances; +// Indexed indirect commands are exactly five packed uints (20 bytes). +layout(std430, binding = 7) writeonly buffer CompactTerrainCommands { uint items[]; } compact_terrain_commands; +layout(std430, binding = 8) writeonly buffer CompactWaterCommands { uint items[]; } compact_water_commands; +layout(std430, binding = 9) buffer Counters { uint values[]; } counters; +layout(std430, binding = 10) writeonly buffer VisibleIds { uint values[]; } visible_ids; +layout(push_constant) uniform Params { vec4 planes[6]; uint candidate_count; float max_distance_blocks; uint max_commands_per_lod; uint padding; } params; + +bool visible(vec3 lo, vec3 hi) { + for (int i = 0; i < 6; ++i) { + vec3 n = params.planes[i].xyz; + vec3 p = vec3(n.x >= 0.0 ? hi.x : lo.x, n.y >= 0.0 ? hi.y : lo.y, n.z >= 0.0 ? hi.z : lo.z); + if (dot(n, p) + params.planes[i].w < 0.0) return false; + } + float dx = lo.x > 0.0 ? lo.x : (hi.x < 0.0 ? hi.x : 0.0); + float dz = lo.z > 0.0 ? lo.z : (hi.z < 0.0 ? hi.z : 0.0); + return params.max_distance_blocks <= 0.0 || dx * dx + dz * dz <= params.max_distance_blocks * params.max_distance_blocks; +} +void emit(bool water, Candidate c, uvec4 command_a, uvec4 command_b) { + if (command_a.x == 0u) return; + uint lod = min(c.lod_and_padding.x, MAX_LOD_LEVELS - 1u); + bool compact = c.lod_and_padding.y != 0u; + uint counter = (compact ? 2u * MAX_LOD_LEVELS : 0u) + (water ? MAX_LOD_LEVELS : 0u) + lod; + uint slot = atomicAdd(counters.values[counter], 1u); + if (slot >= params.max_commands_per_lod) return; + uint out_index = lod * params.max_commands_per_lod + slot; + visible_ids.values[counter * params.max_commands_per_lod + slot] = gl_GlobalInvocationID.x; + if (!compact) { + uvec4 command = uvec4(command_a.x, command_a.y, command_a.z, out_index); + Instance instance = Instance(c.model, c.instance_params); + if (water) { water_instances.items[out_index] = instance; water_commands.items[out_index] = command; } + else { terrain_instances.items[out_index] = instance; terrain_commands.items[out_index] = command; } + return; + } + CompactInstance instance = CompactInstance(c.model, c.instance_params, c.compact_words); + uint command_offset = out_index * 5u; + if (water) { + compact_water_instances.items[out_index] = instance; + compact_water_commands.items[command_offset + 0u] = command_a.x; + compact_water_commands.items[command_offset + 1u] = command_a.y; + compact_water_commands.items[command_offset + 2u] = command_a.z; + compact_water_commands.items[command_offset + 3u] = command_a.w; + compact_water_commands.items[command_offset + 4u] = out_index; + } else { + compact_terrain_instances.items[out_index] = instance; + compact_terrain_commands.items[command_offset + 0u] = command_a.x; + compact_terrain_commands.items[command_offset + 1u] = command_a.y; + compact_terrain_commands.items[command_offset + 2u] = command_a.z; + compact_terrain_commands.items[command_offset + 3u] = command_a.w; + compact_terrain_commands.items[command_offset + 4u] = out_index; + } +} +void main() { + uint index = gl_GlobalInvocationID.x; + if (index >= params.candidate_count) return; + Candidate c = candidates.items[index]; + if (!visible(c.min_point.xyz, c.max_point.xyz)) return; + emit(false, c, c.terrain_command_a, c.terrain_command_b); + emit(true, c, c.water_command_a, c.water_command_b); +} diff --git a/build.zig b/build.zig index 874bb9a9..6d4a9632 100644 --- a/build.zig +++ b/build.zig @@ -18,6 +18,7 @@ const BuildOptions = struct { window_no_focus: bool, shadow_test_variant: []const u8, benchmark_preset: []const u8, + benchmark_scenario: []const u8, benchmark_duration: u32, benchmark_output: []const u8, benchmark_render_distance: i32, @@ -65,7 +66,7 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const opts = defineBuildOptions(b); + const opts = defineBuildOptions(b, optimize); const modules = defineModules(b, target, optimize, opts); defineBuildSteps(b, target, optimize, opts, modules); } @@ -418,6 +419,7 @@ fn defineBuildSteps( const window_video_driver = opts.window_video_driver; const window_no_focus = opts.window_no_focus; const benchmark_preset = opts.benchmark_preset; + const benchmark_scenario = opts.benchmark_scenario; const benchmark_duration = opts.benchmark_duration; const benchmark_output = opts.benchmark_output; const sanitize_c = opts.sanitize_c; @@ -512,9 +514,11 @@ fn defineBuildSteps( benchmark_options.addOption([]const u8, "shadow_test_variant", "dug-cave"); benchmark_options.addOption(bool, "benchmark", true); benchmark_options.addOption([]const u8, "benchmark_preset", benchmark_preset); + benchmark_options.addOption([]const u8, "benchmark_scenario", benchmark_scenario); benchmark_options.addOption(u32, "benchmark_duration", benchmark_duration); benchmark_options.addOption([]const u8, "benchmark_output", benchmark_output); benchmark_options.addOption(i32, "benchmark_render_distance", opts.benchmark_render_distance); + benchmark_options.addOption([]const u8, "benchmark_build_mode", @tagName(optimize)); const benchmark_root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), @@ -558,6 +562,18 @@ fn defineBuildSteps( benchmark_run_cmd.step.dependOn(b.getInstallStep()); benchmark_run_cmd.step.dependOn(&shader_cmd.step); benchmark_run_cmd.setCwd(b.path(".")); + benchmark_run_cmd.setEnvironmentVariable("ZIGCRAFT_LOD_PROFILE", "1"); + // Benchmark presets must scale large persistent world buffers coherently, + // not only shader quality. This keeps their documented VRAM SLO meaningful + // on high-VRAM devices where runtime defaults otherwise reserve maximum + // pools regardless of the selected preset. + if (std.mem.eql(u8, benchmark_preset, "low")) { + benchmark_run_cmd.setEnvironmentVariable("ZIGCRAFT_VERTEX_CAPACITY_MB", "96"); + benchmark_run_cmd.setEnvironmentVariable("ZIGCRAFT_GPU_BLOCK_BUDGET_MB", "96"); + } else if (std.mem.eql(u8, benchmark_preset, "medium")) { + benchmark_run_cmd.setEnvironmentVariable("ZIGCRAFT_VERTEX_CAPACITY_MB", "96"); + benchmark_run_cmd.setEnvironmentVariable("ZIGCRAFT_GPU_BLOCK_BUDGET_MB", "96"); + } const benchmark_step = b.step("benchmark", "Run benchmark harness"); benchmark_step.dependOn(&benchmark_run_cmd.step); @@ -663,6 +679,89 @@ fn defineBuildSteps( run_exe_tests.step.dependOn(&shader_cmd.step); test_step.dependOn(&run_exe_tests.step); + // world-lod gates its dedicated test modules on builtin.is_test. Importing + // the production module into src/tests.zig leaves that module compiled with + // is_test=false, so run it as a test root as well. + const world_lod_test_root = b.createModule(.{ + .root_source_file = b.path("modules/world-lod/src/tests.zig"), + .target = target, + .optimize = optimize, + .sanitize_c = sanitize_c, + }); + addSharedImports(world_lod_test_root, modules.zig_math, modules.zig_noise, modules.fs_module, modules.sync_module, modules.c_module, options); + world_lod_test_root.addImport("engine-core", modules.engine_core); + world_lod_test_root.addImport("engine-assets", modules.engine_assets); + world_lod_test_root.addImport("engine-graphics", modules.engine_graphics); + world_lod_test_root.addImport("engine-math", modules.engine_math); + world_lod_test_root.addImport("engine-rhi", modules.engine_rhi); + world_lod_test_root.addImport("world-meshing", modules.world_meshing); + world_lod_test_root.addImport("world-core", modules.world_core); + world_lod_test_root.addImport("world-persistence", modules.world_persistence); + world_lod_test_root.addImport("world-worldgen", modules.world_worldgen); + world_lod_test_root.addOptions("world_lod_options", opts.world_lod_options); + + const world_lod_tests = b.addTest(.{ + .root_module = world_lod_test_root, + .filters = test_filters, + }); + const run_world_lod_tests = b.addRunArtifact(world_lod_tests); + run_world_lod_tests.setEnvironmentVariable("ZIGCRAFT_LOG_LEVEL", "fatal"); + test_step.dependOn(&run_world_lod_tests.step); + + const benchmark_phase5_gate_root = b.createModule(.{ + .root_source_file = b.path("phase5_benchmark_gate_tests.zig"), + .target = target, + .optimize = optimize, + .sanitize_c = sanitize_c, + }); + benchmark_phase5_gate_root.addImport("game-core", game_core); + benchmark_phase5_gate_root.addImport("engine-rhi", modules.engine_rhi); + const benchmark_phase5_gate_tests = b.addTest(.{ + .root_module = benchmark_phase5_gate_root, + .filters = test_filters, + }); + const run_benchmark_phase5_gate_tests = b.addRunArtifact(benchmark_phase5_gate_tests); + run_benchmark_phase5_gate_tests.setEnvironmentVariable("ZIGCRAFT_LOG_LEVEL", "fatal"); + test_step.dependOn(&run_benchmark_phase5_gate_tests.step); + + const phase5_lod_gate_root = b.createModule(.{ + .root_source_file = b.path("modules/world-lod/src/tests.zig"), + .target = target, + .optimize = optimize, + .sanitize_c = sanitize_c, + }); + addSharedImports(phase5_lod_gate_root, modules.zig_math, modules.zig_noise, modules.fs_module, modules.sync_module, modules.c_module, options); + phase5_lod_gate_root.addImport("engine-core", modules.engine_core); + phase5_lod_gate_root.addImport("engine-assets", modules.engine_assets); + phase5_lod_gate_root.addImport("engine-graphics", modules.engine_graphics); + phase5_lod_gate_root.addImport("engine-math", modules.engine_math); + phase5_lod_gate_root.addImport("engine-rhi", modules.engine_rhi); + phase5_lod_gate_root.addImport("world-meshing", modules.world_meshing); + phase5_lod_gate_root.addImport("world-core", modules.world_core); + phase5_lod_gate_root.addImport("world-persistence", modules.world_persistence); + phase5_lod_gate_root.addImport("world-worldgen", modules.world_worldgen); + phase5_lod_gate_root.addOptions("world_lod_options", opts.world_lod_options); + const phase5_lod_gate_tests = b.addTest(.{ + .root_module = phase5_lod_gate_root, + .filters = &.{"LODManager preserves the CPU heightfield fallback for far LODs"}, + }); + const run_phase5_lod_gate_tests = b.addRunArtifact(phase5_lod_gate_tests); + run_phase5_lod_gate_tests.setEnvironmentVariable("ZIGCRAFT_LOG_LEVEL", "fatal"); + + const phase5_benchmark_config = b.addSystemCommand(&.{ "bash", "scripts/check_phase5_benchmark_config.sh" }); + phase5_benchmark_config.setCwd(b.path(".")); + + const phase5_visual_smoke = b.addSystemCommand(&.{ "bash", "scripts/run_phase5_visual_smoke.sh" }); + phase5_visual_smoke.setCwd(b.path(".")); + + const phase5_gate_step = b.step("phase5-gate", "Validate Phase 5 LOD fallback and policy"); + phase5_gate_step.dependOn(&run_benchmark_phase5_gate_tests.step); + phase5_gate_step.dependOn(&run_phase5_lod_gate_tests.step); + phase5_gate_step.dependOn(&phase5_benchmark_config.step); + + const phase5_visual_gate_step = b.step("phase5-visual-gate", "Capture compact off/auto scenes and reject empty or divergent output"); + phase5_visual_gate_step.dependOn(&phase5_visual_smoke.step); + const engine_math_fuzz_root = b.createModule(.{ .root_source_file = b.path("modules/engine-math/src/ray_fuzz_tests.zig"), .target = target, .optimize = optimize, .sanitize_c = sanitize_c }); engine_math_fuzz_root.addImport("zig-math", zig_math); const engine_math_fuzz_tests = b.addTest(.{ .root_module = engine_math_fuzz_root, .filters = test_filters }); @@ -788,7 +887,7 @@ fn defineBuildSteps( defineShaderValidation(b, test_step); } -fn defineBuildOptions(b: *std.Build) BuildOptions { +fn defineBuildOptions(b: *std.Build, optimize: std.builtin.OptimizeMode) BuildOptions { const options = b.addOptions(); const enable_debug_shadows = b.option(bool, "debug_shadows", "Enable debug shadow visualization resources") orelse false; options.addOption(bool, "debug_shadows", enable_debug_shadows); @@ -859,10 +958,21 @@ fn defineBuildOptions(b: *std.Build) BuildOptions { const benchmark = b.option(bool, "benchmark", "Enable benchmark mode") orelse false; options.addOption(bool, "benchmark", benchmark); + // Benchmark runs opt into LOD CPU/pressure collection without requiring a + // process-wide environment mutation. Normal runs remain opt-in through + // ZIGCRAFT_LOD_PROFILE. + world_lod_options.addOption(bool, "benchmark_lod_profile", benchmark); const benchmark_preset = b.option([]const u8, "benchmark-preset", "Graphics preset to benchmark (low, medium, high, ultra, extreme)") orelse "medium"; options.addOption([]const u8, "benchmark_preset", benchmark_preset); + const benchmark_scenario = b.option([]const u8, "benchmark-scenario", "Benchmark pose scenario (stationary, traversal, rapid-turn, teleport-eviction)") orelse "traversal"; + if (!isBenchmarkScenario(benchmark_scenario)) { + std.log.err("unsupported -Dbenchmark-scenario value '{s}' (expected stationary, traversal, rapid-turn, or teleport-eviction)", .{benchmark_scenario}); + b.invalid_user_input = true; + } + options.addOption([]const u8, "benchmark_scenario", benchmark_scenario); + const benchmark_duration = b.option(u32, "benchmark-duration", "Benchmark duration in seconds") orelse 60; options.addOption(u32, "benchmark_duration", benchmark_duration); @@ -871,6 +981,7 @@ fn defineBuildOptions(b: *std.Build) BuildOptions { const benchmark_render_distance = b.option(i32, "benchmark-render-distance", "Override benchmark render and LOD horizon distance; 0 uses selected preset") orelse 0; options.addOption(i32, "benchmark_render_distance", benchmark_render_distance); + options.addOption([]const u8, "benchmark_build_mode", @tagName(optimize)); const sanitize = b.option([]const u8, "sanitize", "Sanitizer profile for test builds (none, address)") orelse "none"; const sanitize_c = resolveSanitizeC(b, sanitize); @@ -893,6 +1004,7 @@ fn defineBuildOptions(b: *std.Build) BuildOptions { .window_no_focus = window_no_focus, .shadow_test_variant = shadow_test_variant, .benchmark_preset = benchmark_preset, + .benchmark_scenario = benchmark_scenario, .benchmark_duration = benchmark_duration, .benchmark_output = benchmark_output, .benchmark_render_distance = benchmark_render_distance, @@ -900,6 +1012,13 @@ fn defineBuildOptions(b: *std.Build) BuildOptions { }; } +fn isBenchmarkScenario(scenario: []const u8) bool { + return std.mem.eql(u8, scenario, "stationary") or + std.mem.eql(u8, scenario, "traversal") or + std.mem.eql(u8, scenario, "rapid-turn") or + std.mem.eql(u8, scenario, "teleport-eviction"); +} + fn resolveSanitizeC(b: *std.Build, sanitize: []const u8) ?std.zig.SanitizeC { if (std.mem.eql(u8, sanitize, "none")) return null; if (std.mem.eql(u8, sanitize, "address")) return .full; diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md index 113c7ebf..edbedd71 100644 --- a/docs/benchmarks/README.md +++ b/docs/benchmarks/README.md @@ -28,6 +28,115 @@ After the run finishes, download the `benchmark-results` artifact and replace th This gate is intentionally a short canary so every `dev` push gets a bounded performance signal without consuming long runner time. The benchmark warmup is excluded from samples so startup shader/resource transitions do not dominate steady-state regression checks. Longer profiling runs should use the manual workflow input with a larger duration and should not replace the CI canary baseline unless that policy changes deliberately. +## Reproducible scenarios + +`-Dbenchmark-scenario` selects a deterministic, bounded camera path. The +default is `traversal`, which is the original benchmark path and remains the +only scenario used by the current CI baseline. + +| Scenario | Path | Intended signal | +| --- | --- | --- | +| `stationary` | Holds the camera at `(8, 100, 8)`. | Steady-state LOD and renderer cost. | +| `traversal` | The original 60-second, smoothly interpolated six-waypoint loop. | Streaming and normal movement; default. | +| `rapid-turn` | Holds position at `(8, 100, 8)` while cycling its view every second. | Frustum, visibility, and coverage churn without streaming movement. | +| `teleport-eviction` | Jumps every four seconds between five fixed positions within ±1,536 blocks. | Streaming, cache, deferred-destruction, and eviction pressure. | + +Every benchmark starts the `overworld` generator with fixed seed `12345`, runs +headless at 1920×1080, and records the scenario, seed, and build metadata in +the result JSON. The `build` object records the Zig optimize mode, headless +status, and fixed resolution, so artifacts can be compared only when their +configuration is compatible. + +Run each bounded scenario with these commands (15 sampled seconds plus the +one-second warmup; the external timeout also bounds a stalled process): + +```bash +timeout --preserve-status 90s nix develop --command zig build benchmark -Doptimize=ReleaseFast -Dbenchmark-duration=15 -Dbenchmark-scenario=stationary -Dbenchmark-output=docs/benchmarks/results/stationary.json +timeout --preserve-status 90s nix develop --command zig build benchmark -Doptimize=ReleaseFast -Dbenchmark-duration=15 -Dbenchmark-scenario=traversal -Dbenchmark-output=docs/benchmarks/results/traversal.json +timeout --preserve-status 90s nix develop --command zig build benchmark -Doptimize=ReleaseFast -Dbenchmark-duration=15 -Dbenchmark-scenario=rapid-turn -Dbenchmark-output=docs/benchmarks/results/rapid-turn.json +timeout --preserve-status 90s nix develop --command zig build benchmark -Doptimize=ReleaseFast -Dbenchmark-duration=15 -Dbenchmark-scenario=teleport-eviction -Dbenchmark-output=docs/benchmarks/results/teleport-eviction.json +``` + +Use the same preset and render-distance override for before/after comparison. +For example, append `-Dbenchmark-preset=high -Dbenchmark-render-distance=16` +to both commands. Unknown scenario values fail at build configuration time. + +## LOD telemetry JSON + +Benchmark results include an opt-in `lod` object when LOD profiling is enabled. +`lod.cpu_categories.manager_lock_wait` and +`lod.cpu_categories.manager_lock_hold` report cumulative CPU timing deltas as +`total_ms` and per-sampled-frame `avg_ms`, just like the other CPU categories. + +`lod.memory_bytes` reports LOD accounting gauges as `avg_bytes`, `max_bytes`, +and `last_bytes`; these are current known allocations, not GPU-driver memory +measurements. The gauge fields are: + +- `pending_cpu_upload_bytes` +- `deferred_deletion_gpu_bytes` and `deferred_deletion_cpu_bytes` +- `pool_gpu_capacity_bytes`, `pool_gpu_allocated_bytes`, + `pool_gpu_slack_bytes`, and `pool_cpu_shadow_bytes` +- `compact_pool_capacity_bytes`, `compact_pool_allocated_bytes`, + `compact_pool_free_bytes`, and `compact_pool_retired_bytes` +- `direct_mesh_gpu_bytes` and `known_memory_bytes` + +`deferred_deletion_bytes` remains as a compatibility alias of the GPU deferred +deletion gauge. In contrast, `upload_total_bytes`, visibility totals, and +pressure counts are cumulative counter deltas over sampled frames; they must +not be interpreted as allocation gauges. + +## Phase 5 production gate + +Run the lightweight policy gate with: + +```bash +nix develop --command zig build phase5-gate +``` + +It verifies that the build accepts exactly the four scenarios above (and rejects +an unbounded name), that the runtime parser recognizes the same bounded set, +that LOD3/LOD4 retain the CPU heightfield fallback when an optional mesh path +is selected, and that the tables in this document and +[`../lod-quality-controls.md`](../lod-quality-controls.md) match the runtime +benchmark SLO and LOD-preset budgets. + +The compact visual smoke gate is intentionally a separate graphics target. It +captures the same deterministic dry `flat` auto-world scene with +`ZIGCRAFT_LOD_COMPACT=off` and `force`, writing `compact-off.png`, +`compact-auto.png`, per-capture logs, and `metrics.json` to +`zig-out/phase5-visual-smoke/`. It rejects missing, black, or visually empty +captures, requires non-zero compact pool residency in the forced capture, and +rejects gross coarse-sampled divergence above 0.70. This deliberately avoids a pixel-exact golden; override its broad +health thresholds only for diagnosed platform differences with +`PHASE5_VISUAL_MIN_NON_BLACK`, `PHASE5_VISUAL_MIN_LUMA_RANGE`, +`PHASE5_VISUAL_MIN_LUMA_BINS`, and +`PHASE5_VISUAL_MAX_NMAE`. + +Run only this slower graphics check with: + +```bash +nix develop --command zig build phase5-visual-gate +``` + +## Visual baseline capture matrix + +When a LOD rendering change needs visual evidence, capture before and after +images using the matching fixed seed, preset, render distance, and scenario. +The benchmark runner is headless; this table defines the required evidence +rather than storing screenshots in this repository. + +| Visual concern | Scenario | Capture points | Inspect | +| --- | --- | --- | --- | +| Terrain seams | `stationary` | After one-second warmup; after full-detail chunks settle. | Cracks, gaps, normal discontinuities, and material changes at LOD boundaries. | +| LOD transitions | `traversal` | Before, during, and after each terrain-ring handoff. | Popping, holes, duplicate terrain, and transition timing. | +| Water | `traversal` | Near water at an LOD boundary and after the boundary moves. | Shoreline cracks, water/terrain overlap, and missing distant water. | +| Fog | `rapid-turn` | Each cardinal-view turn, especially toward the horizon. | Fog discontinuities, horizon banding, and terrain visible through fog. | +| Full-detail handoff | `teleport-eviction` | Immediately after a jump and after detailed chunks replace LOD. | Stale regions, incorrect ownership, and gaps while uploads/evictions overlap. | + +Record the generated JSON filename beside each capture set. No screenshot is +required for routine benchmark changes; use this matrix when visual behavior +is affected. + ## Tolerance Policy `scripts/compare_benchmarks.sh` applies an explicit tolerance per metric: @@ -42,7 +151,7 @@ The p1 FPS metric is the primary user-visible smoothness guard. GPU time and dra ## Absolute SLOs -The benchmark harness also enforces absolute service-level objectives before regression comparison. These thresholds are intentionally separate from `baseline.json`: a run can fail even if there is no baseline drift when the absolute floor or ceiling is breached. +The benchmark harness also enforces absolute service-level objectives before regression comparison. These thresholds are intentionally separate from `baseline.json`: a run can fail even if there is no baseline drift when the absolute floor or ceiling is breached. The LOD preset's CPU-source/mesh RAM cap and persistent-store cap are defined in [`../lod-quality-controls.md`](../lod-quality-controls.md); the GPU limits below cover measured renderer resource memory. | Preset | p1 FPS min | Max frame ms | Draw calls avg max | Vertices avg max | GPU memory max | | --- | ---: | ---: | ---: | ---: | ---: | diff --git a/docs/decisions/917-compact-gpu-lod-tiles.md b/docs/decisions/917-compact-gpu-lod-tiles.md new file mode 100644 index 00000000..b7dcb580 --- /dev/null +++ b/docs/decisions/917-compact-gpu-lod-tiles.md @@ -0,0 +1,165 @@ +# Decision: compact GPU-rendered far-terrain tiles + +**Issue:** [#917](https://github.com/OpenStaticFish/ZigCraft/issues/917) + +**Status:** Proposed decision with implementation foundation; not a claim that a compact GPU terrain renderer is already shipped. + +## Context and evidence + +Issue #917 is Phase 4 of the LOD GPU roadmap. It follows the completed Phase 0–3 work rather than replacing it: + +- Phase 0 added opt-in LOD CPU/upload/pressure telemetry in + [`modules/world-lod/src/lod_stats.zig`](../../modules/world-lod/src/lod_stats.zig), bounded benchmark scenarios in + [`docs/benchmarks/README.md`](../benchmarks/README.md), and the CPU-only generation probe in + [`src/lod_bench_main.zig`](../../src/lod_bench_main.zig). +- Phase 1 moves mesh generation outside the manager lock, keeps CPU generation and policy authoritative, batches MDI where possible, and retains selectable heightfield, span, and QEM CPU mesh paths in + [`lod_manager_generation_ops.zig`](../../modules/world-lod/src/lod_manager_generation_ops.zig) and + [`lod_mesh.zig`](../../modules/world-lod/src/lod_mesh.zig). +- Phase 2 gives streaming a bounded upload path and deferred retirement. Upload bytes and staging-pressure events are recorded in + [`lod_manager_upload_ops.zig`](../../modules/world-lod/src/lod_manager_upload_ops.zig); eviction defers deletion in + [`lod_manager_eviction_ops.zig`](../../modules/world-lod/src/lod_manager_eviction_ops.zig). The current shared vertex pool remains a CPU-shadowed `Vertex` pool in + [`lod_vertex_pool.zig`](../../modules/world-lod/src/lod_vertex_pool.zig). +- Phase 3 supplies CPU-approved candidates, GPU frustum compaction, and indirect terrain/water streams through + [`lod_renderer.zig`](../../modules/world-lod/src/lod_renderer.zig) and + [`modules/engine-rhi/src/culling.zig`](../../modules/engine-rhi/src/culling.zig). It intentionally falls back to CPU visibility when the workload, pooled-buffer state, or indirect-count support is unsuitable. + +The present far path still worker-builds CPU-expanded `engine-rhi.Vertex` arrays and uploads them. `Vertex` is an `extern struct` carrying position, packed colour/normal/meta, UV, and block light +([`rhi_types.zig`](../../modules/engine-rhi/src/rhi_types.zig)); `LODMesh` holds pending vertices and separate opaque/water ranges. LOD3 and coarser currently select the heightfield path, which emits top quads, stepped side faces, optional water quads, and some vegetation geometry. Rich source columns already contain height, material layers, water, lighting, vegetation, provenance, and optional vertical spans in +[`modules/world-core/src/lod_data.zig`](../../modules/world-core/src/lod_data.zig). + +This is enough evidence to define and prototype a compact representation, but not enough to assert that it wins on every GPU. Existing instrumentation measures worker mesh construction, uploads, CPU visibility, state work, staging pressure, deferred CPU vertex capacity, candidate counts, and CPU/GPU culling diagnostics. It does **not** yet separately account for compact-tile residency, tile-fetch bandwidth, tile-expansion time, or far-terrain GPU timestamps. Those measurements are rollout requirements below. No performance result is implied by this decision. + +## Decision + +Adopt **a reusable indexed grid plus compact, device-local storage-buffer tiles** as the first production GPU path for LOD3 and LOD4. The grid supplies only local sample coordinates and indices; the vertex shader (vertex pulling) obtains the selected tile's compact samples and derives final terrain vertices. The existing Phase 3 CPU-approved visibility and indirect submission remain the first submission path. + +The path is intentionally a heightfield-oriented far representation. It does not promise arbitrary overhangs, per-block side topology, or detailed tree meshes at LOD3/4. Those stay on the existing near/mid CPU mesh paths until a separately validated representation is needed. + +Compute expansion is retained as a measured follow-up option, not the initial default. Mesh shaders are an experimental, supported-device-only spike and are not a production dependency. + +### Why this is the first path + +| Option | Transfer bytes and VRAM | Shader bandwidth / work | Seams and normals | Edits | Support and maintenance | Decision | +| --- | --- | --- | --- | --- | --- | --- | +| **Reusable indexed grid + compact tile SSBO** | Uploads tile samples and metadata instead of repeated expanded vertices; one grid/index buffer is shared. Resident tile payload is proportional to samples, plus descriptors/indirection, rather than per-region vertex capacity. Actual reduction must be measured. | Each grid vertex fetches a small, regular sample neighbourhood. It trades vertex fetch/upload bandwidth for SSBO reads and derived attributes, but has no expansion pass or generated vertex pool. | A one-sample apron makes central-difference normals and edge positions deterministic; precomputed stitch/skirt variants handle topology. | Upload changed tile records or replace a tile version; no readback and no CPU vertex rebuild for the far path. | Uses conventional Vulkan graphics, storage buffers, and indirect drawing already represented by the RHI. One terrain shader variant and one tile allocator are required. | **Selected.** Lowest new synchronization and allocator surface while directly attacking repeated vertex data. | +| **Compute expansion into pooled vertex/index buffers** | Compact upload is still possible, but expanded GPU vertices/indices reintroduce substantial resident memory and pool fragmentation. | Moves expansion and side/skirt generation to compute, adds write bandwidth and compute-to-graphics barriers. It may win only when vertex pulling is demonstrated to be the bottleneck. | Can emit exact stitch/side geometry, but owns a second geometry generator whose boundary rules must stay equivalent to CPU and shader rules. | A tile edit requires re-expanding its allocation and safely retiring/reallocating its old range. | Needs a robust output-pool allocator, overflow handling, barriers, and retirement for generated ranges in addition to the input tile system. | Defer until grid-path GPU timing or feature limits demonstrate a need. | +| **Mesh shaders** | Can avoid persistent expanded geometry and can generate topology per workgroup. | Potentially efficient for culling/amplification and topology generation, but performance and limits are device-specific. | Flexible, but seam and normal code becomes mesh-shader-specific and must still match the fallback. | Tile replacement is straightforward; generated output is transient. | Requires mesh-shader capability detection, new RHI/pipeline work, and a conventional maintained renderer. It excludes too many target devices as a first path. | Research-only after the selected path is stable; never the sole renderer. | + +The comparison explicitly does not assume that shader bandwidth is free or that compute is automatically superior. The grid path is accepted only if its tile reads, vertex work, and visual quality meet the gates below; the CPU mesh path remains the safe alternative. + +## Compact semantic tile ABI + +The compact tile is a **GPU transport/render ABI**, not a replacement for `LODSimplifiedData` or its persisted source semantics on day one. CPU data and persistence remain authoritative. The foundation provides a versioned, checksummed compact wire encoding for testing and future cache integration, but the canonical LOD cache does not consume it yet; that integration requires an explicit cache-version/migration decision. + +The foundation tile is a versioned, fixed-endian 16-byte sample grid with a small serialized header (magic, ABI version, LOD, neighbor-apron mask, width/count, and payload CRC). The production renderer must add a std430-compatible GPU header/instance view that provides: + +- ABI version, tile state/flags, LOD level, region key/origin, cell size, interior grid dimensions, and the tile's generation/edit version; +- a documented height decode and quantization error bound derived from the selected level's needs (the foundation ABI uses signed 1/8-block absolute heights; a future affine/base-relative format requires an ABI version bump); +- offsets/counts into separately aligned arrays for samples and optional far-detail records, not native Zig pointers or enums; +- conservative min/max terrain and water heights, world-space bounds, and an availability/ready flag used by CPU hierarchy policy; and +- explicit feature bits for water, lighting hint, vegetation aggregate, provenance availability, apron validity, and any future extension. Unknown required bits or ABI versions reject the compact path and use CPU mesh fallback. + +The interior sample record must preserve the following semantics even if the final packing evolves: + +| Semantic | Requirement | +| --- | --- | +| Terrain height | Quantized relative to the tile decode range; decoding must reproduce the shared edge sample exactly for adjacent same-level tiles. | +| Surface/material | Stable semantic block IDs plus representative tint information sufficient for far terrain. The foundation ABI stores known seven-bit `BlockType` wire values—not atlas IDs—and reduces unknown future values to stone; changing those wire values requires an ABI version bump. | +| Colour and lighting | Packed representative colour/tint and the far lighting/AO hints required by the selected terrain shading policy. Do not silently reinterpret channel order or colour space. | +| Water | Separate surface-present/coverage classification, quantized surface height or canonical sea-level indication, and depth/colour category sufficient for the far-water pass. Terrain-floor and water-surface semantics remain distinct. | +| Vegetation | The foundation ABI stores aggregate coverage and representative height. A production renderer must either declare a folded representative terrain-colour policy or add versioned type/colour fields; it must not imply a full tree mesh. | +| Provenance | Worldgen, chunk-derived, and edited authority must be retained in CPU source data. A compact provenance class is included when needed for diagnostics and invalidation, not as permission for the GPU to resolve edits. | + +Vertical spans are deliberately outside the first LOD3/4 tile contract. A tile marked as requiring unsupported span/overhang representation is rendered through the CPU fallback (or a later explicitly versioned extension), never flattened without a visual-policy decision. + +### Edge apron and seam contract + +Every resident tile contains a **one-sample apron** around its interior height/material/water samples. Foundation generation starts with a duplicated-edge fallback and records no neighbor-valid bits. As same-level neighbors become available, the producer patches each apron from the neighbor's opposite interior edge and records the corresponding validity bit before enabling seamless compact rendering. + +- Shared same-level edges must decode to identical positions and compatible material/water classification. Adjacent uploads may arrive in any order; a tile with an invalid apron is not eligible for the seamless compact draw path. +- Vertex normals use central differences from the apron in the interior and at boundaries. They are derived in the vertex shader for the initial path; no per-vertex normal storage is required. The normal calculation uses decoded world spacing and the same height quantization policy on both sides of an edge. +- Cross-LOD boundaries use an explicit edge mask selected by CPU hierarchy policy. The initial implementation may use prebuilt indexed-grid edge/stitch variants and conservative skirts; it must not rely on a fragment discard to hide geometry gaps. Geomorphing is optional only after its transition versioning and temporal behaviour are validated. +- Skirt depth, water handoff, and unavailable-neighbour behaviour are part of the tile draw constants, not shader guesses. Existing CPU helpers in + [`lod_seam.zig`](../../modules/world-lod/src/lod_seam.zig) and + [`lod_geometry.zig`](../../modules/world-lod/src/lod_geometry.zig) are the behavioural reference to test against, not a guarantee that their current CPU mutation algorithm can be copied unchanged. + +## CPU and GPU responsibilities + +| CPU-authoritative work | GPU work for the selected path | +| --- | --- | +| Procedural sampling, chunk-derived ingestion, edit merge/provenance precedence, source persistence, invalidation, LOD hierarchy/readiness, parent fallback, coverage policy, and choosing a tile/edge variant. | Vertex-pull compact terrain samples from the reusable grid, decode heights/material attributes, calculate local normals from the apron, rasterize terrain, and submit the already-established indirect draw stream. | +| Build/version compact records; enqueue bounded uploads and tile replacement; retain CPU source until the new GPU version is known safe. | Render far water as a separate compact grid/indirect stream and apply far-only shading. GPU culling may reject CPU-approved candidates, but does not decide hierarchy correctness. | +| Determine whether a source requires the CPU mesh fallback; respond to unsupported hardware and validation failures. | No procedural world generation, persistence, edit conflict resolution, source-of-truth mutation, or GPU-to-CPU readback in normal operation. | + +### Far water + +Far water uses the tile's separate water-surface semantics and a flat/reduced grid. It is a distinct terrain-adjacent pass so opaque terrain does not need water vertices. Its first production shader must use fog, a stable water colour/depth category, inexpensive animated normal/specular treatment, and the shared edge policy. It must avoid unnecessary screen-space reflections, reflection-buffer sampling, and scene-depth/depth-thickness work for the far tier. Near water retains the existing quality path in +[`assets/shaders/vulkan/water.frag`](../../assets/shaders/vulkan/water.frag); this decision does not remove it. + +## Lifetime and synchronization + +Compact input tiles, tile-instance/indirection records, and the reusable grid have separate lifetimes. + +1. Workers produce immutable CPU tile versions from an immutable source snapshot. They do not call RHI or mutate a record visible to the render thread. +2. The render thread allocates a device-local tile range, stages the complete new version, writes it into a frame-safe instance/indirection slot, and records the required transfer-to-shader-read barrier before the draw/compute use. +3. CPU hierarchy readiness publishes the new version only after its upload has been submitted with an associated submission/timeline value. Until then it draws the old version or the parent/CPU fallback; it never points an indirect draw at partially uploaded storage. +4. Replaced or evicted ranges, descriptor/instance entries, and staging allocations are retired only after the last graphics or compute submission that can reference them has completed. Retirements are bounded, accounted for, and reclaimed by completed fence/timeline values—not `vkDeviceWaitIdle` during streaming. +5. Pool/atlas growth uses a new backing allocation and preserves old bindings until their retirement value completes. It must not require CPU shadow copies of expanded vertices. Shutdown remains the only permitted whole-device idle boundary. + +This extends the Phase 2 deferred-deletion intent to compact resources. It must also work with the existing frames-in-flight policy and the Phase 3 compute-to-indirect/storage ordering. A device loss, upload failure, allocator exhaustion, ABI mismatch, or compact-path validation failure leaves the prior safe draw active where possible and otherwise returns the region to the CPU mesh/parent fallback state. + +## Feature gates and fallback + +The feature is opt-in during rollout and is selected per device and per region. The exact public setting name may be chosen with the implementation, but it must expose `off`, `auto`, and `force` semantics and report the selected path/reason in diagnostics. Existing controls such as `ZIGCRAFT_LOD_GPU_CULLING`, `ZIGCRAFT_DISABLE_LOD_MDI`, `ZIGCRAFT_LOD_MESH_PATH_SPANS`, and `ZIGCRAFT_LOD_MESH_PATH_QEM` continue to exercise their current paths; they are not redefined by this decision. + +`auto` enables compact tiles only when the backend can create and bind the required storage buffers, supports the conventional graphics/indirect route used by the selected renderer, has sufficient descriptor/range limits for the configured tile pool, and passes startup ABI/shader self-checks. `force` is a diagnostic mode: it may fail visibly in logs and fall back, but must never disable the fallback or manufacture support. Mesh-shader availability is a separate capability and cannot enable the first production path. + +The maintained fallback is the present CPU-generated `LODMesh` heightfield path, through direct draws or MDI/CPU visibility as supported. It remains covered by unit, integration, and visual tests. LOD0–2 stay on their current quality paths; unsupported compact features (notably span/overhang source data) also use this fallback. The fallback is a release requirement, not a temporary debug option. + +## Phased rollout and validation gates + +### A. Foundation: ABI and accounting + +- Add pure CPU encode/decode tests for every semantic, alignment/offset assertion for the GPU view, version rejection, quantization error bounds, and identical shared-edge decoding. +- Add source-to-tile provenance/edit invalidation tests using `LODSimplifiedData`; do not alter persistence format yet. +- Add compact-specific counters: submitted/uploaded tile bytes, resident/allocated/free/retired tile bytes, tile count by LOD/state, tile replacement count, apron-missing/fallback count, and CPU fallback reason. Keep expanded CPU mesh and compact tile memory distinct. +- Add GPU timestamp scopes for compact terrain vertex/raster work and far water, plus optional vertex-pulling/expansion scope if a compute prototype exists. Report these separately from Phase 3 culling and the existing terrain pass. + +**Gate:** ABI tests pass; accounting includes live plus retired compact allocations; `auto` is the default but must fail closed until the active driver is qualified, with explicit `off` and diagnostic `force` overrides; normal operation makes no GPU readback and no streaming `waitIdle`. + +### B. Reusable-grid terrain prototype + +- Add a shared indexed grid and compact-tile storage/instance binding without changing CPU hierarchy or Phase 3 candidate approval. +- Render a single LOD3/4 tile beside the CPU reference using deterministic source data. Compare decoded heights, material/tint selection, water classification, and normal direction in a debug mode. +- Exercise direct and MDI submission, as well as CPU and GPU-culling selection, without requiring GPU culling to be active. + +**Gate:** GPU and CPU reference images show no unacceptable height displacement, material mismatch, or normal discontinuity; fallback renders if any capability or tile validation check fails; shader validation and RHI contract tests pass. + +### C. Edges, hierarchy, water, and edits + +- Enable adjacent same-level tiles with aprons, then cross-LOD edge variants/skirting and parent-child transition coverage. +- Add the simplified far-water pass and test water-to-terrain as well as water-to-water edges. +- Test rapid edits, chunk-derived ingestion, replacement-before-use, eviction while in flight, teleport, save/reload, and stale job/token rejection. Record edit-to-visible-version latency; do not set a target before baseline data exists. + +**Gate:** the visual matrix in [`docs/benchmarks/README.md`](../benchmarks/README.md) finds no unacceptable cracks, holes, normal seams, transition instability, stale edit, water artifact, or full-detail handoff regression. GPU validation reports no lifetime/barrier errors and retirement stays bounded. + +### D. Measured production decision + +Run comparable fixed-seed, fixed-preset, fixed-render-distance `stationary`, `traversal`, `rapid-turn`, and `teleport-eviction` scenarios, using the documented headless commands and recording build mode and hardware. Compare compact and CPU paths using: + +- per-LOD and total upload bytes, pending upload bytes, worker mesh-construction time, compact encode time, and edit replacement latency; +- compact resident/allocated/retired VRAM, CPU source memory, CPU-expanded vertex memory, grid/index memory, and staging pressure; +- compact terrain/water GPU timestamp cost, frame p50/p95/p99 and worst-frame attribution, draw/indirect counts, candidate/visible/rejected counts, and shader bandwidth proxies available from the profiler; and +- visual captures from the documented seam, transition, water, fog, and full-detail-handoff matrix on supported and fallback devices. + +**Gate:** demonstrate, with recorded artifacts rather than assumed ratios, materially lower LOD3/4 upload bytes and resident geometry memory than the comparable CPU-expanded baseline; eliminate or substantially reduce far-level worker mesh time; preserve visual quality; and keep the fallback functional. The existing benchmark regression/SLO policy remains a guardrail, but it is not evidence by itself that compact tiles are beneficial on player hardware. + +### E. Default enablement and follow-ups + +Default `auto` only after the preceding gate succeeds on the supported-device matrix and long-running robustness runs show bounded resource retirement. Keep `off` available for bisecting. Re-evaluate compute expansion only if measurements identify vertex-pulling bandwidth, topology limits, or raster work as the next constraint. Re-evaluate mesh shaders only as an optional specialization with the conventional path still maintained. + +## Consequences + +This choice reduces the first implementation's scope to a compact heightfield ABI, a reusable grid, shader vertex pulling, and resource lifetime integration. It preserves the existing CPU source, persistence, hierarchy, MDI, and GPU-culling investments, and makes quality knobs such as sample density, material aggregation, water, and vegetation independent of raw expanded-vertex uploads. + +It also introduces a durable ABI, tile allocator, descriptor/instance versioning, new shader variants, and visual boundary obligations. The work must not be described as complete merely because this decision document or its foundation code lands: issue #917's renderer, metrics, visual validation, and maintained fallback gates remain implementation work. diff --git a/docs/lod-quality-controls.md b/docs/lod-quality-controls.md index d641bdfd..70f67926 100644 --- a/docs/lod-quality-controls.md +++ b/docs/lod-quality-controls.md @@ -2,14 +2,60 @@ The render distance preset is the supported user-facing control for distant LOD quality. Presets intentionally expose a small set of stable knobs: -- `lod_radii`: chunk radii for LOD0 through LOD3. +- `lod_radii`: chunk radii for LOD0 through LOD4. +- `horizon_radius`: the supported far-terrain horizon in chunks. +- `lod_store_size_cap_mb`: an aggregate cap across all persistent `.zlod` + 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. -- `vertical_span_budget`: enables rich column/span source data when greater than zero. Defaults keep this disabled to preserve current memory and generation cost. -- `mesh_path`: selects the stable heightfield path by default. `column_spans` and `qem` are available for controlled testing. +- `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. - `memory_budget_mb` and `max_uploads_per_frame`: bound cache pressure and per-frame GPU upload work. -Default presets preserve existing performance expectations by keeping `mesh_path = .heightfield` and `vertical_span_budget = 0`. +## Supported presets + +| Preset | Horizon | Horizontal detail LOD0–4 | Span setting | RAM budget | Store cap | Uploads/frame | +| --- | ---: | --- | ---: | ---: | ---: | ---: | +| Low | 256 chunks | 33/33/33/65/65 | 2 | 128 MB | 512 MB | 4 | +| Medium | 512 chunks | 33/49/49/65/65 | 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 | + +These values are policy inputs, not a promise that all hardware sustains the +full horizon. The memory governor shrinks refinement radii under pressure but +preserves the coarsest parent horizon so missing children do not produce holes. +The benchmark SLOs and regression thresholds are maintained in +[`benchmarks/README.md`](benchmarks/README.md). + +## Requirements and fallback behavior + +- Vulkan compute and indirect drawing are preferred for GPU LOD culling. When + unavailable or disabled, CPU visibility and indirect submission remain the + correctness fallback. +- Compact far-tile encoding rejects source columns it cannot represent (for + example, an unsupported span layout). Production rendering remains on the + CPU mesh path until compact-tile residency and shaders are enabled, so + unsupported data is never silently dropped. +- 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. +- 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. +- Cache payloads are checksummed and keyed by seed, generator identity/version, + coordinates, and schema. Corrupt or provenance-incomplete payloads are + deleted and regenerated. Source authority (`worldgen`, `chunk_derived`, or + `edited`) is serialized even for span-less far levels. +- Persistent storage, pending cache I/O, generation admission, CPU source/mesh + memory, upload count, and upload bytes are bounded. A container that cannot + fit within the aggregate store cap is rejected rather than allowing + persistent storage to grow without limit. ## Diagnostics @@ -17,6 +63,21 @@ Set `ZIGCRAFT_LOD_DIAG=1` to log LOD queue, render, and aggregate stats diagnost Runtime mesh path overrides are available while alternate mesh paths stabilize: +- `ZIGCRAFT_LOD_COMPACT=off` explicitly keeps expanded CPU LOD meshes. +- `ZIGCRAFT_LOD_COMPACT=auto` is the default and currently fails closed to + expanded GPU LOD meshes. API/resource capability checks alone are insufficient: + mixed compact and expanded regions can trigger RADV command-stream rejection. +- `ZIGCRAFT_LOD_COMPACT=force` requests the same compact path for validation; + unrepresentable terrain, partial-water tiles, allocation pressure, and runtime + draw failures still fall back to a dedicated CPU-built GPU mesh rather than dropping a region. +- Normal expanded LOD drawing remains GPU-backed by default. The separate + `ZIGCRAFT_LOD_GPU_CULLING=1` indirect-compaction optimization is opt-in and + currently applies only to expanded pooled meshes. Compact tiles intentionally + use CPU visibility plus direct GPU draws until terrain and water have immutable + per-layer instance descriptor sets. +- Wet LOD3/4 regions currently remain on the expanded fallback mesh because the + compact water vertex path can trigger RADV command-stream rejection. Dry + regions use compact GPU terrain only in diagnostic `force` mode. - `ZIGCRAFT_LOD_MESH_PATH_QEM=1` forces the QEM decimation path. - `ZIGCRAFT_LOD_MESH_PATH_SPANS=1` forces the column/span mesh path. diff --git a/docs/shaders/spirv-sizes.json b/docs/shaders/spirv-sizes.json index 05a7c6c7..5a04dd10 100644 --- a/docs/shaders/spirv-sizes.json +++ b/docs/shaders/spirv-sizes.json @@ -12,6 +12,11 @@ "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.vert": 18452, + "assets/shaders/vulkan/lod_compact_water.frag": 5364, + "assets/shaders/vulkan/lod_compact_water.vert": 13332, + "assets/shaders/vulkan/lod_culling.comp": 14044, "assets/shaders/vulkan/lpv_inject.comp": 4844, "assets/shaders/vulkan/lpv_propagate.comp": 8316, "assets/shaders/vulkan/mesh.comp": 45244, @@ -21,18 +26,18 @@ "assets/shaders/vulkan/shadow.vert": 5236, "assets/shaders/vulkan/sky.frag": 18756, "assets/shaders/vulkan/sky.vert": 2696, - "assets/shaders/vulkan/ssao_blur.frag": 1760, "assets/shaders/vulkan/ssao.frag": 6588, "assets/shaders/vulkan/ssao.vert": 1160, + "assets/shaders/vulkan/ssao_blur.frag": 1760, "assets/shaders/vulkan/taa.frag": 9760, "assets/shaders/vulkan/taa.vert": 1160, - "assets/shaders/vulkan/terrain_debug.frag": 1336, "assets/shaders/vulkan/terrain.frag": 63336, "assets/shaders/vulkan/terrain.vert": 10500, + "assets/shaders/vulkan/terrain_debug.frag": 1336, "assets/shaders/vulkan/ui.frag": 744, + "assets/shaders/vulkan/ui.vert": 1336, "assets/shaders/vulkan/ui_tex.frag": 1408, "assets/shaders/vulkan/ui_tex.vert": 1344, - "assets/shaders/vulkan/ui.vert": 1336, "assets/shaders/vulkan/water.frag": 22236, "assets/shaders/vulkan/water.vert": 8796 } diff --git a/modules/engine-core/src/time.zig b/modules/engine-core/src/time.zig index 9225c909..4fab2720 100644 --- a/modules/engine-core/src/time.zig +++ b/modules/engine-core/src/time.zig @@ -8,6 +8,26 @@ pub fn timestampMs() i64 { return std.Io.Clock.real.now(std.Options.debug_io).toMilliseconds(); } +/// A lightweight monotonic elapsed-time timer backed by SDL's high-resolution +/// performance counter. Construct it only at an enabled instrumentation site. +pub const MonotonicTimer = struct { + start_ticks: u64, + + pub fn start() MonotonicTimer { + return .{ .start_ticks = c.SDL_GetPerformanceCounter() }; + } + + pub fn read(self: MonotonicTimer) u64 { + const frequency = c.SDL_GetPerformanceFrequency(); + if (frequency == 0) return 0; + const elapsed_ticks = c.SDL_GetPerformanceCounter() - self.start_ticks; + // Split quotient/remainder to preserve precision without overflowing + // for a process that has been running for a long time. + return (elapsed_ticks / frequency) * std.time.ns_per_s + + (elapsed_ticks % frequency) * std.time.ns_per_s / frequency; + } +}; + pub const Time = struct { /// Time since last frame in seconds delta_time: f32 = 0, diff --git a/modules/engine-graphics/src/cloud_system.zig b/modules/engine-graphics/src/cloud_system.zig index 6a4b4457..3f96e3c2 100644 --- a/modules/engine-graphics/src/cloud_system.zig +++ b/modules/engine-graphics/src/cloud_system.zig @@ -67,7 +67,10 @@ pub const CloudSystem = struct { pub fn render(self: *CloudSystem, ctx: rhi.RenderContext, camera_pos: Vec3) !void { if (!self.config.enabled or self.config.density <= 0.0) return; try self.updateMesh(camera_pos); - try self.uploadMesh(); + self.uploadMesh() catch |err| switch (err) { + error.OutOfMemory, error.PendingCopyOverflow => return, + else => return err, + }; if (self.vertex_count == 0) return; ctx.setTerrainPipelineBound(false); const smooth_offset = Vec3.init( @@ -84,7 +87,10 @@ pub const CloudSystem = struct { pub fn renderShadow(self: *CloudSystem, ctx: rhi.RenderContext, camera_pos: Vec3) !void { if (!self.config.enabled or self.config.density <= 0.0 or self.vertex_count == 0 or !self.gpu_valid) return; try self.updateMesh(camera_pos); - try self.uploadMesh(); + self.uploadMesh() catch |err| switch (err) { + error.OutOfMemory, error.PendingCopyOverflow => return, + else => return err, + }; const smooth_offset = Vec3.init( (self.origin_x - self.mesh_origin_x) - (camera_pos.x - self.mesh_camera_pos.x), -(camera_pos.y - self.mesh_camera_pos.y), diff --git a/modules/engine-graphics/src/render_graph.zig b/modules/engine-graphics/src/render_graph.zig index 3d202250..2d5be621 100644 --- a/modules/engine-graphics/src/render_graph.zig +++ b/modules/engine-graphics/src/render_graph.zig @@ -478,6 +478,19 @@ pub const OpaquePass = struct { } }; +/// Must execute while no graphics pass is active so Vulkan can place the +/// compute-to-indirect/storage barriers in the same frame command buffer. +pub const LODCullingPass = struct { + const VTABLE = IRenderPass.VTable{ .name = "LODCullingPass", .needs_main_pass = false, .execute = execute }; + pub fn pass(self: *LODCullingPass) IRenderPass { + return .{ .ptr = self, .vtable = &VTABLE }; + } + fn execute(_: *anyopaque, ctx: SceneContext) anyerror!void { + const view_proj = ctx.camera.getJitteredProjectionMatrixReverseZ(ctx.aspect, ctx.viewport_width, ctx.viewport_height, ctx.taa_enabled).multiply(ctx.camera.getViewMatrixOriginCentered()); + ctx.world.prepareLODCulling(view_proj, ctx.camera.position); + } +}; + pub const EntityPass = struct { const VTABLE = IRenderPass.VTable{ .name = "EntityPass", diff --git a/modules/engine-graphics/src/render_system.zig b/modules/engine-graphics/src/render_system.zig index fc0479bb..1607c4a6 100644 --- a/modules/engine-graphics/src/render_system.zig +++ b/modules/engine-graphics/src/render_system.zig @@ -66,6 +66,7 @@ pub const RenderSystem = struct { sky_pass: render_graph_pkg.SkyPass, cloud_pass: render_graph_pkg.CloudPass, opaque_pass: render_graph_pkg.OpaquePass, + lod_culling_pass: render_graph_pkg.LODCullingPass, entity_pass: render_graph_pkg.EntityPass, taa_pass: render_graph_pkg.TAAPass, bloom_pass: render_graph_pkg.BloomPass, @@ -220,6 +221,7 @@ pub const RenderSystem = struct { .sky_pass = .{}, .cloud_pass = .{}, .opaque_pass = undefined, + .lod_culling_pass = .{}, .entity_pass = .{}, .taa_pass = .{ .enabled = !disable_taa and config.taa_enabled }, .bloom_pass = .{ .enabled = !disable_bloom and config.bloom_enabled }, @@ -277,6 +279,7 @@ pub const RenderSystem = struct { if (self.water_reflection_pass.enabled) { try self.render_graph.addPass(self.water_reflection_pass.pass()); } + try self.render_graph.addPass(self.lod_culling_pass.pass()); try self.render_graph.addPass(self.sky_pass.pass()); try self.render_graph.addPass(self.cloud_pass.pass()); try self.render_graph.addPass(self.opaque_pass.pass()); diff --git a/modules/engine-graphics/src/rhi_tests.zig b/modules/engine-graphics/src/rhi_tests.zig index a40f0958..cdf96f57 100644 --- a/modules/engine-graphics/src/rhi_tests.zig +++ b/modules/engine-graphics/src/rhi_tests.zig @@ -64,6 +64,9 @@ const MockContext = struct { _ = draw_count; _ = stride; } + fn drawIndirectCount(_: *anyopaque, _: rhi.BufferHandle, _: rhi.BufferHandle, _: usize, _: rhi.BufferHandle, _: usize, _: u32, _: u32) bool { + return false; + } fn drawInstance(ptr: *anyopaque, handle: rhi.BufferHandle, count: u32, instance_index: u32) void { _ = ptr; _ = handle; @@ -193,6 +196,9 @@ const MockContext = struct { _ = max_chunks; return null; } + fn createLODCullingSystem(_: *anyopaque, _: std.mem.Allocator, _: usize) anyerror!?rhi.ILODCullingSystem { + return null; + } fn getRenderResolution(ptr: *anyopaque) rhi.RenderResolution { _ = ptr; @@ -471,6 +477,7 @@ const MockContext = struct { const MOCK_QUERY_VTABLE = rhi.IDeviceQuery.VTable{ .getFrameIndex = undefined, .supportsIndirectFirstInstance = undefined, + .supportsIndirectCount = undefined, .getMaxAnisotropy = undefined, .getMaxMSAASamples = undefined, .getFaultCount = undefined, @@ -566,6 +573,7 @@ const MockContext = struct { }, .culling_factory = .{ .createCullingSystem = MockContext.createCullingSystem, + .createLODCullingSystem = MockContext.createLODCullingSystem, }, .screenshot = .{ .captureFrame = undefined, @@ -579,7 +587,9 @@ const MockContext = struct { .draw = draw, .drawOffset = drawOffset, .drawIndexed = drawIndexed, + .drawCompactLOD = undefined, .drawIndirect = drawIndirect, + .drawIndirectCount = drawIndirectCount, .drawInstance = drawInstance, .setViewport = setViewport, }; @@ -588,6 +598,7 @@ const MockContext = struct { .setModelMatrix = undefined, .setInstanceBuffer = undefined, .setLODInstanceBuffer = undefined, + .setLODCompactSampleBuffer = undefined, .setTerrainPipelineBound = undefined, .setSelectionMode = undefined, .updateGlobalUniforms = undefined, diff --git a/modules/engine-graphics/src/rhi_vulkan.zig b/modules/engine-graphics/src/rhi_vulkan.zig index 6b174559..c81794f8 100644 --- a/modules/engine-graphics/src/rhi_vulkan.zig +++ b/modules/engine-graphics/src/rhi_vulkan.zig @@ -22,6 +22,7 @@ const rhi_timing = @import("vulkan/rhi_timing.zig"); const screenshot = @import("vulkan/screenshot.zig"); const Utils = @import("vulkan/utils.zig"); const CullingSystem = @import("vulkan/culling_system.zig").CullingSystem; +const lod_culling = @import("vulkan/lod_culling_system.zig"); const build_options = @import("engine_graphics_options"); const imgui_c = if (build_options.imgui) @cImport({ @@ -406,8 +407,15 @@ fn createCullingSystem(ctx_ptr: *anyopaque, allocator: std.mem.Allocator, max_ch return system.interface(); } +fn createLODCullingSystem(ctx_ptr: *anyopaque, allocator: std.mem.Allocator, max_regions: usize) anyerror!?rhi.ILODCullingSystem { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + return @as(?rhi.ILODCullingSystem, try lod_culling.create(allocator, ctx, max_regions)); +} + fn captureFrame(ctx_ptr: *anyopaque, path: []const u8) bool { const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + ctx.mutex.lock(); + defer ctx.mutex.unlock(); return screenshot.captureScreenshot(ctx, path); } @@ -480,6 +488,16 @@ fn setLODInstanceBuffer(ctx_ptr: *anyopaque, handle: rhi.BufferHandle) void { render_state.setLODInstanceBuffer(ctx, handle); } +fn setLODCompactSampleBuffer(ctx_ptr: *anyopaque, handle: rhi.BufferHandle) void { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + render_state.setLODCompactSampleBuffer(ctx, handle); +} + +fn setLODCompactInstanceBuffer(ctx_ptr: *anyopaque, handle: rhi.BufferHandle) void { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + render_state.setLODCompactInstanceBuffer(ctx, handle); +} + fn setTerrainPipelineBound(ctx_ptr: *anyopaque, bound: bool) void { const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); render_state.setTerrainPipelineBound(ctx, bound); @@ -584,6 +602,11 @@ fn supportsIndirectFirstInstance(ctx_ptr: *anyopaque) bool { return state_control.supportsIndirectFirstInstance(ctx); } +fn supportsIndirectCount(ctx_ptr: *anyopaque) bool { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + return state_control.supportsIndirectCount(ctx); +} + fn recover(ctx_ptr: *anyopaque) anyerror!void { const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); try state_control.recover(ctx); @@ -679,6 +702,18 @@ fn drawIndexed(ctx_ptr: *anyopaque, vbo_handle: rhi.BufferHandle, ebo_handle: rh draw_submission.drawIndexed(ctx, vbo_handle, ebo_handle, count); } +fn drawCompactLOD(ctx_ptr: *anyopaque, index_handle: rhi.BufferHandle, index_count: u32, params: rhi.CompactLODDraw) bool { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + ctx.mutex.lock(); + defer ctx.mutex.unlock(); + return draw_submission.drawCompactLOD(ctx, index_handle, index_count, params); +} + +fn drawCompactLODIndirectCount(ctx_ptr: *anyopaque, index_handle: rhi.BufferHandle, command_buffer: rhi.BufferHandle, offset: usize, count_buffer: rhi.BufferHandle, count_offset: usize, max_draw_count: u32) bool { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + return draw_submission.drawCompactLODIndirectCount(ctx, index_handle, command_buffer, offset, count_buffer, count_offset, max_draw_count); +} + fn drawIndirect(ctx_ptr: *anyopaque, handle: rhi.BufferHandle, command_buffer: rhi.BufferHandle, offset: usize, draw_count: u32, stride: u32) void { const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); ctx.mutex.lock(); @@ -686,6 +721,11 @@ fn drawIndirect(ctx_ptr: *anyopaque, handle: rhi.BufferHandle, command_buffer: r draw_submission.drawIndirect(ctx, handle, command_buffer, offset, draw_count, stride); } +fn drawIndirectCount(ctx_ptr: *anyopaque, handle: rhi.BufferHandle, command_buffer: rhi.BufferHandle, offset: usize, count_buffer: rhi.BufferHandle, count_offset: usize, max_draw_count: u32, stride: u32) bool { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + return draw_submission.drawIndirectCount(ctx, handle, command_buffer, offset, count_buffer, count_offset, max_draw_count, stride); +} + fn drawInstance(ctx_ptr: *anyopaque, handle: rhi.BufferHandle, count: u32, instance_index: u32) void { const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); ctx.mutex.lock(); @@ -1047,6 +1087,8 @@ const VULKAN_STATE_CONTEXT_VTABLE = rhi.IRenderStateContext.VTable{ .setModelMatrix = setModelMatrix, .setInstanceBuffer = setInstanceBuffer, .setLODInstanceBuffer = setLODInstanceBuffer, + .setLODCompactSampleBuffer = setLODCompactSampleBuffer, + .setLODCompactInstanceBuffer = setLODCompactInstanceBuffer, .setTerrainPipelineBound = setTerrainPipelineBound, .setSelectionMode = setSelectionMode, .updateGlobalUniforms = updateGlobalUniforms, @@ -1064,7 +1106,10 @@ const VULKAN_COMMAND_ENCODER_VTABLE = rhi.IGraphicsCommandEncoder.VTable{ .draw = draw, .drawOffset = drawOffset, .drawIndexed = drawIndexed, + .drawCompactLOD = drawCompactLOD, + .drawCompactLODIndirectCount = drawCompactLODIndirectCount, .drawIndirect = drawIndirect, + .drawIndirectCount = drawIndirectCount, .drawInstance = drawInstance, .setViewport = setViewport, }; @@ -1411,6 +1456,7 @@ const VULKAN_NATIVE_HANDLES_VTABLE = rhi.VulkanNativeHandles.VTable{ const VULKAN_DEVICE_QUERY_VTABLE = rhi.IDeviceQuery.VTable{ .getFrameIndex = getFrameIndex, .supportsIndirectFirstInstance = supportsIndirectFirstInstance, + .supportsIndirectCount = supportsIndirectCount, .getMaxAnisotropy = getMaxAnisotropy, .getMaxMSAASamples = getMaxMSAASamples, .getFaultCount = getFaultCount, @@ -1460,6 +1506,7 @@ const VULKAN_DEVICE_RECOVERY_VTABLE = rhi.IDeviceRecovery.VTable{ const VULKAN_CULLING_FACTORY_VTABLE = rhi.ICullingSystemFactory.VTable{ .createCullingSystem = createCullingSystem, + .createLODCullingSystem = createLODCullingSystem, }; const VULKAN_SCREENSHOT_CONTEXT_VTABLE = rhi.IScreenshotContext.VTable{ diff --git a/modules/engine-graphics/src/root.zig b/modules/engine-graphics/src/root.zig index 7fc3ff34..931817ee 100644 --- a/modules/engine-graphics/src/root.zig +++ b/modules/engine-graphics/src/root.zig @@ -18,6 +18,7 @@ pub const render_system = @import("render_system.zig"); pub const resource_pack = @import("engine-assets").resource_pack; pub const rhi_vulkan = @import("rhi_vulkan.zig"); pub const lpv_system = @import("vulkan/lpv_system.zig"); +pub const lod_culling_system = @import("vulkan/lod_culling_system.zig"); pub const lpv_utils = @import("lpv_utils.zig"); pub const shadow_scene = @import("engine-shadows").shadow_scene; pub const shadow_system = @import("engine-shadows").shadow_system; diff --git a/modules/engine-graphics/src/vulkan/descriptor_bindings.zig b/modules/engine-graphics/src/vulkan/descriptor_bindings.zig index 57440889..17358b33 100644 --- a/modules/engine-graphics/src/vulkan/descriptor_bindings.zig +++ b/modules/engine-graphics/src/vulkan/descriptor_bindings.zig @@ -14,3 +14,5 @@ pub const LPV_TEXTURE_G = 12; // LPV SH Green channel pub const LPV_TEXTURE_B = 13; // LPV SH Blue channel pub const WATER_REFLECTION_TEXTURE = 14; // Water planar reflection color pub const SCENE_DEPTH_TEXTURE = 15; // Scene depth for water refraction +pub const COMPACT_LOD_SAMPLES = 16; // 16-byte far-LOD samples, vertex stage +pub const COMPACT_LOD_INSTANCES = 17; // compact indirect instances, vertex stage diff --git a/modules/engine-graphics/src/vulkan/descriptor_manager.zig b/modules/engine-graphics/src/vulkan/descriptor_manager.zig index 74a918ae..3fd68c5c 100644 --- a/modules/engine-graphics/src/vulkan/descriptor_manager.zig +++ b/modules/engine-graphics/src/vulkan/descriptor_manager.zig @@ -46,6 +46,8 @@ pub const DescriptorManager = struct { shadow_ubos_mapped: [rhi.MAX_FRAMES_IN_FLIGHT]?*anyopaque, dummy_instance_ssbo: VulkanBuffer, + dummy_compact_lod_ssbo: VulkanBuffer, + dummy_compact_lod_instance_ssbo: VulkanBuffer, // Dummy textures dummy_texture: rhi.TextureHandle, @@ -67,6 +69,8 @@ pub const DescriptorManager = struct { .shadow_ubos = std.mem.zeroes([rhi.MAX_FRAMES_IN_FLIGHT]VulkanBuffer), .shadow_ubos_mapped = std.mem.zeroes([rhi.MAX_FRAMES_IN_FLIGHT]?*anyopaque), .dummy_instance_ssbo = std.mem.zeroes(VulkanBuffer), + .dummy_compact_lod_ssbo = std.mem.zeroes(VulkanBuffer), + .dummy_compact_lod_instance_ssbo = std.mem.zeroes(VulkanBuffer), .dummy_texture = 0, .dummy_texture_3d = 0, .dummy_normal_texture = 0, @@ -92,6 +96,14 @@ pub const DescriptorManager = struct { self.deinit(); return err; }; + self.dummy_compact_lod_ssbo = Utils.createVulkanBuffer(vulkan_device, 256, c.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, c.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | c.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) catch |err| { + self.deinit(); + return err; + }; + self.dummy_compact_lod_instance_ssbo = Utils.createVulkanBuffer(vulkan_device, 256, c.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, c.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | c.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) catch |err| { + self.deinit(); + return err; + }; // Create dummy textures at frame index 1 to isolate from frame 0's lifecycle. resource_manager.setCurrentFrame(1); @@ -181,6 +193,10 @@ pub const DescriptorManager = struct { .{ .binding = 14, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, // 15: Scene depth texture (for water refraction) .{ .binding = 15, .descriptorType = c.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_FRAGMENT_BIT }, + // 16: Compact LOD sample SSBO, consumed only by vertex-pulling shaders. + .{ .binding = 16, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_VERTEX_BIT }, + // 17: Compact LOD indirect instance SSBO. + .{ .binding = 17, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_VERTEX_BIT }, }; var layout_info = std.mem.zeroes(c.VkDescriptorSetLayoutCreateInfo); @@ -226,6 +242,8 @@ pub const DescriptorManager = struct { .offset = 0, .range = 256, }; + var buffer_info_compact = c.VkDescriptorBufferInfo{ .buffer = self.dummy_compact_lod_ssbo.buffer, .offset = 0, .range = 256 }; + var buffer_info_compact_instance = c.VkDescriptorBufferInfo{ .buffer = self.dummy_compact_lod_instance_ssbo.buffer, .offset = 0, .range = 256 }; var writes = [_]c.VkWriteDescriptorSet{ .{ @@ -252,6 +270,8 @@ pub const DescriptorManager = struct { .descriptorCount = 1, .pBufferInfo = &buffer_info_instance, }, + .{ .sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = self.descriptor_sets[i], .dstBinding = 16, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 1, .pBufferInfo = &buffer_info_compact }, + .{ .sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = self.descriptor_sets[i], .dstBinding = 17, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 1, .pBufferInfo = &buffer_info_compact_instance }, .{ .sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = self.lod_descriptor_sets[i], @@ -276,6 +296,8 @@ pub const DescriptorManager = struct { .descriptorCount = 1, .pBufferInfo = &buffer_info_instance, }, + .{ .sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = self.lod_descriptor_sets[i], .dstBinding = 16, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 1, .pBufferInfo = &buffer_info_compact }, + .{ .sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = self.lod_descriptor_sets[i], .dstBinding = 17, .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 1, .pBufferInfo = &buffer_info_compact_instance }, }; c.vkUpdateDescriptorSets(vulkan_device.vk_device, writes.len, &writes[0], 0, null); } @@ -306,6 +328,16 @@ pub const DescriptorManager = struct { c.vkDestroyBuffer(device, self.dummy_instance_ssbo.buffer, null); c.vkFreeMemory(device, self.dummy_instance_ssbo.memory, null); } + if (self.dummy_compact_lod_ssbo.buffer != null) { + if (self.dummy_compact_lod_ssbo.mapped_ptr != null) c.vkUnmapMemory(device, self.dummy_compact_lod_ssbo.memory); + c.vkDestroyBuffer(device, self.dummy_compact_lod_ssbo.buffer, null); + c.vkFreeMemory(device, self.dummy_compact_lod_ssbo.memory, null); + } + if (self.dummy_compact_lod_instance_ssbo.buffer != null) { + if (self.dummy_compact_lod_instance_ssbo.mapped_ptr != null) c.vkUnmapMemory(device, self.dummy_compact_lod_instance_ssbo.memory); + c.vkDestroyBuffer(device, self.dummy_compact_lod_instance_ssbo.buffer, null); + c.vkFreeMemory(device, self.dummy_compact_lod_instance_ssbo.memory, null); + } if (self.descriptor_set_layout != null) c.vkDestroyDescriptorSetLayout(device, self.descriptor_set_layout, null); if (self.descriptor_pool != null) c.vkDestroyDescriptorPool(device, self.descriptor_pool, null); diff --git a/modules/engine-graphics/src/vulkan/fxaa_system.zig b/modules/engine-graphics/src/vulkan/fxaa_system.zig index 691beb89..57d629be 100644 --- a/modules/engine-graphics/src/vulkan/fxaa_system.zig +++ b/modules/engine-graphics/src/vulkan/fxaa_system.zig @@ -32,7 +32,7 @@ pub const FXAASystem = struct { post_process_to_fxaa_render_pass: c.VkRenderPass = null, post_process_to_fxaa_framebuffer: c.VkFramebuffer = null, - pub fn init(self: *FXAASystem, device: *const VulkanDevice, allocator: Allocator, descriptor_pool: c.VkDescriptorPool, extent: c.VkExtent2D, format: c.VkFormat, sampler: c.VkSampler, swapchain_views: []const c.VkImageView) !void { + pub fn init(self: *FXAASystem, device: *const VulkanDevice, allocator: Allocator, descriptor_pool: c.VkDescriptorPool, extent: c.VkExtent2D, format: c.VkFormat, sampler: c.VkSampler, swapchain_views: []const c.VkImageView, headless: bool) !void { self.deinit(device.vk_device, allocator, descriptor_pool); const vk = device.vk_device; @@ -72,7 +72,7 @@ pub const FXAASystem = struct { color_attachment.loadOp = c.VK_ATTACHMENT_LOAD_OP_DONT_CARE; color_attachment.storeOp = c.VK_ATTACHMENT_STORE_OP_STORE; color_attachment.initialLayout = c.VK_IMAGE_LAYOUT_UNDEFINED; - color_attachment.finalLayout = c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + color_attachment.finalLayout = if (headless) c.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL else c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; var color_ref = c.VkAttachmentReference{ .attachment = 0, .layout = c.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL }; var subpass = std.mem.zeroes(c.VkSubpassDescription); diff --git a/modules/engine-graphics/src/vulkan/lod_culling_system.zig b/modules/engine-graphics/src/vulkan/lod_culling_system.zig new file mode 100644 index 00000000..200f0f09 --- /dev/null +++ b/modules/engine-graphics/src/vulkan/lod_culling_system.zig @@ -0,0 +1,435 @@ +//! Dedicated same-frame compute culling and MDI compaction for distant terrain. +const std = @import("std"); +const fs = @import("fs"); +const c = @import("c").c; +const rhi = @import("engine-rhi"); +const culling = rhi.culling; +const VulkanContext = @import("rhi_context_types.zig").VulkanContext; +const Utils = @import("utils.zig"); + +pub const SHADER_PATH = "assets/shaders/vulkan/lod_culling.comp.spv"; +pub const WORKGROUP_SIZE: u32 = 64; +pub const MAX_LOD_LEVELS: usize = 5; +const FRAME_COUNT = rhi.MAX_FRAMES_IN_FLIGHT; + +const LODCullingSystem = struct { + allocator: std.mem.Allocator, + ctx: *VulkanContext, + capacity: usize, + candidates: [FRAME_COUNT]Utils.VulkanBuffer, + counters: [FRAME_COUNT]rhi.BufferHandle, + terrain_instances: [FRAME_COUNT]rhi.BufferHandle, + water_instances: [FRAME_COUNT]rhi.BufferHandle, + terrain_indirect: [FRAME_COUNT]rhi.BufferHandle, + water_indirect: [FRAME_COUNT]rhi.BufferHandle, + compact_terrain_instances: [FRAME_COUNT]rhi.BufferHandle, + compact_water_instances: [FRAME_COUNT]rhi.BufferHandle, + compact_terrain_indirect: [FRAME_COUNT]rhi.BufferHandle, + compact_water_indirect: [FRAME_COUNT]rhi.BufferHandle, + visible_ids: [FRAME_COUNT]rhi.BufferHandle, + validation_readback: [FRAME_COUNT]Utils.VulkanBuffer, + validation_expected: [FRAME_COUNT][MAX_LOD_LEVELS * 4]u32 = [_][MAX_LOD_LEVELS * 4]u32{[_]u32{0} ** (MAX_LOD_LEVELS * 4)} ** FRAME_COUNT, + validation_expected_ids: [FRAME_COUNT][]u32, + validation_pending: [FRAME_COUNT]bool = [_]bool{false} ** FRAME_COUNT, + validation_enabled: bool, + diagnostics_state: culling.LODCullDiagnostics = .{}, + descriptor_pool: c.VkDescriptorPool = null, + descriptor_layout: c.VkDescriptorSetLayout = null, + descriptor_sets: [FRAME_COUNT]c.VkDescriptorSet = std.mem.zeroes([FRAME_COUNT]c.VkDescriptorSet), + pipeline_layout: c.VkPipelineLayout = null, + pipeline: c.VkPipeline = null, + + fn init(allocator: std.mem.Allocator, ctx: *VulkanContext, requested_capacity: usize) !*LODCullingSystem { + const self = try allocator.create(LODCullingSystem); + errdefer allocator.destroy(self); + const capacity = std.math.clamp(requested_capacity, 1, 2048); + self.* = .{ + .allocator = allocator, + .ctx = ctx, + .capacity = capacity, + .candidates = std.mem.zeroes([FRAME_COUNT]Utils.VulkanBuffer), + .counters = [_]rhi.BufferHandle{0} ** FRAME_COUNT, + .terrain_instances = [_]rhi.BufferHandle{0} ** FRAME_COUNT, + .water_instances = [_]rhi.BufferHandle{0} ** FRAME_COUNT, + .terrain_indirect = [_]rhi.BufferHandle{0} ** FRAME_COUNT, + .water_indirect = [_]rhi.BufferHandle{0} ** FRAME_COUNT, + .compact_terrain_instances = [_]rhi.BufferHandle{0} ** FRAME_COUNT, + .compact_water_instances = [_]rhi.BufferHandle{0} ** FRAME_COUNT, + .compact_terrain_indirect = [_]rhi.BufferHandle{0} ** FRAME_COUNT, + .compact_water_indirect = [_]rhi.BufferHandle{0} ** FRAME_COUNT, + .visible_ids = [_]rhi.BufferHandle{0} ** FRAME_COUNT, + .validation_readback = std.mem.zeroes([FRAME_COUNT]Utils.VulkanBuffer), + .validation_expected_ids = undefined, + .validation_enabled = envFlag("ZIGCRAFT_LOD_GPU_CULLING_VALIDATE"), + }; + for (&self.validation_expected_ids) |*ids| ids.* = &.{}; + errdefer self.deinit(); + + const candidate_bytes = capacity * @sizeOf(culling.LODCullCandidate); + const stream_instances = capacity * MAX_LOD_LEVELS * @sizeOf(rhi.InstanceData); + const compact_stream_instances = capacity * MAX_LOD_LEVELS * @sizeOf(rhi.CompactLODInstance); + const stream_commands = capacity * MAX_LOD_LEVELS * @sizeOf(rhi.DrawIndirectCommand); + const compact_stream_commands = capacity * MAX_LOD_LEVELS * @sizeOf(rhi.DrawIndexedIndirectCommand); + const visible_id_count = capacity * MAX_LOD_LEVELS * 4; + for (0..FRAME_COUNT) |i| { + self.candidates[i] = try Utils.createVulkanBuffer(&ctx.vulkan_device, candidate_bytes, c.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, c.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | c.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + self.counters[i] = try ctx.resources.createBuffer(MAX_LOD_LEVELS * 4 * @sizeOf(u32), .indirect); + self.terrain_instances[i] = try ctx.resources.createBuffer(stream_instances, .storage); + self.water_instances[i] = try ctx.resources.createBuffer(stream_instances, .storage); + self.terrain_indirect[i] = try ctx.resources.createBuffer(stream_commands, .indirect); + self.water_indirect[i] = try ctx.resources.createBuffer(stream_commands, .indirect); + self.compact_terrain_instances[i] = try ctx.resources.createBuffer(compact_stream_instances, .storage); + self.compact_water_instances[i] = try ctx.resources.createBuffer(compact_stream_instances, .storage); + self.compact_terrain_indirect[i] = try ctx.resources.createBuffer(compact_stream_commands, .indirect); + self.compact_water_indirect[i] = try ctx.resources.createBuffer(compact_stream_commands, .indirect); + self.visible_ids[i] = try ctx.resources.createBuffer(visible_id_count * @sizeOf(u32), .storage); + if (self.validation_enabled) { + self.validation_expected_ids[i] = try allocator.alloc(u32, visible_id_count); + self.validation_readback[i] = try Utils.createVulkanBuffer( + &ctx.vulkan_device, + (MAX_LOD_LEVELS * 4 + visible_id_count) * @sizeOf(u32), + c.VK_BUFFER_USAGE_TRANSFER_DST_BIT, + c.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | c.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + ); + } + } + try self.initPipeline(); + return self; + } + + fn deinit(self: *LODCullingSystem) void { + self.destroyPipeline(); + const device = self.ctx.vulkan_device.vk_device; + for (0..FRAME_COUNT) |i| { + destroyNative(device, &self.candidates[i]); + if (self.counters[i] != 0) self.ctx.resources.destroyBuffer(self.counters[i]); + if (self.terrain_instances[i] != 0) self.ctx.resources.destroyBuffer(self.terrain_instances[i]); + if (self.water_instances[i] != 0) self.ctx.resources.destroyBuffer(self.water_instances[i]); + if (self.terrain_indirect[i] != 0) self.ctx.resources.destroyBuffer(self.terrain_indirect[i]); + if (self.water_indirect[i] != 0) self.ctx.resources.destroyBuffer(self.water_indirect[i]); + if (self.compact_terrain_instances[i] != 0) self.ctx.resources.destroyBuffer(self.compact_terrain_instances[i]); + if (self.compact_water_instances[i] != 0) self.ctx.resources.destroyBuffer(self.compact_water_instances[i]); + if (self.compact_terrain_indirect[i] != 0) self.ctx.resources.destroyBuffer(self.compact_terrain_indirect[i]); + if (self.compact_water_indirect[i] != 0) self.ctx.resources.destroyBuffer(self.compact_water_indirect[i]); + if (self.visible_ids[i] != 0) self.ctx.resources.destroyBuffer(self.visible_ids[i]); + destroyNative(device, &self.validation_readback[i]); + if (self.validation_expected_ids[i].len != 0) self.allocator.free(self.validation_expected_ids[i]); + } + self.allocator.destroy(self); + } + + fn dispatch(self: *LODCullingSystem, frame_index: usize, input: []const culling.LODCullCandidate, config: culling.LODCullDispatch) bool { + if (frame_index >= FRAME_COUNT or input.len > self.capacity or input.len == 0) return false; + self.validateCompletedFrame(frame_index); + const command_buffer = self.ctx.frames.command_buffers[frame_index]; + if (command_buffer == null or self.candidates[frame_index].mapped_ptr == null) return false; + @memcpy(@as([*]u8, @ptrCast(self.candidates[frame_index].mapped_ptr.?))[0 .. input.len * @sizeOf(culling.LODCullCandidate)], std.mem.sliceAsBytes(input)); + + _ = self.lookup(self.terrain_instances[frame_index]) orelse return false; + _ = self.lookup(self.water_instances[frame_index]) orelse return false; + const terrain_indirect = self.lookup(self.terrain_indirect[frame_index]) orelse return false; + const water_indirect = self.lookup(self.water_indirect[frame_index]) orelse return false; + const cmd = command_buffer; + const counter_bytes = MAX_LOD_LEVELS * 4 * @sizeOf(u32); + const stream_command_bytes = self.capacity * MAX_LOD_LEVELS * @sizeOf(rhi.DrawIndirectCommand); + const compact_stream_command_bytes = self.capacity * MAX_LOD_LEVELS * @sizeOf(rhi.DrawIndexedIndirectCommand); + const counters = self.lookup(self.counters[frame_index]) orelse return false; + const visible_ids = self.lookup(self.visible_ids[frame_index]) orelse return false; + c.vkCmdFillBuffer(cmd, counters.buffer, 0, counter_bytes, 0); + c.vkCmdFillBuffer(cmd, terrain_indirect.buffer, 0, stream_command_bytes, 0); + c.vkCmdFillBuffer(cmd, water_indirect.buffer, 0, stream_command_bytes, 0); + const compact_terrain_indirect = self.lookup(self.compact_terrain_indirect[frame_index]) orelse return false; + const compact_water_indirect = self.lookup(self.compact_water_indirect[frame_index]) orelse return false; + c.vkCmdFillBuffer(cmd, compact_terrain_indirect.buffer, 0, compact_stream_command_bytes, 0); + c.vkCmdFillBuffer(cmd, compact_water_indirect.buffer, 0, compact_stream_command_bytes, 0); + var transfer_to_compute = std.mem.zeroes(c.VkMemoryBarrier); + transfer_to_compute.sType = c.VK_STRUCTURE_TYPE_MEMORY_BARRIER; + transfer_to_compute.srcAccessMask = c.VK_ACCESS_HOST_WRITE_BIT | c.VK_ACCESS_TRANSFER_WRITE_BIT; + transfer_to_compute.dstAccessMask = c.VK_ACCESS_SHADER_READ_BIT | c.VK_ACCESS_SHADER_WRITE_BIT; + c.vkCmdPipelineBarrier(cmd, c.VK_PIPELINE_STAGE_TRANSFER_BIT | c.VK_PIPELINE_STAGE_HOST_BIT, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 1, &transfer_to_compute, 0, null, 0, null); + + var push = config; + push.candidate_count = @intCast(input.len); + push.max_commands_per_lod = @intCast(self.capacity); + c.vkCmdBindPipeline(cmd, c.VK_PIPELINE_BIND_POINT_COMPUTE, self.pipeline); + c.vkCmdBindDescriptorSets(cmd, c.VK_PIPELINE_BIND_POINT_COMPUTE, self.pipeline_layout, 0, 1, &self.descriptor_sets[frame_index], 0, null); + c.vkCmdPushConstants(cmd, self.pipeline_layout, c.VK_SHADER_STAGE_COMPUTE_BIT, 0, @sizeOf(culling.LODCullDispatch), &push); + c.vkCmdDispatch(cmd, @divFloor(@as(u32, @intCast(input.len)) + WORKGROUP_SIZE - 1, WORKGROUP_SIZE), 1, 1); + + if (self.validation_enabled) { + var compute_to_copy = std.mem.zeroes(c.VkMemoryBarrier); + compute_to_copy.sType = c.VK_STRUCTURE_TYPE_MEMORY_BARRIER; + compute_to_copy.srcAccessMask = c.VK_ACCESS_SHADER_WRITE_BIT; + compute_to_copy.dstAccessMask = c.VK_ACCESS_TRANSFER_READ_BIT; + c.vkCmdPipelineBarrier(cmd, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &compute_to_copy, 0, null, 0, null); + const copy = c.VkBufferCopy{ .srcOffset = 0, .dstOffset = 0, .size = counter_bytes }; + c.vkCmdCopyBuffer(cmd, counters.buffer, self.validation_readback[frame_index].buffer, 1, ©); + const id_bytes = self.capacity * MAX_LOD_LEVELS * 4 * @sizeOf(u32); + const id_copy = c.VkBufferCopy{ .srcOffset = 0, .dstOffset = counter_bytes, .size = id_bytes }; + c.vkCmdCopyBuffer(cmd, visible_ids.buffer, self.validation_readback[frame_index].buffer, 1, &id_copy); + var copy_to_host = std.mem.zeroes(c.VkMemoryBarrier); + copy_to_host.sType = c.VK_STRUCTURE_TYPE_MEMORY_BARRIER; + copy_to_host.srcAccessMask = c.VK_ACCESS_TRANSFER_WRITE_BIT; + copy_to_host.dstAccessMask = c.VK_ACCESS_HOST_READ_BIT; + c.vkCmdPipelineBarrier(cmd, c.VK_PIPELINE_STAGE_TRANSFER_BIT, c.VK_PIPELINE_STAGE_HOST_BIT, 0, 1, ©_to_host, 0, null, 0, null); + self.validation_expected[frame_index] = cpuExpectedCounts(input, push); + fillExpectedIds(self.validation_expected_ids[frame_index], input, push, self.capacity); + self.validation_pending[frame_index] = true; + } + + var compute_to_draw = std.mem.zeroes(c.VkMemoryBarrier); + compute_to_draw.sType = c.VK_STRUCTURE_TYPE_MEMORY_BARRIER; + compute_to_draw.srcAccessMask = c.VK_ACCESS_SHADER_WRITE_BIT; + compute_to_draw.dstAccessMask = c.VK_ACCESS_INDIRECT_COMMAND_READ_BIT | c.VK_ACCESS_SHADER_READ_BIT; + c.vkCmdPipelineBarrier(cmd, c.VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, c.VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT | c.VK_PIPELINE_STAGE_VERTEX_SHADER_BIT, 0, 1, &compute_to_draw, 0, null, 0, null); + return true; + } + + fn validateCompletedFrame(self: *LODCullingSystem, frame_index: usize) void { + if (!self.validation_enabled or !self.validation_pending[frame_index]) return; + // A frame slot is dispatched only after its fence has completed, so + // coherent transfer results are safe to read here without a stall. + const mapped = self.validation_readback[frame_index].mapped_ptr orelse return; + const actual: *const [MAX_LOD_LEVELS * 4]u32 = @ptrCast(@alignCast(mapped)); + var mismatch = !std.mem.eql(u32, actual, &self.validation_expected[frame_index]); + const actual_ids: [*]u32 = @ptrCast(@alignCast(@as([*]u8, @ptrCast(mapped)) + MAX_LOD_LEVELS * 4 * @sizeOf(u32))); + for (0..MAX_LOD_LEVELS * 4) |stream| { + const count = @min(actual[stream], @as(u32, @intCast(self.capacity))); + const start = stream * self.capacity; + const gpu_slice = actual_ids[start .. start + count]; + const cpu_slice = self.validation_expected_ids[frame_index][start .. start + count]; + std.mem.sort(u32, gpu_slice, {}, std.sort.asc(u32)); + std.mem.sort(u32, cpu_slice, {}, std.sort.asc(u32)); + if (!std.mem.eql(u32, gpu_slice, cpu_slice)) mismatch = true; + } + if (mismatch) { + self.diagnostics_state.validation_mismatch_count +|= 1; + } + self.validation_pending[frame_index] = false; + } + + fn lookup(self: *LODCullingSystem, handle: rhi.BufferHandle) ?Utils.VulkanBuffer { + return self.ctx.resources.buffers.get(handle); + } + + fn initPipeline(self: *LODCullingSystem) !void { + const vk = self.ctx.vulkan_device.vk_device; + var pool_sizes = [_]c.VkDescriptorPoolSize{.{ .type = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 11 * FRAME_COUNT }}; + var pool_info = std.mem.zeroes(c.VkDescriptorPoolCreateInfo); + pool_info.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + pool_info.maxSets = FRAME_COUNT; + pool_info.poolSizeCount = pool_sizes.len; + pool_info.pPoolSizes = &pool_sizes; + try Utils.checkVk(c.vkCreateDescriptorPool(vk, &pool_info, null, &self.descriptor_pool)); + var bindings: [11]c.VkDescriptorSetLayoutBinding = undefined; + for (&bindings, 0..) |*binding, i| binding.* = .{ .binding = @intCast(i), .descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .descriptorCount = 1, .stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT, .pImmutableSamplers = null }; + var layout_info = std.mem.zeroes(c.VkDescriptorSetLayoutCreateInfo); + layout_info.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layout_info.bindingCount = bindings.len; + layout_info.pBindings = &bindings; + try Utils.checkVk(c.vkCreateDescriptorSetLayout(vk, &layout_info, null, &self.descriptor_layout)); + var layouts = [_]c.VkDescriptorSetLayout{self.descriptor_layout} ** FRAME_COUNT; + var alloc_info = std.mem.zeroes(c.VkDescriptorSetAllocateInfo); + alloc_info.sType = c.VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + alloc_info.descriptorPool = self.descriptor_pool; + alloc_info.descriptorSetCount = FRAME_COUNT; + alloc_info.pSetLayouts = &layouts; + try Utils.checkVk(c.vkAllocateDescriptorSets(vk, &alloc_info, &self.descriptor_sets)); + self.updateDescriptors(); + var range = std.mem.zeroes(c.VkPushConstantRange); + range.stageFlags = c.VK_SHADER_STAGE_COMPUTE_BIT; + range.size = @sizeOf(culling.LODCullDispatch); + var pipeline_layout_info = std.mem.zeroes(c.VkPipelineLayoutCreateInfo); + pipeline_layout_info.sType = c.VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + pipeline_layout_info.setLayoutCount = 1; + pipeline_layout_info.pSetLayouts = &self.descriptor_layout; + pipeline_layout_info.pushConstantRangeCount = 1; + pipeline_layout_info.pPushConstantRanges = ⦥ + try Utils.checkVk(c.vkCreatePipelineLayout(vk, &pipeline_layout_info, null, &self.pipeline_layout)); + const module = try loadShader(vk, self.allocator); + defer c.vkDestroyShaderModule(vk, module, null); + var stage = std.mem.zeroes(c.VkPipelineShaderStageCreateInfo); + stage.sType = c.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stage.stage = c.VK_SHADER_STAGE_COMPUTE_BIT; + stage.module = module; + stage.pName = "main"; + var info = std.mem.zeroes(c.VkComputePipelineCreateInfo); + info.sType = c.VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO; + info.stage = stage; + info.layout = self.pipeline_layout; + try Utils.checkVk(c.vkCreateComputePipelines(vk, null, 1, &info, null, &self.pipeline)); + } + + fn updateDescriptors(self: *LODCullingSystem) void { + var writes: [11 * FRAME_COUNT]c.VkWriteDescriptorSet = undefined; + var infos: [11 * FRAME_COUNT]c.VkDescriptorBufferInfo = undefined; + var n: usize = 0; + for (0..FRAME_COUNT) |fi| { + const buffers = [_]c.VkBuffer{ + self.candidates[fi].buffer, + self.lookup(self.terrain_instances[fi]).?.buffer, + self.lookup(self.water_instances[fi]).?.buffer, + self.lookup(self.terrain_indirect[fi]).?.buffer, + self.lookup(self.water_indirect[fi]).?.buffer, + self.lookup(self.compact_terrain_instances[fi]).?.buffer, + self.lookup(self.compact_water_instances[fi]).?.buffer, + self.lookup(self.compact_terrain_indirect[fi]).?.buffer, + self.lookup(self.compact_water_indirect[fi]).?.buffer, + self.lookup(self.counters[fi]).?.buffer, + self.lookup(self.visible_ids[fi]).?.buffer, + }; + for (buffers, 0..) |buffer, binding| { + infos[n] = .{ .buffer = buffer, .offset = 0, .range = c.VK_WHOLE_SIZE }; + writes[n] = std.mem.zeroes(c.VkWriteDescriptorSet); + writes[n].sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[n].dstSet = self.descriptor_sets[fi]; + writes[n].dstBinding = @intCast(binding); + writes[n].descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + writes[n].descriptorCount = 1; + writes[n].pBufferInfo = &infos[n]; + n += 1; + } + } + c.vkUpdateDescriptorSets(self.ctx.vulkan_device.vk_device, @intCast(n), &writes, 0, null); + } + + fn destroyPipeline(self: *LODCullingSystem) void { + const vk = self.ctx.vulkan_device.vk_device; + if (self.pipeline != null) c.vkDestroyPipeline(vk, self.pipeline, null); + if (self.pipeline_layout != null) c.vkDestroyPipelineLayout(vk, self.pipeline_layout, null); + if (self.descriptor_layout != null) c.vkDestroyDescriptorSetLayout(vk, self.descriptor_layout, null); + if (self.descriptor_pool != null) c.vkDestroyDescriptorPool(vk, self.descriptor_pool, null); + } +}; + +const VTABLE = culling.ILODCullingSystem.VTable{ + .deinit = struct { + fn call(ptr: *anyopaque) void { + (@as(*LODCullingSystem, @ptrCast(@alignCast(ptr)))).deinit(); + } + }.call, + .dispatch = struct { + fn call(ptr: *anyopaque, frame: usize, candidates: []const culling.LODCullCandidate, config: culling.LODCullDispatch) bool { + return (@as(*LODCullingSystem, @ptrCast(@alignCast(ptr)))).dispatch(frame, candidates, config); + } + }.call, + .instanceBuffer = struct { + fn call(ptr: *anyopaque, frame: usize, fluid: bool, compact: bool) rhi.BufferHandle { + const self: *LODCullingSystem = @ptrCast(@alignCast(ptr)); + if (compact) return if (fluid) self.compact_water_instances[frame] else self.compact_terrain_instances[frame]; + return if (fluid) self.water_instances[frame] else self.terrain_instances[frame]; + } + }.call, + .indirectBuffer = struct { + fn call(ptr: *anyopaque, frame: usize, fluid: bool, compact: bool) rhi.BufferHandle { + const self: *LODCullingSystem = @ptrCast(@alignCast(ptr)); + if (compact) return if (fluid) self.compact_water_indirect[frame] else self.compact_terrain_indirect[frame]; + return if (fluid) self.water_indirect[frame] else self.terrain_indirect[frame]; + } + }.call, + .countBuffer = struct { + fn call(ptr: *anyopaque, frame: usize) rhi.BufferHandle { + const self: *LODCullingSystem = @ptrCast(@alignCast(ptr)); + return self.counters[frame]; + } + }.call, + .diagnostics = struct { + fn call(ptr: *anyopaque) culling.LODCullDiagnostics { + const self: *LODCullingSystem = @ptrCast(@alignCast(ptr)); + return self.diagnostics_state; + } + }.call, +}; + +pub fn create(allocator: std.mem.Allocator, ctx: *VulkanContext, capacity: usize) !rhi.ILODCullingSystem { + const system = try LODCullingSystem.init(allocator, ctx, capacity); + return .{ .ptr = system, .vtable = &VTABLE }; +} + +fn destroyNative(device: c.VkDevice, buffer: *Utils.VulkanBuffer) void { + if (buffer.mapped_ptr != null) c.vkUnmapMemory(device, buffer.memory); + if (buffer.buffer != null) c.vkDestroyBuffer(device, buffer.buffer, null); + if (buffer.memory != null) c.vkFreeMemory(device, buffer.memory, null); + buffer.* = .{}; +} + +fn loadShader(device: c.VkDevice, allocator: std.mem.Allocator) !c.VkShaderModule { + const bytes = try fs.cwd().readFileAlloc(SHADER_PATH, allocator, 16 * 1024 * 1024); + defer allocator.free(bytes); + if (bytes.len % 4 != 0) return error.InvalidShader; + var info = std.mem.zeroes(c.VkShaderModuleCreateInfo); + info.sType = c.VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; + info.codeSize = bytes.len; + info.pCode = @ptrCast(@alignCast(bytes.ptr)); + var module: c.VkShaderModule = null; + try Utils.checkVk(c.vkCreateShaderModule(device, &info, null, &module)); + return module; +} + +fn envFlag(name: [*:0]const u8) bool { + const value = std.c.getenv(name) orelse return false; + const bytes = std.mem.span(value); + return !(std.mem.eql(u8, bytes, "0") or std.ascii.eqlIgnoreCase(bytes, "false")); +} + +fn cpuExpectedCounts(input: []const culling.LODCullCandidate, config: culling.LODCullDispatch) [MAX_LOD_LEVELS * 4]u32 { + var counts = [_]u32{0} ** (MAX_LOD_LEVELS * 4); + for (input) |candidate| { + if (!cpuVisible(candidate, config)) continue; + const lod = @min(candidate.lod_and_padding[0], MAX_LOD_LEVELS - 1); + const base: usize = if (candidate.lod_and_padding[1] != 0) MAX_LOD_LEVELS * 2 else 0; + if (candidate.terrain_command.count != 0 and counts[base + lod] < config.max_commands_per_lod) counts[base + lod] += 1; + const water_index = base + MAX_LOD_LEVELS + lod; + if (candidate.water_command.count != 0 and counts[water_index] < config.max_commands_per_lod) counts[water_index] += 1; + } + return counts; +} + +fn fillExpectedIds(output: []u32, input: []const culling.LODCullCandidate, config: culling.LODCullDispatch, capacity: usize) void { + @memset(output, std.math.maxInt(u32)); + var counts = [_]u32{0} ** (MAX_LOD_LEVELS * 4); + for (input, 0..) |candidate, candidate_index| { + if (!cpuVisible(candidate, config)) continue; + const lod = @min(candidate.lod_and_padding[0], MAX_LOD_LEVELS - 1); + const base: usize = if (candidate.lod_and_padding[1] != 0) MAX_LOD_LEVELS * 2 else 0; + const streams = [_]struct { index: usize, count: u32 }{ + .{ .index = base + lod, .count = candidate.terrain_command.count }, + .{ .index = base + MAX_LOD_LEVELS + lod, .count = candidate.water_command.count }, + }; + for (streams) |stream| { + if (stream.count == 0 or counts[stream.index] >= config.max_commands_per_lod) continue; + output[stream.index * capacity + counts[stream.index]] = @intCast(candidate_index); + counts[stream.index] += 1; + } + } +} + +fn cpuVisible(candidate: culling.LODCullCandidate, config: culling.LODCullDispatch) bool { + for (config.planes) |plane| { + const x = if (plane[0] >= 0) candidate.max_point[0] else candidate.min_point[0]; + const y = if (plane[1] >= 0) candidate.max_point[1] else candidate.min_point[1]; + const z = if (plane[2] >= 0) candidate.max_point[2] else candidate.min_point[2]; + if (plane[0] * x + plane[1] * y + plane[2] * z + plane[3] < 0) return false; + } + if (config.max_distance_blocks <= 0) return true; + const dx: f32 = if (candidate.min_point[0] > 0) candidate.min_point[0] else if (candidate.max_point[0] < 0) candidate.max_point[0] else 0; + const dz: f32 = if (candidate.min_point[2] > 0) candidate.min_point[2] else if (candidate.max_point[2] < 0) candidate.max_point[2] else 0; + return dx * dx + dz * dz <= config.max_distance_blocks * config.max_distance_blocks; +} + +test "LOD culling CPU partitioning separates compact indexed streams" { + var candidate = std.mem.zeroes(culling.LODCullCandidate); + candidate.min_point = .{ -1, -1, -1, 0 }; + candidate.max_point = .{ 1, 1, 1, 0 }; + candidate.terrain_command.count = 3; + candidate.water_command.count = 6; + candidate.lod_and_padding[0] = 2; + const planes = [_][4]f32{.{ 0, 0, 0, 1 }} ** 6; + const counts = cpuExpectedCounts(&.{candidate}, .{ .planes = planes, .candidate_count = 1, .max_distance_blocks = 10, .max_commands_per_lod = 4 }); + try std.testing.expectEqual(@as(u32, 1), counts[2]); + try std.testing.expectEqual(@as(u32, 1), counts[MAX_LOD_LEVELS + 2]); + candidate.lod_and_padding[1] = 1; + const compact_counts = cpuExpectedCounts(&.{candidate}, .{ .planes = planes, .candidate_count = 1, .max_distance_blocks = 10, .max_commands_per_lod = 4 }); + try std.testing.expectEqual(@as(u32, 1), compact_counts[MAX_LOD_LEVELS * 2 + 2]); + try std.testing.expectEqual(@as(u32, 1), compact_counts[MAX_LOD_LEVELS * 3 + 2]); +} diff --git a/modules/engine-graphics/src/vulkan/pipeline_manager.zig b/modules/engine-graphics/src/vulkan/pipeline_manager.zig index d410eb78..8d670bba 100644 --- a/modules/engine-graphics/src/vulkan/pipeline_manager.zig +++ b/modules/engine-graphics/src/vulkan/pipeline_manager.zig @@ -39,6 +39,8 @@ pub const PipelineManager = struct { ui_pipeline: c.VkPipeline = null, ui_tex_pipeline: c.VkPipeline = null, water_pipeline: c.VkPipeline = null, + compact_lod_terrain_pipeline: c.VkPipeline = null, + compact_lod_water_pipeline: c.VkPipeline = null, // Swapchain UI pipelines ui_swapchain_pipeline: c.VkPipeline = null, @@ -214,6 +216,8 @@ pub const PipelineManager = struct { if (self.ui_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); if (self.ui_tex_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); if (self.water_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); + if (self.compact_lod_terrain_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); + if (self.compact_lod_water_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); if (self.ui_swapchain_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); if (self.ui_swapchain_tex_pipeline) |p| c.vkDestroyPipeline(vk_device, p, null); @@ -230,6 +234,8 @@ pub const PipelineManager = struct { self.ui_pipeline = null; self.ui_tex_pipeline = null; self.water_pipeline = null; + self.compact_lod_terrain_pipeline = null; + self.compact_lod_water_pipeline = null; self.ui_swapchain_pipeline = null; self.ui_swapchain_tex_pipeline = null; self.debug_shadow_pipeline = null; @@ -314,6 +320,8 @@ pub const PipelineManager = struct { // Terrain Pipeline try self.createTerrainPipeline(allocator, vk_device, hdr_render_pass, &viewport_state, &dynamic_state, &input_assembly, &rasterizer, &terrain_multisampling, &depth_stencil, &terrain_color_blending, sample_count, g_render_pass); + try pipeline_specialized.createCompactLODPipeline(self, allocator, vk_device, hdr_render_pass, &viewport_state, &dynamic_state, &input_assembly, &rasterizer, &terrain_multisampling, &depth_stencil, &terrain_color_blending, shader_registry.COMPACT_LOD_TERRAIN_VERT, shader_registry.COMPACT_LOD_TERRAIN_FRAG, false, &self.compact_lod_terrain_pipeline); + try pipeline_specialized.createCompactLODPipeline(self, allocator, vk_device, hdr_render_pass, &viewport_state, &dynamic_state, &input_assembly, &rasterizer, &terrain_multisampling, &depth_stencil, &terrain_color_blending, shader_registry.COMPACT_LOD_WATER_VERT, shader_registry.COMPACT_LOD_WATER_FRAG, true, &self.compact_lod_water_pipeline); // Sky Pipeline try self.createSkyPipeline(allocator, vk_device, hdr_render_pass, &viewport_state, &dynamic_state, &input_assembly, &rasterizer, &multisampling, &depth_stencil, &terrain_color_blending); diff --git a/modules/engine-graphics/src/vulkan/pipeline_specialized.zig b/modules/engine-graphics/src/vulkan/pipeline_specialized.zig index 48cd6d4d..855286d6 100644 --- a/modules/engine-graphics/src/vulkan/pipeline_specialized.zig +++ b/modules/engine-graphics/src/vulkan/pipeline_specialized.zig @@ -142,6 +142,73 @@ pub fn createTerrainPipeline( } } +/// A vertex-pulling pipeline intentionally has no Vk vertex bindings. Indexed +/// grid topology reaches the compact samples through descriptor binding 16. +pub fn createCompactLODPipeline( + self: anytype, + allocator: std.mem.Allocator, + vk_device: c.VkDevice, + render_pass: c.VkRenderPass, + viewport_state: *const c.VkPipelineViewportStateCreateInfo, + dynamic_state: *const c.VkPipelineDynamicStateCreateInfo, + input_assembly: *const c.VkPipelineInputAssemblyStateCreateInfo, + rasterizer: *const c.VkPipelineRasterizationStateCreateInfo, + multisampling: *const c.VkPipelineMultisampleStateCreateInfo, + depth_stencil: *const c.VkPipelineDepthStencilStateCreateInfo, + color_blending: *const c.VkPipelineColorBlendStateCreateInfo, + vert_path: []const u8, + frag_path: []const u8, + water: bool, + out_pipeline: *c.VkPipeline, +) !void { + const shaders = try loadShaderPair(allocator, vk_device, vert_path, frag_path); + defer c.vkDestroyShaderModule(vk_device, shaders.vert, null); + defer c.vkDestroyShaderModule(vk_device, shaders.frag, null); + var stages = [_]c.VkPipelineShaderStageCreateInfo{ + .{ .sType = c.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = c.VK_SHADER_STAGE_VERTEX_BIT, .module = shaders.vert, .pName = "main" }, + .{ .sType = c.VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = c.VK_SHADER_STAGE_FRAGMENT_BIT, .module = shaders.frag, .pName = "main" }, + }; + var vertex_input = std.mem.zeroes(c.VkPipelineVertexInputStateCreateInfo); + vertex_input.sType = c.VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; + + var water_depth_stencil = depth_stencil.*; + var compact_rasterizer = rasterizer.*; + // Edge skirts contain both orientations around the tile perimeter. They + // must remain visible regardless of winding after Vulkan Y inversion. + compact_rasterizer.cullMode = c.VK_CULL_MODE_NONE; + compact_rasterizer.polygonMode = c.VK_POLYGON_MODE_FILL; + var water_blend_attachment = color_blending.pAttachments[0]; + var water_color_blending = color_blending.*; + if (water) { + water_depth_stencil.depthWriteEnable = c.VK_FALSE; + water_blend_attachment.blendEnable = c.VK_TRUE; + water_blend_attachment.srcColorBlendFactor = c.VK_BLEND_FACTOR_SRC_ALPHA; + water_blend_attachment.dstColorBlendFactor = c.VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + water_blend_attachment.colorBlendOp = c.VK_BLEND_OP_ADD; + water_blend_attachment.srcAlphaBlendFactor = c.VK_BLEND_FACTOR_ONE; + water_blend_attachment.dstAlphaBlendFactor = c.VK_BLEND_FACTOR_ZERO; + water_blend_attachment.alphaBlendOp = c.VK_BLEND_OP_ADD; + water_color_blending.pAttachments = &water_blend_attachment; + } + + var info = std.mem.zeroes(c.VkGraphicsPipelineCreateInfo); + info.sType = c.VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; + info.stageCount = stages.len; + info.pStages = &stages; + info.pVertexInputState = &vertex_input; + info.pInputAssemblyState = input_assembly; + info.pViewportState = viewport_state; + info.pRasterizationState = &compact_rasterizer; + info.pMultisampleState = multisampling; + info.pDepthStencilState = if (water) &water_depth_stencil else depth_stencil; + info.pColorBlendState = if (water) &water_color_blending else color_blending; + info.pDynamicState = dynamic_state; + info.layout = self.pipeline_layout; + info.renderPass = render_pass; + info.subpass = 0; + try Utils.checkVk(c.vkCreateGraphicsPipelines(vk_device, null, 1, &info, null, out_pipeline)); +} + pub fn createSwapchainUIPipelines( self: anytype, allocator: std.mem.Allocator, diff --git a/modules/engine-graphics/src/vulkan/render_pass_manager.zig b/modules/engine-graphics/src/vulkan/render_pass_manager.zig index 1a03e604..074c2aa8 100644 --- a/modules/engine-graphics/src/vulkan/render_pass_manager.zig +++ b/modules/engine-graphics/src/vulkan/render_pass_manager.zig @@ -16,6 +16,11 @@ const Utils = @import("utils.zig"); /// Depth format used throughout the renderer pub const DEPTH_FORMAT = c.VK_FORMAT_D32_SFLOAT; +/// Layout of a swapchain-like output after its final render pass. +pub fn swapchainOutputLayout(headless: bool) c.VkImageLayout { + return if (headless) c.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL else c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; +} + /// Render pass manager handles all render pass and framebuffer resources pub const RenderPassManager = struct { allocator: ?std.mem.Allocator = null, @@ -357,8 +362,11 @@ pub const RenderPassManager = struct { try Utils.checkVk(c.vkCreateRenderPass(vk_device, &rp_info, null, &self.g_render_pass)); } - /// Create post-process render pass - pub fn createPostProcessRenderPass(self: *RenderPassManager, vk_device: c.VkDevice, swapchain_format: c.VkFormat) !void { + /// Create post-process render pass. + /// + /// Headless output remains color-attachment optimal so screenshots can + /// transition it to a transfer source without pretending it was presented. + pub fn createPostProcessRenderPass(self: *RenderPassManager, vk_device: c.VkDevice, swapchain_format: c.VkFormat, headless: bool) !void { if (self.post_process_render_pass) |rp| { c.vkDestroyRenderPass(vk_device, rp, null); self.post_process_render_pass = null; @@ -367,12 +375,16 @@ pub const RenderPassManager = struct { var color_attachment = std.mem.zeroes(c.VkAttachmentDescription); color_attachment.format = swapchain_format; color_attachment.samples = c.VK_SAMPLE_COUNT_1_BIT; - color_attachment.loadOp = c.VK_ATTACHMENT_LOAD_OP_LOAD; + // The offscreen image is not acquired from WSI. Its first use has an + // undefined layout, and this pass fills every pixel, so discard and + // clear it rather than claiming a nonexistent PRESENT transition. + color_attachment.loadOp = if (headless) c.VK_ATTACHMENT_LOAD_OP_CLEAR else c.VK_ATTACHMENT_LOAD_OP_LOAD; color_attachment.storeOp = c.VK_ATTACHMENT_STORE_OP_STORE; color_attachment.stencilLoadOp = c.VK_ATTACHMENT_LOAD_OP_DONT_CARE; color_attachment.stencilStoreOp = c.VK_ATTACHMENT_STORE_OP_DONT_CARE; - color_attachment.initialLayout = c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; - color_attachment.finalLayout = c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + const output_layout = swapchainOutputLayout(headless); + color_attachment.initialLayout = if (headless) c.VK_IMAGE_LAYOUT_UNDEFINED else output_layout; + color_attachment.finalLayout = output_layout; var color_ref = c.VkAttachmentReference{ .attachment = 0, .layout = c.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL }; @@ -401,8 +413,8 @@ pub const RenderPassManager = struct { try Utils.checkVk(c.vkCreateRenderPass(vk_device, &rp_info, null, &self.post_process_render_pass)); } - /// Create UI swapchain render pass - pub fn createUISwapchainRenderPass(self: *RenderPassManager, vk_device: c.VkDevice, swapchain_format: c.VkFormat) !void { + /// Create UI swapchain render pass. + pub fn createUISwapchainRenderPass(self: *RenderPassManager, vk_device: c.VkDevice, swapchain_format: c.VkFormat, headless: bool) !void { if (self.ui_swapchain_render_pass) |rp| { c.vkDestroyRenderPass(vk_device, rp, null); self.ui_swapchain_render_pass = null; @@ -415,8 +427,9 @@ pub const RenderPassManager = struct { color_attachment.storeOp = c.VK_ATTACHMENT_STORE_OP_STORE; color_attachment.stencilLoadOp = c.VK_ATTACHMENT_LOAD_OP_DONT_CARE; color_attachment.stencilStoreOp = c.VK_ATTACHMENT_STORE_OP_DONT_CARE; - color_attachment.initialLayout = c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; - color_attachment.finalLayout = c.VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + const output_layout = swapchainOutputLayout(headless); + color_attachment.initialLayout = output_layout; + color_attachment.finalLayout = output_layout; var color_ref = c.VkAttachmentReference{ .attachment = 0, .layout = c.VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL }; diff --git a/modules/engine-graphics/src/vulkan/resource_manager.zig b/modules/engine-graphics/src/vulkan/resource_manager.zig index faf2da26..c0064857 100644 --- a/modules/engine-graphics/src/vulkan/resource_manager.zig +++ b/modules/engine-graphics/src/vulkan/resource_manager.zig @@ -89,8 +89,12 @@ pub const ResourceManager = struct { self.staging_ring = try StagingRing.init(vulkan_device, transfer_queue.DEFAULT_STAGING_CAPACITY); log.log.info("Staging ring initialized: {}MB", .{transfer_queue.DEFAULT_STAGING_CAPACITY / (1024 * 1024)}); - const tx_family = vulkan_device.transfer_family; - const is_dedicated = vulkan_device.has_dedicated_transfer_queue; + // Buffer resources use exclusive sharing and are consumed by graphics. + // Until queue-family release/acquire ownership transfers are modeled, + // record uploads on the graphics family. A semaphore alone does not + // transfer exclusive buffer ownership from a transfer-only queue. + const tx_family = vulkan_device.graphics_family; + const is_dedicated = false; self.transfer = try TransferQueue.init(vulkan_device, tx_family, is_dedicated); if (is_dedicated) { log.log.info("Transfer queue: DEDICATED (family {})", .{tx_family}); @@ -265,7 +269,7 @@ pub const ResourceManager = struct { .vertex => c.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | c.VK_BUFFER_USAGE_TRANSFER_DST_BIT, .index => c.VK_BUFFER_USAGE_INDEX_BUFFER_BIT | c.VK_BUFFER_USAGE_TRANSFER_DST_BIT, .uniform => c.VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT | c.VK_BUFFER_USAGE_TRANSFER_DST_BIT, - .indirect => c.VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT | c.VK_BUFFER_USAGE_TRANSFER_DST_BIT | c.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + .indirect => c.VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT | c.VK_BUFFER_USAGE_TRANSFER_SRC_BIT | c.VK_BUFFER_USAGE_TRANSFER_DST_BIT | c.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, .storage => c.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | c.VK_BUFFER_USAGE_TRANSFER_DST_BIT | c.VK_BUFFER_USAGE_TRANSFER_SRC_BIT | c.VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, }; @@ -297,9 +301,11 @@ pub const ResourceManager = struct { pub fn updateBuffer(self: *ResourceManager, handle: rhi.BufferHandle, offset: usize, data: []const u8) rhi.RhiError!void { const buf = self.buffers.get(handle) orelse return; + const end = std.math.add(usize, offset, data.len) catch return error.InvalidState; + if (@as(u64, @intCast(end)) > buf.size) return error.InvalidState; const slice = self.staging_ring.allocate(data.len, self.current_frame_index) orelse { - log.log.err("Staging ring overflow in updateBuffer! Data dropped.", .{}); + log.log.err("Staging ring overflow in updateBuffer: request={} used={} head={} tail={} frame={} frame_used={any}", .{ data.len, self.staging_ring.used_total, self.staging_ring.head, self.staging_ring.tail, self.current_frame_index, self.staging_ring.frame_used }); return error.OutOfMemory; }; diff --git a/modules/engine-graphics/src/vulkan/rhi_context_types.zig b/modules/engine-graphics/src/vulkan/rhi_context_types.zig index 85307c14..22c93735 100644 --- a/modules/engine-graphics/src/vulkan/rhi_context_types.zig +++ b/modules/engine-graphics/src/vulkan/rhi_context_types.zig @@ -156,8 +156,12 @@ const DrawState = struct { lod_mode: bool = false, bound_instance_buffer: [MAX_FRAMES_IN_FLIGHT]rhi.BufferHandle = .{ 0, 0 }, bound_lod_instance_buffer: [MAX_FRAMES_IN_FLIGHT]rhi.BufferHandle = .{ 0, 0 }, + bound_lod_compact_sample_buffer: [MAX_FRAMES_IN_FLIGHT]rhi.BufferHandle = .{ 0, 0 }, + bound_lod_compact_instance_buffer: [MAX_FRAMES_IN_FLIGHT]rhi.BufferHandle = .{ 0, 0 }, pending_instance_buffer: rhi.BufferHandle = 0, pending_lod_instance_buffer: rhi.BufferHandle = 0, + pending_lod_compact_sample_buffer: rhi.BufferHandle = 0, + pending_lod_compact_instance_buffer: rhi.BufferHandle = 0, current_view_proj: Mat4 = Mat4.identity, current_model: Mat4 = Mat4.identity, current_color: [4]f32 = .{ 1.0, 1.0, 1.0, 1.0 }, diff --git a/modules/engine-graphics/src/vulkan/rhi_draw_submission.zig b/modules/engine-graphics/src/vulkan/rhi_draw_submission.zig index d10267d7..a470fbb9 100644 --- a/modules/engine-graphics/src/vulkan/rhi_draw_submission.zig +++ b/modules/engine-graphics/src/vulkan/rhi_draw_submission.zig @@ -1,6 +1,7 @@ const std = @import("std"); const c = @import("c").c; const rhi = @import("engine-rhi").rhi; +const rhi_types = @import("engine-rhi").rhi_types; const log = @import("engine-core").log; const Mat4 = @import("engine-math").Mat4; const pass_orchestration = @import("rhi_pass_orchestration.zig"); @@ -63,6 +64,77 @@ pub fn drawIndexed(ctx: anytype, vbo_handle: rhi.BufferHandle, ebo_handle: rhi.B } } +/// Indexed, storage-buffer vertex pulling for compact far LOD tiles. There is +/// deliberately no vertex buffer binding: the static index grid provides the +/// vertex id and binding 16 supplies the packed 16-byte samples. +pub fn drawCompactLOD(ctx: anytype, index_handle: rhi.BufferHandle, index_count: u32, params: rhi.CompactLODDraw) bool { + if (!ctx.frames.frame_in_progress or index_count == 0 or params.width < 2 or params.layer > 1) return false; + if (!ctx.runtime.main_pass_active and !ctx.water_system.pass_active) pass_orchestration.beginMainPassInternal(ctx); + if (!ctx.runtime.main_pass_active and !ctx.water_system.pass_active) return false; + const index = ctx.resources.buffers.get(index_handle) orelse return false; + const index_bytes = std.math.mul(u64, @as(u64, index_count), @sizeOf(u32)) catch return false; + if ((index.usage & c.VK_BUFFER_USAGE_INDEX_BUFFER_BIT) == 0 or index.size < index_bytes) return false; + const sample_handle = ctx.draw.bound_lod_compact_sample_buffer[ctx.frames.current_frame]; + const samples = ctx.resources.buffers.get(sample_handle) orelse return false; + if ((samples.usage & c.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT) == 0 or samples.size == 0 or samples.size % @sizeOf(rhi.CompactLODSampleWords) != 0) return false; + + // Every shader sample is a 16-byte uvec4. Validate the padded (one-sample + // apron on each side) address range before recording the draw. + const stride = std.math.add(u64, @as(u64, params.width), 2) catch return false; + const sample_count = std.math.mul(u64, stride, stride) catch return false; + const sample_end = std.math.add(u64, @as(u64, params.sample_offset), sample_count) catch return false; + const sample_bytes = std.math.mul(u64, sample_end, @sizeOf(rhi.CompactLODSampleWords)) catch return false; + if (sample_bytes > samples.size) return false; + const cb = ctx.frames.command_buffers[ctx.frames.current_frame]; + const pipeline = if (params.layer == 1) ctx.pipeline_manager.compact_lod_water_pipeline else ctx.pipeline_manager.compact_lod_terrain_pipeline; + if (pipeline == null) return false; + c.vkCmdBindPipeline(cb, c.VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + ctx.draw.terrain_pipeline_bound = false; + const descriptor_set = &ctx.descriptors.lod_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); + c.vkCmdPushConstants(cb, ctx.pipeline_manager.pipeline_layout, c.VK_SHADER_STAGE_VERTEX_BIT | c.VK_SHADER_STAGE_FRAGMENT_BIT, 0, @sizeOf(rhi.CompactLODDraw), ¶ms); + c.vkCmdBindIndexBuffer(cb, index.buffer, 0, c.VK_INDEX_TYPE_UINT32); + c.vkCmdDrawIndexed(cb, index_count, 1, 0, 0, 0); + ctx.runtime.draw_call_count += 1; + return true; +} + +/// Indexed indirect-count submission for compact far LOD. A single sentinel +/// push constant selects SSBO instance data; every command carries an output +/// slot in firstInstance, which GLSL exposes through gl_InstanceIndex. +pub fn drawCompactLODIndirectCount(ctx: anytype, index_handle: rhi.BufferHandle, command_handle: rhi.BufferHandle, offset: usize, count_handle: rhi.BufferHandle, count_offset: usize, max_draw_count: u32) bool { + if (!ctx.frames.frame_in_progress or !ctx.vulkan_device.draw_indirect_count or max_draw_count == 0) return false; + if (!ctx.runtime.main_pass_active and !ctx.water_system.pass_active) pass_orchestration.beginMainPassInternal(ctx); + if (!ctx.runtime.main_pass_active and !ctx.water_system.pass_active) return false; + const index = ctx.resources.buffers.get(index_handle) orelse return false; + const commands = ctx.resources.buffers.get(command_handle) orelse return false; + const counts = ctx.resources.buffers.get(count_handle) orelse return false; + const fi = ctx.frames.current_frame; + const samples = ctx.resources.buffers.get(ctx.draw.bound_lod_compact_sample_buffer[fi]) orelse return false; + const instances = ctx.resources.buffers.get(ctx.draw.bound_lod_compact_instance_buffer[fi]) orelse return false; + if ((index.usage & c.VK_BUFFER_USAGE_INDEX_BUFFER_BIT) == 0 or (commands.usage & c.VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT) == 0 or (counts.usage & c.VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT) == 0) return false; + if ((samples.usage & c.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT) == 0 or (instances.usage & c.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT) == 0) return false; + if (index.size == 0 or index.size % @sizeOf(u32) != 0) return false; + const command_bytes = std.math.mul(usize, max_draw_count, @sizeOf(rhi_types.DrawIndexedIndirectCommand)) catch return false; + const command_end = std.math.add(usize, offset, command_bytes) catch return false; + const count_end = std.math.add(usize, count_offset, @sizeOf(u32)) catch return false; + if (command_end > commands.size or count_end > counts.size) return false; + const cb = ctx.frames.command_buffers[fi]; + const pipeline = if (ctx.water_system.pass_active) ctx.pipeline_manager.compact_lod_water_pipeline else ctx.pipeline_manager.compact_lod_terrain_pipeline; + if (pipeline == null) return false; + c.vkCmdBindPipeline(cb, c.VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + ctx.draw.terrain_pipeline_bound = false; + const descriptor_set = &ctx.descriptors.lod_descriptor_sets[fi]; + c.vkCmdBindDescriptorSets(cb, c.VK_PIPELINE_BIND_POINT_GRAPHICS, ctx.pipeline_manager.pipeline_layout, 0, 1, descriptor_set, 0, null); + const sentinel = rhi.CompactLODDraw{ .model = Mat4.identity, .mask_radius = 0, .lod_fade = 0, .sample_offset = 0, .width = 0, .cell_size = 0, .layer = 2, .skirt_depth = 0 }; + c.vkCmdPushConstants(cb, ctx.pipeline_manager.pipeline_layout, c.VK_SHADER_STAGE_VERTEX_BIT | c.VK_SHADER_STAGE_FRAGMENT_BIT, 0, @sizeOf(rhi.CompactLODDraw), &sentinel); + c.vkCmdBindIndexBuffer(cb, index.buffer, 0, c.VK_INDEX_TYPE_UINT32); + const draw_indirect_count = ctx.vulkan_device.vkCmdDrawIndexedIndirectCountKHR orelse return false; + draw_indirect_count(cb, commands.buffer, @intCast(offset), counts.buffer, @intCast(count_offset), max_draw_count, @sizeOf(rhi_types.DrawIndexedIndirectCommand)); + ctx.runtime.draw_call_count += 1; + return true; +} + pub fn drawIndirect(ctx: anytype, handle: rhi.BufferHandle, command_buffer: rhi.BufferHandle, offset: usize, draw_count: u32, stride: u32) void { if (!ctx.frames.frame_in_progress) return; @@ -181,6 +253,47 @@ pub fn drawIndirect(ctx: anytype, handle: rhi.BufferHandle, command_buffer: rhi. } } +/// Vulkan draw-indirect-count path for compute-compacted streams. It never +/// maps the count on the CPU, so the current frame remains fully GPU-driven. +pub fn drawIndirectCount(ctx: anytype, handle: rhi.BufferHandle, command_buffer: rhi.BufferHandle, offset: usize, count_buffer: rhi.BufferHandle, count_offset: usize, max_draw_count: u32, stride: u32) bool { + if (!ctx.frames.frame_in_progress or !ctx.vulkan_device.draw_indirect_count) return false; + if (!ctx.runtime.main_pass_active and !ctx.shadow_system.pass_active and !ctx.runtime.g_pass_active and !ctx.water_system.pass_active) pass_orchestration.beginMainPassInternal(ctx); + if (!ctx.runtime.main_pass_active and !ctx.shadow_system.pass_active and !ctx.runtime.g_pass_active and !ctx.water_system.pass_active) return false; + const vbo = ctx.resources.buffers.get(handle) orelse return false; + const commands = ctx.resources.buffers.get(command_buffer) orelse return false; + const counts = ctx.resources.buffers.get(count_buffer) orelse return false; + const cb = ctx.frames.command_buffers[ctx.frames.current_frame]; + const use_shadow = ctx.shadow_system.pass_active; + const use_g_pass = ctx.runtime.g_pass_active; + if (use_shadow) { + if (!ctx.shadow_system.pipeline_bound) { + if (ctx.shadow_system.shadow_pipeline == null) return false; + c.vkCmdBindPipeline(cb, c.VK_PIPELINE_BIND_POINT_GRAPHICS, ctx.shadow_system.shadow_pipeline); + ctx.shadow_system.pipeline_bound = true; + } + } else if (use_g_pass) { + if (ctx.pipeline_manager.g_pipeline == null) return false; + c.vkCmdBindPipeline(cb, c.VK_PIPELINE_BIND_POINT_GRAPHICS, ctx.pipeline_manager.g_pipeline); + } else if (!ctx.draw.terrain_pipeline_bound) { + const pipeline = if (ctx.water_system.pass_active) + if (ctx.options.wireframe_enabled and ctx.water_system.reflection_wireframe_pipeline != null) ctx.water_system.reflection_wireframe_pipeline else ctx.water_system.reflection_terrain_pipeline + else if (ctx.options.wireframe_enabled and ctx.pipeline_manager.wireframe_pipeline != null) ctx.pipeline_manager.wireframe_pipeline else ctx.pipeline_manager.terrain_pipeline; + if (pipeline == null) return false; + c.vkCmdBindPipeline(cb, c.VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + ctx.draw.terrain_pipeline_bound = !ctx.water_system.pass_active and pipeline == ctx.pipeline_manager.terrain_pipeline; + } + const descriptor_set = if (ctx.draw.lod_mode) &ctx.descriptors.lod_descriptor_sets[ctx.frames.current_frame] 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 }; + 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); + const draw_indirect_count = ctx.vulkan_device.vkCmdDrawIndirectCountKHR orelse return false; + draw_indirect_count(cb, commands.buffer, @intCast(offset), counts.buffer, @intCast(count_offset), max_draw_count, stride); + ctx.runtime.draw_call_count += 1; + return true; +} + pub fn drawInstance(ctx: anytype, handle: rhi.BufferHandle, count: u32, instance_index: u32) void { if (!ctx.frames.frame_in_progress) return; diff --git a/modules/engine-graphics/src/vulkan/rhi_frame_orchestration.zig b/modules/engine-graphics/src/vulkan/rhi_frame_orchestration.zig index 5b0f522b..62569b16 100644 --- a/modules/engine-graphics/src/vulkan/rhi_frame_orchestration.zig +++ b/modules/engine-graphics/src/vulkan/rhi_frame_orchestration.zig @@ -142,7 +142,7 @@ pub fn recreateSwapchainInternal(ctx: anytype) void { _ = markSwapchainRecreateFailed(ctx, "swapchain UI resources", err); return; }; - ctx.fxaa.init(&ctx.vulkan_device, ctx.allocator, ctx.descriptors.descriptor_pool, ctx.swapchain.getExtent(), ctx.swapchain.getImageFormat(), ctx.post_process.sampler, ctx.swapchain.getImageViews()) catch |err| { + ctx.fxaa.init(&ctx.vulkan_device, ctx.allocator, ctx.descriptors.descriptor_pool, ctx.swapchain.getExtent(), ctx.swapchain.getImageFormat(), ctx.post_process.sampler, ctx.swapchain.getImageViews(), ctx.swapchain.swapchain.headless_mode) catch |err| { _ = markSwapchainRecreateFailed(ctx, "FXAA resources", err); return; }; diff --git a/modules/engine-graphics/src/vulkan/rhi_init_deinit.zig b/modules/engine-graphics/src/vulkan/rhi_init_deinit.zig index 4cc4d9f4..5a109fe9 100644 --- a/modules/engine-graphics/src/vulkan/rhi_init_deinit.zig +++ b/modules/engine-graphics/src/vulkan/rhi_init_deinit.zig @@ -102,7 +102,7 @@ pub fn initContext(ctx: anytype, allocator: std.mem.Allocator, render_device: ?* try setup.createPostProcessResources(ctx); try setup.createSwapchainUIResources(ctx); - try ctx.fxaa.init(&ctx.vulkan_device, ctx.allocator, ctx.descriptors.descriptor_pool, ctx.swapchain.getExtent(), ctx.swapchain.getImageFormat(), ctx.post_process.sampler, ctx.swapchain.getImageViews()); + try ctx.fxaa.init(&ctx.vulkan_device, ctx.allocator, ctx.descriptors.descriptor_pool, ctx.swapchain.getExtent(), ctx.swapchain.getImageFormat(), ctx.post_process.sampler, ctx.swapchain.getImageViews(), ctx.swapchain.swapchain.headless_mode); try ctx.pipeline_manager.createSwapchainUIPipelines(ctx.allocator, ctx.vulkan_device.vk_device, ctx.render_pass_manager.ui_swapchain_render_pass); try ctx.bloom.init(&ctx.vulkan_device, ctx.allocator, ctx.descriptors.descriptor_pool, ctx.hdr.hdr_view, ctx.swapchain.getExtent().width, ctx.swapchain.getExtent().height, c.VK_FORMAT_R16G16B16A16_SFLOAT); diff --git a/modules/engine-graphics/src/vulkan/rhi_render_state.zig b/modules/engine-graphics/src/vulkan/rhi_render_state.zig index d259d742..1d339d19 100644 --- a/modules/engine-graphics/src/vulkan/rhi_render_state.zig +++ b/modules/engine-graphics/src/vulkan/rhi_render_state.zig @@ -85,6 +85,20 @@ pub fn setLODInstanceBuffer(ctx: anytype, handle: rhi.BufferHandle) void { applyPendingDescriptorUpdates(ctx, ctx.frames.current_frame); } +pub fn setLODCompactSampleBuffer(ctx: anytype, handle: rhi.BufferHandle) void { + if (!ctx.frames.frame_in_progress) return; + ctx.draw.pending_lod_compact_sample_buffer = handle; + ctx.draw.lod_mode = true; + applyPendingDescriptorUpdates(ctx, ctx.frames.current_frame); +} + +pub fn setLODCompactInstanceBuffer(ctx: anytype, handle: rhi.BufferHandle) void { + if (!ctx.frames.frame_in_progress) return; + ctx.draw.pending_lod_compact_instance_buffer = handle; + ctx.draw.lod_mode = true; + applyPendingDescriptorUpdates(ctx, ctx.frames.current_frame); +} + pub fn setTerrainPipelineBound(ctx: anytype, bound: bool) void { ctx.draw.terrain_pipeline_bound = bound; } @@ -139,4 +153,31 @@ pub fn applyPendingDescriptorUpdates(ctx: anytype, frame_index: usize) void { ctx.draw.bound_lod_instance_buffer[frame_index] = ctx.draw.pending_lod_instance_buffer; } } + + if (ctx.draw.pending_lod_compact_sample_buffer != 0 and ctx.draw.bound_lod_compact_sample_buffer[frame_index] != ctx.draw.pending_lod_compact_sample_buffer) { + const buf = ctx.resources.buffers.get(ctx.draw.pending_lod_compact_sample_buffer) orelse return; + var info = c.VkDescriptorBufferInfo{ .buffer = buf.buffer, .offset = 0, .range = buf.size }; + var write = std.mem.zeroes(c.VkWriteDescriptorSet); + write.sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = ctx.descriptors.lod_descriptor_sets[frame_index]; + write.dstBinding = bindings.COMPACT_LOD_SAMPLES; + write.descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + write.descriptorCount = 1; + write.pBufferInfo = &info; + c.vkUpdateDescriptorSets(ctx.vulkan_device.vk_device, 1, &write, 0, null); + ctx.draw.bound_lod_compact_sample_buffer[frame_index] = ctx.draw.pending_lod_compact_sample_buffer; + } + if (ctx.draw.pending_lod_compact_instance_buffer != 0 and ctx.draw.bound_lod_compact_instance_buffer[frame_index] != ctx.draw.pending_lod_compact_instance_buffer) { + const buf = ctx.resources.buffers.get(ctx.draw.pending_lod_compact_instance_buffer) orelse return; + var info = c.VkDescriptorBufferInfo{ .buffer = buf.buffer, .offset = 0, .range = buf.size }; + var write = std.mem.zeroes(c.VkWriteDescriptorSet); + write.sType = c.VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = ctx.descriptors.lod_descriptor_sets[frame_index]; + write.dstBinding = bindings.COMPACT_LOD_INSTANCES; + write.descriptorType = c.VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + write.descriptorCount = 1; + write.pBufferInfo = &info; + c.vkUpdateDescriptorSets(ctx.vulkan_device.vk_device, 1, &write, 0, null); + ctx.draw.bound_lod_compact_instance_buffer[frame_index] = ctx.draw.pending_lod_compact_instance_buffer; + } } diff --git a/modules/engine-graphics/src/vulkan/rhi_resource_setup.zig b/modules/engine-graphics/src/vulkan/rhi_resource_setup.zig index 32e7b6eb..3e9dfc2a 100644 --- a/modules/engine-graphics/src/vulkan/rhi_resource_setup.zig +++ b/modules/engine-graphics/src/vulkan/rhi_resource_setup.zig @@ -18,7 +18,7 @@ pub fn createSwapchainUIResources(ctx: anytype) !void { lifecycle.destroySwapchainUIResources(ctx); errdefer lifecycle.destroySwapchainUIResources(ctx); - try ctx.render_pass_manager.createUISwapchainRenderPass(vk, ctx.swapchain.getImageFormat()); + try ctx.render_pass_manager.createUISwapchainRenderPass(vk, ctx.swapchain.getImageFormat(), ctx.swapchain.swapchain.headless_mode); try ctx.render_pass_manager.createUISwapchainFramebuffers(vk, ctx.allocator, ctx.swapchain.getExtent(), ctx.swapchain.getImageViews()); } @@ -445,7 +445,7 @@ pub fn createWaterResources(ctx: anytype) !void { pub fn createPostProcessResources(ctx: anytype) !void { const vk = ctx.vulkan_device.vk_device; - try ctx.render_pass_manager.createPostProcessRenderPass(vk, ctx.swapchain.getImageFormat()); + try ctx.render_pass_manager.createPostProcessRenderPass(vk, ctx.swapchain.getImageFormat(), ctx.swapchain.swapchain.headless_mode); const global_uniform_size: usize = @intCast(ctx.descriptors.global_ubos[0].size); try ctx.post_process.init( diff --git a/modules/engine-graphics/src/vulkan/rhi_state_control.zig b/modules/engine-graphics/src/vulkan/rhi_state_control.zig index 5b3fe8e2..2c43c277 100644 --- a/modules/engine-graphics/src/vulkan/rhi_state_control.zig +++ b/modules/engine-graphics/src/vulkan/rhi_state_control.zig @@ -57,6 +57,10 @@ pub fn supportsIndirectFirstInstance(ctx: anytype) bool { return ctx.vulkan_device.draw_indirect_first_instance; } +pub fn supportsIndirectCount(ctx: anytype) bool { + return ctx.vulkan_device.draw_indirect_count; +} + pub fn recover(ctx: anytype) !void { if (!ctx.runtime.gpu_fault_detected) return; diff --git a/modules/engine-graphics/src/vulkan/rhi_timing.zig b/modules/engine-graphics/src/vulkan/rhi_timing.zig index 172538a0..ad5bbc5e 100644 --- a/modules/engine-graphics/src/vulkan/rhi_timing.zig +++ b/modules/engine-graphics/src/vulkan/rhi_timing.zig @@ -12,11 +12,14 @@ const GpuPass = enum { lpv_compute, sky, opaque_pass, + lod_terrain, + lod_water, + lod_culling_compute, bloom, fxaa, post_process, - pub const COUNT = 12; + pub const COUNT = 15; }; pub const PASS_COUNT = GpuPass.COUNT; @@ -32,6 +35,9 @@ fn mapPassName(name: []const u8) ?GpuPass { if (std.mem.eql(u8, name, "LPVPass")) return .lpv_compute; if (std.mem.eql(u8, name, "SkyPass")) return .sky; if (std.mem.eql(u8, name, "OpaquePass")) return .opaque_pass; + if (std.mem.eql(u8, name, "LODTerrainPass")) return .lod_terrain; + if (std.mem.eql(u8, name, "LODWaterPass")) return .lod_water; + if (std.mem.eql(u8, name, "LODGpuCullingComputeBarrier")) return .lod_culling_compute; if (std.mem.eql(u8, name, "BloomPass")) return .bloom; if (std.mem.eql(u8, name, "FXAAPass")) return .fxaa; if (std.mem.eql(u8, name, "PostProcessPass")) return .post_process; @@ -101,6 +107,9 @@ pub fn processTimingResults(ctx: anytype) void { const lpv = readPassMs(ctx, frame, .lpv_compute, period) orelse return; const sky = readPassMs(ctx, frame, .sky, period) orelse return; const opaque_ms = readPassMs(ctx, frame, .opaque_pass, period) orelse return; + const lod_terrain = readPassMs(ctx, frame, .lod_terrain, period) orelse return; + const lod_water = readPassMs(ctx, frame, .lod_water, period) orelse return; + const lod_culling_compute = readPassMs(ctx, frame, .lod_culling_compute, period) orelse return; const bloom = readPassMs(ctx, frame, .bloom, period) orelse return; const fxaa = readPassMs(ctx, frame, .fxaa, period) orelse return; const post_process = readPassMs(ctx, frame, .post_process, period) orelse return; @@ -114,6 +123,9 @@ pub fn processTimingResults(ctx: anytype) void { ctx.timing.timing_results.lpv_pass_ms = lpv; ctx.timing.timing_results.sky_pass_ms = sky; ctx.timing.timing_results.opaque_pass_ms = opaque_ms; + ctx.timing.timing_results.lod_terrain_pass_ms = lod_terrain; + ctx.timing.timing_results.lod_water_pass_ms = lod_water; + ctx.timing.timing_results.lod_culling_compute_ms = lod_culling_compute; ctx.timing.timing_results.bloom_pass_ms = bloom; ctx.timing.timing_results.fxaa_pass_ms = fxaa; ctx.timing.timing_results.post_process_pass_ms = post_process; @@ -130,7 +142,16 @@ pub fn processTimingResults(ctx: anytype) void { ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.ssao_pass_ms; ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.lpv_pass_ms; ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.main_pass_ms; + ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.lod_culling_compute_ms; + // LOD terrain/water timings are nested in scene passes and must not be + // double-counted. The culling compute pass above is independent. ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.bloom_pass_ms; ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.fxaa_pass_ms; ctx.timing.timing_results.total_gpu_ms += ctx.timing.timing_results.post_process_pass_ms; } + +test "GPU timing maps LOD pass names" { + try std.testing.expectEqual(GpuPass.lod_terrain, mapPassName("LODTerrainPass").?); + try std.testing.expectEqual(GpuPass.lod_water, mapPassName("LODWaterPass").?); + try std.testing.expectEqual(GpuPass.lod_culling_compute, mapPassName("LODGpuCullingComputeBarrier").?); +} diff --git a/modules/engine-graphics/src/vulkan/shader_registry.zig b/modules/engine-graphics/src/vulkan/shader_registry.zig index d72371ed..3f9d7f3a 100644 --- a/modules/engine-graphics/src/vulkan/shader_registry.zig +++ b/modules/engine-graphics/src/vulkan/shader_registry.zig @@ -39,3 +39,8 @@ pub const CULLING_COMP = "assets/shaders/vulkan/culling.comp.spv"; pub const WATER_VERT = "assets/shaders/vulkan/water.vert.spv"; pub const WATER_FRAG = "assets/shaders/vulkan/water.frag.spv"; + +pub const COMPACT_LOD_TERRAIN_VERT = "assets/shaders/vulkan/lod_compact_terrain.vert.spv"; +pub const COMPACT_LOD_TERRAIN_FRAG = "assets/shaders/vulkan/lod_compact_terrain.frag.spv"; +pub const COMPACT_LOD_WATER_VERT = "assets/shaders/vulkan/lod_compact_water.vert.spv"; +pub const COMPACT_LOD_WATER_FRAG = "assets/shaders/vulkan/lod_compact_water.frag.spv"; diff --git a/modules/engine-graphics/src/vulkan/transfer_queue.zig b/modules/engine-graphics/src/vulkan/transfer_queue.zig index e9186322..82472beb 100644 --- a/modules/engine-graphics/src/vulkan/transfer_queue.zig +++ b/modules/engine-graphics/src/vulkan/transfer_queue.zig @@ -28,6 +28,7 @@ pub const StagingRing = struct { capacity: u64 = 0, head: u64 = 0, tail: u64 = 0, + used_total: u64 = 0, frame_base: [rhi.MAX_FRAMES_IN_FLIGHT]u64, frame_used: [rhi.MAX_FRAMES_IN_FLIGHT]u64, @@ -48,6 +49,7 @@ pub const StagingRing = struct { .capacity = capacity, .head = 0, .tail = 0, + .used_total = 0, .frame_base = undefined, .frame_used = undefined, }; @@ -68,6 +70,7 @@ pub const StagingRing = struct { self.capacity = 0; self.head = 0; self.tail = 0; + self.used_total = 0; @memset(&self.frame_base, 0); @memset(&self.frame_used, 0); } @@ -78,13 +81,14 @@ pub const StagingRing = struct { } pub fn reclaimFrame(self: *StagingRing, frame_index: usize) void { - self.tail = self.frame_base[frame_index] + self.frame_used[frame_index]; - if (self.tail >= self.capacity) self.tail -= self.capacity; + const reclaimed = @min(self.frame_used[frame_index], self.used_total); + self.tail = (self.tail + reclaimed) % self.capacity; + self.used_total -= reclaimed; + self.frame_used[frame_index] = 0; } pub fn allocated(self: *StagingRing) u64 { - if (self.head >= self.tail) return self.head - self.tail; - return self.capacity - self.tail + self.head; + return self.used_total; } pub fn available(self: *StagingRing) u64 { @@ -95,10 +99,12 @@ pub const StagingRing = struct { if (size == 0) return null; const aligned_head = std.mem.alignForward(u64, self.head, ALIGNMENT); + const alignment_padding = aligned_head - self.head; var padded_to_end: u64 = 0; const try_offset = blk: { if (aligned_head + size <= self.capacity) { + if (alignment_padding + size > self.available()) return null; break :blk aligned_head; } padded_to_end = self.capacity - self.head; @@ -117,7 +123,9 @@ pub const StagingRing = struct { const new_head = try_offset + size; self.head = if (new_head >= self.capacity) 0 else new_head; - self.frame_used[frame_index] += size + padded_to_end; + const consumed = size + padded_to_end + if (padded_to_end == 0) alignment_padding else 0; + self.frame_used[frame_index] += consumed; + self.used_total += consumed; return slice; } @@ -352,3 +360,42 @@ pub const TransferQueue = struct { try self.submitAndWait(vk_device, queue_mutex); } }; + +test "staging ring distinguishes full from empty" { + var memory: [1024]u8 = undefined; + var ring = StagingRing{ + .mapped = &memory, + .capacity = memory.len, + .frame_base = [_]u64{0} ** rhi.MAX_FRAMES_IN_FLIGHT, + .frame_used = [_]u64{0} ** rhi.MAX_FRAMES_IN_FLIGHT, + }; + ring.beginFrame(0); + try std.testing.expect(ring.allocate(512, 0) != null); + try std.testing.expect(ring.allocate(512, 0) != null); + try std.testing.expectEqual(@as(u64, memory.len), ring.allocated()); + try std.testing.expect(ring.allocate(1, 0) == null); + ring.reclaimFrame(0); + try std.testing.expectEqual(@as(u64, 0), ring.allocated()); +} + +test "staging ring reclaims wrapped frame regions" { + var memory: [1024]u8 = undefined; + var ring = StagingRing{ + .mapped = &memory, + .capacity = memory.len, + .frame_base = [_]u64{0} ** rhi.MAX_FRAMES_IN_FLIGHT, + .frame_used = [_]u64{0} ** rhi.MAX_FRAMES_IN_FLIGHT, + }; + ring.beginFrame(0); + try std.testing.expect(ring.allocate(400, 0) != null); + ring.beginFrame(1); + try std.testing.expect(ring.allocate(400, 1) != null); + ring.reclaimFrame(0); + ring.beginFrame(0); + try std.testing.expect(ring.allocate(400, 0) != null); + try std.testing.expectEqual(@as(u64, 1024), ring.allocated()); + ring.reclaimFrame(1); + ring.reclaimFrame(0); + try std.testing.expectEqual(@as(u64, 0), ring.allocated()); + try std.testing.expectEqual(ring.head, ring.tail); +} diff --git a/modules/engine-graphics/src/vulkan/utils.zig b/modules/engine-graphics/src/vulkan/utils.zig index 86c276d5..7a88d4db 100644 --- a/modules/engine-graphics/src/vulkan/utils.zig +++ b/modules/engine-graphics/src/vulkan/utils.zig @@ -8,6 +8,7 @@ pub const VulkanBuffer = struct { buffer: c.VkBuffer = null, memory: c.VkDeviceMemory = null, size: c.VkDeviceSize = 0, + usage: c.VkBufferUsageFlags = 0, is_host_visible: bool = false, mapped_ptr: ?*anyopaque = null, }; @@ -74,7 +75,10 @@ pub fn createVulkanBuffer(device: *const VulkanDevice, size: usize, usage: c.VkB return .{ .buffer = buffer, .memory = memory, - .size = mem_reqs.size, + // Descriptor ranges and copy bounds are expressed in the VkBuffer's + // requested size, not the (possibly alignment-rounded) allocation. + .size = @intCast(size), + .usage = usage, .is_host_visible = is_host_visible, .mapped_ptr = mapped_ptr, }; diff --git a/modules/engine-graphics/src/vulkan_device.zig b/modules/engine-graphics/src/vulkan_device.zig index 324ad6df..32f8f337 100644 --- a/modules/engine-graphics/src/vulkan_device.zig +++ b/modules/engine-graphics/src/vulkan_device.zig @@ -77,6 +77,9 @@ pub const VulkanDevice = struct { max_msaa_samples: u8 = 1, multi_draw_indirect: bool = false, draw_indirect_first_instance: bool = false, + draw_indirect_count: bool = false, + vkCmdDrawIndirectCountKHR: ?*const fn (c.VkCommandBuffer, c.VkBuffer, c.VkDeviceSize, c.VkBuffer, c.VkDeviceSize, u32, u32) callconv(.c) void = null, + vkCmdDrawIndexedIndirectCountKHR: ?*const fn (c.VkCommandBuffer, c.VkBuffer, c.VkDeviceSize, c.VkBuffer, c.VkDeviceSize, u32, u32) callconv(.c) void = null, timestamp_period: f32 = 1.0, pub fn init(allocator: std.mem.Allocator, window: *c.SDL_Window) !VulkanDevice { @@ -293,16 +296,20 @@ pub const VulkanDevice = struct { const robustness2_name: [*:0]const u8 = @ptrCast(c.VK_EXT_ROBUSTNESS_2_EXTENSION_NAME); const device_fault_name: [*:0]const u8 = @ptrCast(c.VK_EXT_DEVICE_FAULT_EXTENSION_NAME); + const indirect_count_name: [*:0]const u8 = @ptrCast(c.VK_KHR_DRAW_INDIRECT_COUNT_EXTENSION_NAME); const robustness2_name_slice = std.mem.span(robustness2_name); const device_fault_name_slice = std.mem.span(device_fault_name); + const indirect_count_name_slice = std.mem.span(indirect_count_name); var supports_robustness2 = false; var supports_device_fault = false; + var supports_indirect_count = false; for (ext_props) |prop| { const name: [*:0]const u8 = @ptrCast(&prop.extensionName); const name_slice = std.mem.span(name); if (std.mem.eql(u8, name_slice, robustness2_name_slice)) supports_robustness2 = true; if (std.mem.eql(u8, name_slice, device_fault_name_slice)) supports_device_fault = true; + if (std.mem.eql(u8, name_slice, indirect_count_name_slice)) supports_indirect_count = true; } if (supports_robustness2) log.log.info("VK_EXT_robustness2 supported", .{}); @@ -334,7 +341,7 @@ pub const VulkanDevice = struct { robustness2_features.pNext = if (allow_device_fault) @ptrCast(&fault_features) else null; } - var enabled_extensions: [3][*c]const u8 = undefined; + var enabled_extensions: [4][*c]const u8 = undefined; var enabled_extension_count: u32 = 0; enabled_extensions[enabled_extension_count] = c.VK_KHR_SWAPCHAIN_EXTENSION_NAME; enabled_extension_count += 1; @@ -346,6 +353,10 @@ pub const VulkanDevice = struct { enabled_extensions[enabled_extension_count] = c.VK_EXT_DEVICE_FAULT_EXTENSION_NAME; enabled_extension_count += 1; } + if (supports_indirect_count) { + enabled_extensions[enabled_extension_count] = c.VK_KHR_DRAW_INDIRECT_COUNT_EXTENSION_NAME; + enabled_extension_count += 1; + } var device_create_info = std.mem.zeroes(c.VkDeviceCreateInfo); device_create_info.sType = c.VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; @@ -368,6 +379,7 @@ pub const VulkanDevice = struct { device_create_info.pNext = null; enabled_extensions[0] = c.VK_KHR_SWAPCHAIN_EXTENSION_NAME; enabled_extension_count = 1; + supports_indirect_count = false; device_create_info.enabledExtensionCount = enabled_extension_count; device_create_info.ppEnabledExtensionNames = &enabled_extensions; queue_create_count = 1; @@ -387,6 +399,17 @@ pub const VulkanDevice = struct { self.supports_device_fault = false; } } + if (supports_indirect_count and self.vk_device != null) { + const proc = c.vkGetDeviceProcAddr(self.vk_device, "vkCmdDrawIndirectCountKHR"); + if (proc) |function| { + self.vkCmdDrawIndirectCountKHR = @ptrCast(function); + const indexed_proc = c.vkGetDeviceProcAddr(self.vk_device, "vkCmdDrawIndexedIndirectCountKHR"); + if (indexed_proc) |indexed_function| { + self.vkCmdDrawIndexedIndirectCountKHR = @ptrCast(indexed_function); + self.draw_indirect_count = true; + } + } + } return self; } diff --git a/modules/engine-rhi/src/culling.zig b/modules/engine-rhi/src/culling.zig index c595ab4d..1eff0c1a 100644 --- a/modules/engine-rhi/src/culling.zig +++ b/modules/engine-rhi/src/culling.zig @@ -1,4 +1,5 @@ const Mat4 = @import("engine-math").Mat4; +const rhi_types = @import("rhi_types.zig"); pub const ChunkCullData = extern struct { min_point: [4]f32, @@ -55,3 +56,91 @@ pub const ICullingSystem = struct { self.vtable.dispatch(self.ptr, config); } }; + +/// A command source has two vec4 lanes in the candidate SSBO. Standard draws +/// use `count`, `instance_count`, `first`, and `first_instance`; compact draws +/// additionally use `vertex_offset` and are emitted as indexed commands. +pub const LODCullCommand = extern struct { + count: u32, + instance_count: u32, + first: u32, + vertex_offset: i32 = 0, + first_instance: u32 = 0, + _padding: [3]u32 = .{ 0, 0, 0 }, +}; + +/// One CPU-approved LOD region. The layout is a fixed std430 ABI shared with +/// lod_culling.comp. `lod_and_flags[1]` selects indexed compact output. +pub const LODCullCandidate = extern struct { + min_point: [4]f32, + max_point: [4]f32, + model: Mat4, + instance_params: [4]f32, + /// x=sample offset, y=grid width; only meaningful for compact candidates. + compact_words: [4]u32, + /// x=cell size, y=skirt depth; mask/fade remain in instance_params. + compact_metrics: [4]f32, + terrain_command: LODCullCommand, + water_command: LODCullCommand, + lod_and_padding: [4]u32, +}; + +pub const LODCullDispatch = extern struct { + planes: [6][4]f32, + candidate_count: u32, + max_distance_blocks: f32, + max_commands_per_lod: u32, + _padding: u32 = 0, +}; + +pub const LODCullDiagnostics = extern struct { + overflow_count: u32 = 0, + validation_mismatch_count: u32 = 0, +}; + +pub const ILODCullingSystem = struct { + ptr: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + deinit: *const fn (ptr: *anyopaque) void, + dispatch: *const fn (ptr: *anyopaque, frame_index: usize, candidates: []const LODCullCandidate, config: LODCullDispatch) bool, + instanceBuffer: *const fn (ptr: *anyopaque, frame_index: usize, fluid: bool, compact: bool) rhi_types.BufferHandle, + indirectBuffer: *const fn (ptr: *anyopaque, frame_index: usize, fluid: bool, compact: bool) rhi_types.BufferHandle, + countBuffer: *const fn (ptr: *anyopaque, frame_index: usize) rhi_types.BufferHandle, + diagnostics: *const fn (ptr: *anyopaque) LODCullDiagnostics, + }; + + pub fn deinit(self: ILODCullingSystem) void { + self.vtable.deinit(self.ptr); + } + + /// Records same-frame compute culling before graphics render passes begin. + /// Returns false when inputs exceed the fixed GPU capacity. + pub fn dispatch(self: ILODCullingSystem, frame_index: usize, candidates: []const LODCullCandidate, config: LODCullDispatch) bool { + return self.vtable.dispatch(self.ptr, frame_index, candidates, config); + } + + pub fn instanceBuffer(self: ILODCullingSystem, frame_index: usize, fluid: bool, compact: bool) rhi_types.BufferHandle { + return self.vtable.instanceBuffer(self.ptr, frame_index, fluid, compact); + } + + pub fn indirectBuffer(self: ILODCullingSystem, frame_index: usize, fluid: bool, compact: bool) rhi_types.BufferHandle { + return self.vtable.indirectBuffer(self.ptr, frame_index, fluid, compact); + } + pub fn countBuffer(self: ILODCullingSystem, frame_index: usize) rhi_types.BufferHandle { + return self.vtable.countBuffer(self.ptr, frame_index); + } + pub fn diagnostics(self: ILODCullingSystem) LODCullDiagnostics { + return self.vtable.diagnostics(self.ptr); + } +}; + +test "LOD culling candidate ABI is std430 aligned" { + try @import("std").testing.expectEqual(@as(usize, 224), @sizeOf(LODCullCandidate)); + try @import("std").testing.expectEqual(@as(usize, 32), @offsetOf(LODCullCandidate, "model")); + try @import("std").testing.expectEqual(@as(usize, 96), @offsetOf(LODCullCandidate, "instance_params")); + try @import("std").testing.expectEqual(@as(usize, 144), @offsetOf(LODCullCandidate, "terrain_command")); + try @import("std").testing.expectEqual(@as(usize, 208), @offsetOf(LODCullCandidate, "lod_and_padding")); + try @import("std").testing.expectEqual(@as(usize, 32), @sizeOf(LODCullCommand)); +} diff --git a/modules/engine-rhi/src/rhi.zig b/modules/engine-rhi/src/rhi.zig index ab4bf933..543b4455 100644 --- a/modules/engine-rhi/src/rhi.zig +++ b/modules/engine-rhi/src/rhi.zig @@ -86,6 +86,8 @@ pub const DrawMode = rhi_types.DrawMode; pub const ShaderStageFlags = rhi_types.ShaderStageFlags; pub const DrawIndirectCommand = rhi_types.DrawIndirectCommand; pub const InstanceData = rhi_types.InstanceData; +pub const CompactLODDraw = rhi_types.CompactLODDraw; +pub const CompactLODSampleWords = rhi_types.CompactLODSampleWords; pub const SkyParams = rhi_types.SkyParams; pub const SkyPushConstants = rhi_types.SkyPushConstants; pub const FrameRenderParams = rhi_types.FrameRenderParams; @@ -97,6 +99,7 @@ pub const Rect = rhi_types.Rect; pub const UVRect = rhi_types.UVRect; pub const GpuTimingResults = rhi_types.GpuTimingResults; pub const ICullingSystem = culling.ICullingSystem; +pub const ILODCullingSystem = culling.ILODCullingSystem; pub const RenderResolution = struct { width: u32, @@ -427,11 +430,28 @@ pub const RenderContext = struct { pub fn drawIndexed(self: RenderContext, vbo: BufferHandle, ebo: BufferHandle, count: u32) void { self.encoder.drawIndexed(vbo, ebo, count); } + /// Draws a compact far-LOD grid through a storage-buffer vertex-pulling + /// pipeline. The index buffer is reusable and no expanded Vertex array is + /// required for the tile. + pub fn drawCompactLOD(self: RenderContext, index_buffer: BufferHandle, index_count: u32, params: CompactLODDraw) bool { + return self.encoder.drawCompactLOD(index_buffer, index_count, params); + } + /// Draws GPU-compacted indexed compact-LOD commands. The compact instance + /// SSBO is indexed by each command's firstInstance; no CPU count readback + /// or per-draw push constants are used. + pub fn drawCompactLODIndirectCount(self: RenderContext, index_buffer: BufferHandle, command_buffer: BufferHandle, offset: usize, count_buffer: BufferHandle, count_offset: usize, max_draw_count: u32) bool { + return self.encoder.drawCompactLODIndirectCount(index_buffer, command_buffer, offset, count_buffer, count_offset, max_draw_count); + } /// Issues indirect draw commands from a GPU buffer. /// The indirect buffer must contain backend-compatible command records and remain valid through submission. Must be called from the render thread that owns the backend context. pub fn drawIndirect(self: RenderContext, handle: BufferHandle, command_buffer: BufferHandle, offset: usize, draw_count: u32, stride: u32) void { self.encoder.drawIndirect(handle, command_buffer, offset, draw_count, stride); } + /// Issues GPU-generated indirect commands, with the command count read from + /// `count_buffer`. Returns false when the active backend lacks the feature. + pub fn drawIndirectCount(self: RenderContext, handle: BufferHandle, command_buffer: BufferHandle, offset: usize, count_buffer: BufferHandle, count_offset: usize, max_draw_count: u32, stride: u32) bool { + return self.encoder.drawIndirectCount(handle, command_buffer, offset, count_buffer, count_offset, max_draw_count, stride); + } /// Issues an instanced draw using currently bound per-instance state. /// Instance buffers and model data must be populated for the active frame. Must be called from the render thread that owns the backend context. pub fn drawInstance(self: RenderContext, handle: BufferHandle, count: u32, instance_index: u32) void { @@ -460,6 +480,13 @@ pub const RenderContext = struct { pub fn setLODInstanceBuffer(self: RenderContext, handle: BufferHandle) void { self.state.setLODInstanceBuffer(handle); } + /// Binds the shared compact LOD sample pool for subsequent vertex-pulling draws. + pub fn setLODCompactSampleBuffer(self: RenderContext, handle: BufferHandle) void { + self.state.setLODCompactSampleBuffer(handle); + } + pub fn setLODCompactInstanceBuffer(self: RenderContext, handle: BufferHandle) void { + self.state.setLODCompactInstanceBuffer(handle); + } /// Sets terrain pipeline bound on the active graphics backend. /// The setting affects later frames or later commands according to backend state lifetime. Must be called from the render thread that owns the backend context. pub fn setTerrainPipelineBound(self: RenderContext, bound: bool) void { @@ -782,7 +809,10 @@ pub const IGraphicsCommandEncoder = struct { draw: *const fn (ptr: *anyopaque, handle: BufferHandle, count: u32, mode: DrawMode) void, drawOffset: *const fn (ptr: *anyopaque, handle: BufferHandle, count: u32, mode: DrawMode, offset: usize) void, drawIndexed: *const fn (ptr: *anyopaque, vbo: BufferHandle, ebo: BufferHandle, count: u32) void, + drawCompactLOD: *const fn (ptr: *anyopaque, index_buffer: BufferHandle, index_count: u32, params: CompactLODDraw) bool, + drawCompactLODIndirectCount: *const fn (ptr: *anyopaque, index_buffer: BufferHandle, command_buffer: BufferHandle, offset: usize, count_buffer: BufferHandle, count_offset: usize, max_draw_count: u32) bool, drawIndirect: *const fn (ptr: *anyopaque, handle: BufferHandle, command_buffer: BufferHandle, offset: usize, draw_count: u32, stride: u32) void, + drawIndirectCount: *const fn (ptr: *anyopaque, handle: BufferHandle, command_buffer: BufferHandle, offset: usize, count_buffer: BufferHandle, count_offset: usize, max_draw_count: u32, stride: u32) bool, drawInstance: *const fn (ptr: *anyopaque, handle: BufferHandle, count: u32, instance_index: u32) void, setViewport: *const fn (ptr: *anyopaque, width: u32, height: u32) void, }; @@ -817,11 +847,20 @@ pub const IGraphicsCommandEncoder = struct { pub fn drawIndexed(self: IGraphicsCommandEncoder, vbo: BufferHandle, ebo: BufferHandle, count: u32) void { self.vtable.drawIndexed(self.ptr, vbo, ebo, count); } + pub fn drawCompactLOD(self: IGraphicsCommandEncoder, index_buffer: BufferHandle, index_count: u32, params: CompactLODDraw) bool { + return self.vtable.drawCompactLOD(self.ptr, index_buffer, index_count, params); + } + pub fn drawCompactLODIndirectCount(self: IGraphicsCommandEncoder, index_buffer: BufferHandle, command_buffer: BufferHandle, offset: usize, count_buffer: BufferHandle, count_offset: usize, max_draw_count: u32) bool { + return self.vtable.drawCompactLODIndirectCount(self.ptr, index_buffer, command_buffer, offset, count_buffer, count_offset, max_draw_count); + } /// Issues indirect draw commands from a GPU buffer. /// The indirect buffer must contain backend-compatible command records and remain valid through submission. Must be called from the render thread that owns the backend context. pub fn drawIndirect(self: IGraphicsCommandEncoder, handle: BufferHandle, command_buffer: BufferHandle, offset: usize, draw_count: u32, stride: u32) void { self.vtable.drawIndirect(self.ptr, handle, command_buffer, offset, draw_count, stride); } + pub fn drawIndirectCount(self: IGraphicsCommandEncoder, handle: BufferHandle, command_buffer: BufferHandle, offset: usize, count_buffer: BufferHandle, count_offset: usize, max_draw_count: u32, stride: u32) bool { + return self.vtable.drawIndirectCount(self.ptr, handle, command_buffer, offset, count_buffer, count_offset, max_draw_count, stride); + } /// Issues an instanced draw using currently bound per-instance state. /// Instance buffers and model data must be populated for the active frame. Must be called from the render thread that owns the backend context. pub fn drawInstance(self: IGraphicsCommandEncoder, handle: BufferHandle, count: u32, instance_index: u32) void { @@ -842,6 +881,8 @@ pub const IRenderStateContext = struct { setModelMatrix: *const fn (ptr: *anyopaque, model: Mat4, color: Vec3, mask_radius: f32) void, setInstanceBuffer: *const fn (ptr: *anyopaque, handle: BufferHandle) void, setLODInstanceBuffer: *const fn (ptr: *anyopaque, handle: BufferHandle) void, + setLODCompactSampleBuffer: *const fn (ptr: *anyopaque, handle: BufferHandle) void, + setLODCompactInstanceBuffer: *const fn (ptr: *anyopaque, handle: BufferHandle) void, setTerrainPipelineBound: *const fn (ptr: *anyopaque, bound: bool) void, setSelectionMode: *const fn (ptr: *anyopaque, enabled: bool) void, updateGlobalUniforms: *const fn (ptr: *anyopaque, uniforms: GlobalUniforms, frame_params: FrameRenderParams) anyerror!void, @@ -863,6 +904,13 @@ pub const IRenderStateContext = struct { pub fn setLODInstanceBuffer(self: IRenderStateContext, handle: BufferHandle) void { self.vtable.setLODInstanceBuffer(self.ptr, handle); } + /// Selects the immutable shared compact sample pool for LOD vertex pulling. + pub fn setLODCompactSampleBuffer(self: IRenderStateContext, handle: BufferHandle) void { + self.vtable.setLODCompactSampleBuffer(self.ptr, handle); + } + pub fn setLODCompactInstanceBuffer(self: IRenderStateContext, handle: BufferHandle) void { + self.vtable.setLODCompactInstanceBuffer(self.ptr, handle); + } /// Sets terrain pipeline bound on the active graphics backend. /// The setting affects later frames or later commands according to backend state lifetime. Must be called from the render thread that owns the backend context. pub fn setTerrainPipelineBound(self: IRenderStateContext, bound: bool) void { @@ -1281,6 +1329,7 @@ pub const IDeviceQuery = struct { pub const VTable = struct { getFrameIndex: *const fn (ptr: *anyopaque) usize, supportsIndirectFirstInstance: *const fn (ptr: *anyopaque) bool, + supportsIndirectCount: *const fn (ptr: *anyopaque) bool, getMaxAnisotropy: *const fn (ptr: *anyopaque) u8, getMaxMSAASamples: *const fn (ptr: *anyopaque) u8, getFaultCount: *const fn (ptr: *anyopaque) u32, @@ -1301,6 +1350,10 @@ pub const IDeviceQuery = struct { pub fn supportsIndirectFirstInstance(self: IDeviceQuery) bool { return self.vtable.supportsIndirectFirstInstance(self.ptr); } + /// Reports whether GPU-generated indirect command counts are supported. + pub fn supportsIndirectCount(self: IDeviceQuery) bool { + return self.vtable.supportsIndirectCount(self.ptr); + } /// Returns fault count from the active graphics backend. /// The returned value is backend-owned or diagnostic unless the specific type documents otherwise. pub fn getFaultCount(self: IDeviceQuery) u32 { @@ -1536,6 +1589,7 @@ pub const ICullingSystemFactory = struct { pub const VTable = struct { createCullingSystem: *const fn (ctx: *anyopaque, allocator: Allocator, max_chunks: usize) anyerror!?ICullingSystem, + createLODCullingSystem: *const fn (ctx: *anyopaque, allocator: Allocator, max_regions: usize) anyerror!?ILODCullingSystem, }; /// Creates a GPU culling system bound to the active backend resources. @@ -1543,6 +1597,9 @@ pub const ICullingSystemFactory = struct { pub fn createCullingSystem(self: ICullingSystemFactory, allocator: Allocator, max_chunks: usize) anyerror!?ICullingSystem { return self.vtable.createCullingSystem(self.ptr, allocator, max_chunks); } + pub fn createLODCullingSystem(self: ICullingSystemFactory, allocator: Allocator, max_regions: usize) anyerror!?ILODCullingSystem { + return self.vtable.createLODCullingSystem(self.ptr, allocator, max_regions); + } }; pub const IScreenshotContext = struct { @@ -1806,6 +1863,10 @@ pub const RHI = struct { pub fn createCullingSystem(self: RHI, allocator: Allocator, max_chunks: usize) anyerror!?ICullingSystem { return self.cullingFactory().createCullingSystem(allocator, max_chunks); } + /// Creates the dedicated LOD compute/MDI compaction system. + pub fn createLODCullingSystem(self: RHI, allocator: Allocator, max_regions: usize) anyerror!?ILODCullingSystem { + return self.cullingFactory().createLODCullingSystem(allocator, max_regions); + } // Lifecycle /// Constructs an `RHI` composite from a backend pointer and vtable. diff --git a/modules/engine-rhi/src/rhi_types.zig b/modules/engine-rhi/src/rhi_types.zig index d3a7d38e..aa6ef88b 100644 --- a/modules/engine-rhi/src/rhi_types.zig +++ b/modules/engine-rhi/src/rhi_types.zig @@ -225,6 +225,157 @@ pub const DrawMode = enum { points, }; +/// Parameters for the far-LOD vertex-pulling path. Samples live in a storage +/// buffer; the reusable index grid supplies `gl_VertexIndex`. +pub const CompactLODDraw = extern struct { + model: Mat4, + mask_radius: f32, + lod_fade: f32, + sample_offset: u32, + width: u32, + cell_size: f32, + layer: u32, + skirt_depth: f32, + _reserved: u32 = 0, +}; + +/// Per-draw compact-tile data consumed from a std430 SSBO by the indirect +/// vertex-pulling path. `gl_InstanceIndex` includes `firstInstance`, so the +/// compute pass can compact this record and use its output slot directly. +pub const CompactLODInstance = extern struct { + model: Mat4, + /// mask radius, LOD fade, cell size, skirt depth + params: [4]f32, + /// sample offset, tile width, layer, reserved + words: [4]u32, +}; + +pub const DrawIndexedIndirectCommand = extern struct { + indexCount: u32, + instanceCount: u32, + firstIndex: u32, + vertexOffset: i32, + firstInstance: u32, +}; + +comptime { + // extern alignment rounds the scalar payload fields to a 16-byte + // push-constant block, matching Vulkan's GLSL layout size. + std.debug.assert(@sizeOf(CompactLODDraw) == 96); + std.debug.assert(@offsetOf(CompactLODDraw, "mask_radius") == 64); + std.debug.assert(@offsetOf(CompactLODDraw, "lod_fade") == 68); + std.debug.assert(@offsetOf(CompactLODDraw, "sample_offset") == 72); + std.debug.assert(@offsetOf(CompactLODDraw, "width") == 76); + std.debug.assert(@offsetOf(CompactLODDraw, "cell_size") == 80); + std.debug.assert(@offsetOf(CompactLODDraw, "layer") == 84); + std.debug.assert(@offsetOf(CompactLODDraw, "skirt_depth") == 88); + std.debug.assert(@offsetOf(CompactLODDraw, "_reserved") == 92); +} + +test "compact LOD push constants match GLSL scalar offsets" { + try std.testing.expectEqual(@as(usize, 96), @sizeOf(CompactLODDraw)); + try std.testing.expectEqual(@as(usize, 0), @offsetOf(CompactLODDraw, "model")); + try std.testing.expectEqual(@as(usize, 64), @offsetOf(CompactLODDraw, "mask_radius")); + try std.testing.expectEqual(@as(usize, 68), @offsetOf(CompactLODDraw, "lod_fade")); + try std.testing.expectEqual(@as(usize, 72), @offsetOf(CompactLODDraw, "sample_offset")); + try std.testing.expectEqual(@as(usize, 76), @offsetOf(CompactLODDraw, "width")); + try std.testing.expectEqual(@as(usize, 80), @offsetOf(CompactLODDraw, "cell_size")); + try std.testing.expectEqual(@as(usize, 84), @offsetOf(CompactLODDraw, "layer")); + try std.testing.expectEqual(@as(usize, 88), @offsetOf(CompactLODDraw, "skirt_depth")); + try std.testing.expectEqual(@as(usize, 92), @offsetOf(CompactLODDraw, "_reserved")); +} + +test "compact LOD indirect instance and indexed command ABI" { + try std.testing.expectEqual(@as(usize, 96), @sizeOf(CompactLODInstance)); + try std.testing.expectEqual(@as(usize, 64), @offsetOf(CompactLODInstance, "params")); + try std.testing.expectEqual(@as(usize, 80), @offsetOf(CompactLODInstance, "words")); + try std.testing.expectEqual(@as(usize, 20), @sizeOf(DrawIndexedIndirectCommand)); + try std.testing.expectEqual(@as(usize, 16), @offsetOf(DrawIndexedIndirectCommand, "firstInstance")); +} + +/// Little-endian 128-bit compact LOD sample, represented as Vulkan SSBO words. +/// The field locations are shared by the world upload format and the compact +/// vertex shaders. This is deliberately a word view: Vulkan storage buffers +/// expose the 16-byte sample as `uvec4`. +pub const CompactLODSampleWords = extern struct { + words: [4]u32, + + pub const Decoded = struct { + terrain_height: i16, + water_height: i16, + water_depth: u8, + water_coverage: u8, + surface_material: u7, + subsurface_material: u7, + foundation_material: u7, + color: u32, + sky_light: u4, + block_light: u4, + ambient_occlusion: u6, + }; + + pub fn decode(self: CompactLODSampleWords) Decoded { + return .{ + .terrain_height = signed16(self.words[0], 0), + .water_height = signed16(self.words[0], 16), + .water_depth = @truncate(self.words[1]), + .water_coverage = @truncate(self.words[1] >> 8), + .surface_material = @truncate(self.words[1] >> 16), + .subsurface_material = @truncate(self.words[1] >> 23), + .foundation_material = @truncate((self.words[1] >> 30) | (self.words[2] << 2)), + .color = (self.words[2] >> 5) & 0x00ff_ffff, + .sky_light = @truncate((self.words[2] >> 29) | (self.words[3] << 3)), + .block_light = @truncate(self.words[3] >> 1), + .ambient_occlusion = @truncate(self.words[3] >> 5), + }; + } + + fn signed16(word: u32, shift: u5) i16 { + const raw: u16 = @truncate(word >> shift); + return @bitCast(raw); + } +}; + +comptime { + std.debug.assert(@sizeOf(CompactLODSampleWords) == 16); + std.debug.assert(@alignOf(CompactLODSampleWords) == @alignOf(u32)); + std.debug.assert(@offsetOf(CompactLODSampleWords, "words") == 0); +} + +test "compact LOD 128-bit sample golden vector decodes across word boundaries" { + var bits: u128 = 0; + bits |= @as(u128, @as(u16, @bitCast(@as(i16, -100)))); + bits |= @as(u128, @as(u16, @bitCast(@as(i16, 58)))) << 16; + bits |= @as(u128, 9) << 32; + bits |= @as(u128, 255) << 40; + bits |= @as(u128, 0x15) << 48; + bits |= @as(u128, 0x2a) << 55; + bits |= @as(u128, 0x45) << 62; + bits |= @as(u128, 0x00ab_cdef) << 69; + bits |= @as(u128, 15) << 93; + bits |= @as(u128, 11) << 97; + bits |= @as(u128, 42) << 101; + + const sample = CompactLODSampleWords{ .words = .{ + @truncate(bits), + @truncate(bits >> 32), + @truncate(bits >> 64), + @truncate(bits >> 96), + } }; + const decoded = sample.decode(); + try std.testing.expectEqual(@as(i16, -100), decoded.terrain_height); + try std.testing.expectEqual(@as(i16, 58), decoded.water_height); + try std.testing.expectEqual(@as(u8, 9), decoded.water_depth); + try std.testing.expectEqual(@as(u8, 255), decoded.water_coverage); + try std.testing.expectEqual(@as(u7, 0x15), decoded.surface_material); + try std.testing.expectEqual(@as(u7, 0x2a), decoded.subsurface_material); + try std.testing.expectEqual(@as(u7, 0x45), decoded.foundation_material); + try std.testing.expectEqual(@as(u32, 0x00ab_cdef), decoded.color); + try std.testing.expectEqual(@as(u4, 15), decoded.sky_light); + try std.testing.expectEqual(@as(u4, 11), decoded.block_light); + try std.testing.expectEqual(@as(u6, 42), decoded.ambient_occlusion); +} + pub const ShaderStageFlags = packed struct(u32) { vertex: bool = false, fragment: bool = false, @@ -372,6 +523,15 @@ pub const GpuTimingResults = struct { lpv_pass_ms: f32, sky_pass_ms: f32, opaque_pass_ms: f32, + /// GPU time spent drawing distant-terrain LOD geometry. This is a subset + /// of the containing scene pass and is therefore excluded from total_gpu_ms. + lod_terrain_pass_ms: f32, + /// GPU time spent drawing distant-water LOD geometry. This is a subset + /// of the containing scene pass and is therefore excluded from total_gpu_ms. + lod_water_pass_ms: f32, + /// GPU time spent culling LOD candidates, compacting indirect commands, + /// and executing the compute-to-indirect barrier. + lod_culling_compute_ms: f32, main_pass_ms: f32, // Overall main pass time (sum of sky and opaque) bloom_pass_ms: f32, fxaa_pass_ms: f32, diff --git a/modules/engine-rhi/src/root.zig b/modules/engine-rhi/src/root.zig index fb17de26..44f29930 100644 --- a/modules/engine-rhi/src/root.zig +++ b/modules/engine-rhi/src/root.zig @@ -39,7 +39,11 @@ pub const Vertex = rhi_types.Vertex; pub const DrawMode = rhi_types.DrawMode; pub const ShaderStageFlags = rhi_types.ShaderStageFlags; pub const DrawIndirectCommand = rhi_types.DrawIndirectCommand; +pub const DrawIndexedIndirectCommand = rhi_types.DrawIndexedIndirectCommand; pub const InstanceData = rhi_types.InstanceData; +pub const CompactLODDraw = rhi_types.CompactLODDraw; +pub const CompactLODInstance = rhi_types.CompactLODInstance; +pub const CompactLODSampleWords = rhi_types.CompactLODSampleWords; pub const SkyParams = rhi_types.SkyParams; pub const SkyPushConstants = rhi_types.SkyPushConstants; pub const FrameRenderParams = rhi_types.FrameRenderParams; @@ -92,6 +96,11 @@ pub const getPresetConfig = render_settings.getPresetConfig; pub const ChunkCullData = culling.ChunkCullData; pub const DispatchConfig = culling.DispatchConfig; pub const ICullingSystem = culling.ICullingSystem; +pub const LODCullCandidate = culling.LODCullCandidate; +pub const LODCullCommand = culling.LODCullCommand; +pub const LODCullDispatch = culling.LODCullDispatch; +pub const LODCullDiagnostics = culling.LODCullDiagnostics; +pub const ILODCullingSystem = culling.ILODCullingSystem; pub const RenderDevice = render_device.RenderDevice; pub const Stats = render_device.Stats; pub const encodeColor = rhi_types.encodeColor; diff --git a/modules/engine-rhi/src/world_contracts.zig b/modules/engine-rhi/src/world_contracts.zig index 7ea1edce..ec779b77 100644 --- a/modules/engine-rhi/src/world_contracts.zig +++ b/modules/engine-rhi/src/world_contracts.zig @@ -25,11 +25,16 @@ pub const IWorldRenderView = struct { vtable: *const VTable, pub const VTable = struct { + prepareLODCulling: *const fn (ptr: *anyopaque, view_proj: Mat4, camera_pos: Vec3) void, render: *const fn (ptr: *anyopaque, view_proj: Mat4, camera_pos: Vec3, render_lod: bool) void, renderOpaque: *const fn (ptr: *anyopaque, view_proj: Mat4, camera_pos: Vec3, render_lod: bool) void, renderFluid: *const fn (ptr: *anyopaque, view_proj: Mat4, camera_pos: Vec3, render_lod: bool) void, }; + pub fn prepareLODCulling(self: IWorldRenderView, view_proj: Mat4, camera_pos: Vec3) void { + self.vtable.prepareLODCulling(self.ptr, view_proj, camera_pos); + } + pub fn render(self: IWorldRenderView, view_proj: Mat4, camera_pos: Vec3, render_lod: bool) void { self.vtable.render(self.ptr, view_proj, camera_pos, render_lod); } diff --git a/modules/engine-ui/src/root.zig b/modules/engine-ui/src/root.zig index 6e485476..406f7df5 100644 --- a/modules/engine-ui/src/root.zig +++ b/modules/engine-ui/src/root.zig @@ -25,6 +25,9 @@ pub const DebugUI = debug_ui.DebugUI; pub const FEATURE_INFOS = debug_menu.FEATURE_INFOS; pub const FontAtlas = font_atlas.FontAtlas; pub const InputEvent = ui_system.InputEvent; +pub const LODProfilingDisplay = timing_overlay.LODProfilingDisplay; +pub const LODVisibilityLevelDisplay = timing_overlay.LODVisibilityLevelDisplay; +pub const LOD_VISIBILITY_LEVEL_COUNT = timing_overlay.LOD_VISIBILITY_LEVEL_COUNT; pub const LODStatsDisplay = timing_overlay.LODStatsDisplay; pub const PerformanceData = timing_overlay.PerformanceData; pub const Rect = ui_system.Rect; diff --git a/modules/engine-ui/src/timing_overlay.zig b/modules/engine-ui/src/timing_overlay.zig index 7b77b6c6..14b2ba1a 100644 --- a/modules/engine-ui/src/timing_overlay.zig +++ b/modules/engine-ui/src/timing_overlay.zig @@ -7,6 +7,8 @@ const RenderDeviceStats = @import("engine-rhi").render_device.Stats; const LODLevel = @import("engine-core").lod_types.LODLevel; const font = @import("font.zig"); +pub const LOD_VISIBILITY_LEVEL_COUNT = LODLevel.count; + pub const TimingOverlay = struct { enabled: bool = false, @@ -23,7 +25,7 @@ pub const TimingOverlay = struct { comptime std.debug.assert(rhi.SHADOW_CASCADE_COUNT >= 3); - var num_lines: f32 = 2 + 1 + 13 + 1 + 6 + 1 + 4 + 1; + var num_lines: f32 = 2 + 1 + 15 + 1 + 6 + 1 + 4 + 1; if (data.world) |ws| { num_lines += 1 + 6; if (ws.lod != null) { @@ -61,6 +63,9 @@ pub const TimingOverlay = struct { drawGpuLine(ui, "LPV:", data.gpu.lpv_pass_ms, label_x, value_x, &y, scale, muted); drawGpuLine(ui, "SKY:", data.gpu.sky_pass_ms, label_x, value_x, &y, scale, muted); drawGpuLine(ui, "OPAQUE:", data.gpu.opaque_pass_ms, label_x, value_x, &y, scale, muted); + drawGpuLine(ui, "LOD TERRAIN:", data.gpu.lod_terrain_pass_ms, label_x, value_x, &y, scale, muted); + drawGpuLine(ui, "LOD CULLING:", data.gpu.lod_culling_compute_ms, label_x, value_x, &y, scale, muted); + drawGpuLine(ui, "LOD WATER:", data.gpu.lod_water_pass_ms, label_x, value_x, &y, scale, muted); drawGpuLine(ui, "MAIN:", data.gpu.main_pass_ms, label_x, value_x, &y, scale, muted); drawGpuLine(ui, "BLOOM:", data.gpu.bloom_pass_ms, label_x, value_x, &y, scale, muted); drawGpuLine(ui, "FXAA:", data.gpu.fxaa_pass_ms, label_x, value_x, &y, scale, muted); @@ -172,4 +177,78 @@ pub const WorldStats = struct { pub const LODStatsDisplay = struct { loaded: [LODLevel.count]u32, memory_used_mb: u32, + /// Current known LOD allocations. These are accounting gauges, not + /// GPU-driver memory measurements. + memory_used_bytes: u64 = 0, + pool_gpu_capacity_bytes: u64 = 0, + pool_gpu_allocated_bytes: u64 = 0, + pool_gpu_slack_bytes: u64 = 0, + pool_cpu_shadow_bytes: u64 = 0, + compact_pool_capacity_bytes: u64 = 0, + compact_pool_allocated_bytes: u64 = 0, + compact_pool_free_bytes: u64 = 0, + compact_pool_retired_bytes: u64 = 0, + direct_mesh_gpu_bytes: u64 = 0, + pending_cpu_upload_bytes: u64 = 0, + deferred_deletion_gpu_bytes: u64 = 0, + deferred_deletion_cpu_bytes: u64 = 0, + profiling: LODProfilingDisplay = .{}, +}; + +/// Dependency-neutral projection of the world-lod cumulative profiling snapshot. +/// Keeping this at the UI boundary lets telemetry consumers avoid a world-lod import. +pub const LODProfilingDisplay = struct { + enabled: bool = false, + update_ms: f64 = 0, + scheduling_ms: f64 = 0, + cache_ms: f64 = 0, + generation_dispatch_ms: f64 = 0, + state_transition_ms: f64 = 0, + upload_prep_ms: f64 = 0, + upload_submission_ms: f64 = 0, + visibility_ms: f64 = 0, + coverage_ms: f64 = 0, + eviction_ms: f64 = 0, + worker_generation_ms: f64 = 0, + worker_mesh_construction_ms: f64 = 0, + manager_lock_wait_ms: f64 = 0, + manager_lock_hold_ms: f64 = 0, + upload_bytes: u64 = 0, + pending_cpu_upload_bytes: u64 = 0, + staging_pressure_count: u64 = 0, + visible_count: u64 = 0, + rejected_count: u64 = 0, + coverage_count: u64 = 0, + visibility_levels: [LOD_VISIBILITY_LEVEL_COUNT]LODVisibilityLevelDisplay = [_]LODVisibilityLevelDisplay{.{}} ** LOD_VISIBILITY_LEVEL_COUNT, + deferred_deletion_bytes: u64 = 0, + deferred_deletion_cpu_bytes: u64 = 0, + pool_gpu_capacity_bytes: u64 = 0, + pool_gpu_allocated_bytes: u64 = 0, + pool_gpu_slack_bytes: u64 = 0, + pool_cpu_shadow_bytes: u64 = 0, + compact_pool_capacity_bytes: u64 = 0, + compact_pool_allocated_bytes: u64 = 0, + compact_pool_free_bytes: u64 = 0, + compact_pool_retired_bytes: u64 = 0, + direct_mesh_gpu_bytes: u64 = 0, + known_memory_bytes: u64 = 0, + wait_idle_count: u64 = 0, + wait_idle_ms: f64 = 0, + gpu_culling_overflows: u32 = 0, + gpu_culling_validation_mismatches: u32 = 0, +}; + +/// UI-neutral per-level visibility projection counters from world-lod. +pub const LODVisibilityLevelDisplay = struct { + candidates: u64 = 0, + accepted: u64 = 0, + rejected_no_draw: u64 = 0, + rejected_not_ready: u64 = 0, + rejected_missing_region: u64 = 0, + rejected_not_renderable: u64 = 0, + rejected_finer_coverage: u64 = 0, + rejected_range: u64 = 0, + rejected_frustum: u64 = 0, + rejected_chunk_coverage: u64 = 0, + coverage_checks: u64 = 0, }; diff --git a/modules/game-core/src/benchmark.zig b/modules/game-core/src/benchmark.zig index 2a899100..5f096d9e 100644 --- a/modules/game-core/src/benchmark.zig +++ b/modules/game-core/src/benchmark.zig @@ -5,6 +5,10 @@ const Player = @import("player.zig").Player; const Vec3 = @import("engine-math").Vec3; const GpuTimingResults = @import("engine-rhi").GpuTimingResults; const WorldStats = @import("engine-ui").WorldStats; +const LODStatsDisplay = @import("engine-ui").LODStatsDisplay; +const LODProfilingDisplay = @import("engine-ui").LODProfilingDisplay; +const LODVisibilityLevelDisplay = @import("engine-ui").LODVisibilityLevelDisplay; +const LOD_VISIBILITY_LEVEL_COUNT = @import("engine-ui").LOD_VISIBILITY_LEVEL_COUNT; pub const Waypoint = struct { pos: Vec3, @@ -12,6 +16,37 @@ pub const Waypoint = struct { duration: f32, }; +pub const BENCHMARK_WORLD_SEED: u64 = 12345; + +pub const Scenario = enum { + stationary, + traversal, + rapid_turn, + teleport_eviction, + + pub fn parse(value: []const u8) !Scenario { + if (std.mem.eql(u8, value, "stationary")) return .stationary; + if (std.mem.eql(u8, value, "traversal")) return .traversal; + if (std.mem.eql(u8, value, "rapid-turn")) return .rapid_turn; + if (std.mem.eql(u8, value, "teleport-eviction")) return .teleport_eviction; + return error.InvalidBenchmarkScenario; + } + + pub fn name(self: Scenario) []const u8 { + return switch (self) { + .stationary => "stationary", + .traversal => "traversal", + .rapid_turn => "rapid-turn", + .teleport_eviction => "teleport-eviction", + }; + } +}; + +const Pose = struct { + pos: Vec3, + look: Vec3, +}; + pub const BENCH_PATH = [_]Waypoint{ .{ .pos = Vec3.init(8, 100, 8), .look = Vec3.init(1, 0, 0), .duration = 5.0 }, .{ .pos = Vec3.init(200, 150, 200), .look = Vec3.init(0, -0.3, 1), .duration = 10.0 }, @@ -21,16 +56,82 @@ pub const BENCH_PATH = [_]Waypoint{ .{ .pos = Vec3.init(900, 160, 700), .look = Vec3.init(0.3, -0.9, -0.1), .duration = 15.0 }, }; +const STATIONARY_PATH = [_]Waypoint{ + .{ .pos = Vec3.init(8, 100, 8), .look = Vec3.init(1, -0.1, 0), .duration = 60.0 }, +}; + +const RAPID_TURN_PATH = [_]Waypoint{ + .{ .pos = Vec3.init(8, 100, 8), .look = Vec3.init(1, 0, 0), .duration = 1.0 }, + .{ .pos = Vec3.init(8, 100, 8), .look = Vec3.init(0.2, -0.2, 1), .duration = 1.0 }, + .{ .pos = Vec3.init(8, 100, 8), .look = Vec3.init(-1, 0.1, 0), .duration = 1.0 }, + .{ .pos = Vec3.init(8, 100, 8), .look = Vec3.init(-0.2, 0.2, -1), .duration = 1.0 }, +}; + +const TELEPORT_EVICTION_PATH = [_]Waypoint{ + .{ .pos = Vec3.init(8, 100, 8), .look = Vec3.init(1, -0.1, 0), .duration = 4.0 }, + .{ .pos = Vec3.init(1_536, 140, 512), .look = Vec3.init(-1, -0.2, 0), .duration = 4.0 }, + .{ .pos = Vec3.init(-1_536, 120, 1_024), .look = Vec3.init(0, -0.1, -1), .duration = 4.0 }, + .{ .pos = Vec3.init(-1_024, 160, -1_536), .look = Vec3.init(1, -0.3, 0.2), .duration = 4.0 }, + .{ .pos = Vec3.init(1_024, 110, -1_024), .look = Vec3.init(0.1, -0.1, 1), .duration = 4.0 }, +}; + pub const FrameSample = struct { cpu_ms: f32, fps: f32, gpu_shadow_ms: f32, gpu_opaque_ms: f32, + gpu_lod_terrain_ms: f32, + gpu_lod_water_ms: f32, + gpu_lod_culling_ms: f32, gpu_total_ms: f32, draw_calls: u32, vertices: u64, chunks_rendered: u32, gpu_memory_mb: f32, + lod: LODFrameSample = .{}, +}; + +/// One benchmark-frame delta derived from world-lod telemetry. Timing and event +/// fields are cumulative counters; every byte-accounting field is a gauge. +pub const LODFrameSample = struct { + enabled: bool = false, + cpu_ms: f64 = 0, + scheduling_ms: f64 = 0, + cache_ms: f64 = 0, + generation_dispatch_ms: f64 = 0, + state_transition_ms: f64 = 0, + upload_prep_ms: f64 = 0, + upload_submission_ms: f64 = 0, + visibility_ms: f64 = 0, + coverage_ms: f64 = 0, + eviction_ms: f64 = 0, + worker_generation_ms: f64 = 0, + worker_mesh_construction_ms: f64 = 0, + manager_lock_wait_ms: f64 = 0, + manager_lock_hold_ms: f64 = 0, + upload_bytes: u64 = 0, + pending_cpu_upload_bytes: u64 = 0, + staging_pressure_count: u64 = 0, + visible_count: u64 = 0, + rejected_count: u64 = 0, + coverage_count: u64 = 0, + visibility_levels: [LOD_VISIBILITY_LEVEL_COUNT]LODVisibilityLevelDisplay = [_]LODVisibilityLevelDisplay{.{}} ** LOD_VISIBILITY_LEVEL_COUNT, + deferred_deletion_bytes: u64 = 0, + deferred_deletion_cpu_bytes: u64 = 0, + pool_gpu_capacity_bytes: u64 = 0, + pool_gpu_allocated_bytes: u64 = 0, + pool_gpu_slack_bytes: u64 = 0, + pool_cpu_shadow_bytes: u64 = 0, + compact_pool_capacity_bytes: u64 = 0, + compact_pool_allocated_bytes: u64 = 0, + compact_pool_free_bytes: u64 = 0, + compact_pool_retired_bytes: u64 = 0, + direct_mesh_gpu_bytes: u64 = 0, + known_memory_bytes: u64 = 0, + wait_idle_count: u64 = 0, + wait_idle_ms: f64 = 0, + gpu_culling_overflows: u32 = 0, + gpu_culling_validation_mismatches: u32 = 0, }; pub const SloThresholds = struct { @@ -55,49 +156,202 @@ pub const Summary = struct { pub const GpuSummary = struct { shadow_avg: f64, opaque_avg: f64, + lod_terrain_avg: f64, + lod_water_avg: f64, + lod_culling_avg: f64, total_avg: f64, }; +pub const TimingTotalAverage = struct { + total_ms: f64, + avg_ms: f64, +}; + +pub const LODCpuCategories = struct { + scheduling: TimingTotalAverage, + cache: TimingTotalAverage, + generation_dispatch: TimingTotalAverage, + state_transition: TimingTotalAverage, + upload_prep: TimingTotalAverage, + upload_submission: TimingTotalAverage, + visibility: TimingTotalAverage, + coverage: TimingTotalAverage, + eviction: TimingTotalAverage, + manager_lock_wait: TimingTotalAverage, + manager_lock_hold: TimingTotalAverage, +}; + +pub const LODWorkerSummary = struct { + generation_total_ms: f64, + mesh_construction_total_ms: f64, +}; + +pub const ByteGaugeSummary = struct { + avg_bytes: f64, + max_bytes: u64, + last_bytes: u64, +}; + +const ByteGaugeAccumulator = struct { + sum: f64 = 0, + max: u64 = 0, + last: u64 = 0, + + fn add(self: *ByteGaugeAccumulator, value: u64) void { + self.sum += @floatFromInt(value); + self.max = @max(self.max, value); + self.last = value; + } + + fn summarize(self: ByteGaugeAccumulator, frame_count: f64) ByteGaugeSummary { + return .{ + .avg_bytes = self.sum / frame_count, + .max_bytes = self.max, + .last_bytes = self.last, + }; + } +}; + +pub const LODMemorySummary = struct { + upload_total_bytes: u64, + upload_avg_bytes: f64, + pending_cpu_upload_bytes: ByteGaugeSummary, + /// Compatibility alias for `deferred_deletion_gpu_bytes`. + deferred_deletion_bytes: ByteGaugeSummary, + deferred_deletion_gpu_bytes: ByteGaugeSummary, + deferred_deletion_cpu_bytes: ByteGaugeSummary, + pool_gpu_capacity_bytes: ByteGaugeSummary, + pool_gpu_allocated_bytes: ByteGaugeSummary, + pool_gpu_slack_bytes: ByteGaugeSummary, + pool_cpu_shadow_bytes: ByteGaugeSummary, + compact_pool_capacity_bytes: ByteGaugeSummary, + compact_pool_allocated_bytes: ByteGaugeSummary, + compact_pool_free_bytes: ByteGaugeSummary, + compact_pool_retired_bytes: ByteGaugeSummary, + direct_mesh_gpu_bytes: ByteGaugeSummary, + known_memory_bytes: ByteGaugeSummary, +}; + +pub const LODVisibilitySummary = struct { + visible_total: u64, + rejected_total: u64, + coverage_total: u64, + levels: [LOD_VISIBILITY_LEVEL_COUNT]LODVisibilityLevelDisplay, +}; + +pub const LODPressureSummary = struct { + staging_pressure_total: u64, + wait_idle_count_total: u64, + wait_idle_ms_total: f64, + wait_idle_ms_avg: f64, + gpu_culling_overflows_max: u32 = 0, + gpu_culling_validation_mismatches_max: u32 = 0, +}; + +pub const LODBenchmarkSummary = struct { + profiling_enabled: bool, + cpu_frame_ms: Summary, + cpu_categories: LODCpuCategories, + workers: LODWorkerSummary, + memory_bytes: LODMemorySummary, + visibility: LODVisibilitySummary, + pressure: LODPressureSummary, +}; + +pub const WorstFrame = struct { + frame_index: u32, + frame_ms: f64, + gpu_total_ms: f64, + gpu_lod_terrain_ms: f64, + gpu_lod_water_ms: f64, + gpu_lod_culling_ms: f64, + dominant_gpu_pass: []const u8, + dominant_gpu_pass_ms: f64, + lod_cpu_ms: f64, + dominant_lod_cpu_category: []const u8, + dominant_lod_cpu_category_ms: f64, + lod_worker_generation_ms: f64, + lod_worker_mesh_construction_ms: f64, + lod_upload_bytes: u64, + lod_pending_cpu_upload_bytes: u64, + lod_deferred_deletion_bytes: u64, + lod_visible_count: u64, + lod_rejected_count: u64, + lod_coverage_count: u64, + lod_wait_idle_count: u64, + lod_wait_idle_ms: f64, + lod_staging_pressure_count: u64, +}; + pub const BenchmarkResults = struct { preset: []const u8, + scenario: []const u8, + world_seed: u64, + build: BuildMetadata, render_distance: i32, gpu_memory_mb_avg: f64, gpu_memory_mb_max: f64, frames: u32, duration_s: f32, fps: Summary, + frame_ms: Summary, max_frame_ms: f64, cpu_ms_avg: f64, gpu_ms: GpuSummary, draw_calls_avg: f64, vertices_avg: f64, chunks_rendered_avg: f64, + worst_frame: WorstFrame, + lod: LODBenchmarkSummary, +}; + +pub const BuildMetadata = struct { + mode: []const u8, + headless: bool = true, + resolution: [2]u32 = .{ 1920, 1080 }, }; pub const BenchmarkRunner = struct { allocator: std.mem.Allocator, preset: []const u8, + scenario: Scenario, render_distance: i32, duration_s: f32, + world_seed: u64, + build: BuildMetadata, output_path: []const u8, start_ms: i64, elapsed_s: f32 = 0, sampled_s: f32 = 0, warmup_s: f32 = 1.0, samples: std.ArrayListUnmanaged(FrameSample) = .empty, + previous_lod_profiling: ?LODProfilingDisplay = null, - pub fn init(allocator: std.mem.Allocator, preset: []const u8, render_distance: i32, duration_s: f32, output_path: []const u8) !BenchmarkRunner { + pub fn init( + allocator: std.mem.Allocator, + preset: []const u8, + scenario_name: []const u8, + render_distance: i32, + duration_s: f32, + world_seed: u64, + build_mode: []const u8, + output_path: []const u8, + ) !BenchmarkRunner { var runner = BenchmarkRunner{ .allocator = allocator, .preset = preset, + .scenario = try Scenario.parse(scenario_name), .render_distance = render_distance, .duration_s = duration_s, + .world_seed = world_seed, + .build = .{ .mode = build_mode }, .output_path = output_path, .start_ms = nowMs(), .elapsed_s = 0, .sampled_s = 0, .warmup_s = 1.0, .samples = .empty, + .previous_lod_profiling = null, }; const estimate_frames = @max(@as(usize, 64), @as(usize, @intFromFloat(@ceil(duration_s * 120.0)))); @@ -110,7 +364,7 @@ pub const BenchmarkRunner = struct { } pub fn applyPose(self: *const BenchmarkRunner, player: *Player) void { - const pose = poseAtTime(@max(self.elapsed_s - self.warmup_s, 0.0)); + const pose = poseAtTime(self.scenario, @max(self.elapsed_s - self.warmup_s, 0.0)); player.fly_mode = true; player.can_fly = true; player.noclip = true; @@ -124,6 +378,11 @@ pub const BenchmarkRunner = struct { pub fn recordFrame(self: *BenchmarkRunner, dt: f32, fps: f32, gpu: GpuTimingResults, world_stats: ?WorldStats, draw_calls: u32, gpu_memory_mb: f32) !void { self.elapsed_s += dt; + const lod_stats = if (world_stats) |ws| blk: { + if (ws.lod) |ls| break :blk ls; + break :blk null; + } else null; + const lod = lodFrameDelta(lod_stats, &self.previous_lod_profiling); if (self.elapsed_s < self.warmup_s) return; const shadow_avg = averageArray(&gpu.shadow_pass_ms); @@ -136,11 +395,15 @@ pub const BenchmarkRunner = struct { .fps = frame_fps, .gpu_shadow_ms = shadow_avg, .gpu_opaque_ms = gpu.opaque_pass_ms, + .gpu_lod_terrain_ms = gpu.lod_terrain_pass_ms, + .gpu_lod_water_ms = gpu.lod_water_pass_ms, + .gpu_lod_culling_ms = gpu.lod_culling_compute_ms, .gpu_total_ms = gpu.total_gpu_ms, .draw_calls = draw_calls, .vertices = vertices, .chunks_rendered = chunks_rendered, .gpu_memory_mb = gpu_memory_mb, + .lod = lod, }); self.sampled_s += dt; } @@ -168,10 +431,17 @@ pub const BenchmarkRunner = struct { pub fn makeResults(self: *const BenchmarkRunner) !BenchmarkResults { const fps_values = try self.collectField(fpsField); defer self.allocator.free(fps_values); + const frame_ms_values = try self.collectField(frameMsField); + defer self.allocator.free(frame_ms_values); + const lod_cpu_values = try self.collectLodCpuValues(); + defer self.allocator.free(lod_cpu_values); var cpu_sum: f64 = 0; var shadow_sum: f64 = 0; var opaque_sum: f64 = 0; + var lod_terrain_sum: f64 = 0; + var lod_water_sum: f64 = 0; + var lod_culling_sum: f64 = 0; var total_sum: f64 = 0; var draw_sum: f64 = 0; var vertices_sum: f64 = 0; @@ -179,22 +449,130 @@ pub const BenchmarkRunner = struct { var memory_sum: f64 = 0; var memory_max: f64 = 0; var max_frame_ms: f64 = 0; + var lod_profiling_enabled = false; + var lod_scheduling_ms: f64 = 0; + var lod_cache_ms: f64 = 0; + var lod_generation_dispatch_ms: f64 = 0; + var lod_state_transition_ms: f64 = 0; + var lod_upload_prep_ms: f64 = 0; + var lod_upload_submission_ms: f64 = 0; + var lod_visibility_ms: f64 = 0; + var lod_coverage_ms: f64 = 0; + var lod_eviction_ms: f64 = 0; + var lod_worker_generation_ms: f64 = 0; + var lod_worker_mesh_construction_ms: f64 = 0; + var lod_manager_lock_wait_ms: f64 = 0; + var lod_manager_lock_hold_ms: f64 = 0; + var lod_upload_bytes: u64 = 0; + var lod_pending_cpu_upload_bytes = ByteGaugeAccumulator{}; + var lod_deferred_deletion_gpu_bytes = ByteGaugeAccumulator{}; + var lod_deferred_deletion_cpu_bytes = ByteGaugeAccumulator{}; + var lod_pool_gpu_capacity_bytes = ByteGaugeAccumulator{}; + var lod_pool_gpu_allocated_bytes = ByteGaugeAccumulator{}; + var lod_pool_gpu_slack_bytes = ByteGaugeAccumulator{}; + var lod_pool_cpu_shadow_bytes = ByteGaugeAccumulator{}; + var lod_compact_pool_capacity_bytes = ByteGaugeAccumulator{}; + var lod_compact_pool_allocated_bytes = ByteGaugeAccumulator{}; + var lod_compact_pool_free_bytes = ByteGaugeAccumulator{}; + var lod_compact_pool_retired_bytes = ByteGaugeAccumulator{}; + var lod_direct_mesh_gpu_bytes = ByteGaugeAccumulator{}; + var lod_known_memory_bytes = ByteGaugeAccumulator{}; + var lod_staging_pressure_count: u64 = 0; + var lod_visible_count: u64 = 0; + var lod_rejected_count: u64 = 0; + var lod_coverage_count: u64 = 0; + var lod_visibility_levels: [LOD_VISIBILITY_LEVEL_COUNT]LODVisibilityLevelDisplay = [_]LODVisibilityLevelDisplay{.{}} ** LOD_VISIBILITY_LEVEL_COUNT; + var lod_wait_idle_count: u64 = 0; + var lod_wait_idle_ms: f64 = 0; + var lod_gpu_culling_overflows_max: u32 = 0; + var lod_gpu_culling_validation_mismatches_max: u32 = 0; + var worst_frame = WorstFrame{ + .frame_index = 0, + .frame_ms = 0, + .gpu_total_ms = 0, + .gpu_lod_terrain_ms = 0, + .gpu_lod_water_ms = 0, + .gpu_lod_culling_ms = 0, + .dominant_gpu_pass = "none", + .dominant_gpu_pass_ms = 0, + .lod_cpu_ms = 0, + .dominant_lod_cpu_category = "none", + .dominant_lod_cpu_category_ms = 0, + .lod_worker_generation_ms = 0, + .lod_worker_mesh_construction_ms = 0, + .lod_upload_bytes = 0, + .lod_pending_cpu_upload_bytes = 0, + .lod_deferred_deletion_bytes = 0, + .lod_visible_count = 0, + .lod_rejected_count = 0, + .lod_coverage_count = 0, + .lod_wait_idle_count = 0, + .lod_wait_idle_ms = 0, + .lod_staging_pressure_count = 0, + }; - for (self.samples.items) |sample| { + for (self.samples.items, 0..) |sample, index| { cpu_sum += sample.cpu_ms; - max_frame_ms = @max(max_frame_ms, sample.cpu_ms); + if (sample.cpu_ms > max_frame_ms) { + max_frame_ms = sample.cpu_ms; + worst_frame = worstFrameForSample(index, sample); + } shadow_sum += sample.gpu_shadow_ms; opaque_sum += sample.gpu_opaque_ms; + lod_terrain_sum += sample.gpu_lod_terrain_ms; + lod_water_sum += sample.gpu_lod_water_ms; + lod_culling_sum += sample.gpu_lod_culling_ms; total_sum += sample.gpu_total_ms; draw_sum += @floatFromInt(sample.draw_calls); vertices_sum += @floatFromInt(sample.vertices); chunks_sum += @floatFromInt(sample.chunks_rendered); memory_sum += sample.gpu_memory_mb; memory_max = @max(memory_max, sample.gpu_memory_mb); + + const lod = sample.lod; + lod_profiling_enabled = lod_profiling_enabled or lod.enabled; + lod_scheduling_ms += lod.scheduling_ms; + lod_cache_ms += lod.cache_ms; + lod_generation_dispatch_ms += lod.generation_dispatch_ms; + lod_state_transition_ms += lod.state_transition_ms; + lod_upload_prep_ms += lod.upload_prep_ms; + lod_upload_submission_ms += lod.upload_submission_ms; + lod_visibility_ms += lod.visibility_ms; + lod_coverage_ms += lod.coverage_ms; + lod_eviction_ms += lod.eviction_ms; + lod_worker_generation_ms += lod.worker_generation_ms; + lod_worker_mesh_construction_ms += lod.worker_mesh_construction_ms; + lod_manager_lock_wait_ms += lod.manager_lock_wait_ms; + lod_manager_lock_hold_ms += lod.manager_lock_hold_ms; + lod_upload_bytes +|= lod.upload_bytes; + lod_pending_cpu_upload_bytes.add(lod.pending_cpu_upload_bytes); + lod_deferred_deletion_gpu_bytes.add(lod.deferred_deletion_bytes); + lod_deferred_deletion_cpu_bytes.add(lod.deferred_deletion_cpu_bytes); + lod_pool_gpu_capacity_bytes.add(lod.pool_gpu_capacity_bytes); + lod_pool_gpu_allocated_bytes.add(lod.pool_gpu_allocated_bytes); + lod_pool_gpu_slack_bytes.add(lod.pool_gpu_slack_bytes); + lod_pool_cpu_shadow_bytes.add(lod.pool_cpu_shadow_bytes); + lod_compact_pool_capacity_bytes.add(lod.compact_pool_capacity_bytes); + lod_compact_pool_allocated_bytes.add(lod.compact_pool_allocated_bytes); + lod_compact_pool_free_bytes.add(lod.compact_pool_free_bytes); + lod_compact_pool_retired_bytes.add(lod.compact_pool_retired_bytes); + lod_direct_mesh_gpu_bytes.add(lod.direct_mesh_gpu_bytes); + lod_known_memory_bytes.add(lod.known_memory_bytes); + lod_staging_pressure_count +|= lod.staging_pressure_count; + lod_visible_count +|= lod.visible_count; + lod_rejected_count +|= lod.rejected_count; + lod_coverage_count +|= lod.coverage_count; + for (&lod_visibility_levels, lod.visibility_levels) |*total, level| addVisibilityLevel(total, level); + lod_wait_idle_count +|= lod.wait_idle_count; + lod_wait_idle_ms += lod.wait_idle_ms; + lod_gpu_culling_overflows_max = @max(lod_gpu_culling_overflows_max, lod.gpu_culling_overflows); + lod_gpu_culling_validation_mismatches_max = @max(lod_gpu_culling_validation_mismatches_max, lod.gpu_culling_validation_mismatches); } const count = @as(f64, @floatFromInt(@max(self.samples.items.len, 1))); var fps_summary = try summarizeSeries(self.allocator, fps_values); + const frame_ms_summary = try summarizeSeries(self.allocator, frame_ms_values); + const lod_cpu_summary = try summarizeSeries(self.allocator, lod_cpu_values); const sampled_s = cpu_sum / 1000.0; if (sampled_s > 0.0) { fps_summary.avg = @as(f64, @floatFromInt(self.samples.items.len)) / sampled_s; @@ -202,22 +580,83 @@ pub const BenchmarkRunner = struct { return .{ .preset = self.preset, + .scenario = self.scenario.name(), + .world_seed = self.world_seed, + .build = self.build, .render_distance = self.render_distance, .gpu_memory_mb_avg = memory_sum / count, .gpu_memory_mb_max = memory_max, .frames = @intCast(self.samples.items.len), .duration_s = self.duration_s, .fps = fps_summary, + .frame_ms = frame_ms_summary, .max_frame_ms = max_frame_ms, .cpu_ms_avg = cpu_sum / count, .gpu_ms = .{ .shadow_avg = shadow_sum / count, .opaque_avg = opaque_sum / count, + .lod_terrain_avg = lod_terrain_sum / count, + .lod_water_avg = lod_water_sum / count, + .lod_culling_avg = lod_culling_sum / count, .total_avg = total_sum / count, }, .draw_calls_avg = draw_sum / count, .vertices_avg = vertices_sum / count, .chunks_rendered_avg = chunks_sum / count, + .worst_frame = worst_frame, + .lod = .{ + .profiling_enabled = lod_profiling_enabled, + .cpu_frame_ms = lod_cpu_summary, + .cpu_categories = .{ + .scheduling = timingTotalAverage(lod_scheduling_ms, count), + .cache = timingTotalAverage(lod_cache_ms, count), + .generation_dispatch = timingTotalAverage(lod_generation_dispatch_ms, count), + .state_transition = timingTotalAverage(lod_state_transition_ms, count), + .upload_prep = timingTotalAverage(lod_upload_prep_ms, count), + .upload_submission = timingTotalAverage(lod_upload_submission_ms, count), + .visibility = timingTotalAverage(lod_visibility_ms, count), + .coverage = timingTotalAverage(lod_coverage_ms, count), + .eviction = timingTotalAverage(lod_eviction_ms, count), + .manager_lock_wait = timingTotalAverage(lod_manager_lock_wait_ms, count), + .manager_lock_hold = timingTotalAverage(lod_manager_lock_hold_ms, count), + }, + .workers = .{ + .generation_total_ms = lod_worker_generation_ms, + .mesh_construction_total_ms = lod_worker_mesh_construction_ms, + }, + .memory_bytes = .{ + .upload_total_bytes = lod_upload_bytes, + .upload_avg_bytes = @as(f64, @floatFromInt(lod_upload_bytes)) / count, + .pending_cpu_upload_bytes = lod_pending_cpu_upload_bytes.summarize(count), + .deferred_deletion_bytes = lod_deferred_deletion_gpu_bytes.summarize(count), + .deferred_deletion_gpu_bytes = lod_deferred_deletion_gpu_bytes.summarize(count), + .deferred_deletion_cpu_bytes = lod_deferred_deletion_cpu_bytes.summarize(count), + .pool_gpu_capacity_bytes = lod_pool_gpu_capacity_bytes.summarize(count), + .pool_gpu_allocated_bytes = lod_pool_gpu_allocated_bytes.summarize(count), + .pool_gpu_slack_bytes = lod_pool_gpu_slack_bytes.summarize(count), + .pool_cpu_shadow_bytes = lod_pool_cpu_shadow_bytes.summarize(count), + .compact_pool_capacity_bytes = lod_compact_pool_capacity_bytes.summarize(count), + .compact_pool_allocated_bytes = lod_compact_pool_allocated_bytes.summarize(count), + .compact_pool_free_bytes = lod_compact_pool_free_bytes.summarize(count), + .compact_pool_retired_bytes = lod_compact_pool_retired_bytes.summarize(count), + .direct_mesh_gpu_bytes = lod_direct_mesh_gpu_bytes.summarize(count), + .known_memory_bytes = lod_known_memory_bytes.summarize(count), + }, + .visibility = .{ + .visible_total = lod_visible_count, + .rejected_total = lod_rejected_count, + .coverage_total = lod_coverage_count, + .levels = lod_visibility_levels, + }, + .pressure = .{ + .staging_pressure_total = lod_staging_pressure_count, + .wait_idle_count_total = lod_wait_idle_count, + .wait_idle_ms_total = lod_wait_idle_ms, + .wait_idle_ms_avg = lod_wait_idle_ms / count, + .gpu_culling_overflows_max = lod_gpu_culling_overflows_max, + .gpu_culling_validation_mismatches_max = lod_gpu_culling_validation_mismatches_max, + }, + }, }; } @@ -229,6 +668,14 @@ pub const BenchmarkRunner = struct { return values; } + fn collectLodCpuValues(self: *const BenchmarkRunner) ![]f32 { + var values = try self.allocator.alloc(f32, self.samples.items.len); + for (self.samples.items, 0..) |sample, i| { + values[i] = @floatCast(sample.lod.cpu_ms); + } + return values; + } + fn results_json(results: BenchmarkResults, allocator: std.mem.Allocator) ![]u8 { return try std.json.Stringify.valueAlloc(allocator, results, .{ .whitespace = .indent_2 }); } @@ -303,18 +750,84 @@ test "benchmark draw-call SLO scales for render-distance override" { const base = thresholdsForPreset("medium"); const results = BenchmarkResults{ .preset = "medium", + .scenario = "traversal", + .world_seed = BENCHMARK_WORLD_SEED, + .build = .{ .mode = "Debug" }, .render_distance = 22, .gpu_memory_mb_avg = 0, .gpu_memory_mb_max = 0, .frames = 0, .duration_s = 0, .fps = .{ .min = 0, .avg = 0, .max = 0, .p1 = 0, .p5 = 0, .p50 = 0, .p95 = 0, .p99 = 0 }, + .frame_ms = .{ .min = 0, .avg = 0, .max = 0, .p1 = 0, .p5 = 0, .p50 = 0, .p95 = 0, .p99 = 0 }, .max_frame_ms = 0, .cpu_ms_avg = 0, - .gpu_ms = .{ .shadow_avg = 0, .opaque_avg = 0, .total_avg = 0 }, + .gpu_ms = .{ .shadow_avg = 0, .opaque_avg = 0, .lod_terrain_avg = 0, .lod_water_avg = 0, .lod_culling_avg = 0, .total_avg = 0 }, .draw_calls_avg = 0, .vertices_avg = 0, .chunks_rendered_avg = 0, + .worst_frame = .{ + .frame_index = 0, + .frame_ms = 0, + .gpu_total_ms = 0, + .gpu_lod_terrain_ms = 0, + .gpu_lod_water_ms = 0, + .gpu_lod_culling_ms = 0, + .dominant_gpu_pass = "none", + .dominant_gpu_pass_ms = 0, + .lod_cpu_ms = 0, + .dominant_lod_cpu_category = "none", + .dominant_lod_cpu_category_ms = 0, + .lod_worker_generation_ms = 0, + .lod_worker_mesh_construction_ms = 0, + .lod_upload_bytes = 0, + .lod_pending_cpu_upload_bytes = 0, + .lod_deferred_deletion_bytes = 0, + .lod_visible_count = 0, + .lod_rejected_count = 0, + .lod_coverage_count = 0, + .lod_wait_idle_count = 0, + .lod_wait_idle_ms = 0, + .lod_staging_pressure_count = 0, + }, + .lod = .{ + .profiling_enabled = false, + .cpu_frame_ms = .{ .min = 0, .avg = 0, .max = 0, .p1 = 0, .p5 = 0, .p50 = 0, .p95 = 0, .p99 = 0 }, + .cpu_categories = .{ + .scheduling = .{ .total_ms = 0, .avg_ms = 0 }, + .cache = .{ .total_ms = 0, .avg_ms = 0 }, + .generation_dispatch = .{ .total_ms = 0, .avg_ms = 0 }, + .state_transition = .{ .total_ms = 0, .avg_ms = 0 }, + .upload_prep = .{ .total_ms = 0, .avg_ms = 0 }, + .upload_submission = .{ .total_ms = 0, .avg_ms = 0 }, + .visibility = .{ .total_ms = 0, .avg_ms = 0 }, + .coverage = .{ .total_ms = 0, .avg_ms = 0 }, + .eviction = .{ .total_ms = 0, .avg_ms = 0 }, + .manager_lock_wait = .{ .total_ms = 0, .avg_ms = 0 }, + .manager_lock_hold = .{ .total_ms = 0, .avg_ms = 0 }, + }, + .workers = .{ .generation_total_ms = 0, .mesh_construction_total_ms = 0 }, + .memory_bytes = .{ + .upload_total_bytes = 0, + .upload_avg_bytes = 0, + .pending_cpu_upload_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + .deferred_deletion_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + .deferred_deletion_gpu_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + .deferred_deletion_cpu_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + .pool_gpu_capacity_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + .pool_gpu_allocated_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + .pool_gpu_slack_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + .pool_cpu_shadow_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + .compact_pool_capacity_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + .compact_pool_allocated_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + .compact_pool_free_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + .compact_pool_retired_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + .direct_mesh_gpu_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + .known_memory_bytes = .{ .avg_bytes = 0, .max_bytes = 0, .last_bytes = 0 }, + }, + .visibility = .{ .visible_total = 0, .rejected_total = 0, .coverage_total = 0, .levels = [_]LODVisibilityLevelDisplay{.{}} ** LOD_VISIBILITY_LEVEL_COUNT }, + .pressure = .{ .staging_pressure_total = 0, .wait_idle_count_total = 0, .wait_idle_ms_total = 0, .wait_idle_ms_avg = 0 }, + }, }; const adjusted = thresholdsForResults(results); @@ -323,10 +836,508 @@ test "benchmark draw-call SLO scales for render-distance override" { try std.testing.expectEqual(base.fps_p1_min, adjusted.fps_p1_min); } +test "benchmark reports LOD GPU timing, frame percentiles, and worst-frame attribution" { + var runner = try BenchmarkRunner.init(std.testing.allocator, "medium", "traversal", 12, 1, BENCHMARK_WORLD_SEED, "Debug", "unused.json"); + defer runner.deinit(); + + try runner.samples.append(std.testing.allocator, .{ + .cpu_ms = 10, + .fps = 100, + .gpu_shadow_ms = 1, + .gpu_opaque_ms = 2, + .gpu_lod_terrain_ms = 3, + .gpu_lod_water_ms = 4, + .gpu_lod_culling_ms = 2, + .gpu_total_ms = 8, + .draw_calls = 10, + .vertices = 100, + .chunks_rendered = 1, + .gpu_memory_mb = 100, + }); + try runner.samples.append(std.testing.allocator, .{ + .cpu_ms = 20, + .fps = 50, + .gpu_shadow_ms = 1, + .gpu_opaque_ms = 2, + .gpu_lod_terrain_ms = 9, + .gpu_lod_water_ms = 4, + .gpu_lod_culling_ms = 5, + .gpu_total_ms = 16, + .draw_calls = 20, + .vertices = 200, + .chunks_rendered = 2, + .gpu_memory_mb = 200, + }); + + const results = try runner.makeResults(); + try std.testing.expectApproxEqAbs(@as(f64, 15), results.frame_ms.p50, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 19.5), results.frame_ms.p95, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 8), results.gpu_ms.lod_terrain_avg, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 4), results.gpu_ms.lod_water_avg, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 3.5), results.gpu_ms.lod_culling_avg, 0.001); + try std.testing.expectEqual(@as(u32, 1), results.worst_frame.frame_index); + try std.testing.expectEqualStrings("lod_terrain", results.worst_frame.dominant_gpu_pass); + try std.testing.expectApproxEqAbs(@as(f64, 9), results.worst_frame.dominant_gpu_pass_ms, 0.001); + try std.testing.expectEqualStrings("traversal", results.scenario); + try std.testing.expectEqual(BENCHMARK_WORLD_SEED, results.world_seed); + try std.testing.expectEqualStrings("Debug", results.build.mode); +} + +test "benchmark projects LOD profiling deltas and accepts cumulative counter resets" { + const Test = struct { + fn world(profiling: LODProfilingDisplay) WorldStats { + return .{ + .chunks_total = 0, + .chunks_rendered = 0, + .chunks_culled = 0, + .vertices_rendered = 0, + .gen_queue = 0, + .mesh_queue = 0, + .upload_queue = 0, + .lod = .{ + .loaded = .{ 0, 0, 0, 0, 0 }, + .memory_used_mb = 0, + .memory_used_bytes = profiling.known_memory_bytes, + .pool_gpu_capacity_bytes = profiling.pool_gpu_capacity_bytes, + .pool_gpu_allocated_bytes = profiling.pool_gpu_allocated_bytes, + .pool_gpu_slack_bytes = profiling.pool_gpu_slack_bytes, + .pool_cpu_shadow_bytes = profiling.pool_cpu_shadow_bytes, + .compact_pool_capacity_bytes = profiling.compact_pool_capacity_bytes, + .compact_pool_allocated_bytes = profiling.compact_pool_allocated_bytes, + .compact_pool_free_bytes = profiling.compact_pool_free_bytes, + .compact_pool_retired_bytes = profiling.compact_pool_retired_bytes, + .direct_mesh_gpu_bytes = profiling.direct_mesh_gpu_bytes, + .pending_cpu_upload_bytes = profiling.pending_cpu_upload_bytes, + .deferred_deletion_gpu_bytes = profiling.deferred_deletion_bytes, + .deferred_deletion_cpu_bytes = profiling.deferred_deletion_cpu_bytes, + .profiling = profiling, + }, + }; + } + }; + + var runner = try BenchmarkRunner.init(std.testing.allocator, "medium", "traversal", 12, 1, BENCHMARK_WORLD_SEED, "Debug", "unused.json"); + defer runner.deinit(); + runner.warmup_s = 0; + const gpu = std.mem.zeroes(GpuTimingResults); + + try runner.recordFrame(0.01, 100, gpu, Test.world(.{ + .enabled = true, + .update_ms = 10, + .scheduling_ms = 5, + .cache_ms = 1, + .generation_dispatch_ms = 2, + .state_transition_ms = 1, + .upload_prep_ms = 1, + .upload_submission_ms = 1, + .visibility_ms = 1, + .coverage_ms = 1, + .eviction_ms = 1, + .worker_generation_ms = 8, + .worker_mesh_construction_ms = 4, + .manager_lock_wait_ms = 2, + .manager_lock_hold_ms = 4, + .upload_bytes = 100, + .pending_cpu_upload_bytes = 10, + .staging_pressure_count = 3, + .visible_count = 10, + .rejected_count = 4, + .coverage_count = 6, + .visibility_levels = .{ .{}, .{ .candidates = 5, .accepted = 2, .rejected_frustum = 1, .coverage_checks = 4 }, .{}, .{}, .{} }, + .deferred_deletion_bytes = 30, + .deferred_deletion_cpu_bytes = 3, + .pool_gpu_capacity_bytes = 100, + .pool_gpu_allocated_bytes = 80, + .pool_gpu_slack_bytes = 20, + .pool_cpu_shadow_bytes = 100, + .compact_pool_capacity_bytes = 40, + .compact_pool_allocated_bytes = 30, + .compact_pool_free_bytes = 10, + .compact_pool_retired_bytes = 4, + .direct_mesh_gpu_bytes = 50, + .known_memory_bytes = 253, + .wait_idle_count = 5, + .wait_idle_ms = 3, + }), 0, 0); + try runner.recordFrame(0.02, 50, gpu, Test.world(.{ + .enabled = true, + .update_ms = 12, + .scheduling_ms = 7, + .cache_ms = 2, + .generation_dispatch_ms = 3, + .state_transition_ms = 2, + .upload_prep_ms = 3, + .upload_submission_ms = 4, + .visibility_ms = 3, + .coverage_ms = 2, + .eviction_ms = 2, + .worker_generation_ms = 12, + .worker_mesh_construction_ms = 6, + .manager_lock_wait_ms = 5, + .manager_lock_hold_ms = 7, + .upload_bytes = 150, + .pending_cpu_upload_bytes = 20, + .staging_pressure_count = 4, + .visible_count = 13, + .rejected_count = 5, + .coverage_count = 8, + .visibility_levels = .{ .{}, .{ .candidates = 7, .accepted = 3, .rejected_frustum = 2, .coverage_checks = 6 }, .{}, .{}, .{} }, + .deferred_deletion_bytes = 40, + .deferred_deletion_cpu_bytes = 5, + .pool_gpu_capacity_bytes = 120, + .pool_gpu_allocated_bytes = 90, + .pool_gpu_slack_bytes = 30, + .pool_cpu_shadow_bytes = 120, + .compact_pool_capacity_bytes = 50, + .compact_pool_allocated_bytes = 40, + .compact_pool_free_bytes = 10, + .compact_pool_retired_bytes = 5, + .direct_mesh_gpu_bytes = 60, + .known_memory_bytes = 305, + .wait_idle_count = 6, + .wait_idle_ms = 5, + }), 0, 0); + try runner.recordFrame(0.03, 33, gpu, Test.world(.{ + .enabled = true, + .update_ms = 1, + .scheduling_ms = 0.5, + .cache_ms = 4, + .generation_dispatch_ms = 0.25, + .state_transition_ms = 0.5, + .upload_prep_ms = 0.25, + .upload_submission_ms = 0.25, + .visibility_ms = 0.5, + .coverage_ms = 0.25, + .eviction_ms = 0.5, + .worker_generation_ms = 3, + .worker_mesh_construction_ms = 2, + .manager_lock_wait_ms = 1, + .manager_lock_hold_ms = 1, + .upload_bytes = 20, + .pending_cpu_upload_bytes = 5, + .staging_pressure_count = 2, + .visible_count = 2, + .rejected_count = 1, + .coverage_count = 3, + .visibility_levels = .{ .{}, .{ .candidates = 1, .accepted = 1, .rejected_chunk_coverage = 1, .coverage_checks = 1 }, .{}, .{}, .{} }, + .deferred_deletion_bytes = 8, + .deferred_deletion_cpu_bytes = 1, + .pool_gpu_capacity_bytes = 90, + .pool_gpu_allocated_bytes = 70, + .pool_gpu_slack_bytes = 20, + .pool_cpu_shadow_bytes = 90, + .compact_pool_capacity_bytes = 30, + .compact_pool_allocated_bytes = 20, + .compact_pool_free_bytes = 10, + .compact_pool_retired_bytes = 2, + .direct_mesh_gpu_bytes = 40, + .known_memory_bytes = 229, + .wait_idle_count = 2, + .wait_idle_ms = 0.5, + }), 0, 0); + + const results = try runner.makeResults(); + try std.testing.expect(results.lod.profiling_enabled); + try std.testing.expectApproxEqAbs(@as(f64, 1), results.lod.cpu_frame_ms.p50, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 1.9), results.lod.cpu_frame_ms.p95, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 2.5), results.lod.cpu_categories.scheduling.total_ms, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 5), results.lod.cpu_categories.cache.total_ms, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 7), results.lod.workers.generation_total_ms, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 4), results.lod.workers.mesh_construction_total_ms, 0.001); + try std.testing.expectEqual(@as(u64, 70), results.lod.memory_bytes.upload_total_bytes); + try std.testing.expectEqual(@as(u64, 20), results.lod.memory_bytes.pending_cpu_upload_bytes.max_bytes); + try std.testing.expectEqual(@as(u64, 8), results.lod.memory_bytes.deferred_deletion_bytes.last_bytes); + try std.testing.expectEqual(@as(u64, 40), results.lod.memory_bytes.deferred_deletion_gpu_bytes.max_bytes); + try std.testing.expectEqual(@as(u64, 1), results.lod.memory_bytes.deferred_deletion_cpu_bytes.last_bytes); + try std.testing.expectEqual(@as(u64, 120), results.lod.memory_bytes.pool_gpu_capacity_bytes.max_bytes); + try std.testing.expectEqual(@as(u64, 90), results.lod.memory_bytes.pool_gpu_capacity_bytes.last_bytes); + try std.testing.expectEqual(@as(u64, 90), results.lod.memory_bytes.pool_gpu_allocated_bytes.max_bytes); + try std.testing.expectEqual(@as(u64, 30), results.lod.memory_bytes.pool_gpu_slack_bytes.max_bytes); + try std.testing.expectEqual(@as(u64, 120), results.lod.memory_bytes.pool_cpu_shadow_bytes.max_bytes); + try std.testing.expectEqual(@as(u64, 50), results.lod.memory_bytes.compact_pool_capacity_bytes.max_bytes); + try std.testing.expectEqual(@as(u64, 40), results.lod.memory_bytes.compact_pool_allocated_bytes.max_bytes); + try std.testing.expectEqual(@as(u64, 10), results.lod.memory_bytes.compact_pool_free_bytes.max_bytes); + try std.testing.expectEqual(@as(u64, 2), results.lod.memory_bytes.compact_pool_retired_bytes.last_bytes); + try std.testing.expectEqual(@as(u64, 60), results.lod.memory_bytes.direct_mesh_gpu_bytes.max_bytes); + try std.testing.expectEqual(@as(u64, 305), results.lod.memory_bytes.known_memory_bytes.max_bytes); + try std.testing.expectEqual(@as(u64, 229), results.lod.memory_bytes.known_memory_bytes.last_bytes); + try std.testing.expectEqual(@as(u64, 5), results.lod.visibility.visible_total); + try std.testing.expectEqual(@as(u64, 2), results.lod.visibility.rejected_total); + try std.testing.expectEqual(@as(u64, 5), results.lod.visibility.coverage_total); + try std.testing.expectEqual(@as(u64, 3), results.lod.pressure.wait_idle_count_total); + try std.testing.expectApproxEqAbs(@as(f64, 2.5), results.lod.pressure.wait_idle_ms_total, 0.001); + try std.testing.expectEqual(@as(u64, 3), results.lod.pressure.staging_pressure_total); + try std.testing.expectApproxEqAbs(@as(f64, 4), results.lod.cpu_categories.manager_lock_wait.total_ms, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 4), results.lod.cpu_categories.manager_lock_hold.total_ms, 0.001); + try std.testing.expectEqual(@as(u64, 3), results.lod.visibility.levels[1].candidates); + try std.testing.expectEqual(@as(u64, 2), results.lod.visibility.levels[1].accepted); + try std.testing.expectEqual(@as(u64, 1), results.lod.visibility.levels[1].rejected_frustum); + try std.testing.expectEqual(@as(u64, 1), results.lod.visibility.levels[1].rejected_chunk_coverage); + try std.testing.expectEqual(@as(u64, 3), results.lod.visibility.levels[1].coverage_checks); + try std.testing.expectEqual(@as(u32, 2), results.worst_frame.frame_index); + try std.testing.expectEqualStrings("cache", results.worst_frame.dominant_lod_cpu_category); + try std.testing.expectEqual(@as(u64, 20), results.worst_frame.lod_upload_bytes); + + const json = try BenchmarkRunner.results_json(results, std.testing.allocator); + defer std.testing.allocator.free(json); + inline for (.{ + "manager_lock_wait", + "manager_lock_hold", + "pool_gpu_capacity_bytes", + "pool_gpu_allocated_bytes", + "pool_gpu_slack_bytes", + "pool_cpu_shadow_bytes", + "compact_pool_capacity_bytes", + "compact_pool_allocated_bytes", + "compact_pool_free_bytes", + "compact_pool_retired_bytes", + "direct_mesh_gpu_bytes", + "pending_cpu_upload_bytes", + "deferred_deletion_gpu_bytes", + "deferred_deletion_cpu_bytes", + "known_memory_bytes", + }) |field| { + try std.testing.expect(std.mem.indexOf(u8, json, field) != null); + } +} + +test "benchmark scenarios have stable bounded poses" { + try std.testing.expectEqual(Scenario.traversal, try Scenario.parse("traversal")); + try std.testing.expectEqual(Scenario.teleport_eviction, try Scenario.parse("teleport-eviction")); + try std.testing.expectError(error.InvalidBenchmarkScenario, Scenario.parse("unbounded")); + + const stationary_a = poseAtTime(.stationary, 0); + const stationary_b = poseAtTime(.stationary, 37); + try std.testing.expectApproxEqAbs(stationary_a.pos.x, stationary_b.pos.x, 0.0001); + try std.testing.expectApproxEqAbs(stationary_a.pos.z, stationary_b.pos.z, 0.0001); + + const teleport_a = poseAtTime(.teleport_eviction, 3.9); + const teleport_b = poseAtTime(.teleport_eviction, 4.0); + try std.testing.expectApproxEqAbs(@as(f32, 8), teleport_a.pos.x, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 1_536), teleport_b.pos.x, 0.0001); + + const rapid_turn_a = poseAtTime(.rapid_turn, 0); + const rapid_turn_b = poseAtTime(.rapid_turn, 1); + try std.testing.expect(rapid_turn_a.look.z < rapid_turn_b.look.z); +} + fn fpsField(sample: FrameSample) f32 { return sample.fps; } +fn frameMsField(sample: FrameSample) f32 { + return sample.cpu_ms; +} + +fn timingTotalAverage(total_ms: f64, frame_count: f64) TimingTotalAverage { + return .{ .total_ms = total_ms, .avg_ms = total_ms / frame_count }; +} + +/// Calculates a per-frame telemetry sample. A reduced cumulative value means +/// the LOD collector reset, so the current value is used instead of underflowing. +fn lodFrameDelta(current: ?LODStatsDisplay, previous: *?LODProfilingDisplay) LODFrameSample { + const display = current orelse { + previous.* = null; + return .{}; + }; + const snapshot = display.profiling; + const last = previous.*; + previous.* = snapshot; + + const gauges = struct { + pending_cpu_upload_bytes: u64, + deferred_deletion_bytes: u64, + deferred_deletion_cpu_bytes: u64, + pool_gpu_capacity_bytes: u64, + pool_gpu_allocated_bytes: u64, + pool_gpu_slack_bytes: u64, + pool_cpu_shadow_bytes: u64, + compact_pool_capacity_bytes: u64, + compact_pool_allocated_bytes: u64, + compact_pool_free_bytes: u64, + compact_pool_retired_bytes: u64, + direct_mesh_gpu_bytes: u64, + known_memory_bytes: u64, + }{ + .pending_cpu_upload_bytes = display.pending_cpu_upload_bytes, + .deferred_deletion_bytes = display.deferred_deletion_gpu_bytes, + .deferred_deletion_cpu_bytes = display.deferred_deletion_cpu_bytes, + .pool_gpu_capacity_bytes = display.pool_gpu_capacity_bytes, + .pool_gpu_allocated_bytes = display.pool_gpu_allocated_bytes, + .pool_gpu_slack_bytes = display.pool_gpu_slack_bytes, + .pool_cpu_shadow_bytes = display.pool_cpu_shadow_bytes, + .compact_pool_capacity_bytes = display.compact_pool_capacity_bytes, + .compact_pool_allocated_bytes = display.compact_pool_allocated_bytes, + .compact_pool_free_bytes = display.compact_pool_free_bytes, + .compact_pool_retired_bytes = display.compact_pool_retired_bytes, + .direct_mesh_gpu_bytes = display.direct_mesh_gpu_bytes, + .known_memory_bytes = display.memory_used_bytes, + }; + if (!snapshot.enabled or last == null or !last.?.enabled) { + return .{ + .enabled = snapshot.enabled, + .pending_cpu_upload_bytes = gauges.pending_cpu_upload_bytes, + .deferred_deletion_bytes = gauges.deferred_deletion_bytes, + .deferred_deletion_cpu_bytes = gauges.deferred_deletion_cpu_bytes, + .pool_gpu_capacity_bytes = gauges.pool_gpu_capacity_bytes, + .pool_gpu_allocated_bytes = gauges.pool_gpu_allocated_bytes, + .pool_gpu_slack_bytes = gauges.pool_gpu_slack_bytes, + .pool_cpu_shadow_bytes = gauges.pool_cpu_shadow_bytes, + .compact_pool_capacity_bytes = gauges.compact_pool_capacity_bytes, + .compact_pool_allocated_bytes = gauges.compact_pool_allocated_bytes, + .compact_pool_free_bytes = gauges.compact_pool_free_bytes, + .compact_pool_retired_bytes = gauges.compact_pool_retired_bytes, + .direct_mesh_gpu_bytes = gauges.direct_mesh_gpu_bytes, + .known_memory_bytes = gauges.known_memory_bytes, + .gpu_culling_overflows = snapshot.gpu_culling_overflows, + .gpu_culling_validation_mismatches = snapshot.gpu_culling_validation_mismatches, + }; + } + + const prior = last.?; + return .{ + .enabled = true, + // Manager update and render visibility work are separate main-thread + // scopes. Coverage is nested in visibility and is not added again. + .cpu_ms = cumulativeTimingDelta(snapshot.update_ms, prior.update_ms) + cumulativeTimingDelta(snapshot.visibility_ms, prior.visibility_ms), + .scheduling_ms = cumulativeTimingDelta(snapshot.scheduling_ms, prior.scheduling_ms), + .cache_ms = cumulativeTimingDelta(snapshot.cache_ms, prior.cache_ms), + .generation_dispatch_ms = cumulativeTimingDelta(snapshot.generation_dispatch_ms, prior.generation_dispatch_ms), + .state_transition_ms = cumulativeTimingDelta(snapshot.state_transition_ms, prior.state_transition_ms), + .upload_prep_ms = cumulativeTimingDelta(snapshot.upload_prep_ms, prior.upload_prep_ms), + .upload_submission_ms = cumulativeTimingDelta(snapshot.upload_submission_ms, prior.upload_submission_ms), + .visibility_ms = cumulativeTimingDelta(snapshot.visibility_ms, prior.visibility_ms), + .coverage_ms = cumulativeTimingDelta(snapshot.coverage_ms, prior.coverage_ms), + .eviction_ms = cumulativeTimingDelta(snapshot.eviction_ms, prior.eviction_ms), + .worker_generation_ms = cumulativeTimingDelta(snapshot.worker_generation_ms, prior.worker_generation_ms), + .worker_mesh_construction_ms = cumulativeTimingDelta(snapshot.worker_mesh_construction_ms, prior.worker_mesh_construction_ms), + .manager_lock_wait_ms = cumulativeTimingDelta(snapshot.manager_lock_wait_ms, prior.manager_lock_wait_ms), + .manager_lock_hold_ms = cumulativeTimingDelta(snapshot.manager_lock_hold_ms, prior.manager_lock_hold_ms), + .upload_bytes = cumulativeCounterDelta(snapshot.upload_bytes, prior.upload_bytes), + .pending_cpu_upload_bytes = gauges.pending_cpu_upload_bytes, + .staging_pressure_count = cumulativeCounterDelta(snapshot.staging_pressure_count, prior.staging_pressure_count), + .visible_count = cumulativeCounterDelta(snapshot.visible_count, prior.visible_count), + .rejected_count = cumulativeCounterDelta(snapshot.rejected_count, prior.rejected_count), + .coverage_count = cumulativeCounterDelta(snapshot.coverage_count, prior.coverage_count), + .visibility_levels = blk: { + var levels: [LOD_VISIBILITY_LEVEL_COUNT]LODVisibilityLevelDisplay = undefined; + for (&levels, 0..) |*level, index| level.* = visibilityLevelDelta(snapshot.visibility_levels[index], prior.visibility_levels[index]); + break :blk levels; + }, + .deferred_deletion_bytes = gauges.deferred_deletion_bytes, + .deferred_deletion_cpu_bytes = gauges.deferred_deletion_cpu_bytes, + .pool_gpu_capacity_bytes = gauges.pool_gpu_capacity_bytes, + .pool_gpu_allocated_bytes = gauges.pool_gpu_allocated_bytes, + .pool_gpu_slack_bytes = gauges.pool_gpu_slack_bytes, + .pool_cpu_shadow_bytes = gauges.pool_cpu_shadow_bytes, + .compact_pool_capacity_bytes = gauges.compact_pool_capacity_bytes, + .compact_pool_allocated_bytes = gauges.compact_pool_allocated_bytes, + .compact_pool_free_bytes = gauges.compact_pool_free_bytes, + .compact_pool_retired_bytes = gauges.compact_pool_retired_bytes, + .direct_mesh_gpu_bytes = gauges.direct_mesh_gpu_bytes, + .known_memory_bytes = gauges.known_memory_bytes, + .wait_idle_count = cumulativeCounterDelta(snapshot.wait_idle_count, prior.wait_idle_count), + .wait_idle_ms = cumulativeTimingDelta(snapshot.wait_idle_ms, prior.wait_idle_ms), + .gpu_culling_overflows = snapshot.gpu_culling_overflows, + .gpu_culling_validation_mismatches = snapshot.gpu_culling_validation_mismatches, + }; +} + +fn visibilityLevelDelta(current: LODVisibilityLevelDisplay, previous: LODVisibilityLevelDisplay) LODVisibilityLevelDisplay { + var result = LODVisibilityLevelDisplay{}; + inline for (std.meta.fields(LODVisibilityLevelDisplay)) |field| { + @field(result, field.name) = cumulativeCounterDelta(@field(current, field.name), @field(previous, field.name)); + } + return result; +} + +fn addVisibilityLevel(total: *LODVisibilityLevelDisplay, value: LODVisibilityLevelDisplay) void { + inline for (std.meta.fields(LODVisibilityLevelDisplay)) |field| { + @field(total.*, field.name) +|= @field(value, field.name); + } +} + +fn cumulativeTimingDelta(current: f64, previous: f64) f64 { + if (!std.math.isFinite(current) or current < 0) return 0; + if (!std.math.isFinite(previous) or previous < 0 or current < previous) return current; + return current - previous; +} + +fn cumulativeCounterDelta(current: u64, previous: u64) u64 { + return if (current < previous) current else current - previous; +} + +fn worstFrameForSample(index: usize, sample: FrameSample) WorstFrame { + var dominant_gpu_pass: []const u8 = "shadow"; + var dominant_gpu_pass_ms = sample.gpu_shadow_ms; + if (sample.gpu_opaque_ms > dominant_gpu_pass_ms) { + dominant_gpu_pass = "opaque"; + dominant_gpu_pass_ms = sample.gpu_opaque_ms; + } + if (sample.gpu_lod_terrain_ms > dominant_gpu_pass_ms) { + dominant_gpu_pass = "lod_terrain"; + dominant_gpu_pass_ms = sample.gpu_lod_terrain_ms; + } + if (sample.gpu_lod_water_ms > dominant_gpu_pass_ms) { + dominant_gpu_pass = "lod_water"; + dominant_gpu_pass_ms = sample.gpu_lod_water_ms; + } + if (sample.gpu_lod_culling_ms > dominant_gpu_pass_ms) { + dominant_gpu_pass = "lod_culling"; + dominant_gpu_pass_ms = sample.gpu_lod_culling_ms; + } + + const lod_attribution = dominantLodCpuCategory(sample.lod); + + return .{ + .frame_index = @intCast(index), + .frame_ms = sample.cpu_ms, + .gpu_total_ms = sample.gpu_total_ms, + .gpu_lod_terrain_ms = sample.gpu_lod_terrain_ms, + .gpu_lod_water_ms = sample.gpu_lod_water_ms, + .gpu_lod_culling_ms = sample.gpu_lod_culling_ms, + .dominant_gpu_pass = dominant_gpu_pass, + .dominant_gpu_pass_ms = dominant_gpu_pass_ms, + .lod_cpu_ms = sample.lod.cpu_ms, + .dominant_lod_cpu_category = lod_attribution.name, + .dominant_lod_cpu_category_ms = lod_attribution.ms, + .lod_worker_generation_ms = sample.lod.worker_generation_ms, + .lod_worker_mesh_construction_ms = sample.lod.worker_mesh_construction_ms, + .lod_upload_bytes = sample.lod.upload_bytes, + .lod_pending_cpu_upload_bytes = sample.lod.pending_cpu_upload_bytes, + .lod_deferred_deletion_bytes = sample.lod.deferred_deletion_bytes, + .lod_visible_count = sample.lod.visible_count, + .lod_rejected_count = sample.lod.rejected_count, + .lod_coverage_count = sample.lod.coverage_count, + .lod_wait_idle_count = sample.lod.wait_idle_count, + .lod_wait_idle_ms = sample.lod.wait_idle_ms, + .lod_staging_pressure_count = sample.lod.staging_pressure_count, + }; +} + +fn dominantLodCpuCategory(sample: LODFrameSample) struct { name: []const u8, ms: f64 } { + var name: []const u8 = "none"; + var ms: f64 = 0; + const candidates = [_]struct { name: []const u8, ms: f64 }{ + .{ .name = "scheduling", .ms = sample.scheduling_ms }, + .{ .name = "cache", .ms = sample.cache_ms }, + .{ .name = "generation_dispatch", .ms = sample.generation_dispatch_ms }, + .{ .name = "state_transition", .ms = sample.state_transition_ms }, + .{ .name = "upload_prep", .ms = sample.upload_prep_ms }, + .{ .name = "upload_submission", .ms = sample.upload_submission_ms }, + .{ .name = "visibility", .ms = sample.visibility_ms }, + .{ .name = "coverage", .ms = sample.coverage_ms }, + .{ .name = "eviction", .ms = sample.eviction_ms }, + }; + for (candidates) |candidate| { + if (candidate.ms > ms) { + name = candidate.name; + ms = candidate.ms; + } + } + return .{ .name = name, .ms = ms }; +} + fn summarizeSeries(allocator: std.mem.Allocator, values: []f32) !Summary { if (values.len == 0) { return .{ .min = 0, .avg = 0, .max = 0, .p1 = 0, .p5 = 0, .p50 = 0, .p95 = 0, .p99 = 0 }; @@ -374,18 +1385,35 @@ fn averageArray(values: []const f32) f32 { return sum / @as(f32, @floatFromInt(values.len)); } -fn poseAtTime(time_s: f32) struct { pos: Vec3, look: Vec3 } { - const total = pathDuration(); +fn poseAtTime(scenario: Scenario, time_s: f32) Pose { + return switch (scenario) { + .stationary => poseAtWaypoints(&STATIONARY_PATH, time_s, true), + // Keep traversal byte-for-byte equivalent to the benchmark path that + // predated scenarios so its existing baseline remains meaningful. + .traversal => poseAtWaypoints(&BENCH_PATH, time_s, true), + .rapid_turn => poseAtWaypoints(&RAPID_TURN_PATH, time_s, true), + // Eviction pressure requires discontinuous player movement rather + // than interpolation between distant locations. + .teleport_eviction => poseAtWaypoints(&TELEPORT_EVICTION_PATH, time_s, false), + }; +} + +fn poseAtWaypoints(path: []const Waypoint, time_s: f32, interpolate: bool) Pose { + const total = pathDuration(path); if (total <= 0.0) { - return .{ .pos = BENCH_PATH[0].pos, .look = BENCH_PATH[0].look.normalize() }; + return .{ .pos = path[0].pos, .look = path[0].look.normalize() }; } var t = time_s; while (t >= total) t -= total; while (t < 0) t += total; - for (BENCH_PATH, 0..) |waypoint, i| { - const next = BENCH_PATH[(i + 1) % BENCH_PATH.len]; - if (t <= waypoint.duration or i == BENCH_PATH.len - 1) { + for (path, 0..) |waypoint, i| { + const next = path[(i + 1) % path.len]; + const in_segment = if (interpolate) t <= waypoint.duration else t < waypoint.duration; + if (in_segment or i == path.len - 1) { + if (!interpolate) { + return .{ .pos = waypoint.pos, .look = waypoint.look.normalize() }; + } const segment = @max(waypoint.duration, 0.0001); const alpha = std.math.clamp(t / segment, 0.0, 1.0); const eased = alpha * alpha * (3.0 - 2.0 * alpha); @@ -397,12 +1425,12 @@ fn poseAtTime(time_s: f32) struct { pos: Vec3, look: Vec3 } { t -= waypoint.duration; } - return .{ .pos = BENCH_PATH[0].pos, .look = BENCH_PATH[0].look.normalize() }; + return .{ .pos = path[0].pos, .look = path[0].look.normalize() }; } -fn pathDuration() f32 { +fn pathDuration(path: []const Waypoint) f32 { var total: f32 = 0; - for (BENCH_PATH) |waypoint| total += waypoint.duration; + for (path) |waypoint| total += waypoint.duration; return total; } diff --git a/modules/game-core/src/root.zig b/modules/game-core/src/root.zig index 991aa772..900dba7c 100644 --- a/modules/game-core/src/root.zig +++ b/modules/game-core/src/root.zig @@ -19,6 +19,7 @@ pub const session_hud = @import("ui/session_hud.zig"); pub const BLOCK_TEXTURE_DEFINITIONS = block_texture_definitions.BLOCK_TEXTURE_DEFINITIONS; pub const BenchmarkRunner = benchmark.BenchmarkRunner; +pub const BENCHMARK_WORLD_SEED = benchmark.BENCHMARK_WORLD_SEED; pub const BuildConfig = session.BuildConfig; pub const GameSession = session.GameSession; pub const InputMapper = input_mapper.InputMapper; diff --git a/modules/game-ui/src/screens/world.zig b/modules/game-ui/src/screens/world.zig index e5521398..1b52a48c 100644 --- a/modules/game-ui/src/screens/world.zig +++ b/modules/game-ui/src/screens/world.zig @@ -672,6 +672,75 @@ pub const WorldScreen = struct { lod_display = .{ .loaded = ls.loaded, .memory_used_mb = ls.memory_used_mb, + .memory_used_bytes = ls.memory_used_bytes, + .pool_gpu_capacity_bytes = ls.pool_gpu_capacity_bytes, + .pool_gpu_allocated_bytes = ls.pool_gpu_allocated_bytes, + .pool_gpu_slack_bytes = ls.pool_gpu_slack_bytes, + .pool_cpu_shadow_bytes = ls.pool_cpu_shadow_bytes, + .compact_pool_capacity_bytes = ls.compact_pool_capacity_bytes, + .compact_pool_allocated_bytes = ls.compact_pool_allocated_bytes, + .compact_pool_free_bytes = ls.compact_pool_free_bytes, + .compact_pool_retired_bytes = ls.compact_pool_retired_bytes, + .direct_mesh_gpu_bytes = ls.direct_mesh_gpu_bytes, + .pending_cpu_upload_bytes = ls.pending_cpu_upload_bytes, + .deferred_deletion_gpu_bytes = ls.deferred_deletion_gpu_bytes, + .deferred_deletion_cpu_bytes = ls.deferred_deletion_cpu_bytes, + .profiling = .{ + .enabled = ls.profiling.enabled, + .update_ms = ls.profiling.update_ms, + .scheduling_ms = ls.profiling.scheduling_ms, + .cache_ms = ls.profiling.cache_ms, + .generation_dispatch_ms = ls.profiling.generation_dispatch_ms, + .state_transition_ms = ls.profiling.state_transition_ms, + .upload_prep_ms = ls.profiling.upload_prep_ms, + .upload_submission_ms = ls.profiling.upload_submission_ms, + .visibility_ms = ls.profiling.visibility_ms, + .coverage_ms = ls.profiling.coverage_ms, + .eviction_ms = ls.profiling.eviction_ms, + .worker_generation_ms = ls.profiling.worker_generation_ms, + .worker_mesh_construction_ms = ls.profiling.worker_mesh_construction_ms, + .manager_lock_wait_ms = ls.profiling.manager_lock_wait_ms, + .manager_lock_hold_ms = ls.profiling.manager_lock_hold_ms, + .upload_bytes = ls.profiling.upload_bytes, + .pending_cpu_upload_bytes = ls.profiling.pending_cpu_upload_bytes, + .staging_pressure_count = ls.profiling.staging_pressure_count, + .visible_count = ls.profiling.visible_count, + .rejected_count = ls.profiling.rejected_count, + .coverage_count = ls.profiling.coverage_count, + .visibility_levels = blk: { + var levels: [@import("engine-ui").LOD_VISIBILITY_LEVEL_COUNT]@import("engine-ui").LODVisibilityLevelDisplay = undefined; + for (&levels, 0..) |*level, index| level.* = .{ + .candidates = ls.profiling.visibility_levels[index].candidates, + .accepted = ls.profiling.visibility_levels[index].accepted, + .rejected_no_draw = ls.profiling.visibility_levels[index].rejected_no_draw, + .rejected_not_ready = ls.profiling.visibility_levels[index].rejected_not_ready, + .rejected_missing_region = ls.profiling.visibility_levels[index].rejected_missing_region, + .rejected_not_renderable = ls.profiling.visibility_levels[index].rejected_not_renderable, + .rejected_finer_coverage = ls.profiling.visibility_levels[index].rejected_finer_coverage, + .rejected_range = ls.profiling.visibility_levels[index].rejected_range, + .rejected_frustum = ls.profiling.visibility_levels[index].rejected_frustum, + .rejected_chunk_coverage = ls.profiling.visibility_levels[index].rejected_chunk_coverage, + .coverage_checks = ls.profiling.visibility_levels[index].coverage_checks, + }; + break :blk levels; + }, + .deferred_deletion_bytes = ls.profiling.deferred_deletion_bytes, + .deferred_deletion_cpu_bytes = ls.profiling.deferred_deletion_cpu_bytes, + .pool_gpu_capacity_bytes = ls.profiling.pool_gpu_capacity_bytes, + .pool_gpu_allocated_bytes = ls.profiling.pool_gpu_allocated_bytes, + .pool_gpu_slack_bytes = ls.profiling.pool_gpu_slack_bytes, + .pool_cpu_shadow_bytes = ls.profiling.pool_cpu_shadow_bytes, + .compact_pool_capacity_bytes = ls.profiling.compact_pool_capacity_bytes, + .compact_pool_allocated_bytes = ls.profiling.compact_pool_allocated_bytes, + .compact_pool_free_bytes = ls.profiling.compact_pool_free_bytes, + .compact_pool_retired_bytes = ls.profiling.compact_pool_retired_bytes, + .direct_mesh_gpu_bytes = ls.profiling.direct_mesh_gpu_bytes, + .known_memory_bytes = ls.profiling.known_memory_bytes, + .wait_idle_count = ls.profiling.wait_idle_count, + .wait_idle_ms = ls.profiling.wait_idle_ms, + .gpu_culling_overflows = ls.gpu_culling_overflows, + .gpu_culling_validation_mismatches = ls.gpu_culling_validation_mismatches, + }, }; } return .{ diff --git a/modules/world-core/src/lod_data.zig b/modules/world-core/src/lod_data.zig index a1921e1d..259d69e7 100644 --- a/modules/world-core/src/lod_data.zig +++ b/modules/world-core/src/lod_data.zig @@ -483,12 +483,12 @@ test "LODSimplifiedData initializes rich column defaults" { try std.testing.expectEqual(@as(f32, 0.0), data.vegetation[0].tree_coverage); } -test "LODSimplifiedData far levels use high-detail grids" { +test "LODSimplifiedData far levels use configured detail grids" { try std.testing.expectEqual(@as(u32, 33), LODSimplifiedData.getGridSize(.lod0)); try std.testing.expectEqual(@as(u32, 65), LODSimplifiedData.getGridSize(.lod1)); try std.testing.expectEqual(@as(u32, 65), LODSimplifiedData.getGridSize(.lod2)); try std.testing.expectEqual(@as(u32, 129), LODSimplifiedData.getGridSize(.lod3)); - try std.testing.expectEqual(@as(u32, 129), LODSimplifiedData.getGridSize(.lod4)); + try std.testing.expectEqual(@as(u32, 65), LODSimplifiedData.getGridSize(.lod4)); } test "LODColumnProvenance orders overwrite authority" { diff --git a/modules/world-lod/src/lod_cache.zig b/modules/world-lod/src/lod_cache.zig index 7030ac82..a09c9b61 100644 --- a/modules/world-lod/src/lod_cache.zig +++ b/modules/world-lod/src/lod_cache.zig @@ -14,7 +14,8 @@ const BlockType = world_core.BlockType; const BiomeId = world_core.BiomeId; pub const MAGIC: u32 = 0x5A4C4F44; // "ZLOD" -pub const CACHE_VERSION: u8 = 10; +pub const CACHE_VERSION: u8 = 11; +const CACHE_VERSION_V10: u8 = 10; pub const CACHE_VERSION_V1: u8 = 1; pub const HEADER_SIZE: usize = 42; @@ -36,6 +37,8 @@ pub const CacheError = error{ InvalidBiome, InvalidBlock, InvalidSpanCount, + InvalidProvenance, + MissingProvenance, ChecksumMismatch, }; @@ -70,15 +73,39 @@ fn payloadSize(count: usize) usize { pub fn serializedSize(data: *const LODSimplifiedData) usize { const count = @as(usize, @intCast(data.width)) * @as(usize, @intCast(data.width)); - var total = HEADER_SIZE + payloadSize(count) + SPAN_FLAGS_WIRE_SIZE; + var total = HEADER_SIZE + payloadSize(count) + SPAN_FLAGS_WIRE_SIZE + count; if (data.hasVerticalSpans()) { - total += count; // provenance bytes total += count; // vertical span counts total += count * world_core.MAX_LOD_VERTICAL_SPANS * SPAN_WIRE_SIZE; } return total; } +/// Creates an allocator-owned snapshot suitable for handing to an asynchronous +/// serializer. Source data remains owned by the LOD region and may be edited +/// as soon as this returns. +pub fn cloneSourceData(data: *const LODSimplifiedData, lod: LODLevel, allocator: std.mem.Allocator) !LODSimplifiedData { + var copy = if (data.hasVerticalSpans()) + try LODSimplifiedData.initWithVerticalSpans(allocator, lod) + else + try LODSimplifiedData.init(allocator, lod); + errdefer copy.deinit(); + + copy.version = data.version; + @memcpy(copy.heightmap, data.heightmap); + @memcpy(copy.biomes, data.biomes); + @memcpy(copy.top_blocks, data.top_blocks); + @memcpy(copy.colors, data.colors); + @memcpy(copy.material_layers, data.material_layers); + @memcpy(copy.water, data.water); + @memcpy(copy.lighting, data.lighting); + @memcpy(copy.vegetation, data.vegetation); + @memcpy(copy.provenance, data.provenance); + if (data.vertical_span_counts) |counts| @memcpy(copy.vertical_span_counts.?, counts); + if (data.vertical_spans) |spans| @memcpy(copy.vertical_spans.?, spans); + return copy; +} + fn writeF32(buf: []u8, value: f32) void { std.mem.writeInt(u32, buf[0..4], @as(u32, @bitCast(value)), .little); } @@ -277,13 +304,13 @@ pub fn serialize(data: *const LODSimplifiedData, key: Key, allocator: std.mem.Al const has_spans = data.hasVerticalSpans(); buf[off] = if (has_spans) 1 else 0; off += 1; + for (data.provenance) |provenance| { + buf[off] = @intFromEnum(provenance); + off += 1; + } if (has_spans) { const counts = data.vertical_span_counts.?; const spans = data.vertical_spans.?; - for (data.provenance) |provenance| { - buf[off] = @intFromEnum(provenance); - off += 1; - } @memcpy(buf[off..][0..count], counts); off += count; for (spans) |span| { @@ -306,7 +333,7 @@ pub fn deserialize(bytes: []const u8, key: Key, allocator: std.mem.Allocator) !L off += 4; const version = bytes[off]; - if (version != CACHE_VERSION and version != CACHE_VERSION_V1) return CacheError.UnsupportedVersion; + if (version != CACHE_VERSION and version != CACHE_VERSION_V10 and version != CACHE_VERSION_V1) return CacheError.UnsupportedVersion; off += 1; const lod_byte = bytes[off]; off += 1; @@ -338,14 +365,23 @@ pub fn deserialize(bytes: []const u8, key: Key, allocator: std.mem.Allocator) !L var has_spans = false; var expected = v1_expected; - if (version == CACHE_VERSION) { + if (version == CACHE_VERSION or version == CACHE_VERSION_V10) { if (bytes.len < v1_expected + SPAN_FLAGS_WIRE_SIZE) return CacheError.DataTooShort; has_spans = bytes[v1_expected] != 0; expected = v1_expected + SPAN_FLAGS_WIRE_SIZE; - if (has_spans) expected += count + count + count * world_core.MAX_LOD_VERTICAL_SPANS * SPAN_WIRE_SIZE; + if (version == CACHE_VERSION) expected += count; + if (has_spans) { + if (version == CACHE_VERSION_V10) expected += count; + expected += count + count * world_core.MAX_LOD_VERTICAL_SPANS * SPAN_WIRE_SIZE; + } } if (bytes.len < expected) return CacheError.DataTooShort; + // Older span-less payloads never recorded source authority. Treat them as + // stale instead of silently converting edited/chunk-derived columns back + // to worldgen data; the cache pipeline will regenerate them safely. + if ((version == CACHE_VERSION_V1) or (version == CACHE_VERSION_V10 and !has_spans)) return CacheError.MissingProvenance; + var data = if (has_spans) try LODSimplifiedData.initWithVerticalSpans(allocator, key.lod) else try LODSimplifiedData.init(allocator, key.lod); errdefer data.deinit(); @@ -382,21 +418,23 @@ pub fn deserialize(bytes: []const u8, key: Key, allocator: std.mem.Allocator) !L off += 18; } - if (version == CACHE_VERSION) { + if (version == CACHE_VERSION or version == CACHE_VERSION_V10) { const flags = bytes[off]; off += 1; if ((flags & ~@as(u8, 1)) != 0) return CacheError.InvalidSpanCount; - if (has_spans) { + if (version == CACHE_VERSION or has_spans) { for (data.provenance) |*provenance| { const byte = bytes[off]; provenance.* = switch (byte) { 0 => .worldgen, 1 => .chunk_derived, 2 => .edited, - else => return CacheError.InvalidSpanCount, + else => return CacheError.InvalidProvenance, }; off += 1; } + } + if (has_spans) { const counts = data.vertical_span_counts.?; @memcpy(counts, bytes[off..][0..count]); off += count; @@ -498,18 +536,18 @@ test "LOD cache round-trip preserves vertical spans" { try testing.expect(decoded.hasVerticalSpans()); try testing.expectEqual(data.vertical_span_counts.?.len, decoded.vertical_span_counts.?.len); try testing.expectEqualSlices(u8, data.vertical_span_counts.?, decoded.vertical_span_counts.?); - try testing.expectEqual(data.vertical_spans.?.len, decoded.vertical_spans.?.len); - try testing.expectEqualSlices(u8, std.mem.sliceAsBytes(data.vertical_spans.?), std.mem.sliceAsBytes(decoded.vertical_spans.?)); + try testing.expectEqual(lower, decoded.getVerticalSpan(2, 3, 0).?); + try testing.expectEqual(upper, decoded.getVerticalSpan(2, 3, 1).?); } -test "LOD cache round-trip preserves column provenance" { - var data = try LODSimplifiedData.initWithVerticalSpans(testing.allocator, .lod1); +test "LOD cache round-trip preserves span-less column provenance" { + var data = try LODSimplifiedData.init(testing.allocator, .lod3); defer data.deinit(); data.setColumnProvenance(2, 3, .chunk_derived); data.setColumnProvenance(4, 5, .edited); - const key = Key{ .seed = 1234, .generator_identity_hash = 99, .generator_version = 7, .rx = -2, .rz = 3, .lod = .lod1 }; + const key = Key{ .seed = 1234, .generator_identity_hash = 99, .generator_version = 7, .rx = -2, .rz = 3, .lod = .lod3 }; const bytes = try serialize(&data, key, testing.allocator); defer testing.allocator.free(bytes); @@ -521,7 +559,7 @@ test "LOD cache round-trip preserves column provenance" { try testing.expectEqual(ColumnProvenance.worldgen, decoded.getColumnProvenance(0, 0)); } -test "LOD cache accepts v1 payload as span-less data" { +test "LOD cache rejects legacy span-less payload without provenance" { var data = try LODSimplifiedData.init(testing.allocator, .lod2); defer data.deinit(); data.setColumn(1, 1, 91.0, .mountains, .{ .surface = .stone, .subsurface = .dirt, .foundation = .stone }, 0xFF445566, .empty, .daylight, .empty); @@ -537,13 +575,27 @@ test "LOD cache accepts v1 payload as span-less data" { std.mem.writeInt(u32, v1_bytes[6..][0..4], 0, .little); std.mem.writeInt(u32, v1_bytes[6..][0..4], computeCrc(v1_bytes), .little); - var decoded = try deserialize(v1_bytes, key, testing.allocator); - defer decoded.deinit(); + try testing.expectError(CacheError.MissingProvenance, deserialize(v1_bytes, key, testing.allocator)); +} - try testing.expect(!decoded.hasVerticalSpans()); - const idx = 1 + data.width; - try testing.expectEqual(data.heightmap[idx], decoded.heightmap[idx]); - try testing.expectEqual(data.biomes[idx], decoded.biomes[idx]); +test "LOD cache rejects v10 span-less payload without provenance" { + var data = try LODSimplifiedData.init(testing.allocator, .lod3); + defer data.deinit(); + + const key = Key{ .seed = 4, .generator_identity_hash = 5, .generator_version = 6, .rx = 1, .rz = -2, .lod = .lod3 }; + const current_bytes = try serialize(&data, key, testing.allocator); + defer testing.allocator.free(current_bytes); + const count = @as(usize, @intCast(data.width)) * @as(usize, @intCast(data.width)); + const legacy_size = current_bytes.len - count; + const legacy_bytes = try testing.allocator.alloc(u8, legacy_size); + defer testing.allocator.free(legacy_bytes); + const provenance_offset = HEADER_SIZE + payloadSize(count) + SPAN_FLAGS_WIRE_SIZE; + @memcpy(legacy_bytes[0..provenance_offset], current_bytes[0..provenance_offset]); + legacy_bytes[4] = CACHE_VERSION_V10; + std.mem.writeInt(u32, legacy_bytes[6..][0..4], 0, .little); + std.mem.writeInt(u32, legacy_bytes[6..][0..4], computeCrc(legacy_bytes), .little); + + try testing.expectError(CacheError.MissingProvenance, deserialize(legacy_bytes, key, testing.allocator)); } test "LOD cache rejects checksum mismatch" { @@ -558,6 +610,22 @@ test "LOD cache rejects checksum mismatch" { try testing.expectError(CacheError.ChecksumMismatch, deserialize(bytes, key, testing.allocator)); } +test "LOD cache reports invalid provenance" { + var data = try LODSimplifiedData.init(testing.allocator, .lod2); + defer data.deinit(); + + const key = Key{ .seed = 1, .generator_identity_hash = 2, .generator_version = 1, .rx = 0, .rz = 0, .lod = .lod2 }; + const bytes = try serialize(&data, key, testing.allocator); + defer testing.allocator.free(bytes); + const count = @as(usize, @intCast(data.width)) * @as(usize, @intCast(data.width)); + const provenance_offset = HEADER_SIZE + payloadSize(count) + SPAN_FLAGS_WIRE_SIZE; + bytes[provenance_offset] = 3; + std.mem.writeInt(u32, bytes[6..][0..4], 0, .little); + std.mem.writeInt(u32, bytes[6..][0..4], computeCrc(bytes), .little); + + try testing.expectError(CacheError.InvalidProvenance, deserialize(bytes, key, testing.allocator)); +} + test "LOD cache rejects mismatched cache key" { var data = try LODSimplifiedData.init(testing.allocator, .lod1); defer data.deinit(); @@ -585,4 +653,9 @@ test "LOD cache checksum covers key header fields" { test "LOD cache payload size follows named wire fields" { try testing.expectEqual(@as(usize, 50), payloadSize(1)); try testing.expectEqual(@as(usize, HEADER_SIZE + 50 * 4), HEADER_SIZE + payloadSize(4)); + + var data = try LODSimplifiedData.init(testing.allocator, .lod0); + defer data.deinit(); + const count = @as(usize, @intCast(data.width)) * @as(usize, @intCast(data.width)); + try testing.expectEqual(HEADER_SIZE + payloadSize(count) + SPAN_FLAGS_WIRE_SIZE + count, serializedSize(&data)); } diff --git a/modules/world-lod/src/lod_cache_io.zig b/modules/world-lod/src/lod_cache_io.zig new file mode 100644 index 00000000..3e11a420 --- /dev/null +++ b/modules/world-lod/src/lod_cache_io.zig @@ -0,0 +1,296 @@ +//! Dedicated bounded cache I/O worker for LOD source data. +//! +//! This worker is deliberately separate from generation workers. The update +//! thread only snapshots/enqueues work and applies completions; all filesystem +//! access and cache serialization/deserialization happens on this one thread. + +const std = @import("std"); +const fs = @import("fs"); +const sync = @import("sync"); +const log = @import("engine-core").log; +const LODRegionKey = @import("lod_chunk.zig").LODRegionKey; +const LODSimplifiedData = @import("lod_chunk.zig").LODSimplifiedData; +const lod_cache = @import("lod_cache.zig"); +const lod_store = @import("lod_store.zig"); +const LODProfilingCollector = @import("lod_stats.zig").LODProfilingCollector; + +pub const MAX_PENDING_TASKS: usize = 16; +const MAX_COMPLETIONS: usize = MAX_PENDING_TASKS + 1; + +pub const ReadResult = union(enum) { + hit: LODSimplifiedData, + miss: void, +}; + +pub const Completion = union(enum) { + read: struct { + key: LODRegionKey, + token: u32, + result: ReadResult, + used_legacy: bool, + }, + write: struct { + key: LODRegionKey, + revision: u32, + success: bool, + size_limited: bool, + store_size_cap_mb: u32, + }, +}; + +const Task = union(enum) { + read: struct { + path: []u8, + region_key: LODRegionKey, + cache_key: lod_cache.Key, + token: u32, + }, + write: struct { + path: []u8, + region_key: LODRegionKey, + cache_key: lod_cache.Key, + revision: u32, + data: LODSimplifiedData, + store_size_cap_mb: u32, + }, +}; + +/// A single worker and two bounded queues. Completion ownership transfers to +/// the update thread, which must deinit hit data after applying or rejecting it. +pub const CacheIoPipeline = struct { + allocator: std.mem.Allocator, + profiling: *LODProfilingCollector, + mutex: sync.Mutex = .{}, + work_ready: sync.Condition = .{}, + idle: sync.Condition = .{}, + tasks: std.ArrayListUnmanaged(Task) = .empty, + completions: std.ArrayListUnmanaged(Completion) = .empty, + stopping: bool = false, + active: bool = false, + thread: std.Thread, + + pub fn init(allocator: std.mem.Allocator, profiling: *LODProfilingCollector) !*CacheIoPipeline { + const result = try allocator.create(CacheIoPipeline); + errdefer allocator.destroy(result); + result.* = .{ + .allocator = allocator, + .profiling = profiling, + .thread = undefined, + }; + try result.tasks.ensureTotalCapacity(allocator, MAX_PENDING_TASKS); + errdefer result.tasks.deinit(allocator); + try result.completions.ensureTotalCapacity(allocator, MAX_COMPLETIONS); + errdefer result.completions.deinit(allocator); + result.thread = try std.Thread.spawn(.{}, workerMain, .{result}); + return result; + } + + pub fn enqueueRead(self: *CacheIoPipeline, path: []const u8, region_key: LODRegionKey, cache_key: lod_cache.Key, token: u32) !bool { + const path_copy = try self.allocator.dupe(u8, path); + errdefer self.allocator.free(path_copy); + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.stopping or self.tasks.items.len >= MAX_PENDING_TASKS) { + self.allocator.free(path_copy); + return false; + } + self.tasks.appendAssumeCapacity(.{ .read = .{ .path = path_copy, .region_key = region_key, .cache_key = cache_key, .token = token } }); + self.work_ready.signal(); + return true; + } + + /// Transfers ownership of `data` when accepted. Rejected snapshots remain + /// owned by the caller so it can release them or retry later. + pub fn enqueueWrite(self: *CacheIoPipeline, path: []const u8, region_key: LODRegionKey, cache_key: lod_cache.Key, revision: u32, data: LODSimplifiedData, store_size_cap_mb: u32) !bool { + const path_copy = try self.allocator.dupe(u8, path); + errdefer self.allocator.free(path_copy); + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.stopping or self.tasks.items.len >= MAX_PENDING_TASKS) { + self.allocator.free(path_copy); + return false; + } + self.tasks.appendAssumeCapacity(.{ .write = .{ + .path = path_copy, + .region_key = region_key, + .cache_key = cache_key, + .revision = revision, + .data = data, + .store_size_cap_mb = store_size_cap_mb, + } }); + self.work_ready.signal(); + return true; + } + + /// Moves all available completions into `out`. The caller becomes owner of + /// every read-hit payload in the returned list. + pub fn drainCompletions(self: *CacheIoPipeline, out: *std.ArrayListUnmanaged(Completion)) void { + self.mutex.lock(); + defer self.mutex.unlock(); + while (self.completions.items.len > 0) { + const completion = self.completions.items[0]; + out.append(self.allocator, completion) catch { + // Allocation failure cannot strand a read request. Keep the + // completion for a later update rather than dropping it. + return; + }; + _ = self.completions.orderedRemove(0); + } + } + + /// Explicit lifecycle operation only; never call from the frame path. + pub fn waitUntilIdle(self: *CacheIoPipeline) void { + self.mutex.lock(); + defer self.mutex.unlock(); + while (self.tasks.items.len != 0 or self.active) self.idle.wait(&self.mutex); + } + + /// Stops accepting work, drains accepted tasks, joins the sole worker, and + /// releases queued completion payloads. This may block by design. + pub fn deinit(self: *CacheIoPipeline) void { + self.mutex.lock(); + self.stopping = true; + self.work_ready.broadcast(); + self.mutex.unlock(); + self.thread.join(); + + for (self.tasks.items) |*task| deinitTask(self.allocator, task); + self.tasks.deinit(self.allocator); + for (self.completions.items) |*completion| deinitCompletion(completion); + self.completions.deinit(self.allocator); + self.allocator.destroy(self); + } + + fn workerMain(self: *CacheIoPipeline) void { + while (true) { + self.mutex.lock(); + while (self.tasks.items.len == 0 and !self.stopping) self.work_ready.wait(&self.mutex); + if (self.tasks.items.len == 0 and self.stopping) { + self.idle.broadcast(); + self.mutex.unlock(); + return; + } + const task = self.tasks.orderedRemove(0); + self.active = true; + self.mutex.unlock(); + + const completion = self.runTask(task); + + self.mutex.lock(); + // Capacity is guaranteed by the bounded work queue: at most one + // active task plus MAX_PENDING_TASKS accepted tasks can complete. + self.completions.appendAssumeCapacity(completion); + self.active = false; + if (self.tasks.items.len == 0) self.idle.broadcast(); + self.mutex.unlock(); + } + } + + fn runTask(self: *CacheIoPipeline, task: Task) Completion { + const timer = self.profiling.begin(); + defer self.profiling.end(.cache, timer); + return switch (task) { + .read => |read| blk: { + defer self.allocator.free(read.path); + const outcome = readData(self.allocator, read.path, read.cache_key); + break :blk .{ .read = .{ + .key = read.region_key, + .token = read.token, + .result = outcome.result, + .used_legacy = outcome.used_legacy, + } }; + }, + .write => |write| blk: { + defer self.allocator.free(write.path); + var data = write.data; + defer data.deinit(); + const bytes = lod_cache.serialize(&data, write.cache_key, self.allocator) catch |err| { + log.log.warn("Failed to serialize LOD{} cache ({}, {}): {}", .{ @intFromEnum(write.region_key.lod), write.region_key.rx, write.region_key.rz, err }); + break :blk .{ .write = .{ .key = write.region_key, .revision = write.revision, .success = false, .size_limited = false, .store_size_cap_mb = write.store_size_cap_mb } }; + }; + defer self.allocator.free(bytes); + var size_limited = false; + const success = blk_success: { + lod_store.writePayload(self.allocator, write.path, write.cache_key, bytes, write.store_size_cap_mb) catch |err| { + log.log.warn("Failed to write LOD{} store ({}, {}): {}", .{ @intFromEnum(write.region_key.lod), write.region_key.rx, write.region_key.rz, err }); + size_limited = err == lod_store.StoreError.StoreSizeLimit; + break :blk_success false; + }; + break :blk_success true; + }; + break :blk .{ .write = .{ .key = write.region_key, .revision = write.revision, .success = success, .size_limited = size_limited, .store_size_cap_mb = write.store_size_cap_mb } }; + }, + }; + } +}; + +const ReadDataOutcome = struct { + result: ReadResult, + used_legacy: bool = false, +}; + +fn readData(allocator: std.mem.Allocator, path: []const u8, key: lod_cache.Key) ReadDataOutcome { + const payload = lod_store.readPayload(allocator, path, key) catch |err| switch (err) { + lod_store.StoreError.CorruptContainer => { + const container_path = lod_store.containerPath(allocator, path, key) catch return .{ .result = .miss }; + defer allocator.free(container_path); + log.log.warn("Discarding corrupt LOD store container '{s}'", .{container_path}); + fs.cwd().deleteFile(container_path) catch |delete_err| { + if (delete_err != error.FileNotFound) log.log.warn("Failed to delete corrupt LOD store container '{s}': {}", .{ container_path, delete_err }); + }; + return .{ .result = .miss }; + }, + else => { + log.log.warn("Failed to read LOD store for LOD{} ({}, {}): {}", .{ @intFromEnum(key.lod), key.rx, key.rz, err }); + return .{ .result = .miss }; + }, + }; + if (payload) |bytes| { + defer allocator.free(bytes); + const data = lod_cache.deserialize(bytes, key, allocator) catch |err| { + log.log.warn("Discarding corrupt LOD store payload LOD{} ({}, {}): {}", .{ @intFromEnum(key.lod), key.rx, key.rz, err }); + lod_store.deletePayload(allocator, path, key); + return .{ .result = .miss }; + }; + return .{ .result = .{ .hit = data } }; + } + + const legacy_path = std.fmt.allocPrint(allocator, "{s}/lod_cache/lod_{}_{}_{}_{}_{}_{}.dat", .{ path, key.seed, key.generator_identity_hash, key.generator_version, key.rx, key.rz, @intFromEnum(key.lod) }) catch return .{ .result = .miss }; + defer allocator.free(legacy_path); + const bytes = fs.cwd().readFileAlloc(legacy_path, allocator, 16 * 1024 * 1024) catch |err| switch (err) { + error.FileNotFound => return .{ .result = .miss }, + else => { + log.log.warn("Failed to read legacy LOD cache '{s}': {}", .{ legacy_path, err }); + return .{ .result = .miss }; + }, + }; + defer allocator.free(bytes); + const data = lod_cache.deserialize(bytes, key, allocator) catch |err| { + log.log.warn("Discarding corrupt legacy LOD cache '{s}': {}", .{ legacy_path, err }); + fs.cwd().deleteFile(legacy_path) catch |delete_err| { + if (delete_err != error.FileNotFound) log.log.warn("Failed to delete corrupt legacy LOD cache '{s}': {}", .{ legacy_path, delete_err }); + }; + return .{ .result = .miss, .used_legacy = true }; + }; + return .{ .result = .{ .hit = data }, .used_legacy = true }; +} + +fn deinitTask(allocator: std.mem.Allocator, task: *Task) void { + switch (task.*) { + .read => |read| allocator.free(read.path), + .write => |*write| { + allocator.free(write.path); + write.data.deinit(); + }, + } +} + +fn deinitCompletion(completion: *Completion) void { + switch (completion.*) { + .read => |*read| switch (read.result) { + .hit => |*data| data.deinit(), + .miss => {}, + }, + .write => {}, + } +} diff --git a/modules/world-lod/src/lod_chunk.zig b/modules/world-lod/src/lod_chunk.zig index 0befea04..bf094f22 100644 --- a/modules/world-lod/src/lod_chunk.zig +++ b/modules/world-lod/src/lod_chunk.zig @@ -161,6 +161,11 @@ pub const LODChunk = struct { /// Pin count for preventing unload during async work pin_count: std.atomic.Value(u32), + /// Per-region cancellation signal for worker jobs invalidated by pause, + /// teleport, or horizon changes. Unlike the manager teardown flag, this + /// remains set until a new job is explicitly dispatched for the region. + cancel_requested: std.atomic.Value(bool), + /// Chunk data - either full detail or simplified data: union(enum) { /// LOD0: Full chunk data (pointer to existing Chunk) @@ -192,6 +197,21 @@ pub const LODChunk = struct { /// or edit). The manager writes the region container to disk lazily. store_dirty: bool, + /// Monotonic source revision used to reject stale cache write completions. + source_revision: u32, + + /// Cache pipeline state. These flags are owned by the manager update + /// thread and prevent duplicate requests without pinning region memory. + cache_read_queued: bool, + store_write_queued: bool, + /// Suppresses retries for a snapshot that cannot fit the current store + /// cap. A source revision or cap increase makes it eligible again. + store_size_limited: bool, + store_size_limit_cap_mb: u32, + /// Sticky for this region lifetime after a compact runtime submission + /// failure, preventing an auto/force rebuild loop. + compact_disabled: bool, + /// Creates an empty LOD region record in the missing state. /// Source data, mesh handles, readiness counts, and transition state are initialized to safe defaults. pub fn init(rx: i32, rz: i32, lod: LODLevel) LODChunk { @@ -202,6 +222,7 @@ pub const LODChunk = struct { .state = .missing, .job_token = 0, .pin_count = std.atomic.Value(u32).init(0), + .cancel_requested = std.atomic.Value(bool).init(false), .data = .{ .empty = {} }, .mesh_handle = 0, .min_height = 0.0, @@ -210,6 +231,12 @@ pub const LODChunk = struct { .transition_frames_remaining = 0, .dirty = false, .store_dirty = false, + .source_revision = 0, + .cache_read_queued = false, + .store_write_queued = false, + .store_size_limited = false, + .store_size_limit_cap_mb = 0, + .compact_disabled = false, }; } @@ -243,6 +270,18 @@ pub const LODChunk = struct { return self.pin_count.load(.monotonic) > 0; } + pub fn requestCancellation(self: *LODChunk) void { + self.cancel_requested.store(true, .release); + } + + pub fn resetCancellation(self: *LODChunk) void { + self.cancel_requested.store(false, .release); + } + + pub fn cancellationRequested(self: *const LODChunk) bool { + return self.cancel_requested.load(.acquire); + } + /// Returns the immutable map key corresponding to this chunk's region coordinates and LOD level. /// The key can be used to look up the same chunk in manager maps. pub fn key(self: *const LODChunk) LODRegionKey { @@ -341,6 +380,8 @@ pub const LODChunk = struct { pub fn markSourceDirty(self: *LODChunk) void { self.dirty = true; self.store_dirty = true; + self.store_size_limited = false; + self.source_revision +%= 1; } /// Sets the ready-child count directly, clamped to four direct children. diff --git a/modules/world-lod/src/lod_compact_pool.zig b/modules/world-lod/src/lod_compact_pool.zig new file mode 100644 index 00000000..6f419352 --- /dev/null +++ b/modules/world-lod/src/lod_compact_pool.zig @@ -0,0 +1,244 @@ +//! Shared storage for compact LOD3/4 samples. Freed ranges are not reusable +//! until the same completed frame slot is observed at a later frame serial. +const std = @import("std"); +const rhi = @import("engine-rhi"); +const LODMesh = @import("lod_mesh.zig").LODMesh; +const LODMeshResources = @import("lod_mesh_resources.zig").LODMeshResources; + +pub const CompactLODPool = struct { + pub const CAPACITY_BYTES = 64 * 1024 * 1024; + const Range = struct { offset: usize, size: usize }; + const Retired = struct { range: Range, serial: u64, frame_slot: usize }; + + pub const MemoryStats = struct { + capacity_bytes: usize = 0, + allocated_bytes: usize = 0, + free_bytes: usize = 0, + retired_bytes: usize = 0, + }; + + allocator: std.mem.Allocator, + buffer_handle: rhi.BufferHandle = 0, + capacity_bytes: usize, + /// Live bytes. Retired bytes remain unavailable but are not live mesh data. + allocated_bytes: usize = 0, + free_bytes: usize = 0, + retired_bytes: usize = 0, + free_ranges: std.ArrayListUnmanaged(Range) = .empty, + retired: std.ArrayListUnmanaged(Retired) = .empty, + last_completed_serial: ?u64 = null, + + pub fn init(allocator: std.mem.Allocator) CompactLODPool { + return .{ .allocator = allocator, .capacity_bytes = CAPACITY_BYTES }; + } + + fn initForTest(allocator: std.mem.Allocator, capacity_bytes: usize) CompactLODPool { + return .{ .allocator = allocator, .capacity_bytes = capacity_bytes }; + } + + pub fn deinit(self: *CompactLODPool, resources: LODMeshResources) void { + if (self.buffer_handle != 0) resources.destroyBuffer(self.buffer_handle); + self.free_ranges.deinit(self.allocator); + self.retired.deinit(self.allocator); + self.* = undefined; + } + + pub fn memoryStats(self: *const CompactLODPool) MemoryStats { + // A configured capacity is not GPU memory until the backing buffer is + // successfully created. This prevents accounting a failed allocation. + return .{ + .capacity_bytes = if (self.buffer_handle != 0) self.capacity_bytes else 0, + .allocated_bytes = self.allocated_bytes, + .free_bytes = self.free_bytes, + .retired_bytes = self.retired_bytes, + }; + } + + pub fn upload(self: *CompactLODPool, mesh: *LODMesh, resources: LODMeshResources) rhi.RhiError!void { + const tile = mesh.compact_tile orelse return error.InvalidState; + const bytes = std.mem.sliceAsBytes(tile.samples); + if (bytes.len == 0 or bytes.len % 16 != 0) return error.InvalidState; + try self.ensureBuffer(resources); + const range = try self.allocate(bytes.len); + errdefer self.releaseRange(range) catch {}; + try resources.updateBuffer(self.buffer_handle, range.offset, bytes); + + mesh.mutex.lock(); + defer mesh.mutex.unlock(); + // A range must never be overwritten in place. Callers retire a compact + // mesh before remeshing, so seeing one here proves a lifecycle bug. + if (mesh.compact_sample_bytes != 0) return error.InvalidState; + mesh.compact_sample_offset = @intCast(range.offset / 16); + mesh.compact_sample_bytes = range.size; + mesh.buffer_handle = self.buffer_handle; + mesh.vertex_count = mesh.compact_index_count; + mesh.opaque_vertex_count = mesh.compact_index_count; + mesh.water_vertex_count = if (mesh.compact_has_water) mesh.compact_index_count else 0; + mesh.ready = true; + var mutable_tile = mesh.compact_tile.?; + mutable_tile.deinit(); + mesh.compact_tile = null; + } + + /// Retire a compact range at the serial/frame-slot that last submitted a + /// draw. `collectRetired` only returns it after that slot is completed and + /// reused at a strictly later serial. + pub fn retireMesh(self: *CompactLODPool, mesh: *LODMesh, serial: u64, frame_slot: usize) void { + if (!mesh.isCompact()) return; + mesh.mutex.lock(); + defer mesh.mutex.unlock(); + if (mesh.compact_sample_bytes != 0) { + const range = Range{ .offset = @as(usize, mesh.compact_sample_offset) * 16, .size = mesh.compact_sample_bytes }; + self.retired.append(self.allocator, .{ .range = range, .serial = serial, .frame_slot = frame_slot }) catch { + // Never reuse on bookkeeping failure. The mesh state is still + // cleared below so a CPU fallback cannot render stale offsets. + self.allocated_bytes -= range.size; + self.retired_bytes += range.size; + self.clearMeshUnlocked(mesh); + return; + }; + self.allocated_bytes -= range.size; + self.retired_bytes += range.size; + } + self.clearMeshUnlocked(mesh); + } + + /// `completed_serial` comes from the monotonic WorldRenderer frame serial; + /// the completed slot is supplied only after its frame fence has made it + /// reusable. Older or duplicate serials cannot reclaim anything. + pub fn collectRetired(self: *CompactLODPool, completed_serial: u64, completed_frame_slot: usize) void { + if (self.last_completed_serial) |last| { + if (completed_serial <= last) return; + } + self.last_completed_serial = completed_serial; + var i: usize = 0; + while (i < self.retired.items.len) { + const retired = self.retired.items[i]; + if (retired.frame_slot == completed_frame_slot and completed_serial > retired.serial) { + self.releaseRange(retired.range) catch { + // Keep the range retired on allocation failure; it remains + // safe and will be retried at a later completed boundary. + i += 1; + continue; + }; + self.retired_bytes -= retired.range.size; + _ = self.retired.swapRemove(i); + } else i += 1; + } + } + + fn ensureBuffer(self: *CompactLODPool, resources: LODMeshResources) rhi.RhiError!void { + if (self.buffer_handle != 0) return; + // The fixed pool can contain at most a bounded number of minimum-size + // tiles. Reserve retirement metadata up front so pressure cannot turn + // a fence-safe retirement into a permanently leaked range. + try self.retired.ensureTotalCapacity(self.allocator, 16_384); + const handle = try resources.createBuffer(self.capacity_bytes, .storage); + errdefer resources.destroyBuffer(handle); + try self.free_ranges.append(self.allocator, .{ .offset = 0, .size = self.capacity_bytes }); + self.buffer_handle = handle; + self.free_bytes = self.capacity_bytes; + } + + fn allocate(self: *CompactLODPool, size: usize) rhi.RhiError!Range { + if (size == 0 or size % 16 != 0) return error.InvalidState; + for (self.free_ranges.items, 0..) |*range, i| if (range.size >= size) { + const result = Range{ .offset = range.offset, .size = size }; + range.offset += size; + range.size -= size; + if (range.size == 0) _ = self.free_ranges.swapRemove(i); + self.free_bytes -= size; + self.allocated_bytes += size; + return result; + }; + return error.OutOfMemory; + } + + fn releaseRange(self: *CompactLODPool, range: Range) !void { + var insert_at: usize = 0; + while (insert_at < self.free_ranges.items.len and self.free_ranges.items[insert_at].offset < range.offset) : (insert_at += 1) {} + try self.free_ranges.insert(self.allocator, insert_at, range); + self.free_bytes += range.size; + + var i = if (insert_at > 0) insert_at - 1 else insert_at; + while (i + 1 < self.free_ranges.items.len) { + const current = self.free_ranges.items[i]; + const next = self.free_ranges.items[i + 1]; + if (current.offset + current.size != next.offset) { + i += 1; + continue; + } + self.free_ranges.items[i].size += next.size; + _ = self.free_ranges.orderedRemove(i + 1); + } + } + + fn clearMeshUnlocked(_: *CompactLODPool, mesh: *LODMesh) void { + if (mesh.compact_tile) |*tile| tile.deinit(); + mesh.compact_tile = null; + mesh.compact = false; + mesh.compact_sample_offset = 0; + mesh.compact_sample_bytes = 0; + mesh.compact_index_count = 0; + mesh.compact_tile_width = 0; + mesh.compact_has_water = false; + mesh.buffer_handle = 0; + mesh.vertex_count = 0; + mesh.opaque_vertex_count = 0; + mesh.water_vertex_offset = 0; + mesh.water_vertex_count = 0; + mesh.vertex_offset = 0; + mesh.capacity = 0; + mesh.pooled = false; + mesh.ready = false; + } +}; + +test "compact pool capacity is sample aligned" { + try std.testing.expectEqual(@as(usize, 0), CompactLODPool.CAPACITY_BYTES % 16); +} + +test "compact pool retires only at a later completed frame-slot boundary" { + var pool = CompactLODPool.initForTest(std.testing.allocator, 64); + defer { + pool.free_ranges.deinit(std.testing.allocator); + pool.retired.deinit(std.testing.allocator); + } + try pool.free_ranges.append(std.testing.allocator, .{ .offset = 0, .size = 64 }); + pool.free_bytes = 64; + // This test exercises bookkeeping without creating a GPU backing buffer. + const range = try pool.allocate(16); + try pool.retired.append(std.testing.allocator, .{ .range = range, .serial = 10, .frame_slot = 1 }); + pool.allocated_bytes -= 16; + pool.retired_bytes += 16; + + pool.collectRetired(10, 1); + try std.testing.expectEqual(@as(usize, 48), pool.free_bytes); + pool.collectRetired(11, 0); + try std.testing.expectEqual(@as(usize, 48), pool.free_bytes); + pool.collectRetired(11, 1); // duplicate serial cannot reclaim. + try std.testing.expectEqual(@as(usize, 48), pool.free_bytes); + pool.collectRetired(12, 1); + try std.testing.expectEqual(@as(usize, 64), pool.free_bytes); + try std.testing.expectEqual(@as(usize, 0), pool.retired_bytes); +} + +test "compact pool reports exhaustion without reusing retired bytes" { + var pool = CompactLODPool.initForTest(std.testing.allocator, 32); + defer { + pool.free_ranges.deinit(std.testing.allocator); + pool.retired.deinit(std.testing.allocator); + } + try pool.free_ranges.append(std.testing.allocator, .{ .offset = 0, .size = 32 }); + pool.free_bytes = 32; + const a = try pool.allocate(16); + const b = try pool.allocate(16); + try std.testing.expectError(error.OutOfMemory, pool.allocate(16)); + try pool.retired.append(std.testing.allocator, .{ .range = a, .serial = 4, .frame_slot = 0 }); + pool.allocated_bytes -= 16; + pool.retired_bytes += 16; + try std.testing.expectError(error.OutOfMemory, pool.allocate(16)); + pool.collectRetired(5, 0); + _ = try pool.allocate(16); + try std.testing.expectEqual(@as(usize, 16), b.size); +} diff --git a/modules/world-lod/src/lod_ingest.zig b/modules/world-lod/src/lod_ingest.zig index 897ac892..be8e0d44 100644 --- a/modules/world-lod/src/lod_ingest.zig +++ b/modules/world-lod/src/lod_ingest.zig @@ -575,23 +575,23 @@ test "writeIngestedColumn respects provenance authority" { // Worldgen cell can be upgraded by chunk_derived. data.setColumn(1, 1, 5.0, .plains, .{ .surface = .grass, .subsurface = .dirt, .foundation = .stone }, 0, LODWaterState.empty, LODLightingHint.daylight, LODVegetationHint.empty); - const upgraded = writeIngestedColumn(data, 1, 1, .{ .terrain_height = 40.0, .biome = .forest }, .chunk_derived); + const upgraded = writeIngestedColumn(&data, 1, 1, .{ .terrain_height = 40.0, .biome = .forest }, .chunk_derived); try testing.expect(upgraded); try testing.expectEqual(@as(f32, 40.0), data.getHeight(1, 1)); // chunk_derived refresh can replace previous chunk_derived data. - const refreshed = writeIngestedColumn(data, 1, 1, .{ .terrain_height = 45.0, .biome = .forest }, .chunk_derived); + const refreshed = writeIngestedColumn(&data, 1, 1, .{ .terrain_height = 45.0, .biome = .forest }, .chunk_derived); try testing.expect(refreshed); try testing.expectEqual(@as(f32, 45.0), data.getHeight(1, 1)); // chunk_derived must NOT be overwritten by worldgen. - const clobbered = writeIngestedColumn(data, 1, 1, .{ .terrain_height = 1.0, .biome = .desert }, .worldgen); + const clobbered = writeIngestedColumn(&data, 1, 1, .{ .terrain_height = 1.0, .biome = .desert }, .worldgen); try testing.expect(!clobbered); try testing.expectEqual(@as(f32, 45.0), data.getHeight(1, 1)); try testing.expectEqual(BiomeId.forest, data.biomes[1 + data.width]); // edited beats chunk_derived. - const edited = writeIngestedColumn(data, 1, 1, .{ .terrain_height = 70.0, .biome = .mountains }, .edited); + const edited = writeIngestedColumn(&data, 1, 1, .{ .terrain_height = 70.0, .biome = .mountains }, .edited); try testing.expect(edited); try testing.expectEqual(@as(f32, 70.0), data.getHeight(1, 1)); try testing.expectEqual(LODColumnProvenance.edited, data.getColumnProvenance(1, 1)); diff --git a/modules/world-lod/src/lod_manager.zig b/modules/world-lod/src/lod_manager.zig index d5cc6b4a..ffda782d 100644 --- a/modules/world-lod/src/lod_manager.zig +++ b/modules/world-lod/src/lod_manager.zig @@ -72,6 +72,7 @@ const lod_manager_ingestion_ops = @import("lod_manager_ingestion_ops.zig"); const lod_manager_generation_ops = @import("lod_manager_generation_ops.zig"); const lod_manager_upload_ops = @import("lod_manager_upload_ops.zig"); const lod_manager_eviction_ops = @import("lod_manager_eviction_ops.zig"); +const CacheIoPipeline = @import("lod_cache_io.zig").CacheIoPipeline; const LODColumnProvenance = world_core.LODColumnProvenance; const ChunkCoordKey = lod_manager_context.ChunkCoordKey; const ChunkCoordKeyContext = lod_manager_context.ChunkCoordKeyContext; @@ -94,6 +95,8 @@ const LODTransition = struct { pub const LODCacheStore = struct { cache_dir_path: ?[]const u8 = null, logged_legacy_cache_notice: bool = false, + store_size_cap_mb: u32 = lod_store.DEFAULT_STORE_SIZE_CAP_MB, + use_config_store_size_cap: bool = false, store_mutex: sync.Mutex = .{}, }; @@ -166,8 +169,10 @@ pub const LODManager = struct { // Stats stats: LODStats, + profiling: @import("lod_stats.zig").LODProfilingCollector, cache_hits: u32, cache_misses: u32, + cancelled_jobs: u32, // Mutex for thread safety mutex: sync.RwLock, @@ -198,6 +203,11 @@ pub const LODManager = struct { // Optional source-data persistence store. cache_store: LODCacheStore, + // Dedicated, bounded single-worker cache I/O pipeline. It is intentionally + // separate from the generation pool so disk latency cannot occupy terrain + // generation workers or the update path. + cache_io: *CacheIoPipeline, + // Keep cleanup behavior testable, but allow the live world to opt out. cleanup_covered_regions: bool = true, @@ -214,6 +224,11 @@ pub const LODManager = struct { mesh_candidates_scratch: std.ArrayListUnmanaged(MeshCandidate) = .empty, upload_candidates_scratch: std.ArrayListUnmanaged(UploadCandidate) = .empty, + // Number of regions admitted to the generation/mesh/upload pipeline. This + // is maintained at lifecycle boundaries so scheduling does not need to + // recount every region map before each admission pass. + pending_region_count: usize = 0, + // Callback type to check if a regular chunk is loaded and renderable pub const ChunkChecker = lod_gpu.ChunkChecker; @@ -228,7 +243,7 @@ pub const LODManager = struct { /// Test-only lightweight manager state. Cache ownership starts disabled; /// tests that need persistence should call enableCache() and free it after. - pub fn initCacheTestManager(allocator: std.mem.Allocator, cache_dir_path: []const u8) Self { + pub fn initCacheTestManager(allocator: std.mem.Allocator, cache_dir_path: []const u8) !Self { return lod_manager_cache_ops.initCacheTestManager(allocator, cache_dir_path); } @@ -292,6 +307,17 @@ pub const LODManager = struct { return lod_manager_core.render(self, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer); } + /// 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); + } + + /// 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); + } + /// Enables persistent source-data caching for LOD regions below `save_dir_path`. /// Allocates and stores the cache path; errors indicate allocation or filesystem setup failure. pub fn enableCache(self: *Self, save_dir_path: []const u8) !void { @@ -304,6 +330,23 @@ 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. + pub fn flushCacheIO(self: *Self) void { + return lod_manager_cache_ops.flushCacheIO(self); + } + + /// Applies completed cache reads/writes without waiting for worker I/O. + pub fn drainCacheCompletions(self: *Self) void { + return lod_manager_cache_ops.drainCacheCompletions(self); + } + + /// Shutdown-only cache flush. This may block until the bounded worker has + /// serialized and persisted accepted snapshots. + pub fn shutdownCacheIO(self: *Self) void { + return lod_manager_cache_ops.shutdownCacheIO(self); + } + /// Builds the persistent cache key for an LOD region using generator identity and LOD coordinates. /// The returned key is deterministic for the same generator seed/version and region key. pub fn cacheKey(self: *const Self, key: LODRegionKey) lod_cache.Key { @@ -382,6 +425,12 @@ pub const LODManager = struct { return lod_manager_cache_ops.saveCachedSourceData(self, key, data); } + /// Dispatches a generation fallback after an asynchronous cache miss. + /// Cache completion processing is the only caller; it never performs I/O. + pub fn dispatchCacheMiss(self: *Self, key: LODRegionKey, token: u32) void { + return lod_manager_generation_ops.dispatchCacheMiss(self, key, token); + } + /// Installs the callback used to resolve full-detail chunks for ingestion retries. /// Call from the world update thread before requesting deferred ingestion. pub fn setChunkResolver(self: *Self, resolver: ChunkResolver) void { @@ -496,6 +545,11 @@ pub const LODManager = struct { return lod_manager_upload_ops.requeueUpload(self, lod_idx, chunk); } + /// Replaces a failed compact upload with the maintained CPU heightfield. + pub fn fallbackCompactMeshToCpu(self: *Self, mesh: *LODMesh, chunk: *LODChunk) !void { + return lod_manager_generation_ops.fallbackCompactMeshToCpu(self, mesh, chunk); + } + /// Counts direct finer child regions that are currently renderable for a parent region. /// Used to decide parent fallback visibility and transition fades. pub fn countRenderableChildren(self: *Self, key: LODRegionKey) u8 { diff --git a/modules/world-lod/src/lod_manager_cache_ops.zig b/modules/world-lod/src/lod_manager_cache_ops.zig index e6672605..00fec9a2 100644 --- a/modules/world-lod/src/lod_manager_cache_ops.zig +++ b/modules/world-lod/src/lod_manager_cache_ops.zig @@ -1,70 +1,18 @@ const std = @import("std"); -const Self = @import("lod_manager.zig").LODManager; const fs = @import("fs"); +const Self = @import("lod_manager.zig").LODManager; +const LODRegionKey = @import("lod_chunk.zig").LODRegionKey; +const LODSimplifiedData = @import("lod_chunk.zig").LODSimplifiedData; const lod_chunk = @import("lod_chunk.zig"); -const LODLevel = lod_chunk.LODLevel; -const LODChunk = lod_chunk.LODChunk; -const LODRegionKey = lod_chunk.LODRegionKey; -const LODConfig = lod_chunk.LODConfig; -const LODState = lod_chunk.LODState; -const LODSimplifiedData = lod_chunk.LODSimplifiedData; -const ILODConfig = lod_chunk.ILODConfig; -const world_core = @import("world-core"); -const Chunk = world_core.Chunk; -const CHUNK_SIZE_X = world_core.CHUNK_SIZE_X; -const CHUNK_SIZE_Z = world_core.CHUNK_SIZE_Z; -const LODColumnProvenance = world_core.LODColumnProvenance; -const Vec3 = @import("engine-math").Vec3; -const Mat4 = @import("engine-math").Mat4; -const Vertex = @import("engine-rhi").Vertex; -const engine_core = @import("engine-core"); -const log = engine_core.log; -const JobSystem = engine_core.job_system; -const JobQueue = JobSystem.JobQueue; -const WorkerPool = JobSystem.WorkerPool; -const Job = JobSystem.Job; -const RingBuffer = engine_core.ring_buffer.RingBuffer; -const LODMesh = @import("lod_mesh.zig").LODMesh; -const lod_gpu = @import("lod_upload_queue.zig"); -const LODGPUBridge = lod_gpu.LODGPUBridge; -const LODRenderInterface = lod_gpu.LODRenderInterface; -const LODRenderLayer = lod_gpu.LODRenderLayer; -const ChunkChecker = lod_gpu.ChunkChecker; -const MeshMap = lod_gpu.MeshMap; -const RegionMap = lod_gpu.RegionMap; -const lod_scheduler = @import("lod_scheduler.zig"); const lod_cache = @import("lod_cache.zig"); const lod_store = @import("lod_store.zig"); -const lod_ingest = @import("lod_ingest.zig"); -const TextureAtlas = @import("engine-assets").TextureAtlas; -const LODGenerator = @import("lod_generator.zig").LODGenerator; -const LODStats = @import("lod_stats.zig").LODStats; -const manager_ctx = @import("lod_manager_context.zig"); -const ChunkCoordKey = manager_ctx.ChunkCoordKey; -const ChunkCoordKeyContext = manager_ctx.ChunkCoordKeyContext; -const ChunkCoordSet = std.HashMap(ChunkCoordKey, void, ChunkCoordKeyContext, std.hash_map.default_max_load_percentage); -const ChunkResolver = manager_ctx.ChunkResolver; -const PendingIngestion = manager_ctx.PendingIngestion; -const PlayerChunkPos = manager_ctx.PlayerChunkPos; -const MAX_CACHE_LOADS_PER_UPDATE = manager_ctx.MAX_CACHE_LOADS_PER_UPDATE; -const MAX_MEMORY_EVICTIONS_PER_UPDATE = manager_ctx.MAX_MEMORY_EVICTIONS_PER_UPDATE; -const MAX_MESH_DELETIONS_PER_SWEEP = manager_ctx.MAX_MESH_DELETIONS_PER_SWEEP; -const DEFAULT_LOD_UPLOAD_BUDGET_BYTES = manager_ctx.DEFAULT_LOD_UPLOAD_BUDGET_BYTES; -const LOD_UPLOAD_BUDGET_ENV = manager_ctx.LOD_UPLOAD_BUDGET_ENV; -const LOD_UPDATE_DIVISOR = manager_ctx.LOD_UPDATE_DIVISOR; -const DELETION_SWEEP_SECONDS = manager_ctx.DELETION_SWEEP_SECONDS; -const CHUNK_COVERAGE_PADDING = manager_ctx.CHUNK_COVERAGE_PADDING; -const MIN_LOD_WORKERS = manager_ctx.MIN_LOD_WORKERS; -const MAX_LOD_WORKERS = manager_ctx.MAX_LOD_WORKERS; -const MAX_PENDING_INGESTIONS = manager_ctx.MAX_PENDING_INGESTIONS; -const PENDING_INGESTION_TTL = manager_ctx.PENDING_INGESTION_TTL; -const EDIT_FLUSH_COOLDOWN = manager_ctx.EDIT_FLUSH_COOLDOWN; -const LOD_FRAME_DT_APPROX = manager_ctx.LOD_FRAME_DT_APPROX; -const lodUploadBudgetBytes = manager_ctx.lodUploadBudgetBytes; -const wouldExceedUploadBudget = manager_ctx.wouldExceedUploadBudget; -const isUploadPressureError = manager_ctx.isUploadPressureError; +const cache_io = @import("lod_cache_io.zig"); +const log = @import("engine-core").log; +/// Cache setup is an explicit world-load operation. It may perform synchronous +/// filesystem maintenance, unlike the frame/update path. pub fn enableCache(self: *Self, save_dir_path: []const u8) !void { + self.flushCacheIO(); const cache_dir_path = try self.allocator.dupe(u8, save_dir_path); errdefer self.allocator.free(cache_dir_path); @@ -73,115 +21,174 @@ pub fn enableCache(self: *Self, save_dir_path: []const u8) !void { .generator_identity_hash = self.generator.identity_hash, .generator_version = self.generator.version, }; - if (try lod_store.readHeader(self.allocator, save_dir_path)) |stored_header| { - if (stored_header.seed != live_header.seed) { - // Seed mismatch => the entire store is foreign; discard all. - log.log.warn("LOD store seed mismatch; discarding foreign LOD source store", .{}); - try lod_store.deleteStore(self.allocator, save_dir_path); - } else if (stored_header.lod_data_version != live_header.lod_data_version) { - log.log.warn("LOD store data version changed; discarding stale LOD source store", .{}); - try lod_store.deleteStore(self.allocator, save_dir_path); - } else if (stored_header.generator_identity_hash != live_header.generator_identity_hash or + if (stored_header.seed != live_header.seed or + stored_header.lod_data_version != live_header.lod_data_version or + stored_header.generator_identity_hash != live_header.generator_identity_hash or stored_header.generator_version != live_header.generator_version) { - // Generator changed but seed is the same: worldgen-sampled LOD - // is stale, but chunk-derived columns reflect real saved chunks - // and remain valid. Cached payloads are keyed by generator - // identity/version, so the old data is naturally ignored on - // read (cache miss) and regenerated with the new generator; - // `setGeneratedColumn` is provenance-aware, so regeneration - // preserves any chunk_derived/edited columns. We still delete - // the orphaned old-keyed store for disk hygiene. - log.log.warn("LOD store generator mismatch; regenerating stale worldgen LOD (chunk-derived data re-ingested from saved chunks)", .{}); + log.log.warn("LOD store identity changed; discarding stale LOD source store", .{}); try lod_store.deleteStore(self.allocator, save_dir_path); } } - try lod_store.writeHeader(self.allocator, save_dir_path, live_header); self.mutex.lock(); defer self.mutex.unlock(); - if (self.cache_store.cache_dir_path) |old_path| { - self.allocator.free(old_path); - } + if (self.cache_store.cache_dir_path) |old_path| self.allocator.free(old_path); self.cache_store.cache_dir_path = cache_dir_path; self.cache_store.logged_legacy_cache_notice = false; log.log.info("LOD source store enabled at '{s}/lod'", .{cache_dir_path}); } +/// Enqueues at most one source-data snapshot. Cloning is intentionally the +/// only update-thread cache work; serialization and filesystem writes run on +/// CacheIoPipeline's dedicated worker. pub fn flushDirtyStores(self: *Self) void { - const save_dir_path = self.cacheDirPathSnapshot() orelse return; - defer self.allocator.free(save_dir_path); + _ = queueDirtyStores(self, 1); +} - var write_key: ?LODRegionKey = null; - var write_bytes: ?[]u8 = null; +pub fn flushCacheIO(self: *Self) void { + self.cache_io.waitUntilIdle(); + drainCacheCompletions(self); +} - { - self.mutex.lock(); - defer self.mutex.unlock(); +/// 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 { + 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(); +} - const active = lod_chunk.activeLODCount(self.config); - var i: usize = 1; - while (i < active) : (i += 1) { - var it = self.regions[i].iterator(); - while (it.next()) |entry| { - const lcp = entry.value_ptr.*; - if (!lcp.store_dirty) continue; - switch (lcp.data) { - .simplified => |*data| { - const key = LODRegionKey{ .rx = lcp.region_x, .rz = lcp.region_z, .lod = lcp.lodLevel() }; - const cache_key = self.cacheKey(key); - const bytes = lod_cache.serialize(data, cache_key, self.allocator) catch |err| { - log.log.warn("Failed to serialize LOD{} cache ({}, {}): {}", .{ @intFromEnum(key.lod), key.rx, key.rz, err }); - return; - }; - lcp.store_dirty = false; - write_key = key; - write_bytes = bytes; - }, - else => {}, +pub fn drainCacheCompletions(self: *Self) void { + var completions: std.ArrayListUnmanaged(cache_io.Completion) = .empty; + defer { + for (completions.items) |*completion| deinitCompletion(completion); + completions.deinit(self.allocator); + } + self.cache_io.drainCompletions(&completions); + + for (completions.items) |*completion| switch (completion.*) { + .read => |*read| { + var dispatch_generation = false; + var log_legacy_notice = false; + self.mutex.lock(); + const chunk = self.regions[@intFromEnum(read.key.lod)].get(read.key); + if (chunk) |region| { + if (region.cache_read_queued and region.job_token == read.token and region.getState() == .queued_for_generation) { + region.cache_read_queued = false; + log_legacy_notice = read.used_legacy; + switch (read.result) { + .hit => |*data| { + if (regionRequiresSpans(self, read.key) and !data.hasVerticalSpans()) { + data.deinit(); + read.result = .miss; + self.cache_misses += 1; + dispatch_generation = true; + } else { + region.data = .{ .simplified = data.* }; + // Ownership moved into the region. Retag the + // completion so deferred cleanup does not + // deinitialize the consumed payload. + read.result = .miss; + region.updateHeightBoundsFromData(); + region.source_revision +%= 1; + region.setState(.generated); + self.cache_hits += 1; + } + }, + .miss => { + self.cache_misses += 1; + dispatch_generation = true; + }, + } } - break; // one region per frame keeps frame cost bounded } - if (write_bytes != null) break; + self.mutex.unlock(); + if (log_legacy_notice) self.logLegacyCacheNotice(); + if (dispatch_generation) self.dispatchCacheMiss(read.key, read.token); + }, + .write => |write| { + // Explicit saveCachedSourceData requests are not tied to a live + // region lifecycle and use revision zero. + if (write.revision == 0) continue; + self.mutex.lock(); + if (self.regions[@intFromEnum(write.key.lod)].get(write.key)) |region| { + if (region.store_write_queued and region.source_revision == write.revision) { + region.store_write_queued = false; + region.store_dirty = !write.success; + region.store_size_limited = write.size_limited; + region.store_size_limit_cap_mb = if (write.size_limited) write.store_size_cap_mb else 0; + } else if (region.store_write_queued) { + // A newer source snapshot arrived while this write was in + // flight. Preserve dirty state and let a later update + // enqueue that newer revision. + region.store_write_queued = false; + } + } + self.mutex.unlock(); + }, + }; +} + +fn queueDirtyStores(self: *Self, max_count: usize) usize { + const path = self.cacheDirPathSnapshot() orelse return 0; + defer self.allocator.free(path); + var queued: usize = 0; + self.mutex.lock(); + defer self.mutex.unlock(); + const active = lod_chunk.activeLODCount(self.config); + var level: usize = 1; + while (level < active and queued < max_count) : (level += 1) { + var iter = self.regions[level].iterator(); + while (iter.next()) |entry| { + if (queued >= max_count) break; + const region = entry.value_ptr.*; + if (!region.store_dirty or region.store_write_queued) continue; + const store_size_cap_mb = if (self.cache_store.use_config_store_size_cap) self.config.getLODStoreSizeCapMB() else self.cache_store.store_size_cap_mb; + if (region.store_size_limited and store_size_cap_mb <= region.store_size_limit_cap_mb) continue; + const snapshot = switch (region.data) { + .simplified => |*data| lod_cache.cloneSourceData(data, entry.key_ptr.*.lod, self.allocator) catch |err| { + log.log.warn("Failed to snapshot LOD{} cache ({}, {}): {}", .{ @intFromEnum(entry.key_ptr.*.lod), entry.key_ptr.*.rx, entry.key_ptr.*.rz, err }); + continue; + }, + else => continue, + }; + const key = entry.key_ptr.*; + const revision = region.source_revision; + region.store_write_queued = true; + const accepted = self.cache_io.enqueueWrite(path, key, self.cacheKey(key), revision, snapshot, store_size_cap_mb) catch |err| blk: { + log.log.warn("Failed to queue LOD{} cache write ({}, {}): {}", .{ @intFromEnum(key.lod), key.rx, key.rz, err }); + break :blk false; + }; + if (!accepted) { + var unused = snapshot; + unused.deinit(); + region.store_write_queued = false; + return queued; + } + queued += 1; } } + return queued; +} - const key = write_key orelse return; - const bytes = write_bytes orelse return; - defer self.allocator.free(bytes); - - const cache_key = self.cacheKey(key); - self.writeStorePayload(save_dir_path, cache_key, bytes) catch |err| { - log.log.warn("Failed to write LOD{} store ({}, {}): {}", .{ @intFromEnum(key.lod), key.rx, key.rz, err }); - self.mutex.lock(); - if (self.regions[@intFromEnum(key.lod)].get(key)) |chunk| { - chunk.store_dirty = true; - } - self.mutex.unlock(); - }; +fn regionRequiresSpans(self: *Self, key: LODRegionKey) bool { + return self.config.getVerticalSpanBudget() > 0 and self.effectiveMeshPath(key.lod) == .column_spans; } pub fn cacheKey(self: *const Self, key: LODRegionKey) lod_cache.Key { - return .{ - .seed = self.generator.seed, - .generator_identity_hash = self.generator.identity_hash, - .generator_version = self.generator.version, - .rx = key.rx, - .rz = key.rz, - .lod = key.lod, - }; + return .{ .seed = self.generator.seed, .generator_identity_hash = self.generator.identity_hash, .generator_version = self.generator.version, .rx = key.rx, .rz = key.rz, .lod = key.lod }; } pub fn legacyCacheFilePath(self: *Self, save_dir_path: []const u8, key: lod_cache.Key) ![]u8 { - const filename = try std.fmt.allocPrint( - self.allocator, - "lod_{}_{}_{}_{}_{}_{}.dat", - .{ key.seed, key.generator_identity_hash, key.generator_version, key.rx, key.rz, @intFromEnum(key.lod) }, - ); - defer self.allocator.free(filename); - return std.fs.path.join(self.allocator, &.{ save_dir_path, "lod_cache", filename }); + return std.fmt.allocPrint(self.allocator, "{s}/lod_cache/lod_{}_{}_{}_{}_{}_{}.dat", .{ save_dir_path, key.seed, key.generator_identity_hash, key.generator_version, key.rx, key.rz, @intFromEnum(key.lod) }); } pub fn logLegacyCacheNotice(self: *Self) void { @@ -195,9 +202,8 @@ pub fn logLegacyCacheNotice(self: *Self) void { pub fn cacheDirPathSnapshot(self: *Self) ?[]u8 { self.mutex.lockShared(); defer self.mutex.unlockShared(); - - const cache_dir_path = self.cache_store.cache_dir_path orelse return null; - return self.allocator.dupe(u8, cache_dir_path) catch |err| { + const path = self.cache_store.cache_dir_path orelse return null; + return self.allocator.dupe(u8, path) catch |err| { log.log.warn("LOD cache path snapshot allocation failed: {}", .{err}); return null; }; @@ -209,6 +215,8 @@ pub fn cacheEnabled(self: *Self) bool { return self.cache_store.cache_dir_path != null; } +// Synchronous helpers remain explicit setup/diagnostic APIs. Update and +// generation paths use CacheIoPipeline exclusively. pub fn readStorePayload(self: *Self, save_dir_path: []const u8, cache_key: lod_cache.Key) !?[]u8 { self.cache_store.store_mutex.lock(); defer self.cache_store.store_mutex.unlock(); @@ -216,12 +224,9 @@ pub fn readStorePayload(self: *Self, save_dir_path: []const u8, cache_key: lod_c } pub fn writeStorePayload(self: *Self, save_dir_path: []const u8, cache_key: lod_cache.Key, bytes: []const u8) !void { - self.mutex.lockShared(); - const store_size_cap_mb = self.config.getLODStoreSizeCapMB(); - self.mutex.unlockShared(); - self.cache_store.store_mutex.lock(); defer self.cache_store.store_mutex.unlock(); + const store_size_cap_mb = if (self.cache_store.use_config_store_size_cap) self.config.getLODStoreSizeCapMB() else self.cache_store.store_size_cap_mb; try lod_store.writePayload(self.allocator, save_dir_path, cache_key, bytes, store_size_cap_mb); } @@ -234,27 +239,21 @@ pub fn deleteStorePayload(self: *Self, save_dir_path: []const u8, cache_key: lod pub fn deleteStoreContainer(self: *Self, path: []const u8) void { self.cache_store.store_mutex.lock(); defer self.cache_store.store_mutex.unlock(); - fs.cwd().deleteFile(path) catch |delete_err| { - if (delete_err != error.FileNotFound) { - log.log.warn("Failed to delete corrupt LOD store container '{s}': {}", .{ path, delete_err }); - } + fs.cwd().deleteFile(path) catch |err| { + if (err != error.FileNotFound) log.log.warn("Failed to delete corrupt LOD store container '{s}': {}", .{ path, err }); }; } pub fn loadCachedSourceData(self: *Self, key: LODRegionKey) ?LODSimplifiedData { - const save_dir_path = self.cacheDirPathSnapshot() orelse return null; - defer self.allocator.free(save_dir_path); - + const path = self.cacheDirPathSnapshot() orelse return null; + defer self.allocator.free(path); const cache_key = self.cacheKey(key); - - if (self.readStorePayload(save_dir_path, cache_key) catch |err| switch (err) { + if (self.readStorePayload(path, cache_key) catch |err| switch (err) { lod_store.StoreError.CorruptContainer => { - const path = lod_store.containerPath(self.allocator, save_dir_path, cache_key) catch null; - if (path) |container_path| { - defer self.allocator.free(container_path); - log.log.warn("Discarding corrupt LOD store container '{s}'", .{container_path}); - self.deleteStoreContainer(container_path); - } + const container_path = lod_store.containerPath(self.allocator, path, cache_key) catch return null; + defer self.allocator.free(container_path); + log.log.warn("Discarding corrupt LOD store container '{s}'", .{container_path}); + self.deleteStoreContainer(container_path); return null; }, else => { @@ -265,33 +264,25 @@ pub fn loadCachedSourceData(self: *Self, key: LODRegionKey) ?LODSimplifiedData { defer self.allocator.free(bytes); return lod_cache.deserialize(bytes, cache_key, self.allocator) catch |err| { log.log.warn("Discarding corrupt LOD store payload LOD{} ({}, {}): {}", .{ @intFromEnum(key.lod), key.rx, key.rz, err }); - self.deleteStorePayload(save_dir_path, cache_key); + self.deleteStorePayload(path, cache_key); return null; }; } - - const path = self.legacyCacheFilePath(save_dir_path, cache_key) catch |err| { - log.log.warn("LOD legacy cache path allocation failed: {}", .{err}); - return null; - }; - defer self.allocator.free(path); - - const bytes = fs.cwd().readFileAlloc(path, self.allocator, 16 * 1024 * 1024) catch |err| switch (err) { + const legacy_path = self.legacyCacheFilePath(path, cache_key) catch return null; + defer self.allocator.free(legacy_path); + const bytes = fs.cwd().readFileAlloc(legacy_path, self.allocator, 16 * 1024 * 1024) catch |err| switch (err) { error.FileNotFound => return null, else => { - log.log.warn("Failed to read legacy LOD cache '{s}': {}", .{ path, err }); + log.log.warn("Failed to read legacy LOD cache '{s}': {}", .{ legacy_path, err }); return null; }, }; defer self.allocator.free(bytes); self.logLegacyCacheNotice(); - return lod_cache.deserialize(bytes, cache_key, self.allocator) catch |err| { - log.log.warn("Discarding corrupt legacy LOD cache '{s}': {}", .{ path, err }); - fs.cwd().deleteFile(path) catch |delete_err| { - if (delete_err != error.FileNotFound) { - log.log.warn("Failed to delete corrupt legacy LOD cache '{s}': {}", .{ path, delete_err }); - } + log.log.warn("Discarding corrupt legacy LOD cache '{s}': {}", .{ legacy_path, err }); + fs.cwd().deleteFile(legacy_path) catch |delete_err| { + if (delete_err != error.FileNotFound) log.log.warn("Failed to delete corrupt legacy LOD cache '{s}': {}", .{ legacy_path, delete_err }); }; return null; }; @@ -309,26 +300,26 @@ pub fn recordCacheMiss(self: *Self) void { self.cache_misses += 1; } +/// Explicit API used by tools/tests. Like frame writes, it snapshots then +/// serializes on the cache worker; callers may use flushCacheIO to wait. pub fn saveCachedSourceData(self: *Self, key: LODRegionKey, data: *const LODSimplifiedData) void { - const save_dir_path = self.cacheDirPathSnapshot() orelse return; - defer self.allocator.free(save_dir_path); - - const cache_key = self.cacheKey(key); - - const bytes = lod_cache.serialize(data, cache_key, self.allocator) catch |err| { - log.log.warn("Failed to serialize LOD{} cache ({}, {}): {}", .{ @intFromEnum(key.lod), key.rx, key.rz, err }); + const path = self.cacheDirPathSnapshot() orelse return; + defer self.allocator.free(path); + const snapshot = lod_cache.cloneSourceData(data, key.lod, self.allocator) catch |err| { + log.log.warn("Failed to snapshot LOD{} cache ({}, {}): {}", .{ @intFromEnum(key.lod), key.rx, key.rz, err }); return; }; - defer self.allocator.free(bytes); - - self.writeStorePayload(save_dir_path, cache_key, bytes) catch |err| { - log.log.warn("Failed to write LOD{} store ({}, {}): {}", .{ @intFromEnum(key.lod), key.rx, key.rz, err }); - }; + const store_size_cap_mb = if (self.cache_store.use_config_store_size_cap) self.config.getLODStoreSizeCapMB() else self.cache_store.store_size_cap_mb; + const accepted = self.cache_io.enqueueWrite(path, key, self.cacheKey(key), 0, snapshot, store_size_cap_mb) catch false; + if (!accepted) { + var unused = snapshot; + unused.deinit(); + } } -pub fn initCacheTestManager(allocator: std.mem.Allocator, cache_dir_path: []const u8) Self { +pub fn initCacheTestManager(allocator: std.mem.Allocator, cache_dir_path: []const u8) !Self { _ = cache_dir_path; - return .{ + var manager = Self{ .allocator = allocator, .config = undefined, .regions = undefined, @@ -339,18 +330,13 @@ pub fn initCacheTestManager(allocator: std.mem.Allocator, cache_dir_path: []cons .player_cx = std.atomic.Value(i32).init(0), .player_cz = std.atomic.Value(i32).init(0), .stats = .{}, + .profiling = .init(false), .cache_hits = 0, .cache_misses = 0, + .cancelled_jobs = 0, .mutex = .{}, .gpu_bridge = undefined, - .generator = .{ - .ptr = undefined, - .generate_heightmap_only = undefined, - .maybe_recenter_cache = undefined, - .seed = 42, - .identity_hash = 99, - .version = 7, - }, + .generator = .{ .ptr = undefined, .generate_heightmap_only = undefined, .maybe_recenter_cache = undefined, .seed = 42, .identity_hash = 99, .version = 7 }, .atlas = undefined, .paused = false, .memory_governor = .{}, @@ -358,7 +344,20 @@ pub fn initCacheTestManager(allocator: std.mem.Allocator, cache_dir_path: []cons .mesh_disposal = .{}, .renderer = undefined, .cache_store = .{}, + .cache_io = undefined, .cleanup_covered_regions = true, .ingestion_queue = @import("lod_manager.zig").LODIngestionQueue.init(allocator), }; + manager.cache_io = try cache_io.CacheIoPipeline.init(allocator, &manager.profiling); + return manager; +} + +fn deinitCompletion(completion: *cache_io.Completion) void { + switch (completion.*) { + .read => |*read| switch (read.result) { + .hit => |*data| data.deinit(), + .miss => {}, + }, + .write => {}, + } } diff --git a/modules/world-lod/src/lod_manager_context.zig b/modules/world-lod/src/lod_manager_context.zig index 89c33ff7..56447888 100644 --- a/modules/world-lod/src/lod_manager_context.zig +++ b/modules/world-lod/src/lod_manager_context.zig @@ -109,9 +109,9 @@ pub fn lodUploadBudgetBytes() usize { return std.math.mul(usize, mb, 1024 * 1024) catch DEFAULT_LOD_UPLOAD_BUDGET_BYTES; } -pub fn wouldExceedUploadBudget(uploaded_bytes: usize, pending_bytes: usize, budget_bytes: usize, uploads: u32) bool { +pub fn wouldExceedUploadBudget(uploaded_bytes: usize, pending_bytes: usize, budget_bytes: usize) bool { if (budget_bytes == 0 or budget_bytes == std.math.maxInt(usize)) return false; - if (pending_bytes == 0 or uploads == 0) return false; + if (pending_bytes == 0) return false; if (uploaded_bytes >= budget_bytes) return true; return pending_bytes > budget_bytes - uploaded_bytes; } diff --git a/modules/world-lod/src/lod_manager_core_ops.zig b/modules/world-lod/src/lod_manager_core_ops.zig index 082f0a1e..2db6c581 100644 --- a/modules/world-lod/src/lod_manager_core_ops.zig +++ b/modules/world-lod/src/lod_manager_core_ops.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const lod_options = @import("world_lod_options"); const Self = @import("lod_manager.zig").LODManager; const fs = @import("fs"); const lod_chunk = @import("lod_chunk.zig"); @@ -65,6 +66,7 @@ const LOD_FRAME_DT_APPROX = manager_ctx.LOD_FRAME_DT_APPROX; const lodUploadBudgetBytes = manager_ctx.lodUploadBudgetBytes; const wouldExceedUploadBudget = manager_ctx.wouldExceedUploadBudget; const isUploadPressureError = manager_ctx.isUploadPressureError; +const TELEPORT_CANCEL_DISTANCE_CHUNKS: i32 = 32; pub fn storePlayerChunkPos(self: *Self, cx: i32, cz: i32) void { self.player_cx.store(cx, .release); @@ -132,8 +134,10 @@ pub fn init(allocator: std.mem.Allocator, config: ILODConfig, gpu_bridge: LODGPU .player_cx = std.atomic.Value(i32).init(0), .player_cz = std.atomic.Value(i32).init(0), .stats = .{}, + .profiling = .init(engine_core.envFlag("ZIGCRAFT_LOD_PROFILE", false) or lod_options.benchmark_lod_profile), .cache_hits = 0, .cache_misses = 0, + .cancelled_jobs = 0, .mutex = .{}, .gpu_bridge = gpu_bridge, .generator = generator, @@ -144,10 +148,14 @@ pub fn init(allocator: std.mem.Allocator, config: ILODConfig, gpu_bridge: LODGPU .mesh_disposal = .{}, .renderer = render_iface, .cleanup_covered_regions = true, - .cache_store = .{}, + .cache_store = .{ .store_size_cap_mb = config.getLODStoreSizeCapMB(), .use_config_store_size_cap = true }, + .cache_io = undefined, .ingestion_queue = LODIngestionQueue.init(allocator), }; + mgr.cache_io = try @import("lod_cache_io.zig").CacheIoPipeline.init(allocator, &mgr.profiling); + errdefer mgr.cache_io.deinit(); + const cpu_count = std.Thread.getCpuCount() catch MIN_LOD_WORKERS; const lod_worker_count = std.math.clamp(cpu_count / 2, MIN_LOD_WORKERS, MAX_LOD_WORKERS); @@ -172,6 +180,17 @@ pub fn deinit(self: *Self) void { // map-open leave it false so LOD generation runs uninterrupted. self.job_dispatcher.stop_flag.store(true, .release); + const cancellation_lock_wait_timer = self.profiling.begin(); + self.mutex.lock(); + self.profiling.end(.manager_lock_wait, cancellation_lock_wait_timer); + const cancellation_lock_hold_timer = self.profiling.begin(); + for (0..LODLevel.count) |i| { + var iter = self.regions[i].iterator(); + while (iter.next()) |entry| entry.value_ptr.*.requestCancellation(); + } + self.profiling.end(.manager_lock_hold, cancellation_lock_hold_timer); + self.mutex.unlock(); + // Stop and cleanup queues for (0..LODLevel.count) |i| { self.job_dispatcher.queues[i].stop(); @@ -183,6 +202,12 @@ pub fn deinit(self: *Self) void { pool.deinit(); } + // This is an explicit shutdown boundary: it is permitted to wait for the + // dedicated cache worker and flush source snapshots before region storage + // is released. The normal update path only enqueues/drains completions. + self.shutdownCacheIO(); + self.cache_io.deinit(); + for (0..LODLevel.count) |i| { self.job_dispatcher.queues[i].deinit(); self.allocator.destroy(self.job_dispatcher.queues[i]); @@ -191,6 +216,7 @@ pub fn deinit(self: *Self) void { // Cleanup meshes var mesh_iter = self.meshes[i].iterator(); while (mesh_iter.next()) |entry| { + entry.value_ptr.*.releasePendingCompactTile(); self.gpu_bridge.destroy(entry.value_ptr.*); self.allocator.destroy(entry.value_ptr.*); } @@ -228,6 +254,11 @@ pub fn deinit(self: *Self) void { /// Update LOD system with player position pub fn update(self: *Self, player_pos: Vec3, player_velocity: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque) !void { + const update_timer = self.profiling.begin(); + defer self.profiling.end(.update, update_timer); + // Completion application only: no filesystem or serialization occurs on + // this frame/update path. + self.drainCacheCompletions(); if (self.paused) return; // Deferred deletion handling. LOD meshes do not carry frame fences yet, @@ -243,7 +274,14 @@ pub fn update(self: *Self, player_pos: Vec3, player_velocity: Vec3, chunk_checke if (!std.math.isFinite(player_pos.x) or !std.math.isFinite(player_pos.z)) return; const pc = worldToChunkFromFloat(player_pos.x, player_pos.z); + const previous_pc = self.loadPlayerChunkPos(); self.storePlayerChunkPos(pc.chunk_x, pc.chunk_z); + const moved_x = @abs(@as(i64, pc.chunk_x) - @as(i64, previous_pc.cx)); + const moved_z = @abs(@as(i64, pc.chunk_z) - @as(i64, previous_pc.cz)); + const teleport_distance_sq = @as(i64, TELEPORT_CANCEL_DISTANCE_CHUNKS) * TELEPORT_CANCEL_DISTANCE_CHUNKS; + if (moved_x * moved_x + moved_z * moved_z >= teleport_distance_sq) { + cancelWorkOutsideHorizon(self, pc.chunk_x, pc.chunk_z); + } // Keep LOD job priorities fresh as the player moves. doReprioritize is // LOD-aware (scales region coords to chunk space, preserves LOD-bias @@ -280,6 +318,7 @@ pub fn update(self: *Self, player_pos: Vec3, player_velocity: Vec3, chunk_checke // Queue a small horizon seed first so something appears quickly, then // let LOD0/LOD1/LOD2 refinements replace the coarse fallback. + const scheduling_timer = self.profiling.begin(); var order_idx: usize = 0; while (order_idx < active_lod_count) : (order_idx += 1) { const i = lod_scheduler.priorityLevelIndex(order_idx, active_lod_count); @@ -287,24 +326,29 @@ pub fn update(self: *Self, player_pos: Vec3, player_velocity: Vec3, chunk_checke log.log.warn("LOD queue error for level {}: {} (non-fatal)", .{ i, err }); }; } + self.profiling.end(.scheduling, scheduling_timer); self.processQueuedGenerations(player_velocity) catch |err| { log.log.warn("LOD cache/generation dispatch error: {} (non-fatal)", .{err}); }; // Process state transitions + const transitions_timer = self.profiling.begin(); self.processStateTransitions(player_velocity) catch |err| { log.log.warn("LOD state transitions error: {} (non-fatal)", .{err}); }; + self.profiling.end(.state_transition, transitions_timer); // Process uploads (limited per frame) self.processUploads(); // Update stats self.updateStats(); + const eviction_timer = self.profiling.begin(); self.enforceMemoryBudget() catch |err| { log.log.warn("LOD memory budget eviction error: {} (non-fatal)", .{err}); }; + self.profiling.end(.eviction, eviction_timer); // Periodic WARN-level LOD stats so logs/zigcraft.log shows LOD fill // progress by default (no env vars needed). update_tick counts frames; @@ -327,9 +371,11 @@ pub fn update(self: *Self, player_pos: Vec3, player_velocity: Vec3, chunk_checke } // Unload distant regions + const distant_eviction_timer = self.profiling.begin(); self.unloadDistantRegions() catch |err| { log.log.warn("LOD unload error: {} (non-fatal)", .{err}); }; + self.profiling.end(.eviction, distant_eviction_timer); // Chunk-derived ingestion: replay deferred ingestions, flush debounced // player edits, and persist any dirty source regions to the store. @@ -341,7 +387,15 @@ pub fn update(self: *Self, player_pos: Vec3, player_velocity: Vec3, chunk_checke /// Get current statistics pub fn getStats(self: *Self) LODStats { - return self.stats; + 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(); + var snapshot = self.stats; + snapshot.profiling = self.profiling.snapshot(); + return snapshot; } /// Pause all LOD generation @@ -350,6 +404,72 @@ pub fn pause(self: *Self) void { for (0..LODLevel.count) |i| { self.job_dispatcher.queues[i].setPaused(true); } + + // setPaused clears queued jobs. In-flight jobs are cancelled through their + // per-region signal and token so they cannot publish stale data after an + // unpause, even when unpause happens before a worker checks cancellation. + self.mutex.lock(); + defer self.mutex.unlock(); + for (0..LODLevel.count) |i| { + var iter = self.regions[i].iterator(); + while (iter.next()) |entry| { + const chunk = entry.value_ptr.*; + switch (chunk.getState()) { + .generating => { + chunk.requestCancellation(); + chunk.job_token +%= 1; + chunk.cache_read_queued = false; + if (self.pending_region_count > 0) self.pending_region_count -= 1; + chunk.setState(.missing); + self.cancelled_jobs +|= 1; + }, + .meshing => { + chunk.requestCancellation(); + chunk.job_token +%= 1; + // The generated source data remains valid. Requeue the + // mesh transition after unpause without discarding its + // pending admission slot. + chunk.setState(.generated); + self.cancelled_jobs +|= 1; + }, + .queued_for_generation => { + chunk.requestCancellation(); + chunk.job_token +%= 1; + chunk.cache_read_queued = false; + if (self.pending_region_count > 0) self.pending_region_count -= 1; + chunk.setState(.missing); + self.cancelled_jobs +|= 1; + }, + else => {}, + } + } + } +} + +pub fn cancelWorkOutsideHorizon(self: *Self, player_cx: i32, player_cz: i32) void { + self.mutex.lock(); + defer self.mutex.unlock(); + const radii = self.config.getRadii(); + const active = lod_chunk.activeLODCount(self.config); + for (0..LODLevel.count) |i| { + var iter = self.regions[i].iterator(); + while (iter.next()) |entry| { + const chunk = entry.value_ptr.*; + switch (chunk.getState()) { + .queued_for_generation, .generating, .meshing => {}, + else => continue, + } + if (i < active and entry.key_ptr.*.chunkBounds().intersectsRadius(player_cx, player_cz, radii[i])) continue; + + const was_generation = chunk.getState() != .meshing; + chunk.requestCancellation(); + chunk.job_token +%= 1; + chunk.cache_read_queued = false; + if (was_generation and self.pending_region_count > 0) self.pending_region_count -= 1; + chunk.setState(if (was_generation) .missing else .generated); + self.cancelled_jobs +|= 1; + } + } } /// Resume LOD generation @@ -391,10 +511,40 @@ pub fn isInRange(self: *const Self, chunk_x: i32, chunk_z: i32) bool { /// NOTE: Acquires a shared lock on LODManager. LODRenderer must NOT attempt to acquire /// a write lock on LODManager during rendering to avoid deadlocks. pub fn render(self: *Self, view_proj: Mat4, camera_pos: Vec3, chunk_checker: ?ChunkChecker, checker_ctx: ?*anyopaque, use_frustum: bool, max_distance_chunks: ?i32, layer: LODRenderLayer) 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.render(&self.meshes, &self.regions, self.config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer, &self.stats); + const visibility_timer = self.profiling.begin(); + defer self.profiling.end(.visibility, visibility_timer); + self.renderer.render(&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); +} + +/// 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 { + 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.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); +} + +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 { + 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); } pub fn pointDistanceSquared(x0: i32, z0: i32, x1: i32, z1: i32) i64 { diff --git a/modules/world-lod/src/lod_manager_eviction_ops.zig b/modules/world-lod/src/lod_manager_eviction_ops.zig index 969c32fc..48106ca0 100644 --- a/modules/world-lod/src/lod_manager_eviction_ops.zig +++ b/modules/world-lod/src/lod_manager_eviction_ops.zig @@ -16,7 +16,6 @@ const CHUNK_SIZE_Z = world_core.CHUNK_SIZE_Z; const LODColumnProvenance = world_core.LODColumnProvenance; const Vec3 = @import("engine-math").Vec3; const Mat4 = @import("engine-math").Mat4; -const Vertex = @import("engine-rhi").Vertex; const engine_core = @import("engine-core"); const log = engine_core.log; const JobSystem = engine_core.job_system; @@ -83,7 +82,11 @@ pub fn unloadDistantForLevel(self: *Self, lod: LODLevel, max_radius: i32) !void defer to_remove.deinit(self.allocator); // Hold lock for entire operation to prevent races with worker threads + const lock_wait_timer = self.profiling.begin(); self.mutex.lock(); + 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.unlock(); const player = self.loadPlayerChunkPos(); @@ -107,6 +110,9 @@ pub fn unloadDistantForLevel(self: *Self, lod: LODLevel, max_radius: i32) !void if (to_remove.items.len > 0) { for (to_remove.items) |key| { if (storage.get(key)) |chunk| { + if (chunk.getState() != .missing and chunk.getState() != .renderable and self.pending_region_count > 0) { + self.pending_region_count -= 1; + } // Clean up mesh before removing chunk const meshes = &self.meshes[@intFromEnum(lod)]; self.noteRegionRemoved(key, chunk); @@ -125,24 +131,40 @@ pub fn unloadDistantForLevel(self: *Self, lod: LODLevel, max_radius: i32) !void } pub fn queueMeshDeletion(self: *Self, mesh: *LODMesh) void { + const memory = mesh.memorySnapshot(); self.mesh_disposal.queue.append(self.allocator, mesh) catch { + mesh.releasePendingCompactTile(); self.gpu_bridge.destroy(mesh); self.allocator.destroy(mesh); + return; }; + self.profiling.addDeferredDeletionBytes(memory.capacity_bytes); + self.profiling.addDeferredDeletionCpuBytes(memory.pending_upload_bytes); } pub fn processMeshDeletions(self: *Self, max_count: usize) void { + const lock_wait_timer = self.profiling.begin(); + self.mutex.lock(); + 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.unlock(); + const count = @min(max_count, self.mesh_disposal.queue.items.len); if (count == 0) return; - // LODMesh does not carry a per-frame fence today, so destruction still - // requires GPU idle. Bound each sweep so a memory-pressure eviction burst - // cannot turn into an unbounded main-thread stall. - self.gpu_bridge.waitIdle(); + // Meshes have already spent a full disposal grace period outside the + // render maps. Whole buffers are retired by the RHI's frame-fence deletion + // queue; pooled ranges are only returned here, after that grace period. + // Do not turn routine streaming eviction into a device-global stall. var processed: usize = 0; while (processed < count) : (processed += 1) { const idx = self.mesh_disposal.queue.items.len - 1; const mesh = self.mesh_disposal.queue.items[idx]; + const memory = mesh.memorySnapshot(); + self.profiling.removeDeferredDeletionBytes(memory.capacity_bytes); + self.profiling.removeDeferredDeletionCpuBytes(memory.pending_upload_bytes); + mesh.releasePendingCompactTile(); self.gpu_bridge.destroy(mesh); self.allocator.destroy(mesh); self.mesh_disposal.queue.items.len = idx; @@ -155,7 +177,12 @@ pub fn regionMemoryBytes(chunk: *const LODChunk, mesh: ?*LODMesh) usize { .simplified => |*s| total += s.totalMemoryBytes(), else => {}, } - if (mesh) |m| total += m.capacity * @sizeOf(Vertex); + if (mesh) |m| { + const memory = m.memorySnapshot(); + // Returning a pooled range does not release the renderer's backing + // buffer or CPU shadow, so it cannot lower the known memory total. + if (!memory.pooled) total += memory.capacity_bytes; + } return total; } @@ -167,7 +194,10 @@ pub fn enforceMemoryBudget(self: *Self) !void { // Decay path: comfortably under budget -> gradually re-expand radii. if (self.memory_governor.used_bytes < hysteresis_low) { + const lock_wait_timer = self.profiling.begin(); self.mutex.lock(); + self.profiling.end(.manager_lock_wait, lock_wait_timer); + const lock_hold_timer = self.profiling.begin(); var decayed = false; for (&self.memory_governor.radius_shrink_chunks) |*s| { if (s.* > 0) { @@ -175,6 +205,7 @@ pub fn enforceMemoryBudget(self: *Self) !void { decayed = true; } } + self.profiling.end(.manager_lock_hold, lock_hold_timer); self.mutex.unlock(); if (decayed) { log.log.trace("LOD memory below 80% budget; re-expanding radii", .{}); @@ -188,7 +219,11 @@ pub fn enforceMemoryBudget(self: *Self) !void { var candidates = std.ArrayListUnmanaged(Candidate).empty; defer candidates.deinit(self.allocator); + const lock_wait_timer = self.profiling.begin(); self.mutex.lock(); + 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.unlock(); const player = self.loadPlayerChunkPos(); @@ -263,9 +298,17 @@ pub fn enforceMemoryBudget(self: *Self) !void { /// Update statistics pub fn updateStats(self: *Self) void { self.stats.reset(); - var mem_usage: usize = 0; + var source_data_cpu_bytes: usize = 0; + var direct_mesh_gpu_bytes: usize = 0; + var pending_cpu_upload_bytes: usize = 0; + var deferred_deletion_gpu_bytes: usize = 0; + var deferred_deletion_cpu_bytes: usize = 0; + 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(); for (0..LODLevel.count) |i| { @@ -277,31 +320,77 @@ pub fn updateStats(self: *Self) void { // Calculate actual memory usage for this chunk's data switch (chunk.data) { .simplified => |*s| { - mem_usage += s.totalMemoryBytes(); + source_data_cpu_bytes += s.totalMemoryBytes(); }, else => {}, } } - // Add mesh memory + // Direct meshes own a dedicated GPU buffer. Pooled mesh capacity is a + // sub-allocation and is accounted once by the renderer pool snapshot. var mesh_iter = self.meshes[i].iterator(); while (mesh_iter.next()) |entry| { const mesh = entry.value_ptr.*; + const memory = mesh.memorySnapshot(); self.stats.mesh_count[i] += 1; - self.stats.mesh_vertices[i] += mesh.vertexCount(); - mem_usage += mesh.capacity * @sizeOf(Vertex); + self.stats.mesh_vertices[i] += memory.vertex_count; + pending_cpu_upload_bytes += memory.pending_upload_bytes; + if (!memory.pooled) direct_mesh_gpu_bytes += memory.capacity_bytes; } self.stats.gen_queue_depth[i] = @intCast(self.job_dispatcher.queues[i].count()); self.stats.upload_queue_depth[i] = @intCast(self.upload_queues[i].count()); } - self.stats.addMemory(mem_usage); + // A queued mesh is no longer in the render map. Count its retained GPU + // allocation and pending CPU vertices independently; pooled allocations + // remain included in the pool capacity below and must not be double-counted. + for (self.mesh_disposal.queue.items) |mesh| { + const memory = mesh.memorySnapshot(); + deferred_deletion_gpu_bytes += memory.capacity_bytes; + deferred_deletion_cpu_bytes += memory.pending_upload_bytes; + if (!memory.pooled) direct_mesh_gpu_bytes += memory.capacity_bytes; + } + + const pool_memory = self.renderer.memoryStats(); + const known_memory_bytes = source_data_cpu_bytes + + direct_mesh_gpu_bytes + + pool_memory.pool_gpu_capacity_bytes + + pool_memory.pool_cpu_shadow_bytes + + pending_cpu_upload_bytes + + deferred_deletion_cpu_bytes; + self.stats.addMemory(known_memory_bytes); + self.stats.pool_gpu_capacity_bytes = @intCast(pool_memory.pool_gpu_capacity_bytes); + self.stats.pool_gpu_allocated_bytes = @intCast(pool_memory.pool_gpu_allocated_bytes); + self.stats.pool_gpu_slack_bytes = @intCast(pool_memory.pool_gpu_slack_bytes); + self.stats.pool_cpu_shadow_bytes = @intCast(pool_memory.pool_cpu_shadow_bytes); + self.stats.compact_pool_capacity_bytes = @intCast(pool_memory.compact_pool_capacity_bytes); + self.stats.compact_pool_allocated_bytes = @intCast(pool_memory.compact_pool_allocated_bytes); + self.stats.compact_pool_free_bytes = @intCast(pool_memory.compact_pool_free_bytes); + self.stats.compact_pool_retired_bytes = @intCast(pool_memory.compact_pool_retired_bytes); + self.stats.direct_mesh_gpu_bytes = @intCast(direct_mesh_gpu_bytes); + self.stats.pending_cpu_upload_bytes = @intCast(pending_cpu_upload_bytes); + self.stats.deferred_deletion_gpu_bytes = @intCast(deferred_deletion_gpu_bytes); + self.stats.deferred_deletion_cpu_bytes = @intCast(deferred_deletion_cpu_bytes); self.stats.store_hits = self.cache_hits; self.stats.store_misses = self.cache_misses; self.stats.cache_hits = self.cache_hits; self.stats.cache_misses = self.cache_misses; - self.memory_governor.used_bytes = mem_usage; + self.stats.cancelled_jobs = self.cancelled_jobs; + self.memory_governor.used_bytes = known_memory_bytes; + self.profiling.setPendingCpuUploadBytes(pending_cpu_upload_bytes); + self.profiling.setMemoryAccounting( + pool_memory.pool_gpu_capacity_bytes, + pool_memory.pool_gpu_allocated_bytes, + pool_memory.pool_gpu_slack_bytes, + pool_memory.pool_cpu_shadow_bytes, + pool_memory.compact_pool_capacity_bytes, + pool_memory.compact_pool_allocated_bytes, + pool_memory.compact_pool_free_bytes, + pool_memory.compact_pool_retired_bytes, + direct_mesh_gpu_bytes, + known_memory_bytes, + ); if (engine_core.envFlag("ZIGCRAFT_LOD_DIAG", false)) { const S = struct { @@ -333,7 +422,11 @@ pub fn updateStats(self: *Self) void { /// Free LOD meshes where all underlying chunks are loaded pub fn unloadLODWhereChunksLoaded(self: *Self, checker: ChunkChecker, ctx: *anyopaque) void { // Lock exclusive because we modify meshes and regions maps + const lock_wait_timer = self.profiling.begin(); self.mutex.lock(); + 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.unlock(); const active_lod_count = lod_chunk.activeLODCount(self.config); @@ -366,6 +459,9 @@ pub fn unloadLODWhereChunksLoaded(self: *Self, checker: ChunkChecker, ctx: *anyo self.queueMeshDeletion(mesh_entry.value); } if (storage.fetchRemove(rem_key)) |chunk_entry| { + if (chunk_entry.value.getState() != .missing and chunk_entry.value.getState() != .renderable and self.pending_region_count > 0) { + self.pending_region_count -= 1; + } chunk_entry.value.deinit(self.allocator); self.allocator.destroy(chunk_entry.value); } diff --git a/modules/world-lod/src/lod_manager_generation_ops.zig b/modules/world-lod/src/lod_manager_generation_ops.zig index 2b8c00a2..4033348c 100644 --- a/modules/world-lod/src/lod_manager_generation_ops.zig +++ b/modules/world-lod/src/lod_manager_generation_ops.zig @@ -25,6 +25,7 @@ const WorkerPool = JobSystem.WorkerPool; const Job = JobSystem.Job; const RingBuffer = engine_core.ring_buffer.RingBuffer; const LODMesh = @import("lod_mesh.zig").LODMesh; +const lod_tile = @import("lod_tile.zig"); const lod_gpu = @import("lod_upload_queue.zig"); const LODGPUBridge = lod_gpu.LODGPUBridge; const LODRenderInterface = lod_gpu.LODRenderInterface; @@ -73,16 +74,6 @@ pub fn queueLODRegions(self: *Self, lod: LODLevel, velocity: Vec3, chunk_checker const radii = self.config.getRadii(); const active_lod_count = lod_chunk.activeLODCount(self.config); const use_vertical_spans = self.config.getVerticalSpanBudget() > 0 and self.effectiveMeshPath(lod) == .column_spans; - var pending_regions: usize = 0; - for (0..active_lod_count) |i| { - var iter = self.regions[i].iterator(); - while (iter.next()) |entry| { - switch (entry.value_ptr.*.getState()) { - .missing, .renderable => {}, - else => pending_regions += 1, - } - } - } self.mutex.unlock(); const Coverage = struct { @@ -109,7 +100,7 @@ pub fn queueLODRegions(self: *Self, lod: LODLevel, velocity: Vec3, chunk_checker // Route every region through the bounded admission path, whether or // not persistent LOD caching is enabled. .defer_generation_dispatch = true, - .pending_regions = &pending_regions, + .pending_regions = &self.pending_region_count, .use_vertical_spans = use_vertical_spans, }, lod, velocity, chunk_checker, checker_ctx); } @@ -120,7 +111,8 @@ pub fn processQueuedGenerations(self: *Self, velocity: Vec3) !void { candidates.clearRetainingCapacity(); const player = self.loadPlayerChunkPos(); - const cache_enabled = self.cacheEnabled(); + const cache_path = self.cacheDirPathSnapshot(); + defer if (cache_path) |path| self.allocator.free(path); var active_lod_count: usize = 0; self.mutex.lock(); @@ -162,68 +154,88 @@ pub fn processQueuedGenerations(self: *Self, velocity: Vec3) !void { var cache_reads: usize = 0; for (candidates.items) |candidate| { - var cached_data: ?LODSimplifiedData = null; - var attempted_cache = false; - if (cache_enabled and cache_reads < MAX_CACHE_LOADS_PER_UPDATE) { - cache_reads += 1; - attempted_cache = true; - cached_data = self.loadCachedSourceData(candidate.key); - if (cached_data) |*cached| { - if (candidate.want_spans and !cached.hasVerticalSpans()) { - cached.deinit(); - cached_data = null; + if (cache_path) |path| { + if (cache_reads < MAX_CACHE_LOADS_PER_UPDATE) { + cache_reads += 1; + self.mutex.lock(); + if (candidate.chunk.getState() == .queued_for_generation and candidate.chunk.job_token == candidate.job_token and !candidate.chunk.cache_read_queued) { + candidate.chunk.cache_read_queued = true; + const accepted = self.cache_io.enqueueRead(path, candidate.key, self.cacheKey(candidate.key), candidate.job_token) catch false; + if (accepted) { + self.mutex.unlock(); + continue; + } + candidate.chunk.cache_read_queued = false; } + self.mutex.unlock(); } } - if (cached_data) |data| { - self.recordCacheHit(); - self.mutex.lock(); - if (candidate.chunk.getState() == .queued_for_generation and candidate.chunk.job_token == candidate.job_token) { - candidate.chunk.data = .{ .simplified = data }; - candidate.chunk.updateHeightBoundsFromData(); - candidate.chunk.setState(.generated); - } else { - var stale_data = data; - stale_data.deinit(); - } - self.mutex.unlock(); - continue; - } - - if (attempted_cache) self.recordCacheMiss(); + dispatchGenerationCandidate(self, candidate) catch |err| return err; + } +} - self.mutex.lock(); - if (candidate.chunk.getState() != .queued_for_generation or candidate.chunk.job_token != candidate.job_token) { - self.mutex.unlock(); - continue; - } - candidate.chunk.setState(.generating); +pub fn dispatchCacheMiss(self: *Self, key: LODRegionKey, token: u32) void { + const lod_idx = @intFromEnum(key.lod); + const player = self.loadPlayerChunkPos(); + self.mutex.lock(); + const chunk = self.regions[lod_idx].get(key) orelse { self.mutex.unlock(); + return; + }; + if (chunk.getState() != .queued_for_generation or chunk.job_token != token) { + self.mutex.unlock(); + return; + } + const active_lod_count = lod_chunk.activeLODCount(self.config); + const scale: i32 = @intCast(key.lod.chunksPerSide()); + const candidate = GenerationCandidate{ + .key = key, + .chunk = chunk, + .encoded_priority = lod_scheduler.encodePriority(key.lod, key.rx * scale + @divFloor(scale, 2) - player.cx, key.rz * scale + @divFloor(scale, 2) - player.cz, Vec3.zero, active_lod_count), + .level = @intCast(lod_idx), + .coord_scale = scale, + .job_token = token, + .lod_radius = self.config.getRadii()[lod_idx], + .want_spans = self.config.getVerticalSpanBudget() > 0 and self.effectiveMeshPath(key.lod) == .column_spans, + }; + self.mutex.unlock(); + dispatchGenerationCandidate(self, candidate) catch |err| { + log.log.warn("Failed to dispatch cache-miss LOD{} generation ({}, {}): {}", .{ @intFromEnum(key.lod), key.rx, key.rz, err }); + }; +} - self.job_dispatcher.queues[LODLevel.count - 1].push(.{ - .type = .chunk_generation, - .dist_sq = candidate.encoded_priority, - .data = .{ - .chunk = .{ - .x = candidate.chunk.region_x, - .z = candidate.chunk.region_z, - .job_token = candidate.job_token, - .lod_level = candidate.level, - .coord_scale = candidate.coord_scale, - .lod_radius = candidate.lod_radius, - .use_vertical_spans = candidate.want_spans, - }, - }, - }) catch |err| { - self.mutex.lock(); - if (candidate.chunk.getState() == .generating and candidate.chunk.job_token == candidate.job_token) { - candidate.chunk.setState(.queued_for_generation); - } - self.mutex.unlock(); - return err; - }; +fn dispatchGenerationCandidate(self: *Self, candidate: GenerationCandidate) !void { + self.mutex.lock(); + if (candidate.chunk.getState() != .queued_for_generation or candidate.chunk.job_token != candidate.job_token or candidate.chunk.cache_read_queued) { + self.mutex.unlock(); + return; } + candidate.chunk.resetCancellation(); + candidate.chunk.setState(.generating); + self.mutex.unlock(); + + const dispatch_timer = self.profiling.begin(); + self.job_dispatcher.queues[LODLevel.count - 1].push(.{ + .type = .chunk_generation, + .dist_sq = candidate.encoded_priority, + .data = .{ .chunk = .{ + .x = candidate.chunk.region_x, + .z = candidate.chunk.region_z, + .job_token = candidate.job_token, + .lod_level = candidate.level, + .coord_scale = candidate.coord_scale, + .lod_radius = candidate.lod_radius, + .use_vertical_spans = candidate.want_spans, + } }, + }) catch |err| { + self.profiling.end(.generation_dispatch, dispatch_timer); + self.mutex.lock(); + if (candidate.chunk.getState() == .generating and candidate.chunk.job_token == candidate.job_token) candidate.chunk.setState(.queued_for_generation); + self.mutex.unlock(); + return err; + }; + self.profiling.end(.generation_dispatch, dispatch_timer); } /// Process state transitions (generated -> meshing -> ready) @@ -251,19 +263,25 @@ pub fn processStateTransitions(self: *Self, velocity: Vec3) !void { var iter = self.regions[i].iterator(); while (iter.next()) |entry| { const chunk = entry.value_ptr.*; + if (chunk.isPinned()) continue; + if (chunk.getState() == .renderable) { + if (self.meshes[i].get(entry.key_ptr.*)) |mesh| { + if (mesh.isCompact() and mesh.compactDrawFailed()) { + chunk.compact_disabled = true; + chunk.setState(.generated); + } + } + } if (chunk.getState() == .generated) { const center_cx = chunk.region_x * scale + @divFloor(scale, 2); const center_cz = chunk.region_z * scale + @divFloor(scale, 2); const encoded_priority = lod_scheduler.encodePriority(lod, center_cx - player.cx, center_cz - player.cz, velocity, active_lod_count); - // Append before flipping state so an allocation failure - // leaves the chunk in .generated (re-tried next tick) - // instead of stuck in .meshing with no queued job. mesh_candidates.append(self.allocator, .{ .chunk = chunk, .encoded_priority = encoded_priority, .level = level, .coord_scale = scale, .job_token = chunk.job_token, .lod_radius = radii[i] }) catch |err| { self.mutex.unlock(); return err; }; - chunk.setState(.meshing); } else if (chunk.getState() == .mesh_ready) { + if (self.meshes[i].get(entry.key_ptr.*)) |mesh| patchCompactAprons(self, i, entry.key_ptr.*, mesh); const center_cx = chunk.region_x * scale + @divFloor(scale, 2); const center_cz = chunk.region_z * scale + @divFloor(scale, 2); const encoded_priority = lod_scheduler.encodePriority(lod, center_cx - player.cx, center_cz - player.cz, velocity, active_lod_count); @@ -271,7 +289,6 @@ pub fn processStateTransitions(self: *Self, velocity: Vec3) !void { self.mutex.unlock(); return err; }; - chunk.setState(.uploading); } } } @@ -285,6 +302,16 @@ pub fn processStateTransitions(self: *Self, velocity: Vec3) !void { } }.lt); for (mesh_candidates.items) |mc| { + // Transition and enqueue atomically with respect to workers. A worker + // may pop immediately, but cannot inspect the chunk until this short + // manager critical section publishes the matching state. + self.mutex.lock(); + if (mc.chunk.getState() != .generated or mc.chunk.job_token != mc.job_token) { + self.mutex.unlock(); + continue; + } + mc.chunk.setState(.meshing); + mc.chunk.resetCancellation(); self.job_dispatcher.queues[LODLevel.count - 1].push(.{ .type = .chunk_meshing, .dist_sq = mc.encoded_priority, @@ -299,13 +326,21 @@ pub fn processStateTransitions(self: *Self, velocity: Vec3) !void { }, }, }) catch |err| { - self.mutex.lock(); if (mc.chunk.getState() == .meshing and mc.chunk.job_token == mc.job_token) { mc.chunk.setState(.generated); } self.mutex.unlock(); return err; }; + // Never let a worker invoke RHI or release a live pooled range. Once + // the replacement job is guaranteed to be queued, detach the previous + // representation and retire it through the normal frame-delayed + // disposal queue. The worker will create a fresh mesh object. + const mesh_key = LODRegionKey{ .rx = mc.chunk.region_x, .rz = mc.chunk.region_z, .lod = @enumFromInt(mc.level) }; + if (self.meshes[mc.level].fetchRemove(mesh_key)) |old_mesh| { + self.queueMeshDeletion(old_mesh.value); + } + self.mutex.unlock(); } // Uploads go to per-level FIFO queues. Sort by the same encoded priority @@ -317,14 +352,34 @@ pub fn processStateTransitions(self: *Self, velocity: Vec3) !void { } }.lt); for (upload_candidates.items) |uc| { + self.mutex.lock(); + if (uc.chunk.getState() != .mesh_ready) { + self.mutex.unlock(); + continue; + } + uc.chunk.setState(.uploading); self.upload_queues[uc.level].push(uc.chunk) catch |err| { - self.mutex.lock(); if (uc.chunk.getState() == .uploading) { uc.chunk.setState(.mesh_ready); } self.mutex.unlock(); return err; }; + self.mutex.unlock(); + } +} + +fn patchCompactAprons(self: *Self, lod_index: usize, key: LODRegionKey, mesh: *LODMesh) void { + const neighbors = [_]struct { dx: i32, dz: i32, edge: lod_tile.TileEdge }{ + .{ .dx = -1, .dz = 0, .edge = .west }, + .{ .dx = 1, .dz = 0, .edge = .east }, + .{ .dx = 0, .dz = -1, .edge = .north }, + .{ .dx = 0, .dz = 1, .edge = .south }, + }; + for (neighbors) |neighbor| { + const neighbor_key = LODRegionKey{ .rx = key.rx + neighbor.dx, .rz = key.rz + neighbor.dz, .lod = key.lod }; + const neighbor_mesh = self.meshes[lod_index].get(neighbor_key) orelse continue; + _ = mesh.patchCompactNeighbor(neighbor.edge, neighbor_mesh); } } @@ -350,6 +405,13 @@ pub fn getOrCreateMesh(self: *Self, key: LODRegionKey) !*LODMesh { /// Build mesh for an LOD chunk (called after generation completes) pub fn buildMeshForChunk(self: *Self, chunk: *LODChunk) !void { + // A meshing worker pins its chunk while it owns the immutable source data. + // Ingestion defers writes to .meshing chunks and eviction skips pinned + // chunks, so retaining the manager lock through mesh construction is both + // unnecessary and a substantial source of contention. + std.debug.assert(chunk.isPinned()); + std.debug.assert(chunk.getState() == .meshing); + const key = LODRegionKey{ .rx = chunk.region_x, .rz = chunk.region_z, @@ -358,14 +420,22 @@ pub fn buildMeshForChunk(self: *Self, chunk: *LODChunk) !void { const mesh = try self.getOrCreateMesh(key); - // Access chunk.data under shared lock - the data is read-only during meshing - // and the chunk is pinned, so we just need to ensure visibility - self.mutex.lockShared(); - defer self.mutex.unlockShared(); - switch (chunk.data) { .simplified => |*data| { const bounds = chunk.worldBounds(); + // LOD3/4 can use the diagnostic compact heightfield path while the + // expanded CPU-built GPU mesh remains the production fallback. + // Compact water is temporarily quarantined on RADV: the direct + // compact water vertex path can cause a rejected command stream on + // real saved worlds even with validation clean. Preserve water by + // routing wet regions through the maintained expanded CPU mesh. + if (shouldUseCompactTiles(self, chunk) and !hasRenderableWater(data)) { + mesh.buildCompactTile(data) catch |err| switch (err) { + error.UnsupportedSourceFeatures => {}, + else => return err, + }; + if (mesh.isCompact()) return; + } switch (self.effectiveMeshPath(chunk.lodLevel())) { .heightfield => try mesh.buildFromSimplifiedData(data, bounds.min_x, bounds.min_z, self.atlas), .column_spans => try mesh.buildFromColumnSpans(data, bounds.min_x, bounds.min_z, self.atlas), @@ -391,6 +461,45 @@ pub fn buildMeshForChunk(self: *Self, chunk: *LODChunk) !void { } } +fn hasRenderableWater(data: *const LODSimplifiedData) bool { + for (data.water) |water| { + if (water.is_surface and water.coverage > 0.001) return true; + } + return false; +} + +/// Converts an unrenderable compact upload to the established CPU heightfield +/// route. The upload task pins `chunk`, so its simplified source is stable for +/// this synchronous recovery and can immediately be requeued without a hole. +pub fn fallbackCompactMeshToCpu(self: *Self, mesh: *LODMesh, chunk: *LODChunk) !void { + std.debug.assert(mesh.isCompact()); + self.gpu_bridge.destroy(mesh); + mesh.clearRetiredState(); + switch (chunk.data) { + .simplified => |*data| { + const bounds = chunk.worldBounds(); + try mesh.buildFromSimplifiedData(data, bounds.min_x, bounds.min_z, self.atlas); + }, + else => return error.InvalidState, + } +} + +fn shouldUseCompactTiles(self: *Self, chunk: *const LODChunk) bool { + if (chunk.compact_disabled) return false; + const lod = chunk.lodLevel(); + if (lod != .lod3 and lod != .lod4) return false; + const mode = engine_core.getenv("ZIGCRAFT_LOD_COMPACT") orelse "auto"; + if (std.ascii.eqlIgnoreCase(mode, "off")) return false; + // Capability checks prove that the required Vulkan entry points and + // resources exist, but the compact path still causes command-stream + // rejection on RADV in mixed wet/dry saved worlds. Auto therefore fails + // closed until driver qualification is represented by a real capability + // probe. `force` remains available for bounded diagnostics. + if (std.ascii.eqlIgnoreCase(mode, "auto")) return false; + if (std.ascii.eqlIgnoreCase(mode, "force")) return self.gpu_bridge.supportsCompact(); + return false; +} + pub fn effectiveMeshPath(self: *Self, lod: LODLevel) lod_chunk.LODMeshPath { // Far bands stay as high-resolution stepped block columns. LOD2 keeps // the richer span path so mid-distance cliffs/trees remain voxel-like @@ -424,6 +533,13 @@ pub fn processLODJob(ctx: *anyopaque, job: Job) void { return; }; + // Reject invalidated work before any state reconciliation. A stale queued + // job must never demote a newer job for the same region. + if (chunk.job_token != job.data.chunk.job_token) { + self.mutex.unlock(); + return; + } + // Stale job check (too far from player) const player = self.loadPlayerChunkPos(); const radius = job.data.chunk.lod_radius; @@ -436,18 +552,13 @@ pub fn processLODJob(ctx: *anyopaque, job: Job) void { if (!job_key.chunkBounds().intersectsRadius(player.cx, player.cz, radius)) { if (chunk.getState() == .generating or chunk.getState() == .meshing) { + if (self.pending_region_count > 0) self.pending_region_count -= 1; chunk.setState(.missing); } self.mutex.unlock(); return; } - // Skip if token mismatch - if (chunk.job_token != job.data.chunk.job_token) { - self.mutex.unlock(); - return; - } - // Check state and capture job type before releasing lock const current_state = chunk.getState(); const job_type = job.type; @@ -477,13 +588,18 @@ pub fn processLODJob(ctx: *anyopaque, job: Job) void { switch (job_type) { .chunk_generation => { + const generation_timer = self.profiling.begin(); + defer self.profiling.end(.worker_generation, generation_timer); // Initialize simplified data if needed if (needs_data_init) { var data = if (use_vertical_spans) LODSimplifiedData.initWithVerticalSpans(self.allocator, lod_level) catch { new_state = .missing; self.mutex.lock(); - if (chunk.job_token == job.data.chunk.job_token) chunk.setState(new_state); + if (chunk.job_token == job.data.chunk.job_token) { + if (self.pending_region_count > 0) self.pending_region_count -= 1; + chunk.setState(new_state); + } chunk.unpin(); self.mutex.unlock(); return; @@ -492,25 +608,31 @@ pub fn processLODJob(ctx: *anyopaque, job: Job) void { LODSimplifiedData.init(self.allocator, lod_level) catch { new_state = .missing; self.mutex.lock(); - if (chunk.job_token == job.data.chunk.job_token) chunk.setState(new_state); + if (chunk.job_token == job.data.chunk.job_token) { + if (self.pending_region_count > 0) self.pending_region_count -= 1; + chunk.setState(new_state); + } chunk.unpin(); self.mutex.unlock(); return; }; // Generate heightmap data (expensive, done without lock). - // Pass the stop flag so teardown/pause can interrupt the - // multi-second coarse-LOD heightmap loop instead of - // forcing the worker-join to block until it finishes. - self.generator.generateHeightmapOnly(&data, chunk.region_x, chunk.region_z, lod_level, &self.job_dispatcher.stop_flag); + // Pass the region cancellation signal so pause and teleport + // can interrupt a multi-second coarse-LOD generation loop. + // Teardown sets both the manager flag and every region signal. + self.generator.generateHeightmapOnly(&data, chunk.region_x, chunk.region_z, lod_level, &chunk.cancel_requested); // If generation was aborted, discard the partial data // and leave the chunk in .missing so it re-generates later. - if (self.job_dispatcher.stop_flag.load(.acquire)) { + if (self.job_dispatcher.stop_flag.load(.acquire) or chunk.cancellationRequested()) { data.deinit(); new_state = .missing; self.mutex.lock(); - if (chunk.job_token == job.data.chunk.job_token) chunk.setState(new_state); + if (chunk.job_token == job.data.chunk.job_token) { + if (self.pending_region_count > 0) self.pending_region_count -= 1; + chunk.setState(new_state); + } chunk.unpin(); self.mutex.unlock(); return; @@ -520,13 +642,15 @@ pub fn processLODJob(ctx: *anyopaque, job: Job) void { self.mutex.lock(); chunk.data = .{ .simplified = data }; chunk.updateHeightBoundsFromData(); - chunk.store_dirty = true; + chunk.markSourceDirty(); self.mutex.unlock(); } success = true; new_state = .generated; }, .chunk_meshing => { + const mesh_timer = self.profiling.begin(); + defer self.profiling.end(.worker_mesh_construction, mesh_timer); // Build mesh (expensive, done without lock) // Note: buildMeshForChunk -> getOrCreateMesh acquires its own lock self.buildMeshForChunk(chunk) catch |err| { diff --git a/modules/world-lod/src/lod_manager_ingestion_ops.zig b/modules/world-lod/src/lod_manager_ingestion_ops.zig index 7f8d25af..44601a6d 100644 --- a/modules/world-lod/src/lod_manager_ingestion_ops.zig +++ b/modules/world-lod/src/lod_manager_ingestion_ops.zig @@ -133,7 +133,8 @@ pub fn applyIngestionToRegions(self: *Self, cx: i32, cz: i32, chunk: *const Chun // Defer if a mesh/generation/upload job is mid-flight for // this region: writing source data concurrently with a // mesh job reading it (under the shared lock) would race. - if (lod_chunk_ptr.getState() == .generating or + if (lod_chunk_ptr.isPinned() or + lod_chunk_ptr.getState() == .generating or lod_chunk_ptr.getState() == .meshing or lod_chunk_ptr.getState() == .uploading) { @@ -145,8 +146,7 @@ pub fn applyIngestionToRegions(self: *Self, cx: i32, cz: i32, chunk: *const Chun const min_z: i32 = lod_chunk_ptr.region_z * region_size; const written = lod_ingest.downsampleChunkIntoRegion(chunk, cx, cz, data, min_x, min_z, region_size, provenance); if (written == 0) continue; - lod_chunk_ptr.dirty = true; - lod_chunk_ptr.store_dirty = true; + lod_chunk_ptr.markSourceDirty(); lod_chunk_ptr.updateHeightBoundsFromData(); // Force a remesh of already-rendered regions so the new // chunk-derived data becomes visible. diff --git a/modules/world-lod/src/lod_manager_internal_tests.zig b/modules/world-lod/src/lod_manager_internal_tests.zig index fe447284..00b27ee9 100644 --- a/modules/world-lod/src/lod_manager_internal_tests.zig +++ b/modules/world-lod/src/lod_manager_internal_tests.zig @@ -12,6 +12,7 @@ const world_core = @import("world-core"); const LODColumnProvenance = world_core.LODColumnProvenance; const Vec3 = @import("engine-math").Vec3; const Vertex = @import("engine-rhi").Vertex; +const TextureAtlas = @import("engine-assets").TextureAtlas; const RingBuffer = @import("engine-core").ring_buffer.RingBuffer; const JobQueue = @import("engine-core").job_system.JobQueue; const LODMesh = @import("lod_mesh.zig").LODMesh; @@ -25,6 +26,8 @@ const manager_mod = @import("lod_manager.zig"); const LODManager = manager_mod.LODManager; const MAX_CACHE_LOADS_PER_UPDATE = @import("lod_manager_context.zig").MAX_CACHE_LOADS_PER_UPDATE; const DEFAULT_LOD_UPLOAD_BUDGET_BYTES = @import("lod_manager_context.zig").DEFAULT_LOD_UPLOAD_BUDGET_BYTES; +const wouldExceedUploadBudget = @import("lod_manager_context.zig").wouldExceedUploadBudget; +const cancelWorkOutsideHorizon = @import("lod_manager_core_ops.zig").cancelWorkOutsideHorizon; const testing = std.testing; test "LODManager cache helpers save and reload source data" { @@ -35,7 +38,8 @@ test "LODManager cache helpers save and reload source data" { var path_buf: [fs.max_path_bytes]u8 = undefined; const save_dir_path = try dir.realpath(".", &path_buf); - var manager = LODManager.initCacheTestManager(testing.allocator, save_dir_path); + var manager = try LODManager.initCacheTestManager(testing.allocator, save_dir_path); + defer manager.cache_io.deinit(); try manager.enableCache(save_dir_path); defer if (manager.cache_store.cache_dir_path) |path| testing.allocator.free(path); const key = LODRegionKey{ .rx = 2, .rz = -3, .lod = .lod1 }; @@ -49,6 +53,7 @@ test "LODManager cache helpers save and reload source data" { }, 0xFF112233, .empty, .daylight, .empty); manager.saveCachedSourceData(key, &data); + manager.flushCacheIO(); const store_path = try lod_store.containerPath(testing.allocator, save_dir_path, manager.cacheKey(key)); defer testing.allocator.free(store_path); @@ -71,7 +76,8 @@ test "LODManager cache helpers delete corrupt cache files" { var path_buf: [fs.max_path_bytes]u8 = undefined; const save_dir_path = try dir.realpath(".", &path_buf); - var manager = LODManager.initCacheTestManager(testing.allocator, save_dir_path); + var manager = try LODManager.initCacheTestManager(testing.allocator, save_dir_path); + defer manager.cache_io.deinit(); 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 = 0, .lod = .lod2 }; @@ -105,7 +111,8 @@ test "LODManager enableCache deletes stale generator-keyed store and writes live const stale_key = lod_cache.Key{ .seed = 42, .generator_identity_hash = 1234, .generator_version = 1, .rx = 0, .rz = 0, .lod = .lod1 }; try lod_store.writePayload(testing.allocator, save_dir_path, stale_key, "stale", lod_store.DEFAULT_STORE_SIZE_CAP_MB); - var manager = LODManager.initCacheTestManager(testing.allocator, save_dir_path); + var manager = try LODManager.initCacheTestManager(testing.allocator, save_dir_path); + defer manager.cache_io.deinit(); manager.cache_store.cache_dir_path = null; try manager.enableCache(save_dir_path); defer if (manager.cache_store.cache_dir_path) |path| testing.allocator.free(path); @@ -134,7 +141,8 @@ test "LODManager enableCache deletes stale data-version store" { const stale_key = lod_cache.Key{ .seed = 42, .generator_identity_hash = 99, .generator_version = 7, .rx = 0, .rz = 0, .lod = .lod1 }; try lod_store.writePayload(testing.allocator, save_dir_path, stale_key, "stale", lod_store.DEFAULT_STORE_SIZE_CAP_MB); - var manager = LODManager.initCacheTestManager(testing.allocator, save_dir_path); + var manager = try LODManager.initCacheTestManager(testing.allocator, save_dir_path); + defer manager.cache_io.deinit(); manager.cache_store.cache_dir_path = null; try manager.enableCache(save_dir_path); defer if (manager.cache_store.cache_dir_path) |path| testing.allocator.free(path); @@ -144,7 +152,7 @@ test "LODManager enableCache deletes stale data-version store" { try testing.expectEqual(lod_cache.CACHE_VERSION, header.lod_data_version); } -test "LODManager queued generation reloads source store on main thread" { +test "LODManager queued generation applies source-store completion asynchronously" { var tmp_dir = testing.tmpDir(.{}); defer tmp_dir.cleanup(); @@ -163,15 +171,18 @@ test "LODManager queued generation reloads source store on main thread" { .foundation = .stone, }, 0xFF445566, .empty, .daylight, .empty); - var writer = LODManager.initCacheTestManager(testing.allocator, save_dir_path); + var writer = try LODManager.initCacheTestManager(testing.allocator, save_dir_path); + defer writer.cache_io.deinit(); writer.config = config.interface(); writer.cache_store.cache_dir_path = null; try writer.enableCache(save_dir_path); writer.saveCachedSourceData(key, &source); + writer.flushCacheIO(); if (writer.cache_store.cache_dir_path) |path| testing.allocator.free(path); writer.ingestion_queue.edit_dirty.deinit(); - var manager = LODManager.initCacheTestManager(testing.allocator, save_dir_path); + var manager = try LODManager.initCacheTestManager(testing.allocator, save_dir_path); + defer manager.cache_io.deinit(); manager.config = config.interface(); manager.cache_store.cache_dir_path = null; try manager.enableCache(save_dir_path); @@ -199,6 +210,9 @@ test "LODManager queued generation reloads source store on main thread" { manager.job_dispatcher.queues[i].deinit(); testing.allocator.destroy(manager.job_dispatcher.queues[i]); } + manager.generation_candidates_scratch.deinit(testing.allocator); + manager.mesh_candidates_scratch.deinit(testing.allocator); + manager.upload_candidates_scratch.deinit(testing.allocator); } const chunk = try testing.allocator.create(LODChunk); @@ -209,6 +223,12 @@ test "LODManager queued generation reloads source store on main thread" { try manager.processQueuedGenerations(Vec3.zero); + // The update-side call only enqueues the read; no source data has been + // deserialized or applied until the dedicated cache worker completes. + try testing.expectEqual(@as(u32, 0), manager.cache_hits); + try testing.expectEqual(LODState.queued_for_generation, chunk.state); + manager.flushCacheIO(); + try testing.expectEqual(@as(u32, 1), manager.cache_hits); try testing.expectEqual(@as(usize, 0), manager.job_dispatcher.queues[LODLevel.count - 1].count()); try testing.expectEqual(LODState.generated, chunk.state); @@ -231,7 +251,8 @@ test "LODManager queued generation dispatches beyond cache read budget" { const save_dir_path = try dir.realpath(".", &path_buf); var config = LODConfig{}; - var manager = LODManager.initCacheTestManager(testing.allocator, save_dir_path); + var manager = try LODManager.initCacheTestManager(testing.allocator, save_dir_path); + defer manager.cache_io.deinit(); manager.config = config.interface(); manager.cache_store.cache_dir_path = null; try manager.enableCache(save_dir_path); @@ -259,6 +280,9 @@ test "LODManager queued generation dispatches beyond cache read budget" { manager.job_dispatcher.queues[i].deinit(); testing.allocator.destroy(manager.job_dispatcher.queues[i]); } + manager.generation_candidates_scratch.deinit(testing.allocator); + manager.mesh_candidates_scratch.deinit(testing.allocator); + manager.upload_candidates_scratch.deinit(testing.allocator); } const candidate_count = MAX_CACHE_LOADS_PER_UPDATE + 4; @@ -273,17 +297,22 @@ test "LODManager queued generation dispatches beyond cache read budget" { try manager.processQueuedGenerations(Vec3.zero); + try testing.expectEqual(@as(u32, 0), manager.cache_misses); + try testing.expectEqual(candidate_count - MAX_CACHE_LOADS_PER_UPDATE, manager.job_dispatcher.queues[LODLevel.count - 1].count()); + manager.flushCacheIO(); try testing.expectEqual(@as(u32, @intCast(MAX_CACHE_LOADS_PER_UPDATE)), manager.cache_misses); try testing.expectEqual(candidate_count, manager.job_dispatcher.queues[LODLevel.count - 1].count()); } fn initEvictionTestManager(allocator: std.mem.Allocator, config: *LODConfig) !LODManager { - var manager = LODManager.initCacheTestManager(allocator, ""); + var manager = try LODManager.initCacheTestManager(allocator, ""); manager.config = config.interface(); for (0..LODLevel.count) |i| { manager.regions[i] = RegionMap.init(allocator); manager.meshes[i] = MeshMap.init(allocator); manager.upload_queues[i] = try RingBuffer(*LODChunk).init(allocator, 4); + manager.job_dispatcher.queues[i] = try allocator.create(JobQueue); + manager.job_dispatcher.queues[i].* = JobQueue.init(allocator); } var bridge_ctx: u8 = 0; manager.gpu_bridge = .{ @@ -302,6 +331,7 @@ fn initEvictionTestManager(allocator: std.mem.Allocator, config: *LODConfig) !LO } fn deinitEvictionTestManager(manager: *LODManager) void { + manager.cache_io.deinit(); for (0..LODLevel.count) |i| { var region_iter = manager.regions[i].iterator(); while (region_iter.next()) |entry| { @@ -319,12 +349,112 @@ fn deinitEvictionTestManager(manager: *LODManager) void { } manager.meshes[i].deinit(); manager.upload_queues[i].deinit(); + manager.job_dispatcher.queues[i].deinit(); + manager.allocator.destroy(manager.job_dispatcher.queues[i]); } for (manager.mesh_disposal.queue.items) |mesh| { + manager.gpu_bridge.destroy(mesh); manager.allocator.destroy(mesh); } manager.mesh_disposal.queue.deinit(manager.allocator); manager.ingestion_queue.edit_dirty.deinit(); + manager.generation_candidates_scratch.deinit(manager.allocator); + manager.mesh_candidates_scratch.deinit(manager.allocator); + manager.upload_candidates_scratch.deinit(manager.allocator); +} + +test "LODManager reports pool, direct, pending, and deferred memory separately" { + var config = LODConfig{}; + var manager = try initEvictionTestManager(testing.allocator, &config); + defer deinitEvictionTestManager(&manager); + + const RendererMemory = struct { + fn stats(_: *anyopaque) lod_gpu.LODRendererMemoryStats { + return .{ + .pool_gpu_capacity_bytes = 100, + .pool_gpu_allocated_bytes = 80, + .pool_gpu_slack_bytes = 20, + .pool_cpu_shadow_bytes = 100, + }; + } + }; + manager.renderer = .{ + .render_fn = undefined, + .deinit_fn = undefined, + .ptr = undefined, + .memory_stats_fn = RendererMemory.stats, + }; + manager.profiling = .init(true); + + const key = LODRegionKey{ .rx = 0, .rz = 0, .lod = .lod1 }; + _ = try putTestRegion(&manager, key, .uploading); + const direct_mesh = try putTestPendingMesh(&manager, key, 3); + direct_mesh.capacity = 2; + + const deferred = try testing.allocator.create(LODMesh); + deferred.* = LODMesh.init(testing.allocator, .lod1); + deferred.capacity = 4; + deferred.pending_vertices = try testing.allocator.alloc(Vertex, 5); + manager.queueMeshDeletion(deferred); + + manager.updateStats(); + const stats = manager.getStats(); + + const vertex_bytes = @sizeOf(Vertex); + const expected_direct_gpu = 6 * vertex_bytes; + const expected_pending = 8 * vertex_bytes; + const expected_known = 200 + expected_direct_gpu + expected_pending; + try testing.expectEqual(@as(u64, 100), stats.pool_gpu_capacity_bytes); + try testing.expectEqual(@as(u64, 80), stats.pool_gpu_allocated_bytes); + try testing.expectEqual(@as(u64, 20), stats.pool_gpu_slack_bytes); + try testing.expectEqual(@as(u64, 100), stats.pool_cpu_shadow_bytes); + try testing.expectEqual(@as(u64, expected_direct_gpu), stats.direct_mesh_gpu_bytes); + try testing.expectEqual(@as(u64, 3 * vertex_bytes), stats.pending_cpu_upload_bytes); + try testing.expectEqual(@as(u64, 4 * vertex_bytes), stats.deferred_deletion_gpu_bytes); + try testing.expectEqual(@as(u64, 5 * vertex_bytes), stats.deferred_deletion_cpu_bytes); + try testing.expectEqual(@as(u64, expected_known), stats.memory_used_bytes); + try testing.expectEqual(@as(u64, expected_known), stats.profiling.known_memory_bytes); + try testing.expectEqual(@as(u64, 4 * vertex_bytes), stats.profiling.deferred_deletion_bytes); + try testing.expectEqual(@as(u64, 5 * vertex_bytes), stats.profiling.deferred_deletion_cpu_bytes); +} + +test "LODManager pool-exhaustion upload failure falls back to CPU heightfield and requeues LOD4" { + var config = LODConfig{ .max_uploads_per_frame = 1 }; + var manager = try initEvictionTestManager(testing.allocator, &config); + defer deinitEvictionTestManager(&manager); + + var atlas: TextureAtlas = undefined; + @memset(std.mem.asBytes(&atlas.tile_mappings), 0); + atlas.tile_luminance = [_]TextureAtlas.BlockTileLuminance{TextureAtlas.BlockTileLuminance.uniform(1.0)} ** world_core.MAX_BLOCK_TYPES; + atlas.tile_colors = [_]TextureAtlas.BlockTileColor{TextureAtlas.BlockTileColor.uniform(0xffffff)} ** world_core.MAX_BLOCK_TYPES; + manager.atlas = &atlas; + + const key = LODRegionKey{ .rx = 96, .rz = -96, .lod = .lod4 }; + const chunk = try putTestRegion(&manager, key, .uploading); + chunk.data = .{ .simplified = try LODSimplifiedData.init(testing.allocator, .lod4) }; + const mesh = try manager.getOrCreateMesh(key); + switch (chunk.data) { + .simplified => |*data| try mesh.buildCompactTile(data), + else => unreachable, + } + try manager.upload_queues[@intFromEnum(key.lod)].push(chunk); + + // This mirrors a compact pool exhaustion during a large teleport: compact + // upload fails, while the ordinary CPU mesh upload remains available. + manager.gpu_bridge.on_upload = struct { + fn f(candidate: *LODMesh, _: *anyopaque) @import("engine-rhi").RhiError!void { + if (candidate.isCompact()) return error.OutOfMemory; + } + }.f; + + manager.processUploadsWithBudget(std.math.maxInt(usize)); + try testing.expect(!mesh.isCompact()); + try testing.expect(mesh.pendingVerticesForTest() != null); + try testing.expectEqual(LODState.uploading, chunk.getState()); + try testing.expectEqual(@as(usize, 1), manager.upload_queues[@intFromEnum(key.lod)].count()); + + manager.processUploadsWithBudget(std.math.maxInt(usize)); + try testing.expectEqual(LODState.renderable, chunk.getState()); } fn putTestRegion(manager: *LODManager, key: LODRegionKey, state: LODState) !*LODChunk { @@ -353,6 +483,36 @@ fn putTestPendingMesh(manager: *LODManager, key: LODRegionKey, vertex_count: usi return mesh; } +test "LODManager backs off size-limited store writes until the cap changes" { + 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 = try dir.realpath(".", &path_buf); + + var config = LODConfig{ .lod_store_size_cap_mb = 1 }; + var manager = try initEvictionTestManager(testing.allocator, &config); + defer deinitEvictionTestManager(&manager); + manager.cache_store.cache_dir_path = try testing.allocator.dupe(u8, save_dir); + defer testing.allocator.free(manager.cache_store.cache_dir_path.?); + manager.cache_store.use_config_store_size_cap = true; + + const key = LODRegionKey{ .rx = 0, .rz = 0, .lod = .lod1 }; + const chunk = try putTestRegion(&manager, key, .generated); + chunk.data = .{ .simplified = try LODSimplifiedData.init(testing.allocator, .lod1) }; + chunk.store_dirty = true; + chunk.store_size_limited = true; + chunk.store_size_limit_cap_mb = 1; + + manager.flushDirtyStores(); + try testing.expect(!chunk.store_write_queued); + try testing.expect(chunk.store_dirty); + + config.lod_store_size_cap_mb = 2; + manager.flushDirtyStores(); + try testing.expect(chunk.store_write_queued); +} + const UploadMock = struct { allocator: std.mem.Allocator, calls: u32 = 0, @@ -409,6 +569,34 @@ 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" { + var config = LODConfig{ .max_uploads_per_frame = 8 }; + var manager = try initEvictionTestManager(testing.allocator, &config); + defer deinitEvictionTestManager(&manager); + + 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); + + manager.processUploadsWithBudget(@sizeOf(Vertex) - 1); + + try testing.expectEqual(@as(u32, 0), mock.calls); + try testing.expectEqual(LODState.uploading, chunk.state); + try testing.expectEqual(@as(usize, 1), manager.upload_queues[1].count()); +} + +test "LOD upload budget permits zero-pending and unlimited uploads" { + const pending_bytes = @sizeOf(Vertex); + + try testing.expect(!wouldExceedUploadBudget(0, 0, 1)); + try testing.expect(!wouldExceedUploadBudget(0, pending_bytes, 0)); + try testing.expect(!wouldExceedUploadBudget(0, pending_bytes, std.math.maxInt(usize))); +} + test "LODManager staging pressure failure stops upload sweep" { var config = LODConfig{ .max_uploads_per_frame = 8 }; var manager = try initEvictionTestManager(testing.allocator, &config); @@ -435,6 +623,71 @@ test "LODManager staging pressure failure stops upload sweep" { try testing.expectEqual(@as(u32, 1), manager.stats.upload_failures); } +test "LODManager pause cancels queued and pinned stale work" { + var config = LODConfig{}; + var manager = try initEvictionTestManager(testing.allocator, &config); + defer deinitEvictionTestManager(&manager); + + const queued_generation = try putTestRegion(&manager, .{ .rx = 0, .rz = 0, .lod = .lod1 }, .generating); + queued_generation.job_token = 11; + const queued_mesh = try putTestRegion(&manager, .{ .rx = 1, .rz = 0, .lod = .lod1 }, .meshing); + queued_mesh.job_token = 12; + const running_generation = try putTestRegion(&manager, .{ .rx = 2, .rz = 0, .lod = .lod1 }, .generating); + running_generation.job_token = 13; + running_generation.pin(); + defer running_generation.unpin(); + const running_mesh = try putTestRegion(&manager, .{ .rx = 3, .rz = 0, .lod = .lod1 }, .meshing); + running_mesh.job_token = 14; + running_mesh.pin(); + defer running_mesh.unpin(); + manager.pending_region_count = 4; + + const queue = manager.job_dispatcher.queues[LODLevel.count - 1]; + try queue.push(.{ .type = .chunk_generation, .data = .{ .chunk = .{ .x = 0, .z = 0, .job_token = 11, .lod_level = 1 } } }); + try queue.push(.{ .type = .chunk_meshing, .data = .{ .chunk = .{ .x = 1, .z = 0, .job_token = 12, .lod_level = 1 } } }); + + manager.pause(); + + try testing.expectEqual(@as(usize, 0), queue.count()); + try testing.expectEqual(LODState.missing, queued_generation.state); + try testing.expectEqual(@as(u32, 12), queued_generation.job_token); + try testing.expectEqual(LODState.generated, queued_mesh.state); + try testing.expectEqual(@as(u32, 13), queued_mesh.job_token); + try testing.expectEqual(LODState.missing, running_generation.state); + try testing.expectEqual(@as(u32, 14), running_generation.job_token); + try testing.expect(running_generation.cancellationRequested()); + try testing.expectEqual(LODState.generated, running_mesh.state); + try testing.expectEqual(@as(u32, 15), running_mesh.job_token); + try testing.expect(running_mesh.cancellationRequested()); + try testing.expectEqual(@as(usize, 2), manager.pending_region_count); + try testing.expectEqual(@as(u32, 4), manager.cancelled_jobs); +} + +test "LODManager traversal cancels work outside its level horizon" { + var config = LODConfig{ .radii = .{ 8, 16, 32, 64, 128 } }; + var manager = try initEvictionTestManager(testing.allocator, &config); + defer deinitEvictionTestManager(&manager); + + const near = try putTestRegion(&manager, .{ .rx = 0, .rz = 0, .lod = .lod1 }, .generating); + near.job_token = 7; + const stale = try putTestRegion(&manager, .{ .rx = 100, .rz = 100, .lod = .lod1 }, .generating); + stale.job_token = 9; + stale.cache_read_queued = true; + manager.pending_region_count = 2; + + cancelWorkOutsideHorizon(&manager, 0, 0); + + try testing.expectEqual(LODState.generating, near.state); + try testing.expectEqual(@as(u32, 7), near.job_token); + try testing.expect(!near.cancellationRequested()); + try testing.expectEqual(LODState.missing, stale.state); + try testing.expectEqual(@as(u32, 10), stale.job_token); + try testing.expect(stale.cancellationRequested()); + try testing.expect(!stale.cache_read_queued); + try testing.expectEqual(@as(usize, 1), manager.pending_region_count); + try testing.expectEqual(@as(u32, 1), manager.cancelled_jobs); +} + test "LODManager ready child counters update on renderable transitions and removal" { var config = LODConfig{}; var manager = try initEvictionTestManager(testing.allocator, &config); diff --git a/modules/world-lod/src/lod_manager_tests.zig b/modules/world-lod/src/lod_manager_tests.zig index 98cbc5fa..67064a8d 100644 --- a/modules/world-lod/src/lod_manager_tests.zig +++ b/modules/world-lod/src/lod_manager_tests.zig @@ -6,18 +6,20 @@ const Vec3 = @import("engine-math").Vec3; const rhi_types = @import("engine-rhi").rhi_types; const Chunk = @import("world-core").Chunk; const world_core = @import("world-core"); -const lod_manager = @import("world-lod").lod_manager; +const lod_manager = @import("lod_manager.zig"); const LODManager = lod_manager.LODManager; +const LODGenerator = @import("lod_generator.zig").LODGenerator; const LODStats = lod_manager.LODStats; -const MAX_LOD_REGIONS = lod_manager.MAX_LOD_REGIONS; -const lod_chunk = @import("world-lod").lod_chunk; +const LODProfilingCollector = @import("lod_stats.zig").LODProfilingCollector; +const MAX_LOD_REGIONS = @import("lod_manager_context.zig").MAX_LOD_REGIONS; +const lod_chunk = @import("lod_chunk.zig"); const LODLevel = lod_chunk.LODLevel; const LODConfig = lod_chunk.LODConfig; const ILODConfig = lod_chunk.ILODConfig; const LODChunk = lod_chunk.LODChunk; const LODSimplifiedData = lod_chunk.LODSimplifiedData; -const LODMesh = @import("world-lod").LODMesh; -const lod_gpu = @import("world-lod").lod_upload_queue; +const LODMesh = @import("lod_mesh.zig").LODMesh; +const lod_gpu = @import("lod_upload_queue.zig"); const LODGPUBridge = lod_gpu.LODGPUBridge; const LODRenderInterface = lod_gpu.LODRenderInterface; const MeshMap = lod_gpu.MeshMap; @@ -30,6 +32,17 @@ const ColumnInfo = world_worldgen.ColumnInfo; const Generator = world_worldgen.Generator; const RegionInfo = world_worldgen.region.RegionInfo; +fn lodGeneratorFromGenerator(generator: Generator) LODGenerator { + return .{ + .ptr = generator.ptr, + .generate_heightmap_only = generator.vtable.generateHeightmapOnly, + .maybe_recenter_cache = generator.vtable.maybeRecenterCache, + .seed = generator.getSeed(), + .identity_hash = std.hash.Wyhash.hash(0, generator.info.name), + .version = generator.info.version, + }; +} + test "LODManager initialization" { const allocator = std.testing.allocator; @@ -39,7 +52,7 @@ test "LODManager initialization" { }; const MockGenerator = struct { - fn generate(_: *anyopaque, _: *Chunk, _: ?*const bool) void {} + fn generate(_: *anyopaque, _: *Chunk, _: ?*const bool) error{OutOfMemory}!void {} fn generateHeightmapOnly(_: *anyopaque, _: *LODSimplifiedData, _: i32, _: i32, _: LODLevel, _: ?*const std.atomic.Value(bool)) void {} fn maybeRecenterCache(_: *anyopaque, _: i32, _: i32) bool { return false; @@ -70,7 +83,7 @@ test "LODManager initialization" { const mock_gen = Generator{ .ptr = &mock_gen_impl, .vtable = &MockGenerator.vtable, - .info = .{ .name = "Mock", .description = "Mock Generator" }, + .info = .{ .name = "Mock", .description = "Mock Generator", .version = 1 }, }; var config = LODConfig{ @@ -96,7 +109,7 @@ test "LODManager initialization" { const mock_render = LODRenderInterface{ .render_fn = struct { - fn f(_: *anyopaque, _: *const [LODLevel.count]MeshMap, _: *const [LODLevel.count]RegionMap, _: ILODConfig, _: Mat4, _: Vec3, _: ?LODManager.ChunkChecker, _: ?*anyopaque, _: bool, _: ?i32, _: lod_gpu.LODRenderLayer, _: ?*LODStats) void {} + 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, .deinit_fn = struct { fn f(_: *anyopaque) void {} @@ -117,7 +130,7 @@ test "LODManager initialization" { .tile_mappings = [_]TextureAtlas.BlockTiles{TextureAtlas.BlockTiles.uniform(0)} ** MAX_BLOCK_TYPES, }; - var mgr = try LODManager.init(allocator, config.interface(), mock_bridge, mock_render, mock_gen.toLODGenerator(), &mock_atlas); + var mgr = try LODManager.init(allocator, config.interface(), mock_bridge, mock_render, lodGeneratorFromGenerator(mock_gen), &mock_atlas); mgr.cleanup_covered_regions = false; const stats = mgr.getStats(); @@ -136,7 +149,7 @@ test "LODManager end-to-end covered cleanup" { const allocator = std.testing.allocator; const MockGenerator = struct { - fn generate(_: *anyopaque, _: *Chunk, _: ?*const bool) void {} + fn generate(_: *anyopaque, _: *Chunk, _: ?*const bool) error{OutOfMemory}!void {} fn generateHeightmapOnly(_: *anyopaque, _: *LODSimplifiedData, _: i32, _: i32, _: LODLevel, _: ?*const std.atomic.Value(bool)) void {} fn maybeRecenterCache(_: *anyopaque, _: i32, _: i32) bool { return false; @@ -167,7 +180,7 @@ test "LODManager end-to-end covered cleanup" { const mock_gen = Generator{ .ptr = &mock_gen_impl, .vtable = &MockGenerator.vtable, - .info = .{ .name = "Mock", .description = "Mock Generator" }, + .info = .{ .name = "Mock", .description = "Mock Generator", .version = 1 }, }; var config = LODConfig{ @@ -190,7 +203,7 @@ test "LODManager end-to-end covered cleanup" { const mock_render = LODRenderInterface{ .render_fn = struct { - fn f(_: *anyopaque, _: *const [LODLevel.count]MeshMap, _: *const [LODLevel.count]RegionMap, _: ILODConfig, _: Mat4, _: Vec3, _: ?LODManager.ChunkChecker, _: ?*anyopaque, _: bool, _: ?i32, _: lod_gpu.LODRenderLayer, _: ?*LODStats) void {} + 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, .deinit_fn = struct { fn f(_: *anyopaque) void {} @@ -211,7 +224,7 @@ test "LODManager end-to-end covered cleanup" { .tile_mappings = [_]TextureAtlas.BlockTiles{TextureAtlas.BlockTiles.uniform(0)} ** MAX_BLOCK_TYPES, }; - var mgr = try LODManager.init(allocator, config.interface(), mock_bridge, mock_render, mock_gen.toLODGenerator(), &mock_atlas); + var mgr = try LODManager.init(allocator, config.interface(), mock_bridge, mock_render, lodGeneratorFromGenerator(mock_gen), &mock_atlas); mgr.cleanup_covered_regions = false; defer mgr.deinit(); @@ -266,10 +279,12 @@ test "LODStats aggregation" { stats.addMemory(2 * 1024 * 1024); try std.testing.expectEqual(@as(u32, 2), stats.memory_used_mb); + stats.profiling.enabled = true; stats.reset(); try std.testing.expectEqual(@as(u32, 0), stats.totalLoaded()); try std.testing.expectEqual(@as(u32, 0), stats.memory_used_mb); + try std.testing.expect(!stats.profiling.enabled); } test "LODManager constants" { @@ -277,6 +292,17 @@ test "LODManager constants" { try std.testing.expect(LODLevel.count >= 2); } +test "LODManager preserves the CPU heightfield fallback for far LODs" { + var config = LODConfig{ .mesh_path = .qem }; + const manager = try buildIngestionManager(std.testing.allocator, &config); + defer manager.deinit(); + + // Far LODs must not inherit an optional mesh path. They retain the + // CPU-expanded heightfield route when a future renderer path is unavailable. + try std.testing.expectEqual(@import("engine-rhi").LODMeshPath.heightfield, manager.effectiveMeshPath(.lod3)); + try std.testing.expectEqual(@import("engine-rhi").LODMeshPath.heightfield, manager.effectiveMeshPath(.lod4)); +} + // --------------------------------------------------------------------------- // Chunk-derived LOD ingestion (issue #752 Phase 2) // --------------------------------------------------------------------------- @@ -285,7 +311,7 @@ test "LODManager constants" { /// `config` (it must outlive the returned manager, since ILODConfig references it). fn buildIngestionManager(allocator: std.mem.Allocator, config: *LODConfig) !*LODManager { const MockGenerator = struct { - fn generate(_: *anyopaque, _: *Chunk, _: ?*const bool) void {} + fn generate(_: *anyopaque, _: *Chunk, _: ?*const bool) error{OutOfMemory}!void {} fn generateHeightmapOnly(_: *anyopaque, _: *LODSimplifiedData, _: i32, _: i32, _: LODLevel, _: ?*const std.atomic.Value(bool)) void {} fn maybeRecenterCache(_: *anyopaque, _: i32, _: i32) bool { return false; @@ -316,7 +342,7 @@ fn buildIngestionManager(allocator: std.mem.Allocator, config: *LODConfig) !*LOD const mock_gen = Generator{ .ptr = &mock_gen_impl, .vtable = &MockGenerator.vtable, - .info = .{ .name = "Mock", .description = "Mock Generator" }, + .info = .{ .name = "Mock", .description = "Mock Generator", .version = 1 }, }; var noop_ctx: u8 = 0; @@ -335,7 +361,7 @@ fn buildIngestionManager(allocator: std.mem.Allocator, config: *LODConfig) !*LOD const mock_render = LODRenderInterface{ .render_fn = struct { - fn f(_: *anyopaque, _: *const [LODLevel.count]MeshMap, _: *const [LODLevel.count]RegionMap, _: ILODConfig, _: Mat4, _: Vec3, _: ?LODManager.ChunkChecker, _: ?*anyopaque, _: bool, _: ?i32, _: lod_gpu.LODRenderLayer, _: ?*LODStats) void {} + 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, .deinit_fn = struct { fn f(_: *anyopaque) void {} @@ -356,7 +382,7 @@ fn buildIngestionManager(allocator: std.mem.Allocator, config: *LODConfig) !*LOD .tile_mappings = [_]TextureAtlas.BlockTiles{TextureAtlas.BlockTiles.uniform(0)} ** MAX_BLOCK_TYPES, }; - const mgr = try LODManager.init(allocator, config.interface(), mock_bridge, mock_render, mock_gen.toLODGenerator(), &mock_atlas); + const mgr = try LODManager.init(allocator, config.interface(), mock_bridge, mock_render, lodGeneratorFromGenerator(mock_gen), &mock_atlas); mgr.cleanup_covered_regions = false; return mgr; } @@ -402,6 +428,29 @@ test "ingestChunk upgrades worldgen region data and schedules a remesh" { try std.testing.expect(lchunk.store_dirty); } +test "ingestChunk defers while a cancelled worker still pins source data" { + 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.data.simplified.setColumn(0, 0, 10.0, .plains, .{ .surface = .grass, .subsurface = .dirt, .foundation = .stone }, 0x4D8033, .empty, .daylight, .empty); + lchunk.state = .generated; + lchunk.pin(); + defer lchunk.unpin(); + + 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(@as(f32, 10.0), lchunk.data.simplified.getHeight(0, 0)); + try std.testing.expectEqual(LODColumnProvenance.worldgen, lchunk.data.simplified.getColumnProvenance(0, 0)); + try std.testing.expect(mgr.ingestion_queue.pending_ingestions.items.len > 0); +} + 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 } }; @@ -466,7 +515,7 @@ test "markChunkEdited coalesces and re-ingests via the resolver on update" { mgr.markChunkEdited(0, 0); // update() drains the edit queue on its cooldown (starts expired). - try mgr.update(Vec3.zero, Vec3.zero, null, null); + for (0..4) |_| try mgr.update(Vec3.zero, Vec3.zero, null, null); 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)); @@ -484,10 +533,10 @@ test "enforceMemoryBudget shrinks finer radii under sustained pressure and spare // Force sustained over-budget state with no evictable regions, so the // hysteresis grow path must fire. - mgr.memory_used_bytes = 50_000_000; - try mgr.update(Vec3.zero, Vec3.zero, null, null); + mgr.memory_governor.used_bytes = 50_000_000; + try mgr.enforceMemoryBudget(); // Finer levels (1..count-2) must have grown; coarsest (horizon) untouched. - try std.testing.expect(mgr.radius_shrink_chunks[1] > 0); - try std.testing.expectEqual(@as(i32, 0), mgr.radius_shrink_chunks[LODLevel.count - 1]); + try std.testing.expect(mgr.memory_governor.radius_shrink_chunks[1] > 0); + try std.testing.expectEqual(@as(i32, 0), mgr.memory_governor.radius_shrink_chunks[LODLevel.count - 1]); } diff --git a/modules/world-lod/src/lod_manager_upload_ops.zig b/modules/world-lod/src/lod_manager_upload_ops.zig index aa328c73..fb59c58e 100644 --- a/modules/world-lod/src/lod_manager_upload_ops.zig +++ b/modules/world-lod/src/lod_manager_upload_ops.zig @@ -86,6 +86,7 @@ pub fn processUploadsWithBudget(self: *Self, upload_budget_bytes: usize) void { var uploaded_bytes: usize = 0; while (uploads < max_uploads) { + const prep_timer = self.profiling.begin(); var task: ?UploadTask = null; var completed_without_upload = false; var made_progress = false; @@ -102,7 +103,8 @@ pub fn processUploadsWithBudget(self: *Self, upload_budget_bytes: usize) void { const key = chunk.key(); if (self.meshes[i].get(key)) |mesh| { const pending_bytes = mesh.pendingUploadBytes(); - if (wouldExceedUploadBudget(uploaded_bytes, pending_bytes, upload_budget_bytes, uploads)) { + if (wouldExceedUploadBudget(uploaded_bytes, pending_bytes, upload_budget_bytes)) { + self.profiling.addStagingPressure(); self.requeueUpload(i, chunk); stop_processing = true; break; @@ -125,16 +127,52 @@ pub fn processUploadsWithBudget(self: *Self, upload_budget_bytes: usize) void { } self.mutex.unlock(); - if (stop_processing or !made_progress) break; - if (completed_without_upload) continue; + if (stop_processing or !made_progress) { + self.profiling.end(.upload_prep, prep_timer); + break; + } + if (completed_without_upload) { + self.profiling.end(.upload_prep, prep_timer); + continue; + } - const upload_task = task orelse continue; + const upload_task = task orelse { + self.profiling.end(.upload_prep, prep_timer); + continue; + }; + self.profiling.end(.upload_prep, prep_timer); + const submission_timer = self.profiling.begin(); self.gpu_bridge.upload(upload_task.mesh) catch |err| { + self.profiling.end(.upload_submission, submission_timer); log.log.warn("LOD{} mesh upload failed (will retry): {}", .{ upload_task.lod_idx, err }); + // Compact allocation/update failures must not strand a far region + // in .mesh_ready. Rebuild its stable CPU heightfield while the + // upload task still pins the source, then put it straight back on + // the upload queue. This also covers LOD4. + if (upload_task.mesh.isCompact()) { + self.fallbackCompactMeshToCpu(upload_task.mesh, upload_task.chunk) catch |fallback_err| { + log.log.warn("LOD{} compact fallback build failed; retaining retryable compact payload: {}", .{ upload_task.lod_idx, fallback_err }); + self.mutex.lock(); + self.stats.upload_failures += 1; + uploads += 1; + self.requeueUpload(upload_task.lod_idx, upload_task.chunk); + upload_task.chunk.unpin(); + self.mutex.unlock(); + return; + }; + self.mutex.lock(); + self.stats.upload_failures += 1; + uploads += 1; + self.requeueUpload(upload_task.lod_idx, upload_task.chunk); + upload_task.chunk.unpin(); + self.mutex.unlock(); + continue; + } self.mutex.lock(); self.stats.upload_failures += 1; uploads += 1; if (isUploadPressureError(err)) { + self.profiling.addStagingPressure(); self.requeueUpload(upload_task.lod_idx, upload_task.chunk); upload_task.chunk.unpin(); self.mutex.unlock(); @@ -145,8 +183,10 @@ pub fn processUploadsWithBudget(self: *Self, upload_budget_bytes: usize) void { self.mutex.unlock(); continue; }; + self.profiling.end(.upload_submission, submission_timer); uploaded_bytes += upload_task.pending_bytes; + self.profiling.addUploadBytes(upload_task.pending_bytes); self.mutex.lock(); self.markRegionRenderable(upload_task.key, upload_task.chunk); uploads += 1; @@ -190,6 +230,7 @@ pub fn adjustParentReadyChildren(self: *Self, key: LODRegionKey, delta: i8) void pub fn markRegionRenderable(self: *Self, key: LODRegionKey, chunk: *LODChunk) void { if (chunk.isRenderable()) return; chunk.markRenderable(self.countRenderableChildren(key)); + if (self.pending_region_count > 0) self.pending_region_count -= 1; if (self.regionContributesGeometry(key, chunk)) { self.adjustParentReadyChildren(key, 1); } @@ -219,6 +260,7 @@ pub fn demoteRegionForRemesh(self: *Self, key: LODRegionKey, chunk: *LODChunk) v if (chunk.getState() == .renderable) { self.noteRegionRemoved(key, chunk); chunk.setState(.generated); + self.pending_region_count += 1; } else if (chunk.getState() == .mesh_ready) { chunk.setState(.generated); } diff --git a/modules/world-lod/src/lod_mesh.zig b/modules/world-lod/src/lod_mesh.zig index 0b163b2c..f9b171fd 100644 --- a/modules/world-lod/src/lod_mesh.zig +++ b/modules/world-lod/src/lod_mesh.zig @@ -28,6 +28,8 @@ const log = @import("engine-core").log; const lod_seam = @import("lod_seam.zig"); const resources_mod = @import("lod_mesh_resources.zig"); const geom = @import("lod_geometry.zig"); +const CompactLODTile = @import("lod_tile.zig").CompactLODTile; +const CompactTileEdge = @import("lod_tile.zig").TileEdge; pub const EdgeDir = lod_seam.EdgeDir; pub const SeamConfig = lod_seam.SeamConfig; @@ -99,6 +101,14 @@ pub const LODMesh = struct { pub const empty: DrawState = .{}; }; + pub const MemorySnapshot = struct { + capacity_bytes: usize, + pending_upload_bytes: usize, + pooled: bool, + compact: bool, + vertex_count: u32, + }; + /// GPU buffer handle buffer_handle: BufferHandle = 0, /// Number of vertices @@ -117,6 +127,16 @@ pub const LODMesh = struct { pooled: bool = false, /// Pending vertices to upload pending_vertices: ?[]Vertex = null, + /// Present only while a compact tile awaits GPU upload. The pool owns the + /// post-upload representation, so this is never an expanded Vertex array. + compact_tile: ?CompactLODTile = null, + compact: bool = false, + compact_sample_offset: u32 = 0, + compact_sample_bytes: usize = 0, + compact_index_count: u32 = 0, + compact_tile_width: u32 = 0, + compact_has_water: bool = false, + compact_draw_failed: bool = false, /// Allocator allocator: std.mem.Allocator, /// Mutex for thread safety @@ -144,6 +164,54 @@ pub const LODMesh = struct { pub fn isPooled(self: *const LODMesh) bool { return self.pooled; } + pub fn isCompact(self: *const LODMesh) bool { + return self.compact; + } + + pub fn compactDrawFailed(self: *LODMesh) bool { + self.mutex.lock(); + defer self.mutex.unlock(); + return self.compact_draw_failed; + } + + pub fn markCompactDrawFailed(self: *LODMesh) void { + self.mutex.lock(); + defer self.mutex.unlock(); + self.compact_draw_failed = true; + self.ready = false; + } + + pub fn patchCompactNeighbor(self: *LODMesh, edge: CompactTileEdge, neighbor: *LODMesh) bool { + if (self == neighbor) return false; + const self_first = @intFromPtr(self) < @intFromPtr(neighbor); + const first = if (self_first) self else neighbor; + const second = if (self_first) neighbor else self; + first.mutex.lock(); + defer first.mutex.unlock(); + second.mutex.lock(); + defer second.mutex.unlock(); + const tile = if (self.compact_tile) |*value| value else return false; + const neighbor_tile = if (neighbor.compact_tile) |*value| value else return false; + tile.applyNeighborApron(edge, neighbor_tile) catch return false; + const opposite: CompactTileEdge = switch (edge) { + .west => .east, + .east => .west, + .north => .south, + .south => .north, + }; + neighbor_tile.applyNeighborApron(opposite, tile) catch return false; + return true; + } + + /// True when replacing this mesh must first retire renderer-owned storage. + /// A remesh may replace either an uploaded representation or source data + /// awaiting upload; both cases must discard the old representation first. + pub fn hasRepresentation(self: *LODMesh) bool { + self.mutex.lock(); + defer self.mutex.unlock(); + return self.compact or self.buffer_handle != 0 or self.pooled or + self.pending_vertices != null or self.capacity != 0 or self.ready; + } pub fn bufferHandle(self: *const LODMesh) BufferHandle { return self.buffer_handle; @@ -166,7 +234,12 @@ pub const LODMesh = struct { } pub fn drawRange(self: *const LODMesh, layer: LODRenderLayer) ?DrawRange { - if (!self.ready or self.buffer_handle == 0) return null; + if (!self.ready) return null; + if (self.compact) return switch (layer) { + .terrain => if (self.compact_index_count > 0) .{ .offset = 0, .count = self.compact_index_count } else null, + .fluid => if (self.compact_has_water and self.compact_index_count > 0) .{ .offset = 0, .count = self.compact_index_count } else null, + }; + if (self.buffer_handle == 0) return null; return switch (layer) { .terrain => if (self.opaque_vertex_count > 0) .{ .offset = 0, .count = self.opaque_vertex_count } @@ -253,9 +326,97 @@ pub const LODMesh = struct { self.allocator.free(p); self.pending_vertices = null; } + if (self.compact_tile) |*tile| tile.deinit(); + self.compact_tile = null; + self.compact = false; + self.compact_sample_offset = 0; + self.compact_sample_bytes = 0; + self.compact_index_count = 0; + self.compact_tile_width = 0; + self.compact_has_water = false; + self.compact_draw_failed = false; + self.ready = false; + } + + pub fn buildCompactTile(self: *LODMesh, data: *const LODSimplifiedData) !void { + const tile = try CompactLODTile.initFromSimplified(self.allocator, self.lod_level, data); + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.compact_tile) |*old| old.deinit(); + self.compact_tile = tile; + self.compact = true; + const cells = (data.width - 1) * (data.width - 1); + self.compact_index_count = cells * 6; + self.compact_tile_width = data.width; + self.compact_has_water = false; + for (data.water) |water| { + if (water.is_surface and water.coverage > 0.001) { + self.compact_has_water = true; + break; + } + } + self.compact_draw_failed = false; self.ready = false; } + /// Releases an unuploaded compact source tile. Shutdown paths call this + /// before handing a mesh to a type-erased backend, so a partial upload never + /// relies on a backend-specific destroy hook for CPU ownership. + pub fn releasePendingCompactTile(self: *LODMesh) void { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.compact_tile) |*tile| tile.deinit(); + self.compact_tile = null; + } + + /// Clears compact-only state after its range has been retired by the + /// renderer, or when it never reached the renderer. This is deliberately + /// separate from `releasePendingCompactTile`: a CPU fallback must not leave + /// `compact` set, otherwise the normal vertex uploader will re-enter the + /// compact path with stale offsets. + pub fn clearCompactState(self: *LODMesh) void { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.compact_tile) |*tile| tile.deinit(); + self.compact_tile = null; + self.compact = false; + self.compact_sample_offset = 0; + self.compact_sample_bytes = 0; + self.compact_index_count = 0; + self.compact_tile_width = 0; + self.compact_has_water = false; + self.compact_draw_failed = false; + self.buffer_handle = 0; + self.vertex_count = 0; + self.opaque_vertex_count = 0; + self.water_vertex_offset = 0; + self.water_vertex_count = 0; + self.vertex_offset = 0; + self.capacity = 0; + self.pooled = false; + self.ready = false; + } + + /// Idempotent cleanup after the renderer has retired/destroyed the GPU + /// object. This also makes lightweight test bridges safe: they need not + /// duplicate representation bookkeeping merely to exercise a remesh. + pub fn clearRetiredState(self: *LODMesh) void { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.pending_vertices) |pending| self.allocator.free(pending); + self.pending_vertices = null; + if (self.compact_tile) |*tile| tile.deinit(); + self.compact_tile = null; + self.clearDrawStateUnlocked(); + self.compact = false; + self.compact_sample_offset = 0; + self.compact_sample_bytes = 0; + self.compact_index_count = 0; + self.compact_tile_width = 0; + self.compact_has_water = false; + self.compact_draw_failed = false; + } + pub fn clearPendingVertices(self: *LODMesh) void { self.mutex.lock(); defer self.mutex.unlock(); @@ -269,10 +430,31 @@ pub const LODMesh = struct { pub fn pendingUploadBytes(self: *LODMesh) usize { self.mutex.lock(); defer self.mutex.unlock(); + if (self.compact_tile) |tile| return std.mem.sliceAsBytes(tile.samples).len; const pending = self.pending_vertices orelse return 0; return std.mem.sliceAsBytes(pending).len; } + /// Atomically snapshots the storage that this mesh currently owns or uses. + /// Pooled capacity is an allocation within the renderer-owned pool, not a + /// separate GPU buffer. + pub fn memorySnapshot(self: *LODMesh) MemorySnapshot { + self.mutex.lock(); + defer self.mutex.unlock(); + return .{ + .capacity_bytes = self.byteSize(), + .pending_upload_bytes = if (self.compact_tile) |tile| + std.mem.sliceAsBytes(tile.samples).len + else if (self.pending_vertices) |pending| + std.mem.sliceAsBytes(pending).len + else + 0, + .pooled = self.pooled, + .compact = self.compact, + .vertex_count = self.vertex_count, + }; + } + /// Build mesh from simplified LOD data (heightmap-based) pub fn buildFromSimplifiedData(self: *LODMesh, data: *const LODSimplifiedData, world_x: i32, world_z: i32, atlas: *const TextureAtlas) !void { if (data.width < 2) return error.EmptyData; @@ -661,7 +843,6 @@ pub const LODMesh = struct { if (pending.len == 0) { if (self.buffer_handle != 0 and !self.pooled) { - resources.waitIdle(); resources.destroyBuffer(self.buffer_handle); } self.buffer_handle = 0; @@ -685,7 +866,7 @@ pub const LODMesh = struct { var new_handle: BufferHandle = 0; // Create or resize buffer. Keep the old buffer renderable until the - // replacement upload succeeds, then destroy it after an idle wait. + // replacement upload succeeds, then retire it through the RHI. if (self.buffer_handle == 0 or needed_capacity > self.capacity * @sizeOf(Vertex)) { new_handle = try resources.createBuffer(needed_capacity, .vertex); upload_handle = new_handle; @@ -701,7 +882,6 @@ pub const LODMesh = struct { self.vertex_offset = 0; self.pooled = false; if (old_handle != 0 and !self.pooled) { - resources.waitIdle(); resources.destroyBuffer(old_handle); } } diff --git a/modules/world-lod/src/lod_renderer.zig b/modules/world-lod/src/lod_renderer.zig index 85f2b438..2219dfc7 100644 --- a/modules/world-lod/src/lod_renderer.zig +++ b/modules/world-lod/src/lod_renderer.zig @@ -38,6 +38,7 @@ const LODRegionKeyContext = lod_chunk.LODRegionKeyContext; const LODMesh = @import("lod_mesh.zig").LODMesh; const LODMeshResources = @import("lod_mesh.zig").LODMeshResources; const LODVertexPool = @import("lod_vertex_pool.zig").LODVertexPool; +const CompactLODPool = @import("lod_compact_pool.zig").CompactLODPool; const CHUNK_SIZE_X = @import("world-core").CHUNK_SIZE_X; const CHUNK_SIZE_Z = @import("world-core").CHUNK_SIZE_Z; const worldToChunkFromFloat = @import("world-core").worldToChunkFromFloat; @@ -46,10 +47,12 @@ const lod_gpu = @import("lod_upload_queue.zig"); const LODGPUBridge = lod_gpu.LODGPUBridge; const LODRenderInterface = lod_gpu.LODRenderInterface; const LODRenderLayer = lod_gpu.LODRenderLayer; +const LODRendererMemoryStats = lod_gpu.LODRendererMemoryStats; const MeshMap = lod_gpu.MeshMap; const RegionMap = lod_gpu.RegionMap; const ChunkChecker = lod_gpu.ChunkChecker; const LODStats = @import("lod_stats.zig").LODStats; +const LODProfilingCollector = @import("lod_stats.zig").LODProfilingCollector; const Vec3 = @import("engine-math").Vec3; const Mat4 = @import("engine-math").Mat4; @@ -58,10 +61,75 @@ const rhi_types = @import("engine-rhi"); const engine_core = @import("engine-core"); const log = engine_core.log; const build_options = @import("world_lod_options"); +const LODCullCandidate = rhi_types.LODCullCandidate; +const LODCullDispatch = rhi_types.LODCullDispatch; +const ILODCullingSystem = rhi_types.ILODCullingSystem; const CHUNK_COVERAGE_PADDING: i32 = 1; const LOD_UNMASKED_SENTINEL: f32 = 0.5; +fn compactGridIndexCount(width: u32, include_skirts: bool) usize { + if (width < 2) return 0; + const top = @as(usize, width - 1) * @as(usize, width - 1) * 6; + const skirts = if (include_skirts) @as(usize, width - 1) * 4 * 6 else 0; + return top + skirts; +} + +fn compactGridIndices(allocator: std.mem.Allocator, width: u32, include_skirts: bool) ![]u32 { + if (width < 2) return error.InvalidGrid; + const result = try allocator.alloc(u32, compactGridIndexCount(width, include_skirts)); + var out: usize = 0; + var z: u32 = 0; + while (z + 1 < width) : (z += 1) { + var x: u32 = 0; + while (x + 1 < width) : (x += 1) { + const a = z * width + x; + const b = a + 1; + const c = a + width; + const d = c + 1; + result[out + 0] = a; + result[out + 1] = c; + result[out + 2] = b; + result[out + 3] = b; + result[out + 4] = c; + result[out + 5] = d; + out += 6; + } + } + + if (include_skirts) { + const top_vertex_count = width * width; + const Edge = struct { + fn append(result_slice: []u32, output: *usize, top_a: u32, top_b: u32, bottom_a: u32, bottom_b: u32) void { + result_slice[output.* + 0] = top_a; + result_slice[output.* + 1] = bottom_a; + result_slice[output.* + 2] = top_b; + result_slice[output.* + 3] = top_b; + result_slice[output.* + 4] = bottom_a; + result_slice[output.* + 5] = bottom_b; + output.* += 6; + } + }; + for (0..width - 1) |edge_index| { + const i: u32 = @intCast(edge_index); + // North and south. + Edge.append(result, &out, i, i + 1, top_vertex_count + i, top_vertex_count + i + 1); + const south_top = (width - 1) * width + i; + const south_bottom = top_vertex_count + width + i; + Edge.append(result, &out, south_top + 1, south_top, south_bottom + 1, south_bottom); + // West and east. + const west_top = i * width; + const west_bottom = top_vertex_count + width * 2 + i; + Edge.append(result, &out, west_top + width, west_top, west_bottom + 1, west_bottom); + const east_top = i * width + width - 1; + const east_bottom = top_vertex_count + width * 3 + i; + Edge.append(result, &out, east_top, east_top + width, east_bottom, east_bottom + 1); + } + } + std.debug.assert(out == result.len); + return result; +} + const RenderDiag = struct { meshes_seen: u32 = 0, missing_region: u32 = 0, @@ -74,6 +142,16 @@ const RenderDiag = struct { drawn: u32 = 0, }; +/// Value-only result of the frame visibility projection. It deliberately holds +/// no mesh or region pointer: the projection can survive the terrain pass and +/// is revalidated under the manager's shared lock before a later water pass. +const VisibleRegion = struct { + key: LODRegionKey, + model: Mat4, + mask_radius: f32, + lod_fade: f32, +}; + const MAX_LOD_MDI_REGIONS: usize = 2048; /// Expected RHI interface for LODRenderer: @@ -93,14 +171,24 @@ pub fn LODRenderer(comptime RHI: type) type { // MDI Resources (Moved from LODManager) instance_data: std.ArrayListUnmanaged(rhi_types.InstanceData), draw_list: std.ArrayListUnmanaged(*LODMesh), + projection_regions: std.ArrayListUnmanaged(VisibleRegion), + projection_frame: ?u64, draw_commands: [LODLevel.count]std.ArrayListUnmanaged(rhi_types.DrawIndirectCommand), instance_buffers: [rhi_types.MAX_FRAMES_IN_FLIGHT]rhi_types.BufferHandle, indirect_buffers: [rhi_types.MAX_FRAMES_IN_FLIGHT]rhi_types.BufferHandle, vertex_pools: [LODLevel.count]LODVertexPool, + compact_pool: CompactLODPool, + /// Per compact LOD: terrain grid with edge skirts, then water top grid. + compact_index_buffers: [2][2]rhi_types.BufferHandle, frame_index: usize, + frame_serial: u64, enable_mdi: bool, gpu_culling_requested: bool, - gpu_culling_fallback_logged: bool, + gpu_culling: ?ILODCullingSystem, + gpu_candidates: std.ArrayListUnmanaged(LODCullCandidate), + gpu_culling_ready_frame: ?u64, + gpu_culling_threshold: usize, + gpu_culling_overflow_count: u32, /// Allocates LOD renderer GPU buffers and per-frame indirect draw resources. /// The renderer owns created buffers until `deinit`; allocation failures are returned to the caller. @@ -129,24 +217,51 @@ pub fn LODRenderer(comptime RHI: type) type { for (0..LODLevel.count) |i| { vertex_pools[i] = LODVertexPool.init(allocator, @enumFromInt(@as(u3, @intCast(i))), 8 * 1024 * 1024); } + var compact_index_buffers = [_][2]rhi_types.BufferHandle{.{ 0, 0 }} ** 2; + errdefer for (&compact_index_buffers) |*lod_handles| for (lod_handles) |handle| if (handle != 0) resources.destroyBuffer(handle); + inline for (.{ LODLevel.lod3, LODLevel.lod4 }, 0..) |lod, idx| { + inline for (.{ true, false }, 0..) |include_skirts, layer_idx| { + const indices = try compactGridIndices(allocator, @import("world-core").LODSimplifiedData.getGridSize(lod), include_skirts); + defer allocator.free(indices); + compact_index_buffers[idx][layer_idx] = try resources.createBuffer(std.mem.sliceAsBytes(indices).len, .index); + try resources.uploadBuffer(compact_index_buffers[idx][layer_idx], std.mem.sliceAsBytes(indices)); + } + } + const gpu_culling_requested = engine_core.envFlag("ZIGCRAFT_LOD_GPU_CULLING", false); + var gpu_culling: ?ILODCullingSystem = null; + if (gpu_culling_requested and @hasDecl(RHI, "createLODCullingSystem")) { + gpu_culling = rhi.createLODCullingSystem(allocator, MAX_LOD_MDI_REGIONS) catch |err| blk: { + log.log.warn("LOD GPU culling unavailable ({}); CPU fallback active", .{err}); + break :blk null; + }; + } renderer.* = .{ .allocator = allocator, .rhi = rhi, .instance_data = .empty, .draw_list = .empty, + .projection_regions = .empty, + .projection_frame = null, .draw_commands = draw_commands, .instance_buffers = instance_buffers, .indirect_buffers = indirect_buffers, .vertex_pools = vertex_pools, + .compact_pool = CompactLODPool.init(allocator), + .compact_index_buffers = compact_index_buffers, .frame_index = 0, - .enable_mdi = engine_core.envFlag("ZIGCRAFT_ENABLE_LOD_MDI", false), - .gpu_culling_requested = engine_core.envFlag("ZIGCRAFT_LOD_GPU_CULLING", false), - .gpu_culling_fallback_logged = false, + .frame_serial = 0, + .enable_mdi = !engine_core.envFlag("ZIGCRAFT_DISABLE_LOD_MDI", false), + .gpu_culling_requested = gpu_culling_requested, + .gpu_culling = gpu_culling, + .gpu_candidates = .empty, + .gpu_culling_ready_frame = null, + .gpu_culling_threshold = gpuCullingThreshold(), + .gpu_culling_overflow_count = 0, }; if (!renderer.enable_mdi) { - log.log.info("LOD MDI disabled by default; set ZIGCRAFT_ENABLE_LOD_MDI=1 to test indirect LOD batches", .{}); + log.log.info("LOD MDI disabled by ZIGCRAFT_DISABLE_LOD_MDI", .{}); } return renderer; @@ -169,13 +284,165 @@ pub fn LODRenderer(comptime RHI: type) type { for (0..LODLevel.count) |i| { self.vertex_pools[i].deinit(mesh_resources); } + self.compact_pool.deinit(mesh_resources); + for (self.compact_index_buffers) |lod_handles| for (lod_handles) |handle| if (handle != 0) resources.destroyBuffer(handle); self.instance_data.deinit(self.allocator); self.draw_list.deinit(self.allocator); + self.projection_regions.deinit(self.allocator); + self.gpu_candidates.deinit(self.allocator); + if (self.gpu_culling) |gpu| gpu.deinit(); for (&self.draw_commands) |*commands| commands.deinit(self.allocator); self.allocator.destroy(self); } /// Render all LOD meshes using explicitly provided data. + /// + /// `frame_serial` is supplied by WorldRenderer and advances only from + /// its `beginFrame`. Visibility and full-chunk coverage are projected + /// once for that frame; terrain and water then consume value-only + /// entries without retaining map-owned mesh pointers. + pub fn renderFrame( + self: *Self, + 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, + use_frustum: bool, + max_distance_chunks: ?i32, + layer: LODRenderLayer, + stats: ?*LODStats, + profiling: ?*LODProfilingCollector, + ) void { + self.frame_serial = 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 + // fence. The compact pool additionally requires a newer monotonic + // frame serial before it returns any retired range to allocation. + self.compact_pool.collectRetired(frame_serial, self.frame_index); + for (&self.vertex_pools) |*pool| pool.collectRetired(frame_serial, self.frame_index); + 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| { + log.log.errWithTrace("Failed to project LOD visibility: {}", .{err}); + return; + }; + self.projection_frame = frame_serial; + } + if (!self.renderProjectedLayer(meshes, layer, stats)) { + // GPU submission can still fail after the compute prepass (for + // example if a pooled buffer becomes unavailable). Never feed + // the broad GPU candidate projection into the CPU renderer. + 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| { + log.log.errWithTrace("Failed to rebuild CPU LOD visibility: {}", .{err}); + self.projection_frame = null; + return; + }; + self.projection_frame = frame_serial; + _ = self.renderProjectedLayer(meshes, layer, stats); + } + } + + /// Records the LOD compute pass before the render graph enters a graphics + /// pass. Hierarchy/readiness/chunk-coverage stay CPU authoritative. + pub fn prepareFrame( + self: *Self, + 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, + ) void { + const gpu = self.gpu_culling orelse return; + if (!self.gpu_culling_requested or self.projection_frame == frame_serial) return; + self.buildVisibilityProjection(meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, false, null, null, null) catch |err| { + log.log.err("LOD GPU culling projection failed: {}", .{err}); + return; + }; + self.projection_frame = frame_serial; + self.gpu_candidates.clearRetainingCapacity(); + // Never truncate a visibility projection: losing an LOD fallback + // region can create a terrain hole, so overflow is CPU-rendered. + if (self.projection_regions.items.len > MAX_LOD_MDI_REGIONS) { + self.gpu_culling_overflow_count +%= 1; + self.projection_frame = null; + return; + } + var all_standard_pooled = true; + for (self.projection_regions.items) |visible| { + const lod_index = @intFromEnum(visible.key.lod); + const mesh = meshes[lod_index].get(visible.key) orelse continue; + const chunk = regions[lod_index].get(visible.key) orelse continue; + const terrain = mesh.drawRange(.terrain); + const fluid = mesh.drawRange(.fluid); + if ((terrain orelse fluid) == null) continue; + const compact = mesh.isCompact(); + if (!compact and !mesh.isPooled()) all_standard_pooled = false; + // Compact terrain and water currently share one graphics descriptor + // binding for their compute-generated instance streams. Updating that + // descriptor for water before submission also changes the earlier + // terrain draws, allowing terrain commands to read uninitialized water + // instance words and fetch outside the compact sample buffer. RADV + // rejects that command stream. Keep compact rendering on the safe + // CPU-visibility/direct-GPU-draw path until the streams have immutable + // per-layer descriptor sets. + if (compact) { + self.projection_frame = null; + return; + } + const bounds = chunk.worldBounds(); + const cell_size: f32 = if (compact) @as(f32, @floatFromInt(lod_chunk.regionSizeBlocks(visible.key.lod))) / @as(f32, @floatFromInt(mesh.compact_tile_width - 1)) else 0; + self.gpu_candidates.append(self.allocator, .{ + .min_point = .{ @as(f32, @floatFromInt(bounds.min_x)) - camera_pos.x, bounds.min_y - camera_pos.y, @as(f32, @floatFromInt(bounds.min_z)) - camera_pos.z, 0 }, + .max_point = .{ @as(f32, @floatFromInt(bounds.max_x)) - camera_pos.x, bounds.max_y - camera_pos.y, @as(f32, @floatFromInt(bounds.max_z)) - camera_pos.z, 0 }, + .model = visible.model, + .instance_params = .{ visible.mask_radius, visible.lod_fade, if (compact) cell_size else 0, if (compact) @max(16.0, cell_size * 2.0) else 0 }, + .compact_words = .{ mesh.compact_sample_offset, mesh.compact_tile_width, 0, 0 }, + .compact_metrics = .{ cell_size, @max(16.0, cell_size * 2.0), 0, 0 }, + .terrain_command = cullCommandFor(mesh, terrain, compact, true), + .water_command = cullCommandFor(mesh, fluid, compact, false), + .lod_and_padding = .{ @intCast(lod_index), if (compact) 1 else 0, 0, 0 }, + }) catch return; + } + if (self.gpu_candidates.items.len < self.gpu_culling_threshold or !all_standard_pooled) { + self.projection_frame = null; + return; + } + const query = if (@hasDecl(RHI, "query")) self.rhi.query() else self.rhi; + if (!@hasDecl(@TypeOf(query), "supportsIndirectCount")) { + self.projection_frame = null; + return; + } + if (!query.supportsIndirectFirstInstance() or !query.supportsIndirectCount()) { + self.projection_frame = null; + return; + } + const distance_chunks = max_distance_chunks orelse config.getRadii()[lod_chunk.activeLODCount(config) - 1]; + if (comptime @hasDecl(RHI, "timing")) { + const timing = self.rhi.timing(); + timing.beginPassTiming("LODGpuCullingComputeBarrier"); + defer timing.endPassTiming("LODGpuCullingComputeBarrier"); + } + if (gpu.dispatch(query.getFrameIndex(), self.gpu_candidates.items, .{ + .planes = extractPlanes(view_proj), + .candidate_count = 0, + .max_distance_blocks = @as(f32, @floatFromInt(distance_chunks * CHUNK_SIZE_X)), + .max_commands_per_lod = 0, + })) self.gpu_culling_ready_frame = frame_serial else self.projection_frame = null; + } + pub fn render( self: *Self, meshes: *const [LODLevel.count]MeshMap, @@ -189,16 +456,12 @@ pub fn LODRenderer(comptime RHI: type) type { max_distance_chunks: ?i32, layer: LODRenderLayer, stats: ?*LODStats, + profiling: ?*LODProfilingCollector, ) void { // Update frame index const query = if (@hasDecl(RHI, "query")) self.rhi.query() else self.rhi; const render_ctx = if (@hasDecl(RHI, "renderContext")) self.rhi.renderContext() else self.rhi; self.frame_index = query.getFrameIndex(); - if (self.gpu_culling_requested and !self.gpu_culling_fallback_logged) { - log.log.warn("ZIGCRAFT_LOD_GPU_CULLING requested, but LOD GPU culling is using CPU fallback until RHI exposes indirect-command compaction", .{}); - self.gpu_culling_fallback_logged = true; - } - // Use the LOD descriptor set while issuing LOD draws, then restore // normal terrain descriptor mode so the chunk pass keeps its textures. defer if (@hasDecl(@TypeOf(render_ctx), "setInstanceBuffer")) render_ctx.setInstanceBuffer(0); @@ -228,16 +491,17 @@ pub fn LODRenderer(comptime RHI: type) type { while (i > 0) { i -= 1; const lod: LODLevel = @enumFromInt(@as(u3, @intCast(i))); - self.collectVisibleMeshes(meshes, regions, lod, config, view_proj, camera_pos, frustum, lod_y_offset, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer, stats) catch |err| { + self.collectVisibleMeshes(meshes, regions, lod, config, view_proj, camera_pos, frustum, lod_y_offset, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer, stats, profiling) catch |err| { log.log.errWithTrace("Failed to collect visible meshes for LOD{}: {}", .{ i, err }); }; } if (self.instance_data.items.len == 0) return; - if (self.enable_mdi and layer == .terrain and self.renderIndirectBatches(render_ctx, query)) return; + const indirect_drawn = self.enable_mdi and self.renderIndirectBatches(render_ctx, query); for (self.draw_list.items, 0..) |mesh, idx| { + if (indirect_drawn and mesh.isPooled()) continue; const instance = self.instance_data.items[idx]; const range = mesh.drawRange(layer) orelse continue; render_ctx.setModelMatrix(instance.model, Vec3.one, instance.mask_radius); @@ -266,7 +530,6 @@ pub fn LODRenderer(comptime RHI: type) type { var total_commands: usize = 0; for (self.draw_commands) |commands| total_commands += commands.items.len; if (total_commands == 0) return false; - if (total_commands != self.draw_list.items.len) return false; if (total_commands > MAX_LOD_MDI_REGIONS) { log.log.warn("LOD MDI: command overflow ({} > {}), falling back to CPU draw", .{ total_commands, MAX_LOD_MDI_REGIONS }); return false; @@ -311,6 +574,322 @@ pub fn LODRenderer(comptime RHI: type) type { return true; } + fn buildVisibilityProjection( + self: *Self, + all_meshes: *const [LODLevel.count]MeshMap, + all_regions: *const [LODLevel.count]RegionMap, + config: ILODConfig, + view_proj: Mat4, + camera_pos: Vec3, + chunk_checker: ?ChunkChecker, + checker_ctx: ?*anyopaque, + use_frustum: bool, + max_distance_chunks: ?i32, + stats: ?*LODStats, + profiling: ?*LODProfilingCollector, + ) !void { + self.projection_regions.clearRetainingCapacity(); + if (stats) |s| { + s.drawn = [_]u32{0} ** LODLevel.count; + s.instances = [_]u32{0} ** LODLevel.count; + s.fluid_drawn = [_]u32{0} ** LODLevel.count; + s.fluid_instances = [_]u32{0} ** LODLevel.count; + s.gpu_terrain_candidates = 0; + s.gpu_fluid_candidates = 0; + } + + 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(); + var i = lod_chunk.activeLODCount(config); + while (i > 0) { + i -= 1; + const lod: LODLevel = @enumFromInt(@as(u3, @intCast(i))); + var telemetry = @import("lod_stats.zig").LODVisibilityLevelSnapshot{}; + var iter = all_meshes[i].iterator(); + while (iter.next()) |entry| { + telemetry.candidates += 1; + const mesh = entry.value_ptr.*; + if (!mesh.isReady()) { + telemetry.rejected_not_ready += 1; + if (profiling) |profile| profile.addRejected(); + continue; + } + if ((mesh.drawRange(.terrain) orelse mesh.drawRange(.fluid)) == null) { + telemetry.rejected_no_draw += 1; + if (profiling) |profile| profile.addRejected(); + continue; + } + const chunk = all_regions[i].get(entry.key_ptr.*) orelse { + telemetry.rejected_missing_region += 1; + if (profiling) |profile| profile.addRejected(); + continue; + }; + if (!chunk.isRenderable()) { + telemetry.rejected_not_renderable += 1; + if (profiling) |profile| profile.addRejected(); + continue; + } + if (self.isCoveredByFinerLOD(chunk, config)) { + telemetry.rejected_finer_coverage += 1; + if (profiling) |profile| profile.addRejected(); + continue; + } + + const bounds = chunk.worldBounds(); + const chunk_bounds = chunk.chunkBounds(); + // Cheap radial and frustum tests intentionally precede the + // potentially large chunk-coverage scan. + if (max_distance_chunks) |max_dist| { + if (!isRegionInRange(chunk_bounds, camera_pos, max_dist)) { + telemetry.rejected_range += 1; + if (profiling) |profile| profile.addRejected(); + continue; + } + } + if (use_frustum and !disable_frustum and !isRegionInFrustum(frustum, bounds, camera_pos)) { + telemetry.rejected_frustum += 1; + if (profiling) |profile| profile.addRejected(); + continue; + } + + var mask_radius = config.calculateMaskRadius(); + if (chunk_checker) |checker| { + if (checker_ctx) |ctx_ptr| { + const coverage_timer = if (profiling) |profile| profile.begin() else null; + const cov = self.isCoveredByChunks(bounds, checker, ctx_ptr, camera_chunk.chunk_x, camera_chunk.chunk_z, chunk_radius); + if (profiling) |profile| { + profile.end(.coverage, coverage_timer); + profile.addCoverage(); + } + telemetry.coverage_checks += 1; + if (cov.covered) { + telemetry.rejected_chunk_coverage += 1; + if (profiling) |profile| profile.addRejected(); + continue; + } + if (cov.missing_chunk_in_radius and !cov.has_chunk_coverage_in_radius) mask_radius = LOD_UNMASKED_SENTINEL; + } + } + + try self.projection_regions.append(self.allocator, .{ + .key = entry.key_ptr.*, + .model = Mat4.translate(Vec3.init(@as(f32, @floatFromInt(bounds.min_x)) - camera_pos.x, -camera_pos.y, @as(f32, @floatFromInt(bounds.min_z)) - camera_pos.z)), + .mask_radius = mask_radius, + .lod_fade = chunk.transitionFadeProgress(), + }); + telemetry.accepted += 1; + if (profiling) |profile| profile.addVisible(); + } + if (profiling) |profile| profile.addVisibilityLevel(lod, telemetry); + } + } + + /// Returns false only when a prepared GPU frame could not be submitted; + /// callers must rebuild the CPU projection with normal culling first. + fn renderProjectedLayer(self: *Self, all_meshes: *const [LODLevel.count]MeshMap, layer: LODRenderLayer, stats: ?*LODStats) bool { + const query = if (@hasDecl(RHI, "query")) self.rhi.query() else self.rhi; + const render_ctx = if (@hasDecl(RHI, "renderContext")) self.rhi.renderContext() else self.rhi; + self.frame_index = query.getFrameIndex(); + defer if (@hasDecl(@TypeOf(render_ctx), "setInstanceBuffer")) render_ctx.setInstanceBuffer(0); + render_ctx.setLODInstanceBuffer(self.instance_buffers[self.frame_index]); + self.instance_data.clearRetainingCapacity(); + self.draw_list.clearRetainingCapacity(); + for (&self.draw_commands) |*commands| commands.clearRetainingCapacity(); + if (stats) |s| { + s.gpu_culling_overflows = self.gpu_culling_overflow_count; + if (self.gpu_culling) |gpu| s.gpu_culling_validation_mismatches = gpu.diagnostics().validation_mismatch_count; + } + + const gpu_frame_prepared = self.projection_frame != null and self.gpu_culling_ready_frame == self.projection_frame; + if (self.renderGpuCulledLayer(layer, stats, render_ctx, query)) return true; + if (gpu_frame_prepared) return false; + + for (self.projection_regions.items) |visible| { + const lod_idx = @intFromEnum(visible.key.lod); + const mesh = all_meshes[lod_idx].get(visible.key) orelse continue; + const range = mesh.drawRange(layer) orelse continue; + if (!mesh.isReady() or range.count == 0) continue; + if (mesh.isCompact()) { + if (self.renderCompactMesh(render_ctx, visible, mesh, layer)) { + if (stats) |s| if (layer == .fluid) { + s.fluid_drawn[lod_idx] += 1; + s.fluid_instances[lod_idx] += 1; + } else { + s.drawn[lod_idx] += 1; + s.instances[lod_idx] += 1; + }; + } else { + // Suppress this representation immediately so the CPU + // projection keeps its parent fallback visible. The + // manager consumes the failure and rebuilds a CPU mesh. + mesh.markCompactDrawFailed(); + _ = self.renderParentFallback(all_meshes, visible, layer, render_ctx); + } + continue; + } + const lod_y_offset: f32 = if (layer == .fluid) 0.0 else -0.05; + var instance = rhi_types.InstanceData{ .model = visible.model, .mask_radius = visible.mask_radius, .lod_fade = visible.lod_fade, .padding = .{ 0, 0 } }; + instance.model.data[3][1] += lod_y_offset; + self.instance_data.append(self.allocator, instance) catch continue; + self.draw_list.append(self.allocator, mesh) catch { + _ = self.instance_data.pop(); + continue; + }; + if (mesh.isPooled()) { + self.draw_commands[lod_idx].append(self.allocator, .{ + .vertexCount = range.count, + .instanceCount = 1, + .firstVertex = mesh.firstVertex(range), + .firstInstance = @intCast(self.instance_data.items.len - 1), + }) catch { + _ = self.draw_list.pop(); + _ = self.instance_data.pop(); + continue; + }; + } + if (stats) |s| { + if (layer == .fluid) { + s.fluid_drawn[lod_idx] += 1; + s.fluid_instances[lod_idx] += 1; + } else { + s.drawn[lod_idx] += 1; + s.instances[lod_idx] += 1; + } + } + } + if (self.instance_data.items.len == 0) return true; + const indirect_drawn = self.enable_mdi and self.renderIndirectBatches(render_ctx, query); + for (self.draw_list.items, 0..) |mesh, index| { + if (indirect_drawn and mesh.isPooled()) continue; + const instance = self.instance_data.items[index]; + const range = mesh.drawRange(layer) orelse continue; + render_ctx.setModelMatrix(instance.model, Vec3.one, instance.mask_radius); + if (@hasDecl(@TypeOf(render_ctx), "drawOffset")) { + render_ctx.drawOffset(mesh.bufferHandle(), range.count, .triangles, mesh.vertexOffset() + range.offset); + } else render_ctx.draw(mesh.bufferHandle(), range.count, .triangles); + } + return true; + } + + fn renderParentFallback(self: *Self, all_meshes: *const [LODLevel.count]MeshMap, child: VisibleRegion, layer: LODRenderLayer, render_ctx: anytype) bool { + var child_key = child.key; + var child_model = child.model; + while (child_key.parentKey()) |parent_key| { + const child_size: i32 = @intCast(child_key.lod.chunksPerSide() * CHUNK_SIZE_X); + const parent_size: i32 = @intCast(parent_key.lod.chunksPerSide() * CHUNK_SIZE_X); + child_model.data[3][0] += @floatFromInt(parent_key.rx * parent_size - child_key.rx * child_size); + child_model.data[3][2] += @floatFromInt(parent_key.rz * parent_size - child_key.rz * child_size); + const mesh = all_meshes[@intFromEnum(parent_key.lod)].get(parent_key) orelse { + child_key = parent_key; + continue; + }; + const range = mesh.drawRange(layer) orelse { + child_key = parent_key; + continue; + }; + if (!mesh.isReady() or range.count == 0) { + child_key = parent_key; + continue; + } + const parent_visible = VisibleRegion{ + .key = parent_key, + .model = child_model, + .mask_radius = LOD_UNMASKED_SENTINEL, + .lod_fade = 1.0, + }; + if (mesh.isCompact()) { + if (self.renderCompactMesh(render_ctx, parent_visible, mesh, layer)) return true; + mesh.markCompactDrawFailed(); + child_key = parent_key; + continue; + } + render_ctx.setModelMatrix(child_model, Vec3.one, LOD_UNMASKED_SENTINEL); + if (@hasDecl(@TypeOf(render_ctx), "drawOffset")) { + render_ctx.drawOffset(mesh.bufferHandle(), range.count, .triangles, mesh.vertexOffset() + range.offset); + } else { + render_ctx.draw(mesh.bufferHandle(), range.count, .triangles); + } + return true; + } + return false; + } + + fn renderCompactMesh(self: *Self, render_ctx: anytype, visible: VisibleRegion, mesh: *LODMesh, layer: LODRenderLayer) bool { + if (comptime !@hasDecl(@TypeOf(render_ctx), "drawCompactLOD")) return false; + const lod = mesh.lodLevel(); + if (lod != .lod3 and lod != .lod4 or self.compact_pool.buffer_handle == 0) return false; + render_ctx.setLODCompactSampleBuffer(self.compact_pool.buffer_handle); + const layer_index: usize = if (layer == .fluid) 1 else 0; + const index_buffer = self.compact_index_buffers[@intFromEnum(lod) - @intFromEnum(LODLevel.lod3)][layer_index]; + const index_count: u32 = @intCast(compactGridIndexCount(mesh.compact_tile_width, layer == .terrain)); + return render_ctx.drawCompactLOD(index_buffer, index_count, .{ + .model = visible.model, + .mask_radius = visible.mask_radius, + .lod_fade = visible.lod_fade, + .sample_offset = mesh.compact_sample_offset, + .width = mesh.compact_tile_width, + .cell_size = @as(f32, @floatFromInt(lod_chunk.regionSizeBlocks(lod))) / @as(f32, @floatFromInt(mesh.compact_tile_width - 1)), + .layer = if (layer == .fluid) 1 else 0, + .skirt_depth = @max(16.0, @as(f32, @floatFromInt(lod_chunk.regionSizeBlocks(lod))) / @as(f32, @floatFromInt(mesh.compact_tile_width - 1)) * 2.0), + }); + } + + fn renderGpuCulledLayer(self: *Self, layer: LODRenderLayer, stats: ?*LODStats, render_ctx: anytype, query: anytype) bool { + const frame = self.projection_frame orelse return false; + if (self.gpu_culling_ready_frame != frame) return false; + const gpu = self.gpu_culling orelse return false; + if (!@hasDecl(@TypeOf(render_ctx), "drawIndirectCount") or !@hasDecl(@TypeOf(query), "supportsIndirectCount")) return false; + if (!query.supportsIndirectFirstInstance() or !query.supportsIndirectCount() or self.gpu_candidates.items.len < self.gpu_culling_threshold) return false; + var has_standard = false; + for (self.gpu_candidates.items) |candidate| { + const lod_index = candidate.lod_and_padding[0]; + if (lod_index >= LODLevel.count) return false; + const command = if (layer == .fluid) candidate.water_command else candidate.terrain_command; + if (candidate.lod_and_padding[1] == 0 and command.count != 0) { + has_standard = true; + if (self.vertex_pools[lod_index].buffer_handle == 0) return false; + } + } + const fi = self.frame_index; + render_ctx.setLODInstanceBuffer(gpu.instanceBuffer(fi, layer == .fluid, false)); + const commands = gpu.indirectBuffer(fi, layer == .fluid, false); + const counts = gpu.countBuffer(fi); + const stride = @sizeOf(rhi_types.DrawIndirectCommand); + if (has_standard) for (0..LODLevel.count) |lod_index| { + if (self.vertex_pools[lod_index].buffer_handle == 0) continue; + if (!render_ctx.drawIndirectCount(self.vertex_pools[lod_index].buffer_handle, commands, lod_index * MAX_LOD_MDI_REGIONS * stride, counts, (if (layer == .fluid) LODLevel.count + lod_index else lod_index) * @sizeOf(u32), MAX_LOD_MDI_REGIONS, stride)) return false; + }; + var has_compact = false; + for (self.gpu_candidates.items) |candidate| { + if (candidate.lod_and_padding[1] != 0 and (if (layer == .fluid) candidate.water_command.count else candidate.terrain_command.count) != 0) has_compact = true; + } + if (has_compact) { + if (!@hasDecl(@TypeOf(render_ctx), "drawCompactLODIndirectCount") or self.compact_pool.buffer_handle == 0) return false; + render_ctx.setLODCompactSampleBuffer(self.compact_pool.buffer_handle); + render_ctx.setLODCompactInstanceBuffer(gpu.instanceBuffer(fi, layer == .fluid, true)); + const compact_commands = gpu.indirectBuffer(fi, layer == .fluid, true); + const compact_count_base: usize = if (layer == .fluid) LODLevel.count * 3 else LODLevel.count * 2; + inline for (.{ LODLevel.lod3, LODLevel.lod4 }) |lod| { + const lod_index = @intFromEnum(lod); + const index_buffer = self.compact_index_buffers[lod_index - @intFromEnum(LODLevel.lod3)][if (layer == .fluid) 1 else 0]; + if (index_buffer == 0) return false; + if (!render_ctx.drawCompactLODIndirectCount(index_buffer, compact_commands, lod_index * MAX_LOD_MDI_REGIONS * @sizeOf(rhi_types.DrawIndexedIndirectCommand), counts, (compact_count_base + lod_index) * @sizeOf(u32), MAX_LOD_MDI_REGIONS)) return false; + } + } + if (stats) |s| { + // GPU counters remain device-local; keep submitted streams distinct + // from CPU visibility counts without introducing a readback stall. + const submitted: u32 = @intCast(self.gpu_candidates.items.len); + if (layer == .fluid) s.gpu_fluid_candidates = submitted else s.gpu_terrain_candidates = submitted; + const diagnostics = gpu.diagnostics(); + s.gpu_culling_overflows = self.gpu_culling_overflow_count + diagnostics.overflow_count; + s.gpu_culling_validation_mismatches = diagnostics.validation_mismatch_count; + } + return true; + } + fn collectVisibleMeshes( self: *Self, all_meshes: *const [LODLevel.count]MeshMap, @@ -327,6 +906,7 @@ pub fn LODRenderer(comptime RHI: type) type { max_distance_chunks: ?i32, layer: LODRenderLayer, stats: ?*LODStats, + profiling: ?*LODProfilingCollector, ) !void { const meshes = &all_meshes[@intFromEnum(lod)]; const regions = &all_regions[@intFromEnum(lod)]; @@ -347,21 +927,29 @@ pub fn LODRenderer(comptime RHI: type) type { while (iter.next()) |entry| { diag.meshes_seen += 1; const mesh = entry.value_ptr.*; + // The legacy immediate path has no indexed compact submission + // context. Never reinterpret compact samples as expanded + // vertices; frame-based rendering owns compact draws. + if (mesh.isCompact()) continue; const draw_range = mesh.drawRange(layer) orelse { diag.not_ready += 1; + if (profiling) |profile| profile.addRejected(); continue; }; if (!mesh.isReady() or draw_range.count == 0) { diag.not_ready += 1; + if (profiling) |profile| profile.addRejected(); continue; } if (regions.get(entry.key_ptr.*)) |chunk| { if (!chunk.isRenderable()) { diag.bad_state += 1; + if (profiling) |profile| profile.addRejected(); continue; } if (self.isCoveredByFinerLOD(chunk, config)) { diag.covered_finer_lod += 1; + if (profiling) |profile| profile.addRejected(); continue; } @@ -371,6 +959,15 @@ pub fn LODRenderer(comptime RHI: type) type { if (max_distance_chunks) |max_dist| { if (!isRegionInRange(chunk_bounds, camera_pos, max_dist)) { diag.out_of_range += 1; + if (profiling) |profile| profile.addRejected(); + continue; + } + } + + if (use_frustum and !disable_frustum) { + if (!isRegionInFrustum(frustum, bounds, camera_pos)) { + diag.frustum_culled += 1; + if (profiling) |profile| profile.addRejected(); continue; } } @@ -382,10 +979,16 @@ pub fn LODRenderer(comptime RHI: type) type { const pc_x = camera_chunk.chunk_x; const pc_z = camera_chunk.chunk_z; const chunk_radius = config.getChunkRenderRadius(); + const coverage_timer = if (profiling) |profile| profile.begin() else null; const cov = self.isCoveredByChunks(bounds, checker, ctx_ptr, pc_x, pc_z, chunk_radius); + if (profiling) |profile| { + profile.end(.coverage, coverage_timer); + profile.addCoverage(); + } if (cov.covered) { lod_covered += 1; diag.covered_chunks += 1; + if (profiling) |profile| profile.addRejected(); continue; } if (lod_rendered == 0) { @@ -405,13 +1008,6 @@ pub fn LODRenderer(comptime RHI: type) type { lod_rendered += 1; - if (use_frustum and !disable_frustum) { - if (!isRegionInFrustum(frustum, bounds, camera_pos)) { - diag.frustum_culled += 1; - continue; - } - } - const model = Mat4.translate(Vec3.init(@as(f32, @floatFromInt(bounds.min_x)) - camera_pos.x, -camera_pos.y + lod_y_offset, @as(f32, @floatFromInt(bounds.min_z)) - camera_pos.z)); // Parent coverage, not camera distance, owns LOD handoff. A @@ -434,6 +1030,7 @@ pub fn LODRenderer(comptime RHI: type) type { }); } diag.drawn += 1; + if (profiling) |profile| profile.addVisible(); if (stats) |s| { const lod_idx = @intFromEnum(lod); if (layer == .fluid) { @@ -446,6 +1043,7 @@ pub fn LODRenderer(comptime RHI: type) type { } } else { diag.missing_region += 1; + if (profiling) |profile| profile.addRejected(); } } @@ -578,16 +1176,22 @@ pub fn LODRenderer(comptime RHI: type) type { fn onUpload(mesh: *LODMesh, ctx: *anyopaque) rhi_types.RhiError!void { const renderer: *Self = @ptrCast(@alignCast(ctx)); const resources = LODMeshResources.fromProvider(RHI, &renderer.rhi); - if (!renderer.enable_mdi) { - return mesh.upload(resources); - } + if (mesh.isCompact()) return renderer.compact_pool.upload(mesh, resources); + // Expanded far CPU fallback meshes use dedicated buffers. + // Growing a far shared pool republishes its whole shadow + // and can exceed the bounded staging ring in one frame. + if (!renderer.enable_mdi or mesh.lodLevel() == .lod3 or mesh.lodLevel() == .lod4) return mesh.upload(resources); return renderer.vertex_pools[@intFromEnum(mesh.lodLevel())].uploadMesh(mesh, resources); } fn onDestroy(mesh: *LODMesh, ctx: *anyopaque) void { const renderer: *Self = @ptrCast(@alignCast(ctx)); const resources = LODMeshResources.fromProvider(RHI, &renderer.rhi); + if (mesh.isCompact()) { + renderer.compact_pool.retireMesh(mesh, renderer.frame_serial, renderer.frame_index); + return; + } if (mesh.isPooled()) { - renderer.vertex_pools[@intFromEnum(mesh.lodLevel())].destroyMesh(mesh); + renderer.vertex_pools[@intFromEnum(mesh.lodLevel())].destroyMeshDeferred(mesh, renderer.frame_serial, renderer.frame_index); } else { mesh.deinit(resources); } @@ -596,15 +1200,46 @@ pub fn LODRenderer(comptime RHI: type) type { const renderer: *Self = @ptrCast(@alignCast(ctx)); renderer.rhi.waitIdle(); } + fn onSupportsCompact(ctx: *anyopaque) bool { + const renderer: *Self = @ptrCast(@alignCast(ctx)); + const RenderContext = @TypeOf(if (@hasDecl(RHI, "renderContext")) @as(RHI, undefined).renderContext() else @as(RHI, undefined)); + if (!@hasDecl(RenderContext, "drawCompactLOD") or !@hasDecl(RenderContext, "setLODCompactSampleBuffer")) return false; + for (renderer.compact_index_buffers) |layers| for (layers) |handle| if (handle == 0) return false; + return true; + } }; return .{ .on_upload = Wrapper.onUpload, .on_destroy = Wrapper.onDestroy, .on_wait_idle = Wrapper.onWaitIdle, .ctx = @ptrCast(self), + .on_supports_compact = Wrapper.onSupportsCompact, }; } + fn memoryStats(self: *Self) LODRendererMemoryStats { + var result = LODRendererMemoryStats{}; + for (&self.vertex_pools) |*pool| { + const capacity = pool.gpuMemoryBytes(); + const allocated = pool.allocatedBytes(); + result.pool_gpu_capacity_bytes += capacity; + result.pool_gpu_allocated_bytes += allocated; + result.pool_gpu_slack_bytes += capacity - allocated; + // The pool keeps a full CPU copy so it can grow and compact + // without a GPU readback. + 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; + result.compact_pool_retired_bytes = compact.retired_bytes; + return result; + } + /// Create a type-erased LODRenderInterface from this renderer. pub fn toInterface(self: *Self) LODRenderInterface { const Wrapper = struct { @@ -621,9 +1256,37 @@ pub fn LODRenderer(comptime RHI: type) type { max_distance_chunks: ?i32, layer: LODRenderLayer, stats: ?*LODStats, + profiling: ?*LODProfilingCollector, ) void { const renderer: *Self = @ptrCast(@alignCast(self_ptr)); - renderer.render(meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer, stats); + renderer.render(meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer, stats, profiling); + } + fn renderFrameFn( + 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, + use_frustum: bool, + max_distance_chunks: ?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); + } + 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) 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); + } + fn memoryStatsFn(self_ptr: *anyopaque) LODRendererMemoryStats { + const renderer: *Self = @ptrCast(@alignCast(self_ptr)); + return renderer.memoryStats(); } fn deinitFn(self_ptr: *anyopaque) void { const renderer: *Self = @ptrCast(@alignCast(self_ptr)); @@ -632,6 +1295,9 @@ pub fn LODRenderer(comptime RHI: type) type { }; return .{ .render_fn = Wrapper.renderFn, + .render_frame_fn = Wrapper.renderFrameFn, + .prepare_frame_fn = Wrapper.prepareFrameFn, + .memory_stats_fn = Wrapper.memoryStatsFn, .deinit_fn = Wrapper.deinitFn, .ptr = self, }; @@ -666,6 +1332,44 @@ fn supports_lod_indirect(comptime RenderCtx: type, comptime Query: type, comptim @hasDecl(Query, "supportsIndirectFirstInstance"); } +fn cullCommandFor(mesh: *const LODMesh, range: ?LODMesh.DrawRange, compact: bool, terrain: bool) rhi_types.LODCullCommand { + const draw_range = range orelse return .{ .count = 0, .instance_count = 0, .first = 0 }; + if (compact) return .{ + .count = @intCast(compactGridIndexCount(mesh.compact_tile_width, terrain)), + .instance_count = 1, + .first = 0, + .vertex_offset = 0, + }; + return .{ + .count = draw_range.count, + .instance_count = 1, + .first = mesh.firstVertex(draw_range), + }; +} + +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] }, + }; + for (&planes) |*plane| { + const length = @sqrt(plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]); + if (length > 0.0001) { + for (plane) |*value| value.* /= length; + } + } + return planes; +} + +fn gpuCullingThreshold() usize { + return engine_core.envInt("ZIGCRAFT_LOD_GPU_CULLING_THRESHOLD", 128); +} + fn isRegionInFrustum(frustum: Frustum, bounds: LODChunk.WorldBounds, camera_pos: Vec3) bool { const min_x: f32 = @floatFromInt(bounds.min_x); const min_z: f32 = @floatFromInt(bounds.min_z); @@ -768,7 +1472,9 @@ test "LODRenderer batches pooled meshes into per-LOD indirect draws" { pub fn setInstanceBuffer(_: @This(), _: anytype) void {} pub fn setSelectionMode(_: @This(), _: bool) void {} pub fn draw(_: @This(), _: u32, _: u32, _: anytype) void {} - pub fn drawOffset(_: @This(), _: u32, _: u32, _: anytype, _: usize) void {} + pub fn drawOffset(self: @This(), _: u32, _: u32, _: anytype, _: usize) void { + self.state.direct_draw_calls += 1; + } pub fn drawIndirect(self: @This(), _: u32, _: u32, _: usize, draw_count: u32, _: u32) void { self.state.draw_indirect_calls += 1; self.state.last_draw_count += draw_count; @@ -781,6 +1487,7 @@ test "LODRenderer batches pooled meshes into per-LOD indirect draws" { const Renderer = LODRenderer(MockRHI); const renderer = try Renderer.init(allocator, mock_rhi); defer renderer.deinit(); + // Explicitly exercise the default-on MDI path. renderer.enable_mdi = true; renderer.vertex_pools[1].buffer_handle = 101; @@ -824,10 +1531,31 @@ test "LODRenderer batches pooled meshes into per-LOD indirect draws" { try regions[2].put(.{ .rx = 8, .rz = 0, .lod = .lod2 }, &chunk_lod2); var mock_config = LODConfig{ .radii = .{ 16, 128, 256, 512, 1024 } }; - renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null); + var profiling = LODProfilingCollector.init(true); + renderer.renderFrame(99, &meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .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); + + // Water reuses the pooled indirect path rather than falling back to one + // direct submission per region. + mesh_lod1.water_vertex_offset = 12 * @sizeOf(rhi_types.Vertex); + 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); + 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; + try std.testing.expectEqual(@as(u64, 1), projection[1].candidates); + try std.testing.expectEqual(@as(u64, 1), projection[2].candidates); + + // 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); + try std.testing.expectEqual(@as(u32, 5), mock_state.draw_indirect_calls); + try std.testing.expectEqual(@as(u32, 1), mock_state.direct_draw_calls); } test "LODRenderer band fade follows configured fog start percent" { @@ -903,6 +1631,9 @@ test "LODRenderer render draw path" { const Renderer = LODRenderer(MockRHI); const renderer = try Renderer.init(allocator, mock_rhi); defer renderer.deinit(); + // MDI is enabled, but this RHI intentionally lacks indirect support. + // Rendering must preserve the direct fallback for both terrain and water. + renderer.enable_mdi = true; // Create mock mesh var mesh = LODMesh.init(allocator, .lod1); @@ -945,7 +1676,7 @@ test "LODRenderer render draw path" { var stats = LODStats{}; // Call render with explicit parameters - renderer.render(&meshes, ®ions, mock_config.interface(), view_proj, camera_pos, null, null, false, null, .terrain, &stats); + renderer.render(&meshes, ®ions, mock_config.interface(), view_proj, camera_pos, null, null, false, null, .terrain, &stats, null); // Verify draw was called with correct parameters try std.testing.expectEqual(@as(u32, 1), mock_state.draw_calls); @@ -955,7 +1686,7 @@ test "LODRenderer render draw path" { try std.testing.expectEqual(@as(u32, 1), stats.drawn[1]); try std.testing.expectEqual(@as(u32, 1), stats.instances[1]); - renderer.render(&meshes, ®ions, mock_config.interface(), view_proj, camera_pos, null, null, false, null, .fluid, &stats); + renderer.render(&meshes, ®ions, mock_config.interface(), view_proj, camera_pos, null, null, false, null, .fluid, &stats, null); try std.testing.expectEqual(@as(u32, 2), mock_state.draw_calls); try std.testing.expectEqual(@as(u32, 6), mock_state.last_vertex_count); try std.testing.expectEqual(@as(u32, 1), stats.drawn[1]); @@ -1030,7 +1761,7 @@ test "LODRenderer keeps coarse LOD visible while finer bands stream" { .radii = .{ 16, 32, 64, 100, 256 }, }; - renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null); + renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null, null); try std.testing.expectEqual(@as(u32, 1), mock_state.draw_calls); try std.testing.expectEqual(@as(u32, 1), mock_state.set_matrix_calls); @@ -1104,7 +1835,7 @@ test "LODRenderer disables mask when chunks are missing inside chunk render radi } }; - renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, Checker.missingInRadius, &checker_ctx, false, null, .terrain, null); + renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, Checker.missingInRadius, &checker_ctx, false, null, .terrain, null, null); try std.testing.expectEqual(@as(u32, 1), mock_state.draw_calls); try std.testing.expectEqual(LOD_UNMASKED_SENTINEL, mock_state.last_mask_radius); @@ -1184,7 +1915,7 @@ test "LODRenderer chunk mask uses chunk render radius instead of LOD0 radius" { } }; - renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, Checker.missing, &checker_ctx, false, null, .terrain, null); + renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, Checker.missing, &checker_ctx, false, null, .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); @@ -1258,7 +1989,7 @@ test "LODRenderer keeps mask when only outside-radius chunks are uncovered" { } }; - renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, Checker.loaded, &checker_ctx, false, null, .terrain, null); + renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, Checker.loaded, &checker_ctx, false, null, .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); @@ -1332,7 +2063,7 @@ 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); + renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, Checker.partiallyLoaded, &checker_ctx, false, null, .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); @@ -1418,7 +2149,7 @@ test "LODRenderer skips coarse LOD when finer coverage is ready" { .radii = .{ 16, 32, 64, 100, 256 }, }; - renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null); + renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null, null); try std.testing.expectEqual(@as(u32, 4), mock_state.draw_calls); } @@ -1483,7 +2214,7 @@ test "LODRenderer always renders ready LOD0 regions" { var mock_config = LODConfig{ .radii = .{ 16, 32, 64, 100, 256 } }; var stats = LODStats{}; - renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, &stats); + renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, &stats, null); try std.testing.expectEqual(@as(u32, 1), mock_state.draw_calls); try std.testing.expectEqual(@as(u32, 1), stats.drawn[0]); @@ -1568,7 +2299,7 @@ test "LODRenderer keeps coarse LOD when a finer child is missing" { var mock_config = LODConfig{ .radii = .{ 16, 32, 64, 100, 256 } }; - renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null); + renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null, null); try std.testing.expectEqual(@as(u32, 4), mock_state.draw_calls); try std.testing.expectEqual(@as(u32, 106), mock_state.handle_sum); @@ -1653,7 +2384,7 @@ test "LODRenderer resolves finer coverage across negative region boundaries" { var mock_config = LODConfig{ .radii = .{ 16, 32, 64, 100, 256 } }; - renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null); + renderer.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null, null); try std.testing.expectEqual(@as(u32, 4), mock_state.draw_calls); try std.testing.expectEqual(@as(u32, 10), mock_state.handle_sum); @@ -1751,7 +2482,7 @@ test "LODRenderer createGPUBridge and toInterface round-trip" { var mock_config = LODConfig{}; // Render through the type-erased interface - iface.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null); + iface.render(&meshes, ®ions, mock_config.interface(), Mat4.identity, Vec3.zero, null, null, false, null, .terrain, null, null); // Verify the real renderer's draw was invoked through the interface try std.testing.expectEqual(@as(u32, 1), mock_state.draw_calls); diff --git a/modules/world-lod/src/lod_scheduler.zig b/modules/world-lod/src/lod_scheduler.zig index 8b8f39f4..1c7c13c9 100644 --- a/modules/world-lod/src/lod_scheduler.zig +++ b/modules/world-lod/src/lod_scheduler.zig @@ -192,7 +192,10 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu const existing = storage.get(cand.key); if (existing != null) diag.existing += 1; - const needs_queue = if (existing) |chunk| chunk.getState() == .missing else true; + // A cancelled worker keeps the region pinned until it observes its + // cancellation signal. Do not reset that signal by dispatching a new + // generation for the same region concurrently. + const needs_queue = if (existing) |chunk| chunk.getState() == .missing and !chunk.isPinned() else true; if (!needs_queue) continue; const chunk = if (existing) |c| c else blk: { @@ -208,6 +211,7 @@ pub fn queueLODRegions(ctx: SchedulerContext, lod: LODLevel, velocity: Vec3, chu if (ctx.defer_generation_dispatch) { chunk.setState(.queued_for_generation); } else { + chunk.resetCancellation(); chunk.setState(.generating); queue.push(.{ .type = .chunk_generation, @@ -306,6 +310,7 @@ test "LOD scheduling queues LOD0 generation jobs" { var mutex: sync.RwLock = .{}; var next_job_token: u32 = 1; var radius_reduction = [_]i32{0} ** LODLevel.count; + var pending_regions: usize = 0; var coverage_ctx: u8 = 0; const Coverage = struct { fn neverCovered(_: *anyopaque, _: LODChunk.WorldBounds, _: ChunkChecker, _: *anyopaque) bool { @@ -328,10 +333,12 @@ test "LOD scheduling queues LOD0 generation jobs" { .coverage_ptr = &coverage_ctx, .are_all_chunks_loaded = Coverage.neverCovered, .radius_reduction = &radius_reduction, + .pending_regions = &pending_regions, }, .lod0, Vec3.zero, null, null); const queue = queue_ptrs[LODLevel.count - 1]; try std.testing.expect(queue.count() > 0); + try std.testing.expectEqual(queue.count(), pending_regions); const job = queue.pop().?; try std.testing.expectEqual(engine_core.job_system.JobType.chunk_generation, job.type); try std.testing.expectEqual(@as(u3, 0), job.data.chunk.lod_level); diff --git a/modules/world-lod/src/lod_stats.zig b/modules/world-lod/src/lod_stats.zig index a8593710..3dc35649 100644 --- a/modules/world-lod/src/lod_stats.zig +++ b/modules/world-lod/src/lod_stats.zig @@ -1,11 +1,350 @@ //! LOD system statistics and aggregation helpers. +const std = @import("std"); +const MonotonicTimer = @import("engine-core").time.MonotonicTimer; const lod_types = @import("lod_types.zig"); const LODLevel = lod_types.LODLevel; const LODState = lod_types.LODState; +/// Read-only LOD profiling data. Durations are cumulative CPU timings; memory +/// fields are current known allocations rather than GPU-driver measurements. +pub const LODProfilingSnapshot = struct { + enabled: bool = false, + update_ms: f64 = 0, + scheduling_ms: f64 = 0, + /// Cumulative dedicated cache-worker CPU/I/O time. This is intentionally + /// not update-thread blocking time; frame work only queues and drains. + cache_ms: f64 = 0, + generation_dispatch_ms: f64 = 0, + state_transition_ms: f64 = 0, + upload_prep_ms: f64 = 0, + upload_submission_ms: f64 = 0, + visibility_ms: f64 = 0, + coverage_ms: f64 = 0, + eviction_ms: f64 = 0, + worker_generation_ms: f64 = 0, + worker_mesh_construction_ms: f64 = 0, + manager_lock_wait_ms: f64 = 0, + manager_lock_hold_ms: f64 = 0, + upload_bytes: u64 = 0, + pending_cpu_upload_bytes: u64 = 0, + /// Upload-budget deferrals plus RHI upload-pressure errors. + staging_pressure_count: u64 = 0, + visible_count: u64 = 0, + rejected_count: u64 = 0, + coverage_count: u64 = 0, + /// Per-LOD visibility projection telemetry. These counters are cumulative + /// and count a region once, even when terrain and water are both drawn. + visibility_levels: [LODLevel.count]LODVisibilityLevelSnapshot = [_]LODVisibilityLevelSnapshot{.{}} ** LODLevel.count, + /// Current known GPU allocation bytes retained by meshes awaiting destruction. + deferred_deletion_bytes: u64 = 0, + deferred_deletion_cpu_bytes: u64 = 0, + pool_gpu_capacity_bytes: u64 = 0, + pool_gpu_allocated_bytes: u64 = 0, + pool_gpu_slack_bytes: u64 = 0, + pool_cpu_shadow_bytes: u64 = 0, + compact_pool_capacity_bytes: u64 = 0, + compact_pool_allocated_bytes: u64 = 0, + compact_pool_free_bytes: u64 = 0, + compact_pool_retired_bytes: u64 = 0, + direct_mesh_gpu_bytes: u64 = 0, + known_memory_bytes: u64 = 0, + wait_idle_count: u64 = 0, + wait_idle_ms: f64 = 0, +}; + +pub const LODVisibilityLevelSnapshot = struct { + candidates: u64 = 0, + accepted: u64 = 0, + rejected_no_draw: u64 = 0, + rejected_not_ready: u64 = 0, + rejected_missing_region: u64 = 0, + rejected_not_renderable: u64 = 0, + rejected_finer_coverage: u64 = 0, + rejected_range: u64 = 0, + rejected_frustum: u64 = 0, + rejected_chunk_coverage: u64 = 0, + coverage_checks: u64 = 0, +}; + +const LODVisibilityLevelCounters = struct { + candidates: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + accepted: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + rejected_no_draw: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + rejected_not_ready: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + rejected_missing_region: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + rejected_not_renderable: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + rejected_finer_coverage: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + rejected_range: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + rejected_frustum: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + rejected_chunk_coverage: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + coverage_checks: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), + + fn add(self: *LODVisibilityLevelCounters, value: LODVisibilityLevelSnapshot) void { + inline for (std.meta.fields(LODVisibilityLevelSnapshot)) |field| { + const amount = @field(value, field.name); + if (amount != 0) _ = @field(self, field.name).fetchAdd(amount, .monotonic); + } + } + + fn reset(self: *LODVisibilityLevelCounters) void { + inline for (std.meta.fields(LODVisibilityLevelSnapshot)) |field| { + @field(self, field.name).store(0, .monotonic); + } + } + + fn snapshot(self: *const LODVisibilityLevelCounters) LODVisibilityLevelSnapshot { + var result = LODVisibilityLevelSnapshot{}; + inline for (std.meta.fields(LODVisibilityLevelSnapshot)) |field| { + @field(result, field.name) = @field(self, field.name).load(.monotonic); + } + return result; + } +}; + +/// Thread-safe backing store for an `LODProfilingSnapshot`. +/// +/// Profiling is enabled once when an LOD manager is initialized. All timing +/// methods return before touching a clock when disabled, while worker updates +/// use atomics so they never race snapshot collection on the update thread. +pub const LODProfilingCollector = struct { + const AtomicU64 = std.atomic.Value(u64); + + pub const TimerKind = enum { + update, + scheduling, + cache, + generation_dispatch, + state_transition, + upload_prep, + upload_submission, + visibility, + coverage, + eviction, + worker_generation, + worker_mesh_construction, + manager_lock_wait, + manager_lock_hold, + wait_idle, + }; + + enabled: bool = false, + update_ns: AtomicU64 = AtomicU64.init(0), + scheduling_ns: AtomicU64 = AtomicU64.init(0), + cache_ns: AtomicU64 = AtomicU64.init(0), + generation_dispatch_ns: AtomicU64 = AtomicU64.init(0), + state_transition_ns: AtomicU64 = AtomicU64.init(0), + upload_prep_ns: AtomicU64 = AtomicU64.init(0), + upload_submission_ns: AtomicU64 = AtomicU64.init(0), + visibility_ns: AtomicU64 = AtomicU64.init(0), + coverage_ns: AtomicU64 = AtomicU64.init(0), + eviction_ns: AtomicU64 = AtomicU64.init(0), + worker_generation_ns: AtomicU64 = AtomicU64.init(0), + worker_mesh_construction_ns: AtomicU64 = AtomicU64.init(0), + manager_lock_wait_ns: AtomicU64 = AtomicU64.init(0), + manager_lock_hold_ns: AtomicU64 = AtomicU64.init(0), + wait_idle_ns: AtomicU64 = AtomicU64.init(0), + upload_bytes: AtomicU64 = AtomicU64.init(0), + pending_cpu_upload_bytes: AtomicU64 = AtomicU64.init(0), + staging_pressure_count: AtomicU64 = AtomicU64.init(0), + visible_count: AtomicU64 = AtomicU64.init(0), + rejected_count: AtomicU64 = AtomicU64.init(0), + coverage_count: AtomicU64 = AtomicU64.init(0), + visibility_levels: [LODLevel.count]LODVisibilityLevelCounters = [_]LODVisibilityLevelCounters{.{}} ** LODLevel.count, + deferred_deletion_bytes: AtomicU64 = AtomicU64.init(0), + deferred_deletion_cpu_bytes: AtomicU64 = AtomicU64.init(0), + pool_gpu_capacity_bytes: AtomicU64 = AtomicU64.init(0), + pool_gpu_allocated_bytes: AtomicU64 = AtomicU64.init(0), + pool_gpu_slack_bytes: AtomicU64 = AtomicU64.init(0), + pool_cpu_shadow_bytes: AtomicU64 = AtomicU64.init(0), + compact_pool_capacity_bytes: AtomicU64 = AtomicU64.init(0), + compact_pool_allocated_bytes: AtomicU64 = AtomicU64.init(0), + compact_pool_free_bytes: AtomicU64 = AtomicU64.init(0), + compact_pool_retired_bytes: AtomicU64 = AtomicU64.init(0), + direct_mesh_gpu_bytes: AtomicU64 = AtomicU64.init(0), + known_memory_bytes: AtomicU64 = AtomicU64.init(0), + wait_idle_count: AtomicU64 = AtomicU64.init(0), + + pub fn init(enabled: bool) LODProfilingCollector { + return .{ .enabled = enabled }; + } + + pub fn begin(self: *const LODProfilingCollector) ?MonotonicTimer { + if (!self.enabled) return null; + return MonotonicTimer.start(); + } + + pub fn end(self: *LODProfilingCollector, kind: TimerKind, timer: ?MonotonicTimer) void { + const elapsed = timer orelse return; + _ = self.counterFor(kind).fetchAdd(elapsed.read(), .monotonic); + } + + pub fn add(self: *LODProfilingCollector, counter: *AtomicU64, value: u64) void { + if (!self.enabled or value == 0) return; + _ = counter.fetchAdd(value, .monotonic); + } + + pub fn addUploadBytes(self: *LODProfilingCollector, bytes: usize) void { + self.add(&self.upload_bytes, @intCast(bytes)); + } + + pub fn setPendingCpuUploadBytes(self: *LODProfilingCollector, bytes: usize) void { + if (!self.enabled) return; + self.pending_cpu_upload_bytes.store(@intCast(bytes), .monotonic); + } + + pub fn setMemoryAccounting(self: *LODProfilingCollector, pool_gpu_capacity: usize, pool_gpu_allocated: usize, pool_gpu_slack: usize, pool_cpu_shadow: usize, compact_pool_capacity: usize, compact_pool_allocated: usize, compact_pool_free: usize, compact_pool_retired: usize, direct_mesh_gpu: usize, known_memory: usize) void { + if (!self.enabled) return; + self.pool_gpu_capacity_bytes.store(@intCast(pool_gpu_capacity), .monotonic); + self.pool_gpu_allocated_bytes.store(@intCast(pool_gpu_allocated), .monotonic); + self.pool_gpu_slack_bytes.store(@intCast(pool_gpu_slack), .monotonic); + self.pool_cpu_shadow_bytes.store(@intCast(pool_cpu_shadow), .monotonic); + self.compact_pool_capacity_bytes.store(@intCast(compact_pool_capacity), .monotonic); + self.compact_pool_allocated_bytes.store(@intCast(compact_pool_allocated), .monotonic); + self.compact_pool_free_bytes.store(@intCast(compact_pool_free), .monotonic); + self.compact_pool_retired_bytes.store(@intCast(compact_pool_retired), .monotonic); + self.direct_mesh_gpu_bytes.store(@intCast(direct_mesh_gpu), .monotonic); + self.known_memory_bytes.store(@intCast(known_memory), .monotonic); + } + + pub fn addStagingPressure(self: *LODProfilingCollector) void { + self.add(&self.staging_pressure_count, 1); + } + + pub fn addVisible(self: *LODProfilingCollector) void { + self.add(&self.visible_count, 1); + } + + pub fn addRejected(self: *LODProfilingCollector) void { + self.add(&self.rejected_count, 1); + } + + pub fn addCoverage(self: *LODProfilingCollector) void { + self.add(&self.coverage_count, 1); + } + + pub fn addVisibilityLevel(self: *LODProfilingCollector, lod: LODLevel, value: LODVisibilityLevelSnapshot) void { + if (!self.enabled) return; + self.visibility_levels[@intFromEnum(lod)].add(value); + } + + pub fn addDeferredDeletionBytes(self: *LODProfilingCollector, bytes: usize) void { + self.add(&self.deferred_deletion_bytes, @intCast(bytes)); + } + + pub fn removeDeferredDeletionBytes(self: *LODProfilingCollector, bytes: usize) void { + if (!self.enabled or bytes == 0) return; + _ = self.deferred_deletion_bytes.fetchSub(@intCast(bytes), .monotonic); + } + + pub fn addDeferredDeletionCpuBytes(self: *LODProfilingCollector, bytes: usize) void { + self.add(&self.deferred_deletion_cpu_bytes, @intCast(bytes)); + } + + pub fn removeDeferredDeletionCpuBytes(self: *LODProfilingCollector, bytes: usize) void { + if (!self.enabled or bytes == 0) return; + _ = self.deferred_deletion_cpu_bytes.fetchSub(@intCast(bytes), .monotonic); + } + + pub fn addWaitIdle(self: *LODProfilingCollector) void { + self.add(&self.wait_idle_count, 1); + } + + /// Resets cumulative profiling counters. Concurrent worker completion may + /// land immediately before or after this reset, but snapshots are always + /// race-free and never contain torn values. + pub fn reset(self: *LODProfilingCollector) void { + inline for (.{ + &self.update_ns, &self.scheduling_ns, &self.cache_ns, + &self.generation_dispatch_ns, &self.state_transition_ns, &self.upload_prep_ns, + &self.upload_submission_ns, &self.visibility_ns, &self.coverage_ns, + &self.eviction_ns, &self.worker_generation_ns, &self.worker_mesh_construction_ns, + &self.manager_lock_wait_ns, &self.manager_lock_hold_ns, &self.wait_idle_ns, + &self.upload_bytes, &self.pending_cpu_upload_bytes, &self.staging_pressure_count, + &self.visible_count, &self.rejected_count, &self.coverage_count, + &self.deferred_deletion_bytes, &self.deferred_deletion_cpu_bytes, &self.pool_gpu_capacity_bytes, + &self.pool_gpu_allocated_bytes, &self.pool_gpu_slack_bytes, &self.pool_cpu_shadow_bytes, + &self.compact_pool_capacity_bytes, &self.compact_pool_allocated_bytes, &self.compact_pool_free_bytes, + &self.compact_pool_retired_bytes, &self.direct_mesh_gpu_bytes, &self.known_memory_bytes, + &self.wait_idle_count, + }) |counter| counter.store(0, .monotonic); + for (&self.visibility_levels) |*level| level.reset(); + } + + pub fn snapshot(self: *const LODProfilingCollector) LODProfilingSnapshot { + return .{ + .enabled = self.enabled, + .update_ms = nsToMs(self.update_ns.load(.monotonic)), + .scheduling_ms = nsToMs(self.scheduling_ns.load(.monotonic)), + .cache_ms = nsToMs(self.cache_ns.load(.monotonic)), + .generation_dispatch_ms = nsToMs(self.generation_dispatch_ns.load(.monotonic)), + .state_transition_ms = nsToMs(self.state_transition_ns.load(.monotonic)), + .upload_prep_ms = nsToMs(self.upload_prep_ns.load(.monotonic)), + .upload_submission_ms = nsToMs(self.upload_submission_ns.load(.monotonic)), + .visibility_ms = nsToMs(self.visibility_ns.load(.monotonic)), + .coverage_ms = nsToMs(self.coverage_ns.load(.monotonic)), + .eviction_ms = nsToMs(self.eviction_ns.load(.monotonic)), + .worker_generation_ms = nsToMs(self.worker_generation_ns.load(.monotonic)), + .worker_mesh_construction_ms = nsToMs(self.worker_mesh_construction_ns.load(.monotonic)), + .manager_lock_wait_ms = nsToMs(self.manager_lock_wait_ns.load(.monotonic)), + .manager_lock_hold_ms = nsToMs(self.manager_lock_hold_ns.load(.monotonic)), + .upload_bytes = self.upload_bytes.load(.monotonic), + .pending_cpu_upload_bytes = self.pending_cpu_upload_bytes.load(.monotonic), + .staging_pressure_count = self.staging_pressure_count.load(.monotonic), + .visible_count = self.visible_count.load(.monotonic), + .rejected_count = self.rejected_count.load(.monotonic), + .coverage_count = self.coverage_count.load(.monotonic), + .visibility_levels = blk: { + var levels: [LODLevel.count]LODVisibilityLevelSnapshot = undefined; + for (&levels, 0..) |*level, index| level.* = self.visibility_levels[index].snapshot(); + break :blk levels; + }, + .deferred_deletion_bytes = self.deferred_deletion_bytes.load(.monotonic), + .deferred_deletion_cpu_bytes = self.deferred_deletion_cpu_bytes.load(.monotonic), + .pool_gpu_capacity_bytes = self.pool_gpu_capacity_bytes.load(.monotonic), + .pool_gpu_allocated_bytes = self.pool_gpu_allocated_bytes.load(.monotonic), + .pool_gpu_slack_bytes = self.pool_gpu_slack_bytes.load(.monotonic), + .pool_cpu_shadow_bytes = self.pool_cpu_shadow_bytes.load(.monotonic), + .compact_pool_capacity_bytes = self.compact_pool_capacity_bytes.load(.monotonic), + .compact_pool_allocated_bytes = self.compact_pool_allocated_bytes.load(.monotonic), + .compact_pool_free_bytes = self.compact_pool_free_bytes.load(.monotonic), + .compact_pool_retired_bytes = self.compact_pool_retired_bytes.load(.monotonic), + .direct_mesh_gpu_bytes = self.direct_mesh_gpu_bytes.load(.monotonic), + .known_memory_bytes = self.known_memory_bytes.load(.monotonic), + .wait_idle_count = self.wait_idle_count.load(.monotonic), + .wait_idle_ms = nsToMs(self.wait_idle_ns.load(.monotonic)), + }; + } + + fn counterFor(self: *LODProfilingCollector, kind: TimerKind) *AtomicU64 { + return switch (kind) { + .update => &self.update_ns, + .scheduling => &self.scheduling_ns, + .cache => &self.cache_ns, + .generation_dispatch => &self.generation_dispatch_ns, + .state_transition => &self.state_transition_ns, + .upload_prep => &self.upload_prep_ns, + .upload_submission => &self.upload_submission_ns, + .visibility => &self.visibility_ns, + .coverage => &self.coverage_ns, + .eviction => &self.eviction_ns, + .worker_generation => &self.worker_generation_ns, + .worker_mesh_construction => &self.worker_mesh_construction_ns, + .manager_lock_wait => &self.manager_lock_wait_ns, + .manager_lock_hold => &self.manager_lock_hold_ns, + .wait_idle => &self.wait_idle_ns, + }; + } +}; + +fn nsToMs(ns: u64) f64 { + return @as(f64, @floatFromInt(ns)) / @as(f64, std.time.ns_per_ms); +} + /// Statistics for LOD system monitoring. pub const LODStats = struct { + /// Cumulative opt-in CPU, upload, and pressure telemetry snapshot. + profiling: LODProfilingSnapshot = .{}, loaded: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, generating: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, generated: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, @@ -14,6 +353,21 @@ pub const LODStats = struct { uploading: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, memory_used_mb: u32 = 0, + /// Exact known LOD CPU/GPU allocation total. `memory_used_mb` is retained + /// for compatibility and is this value rounded down to MiB. + memory_used_bytes: u64 = 0, + pool_gpu_capacity_bytes: u64 = 0, + pool_gpu_allocated_bytes: u64 = 0, + pool_gpu_slack_bytes: u64 = 0, + pool_cpu_shadow_bytes: u64 = 0, + compact_pool_capacity_bytes: u64 = 0, + compact_pool_allocated_bytes: u64 = 0, + compact_pool_free_bytes: u64 = 0, + compact_pool_retired_bytes: u64 = 0, + direct_mesh_gpu_bytes: u64 = 0, + pending_cpu_upload_bytes: u64 = 0, + deferred_deletion_gpu_bytes: u64 = 0, + deferred_deletion_cpu_bytes: u64 = 0, mesh_count: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, mesh_vertices: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, gen_queue_depth: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, @@ -29,10 +383,18 @@ pub const LODStats = struct { instances: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, fluid_drawn: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, fluid_instances: [LODLevel.count]u32 = [_]u32{0} ** LODLevel.count, + /// Candidate counts submitted to device-local LOD compute streams. These + /// deliberately do not require a same-frame readback of compacted counts. + gpu_terrain_candidates: u32 = 0, + gpu_fluid_candidates: u32 = 0, + gpu_culling_overflows: u32 = 0, + gpu_culling_validation_mismatches: u32 = 0, ingestion_backlog: u32 = 0, upgrades_pending: u32 = 0, downgrades_pending: u32 = 0, upload_failures: u32 = 0, + /// Worker and queued jobs invalidated before stale results could publish. + cancelled_jobs: u32 = 0, pub fn totalLoaded(self: *const LODStats) u32 { var total: u32 = 0; @@ -47,6 +409,7 @@ pub const LODStats = struct { } pub fn reset(self: *LODStats) void { + self.profiling = .{}; self.loaded = [_]u32{0} ** LODLevel.count; self.generating = [_]u32{0} ** LODLevel.count; self.generated = [_]u32{0} ** LODLevel.count; @@ -54,6 +417,19 @@ pub const LODStats = struct { self.mesh_ready = [_]u32{0} ** LODLevel.count; self.uploading = [_]u32{0} ** LODLevel.count; self.memory_used_mb = 0; + self.memory_used_bytes = 0; + self.pool_gpu_capacity_bytes = 0; + self.pool_gpu_allocated_bytes = 0; + self.pool_gpu_slack_bytes = 0; + self.pool_cpu_shadow_bytes = 0; + self.compact_pool_capacity_bytes = 0; + self.compact_pool_allocated_bytes = 0; + self.compact_pool_free_bytes = 0; + self.compact_pool_retired_bytes = 0; + self.direct_mesh_gpu_bytes = 0; + self.pending_cpu_upload_bytes = 0; + self.deferred_deletion_gpu_bytes = 0; + self.deferred_deletion_cpu_bytes = 0; self.mesh_count = [_]u32{0} ** LODLevel.count; self.mesh_vertices = [_]u32{0} ** LODLevel.count; self.gen_queue_depth = [_]u32{0} ** LODLevel.count; @@ -62,10 +438,15 @@ pub const LODStats = struct { self.instances = [_]u32{0} ** LODLevel.count; self.fluid_drawn = [_]u32{0} ** LODLevel.count; self.fluid_instances = [_]u32{0} ** LODLevel.count; + self.gpu_terrain_candidates = 0; + self.gpu_fluid_candidates = 0; + self.gpu_culling_overflows = 0; + self.gpu_culling_validation_mismatches = 0; self.upgrades_pending = 0; self.downgrades_pending = 0; self.upload_failures = 0; self.ingestion_backlog = 0; + self.cancelled_jobs = 0; } pub fn recordState(self: *LODStats, lod_idx: usize, state: LODState) void { @@ -81,8 +462,8 @@ pub const LODStats = struct { } pub fn addMemory(self: *LODStats, bytes: usize) void { - const mb = bytes / (1024 * 1024); - self.memory_used_mb += @intCast(mb); + self.memory_used_bytes += @intCast(bytes); + self.memory_used_mb = @intCast(self.memory_used_bytes / (1024 * 1024)); } pub fn cacheHitRate(self: *const LODStats) f32 { @@ -92,8 +473,6 @@ pub const LODStats = struct { } }; -const std = @import("std"); - test "LODStats reports cache hit rate" { var stats = LODStats{}; try std.testing.expectEqual(@as(f32, 0.0), stats.cacheHitRate()); @@ -102,3 +481,61 @@ test "LODStats reports cache hit rate" { stats.store_misses = 1; try std.testing.expectEqual(@as(f32, 0.75), stats.cacheHitRate()); } + +test "LOD profiling collector snapshots and resets cumulative counters" { + var collector = LODProfilingCollector.init(true); + collector.addUploadBytes(128); + collector.setPendingCpuUploadBytes(64); + collector.addVisible(); + collector.addRejected(); + collector.addCoverage(); + collector.addVisibilityLevel(.lod1, .{ .candidates = 3, .accepted = 1, .rejected_frustum = 1, .coverage_checks = 1 }); + collector.addDeferredDeletionBytes(32); + collector.addDeferredDeletionCpuBytes(16); + collector.setMemoryAccounting(256, 192, 64, 256, 1024, 320, 704, 0, 128, 1664); + collector.addWaitIdle(); + + const snapshot = collector.snapshot(); + try std.testing.expect(snapshot.enabled); + try std.testing.expectEqual(@as(u64, 128), snapshot.upload_bytes); + try std.testing.expectEqual(@as(u64, 64), snapshot.pending_cpu_upload_bytes); + try std.testing.expectEqual(@as(u64, 1), snapshot.visible_count); + try std.testing.expectEqual(@as(u64, 1), snapshot.rejected_count); + try std.testing.expectEqual(@as(u64, 1), snapshot.coverage_count); + try std.testing.expectEqual(@as(u64, 3), snapshot.visibility_levels[1].candidates); + try std.testing.expectEqual(@as(u64, 1), snapshot.visibility_levels[1].rejected_frustum); + try std.testing.expectEqual(@as(u64, 32), snapshot.deferred_deletion_bytes); + try std.testing.expectEqual(@as(u64, 16), snapshot.deferred_deletion_cpu_bytes); + try std.testing.expectEqual(@as(u64, 256), snapshot.pool_gpu_capacity_bytes); + try std.testing.expectEqual(@as(u64, 192), snapshot.pool_gpu_allocated_bytes); + try std.testing.expectEqual(@as(u64, 64), snapshot.pool_gpu_slack_bytes); + try std.testing.expectEqual(@as(u64, 256), snapshot.pool_cpu_shadow_bytes); + try std.testing.expectEqual(@as(u64, 1024), snapshot.compact_pool_capacity_bytes); + try std.testing.expectEqual(@as(u64, 320), snapshot.compact_pool_allocated_bytes); + try std.testing.expectEqual(@as(u64, 704), snapshot.compact_pool_free_bytes); + try std.testing.expectEqual(@as(u64, 128), snapshot.direct_mesh_gpu_bytes); + try std.testing.expectEqual(@as(u64, 1664), snapshot.known_memory_bytes); + try std.testing.expectEqual(@as(u64, 1), snapshot.wait_idle_count); + + collector.reset(); + const reset_snapshot = collector.snapshot(); + try std.testing.expectEqual(@as(u64, 0), reset_snapshot.upload_bytes); + try std.testing.expectEqual(@as(u64, 0), reset_snapshot.visible_count); + try std.testing.expectEqual(@as(u64, 0), reset_snapshot.deferred_deletion_bytes); + try std.testing.expectEqual(@as(u64, 0), reset_snapshot.pool_gpu_capacity_bytes); + try std.testing.expectEqual(@as(u64, 0), reset_snapshot.visibility_levels[1].candidates); +} + +test "disabled LOD profiling collector does not accumulate samples" { + var collector = LODProfilingCollector.init(false); + const timer = collector.begin(); + collector.end(.update, timer); + collector.addUploadBytes(256); + collector.addVisible(); + + const snapshot = collector.snapshot(); + try std.testing.expect(!snapshot.enabled); + try std.testing.expectEqual(@as(f64, 0), snapshot.update_ms); + try std.testing.expectEqual(@as(u64, 0), snapshot.upload_bytes); + try std.testing.expectEqual(@as(u64, 0), snapshot.visible_count); +} diff --git a/modules/world-lod/src/lod_store.zig b/modules/world-lod/src/lod_store.zig index 48b83738..f9dfc503 100644 --- a/modules/world-lod/src/lod_store.zig +++ b/modules/world-lod/src/lod_store.zig @@ -18,6 +18,7 @@ pub const StoreHeader = struct { pub const StoreError = error{ CorruptContainer, + StoreSizeLimit, }; pub fn headersMatch(a: StoreHeader, b: StoreHeader) bool { @@ -129,6 +130,147 @@ fn storeSizeCapBytes(cap_mb: u32) usize { return @as(usize, @max(cap_mb, 1)) * 1024 * 1024; } +const ContainerCandidate = struct { + path: []u8, + mtime_ns: i96, +}; + +const StoreUsage = struct { + total_size: u64 = 0, + oldest_candidate: ?ContainerCandidate = null, +}; + +fn isActiveLodDirectory(name: []const u8) bool { + const lod_index = std.fmt.parseInt(u8, name, 10) catch return false; + return lod_index < @import("lod_types.zig").LODLevel.count; +} + +fn isOlderContainer(candidate: ContainerCandidate, path: []const u8, mtime_ns: i96) bool { + return mtime_ns < candidate.mtime_ns or + (mtime_ns == candidate.mtime_ns and std.mem.order(u8, path, candidate.path) == .lt); +} + +/// Returns the total size of all LOD containers and the oldest removable one. +/// +/// This only retains one candidate path, so eviction memory use remains bounded +/// regardless of how many containers are on disk. Files that cannot be opened +/// or statted are skipped; malformed region contents need not be parsed to +/// account for their on-disk size. +fn scanStoreUsage(allocator: std.mem.Allocator, lod_root: []const u8, protected_path: []const u8) !StoreUsage { + var usage = StoreUsage{}; + errdefer if (usage.oldest_candidate) |candidate| allocator.free(candidate.path); + + var root_dir = fs.cwd().openDir(lod_root, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return usage, + else => return err, + }; + defer root_dir.close(); + + var lod_iter = root_dir.iterate(); + while (true) { + const maybe_lod_entry = try lod_iter.next(); + const lod_entry = maybe_lod_entry orelse break; + if (lod_entry.kind != .directory or !isActiveLodDirectory(lod_entry.name)) continue; + + var lod_dir = root_dir.openDir(lod_entry.name, .{ .iterate = true }) catch continue; + defer lod_dir.close(); + + var container_iter = lod_dir.iterate(); + while (true) { + const maybe_container_entry = try container_iter.next(); + const container_entry = maybe_container_entry orelse break; + if (container_entry.kind != .file or !std.mem.endsWith(u8, container_entry.name, ".zlod")) continue; + + const file = lod_dir.openFile(container_entry.name, .{}) catch continue; + const stat = file.stat() catch { + file.close(); + continue; + }; + file.close(); + + usage.total_size +|= stat.size; + const path = try fs.path.join(allocator, &.{ lod_root, lod_entry.name, container_entry.name }); + if (std.mem.eql(u8, path, protected_path)) { + allocator.free(path); + continue; + } + + if (usage.oldest_candidate) |candidate| { + if (!isOlderContainer(candidate, path, stat.mtime.nanoseconds)) { + allocator.free(path); + continue; + } + allocator.free(candidate.path); + } + usage.oldest_candidate = .{ + .path = path, + .mtime_ns = stat.mtime.nanoseconds, + }; + } + } + + return usage; +} + +/// Enforces the aggregate container limit after a successful atomic write. +/// The just-written container is never selected, even when it alone exceeds +/// the configured limit. +fn enforceStoreSizeCap(allocator: std.mem.Allocator, save_dir_path: []const u8, protected_path: []const u8, cap_mb: u32) !void { + const lod_root = try fs.path.join(allocator, &.{ save_dir_path, "lod" }); + defer allocator.free(lod_root); + const cap_bytes = storeSizeCapBytes(cap_mb); + + while (true) { + const usage = try scanStoreUsage(allocator, lod_root, protected_path); + if (usage.total_size <= cap_bytes) return; + + const candidate = usage.oldest_candidate orelse return; + defer allocator.free(candidate.path); + fs.cwd().deleteFile(candidate.path) catch |err| switch (err) { + error.FileNotFound => continue, + else => return err, + }; + } +} + +fn writeCompactedContainer(allocator: std.mem.Allocator, path: []const u8, tmp_path: []const u8, key: lod_cache.Key, bytes: []const u8) !void { + var region = RegionFile.create(allocator, tmp_path) catch |err| { + fs.cwd().deleteFile(tmp_path) catch {}; + return err; + }; + errdefer { + region.close(); + fs.cwd().deleteFile(tmp_path) catch {}; + } + + var existing_region = try RegionFile.open(allocator, path); + defer existing_region.close(); + const target_x = localCoord(key.rx); + const target_z = localCoord(key.rz); + for (0..REGION_GRID) |z| { + for (0..REGION_GRID) |x| { + const local_x: u5 = @intCast(x); + const local_z: u5 = @intCast(z); + if (local_x == target_x and local_z == target_z) continue; + const existing = existing_region.readChunk(local_x, local_z, allocator) catch |err| switch (err) { + error.ChunkNotFound => continue, + else => return err, + }; + region.writeChunk(local_x, local_z, existing) catch |err| { + allocator.free(existing); + return err; + }; + allocator.free(existing); + } + } + + region.writeChunk(target_x, target_z, bytes) catch |err| { + if (err == error.FileTooShort) return StoreError.StoreSizeLimit; + return err; + }; + region.close(); +} + pub fn writePayload(allocator: std.mem.Allocator, save_dir_path: []const u8, key: lod_cache.Key, bytes: []const u8, store_size_cap_mb: u32) !void { const dir_path = try lodDirPath(allocator, save_dir_path, key.lod); defer allocator.free(dir_path); @@ -152,37 +294,87 @@ pub fn writePayload(allocator: std.mem.Allocator, save_dir_path: []const u8, key break :blk true; }; + var force_compaction = false; + // Recover boundedly from a store created by an older implementation that + // allowed one container to grow beyond the aggregate cap. if (use_existing) { - const existing = try fs.cwd().readFileAlloc(path, allocator, storeSizeCapBytes(store_size_cap_mb)); - defer allocator.free(existing); - const tmp_file = try fs.cwd().createFile(tmp_path, .{ .truncate = true }); - tmp_file.writeAll(existing) catch |err| { + const existing_file = try fs.cwd().openFile(path, .{}); + const existing_stat = existing_file.stat() catch |err| { + existing_file.close(); + return err; + }; + existing_file.close(); + if (existing_stat.size > storeSizeCapBytes(store_size_cap_mb)) { + force_compaction = true; + } + } + + if (force_compaction) { + try writeCompactedContainer(allocator, path, tmp_path, key, bytes); + } else { + if (use_existing) { + const existing = try fs.cwd().readFileAlloc(path, allocator, storeSizeCapBytes(store_size_cap_mb)); + defer allocator.free(existing); + const tmp_file = try fs.cwd().createFile(tmp_path, .{ .truncate = true }); + tmp_file.writeAll(existing) catch |err| { + tmp_file.close(); + fs.cwd().deleteFile(tmp_path) catch {}; + return err; + }; tmp_file.close(); + } + + var region = (if (use_existing) + RegionFile.open(allocator, tmp_path) + else + RegionFile.create(allocator, tmp_path)) catch |err| { + fs.cwd().deleteFile(tmp_path) catch {}; + return err; + }; + region.writeChunk(localCoord(key.rx), localCoord(key.rz), bytes) catch |err| { + region.close(); fs.cwd().deleteFile(tmp_path) catch {}; + if (err == error.FileTooShort) return StoreError.StoreSizeLimit; return err; }; - tmp_file.close(); + region.close(); } - var region = (if (use_existing) - RegionFile.open(allocator, tmp_path) - else - RegionFile.create(allocator, tmp_path)) catch |err| { + var completed_tmp = try fs.cwd().openFile(tmp_path, .{}); + var completed_stat = completed_tmp.stat() catch |err| { + completed_tmp.close(); fs.cwd().deleteFile(tmp_path) catch {}; return err; }; - - region.writeChunk(localCoord(key.rx), localCoord(key.rz), bytes) catch |err| { - region.close(); + completed_tmp.close(); + if (completed_stat.size > storeSizeCapBytes(store_size_cap_mb) and use_existing and !force_compaction) { + // Sector growth crossed the cap. Rebuild only live entries on the + // cache worker, then re-check before atomically replacing the source. + try fs.cwd().deleteFile(tmp_path); + try writeCompactedContainer(allocator, path, tmp_path, key, bytes); + completed_tmp = try fs.cwd().openFile(tmp_path, .{}); + completed_stat = completed_tmp.stat() catch |err| { + completed_tmp.close(); + fs.cwd().deleteFile(tmp_path) catch {}; + return err; + }; + completed_tmp.close(); + } + if (completed_stat.size > storeSizeCapBytes(store_size_cap_mb)) { fs.cwd().deleteFile(tmp_path) catch {}; - return err; - }; - region.close(); + // Keep an in-budget previous atomic container. Legacy oversized + // containers are discarded because they cannot satisfy the new bound. + if (force_compaction) fs.cwd().deleteFile(path) catch {}; + try enforceStoreSizeCap(allocator, save_dir_path, path, store_size_cap_mb); + return StoreError.StoreSizeLimit; + } fs.cwd().rename(tmp_path, path) catch |err| { fs.cwd().deleteFile(tmp_path) catch {}; return err; }; + + try enforceStoreSizeCap(allocator, save_dir_path, path, store_size_cap_mb); } pub fn deletePayload(allocator: std.mem.Allocator, save_dir_path: []const u8, key: lod_cache.Key) void { @@ -196,6 +388,16 @@ pub fn deletePayload(allocator: std.mem.Allocator, save_dir_path: []const u8, ke const testing = std.testing; +fn fillPseudoRandom(bytes: []u8, initial_state: u64) void { + var state = initial_state; + for (bytes) |*byte| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + byte.* = @truncate(state); + } +} + test "LOD store round-trips payloads through region containers" { var tmp_dir = testing.tmpDir(.{}); defer tmp_dir.cleanup(); @@ -274,6 +476,83 @@ test "LOD store write replaces corrupt containers" { try testing.expectEqualStrings("replacement", loaded); } +test "LOD store evicts old containers to enforce the total size cap" { + 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 = try dir.realpath(".", &path_buf); + const header = StoreHeader{ .seed = 1, .generator_identity_hash = 2, .generator_version = 3 }; + try writeHeader(testing.allocator, save_dir, header); + + const payload_size = 700 * 1024; + const old_payload = try testing.allocator.alloc(u8, payload_size); + defer testing.allocator.free(old_payload); + const new_payload = try testing.allocator.alloc(u8, payload_size); + defer testing.allocator.free(new_payload); + fillPseudoRandom(old_payload, 0x123456789abcdef0); + fillPseudoRandom(new_payload, 0xfedcba9876543210); + + const old_key = lod_cache.Key{ .seed = 1, .generator_identity_hash = 2, .generator_version = 3, .rx = 0, .rz = 0, .lod = .lod1 }; + const new_key = lod_cache.Key{ .seed = 1, .generator_identity_hash = 2, .generator_version = 3, .rx = 32, .rz = 0, .lod = .lod1 }; + try writePayload(testing.allocator, save_dir, old_key, old_payload, 1); + try writePayload(testing.allocator, save_dir, new_key, new_payload, 1); + + try testing.expect((try readPayload(testing.allocator, save_dir, old_key)) == null); + const retained = (try readPayload(testing.allocator, save_dir, new_key)).?; + defer testing.allocator.free(retained); + try testing.expectEqualSlices(u8, new_payload, retained); + try testing.expect(headersMatch(header, (try readHeader(testing.allocator, save_dir)).?)); +} + +test "LOD store compacts live entries when sector growth reaches the cap" { + 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 = try dir.realpath(".", &path_buf); + const old_payload = try testing.allocator.alloc(u8, 200 * 1024); + defer testing.allocator.free(old_payload); + const sibling_payload = try testing.allocator.alloc(u8, 200 * 1024); + defer testing.allocator.free(sibling_payload); + const replacement = try testing.allocator.alloc(u8, 600 * 1024); + defer testing.allocator.free(replacement); + fillPseudoRandom(old_payload, 0x1111111111111111); + fillPseudoRandom(sibling_payload, 0x2222222222222222); + fillPseudoRandom(replacement, 0x3333333333333333); + + const target_key = lod_cache.Key{ .seed = 1, .generator_identity_hash = 2, .generator_version = 3, .rx = 0, .rz = 0, .lod = .lod1 }; + const sibling_key = lod_cache.Key{ .seed = 1, .generator_identity_hash = 2, .generator_version = 3, .rx = 1, .rz = 0, .lod = .lod1 }; + try writePayload(testing.allocator, save_dir, target_key, old_payload, 1); + try writePayload(testing.allocator, save_dir, sibling_key, sibling_payload, 1); + try writePayload(testing.allocator, save_dir, target_key, replacement, 1); + + const loaded_target = (try readPayload(testing.allocator, save_dir, target_key)).?; + defer testing.allocator.free(loaded_target); + try testing.expectEqualSlices(u8, replacement, loaded_target); + const loaded_sibling = (try readPayload(testing.allocator, save_dir, sibling_key)).?; + defer testing.allocator.free(loaded_sibling); + try testing.expectEqualSlices(u8, sibling_payload, loaded_sibling); +} + +test "LOD store rejects a container larger than the aggregate cap" { + 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 = try dir.realpath(".", &path_buf); + const payload = try testing.allocator.alloc(u8, 2 * 1024 * 1024); + defer testing.allocator.free(payload); + fillPseudoRandom(payload, 0x9e3779b97f4a7c15); + + const key = lod_cache.Key{ .seed = 1, .generator_identity_hash = 2, .generator_version = 3, .rx = 0, .rz = 0, .lod = .lod1 }; + try testing.expectError(StoreError.StoreSizeLimit, writePayload(testing.allocator, save_dir, key, payload, 1)); + try testing.expect((try readPayload(testing.allocator, save_dir, key)) == null); +} + test "LOD store missing and corrupt containers are advisory misses" { var tmp_dir = testing.tmpDir(.{}); defer tmp_dir.cleanup(); @@ -311,7 +590,7 @@ test "LOD store writes metadata header atomically" { defer testing.allocator.free(bytes); try testing.expect(std.mem.indexOf(u8, bytes, "\"seed\": 123") != null); - try testing.expect(std.mem.indexOf(u8, bytes, "\"lod_data_version\": 10") != null); + try testing.expect(std.mem.indexOf(u8, bytes, "\"lod_data_version\": 11") != null); } test "LOD store reads and deletes metadata store" { diff --git a/modules/world-lod/src/lod_tile.zig b/modules/world-lod/src/lod_tile.zig new file mode 100644 index 00000000..f68c387e --- /dev/null +++ b/modules/world-lod/src/lod_tile.zig @@ -0,0 +1,755 @@ +//! CPU-owned compact source data for far-terrain tiles. +//! +//! `CompactLODSample` is a fixed 16-byte, little-endian ABI. It deliberately +//! contains semantic `world_core.BlockType` IDs, not atlas or GPU resource IDs, +//! so it can be built, cached, and tested without a renderer. The two 64-bit +//! words have this bit layout (least-significant bit first): +//! +//! ```text +//! bits 0..15 terrain height: signed i16, 1/8 block, [-4096, 4095.875] +//! bits 16..31 water height: signed i16, 1/8 block, [-4095.875, 4095.875]; +//! -4096 is the no-water sentinel +//! bits 32..39 water depth: unsigned u8, 1/4 block, [0, 63.75] +//! bits 40..47 water coverage: unsigned u8, [0, 1] +//! bits 48..54 surface material semantic ID +//! bits 55..61 subsurface material semantic ID +//! bits 62..68 foundation material semantic ID +//! bits 69..92 RGB color (low 24 bits of source color) +//! bits 93..96 sky light, [0, 15] +//! bits 97..100 block light, [0, 15] +//! bits 101..106 ambient occlusion, unsigned u6, [0, 1] +//! bits 107..112 vegetation coverage, unsigned u6, [0, 1] +//! bits 113..120 vegetation height, unsigned u8, 1/2 block, [0, 127.5] +//! bits 121..122 provenance (worldgen/chunk-derived/edited) +//! bits 123..127 reserved, zero +//! ``` +//! +//! All float quantizers first map non-finite input to zero, clamp to their +//! documented range, then use `@round` (nearest integer). Version 1 recognizes +//! block IDs through `stone_stairs`; later or out-of-range IDs are reduced to +//! the semantic `stone` fallback rather than becoming renderer-specific data. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const world_core = @import("world-core"); + +const LODLevel = world_core.LODLevel; +const LODSimplifiedData = world_core.LODSimplifiedData; +const BlockType = world_core.BlockType; +const LODMaterialLayers = world_core.LODMaterialLayers; +const LODWaterState = world_core.LODWaterState; +const LODLightingHint = world_core.LODLightingHint; +const LODVegetationHint = world_core.LODVegetationHint; +const LODColumnProvenance = world_core.LODColumnProvenance; + +pub const COMPACT_LOD_TILE_MAGIC: u32 = 0x5444_4C43; // "CLDT" in little-endian bytes. +pub const COMPACT_LOD_TILE_VERSION: u16 = 1; +pub const COMPACT_LOD_SAMPLE_BYTES: usize = 16; +pub const COMPACT_LOD_TILE_HEADER_BYTES: usize = 20; +const WATER_HEIGHT_NONE: i16 = std.math.minInt(i16); +const HEIGHT_SCALE: f32 = 8.0; +const DEPTH_SCALE: f32 = 4.0; +const VEGETATION_HEIGHT_SCALE: f32 = 2.0; +const MAX_ENCODED_BLOCK_ID: u8 = 0x7f; +const MAX_KNOWN_BLOCK_ID: u8 = @intFromEnum(BlockType.stone_stairs); +const MAX_VALID_WIRE_BLOCK_ID: u7 = @intCast(@min(MAX_ENCODED_BLOCK_ID, MAX_KNOWN_BLOCK_ID)); + +pub const TileEdge = enum(u2) { + north = 0, + east = 1, + south = 2, + west = 3, +}; + +pub const TileError = error{ + InvalidLod, + InvalidSourceData, + InvalidMagic, + UnsupportedVersion, + InvalidHeader, + DataTooShort, + InvalidLength, + ChecksumMismatch, + InvalidSample, + UnsupportedSourceFeatures, +}; + +/// A renderer-independent compact sample. `bytes` is the stable wire/upload ABI. +pub const CompactLODSample = struct { + bytes: [COMPACT_LOD_SAMPLE_BYTES]u8, + + pub fn decode(self: CompactLODSample) DecodedSample { + const low = std.mem.readInt(u64, self.bytes[0..8], .little); + const high = std.mem.readInt(u64, self.bytes[8..16], .little); + const bits = @as(u128, low) | (@as(u128, high) << 64); + + const water_raw = signedField(bits, 16); + const water_present = water_raw != WATER_HEIGHT_NONE; + return .{ + .terrain_height = dequantizeHeight(signedField(bits, 0)), + .water = .{ + .is_surface = water_present, + .surface_height = if (water_present) dequantizeHeight(water_raw) else 0.0, + .depth = @as(f32, @floatFromInt(unsignedField(bits, 32, u8))) / DEPTH_SCALE, + .coverage = @as(f32, @floatFromInt(unsignedField(bits, 40, u8))) / 255.0, + }, + .material_layers = .{ + .surface = semanticMaterialFromId(unsignedField(bits, 48, u7)), + .subsurface = semanticMaterialFromId(unsignedField(bits, 55, u7)), + .foundation = semanticMaterialFromId(unsignedField(bits, 62, u7)), + }, + .color = unsignedField(bits, 69, u32) & 0x00ff_ffff, + .lighting = .{ + .sky_light = unsignedField(bits, 93, u8) & 0x0f, + .block_light = unsignedField(bits, 97, u8) & 0x0f, + .ambient_occlusion = @as(f32, @floatFromInt(unsignedField(bits, 101, u8) & 0x3f)) / 63.0, + }, + .vegetation = .{ + .tree_coverage = @as(f32, @floatFromInt(unsignedField(bits, 107, u8) & 0x3f)) / 63.0, + .avg_tree_height = @as(f32, @floatFromInt(unsignedField(bits, 113, u8) & 0xff)) / VEGETATION_HEIGHT_SCALE, + .offset_x = 0.0, + .offset_z = 0.0, + .trunk = .air, + .leaves = .air, + }, + .provenance = provenanceFromBits(unsignedField(bits, 121, u8)), + }; + } + + fn isValid(self: CompactLODSample) bool { + const low = std.mem.readInt(u64, self.bytes[0..8], .little); + const high = std.mem.readInt(u64, self.bytes[8..16], .little); + const bits = @as(u128, low) | (@as(u128, high) << 64); + if ((high >> 59) != 0) return false; + if (((high >> 57) & 0x3) > @intFromEnum(LODColumnProvenance.edited)) return false; + return unsignedField(bits, 48, u7) <= MAX_VALID_WIRE_BLOCK_ID and + unsignedField(bits, 55, u7) <= MAX_VALID_WIRE_BLOCK_ID and + unsignedField(bits, 62, u7) <= MAX_VALID_WIRE_BLOCK_ID; + } +}; + +comptime { + std.debug.assert(@sizeOf(CompactLODSample) == COMPACT_LOD_SAMPLE_BYTES); + std.debug.assert(@alignOf(CompactLODSample) == 1); +} + +/// The lossily reconstructed source-column attributes represented by a sample. +pub const DecodedSample = struct { + terrain_height: f32, + water: LODWaterState, + material_layers: LODMaterialLayers, + color: u32, + lighting: LODLightingHint, + vegetation: LODVegetationHint, + provenance: LODColumnProvenance, +}; + +pub const ByteMetrics = struct { + /// Bytes that would be uploaded for the compact padded sample grid. + compact_upload_bytes: usize, + /// Bytes emitted by the current CPU heightfield path for top faces alone. + conservative_expanded_top_grid_bytes: usize, + + pub fn savedBytes(self: ByteMetrics) usize { + return if (self.conservative_expanded_top_grid_bytes > self.compact_upload_bytes) + self.conservative_expanded_top_grid_bytes - self.compact_upload_bytes + else + 0; + } +}; + +/// An allocator-owned top-grid plus one duplicated source edge on every side. +/// `sampleClamped` accepts `-1..width` neighbor coordinates and is stable at a +/// tile boundary, making it suitable for CPU normal derivation before neighbors +/// are available. +pub const CompactLODTile = struct { + allocator: Allocator, + lod_level: LODLevel, + width: u32, + /// Bit per `TileEdge` whose apron was populated from a same-level neighbor. + neighbor_apron_mask: u8 = 0, + samples: []CompactLODSample, + + pub fn initFromSimplified(allocator: Allocator, lod_level: LODLevel, source: *const LODSimplifiedData) !CompactLODTile { + if (lod_level != .lod3 and lod_level != .lod4) return TileError.InvalidLod; + if (source.width != LODSimplifiedData.getGridSize(lod_level)) return TileError.InvalidSourceData; + if (hasUnsupportedVerticalSpans(source)) return TileError.UnsupportedSourceFeatures; + // The compact water path has no shoreline coverage geometry. Rendering + // a fractional cell as a full quad leaks water onto dry land, so keep + // the established CPU heightfield path for these tiles. + if (hasUnsupportedWaterTopology(source)) return TileError.UnsupportedSourceFeatures; + + const source_count = squareCount(source.width) orelse return TileError.InvalidSourceData; + if (source.heightmap.len != source_count or + source.biomes.len != source_count or + source.top_blocks.len != source_count or + source.colors.len != source_count or + source.material_layers.len != source_count or + source.water.len != source_count or + source.lighting.len != source_count or + source.vegetation.len != source_count or + source.provenance.len != source_count) + { + return TileError.InvalidSourceData; + } + + const padded_stride = apronStride(source.width) orelse return TileError.InvalidSourceData; + const sample_count = squareCount(padded_stride) orelse return TileError.InvalidSourceData; + const samples = try allocator.alloc(CompactLODSample, sample_count); + errdefer allocator.free(samples); + + var apron_z: u32 = 0; + while (apron_z < padded_stride) : (apron_z += 1) { + const source_z = apronToSourceCoordinate(apron_z, source.width); + var apron_x: u32 = 0; + while (apron_x < padded_stride) : (apron_x += 1) { + const source_x = apronToSourceCoordinate(apron_x, source.width); + const index = @as(usize, source_z) * @as(usize, source.width) + @as(usize, source_x); + samples[@as(usize, apron_z) * @as(usize, padded_stride) + @as(usize, apron_x)] = packSourceColumn(source, index); + } + } + + return .{ + .allocator = allocator, + .lod_level = lod_level, + .width = source.width, + .neighbor_apron_mask = 0, + .samples = samples, + }; + } + + pub fn deinit(self: *CompactLODTile) void { + self.allocator.free(self.samples); + self.* = undefined; + } + + pub fn stride(self: *const CompactLODTile) u32 { + return self.width + 2; + } + + /// Returns an interior top-grid sample, excluding the duplicated-edge apron. + pub fn sample(self: *const CompactLODTile, x: u32, z: u32) ?CompactLODSample { + if (x >= self.width or z >= self.width) return null; + const stride_value = self.stride(); + return self.samples[@as(usize, z + 1) * @as(usize, stride_value) + @as(usize, x + 1)]; + } + + /// Returns the padded sample at a raw apron coordinate (`0..width + 1`). + pub fn sampleApron(self: *const CompactLODTile, x: u32, z: u32) ?CompactLODSample { + const stride_value = self.stride(); + if (x >= stride_value or z >= stride_value) return null; + return self.samples[@as(usize, z) * @as(usize, stride_value) + @as(usize, x)]; + } + + /// Returns an interior or immediate neighbor sample from the apron. Inputs + /// outside `-1..width` are clamped to that supported normal-sampling range. + pub fn sampleClamped(self: *const CompactLODTile, x: i32, z: i32) CompactLODSample { + const max_coordinate: i32 = @intCast(self.width); + const apron_x: u32 = @intCast(std.math.clamp(x, @as(i32, -1), max_coordinate) + 1); + const apron_z: u32 = @intCast(std.math.clamp(z, @as(i32, -1), max_coordinate) + 1); + return self.sampleApron(apron_x, apron_z).?; + } + + /// Replaces one duplicated fallback apron with authoritative samples from + /// the adjacent same-level tile. The neighbor's opposite interior edge is + /// copied, so central-difference normals agree after both tiles are patched. + pub fn applyNeighborApron(self: *CompactLODTile, edge: TileEdge, neighbor: *const CompactLODTile) !void { + if (self.lod_level != neighbor.lod_level or self.width != neighbor.width) return TileError.InvalidSourceData; + const stride_value = self.stride(); + const stride_usize: usize = stride_value; + var coordinate: u32 = 0; + while (coordinate < self.width) : (coordinate += 1) { + const destination = switch (edge) { + .north => @as(usize, coordinate + 1), + .east => @as(usize, coordinate + 1) * stride_usize + @as(usize, stride_value - 1), + .south => @as(usize, stride_value - 1) * stride_usize + @as(usize, coordinate + 1), + .west => @as(usize, coordinate + 1) * stride_usize, + }; + const source_sample = switch (edge) { + .north => neighbor.sample(coordinate, neighbor.width - 1).?, + .east => neighbor.sample(0, coordinate).?, + .south => neighbor.sample(coordinate, 0).?, + .west => neighbor.sample(neighbor.width - 1, coordinate).?, + }; + self.samples[destination] = source_sample; + } + self.neighbor_apron_mask |= @as(u8, 1) << @intFromEnum(edge); + } + + pub fn hasNeighborApron(self: *const CompactLODTile, edge: TileEdge) bool { + return (self.neighbor_apron_mask & (@as(u8, 1) << @intFromEnum(edge))) != 0; + } + + /// Compares compact bytes with the current CPU top-face expansion using an + /// explicit vertex size supplied by the renderer-facing caller. + pub fn byteMetrics(self: *const CompactLODTile, expanded_vertex_bytes: usize) ByteMetrics { + const cells = @as(usize, self.width - 1) * @as(usize, self.width - 1); + return .{ + .compact_upload_bytes = self.samples.len * COMPACT_LOD_SAMPLE_BYTES, + // The current path emits two triangles (six Vertex values) + // per cell before side faces, water, or vegetation are considered. + .conservative_expanded_top_grid_bytes = cells * 6 * expanded_vertex_bytes, + }; + } + + pub fn serializedSize(self: *const CompactLODTile) usize { + return COMPACT_LOD_TILE_HEADER_BYTES + self.samples.len * COMPACT_LOD_SAMPLE_BYTES; + } + + /// Serializes the versioned little-endian tile format. The returned buffer is caller-owned. + pub fn serialize(self: *const CompactLODTile, allocator: Allocator) ![]u8 { + const bytes = try allocator.alloc(u8, self.serializedSize()); + errdefer allocator.free(bytes); + + std.mem.writeInt(u32, bytes[0..4], COMPACT_LOD_TILE_MAGIC, .little); + std.mem.writeInt(u16, bytes[4..6], COMPACT_LOD_TILE_VERSION, .little); + bytes[6] = @intFromEnum(self.lod_level); + bytes[7] = self.neighbor_apron_mask; + std.mem.writeInt(u32, bytes[8..12], self.width, .little); + std.mem.writeInt(u32, bytes[12..16], @intCast(self.samples.len), .little); + std.mem.writeInt(u32, bytes[16..20], 0, .little); + @memcpy(bytes[COMPACT_LOD_TILE_HEADER_BYTES..], std.mem.sliceAsBytes(self.samples)); + std.mem.writeInt(u32, bytes[16..20], computeTileCrc(bytes), .little); + return bytes; + } + + /// Deserializes only a complete, validated current-version tile. The result owns its samples. + pub fn deserialize(allocator: Allocator, bytes: []const u8) !CompactLODTile { + if (bytes.len < COMPACT_LOD_TILE_HEADER_BYTES) return TileError.DataTooShort; + if (std.mem.readInt(u32, bytes[0..4], .little) != COMPACT_LOD_TILE_MAGIC) return TileError.InvalidMagic; + if (std.mem.readInt(u16, bytes[4..6], .little) != COMPACT_LOD_TILE_VERSION) return TileError.UnsupportedVersion; + if ((bytes[7] & 0xf0) != 0) return TileError.InvalidHeader; + + const lod_level = std.enums.fromInt(LODLevel, bytes[6]) orelse return TileError.InvalidLod; + if (lod_level != .lod3 and lod_level != .lod4) return TileError.InvalidLod; + const width = std.mem.readInt(u32, bytes[8..12], .little); + if (width != LODSimplifiedData.getGridSize(lod_level)) return TileError.InvalidHeader; + const stride_value = apronStride(width) orelse return TileError.InvalidHeader; + const sample_count = squareCount(stride_value) orelse return TileError.InvalidHeader; + if (std.mem.readInt(u32, bytes[12..16], .little) != sample_count) return TileError.InvalidHeader; + + const payload_len = std.math.mul(usize, sample_count, COMPACT_LOD_SAMPLE_BYTES) catch return TileError.InvalidHeader; + if (bytes.len != COMPACT_LOD_TILE_HEADER_BYTES + payload_len) return TileError.InvalidLength; + const payload = bytes[COMPACT_LOD_TILE_HEADER_BYTES..]; + if (std.mem.readInt(u32, bytes[16..20], .little) != computeTileCrc(bytes)) return TileError.ChecksumMismatch; + + const samples = try allocator.alloc(CompactLODSample, sample_count); + errdefer allocator.free(samples); + @memcpy(std.mem.sliceAsBytes(samples), payload); + for (samples) |sample_value| { + if (!sample_value.isValid()) return TileError.InvalidSample; + } + return .{ .allocator = allocator, .lod_level = lod_level, .width = width, .neighbor_apron_mask = bytes[7], .samples = samples }; + } +}; + +/// Reduces a block to the renderer-independent seven-bit semantic material ID. +/// Values not known by this build use stone until an ABI version adds them. +pub fn reduceMaterial(block: BlockType) u7 { + const id: u8 = @intFromEnum(block); + return @intCast(if (id <= @min(MAX_ENCODED_BLOCK_ID, MAX_KNOWN_BLOCK_ID)) id else @intFromEnum(BlockType.stone)); +} + +fn packSourceColumn(source: *const LODSimplifiedData, index: usize) CompactLODSample { + const water = source.water[index]; + const water_height: i16 = if (water.is_surface) quantizeWaterHeight(water.surface_height) else WATER_HEIGHT_NONE; + const layers = source.material_layers[index]; + + var bits: u128 = 0; + bits |= @as(u128, @as(u16, @bitCast(quantizeTerrainHeight(terrainHeightForColumn(source, index))))); + bits |= @as(u128, @as(u16, @bitCast(water_height))) << 16; + bits |= @as(u128, if (water.is_surface) quantizeRange(water.depth, DEPTH_SCALE, 255) else 0) << 32; + bits |= @as(u128, if (water.is_surface) quantizeUnit(water.coverage, 255) else 0) << 40; + bits |= @as(u128, reduceMaterial(layers.surface)) << 48; + bits |= @as(u128, reduceMaterial(layers.subsurface)) << 55; + bits |= @as(u128, reduceMaterial(layers.foundation)) << 62; + bits |= @as(u128, source.colors[index] & 0x00ff_ffff) << 69; + bits |= @as(u128, @min(source.lighting[index].sky_light, 15)) << 93; + bits |= @as(u128, @min(source.lighting[index].block_light, 15)) << 97; + bits |= @as(u128, quantizeUnit(source.lighting[index].ambient_occlusion, 63)) << 101; + bits |= @as(u128, quantizeUnit(source.vegetation[index].tree_coverage, 63)) << 107; + bits |= @as(u128, quantizeRange(source.vegetation[index].avg_tree_height, VEGETATION_HEIGHT_SCALE, 255)) << 113; + bits |= @as(u128, @intFromEnum(source.provenance[index])) << 121; + + var bytes: [COMPACT_LOD_SAMPLE_BYTES]u8 = undefined; + std.mem.writeInt(u64, bytes[0..8], @truncate(bits), .little); + std.mem.writeInt(u64, bytes[8..16], @truncate(bits >> 64), .little); + return .{ .bytes = bytes }; +} + +fn quantizeTerrainHeight(value: f32) i16 { + return quantizeSigned(value, std.math.minInt(i16), std.math.maxInt(i16)); +} + +fn terrainHeightForColumn(source: *const LODSimplifiedData, index: usize) f32 { + const terrain = source.heightmap[index]; + const water = source.water[index]; + if (!water.is_surface or water.coverage <= 0.0 or terrain < water.surface_height) return terrain; + // Some coarse source columns encode the water surface as their height. Keep + // the independently summarized floor in that ambiguous case. + return if (water.depth > 0.0) @max(0.0, water.surface_height - water.depth) else water.surface_height; +} + +fn hasUnsupportedVerticalSpans(source: *const LODSimplifiedData) bool { + if (!source.hasVerticalSpans()) return false; + var z: u32 = 0; + while (z < source.width) : (z += 1) { + var x: u32 = 0; + while (x < source.width) : (x += 1) { + const count = source.verticalSpanCount(x, z); + // Version 1 represents one heightfield surface plus summary water + // and vegetation fields. Any additional explicit topology must use + // the maintained CPU mesh path until a versioned encoding exists. + if (count > 1) return true; + var span_index: u8 = 0; + while (span_index < count) : (span_index += 1) { + const span = source.getVerticalSpan(x, z, span_index) orelse return true; + if (span.min_height > 0.01) return true; + } + } + } + return false; +} + +fn hasUnsupportedWaterTopology(source: *const LODSimplifiedData) bool { + var has_water = false; + var has_dry = false; + for (source.water) |water| { + if (water.is_surface and water.coverage > 0.001 and water.coverage < 0.999) return true; + if (water.is_surface and water.coverage >= 0.999) { + has_water = true; + } else { + has_dry = true; + } + // The reusable full grid cannot reject an individual shoreline cell at + // primitive granularity. Mixed wet/dry tiles retain the CPU water mesh. + if (has_water and has_dry) return true; + } + return false; +} + +fn quantizeWaterHeight(value: f32) i16 { + return quantizeSigned(value, std.math.minInt(i16) + 1, std.math.maxInt(i16)); +} + +fn quantizeSigned(value: f32, min: i16, max: i16) i16 { + const finite = if (std.math.isFinite(value)) value else 0.0; + const min_value = @as(f32, @floatFromInt(min)) / HEIGHT_SCALE; + const max_value = @as(f32, @floatFromInt(max)) / HEIGHT_SCALE; + return @intFromFloat(@round(std.math.clamp(finite, min_value, max_value) * HEIGHT_SCALE)); +} + +fn quantizeRange(value: f32, scale: f32, max: u8) u8 { + const finite = if (std.math.isFinite(value)) value else 0.0; + const max_value = @as(f32, @floatFromInt(max)) / scale; + return @intFromFloat(@round(std.math.clamp(finite, 0.0, max_value) * scale)); +} + +fn quantizeUnit(value: f32, max: u8) u8 { + const finite = if (std.math.isFinite(value)) value else 0.0; + return @intFromFloat(@round(std.math.clamp(finite, 0.0, 1.0) * @as(f32, @floatFromInt(max)))); +} + +fn dequantizeHeight(value: i16) f32 { + return @as(f32, @floatFromInt(value)) / HEIGHT_SCALE; +} + +fn signedField(bits: u128, shift: u7) i16 { + const raw: u16 = @truncate(bits >> shift); + return @bitCast(raw); +} + +fn unsignedField(bits: u128, shift: u7, comptime T: type) T { + return @truncate(bits >> shift); +} + +fn semanticMaterialFromId(id: u7) BlockType { + return @enumFromInt(id); +} + +fn provenanceFromBits(bits: u8) LODColumnProvenance { + return std.enums.fromInt(LODColumnProvenance, bits) orelse .worldgen; +} + +fn computeTileCrc(bytes: []const u8) u32 { + var crc = std.hash.Crc32.init(); + crc.update(bytes[0..16]); + crc.update(&.{ 0, 0, 0, 0 }); + crc.update(bytes[COMPACT_LOD_TILE_HEADER_BYTES..]); + return crc.final(); +} + +fn apronStride(width: u32) ?u32 { + return std.math.add(u32, width, 2) catch null; +} + +fn squareCount(width: u32) ?usize { + const count = std.math.mul(u32, width, width) catch return null; + return @intCast(count); +} + +fn apronToSourceCoordinate(apron_coordinate: u32, width: u32) u32 { + if (apron_coordinate == 0) return 0; + return @min(apron_coordinate - 1, width - 1); +} + +test "CompactLODSample golden vector decodes signed 128-bit height fields" { + // terrain=-12.5, water=7.25. The upper word also proves that decode does + // not accidentally truncate the packed u128 before reading later fields. + var bits: u128 = 0; + bits |= @as(u128, @as(u16, @bitCast(@as(i16, -100)))); + bits |= @as(u128, @as(u16, @bitCast(@as(i16, 58)))) << 16; + bits |= @as(u128, 9) << 32; + bits |= @as(u128, 255) << 40; + bits |= @as(u128, 15) << 93; + var bytes: [16]u8 = undefined; + std.mem.writeInt(u64, bytes[0..8], @truncate(bits), .little); + std.mem.writeInt(u64, bytes[8..16], @truncate(bits >> 64), .little); + const decoded = (CompactLODSample{ .bytes = bytes }).decode(); + try std.testing.expectEqual(@as(f32, -12.5), decoded.terrain_height); + try std.testing.expectEqual(@as(f32, 7.25), decoded.water.surface_height); + try std.testing.expectEqual(@as(u8, 15), decoded.lighting.sky_light); +} + +test "CompactLODTile round trips its versioned little-endian payload" { + const allocator = std.testing.allocator; + var source = try LODSimplifiedData.init(allocator, .lod3); + defer source.deinit(); + for (source.water) |*water| water.* = .{ .is_surface = true, .surface_height = 124.25, .depth = 3.5, .coverage = 1.0 }; + source.setColumn(2, 3, 123.125, .plains, .{ .surface = .grass, .subsurface = .dirt, .foundation = .stone }, 0xaabb_ccdd, .{ .is_surface = true, .surface_height = 124.25, .depth = 3.5, .coverage = 1.0 }, .{ .sky_light = 15, .block_light = 7, .ambient_occlusion = 0.5 }, .{ .tree_coverage = 0.5, .avg_tree_height = 12.5, .offset_x = 0.25, .offset_z = 0.75, .trunk = .wood, .leaves = .leaves }); + source.setColumnProvenance(2, 3, .edited); + + var tile = try CompactLODTile.initFromSimplified(allocator, .lod3, &source); + defer tile.deinit(); + const wire = try tile.serialize(allocator); + defer allocator.free(wire); + var restored = try CompactLODTile.deserialize(allocator, wire); + defer restored.deinit(); + + try std.testing.expectEqual(COMPACT_LOD_TILE_HEADER_BYTES + tile.samples.len * COMPACT_LOD_SAMPLE_BYTES, wire.len); + try std.testing.expectEqualDeep(tile.sample(2, 3).?, restored.sample(2, 3).?); + const decoded = restored.sample(2, 3).?.decode(); + try std.testing.expectEqual(@as(f32, 123.125), decoded.terrain_height); + try std.testing.expectEqual(@as(u32, 0x00bb_ccdd), decoded.color); + try std.testing.expectEqual(LODColumnProvenance.edited, decoded.provenance); +} + +test "CompactLODTile clamps and rounds quantized source values deterministically" { + const allocator = std.testing.allocator; + var source = try LODSimplifiedData.init(allocator, .lod4); + defer source.deinit(); + for (source.water) |*water| water.* = .{ .is_surface = true, .surface_height = 9999.0, .depth = 9999.0, .coverage = 1.0 }; + source.setColumn(0, 0, 1.07, .plains, LODMaterialLayers.default(.stone), 0, .{ .is_surface = true, .surface_height = 9999.0, .depth = 9999.0, .coverage = 2.0 }, .{ .sky_light = 255, .block_light = 99, .ambient_occlusion = -1.0 }, .{ .tree_coverage = 2.0, .avg_tree_height = 999.0, .offset_x = 0.0, .offset_z = 0.0, .trunk = .air, .leaves = .air }); + source.setHeight(1, 0, -9999.0); + + var tile = try CompactLODTile.initFromSimplified(allocator, .lod4, &source); + defer tile.deinit(); + const first = tile.sample(0, 0).?.decode(); + const second = tile.sample(1, 0).?.decode(); + try std.testing.expectEqual(@as(f32, 1.125), first.terrain_height); + try std.testing.expectEqual(@as(f32, 4095.875), first.water.surface_height); + try std.testing.expectEqual(@as(f32, 63.75), first.water.depth); + try std.testing.expectEqual(@as(f32, 1.0), first.water.coverage); + try std.testing.expectEqual(@as(u8, 15), first.lighting.sky_light); + try std.testing.expectEqual(@as(f32, 0.0), first.lighting.ambient_occlusion); + try std.testing.expectEqual(@as(f32, 127.5), first.vegetation.avg_tree_height); + try std.testing.expectEqual(@as(f32, -4096.0), second.terrain_height); +} + +test "CompactLODTile rejects partial water so CPU shore geometry is retained" { + var source = try LODSimplifiedData.init(std.testing.allocator, .lod4); + defer source.deinit(); + source.setColumn(1, 1, 64.0, .plains, LODMaterialLayers.default(.sand), 0, .{ + .is_surface = true, + .surface_height = 65.0, + .depth = 1.0, + .coverage = 0.5, + }, .daylight, .empty); + try std.testing.expectError(TileError.UnsupportedSourceFeatures, CompactLODTile.initFromSimplified(std.testing.allocator, .lod4, &source)); +} + +test "CompactLODTile uses the water-height sentinel for dry columns" { + const allocator = std.testing.allocator; + var source = try LODSimplifiedData.init(allocator, .lod4); + defer source.deinit(); + source.setColumn(0, 0, 10.0, .plains, LODMaterialLayers.default(.sand), 0, .{ .is_surface = false, .surface_height = 42.0, .depth = 8.0, .coverage = 1.0 }, LODLightingHint.daylight, LODVegetationHint.empty); + var tile = try CompactLODTile.initFromSimplified(allocator, .lod4, &source); + defer tile.deinit(); + + const decoded = tile.sample(0, 0).?.decode(); + try std.testing.expect(!decoded.water.is_surface); + try std.testing.expectEqual(@as(f32, 0.0), decoded.water.surface_height); + try std.testing.expectEqual(@as(f32, 0.0), decoded.water.depth); + try std.testing.expectEqual(@as(f32, 0.0), decoded.water.coverage); +} + +test "CompactLODTile retains column provenance" { + const allocator = std.testing.allocator; + var source = try LODSimplifiedData.init(allocator, .lod4); + defer source.deinit(); + source.setColumnProvenance(4, 5, .chunk_derived); + var tile = try CompactLODTile.initFromSimplified(allocator, .lod4, &source); + defer tile.deinit(); + + try std.testing.expectEqual(LODColumnProvenance.chunk_derived, tile.sample(4, 5).?.decode().provenance); +} + +test "CompactLODTile duplicates boundary samples for stable neighbor normals" { + const allocator = std.testing.allocator; + var source = try LODSimplifiedData.init(allocator, .lod4); + defer source.deinit(); + source.setHeight(0, 0, 11.0); + source.setHeight(source.width - 1, source.width - 1, 22.0); + var tile = try CompactLODTile.initFromSimplified(allocator, .lod4, &source); + defer tile.deinit(); + + try std.testing.expectEqual(@as(f32, 11.0), tile.sampleClamped(-1, -1).decode().terrain_height); + try std.testing.expectEqual(@as(f32, 22.0), tile.sampleClamped(@as(i32, @intCast(source.width)), @as(i32, @intCast(source.width))).decode().terrain_height); + try std.testing.expectEqualDeep(tile.sampleApron(0, 0).?, tile.sample(0, 0).?); +} + +test "CompactLODTile patches aprons from same-level neighbors" { + const allocator = std.testing.allocator; + var left_source = try LODSimplifiedData.init(allocator, .lod4); + defer left_source.deinit(); + var right_source = try LODSimplifiedData.init(allocator, .lod4); + defer right_source.deinit(); + var mismatched_source = try LODSimplifiedData.init(allocator, .lod3); + defer mismatched_source.deinit(); + right_source.setHeight(0, 7, 88.0); + + var left = try CompactLODTile.initFromSimplified(allocator, .lod4, &left_source); + defer left.deinit(); + var right = try CompactLODTile.initFromSimplified(allocator, .lod4, &right_source); + defer right.deinit(); + var mismatched = try CompactLODTile.initFromSimplified(allocator, .lod3, &mismatched_source); + defer mismatched.deinit(); + try left.applyNeighborApron(.east, &right); + + try std.testing.expect(left.hasNeighborApron(.east)); + try std.testing.expectEqual(@as(f32, 88.0), left.sampleApron(left.stride() - 1, 8).?.decode().terrain_height); + try std.testing.expectEqual(@as(f32, 88.0), left.sampleClamped(@intCast(left.width), 7).decode().terrain_height); + try std.testing.expectError(TileError.InvalidSourceData, left.applyNeighborApron(.east, &mismatched)); +} + +test "CompactLODTile stores terrain floor separately from water surface" { + const allocator = std.testing.allocator; + var source = try LODSimplifiedData.init(allocator, .lod4); + defer source.deinit(); + for (source.water) |*water| water.* = .{ .is_surface = true, .surface_height = 64.0, .depth = 10.0, .coverage = 1.0 }; + source.setColumn(1, 1, 64.0, .ocean, .{ .surface = .water, .subsurface = .sand, .foundation = .stone }, 0, .{ .is_surface = true, .surface_height = 64.0, .depth = 10.0, .coverage = 1.0 }, .daylight, .empty); + var tile = try CompactLODTile.initFromSimplified(allocator, .lod4, &source); + defer tile.deinit(); + + const decoded = tile.sample(1, 1).?.decode(); + try std.testing.expectEqual(@as(f32, 54.0), decoded.terrain_height); + try std.testing.expectEqual(@as(f32, 64.0), decoded.water.surface_height); +} + +test "CompactLODTile rejects unsupported overhang spans" { + const allocator = std.testing.allocator; + var source = try LODSimplifiedData.initWithVerticalSpans(allocator, .lod4); + defer source.deinit(); + try std.testing.expect(source.setVerticalSpan(1, 1, 0, .{ + .min_height = 12.0, + .max_height = 20.0, + .biome = .plains, + .material_layers = .{ .surface = .stone, .subsurface = .stone, .foundation = .stone }, + .color = 0, + .water = .empty, + .lighting = .daylight, + .vegetation = .empty, + })); + + try std.testing.expectError(TileError.UnsupportedSourceFeatures, CompactLODTile.initFromSimplified(allocator, .lod4, &source)); +} + +test "CompactLODTile rejects additional water spans until topology is versioned" { + const allocator = std.testing.allocator; + var source = try LODSimplifiedData.initWithVerticalSpans(allocator, .lod4); + defer source.deinit(); + try std.testing.expect(source.setVerticalSpan(1, 1, 0, .{ + .min_height = 0.0, + .max_height = 54.0, + .biome = .ocean, + .material_layers = .{ .surface = .sand, .subsurface = .sand, .foundation = .stone }, + .color = 0, + .water = .empty, + .lighting = .daylight, + .vegetation = .empty, + })); + try std.testing.expect(source.setVerticalSpan(1, 1, 1, .{ + .min_height = 54.0, + .max_height = 64.0, + .biome = .ocean, + .material_layers = .{ .surface = .water, .subsurface = .water, .foundation = .stone }, + .color = 0, + .water = .{ .is_surface = true, .surface_height = 64.0, .depth = 10.0, .coverage = 1.0 }, + .lighting = .daylight, + .vegetation = .empty, + })); + + try std.testing.expectError(TileError.UnsupportedSourceFeatures, CompactLODTile.initFromSimplified(allocator, .lod4, &source)); +} + +test "CompactLODTile rejects a corrupted serialized payload" { + const allocator = std.testing.allocator; + var source = try LODSimplifiedData.init(allocator, .lod4); + defer source.deinit(); + var tile = try CompactLODTile.initFromSimplified(allocator, .lod4, &source); + defer tile.deinit(); + const wire = try tile.serialize(allocator); + defer allocator.free(wire); + wire[COMPACT_LOD_TILE_HEADER_BYTES] ^= 0x80; + + try std.testing.expectError(TileError.ChecksumMismatch, CompactLODTile.deserialize(allocator, wire)); +} + +test "CompactLODTile rejects unknown semantic materials with a valid checksum" { + const allocator = std.testing.allocator; + var source = try LODSimplifiedData.init(allocator, .lod4); + defer source.deinit(); + var tile = try CompactLODTile.initFromSimplified(allocator, .lod4, &source); + defer tile.deinit(); + const wire = try tile.serialize(allocator); + defer allocator.free(wire); + + const sample = wire[COMPACT_LOD_TILE_HEADER_BYTES..][0..COMPACT_LOD_SAMPLE_BYTES]; + var low = std.mem.readInt(u64, sample[0..8], .little); + low &= ~(@as(u64, 0x7f) << 48); + low |= @as(u64, MAX_KNOWN_BLOCK_ID + 1) << 48; + std.mem.writeInt(u64, sample[0..8], low, .little); + std.mem.writeInt(u32, wire[16..20], computeTileCrc(wire), .little); + + try std.testing.expectError(TileError.InvalidSample, CompactLODTile.deserialize(allocator, wire)); +} + +test "CompactLODTile reduces future material IDs and reports compact byte metrics" { + const future_block: BlockType = @enumFromInt(200); + try std.testing.expectEqual(@as(u7, @intFromEnum(BlockType.stone)), reduceMaterial(future_block)); + try std.testing.expectEqual(@as(u7, @intFromEnum(BlockType.blue_ice)), reduceMaterial(.blue_ice)); + + const allocator = std.testing.allocator; + var source = try LODSimplifiedData.init(allocator, .lod4); + defer source.deinit(); + var tile = try CompactLODTile.initFromSimplified(allocator, .lod4, &source); + defer tile.deinit(); + const metrics = tile.byteMetrics(32); + try std.testing.expectEqual(tile.samples.len * COMPACT_LOD_SAMPLE_BYTES, metrics.compact_upload_bytes); + try std.testing.expect(metrics.compact_upload_bytes < metrics.conservative_expanded_top_grid_bytes); + try std.testing.expectEqual(metrics.conservative_expanded_top_grid_bytes - metrics.compact_upload_bytes, metrics.savedBytes()); + try std.testing.expectEqual(@as(usize, 0), (ByteMetrics{ .compact_upload_bytes = 16, .conservative_expanded_top_grid_bytes = 0 }).savedBytes()); +} + +test "CompactLODTile checksum covers header metadata" { + const allocator = std.testing.allocator; + var source = try LODSimplifiedData.init(allocator, .lod4); + defer source.deinit(); + var tile = try CompactLODTile.initFromSimplified(allocator, .lod4, &source); + defer tile.deinit(); + const wire = try tile.serialize(allocator); + defer allocator.free(wire); + wire[7] ^= 0x01; + + try std.testing.expectError(TileError.ChecksumMismatch, CompactLODTile.deserialize(allocator, wire)); +} diff --git a/modules/world-lod/src/lod_upload_queue.zig b/modules/world-lod/src/lod_upload_queue.zig index 09510fef..4178088b 100644 --- a/modules/world-lod/src/lod_upload_queue.zig +++ b/modules/world-lod/src/lod_upload_queue.zig @@ -12,6 +12,7 @@ const LODRegionKey = lod_chunk.LODRegionKey; const LODRegionKeyContext = lod_chunk.LODRegionKeyContext; const ILODConfig = lod_chunk.ILODConfig; const LODStats = @import("lod_stats.zig").LODStats; +const LODProfilingCollector = @import("lod_stats.zig").LODProfilingCollector; const LODMesh = @import("lod_mesh.zig").LODMesh; const Vec3 = @import("engine-math").Vec3; const Mat4 = @import("engine-math").Mat4; @@ -29,6 +30,8 @@ pub const LODGPUBridge = struct { on_wait_idle: *const fn (ctx: *anyopaque) void, /// Opaque context pointer (typically the concrete RHI instance). ctx: *anyopaque, + /// Optional capability probe for compact storage-buffer vertex pulling. + on_supports_compact: ?*const fn (ctx: *anyopaque) bool = null, fn hasInvalidCtx(self: LODGPUBridge) bool { const ctx_addr = @intFromPtr(self.ctx); @@ -64,6 +67,12 @@ pub const LODGPUBridge = struct { self.assertValidCtx(); self.on_wait_idle(self.ctx); } + + pub fn supportsCompact(self: LODGPUBridge) bool { + if (self.hasInvalidCtx()) return false; + const probe = self.on_supports_compact orelse return false; + return probe(self.ctx); + } }; /// Type aliases used by LODRenderInterface for mesh/region maps. @@ -78,6 +87,19 @@ pub const LODRenderLayer = enum { fluid, }; +/// Renderer-owned LOD pool accounting. Pool allocation and slack are reported +/// separately because every pool buffer also has a same-sized CPU shadow. +pub const LODRendererMemoryStats = struct { + pool_gpu_capacity_bytes: usize = 0, + pool_gpu_allocated_bytes: usize = 0, + pool_gpu_slack_bytes: usize = 0, + pool_cpu_shadow_bytes: usize = 0, + compact_pool_capacity_bytes: usize = 0, + compact_pool_allocated_bytes: usize = 0, + compact_pool_free_bytes: usize = 0, + compact_pool_retired_bytes: usize = 0, +}; + /// Type-erased interface for LOD rendering. /// Allows LODManager to delegate rendering without knowing the concrete RHI type. pub const LODRenderInterface = struct { @@ -95,7 +117,40 @@ pub const LODRenderInterface = struct { max_distance_chunks: ?i32, layer: LODRenderLayer, stats: ?*LODStats, + profiling: ?*LODProfilingCollector, ) void, + /// Frame-aware render entry point. Optional to preserve small test and + /// alternate renderer implementations; production renderers use it to + /// share a value-only visibility projection across terrain and water. + render_frame_fn: ?*const fn ( + 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, + use_frustum: bool, + max_distance_chunks: ?i32, + layer: LODRenderLayer, + stats: ?*LODStats, + profiling: ?*LODProfilingCollector, + ) void = null, + prepare_frame_fn: ?*const fn ( + 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, + ) void = null, + memory_stats_fn: ?*const fn (self_ptr: *anyopaque) LODRendererMemoryStats = null, /// Destroy renderer resources. deinit_fn: *const fn (self_ptr: *anyopaque) void, /// Opaque pointer to the concrete renderer. @@ -114,11 +169,44 @@ pub const LODRenderInterface = struct { max_distance_chunks: ?i32, layer: LODRenderLayer, stats: ?*LODStats, + profiling: ?*LODProfilingCollector, + ) void { + self.render_fn(self.ptr, meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer, stats, profiling); + } + + pub fn renderFrame( + 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, + use_frustum: bool, + max_distance_chunks: ?i32, + layer: LODRenderLayer, + stats: ?*LODStats, + profiling: ?*LODProfilingCollector, ) void { - self.render_fn(self.ptr, meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer, stats); + 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); + } else { + self.render(meshes, regions, config, view_proj, camera_pos, chunk_checker, checker_ctx, use_frustum, max_distance_chunks, layer, stats, profiling); + } } pub fn deinit(self: LODRenderInterface) void { 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) 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); + } + + pub fn memoryStats(self: LODRenderInterface) LODRendererMemoryStats { + if (self.memory_stats_fn) |memory_stats| return memory_stats(self.ptr); + return .{}; + } }; diff --git a/modules/world-lod/src/lod_vertex_pool.zig b/modules/world-lod/src/lod_vertex_pool.zig index e63147cc..56e90291 100644 --- a/modules/world-lod/src/lod_vertex_pool.zig +++ b/modules/world-lod/src/lod_vertex_pool.zig @@ -27,6 +27,13 @@ const AllocationRecord = struct { size: usize, }; +const RetiredRange = struct { + offset: usize, + size: usize, + serial: u64, + frame_slot: usize, +}; + const MeshDrawState = LODMesh.DrawState; /// Owns one large vertex buffer for a single LOD level and sub-allocates mesh ranges. @@ -38,6 +45,10 @@ pub const LODVertexPool = struct { initial_capacity_bytes: usize = DEFAULT_INITIAL_CAPACITY_BYTES, free_blocks: std.ArrayListUnmanaged(FreeBlock) = .empty, allocations: std.ArrayListUnmanaged(AllocationRecord) = .empty, + retired_ranges: std.ArrayListUnmanaged(RetiredRange) = .empty, + current_serial: u64 = 0, + current_frame_slot: usize = 0, + last_completed_serial: ?u64 = null, shadow: []u8 = undefined, mutex: sync.Mutex = .{}, @@ -62,6 +73,7 @@ pub const LODVertexPool = struct { } self.free_blocks.deinit(self.allocator); self.allocations.deinit(self.allocator); + self.retired_ranges.deinit(self.allocator); self.capacity_bytes = 0; } @@ -87,7 +99,7 @@ pub const LODVertexPool = struct { }; if (pending.len == 0) { - self.freeMeshUnlocked(mesh); + if (self.findRecordIndexUnlocked(mesh)) |idx| self.retireRecordUnlocked(idx, true, mesh); mesh.markEmptyUploadedUnlocked(); self.allocator.free(pending); mesh.pending_vertices = null; @@ -135,7 +147,7 @@ pub const LODVertexPool = struct { const new_size = record.size; if (using_new_record) { if (old_record_index) |idx| { - self.freeRecordUnlocked(idx, false, mesh); + self.retireRecordUnlocked(idx, false, mesh); } } @@ -167,6 +179,45 @@ pub const LODVertexPool = struct { mesh.clearDrawStateUnlocked(); } + /// Removes a mesh from future submissions while keeping its byte range + /// unavailable until the frame slot that last referenced it completes. + pub fn destroyMeshDeferred(self: *LODVertexPool, mesh: *LODMesh, serial: u64, frame_slot: usize) void { + self.mutex.lock(); + defer self.mutex.unlock(); + mesh.mutex.lock(); + defer mesh.mutex.unlock(); + + self.current_serial = serial; + self.current_frame_slot = frame_slot; + if (self.findRecordIndexUnlocked(mesh)) |idx| self.retireRecordUnlocked(idx, true, mesh); + if (mesh.pending_vertices) |pending| { + self.allocator.free(pending); + mesh.pending_vertices = null; + } + mesh.clearDrawStateUnlocked(); + } + + pub fn collectRetired(self: *LODVertexPool, completed_serial: u64, completed_frame_slot: usize) void { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.last_completed_serial) |last| if (completed_serial <= last) return; + self.last_completed_serial = completed_serial; + self.current_serial = completed_serial; + self.current_frame_slot = completed_frame_slot; + + var i: usize = 0; + while (i < self.retired_ranges.items.len) { + const retired = self.retired_ranges.items[i]; + if (retired.frame_slot == completed_frame_slot and completed_serial > retired.serial) { + self.releaseOffsetUnlocked(retired.offset, retired.size) catch { + i += 1; + continue; + }; + _ = self.retired_ranges.swapRemove(i); + } else i += 1; + } + } + pub fn allocatedBytes(self: *LODVertexPool) usize { self.mutex.lock(); defer self.mutex.unlock(); @@ -250,7 +301,6 @@ pub const LODVertexPool = struct { } if (old_handle != 0) { - resources.waitIdle(); resources.destroyBuffer(old_handle); } if (old_shadow) |shadow| self.allocator.free(shadow); @@ -298,6 +348,21 @@ pub const LODVertexPool = struct { _ = self.allocations.orderedRemove(idx); } + fn retireRecordUnlocked(self: *LODVertexPool, idx: usize, reset_mesh: bool, locked_mesh: ?*LODMesh) void { + const record = self.allocations.items[idx]; + self.retired_ranges.append(self.allocator, .{ + .offset = record.offset, + .size = record.size, + .serial = self.current_serial, + .frame_slot = self.current_frame_slot, + }) catch { + // Bookkeeping failure intentionally leaks pool capacity rather than + // risking an in-flight overwrite. + }; + if (reset_mesh) setMeshDrawState(record.mesh, .empty, locked_mesh); + _ = self.allocations.orderedRemove(idx); + } + fn releaseOffsetUnlocked(self: *LODVertexPool, offset: usize, size: usize) !void { if (size == 0) return; @@ -327,6 +392,7 @@ pub const LODVertexPool = struct { fn compactUnlocked(self: *LODVertexPool, resources: LODMeshResources, locked_mesh: ?*LODMesh) RhiError!void { if (self.allocations.items.len == 0) { + if (self.retired_ranges.items.len != 0) return; self.free_blocks.clearRetainingCapacity(); try self.releaseOffsetUnlocked(0, self.capacity_bytes); return; @@ -338,21 +404,51 @@ pub const LODVertexPool = struct { } }.lt); + self.free_blocks.ensureTotalCapacity(self.allocator, 1) catch return error.OutOfMemory; + + // Never relocate ranges inside the live backing buffer. Draws already + // submitted for earlier frames may still read the old offsets while a + // transfer writes the compacted data. Build a replacement buffer, + // publish all new locations only after every upload succeeds, and let + // the RHI retire the old handle through its frame-fence deletion path. + const new_shadow = self.allocator.alloc(u8, self.capacity_bytes) catch return error.OutOfMemory; + errdefer self.allocator.free(new_shadow); + @memset(new_shadow, 0); + + const new_handle = try resources.createBuffer(self.capacity_bytes, .vertex); + errdefer resources.destroyBuffer(new_handle); + var cursor: usize = 0; + for (self.allocations.items) |record| { + const source = self.shadow[record.offset .. record.offset + record.size]; + @memcpy(new_shadow[cursor .. cursor + record.size], source); + try lod_mesh.updateBufferChunked(resources, new_handle, cursor, source); + cursor += record.size; + } + + const old_handle = self.buffer_handle; + const old_shadow = self.shadow; + self.buffer_handle = new_handle; + self.shadow = new_shadow; + + cursor = 0; for (self.allocations.items) |*record| { - if (record.offset != cursor) { - try lod_mesh.updateBufferChunked(resources, self.buffer_handle, cursor, self.shadow[record.offset .. record.offset + record.size]); - std.mem.copyForwards(u8, self.shadow[cursor .. cursor + record.size], self.shadow[record.offset .. record.offset + record.size]); - record.offset = cursor; - setMeshPoolLocation(record.mesh, self.buffer_handle, cursor, locked_mesh); - } + record.offset = cursor; + setMeshPoolLocation(record.mesh, new_handle, cursor, locked_mesh); cursor += record.size; } + // Retired offsets belonged to the old backing buffer. The replacement + // is freshly packed and the old handle is fence-retired by the RHI, so + // only the new packed tail is free here. + self.retired_ranges.clearRetainingCapacity(); self.free_blocks.clearRetainingCapacity(); if (cursor < self.capacity_bytes) { try self.releaseOffsetUnlocked(cursor, self.capacity_bytes - cursor); } + + resources.destroyBuffer(old_handle); + self.allocator.free(old_shadow); } fn findRecordIndexUnlocked(self: *const LODVertexPool, mesh: *const LODMesh) ?usize { @@ -365,6 +461,7 @@ pub const LODVertexPool = struct { fn allocatedBytesUnlocked(self: *const LODVertexPool) usize { var total: usize = 0; for (self.allocations.items) |record| total += record.size; + for (self.retired_ranges.items) |record| total += record.size; return total; } @@ -536,7 +633,7 @@ test "LODVertexPool grows and preserves pooled handles" { try std.testing.expectEqual(first.buffer_handle, second.buffer_handle); try std.testing.expectEqual(@as(u32, 0), resources.uploaded); try std.testing.expect(resources.updated >= 1); - try std.testing.expectEqual(@as(u32, 1), resources.wait_idle); + try std.testing.expectEqual(@as(u32, 0), resources.wait_idle); pool.destroyMesh(&first); pool.destroyMesh(&second); } @@ -576,14 +673,55 @@ test "LODVertexPool compacts fragmented ranges" { pool.destroyMesh(&b); const old_c_offset = c.vertex_offset; + const old_handle = c.buffer_handle; try pool.compact(resources.resources()); try std.testing.expect(c.vertex_offset < old_c_offset); + try std.testing.expect(c.buffer_handle != old_handle); + try std.testing.expectEqual(c.buffer_handle, a.buffer_handle); + try std.testing.expectEqual(@as(u32, 2), resources.created); + try std.testing.expectEqual(@as(u32, 1), resources.destroyed); try std.testing.expectEqual(pool.capacity_bytes - pool.allocatedBytes(), pool.freeBytes()); pool.destroyMesh(&a); pool.destroyMesh(&c); } +test "LODVertexPool compaction failure preserves live allocation locations" { + const allocator = std.testing.allocator; + var resources = TestResources{}; + var pool = LODVertexPool.init(allocator, .lod1, 1024); + defer pool.deinit(resources.resources()); + + var a = LODMesh.init(allocator, .lod1); + var b = LODMesh.init(allocator, .lod1); + var c = LODMesh.init(allocator, .lod1); + try setPending(&a, allocator, 4); + try setPending(&b, allocator, 4); + try setPending(&c, allocator, 4); + try pool.uploadMesh(&a, resources.resources()); + try pool.uploadMesh(&b, resources.resources()); + try pool.uploadMesh(&c, resources.resources()); + + pool.destroyMesh(&b); + const old_a_handle = a.buffer_handle; + const old_a_offset = a.vertex_offset; + const old_c_handle = c.buffer_handle; + const old_c_offset = c.vertex_offset; + resources.fail_updates = true; + + try std.testing.expectError(error.GpuLost, pool.compact(resources.resources())); + try std.testing.expectEqual(old_a_handle, a.buffer_handle); + try std.testing.expectEqual(old_a_offset, a.vertex_offset); + try std.testing.expectEqual(old_c_handle, c.buffer_handle); + try std.testing.expectEqual(old_c_offset, c.vertex_offset); + try std.testing.expectEqual(@as(u32, 2), resources.created); + try std.testing.expectEqual(@as(u32, 1), resources.destroyed); + + resources.fail_updates = false; + pool.destroyMesh(&a); + pool.destroyMesh(&c); +} + test "LODVertexPool upload failure keeps pending vertices retryable" { const allocator = std.testing.allocator; var resources = TestResources{ .fail_updates = true }; @@ -630,3 +768,29 @@ test "LODVertexPool upload failure preserves existing renderable allocation" { test "LODVertexPool exposes compaction threshold helper" { _ = shouldCompactAfterEviction; } + +test "LODVertexPool defers range reuse until its frame slot completes" { + const allocator = std.testing.allocator; + var resources = TestResources{}; + var pool = LODVertexPool.init(allocator, .lod1, 1024); + defer pool.deinit(resources.resources()); + + var a = LODMesh.init(allocator, .lod1); + var b = LODMesh.init(allocator, .lod1); + try setPending(&a, allocator, 4); + try setPending(&b, allocator, 4); + try pool.uploadMesh(&a, resources.resources()); + try pool.uploadMesh(&b, resources.resources()); + const free_before = pool.freeBytes(); + + pool.destroyMeshDeferred(&a, 10, 0); + try std.testing.expectEqual(@as(usize, 1), pool.retired_ranges.items.len); + try std.testing.expectEqual(free_before, pool.freeBytes()); + + pool.collectRetired(10, 1); + try std.testing.expectEqual(@as(usize, 1), pool.retired_ranges.items.len); + pool.collectRetired(11, 0); + try std.testing.expectEqual(@as(usize, 0), pool.retired_ranges.items.len); + try std.testing.expect(pool.freeBytes() > free_before); + pool.destroyMesh(&b); +} diff --git a/modules/world-lod/src/root.zig b/modules/world-lod/src/root.zig index be92c203..0eb79223 100644 --- a/modules/world-lod/src/root.zig +++ b/modules/world-lod/src/root.zig @@ -23,6 +23,8 @@ pub const lod_vegetation = @import("lod_vegetation.zig"); pub const lod_vertex_pool = @import("lod_vertex_pool.zig"); pub const lod_stats = @import("lod_stats.zig"); pub const lod_store = @import("lod_store.zig"); +pub const lod_tile = @import("lod_tile.zig"); +pub const lod_compact_pool = @import("lod_compact_pool.zig"); pub const world_lod = @import("world_lod.zig"); pub const LODChunk = lod_chunk.LODChunk; @@ -41,5 +43,11 @@ pub const LODRenderLayer = lod_upload_queue.LODRenderLayer; pub const LODStreamingCoordinator = lod_streaming_coordinator.LODStreamingCoordinator; pub const LODState = lod_types.LODState; pub const LODStats = lod_stats.LODStats; +pub const LODProfilingSnapshot = lod_stats.LODProfilingSnapshot; +pub const CompactLODTile = lod_tile.CompactLODTile; +pub const CompactLODSample = lod_tile.CompactLODSample; +pub const CompactLODDecodedSample = lod_tile.DecodedSample; +pub const CompactLODTileByteMetrics = lod_tile.ByteMetrics; +pub const CompactLODTileEdge = lod_tile.TileEdge; pub const LODVertexPool = lod_vertex_pool.LODVertexPool; pub const WorldLOD = world_lod.WorldLOD; diff --git a/modules/world-lod/src/tests.zig b/modules/world-lod/src/tests.zig new file mode 100644 index 00000000..0605156d --- /dev/null +++ b/modules/world-lod/src/tests.zig @@ -0,0 +1,12 @@ +//! Dedicated world-lod test root. + +test { + _ = @import("lod_cache.zig"); + _ = @import("lod_manager_internal_tests.zig"); + _ = @import("lod_manager_tests.zig"); + _ = @import("lod_tile.zig"); + _ = @import("lod_compact_pool.zig"); + _ = @import("lod_mesh.zig"); + _ = @import("lod_vertex_pool.zig"); + _ = @import("lod_store.zig"); +} diff --git a/modules/world-persistence/src/region_file.zig b/modules/world-persistence/src/region_file.zig index 5b09795c..3642c41e 100644 --- a/modules/world-persistence/src/region_file.zig +++ b/modules/world-persistence/src/region_file.zig @@ -125,6 +125,7 @@ pub const RegionFile = struct { const total_len: u32 = @intCast(compressed.len + 1); const sectors_needed = (total_len + 4 + SECTOR_SIZE - 1) / SECTOR_SIZE; + if (sectors_needed > std.math.maxInt(u8)) return RegionError.FileTooShort; const idx = @as(u32, local_z) * 32 + @as(u32, local_x); const old_entry = self.header[idx]; diff --git a/modules/world-runtime/src/world.zig b/modules/world-runtime/src/world.zig index ef1700d3..8b231598 100644 --- a/modules/world-runtime/src/world.zig +++ b/modules/world-runtime/src/world.zig @@ -985,6 +985,14 @@ pub const World = struct { WorldOrchestration.render(self.renderer, self.streamer, lod_mgr, self.lod_enabled, view_proj, camera_pos, render_lod, .all); } + /// Render-graph prepass entry point: dispatch LOD compute before a graphics + /// 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); + } + } + /// Renders opaque terrain and world geometry for the current camera. /// Fluid and transparent passes are intentionally excluded. pub fn renderOpaque(self: *World, view_proj: Mat4, camera_pos: Vec3, render_lod: bool) void { @@ -1164,11 +1172,17 @@ pub const World = struct { }; const WORLD_RENDER_VIEW_VTABLE = GraphicsWorldRenderView.VTable{ + .prepareLODCulling = iprepareLODCulling, .render = irender, .renderOpaque = irenderOpaque, .renderFluid = irenderFluid, }; + fn iprepareLODCulling(ptr: *anyopaque, view_proj: Mat4, camera_pos: Vec3) void { + const self: *World = @ptrCast(@alignCast(ptr)); + self.prepareLODCulling(view_proj, camera_pos); + } + fn iupdate(ptr: *anyopaque, player_pos: Vec3, dt: f32) anyerror!void { const self: *World = @ptrCast(@alignCast(ptr)); return self.update(player_pos, dt); diff --git a/modules/world-runtime/src/world_facade_tests.zig b/modules/world-runtime/src/world_facade_tests.zig index e4940fcc..9fc68d37 100644 --- a/modules/world-runtime/src/world_facade_tests.zig +++ b/modules/world-runtime/src/world_facade_tests.zig @@ -85,6 +85,8 @@ const MockWorld = struct { cast(ptr).render_count += 1; } + fn prepareLODCulling(_: *anyopaque, _: math.Mat4, _: math.Vec3) void {} + fn renderOpaque(ptr: *anyopaque, view_proj: math.Mat4, camera_pos: math.Vec3, render_lod: bool) void { _ = view_proj; _ = camera_pos; diff --git a/modules/world-runtime/src/world_renderer.zig b/modules/world-runtime/src/world_renderer.zig index 3b9e84fd..39d8f505 100644 --- a/modules/world-runtime/src/world_renderer.zig +++ b/modules/world-runtime/src/world_renderer.zig @@ -13,6 +13,7 @@ const rhi_mod = @import("engine-rhi").rhi; const ResourceManager = rhi_mod.ResourceManager; const RenderContext = rhi_mod.RenderContext; const IDeviceQuery = rhi_mod.IDeviceQuery; +const IDeviceTiming = rhi_mod.IDeviceTiming; const LODManager = @import("world-lod").LODManager; const LODRenderLayer = @import("world-lod").LODRenderLayer; const math = @import("engine-math"); @@ -56,6 +57,11 @@ pub fn chooseGpuBlockCapacity(vram_mb: usize) usize { return @min(MAX_MDI_CHUNKS, max_by_budget); } +fn gpuBlockCapacityForBudgetMb(budget_mb: usize) usize { + const max_by_budget = (budget_mb * MB) / GPU_BLOCK_SLOT_SIZE; + return @min(MAX_MDI_CHUNKS, max_by_budget); +} + pub const RenderStats = struct { chunks_total: u32 = 0, chunks_rendered: u32 = 0, @@ -103,6 +109,7 @@ pub const WorldRenderer = struct { rm: ResourceManager, render_ctx: RenderContext, query: IDeviceQuery, + timing: IDeviceTiming, culling_screen_size: rhi_mod.RenderResolution, vertex_allocator: *GlobalVertexAllocator, @@ -153,8 +160,9 @@ pub const WorldRenderer = struct { const vram_bytes = query.getDeviceLocalVramBytes(); const vram_mb = vram_bytes / (1024 * 1024); - const vertex_capacity_mb = chooseVertexCapacityMb(vram_mb, strict_safe_mode); - const gpu_block_capacity = chooseGpuBlockCapacity(vram_mb); + const vertex_capacity_mb = runtime_env.envInt("ZIGCRAFT_VERTEX_CAPACITY_MB", chooseVertexCapacityMb(vram_mb, strict_safe_mode)); + const gpu_block_budget_mb = runtime_env.envInt("ZIGCRAFT_GPU_BLOCK_BUDGET_MB", 0); + const gpu_block_capacity = if (gpu_block_budget_mb > 0) gpuBlockCapacityForBudgetMb(gpu_block_budget_mb) else chooseGpuBlockCapacity(vram_mb); log.log.info("VRAM budget: {}MB | vertex_allocator: {}MB | gpu_block_buffer: {} slots", .{ vram_mb, vertex_capacity_mb, gpu_block_capacity }); @@ -221,6 +229,7 @@ pub const WorldRenderer = struct { .rm = rm, .render_ctx = render_ctx, .query = query, + .timing = rhi.timing(), .culling_screen_size = culling_screen_size, .vertex_allocator = vertex_allocator, .visible_chunks = .empty, @@ -321,10 +330,14 @@ pub const WorldRenderer = struct { if (render_lod) { if (lod_manager) |lod_mgr| { if (layer != .fluid) { - lod_mgr.render(view_proj, camera_pos, ChunkStorage.isChunkRenderable, @ptrCast(self.storage), true, null, LODRenderLayer.terrain); + self.timing.beginPassTiming("LODTerrainPass"); + lod_mgr.renderFrame(self.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkRenderable, @ptrCast(self.storage), true, null, LODRenderLayer.terrain); + self.timing.endPassTiming("LODTerrainPass"); } if (layer != .terrain and parseEnabledEnv(getenv("ZIGCRAFT_LOD_WATER"), true)) { - lod_mgr.render(view_proj, camera_pos, ChunkStorage.isChunkRenderable, @ptrCast(self.storage), true, null, LODRenderLayer.fluid); + self.timing.beginPassTiming("LODWaterPass"); + lod_mgr.renderFrame(self.frame_serial, view_proj, camera_pos, ChunkStorage.isChunkRenderable, @ptrCast(self.storage), true, null, LODRenderLayer.fluid); + self.timing.endPassTiming("LODWaterPass"); } } } diff --git a/phase5_benchmark_gate_tests.zig b/phase5_benchmark_gate_tests.zig new file mode 100644 index 00000000..bbc2cd6d --- /dev/null +++ b/phase5_benchmark_gate_tests.zig @@ -0,0 +1,159 @@ +//! Machine-checkable Phase 5 benchmark policy contract. +//! +//! Runtime thresholds live in `game-core/benchmark.zig`; the benchmark and LOD +//! quality-control documents are checked against that source of truth here. + +const std = @import("std"); +const benchmark = @import("game-core").benchmark; +const rhi = @import("engine-rhi"); + +const BENCHMARK_DOC = @embedFile("docs/benchmarks/README.md"); +const LOD_QUALITY_DOC = @embedFile("docs/lod-quality-controls.md"); + +const ScenarioContract = struct { + name: []const u8, + scenario: benchmark.Scenario, +}; + +const SCENARIOS = [_]ScenarioContract{ + .{ .name = "stationary", .scenario = .stationary }, + .{ .name = "traversal", .scenario = .traversal }, + .{ .name = "rapid-turn", .scenario = .rapid_turn }, + .{ .name = "teleport-eviction", .scenario = .teleport_eviction }, +}; + +test "Phase 5 recognizes exactly the four documented bounded scenarios" { + try std.testing.expectEqual(@as(usize, SCENARIOS.len), @typeInfo(benchmark.Scenario).@"enum".fields.len); + try std.testing.expect(std.mem.indexOf(u8, BENCHMARK_DOC, "deterministic, bounded camera path") != null); + + for (SCENARIOS) |contract| { + const parsed = try benchmark.Scenario.parse(contract.name); + try std.testing.expectEqual(contract.scenario, parsed); + try std.testing.expectEqualStrings(contract.name, parsed.name()); + } + + try std.testing.expectError(error.InvalidBenchmarkScenario, benchmark.Scenario.parse("unbounded")); +} + +test "Phase 5 benchmark SLO table matches runtime thresholds" { + const presets = [_][]const u8{ "low", "medium", "high", "ultra", "extreme" }; + + for (presets) |preset| { + const documented = try benchmarkSloRow(preset); + const runtime = benchmark.thresholdsForPreset(preset); + + try std.testing.expectEqual(runtime.fps_p1_min, documented[0]); + try std.testing.expectEqual(runtime.max_frame_ms, documented[1]); + try std.testing.expectEqual(runtime.draw_calls_max, documented[2]); + try std.testing.expectEqual(runtime.vertices_max, documented[3]); + try std.testing.expectEqual(runtime.gpu_memory_mb_max, documented[4]); + } +} + +test "Phase 5 LOD preset budget table matches runtime configuration" { + inline for (@typeInfo(rhi.RenderDistancePreset).@"enum".fields) |field| { + const preset: rhi.RenderDistancePreset = @enumFromInt(field.value); + const documented = try lodBudgetRow(field.name); + const runtime = rhi.getPresetConfig(preset); + + try std.testing.expectEqual(runtime.horizon_radius, documented.horizon_radius); + try std.testing.expectEqual(runtime.horizontal_detail, documented.horizontal_detail); + try std.testing.expectEqual(runtime.vertical_span_budget, documented.vertical_span_budget); + try std.testing.expectEqual(runtime.memory_budget_mb, documented.memory_budget_mb); + try std.testing.expectEqual(runtime.lod_store_size_cap_mb, documented.store_size_cap_mb); + try std.testing.expectEqual(runtime.max_uploads_per_frame, documented.max_uploads_per_frame); + } +} + +fn benchmarkSloRow(preset: []const u8) ![5]f64 { + const row = findTableRow(BENCHMARK_DOC, preset) orelse return error.DocumentedPresetMissing; + if (row.count != 6) return error.InvalidDocumentedPreset; + const cells = row.cells[0..row.count]; + + return .{ + try parseDocumentNumber(cells[1]), + try parseDocumentNumber(cells[2]), + try parseDocumentNumber(cells[3]), + try parseDocumentNumber(cells[4]), + try parseDocumentNumber(cells[5]), + }; +} + +const LodBudget = struct { + horizon_radius: i32, + horizontal_detail: [5]u32, + vertical_span_budget: u8, + memory_budget_mb: u32, + store_size_cap_mb: u32, + max_uploads_per_frame: u32, +}; + +fn lodBudgetRow(preset: []const u8) !LodBudget { + const row = findTableRow(LOD_QUALITY_DOC, preset) orelse return error.DocumentedPresetMissing; + if (row.count != 7) return error.InvalidDocumentedPreset; + const cells = row.cells[0..row.count]; + + return .{ + .horizon_radius = @intFromFloat(try parseDocumentNumber(cells[1])), + .horizontal_detail = try parseDetailBudget(cells[2]), + .vertical_span_budget = @intFromFloat(try parseDocumentNumber(cells[3])), + .memory_budget_mb = @intFromFloat(try parseDocumentNumber(cells[4])), + .store_size_cap_mb = @intFromFloat(try parseDocumentNumber(cells[5])), + .max_uploads_per_frame = @intFromFloat(try parseDocumentNumber(cells[6])), + }; +} + +const TableRow = struct { + cells: [8][]const u8, + count: usize, +}; + +/// Returns a Markdown table's cells, excluding its leading and trailing pipes. +fn findTableRow(document: []const u8, preset: []const u8) ?TableRow { + var lines = std.mem.splitScalar(u8, document, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (!std.mem.startsWith(u8, trimmed, "|")) continue; + + var cells: [8][]const u8 = undefined; + var count: usize = 0; + var fields = std.mem.splitScalar(u8, trimmed, '|'); + _ = fields.next(); // Leading pipe. + while (fields.next()) |field| { + const cell = std.mem.trim(u8, field, " \t\r"); + if (cell.len == 0) continue; // Trailing pipe. + if (count == cells.len) break; + cells[count] = cell; + count += 1; + } + + if (count > 0 and std.ascii.eqlIgnoreCase(cells[0], preset)) return .{ .cells = cells, .count = count }; + } + return null; +} + +/// Accepts human-readable table values such as `3,500,000` and `2,200 MB`. +fn parseDocumentNumber(value: []const u8) !f64 { + var normalized: [64]u8 = undefined; + var len: usize = 0; + for (value) |byte| { + if (std.ascii.isDigit(byte) or byte == '.' or byte == '-') { + if (len == normalized.len) return error.DocumentedNumberTooLong; + normalized[len] = byte; + len += 1; + } + } + if (len == 0) return error.InvalidDocumentedNumber; + return std.fmt.parseFloat(f64, normalized[0..len]); +} + +fn parseDetailBudget(value: []const u8) ![5]u32 { + var detail: [5]u32 = undefined; + var fields = std.mem.splitScalar(u8, value, '/'); + for (&detail) |*entry| { + const field = fields.next() orelse return error.InvalidDetailBudget; + entry.* = @intFromFloat(try parseDocumentNumber(field)); + } + if (fields.next() != null) return error.InvalidDetailBudget; + return detail; +} diff --git a/scripts/check_phase5_benchmark_config.sh b/scripts/check_phase5_benchmark_config.sh new file mode 100644 index 00000000..c5d1feb6 --- /dev/null +++ b/scripts/check_phase5_benchmark_config.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Validates the build-time benchmark scenario contract without starting Vulkan. +set -euo pipefail + +scenarios=(stationary traversal rapid-turn teleport-eviction) + +for scenario in "${scenarios[@]}"; do + zig build -Dbenchmark-scenario="$scenario" --help >/dev/null +done + +if zig build -Dbenchmark-scenario=unbounded --help >/dev/null 2>&1; then + printf 'benchmark build configuration accepted an unbounded scenario\n' >&2 + exit 1 +fi diff --git a/scripts/check_phase5_visual_smoke.py b/scripts/check_phase5_visual_smoke.py new file mode 100644 index 00000000..fb2eb707 --- /dev/null +++ b/scripts/check_phase5_visual_smoke.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Reject empty Phase 5 captures and catastrophic compact-path divergence. + +This deliberately compares coarse image statistics rather than a golden image: +the two capture modes may differ at far LOD boundaries, across Vulkan drivers, +or as lighting evolves, while a black frame or a wholly different scene is +always actionable. +""" + +import json +import os +import struct +import sys +import zlib + + +def read_png(path): + with open(path, "rb") as source: + data = source.read() + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError("not a PNG") + + offset = 8 + chunks = [] + width = height = color_type = bit_depth = None + while offset + 12 <= len(data): + length = struct.unpack(">I", data[offset:offset + 4])[0] + kind = data[offset + 4:offset + 8] + payload = data[offset + 8:offset + 8 + length] + if len(payload) != length: + raise ValueError("truncated PNG chunk") + offset += length + 12 + if kind == b"IHDR": + width, height, bit_depth, color_type, compression, filtering, interlace = struct.unpack(">IIBBBBB", payload) + if (bit_depth, color_type, compression, filtering, interlace) != (8, 2, 0, 0, 0): + raise ValueError("expected non-interlaced 8-bit RGB PNG") + elif kind == b"IDAT": + chunks.append(payload) + elif kind == b"IEND": + break + if not width or not height or not chunks: + raise ValueError("missing PNG image data") + + raw = zlib.decompress(b"".join(chunks)) + stride = width * 3 + if len(raw) != height * (stride + 1): + raise ValueError("unexpected PNG scanline size") + rows = [] + previous = bytearray(stride) + cursor = 0 + for _ in range(height): + filter_type = raw[cursor] + cursor += 1 + current = bytearray(raw[cursor:cursor + stride]) + cursor += stride + for index in range(stride): + left = current[index - 3] if index >= 3 else 0 + above = previous[index] + upper_left = previous[index - 3] if index >= 3 else 0 + if filter_type == 1: + current[index] = (current[index] + left) & 0xff + elif filter_type == 2: + current[index] = (current[index] + above) & 0xff + elif filter_type == 3: + current[index] = (current[index] + ((left + above) // 2)) & 0xff + elif filter_type == 4: + p = left + above - upper_left + pa, pb, pc = abs(p - left), abs(p - above), abs(p - upper_left) + predictor = left if pa <= pb and pa <= pc else (above if pb <= pc else upper_left) + current[index] = (current[index] + predictor) & 0xff + elif filter_type != 0: + raise ValueError("unsupported PNG filter") + rows.append(bytes(current)) + previous = current + return width, height, rows + + +def sampled_rgb(image, sample_width=96, sample_height=54): + width, height, rows = image + samples = [] + for y in range(sample_height): + source_y = min(height - 1, y * height // sample_height) + row = rows[source_y] + for x in range(sample_width): + source_x = min(width - 1, x * width // sample_width) + offset = source_x * 3 + samples.append((row[offset], row[offset + 1], row[offset + 2])) + return samples + + +def image_metrics(image): + samples = sampled_rgb(image) + luma = sorted((54 * red + 183 * green + 19 * blue) // 256 for red, green, blue in samples) + luma_bins = {value // 16 for value in luma} + non_black = sum(value > 12 for value in luma) / len(luma) + p05 = luma[len(luma) * 5 // 100] + p95 = luma[len(luma) * 95 // 100] + p01 = luma[len(luma) // 100] + p99 = luma[len(luma) * 99 // 100] + mean = sum(luma) / len(luma) + return { + "width": image[0], + "height": image[1], + "mean_luma": round(mean, 3), + "p05_luma": p05, + "p95_luma": p95, + "luma_range_p05_p95": p95 - p05, + "luma_range_p01_p99": p99 - p01, + "luma_bin_count": len(luma_bins), + "non_black_fraction": round(non_black, 5), + } + + +def normalized_mae(first, second): + first_samples = sampled_rgb(first) + second_samples = sampled_rgb(second) + return sum(abs(a - b) for first_pixel, second_pixel in zip(first_samples, second_samples) for a, b in zip(first_pixel, second_pixel)) / (len(first_samples) * 3 * 255) + + +def require_non_empty(name, metrics): + if metrics["non_black_fraction"] < float(os.environ.get("PHASE5_VISUAL_MIN_NON_BLACK", "0.02")): + raise ValueError(f"{name} is black or nearly black") + if metrics["luma_range_p01_p99"] < int(os.environ.get("PHASE5_VISUAL_MIN_LUMA_RANGE", "12")): + raise ValueError(f"{name} has insufficient image variation") + if metrics["luma_bin_count"] < int(os.environ.get("PHASE5_VISUAL_MIN_LUMA_BINS", "3")): + raise ValueError(f"{name} is visually empty") + + +def main(): + if len(sys.argv) != 4: + raise SystemExit(f"usage: {sys.argv[0]} off.png auto.png metrics.json") + off_path, auto_path, metrics_path = sys.argv[1:] + off = read_png(off_path) + auto = read_png(auto_path) + if off[:2] != auto[:2]: + raise ValueError(f"capture dimensions differ: {off[:2]} vs {auto[:2]}") + + metrics = {"off": image_metrics(off), "auto": image_metrics(auto)} + metrics["normalized_mae"] = round(normalized_mae(off, auto), 6) + # The same fixed seed can settle at a different far-LOD upload boundary on + # different drivers. Keep this intentionally broad: image health catches + # empty output, while this only rejects a scene-scale mismatch. + metrics["max_normalized_mae"] = float(os.environ.get("PHASE5_VISUAL_MAX_NMAE", "0.55")) + + # Preserve evidence even when the gate rejects the captures. + with open(metrics_path, "w", encoding="utf-8") as output: + json.dump(metrics, output, indent=2, sort_keys=True) + output.write("\n") + + require_non_empty("compact-off capture", metrics["off"]) + require_non_empty("compact-auto capture", metrics["auto"]) + if metrics["normalized_mae"] > metrics["max_normalized_mae"]: + raise ValueError("compact-auto capture diverges grossly from the maintained CPU fallback") + print(json.dumps(metrics, sort_keys=True)) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, zlib.error) as error: + print(f"Phase 5 visual smoke failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/run_phase5_visual_smoke.sh b/scripts/run_phase5_visual_smoke.sh new file mode 100644 index 00000000..43ee9aaa --- /dev/null +++ b/scripts/run_phase5_visual_smoke.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Captures a fixed world through the CPU fallback and compact auto paths, then +# applies stable image-health and coarse-divergence checks. No binary golden is +# required or maintained. +set -euo pipefail + +output_dir=${PHASE5_VISUAL_OUTPUT_DIR:-zig-out/phase5-visual-smoke} +frame=${PHASE5_VISUAL_SCREENSHOT_FRAME:-180} +delay=${PHASE5_VISUAL_SCREENSHOT_DELAY_SECONDS:-3} +mkdir -p "$output_dir" + +capture() { + local mode=$1 + local output=$2 + local capture_log=$3 + ZIGCRAFT_LOD_COMPACT="$mode" ZIGCRAFT_DISABLE_LOD_MDI=1 ZIGCRAFT_LOD_UPLOAD_BUDGET_MB=4 nix develop --command zig build run \ + -Dskip-present \ + -Dauto-world=flat \ + -Dauto-preset=medium \ + -Dscreenshot-path="$output" \ + -Dscreenshot-frame="$frame" \ + -Dscreenshot-delay-seconds="$delay" 2>&1 | tee "$capture_log" +} + +capture off "$output_dir/compact-off.png" "$output_dir/compact-off.log" +capture force "$output_dir/compact-auto.png" "$output_dir/compact-auto.log" +if ! grep -Eq 'COMPACT_CAPTURE: allocated=[1-9][0-9]*' "$output_dir/compact-auto.log"; then + echo "Phase 5 visual smoke failed: forced capture did not exercise compact residency" >&2 + exit 1 +fi +PHASE5_VISUAL_MIN_LUMA_BINS=2 PHASE5_VISUAL_MIN_LUMA_RANGE=4 PHASE5_VISUAL_MAX_NMAE=0.70 \ +python3 scripts/check_phase5_visual_smoke.py \ + "$output_dir/compact-off.png" \ + "$output_dir/compact-auto.png" \ + "$output_dir/metrics.json" diff --git a/src/game/app.zig b/src/game/app.zig index 8303a862..d3ba24ba 100644 --- a/src/game/app.zig +++ b/src/game/app.zig @@ -15,6 +15,7 @@ const InputMapper = @import("game-core").InputMapper; const RenderSystem = @import("engine-graphics").RenderSystem; const AudioSystemManager = @import("audio_system_manager.zig").AudioSystemManager; const BenchmarkRunner = @import("game-core").BenchmarkRunner; +const BENCHMARK_WORLD_SEED = @import("game-core").BENCHMARK_WORLD_SEED; const json_presets = @import("game-core").settings.json_presets; const SettingsManager = @import("game-core").SettingsManager; @@ -136,6 +137,11 @@ pub const App = struct { if (build_options.auto_preset.len > 0) { _ = applyNamedPreset(settings_manager.ptr(), build_options.auto_preset, "AUTO PRESET"); } + if (build_options.screenshot_path.len > 0) { + // Captures are regression artifacts, not a replay of persisted + // developer visualization toggles. + settings_manager.settings.wireframe_enabled = false; + } if (build_options.shadow_test_scene) { applyShadowTestPreset(settings_manager.ptr()); } @@ -200,9 +206,20 @@ pub const App = struct { var benchmark_runner: ?*BenchmarkRunner = null; if (build_options.benchmark) { + // Benchmarks include GPU pass breakdowns, including LOD passes. + render_system.getRHI().timing().setTimingEnabled(true); const runner = try allocator.create(BenchmarkRunner); const benchmark_duration_s: f32 = @as(f32, @floatFromInt(build_options.benchmark_duration)); - runner.* = try BenchmarkRunner.init(allocator, build_options.benchmark_preset, settings_manager.settings.render_distance, benchmark_duration_s, build_options.benchmark_output); + runner.* = try BenchmarkRunner.init( + allocator, + build_options.benchmark_preset, + build_options.benchmark_scenario, + settings_manager.settings.render_distance, + benchmark_duration_s, + BENCHMARK_WORLD_SEED, + build_options.benchmark_build_mode, + build_options.benchmark_output, + ); benchmark_runner = runner; } @@ -232,7 +249,7 @@ pub const App = struct { }; errdefer app.screen_manager.deinit(); - if (build_options.smoke_test or build_options.screenshot_path.len > 0 or build_options.benchmark) { + if (build_options.smoke_test or build_options.screenshot_path.len > 0) { app.render_system.getRHI().timing().setTimingEnabled(true); } @@ -253,7 +270,7 @@ pub const App = struct { } } else if (build_options.benchmark) { log.log.info("BENCHMARK MODE: Deferring world launch until swapchain settles", .{}); - app.pending_world_launch = .{ .seed = 12345, .generator_index = 0 }; + app.pending_world_launch = .{ .seed = BENCHMARK_WORLD_SEED, .generator_index = 0 }; if (runtime_env.strictSafeModeAutoEnabled()) app.direct_launch_resize_guard_frames = 240; } else if (resolveAutoWorldGenerator()) |generator_index| { log.log.info("AUTO WORLD MODE: Deferring '{s}' world launch until swapchain settles", .{build_options.auto_world}); @@ -479,7 +496,12 @@ pub const App = struct { self.pending_world_launch == null and stats.chunks_rendered > 0 and stats.gen_queue == 0 and stats.mesh_queue == 0 and stats.upload_queue == 0 else false; - if (!requires_world_ready or world_ready) { + // The world statistics can settle one frame before the render graph + // has emitted its first scene draw. Require a rendered frame as + // well; otherwise the headless UI-only fallback clears the output + // black and a screenshot can race that transient frame. + const rendered_frame = self.render_system.getRHI().query().getDrawCallCount() > 0; + if ((!requires_world_ready or world_ready) and (!requires_world_ready or rendered_frame)) { self.screenshot_settle_frames += 1; } else { self.screenshot_settle_frames = 0; @@ -494,28 +516,38 @@ pub const App = struct { } else |_| {} } + const capture_ready = self.smoke_test_frames >= target_frames and self.screenshot_settle_frames >= 30; const screenshot_delay_ready = build_options.screenshot_path.len > 0 and build_options.screenshot_delay_seconds > 0; - const should_finish = if (screenshot_delay_ready) blk: { - const target_loaded = self.pending_world_launch == null and self.screen_manager.stack.items.len > 0; - if (!target_loaded) break :blk false; - + const should_finish = if (screenshot_delay_ready and capture_ready) blk: { + // A delay is additional settling time, not a replacement for + // readiness. The old branch captured after wall time alone, + // often while the auto world was still displaying its black + // loading frame in unthrottled headless runs. const start = self.screenshot_delay_start orelse start: { self.screenshot_delay_start = self.time.elapsed; break :start self.time.elapsed; }; const delay_s: f32 = @floatFromInt(build_options.screenshot_delay_seconds); break :blk self.time.elapsed - start >= delay_s; - } else self.smoke_test_frames >= target_frames and self.screenshot_settle_frames >= 30; + } else capture_ready and !screenshot_delay_ready; - if (build_options.screenshot_path.len > 0 and requires_world_ready and self.smoke_test_frames >= target_frames + 1800 and self.screenshot_settle_frames < 30) { + // Headless frames are intentionally unthrottled. A frame-count + // timeout can therefore expire before generation workers receive + // meaningful CPU time; use a wall-time bound instead. + if (build_options.screenshot_path.len > 0 and requires_world_ready and self.time.elapsed >= 90.0 and !capture_ready) { return error.ScreenshotWorldNotReady; } if (should_finish) { if (build_options.screenshot_path.len > 0) { + if (world_stats) |stats| if (stats.lod) |lod| { + log.log.warn("COMPACT_CAPTURE: allocated={} capacity={}", .{ lod.compact_pool_allocated_bytes, lod.compact_pool_capacity_bytes }); + std.debug.print("COMPACT_CAPTURE: allocated={} capacity={}\n", .{ lod.compact_pool_allocated_bytes, lod.compact_pool_capacity_bytes }); + }; log.log.info("SCREENSHOT: Capturing frame to '{s}'", .{build_options.screenshot_path}); if (!self.render_system.getRHI().screenshot().captureFrame(build_options.screenshot_path)) { log.log.err("SCREENSHOT: Failed to capture screenshot", .{}); + return error.ScreenshotCaptureFailed; } } log.log.info("SMOKE TEST COMPLETE: {} frames rendered. Exiting.", .{target_frames}); diff --git a/src/game/player_tests.zig b/src/game/player_tests.zig index 77183901..43b5af55 100644 --- a/src/game/player_tests.zig +++ b/src/game/player_tests.zig @@ -304,8 +304,9 @@ test "Player block mutation errors are handled" { return undefined; } fn graphicsRenderView(ptr: *anyopaque) @import("engine-rhi").IWorldRenderView { - return .{ .ptr = ptr, .vtable = &.{ .render = render, .renderOpaque = render, .renderFluid = render } }; + return .{ .ptr = ptr, .vtable = &.{ .prepareLODCulling = prepareLODCulling, .render = render, .renderOpaque = render, .renderFluid = render } }; } + fn prepareLODCulling(_: *anyopaque, _: @import("engine-math").Mat4, _: Vec3) void {} fn getGpuMeshDispatch(_: *anyopaque) @import("world-runtime").GpuMeshDispatch { return .{ .dispatch_fn = null, .dispatch_ctx = null }; } diff --git a/src/integration_test_robustness.zig b/src/integration_test_robustness.zig index 5ac1aeea..fa287669 100644 --- a/src/integration_test_robustness.zig +++ b/src/integration_test_robustness.zig @@ -3,12 +3,10 @@ const testing = std.testing; const fs = @import("fs"); const c = @import("c").c; -pub fn main() !void { +pub fn main(init: std.process.Init) !void { std.debug.print("Running integration tests...\n", .{}); - var gpa: std.heap.DebugAllocator(.{}) = .init; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); + const allocator = init.gpa; // Find the robust-demo executable // Typically in zig-out/bin/robust-demo or similar @@ -18,7 +16,7 @@ pub fn main() !void { std.debug.print("Found robust-demo at: {s}\n", .{robust_demo_path}); // Run the demo - const run_result = try std.process.run(allocator, std.Options.debug_io, .{ + const run_result = try std.process.run(allocator, init.io, .{ .argv = &[_][]const u8{robust_demo_path}, .stdout_limit = .limited(4096), .stderr_limit = .limited(4096), @@ -27,14 +25,15 @@ pub fn main() !void { defer allocator.free(run_result.stderr); const stdout = run_result.stdout; + const stderr = run_result.stderr; const result = run_result.term; // Check exit code switch (result) { - .Exited => |code| { + .exited => |code| { if (code != 0) { std.debug.print("robust-demo failed with exit code {d}\n", .{code}); - std.debug.print("Output:\n{s}\n", .{stdout}); + std.debug.print("stdout:\n{s}\nstderr:\n{s}\n", .{ stdout, stderr }); return error.DemoFailed; } }, @@ -46,9 +45,9 @@ pub fn main() !void { // Verify expected output const expected_msg = "[SUCCESS] Command completed successfully. Robustness2 prevented device loss."; - if (std.mem.indexOf(u8, stdout, expected_msg) == null) { + if (std.mem.indexOf(u8, stdout, expected_msg) == null and std.mem.indexOf(u8, stderr, expected_msg) == null) { std.debug.print("robust-demo did not output expected success message.\n", .{}); - std.debug.print("Output:\n{s}\n", .{stdout}); + std.debug.print("stdout:\n{s}\nstderr:\n{s}\n", .{ stdout, stderr }); return error.VerificationFailed; } diff --git a/src/interface_mock_tests.zig b/src/interface_mock_tests.zig index 42e05aec..7976d0d2 100644 --- a/src/interface_mock_tests.zig +++ b/src/interface_mock_tests.zig @@ -293,6 +293,8 @@ const MockWorld = struct { self.last_render_lod = render_lod; } + fn prepareLODCulling(_: *anyopaque, _: Mat4, _: Vec3) void {} + fn renderOpaque(ptr: *anyopaque, view_proj: Mat4, camera_pos: Vec3, render_lod: bool) void { render(ptr, view_proj, camera_pos, render_lod); } @@ -471,7 +473,7 @@ const MockWorld = struct { } fn graphicsRenderView(ptr: *anyopaque) GraphicsWorldRenderView { - return .{ .ptr = ptr, .vtable = &.{ .render = render, .renderOpaque = renderOpaque, .renderFluid = renderFluid } }; + return .{ .ptr = ptr, .vtable = &.{ .prepareLODCulling = prepareLODCulling, .render = render, .renderOpaque = renderOpaque, .renderFluid = renderFluid } }; } fn getGpuMeshDispatch(ptr: *anyopaque) GpuMeshDispatch { diff --git a/src/tests.zig b/src/tests.zig index 798175ab..9ade546f 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -47,7 +47,9 @@ test { _ = @import("engine-graphics").utils_tests; _ = @import("vulkan_tests.zig"); _ = @import("engine-graphics").rhi_tests; + _ = @import("engine-graphics").lod_culling_system; _ = @import("engine-rhi").rhi_contract_tests; + _ = @import("engine-rhi").culling; _ = @import("engine-clouds").cloud_system; _ = @import("engine-shadows").shadow_cascade_tests; _ = @import("engine-graphics").shadow_tests; @@ -68,10 +70,6 @@ test { _ = @import("world-worldgen").terrain_modifier_tests; _ = @import("world-worldgen").terrain_shape_generator_tests; _ = @import("world-worldgen").terrain_report; - _ = @import("world-lod").lod_manager_tests; - _ = @import("world-lod").lod_manager_internal_tests; - _ = @import("world-lod").lod_seam; - _ = @import("world-lod").lod_renderer; _ = @import("engine-atmosphere").atmosphere_tests; _ = @import("game-core").settings_tests; _ = @import("game-core").input_settings;