diff --git a/.gitattributes b/.gitattributes index 462cafe3..4930b7db 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,9 @@ # Qualification fixtures are hashed byte-for-byte on every host. examples/quality-transparency/assets/transparent-quad.gltf text eol=lf examples/quality-masked/assets/masked-card.gltf text eol=lf + +# Embedded shader text, generated documentation contracts, and executable shell +# scripts must have the same bytes on Windows and Unix checkouts. +*.wgsl text eol=lf +*.md text eol=lf +*.sh text eol=lf diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index a63db977..e95bc6e7 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] schedule: - cron: "17 3 * * 1" workflow_dispatch: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 67450549..ad66e708 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,6 @@ name: Tests -# Runs on every PR to main, every push to main, and on version tag pushes +# Runs on every PR, every push to main, and on version tag pushes # (so release.yml can gate publish on a green Tests run for the exact SHA # being released). Mirrors the gating pattern from the Perry compiler repo. @@ -9,7 +9,6 @@ on: branches: [main] tags: ['v*'] pull_request: - branches: [main] concurrency: group: test-${{ github.ref }} @@ -88,6 +87,8 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 + with: + submodules: recursive - name: quick / contracts run: ./scripts/ci-check.sh --quick --component contracts - name: quick / example-inventory diff --git a/docs/evidence/issue-127-windows-vulkan-v1.json b/docs/evidence/issue-127-windows-vulkan-v1.json new file mode 100644 index 00000000..87cd10a0 --- /dev/null +++ b/docs/evidence/issue-127-windows-vulkan-v1.json @@ -0,0 +1,333 @@ +{ + "schema": "bloom-issue-127-windows-vulkan-v1", + "qualified_commit": "e8e08b90fc7cbad3e060d5ddb5c3ddb87aeb7add", + "git_dirty": false, + "adapter": { + "name": "AMD Radeon 760M Graphics", + "backend": "Vulkan", + "device_type": "IntegratedGpu", + "driver": "AMD proprietary driver", + "driver_info": "24.12.1 (LLPC)", + "supported_features": "Features { features_wgpu: FeaturesWGPU(SHADER_FLOAT32_ATOMIC | TEXTURE_FORMAT_16BIT_NORM | TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES | PIPELINE_STATISTICS_QUERY | TIMESTAMP_QUERY_INSIDE_ENCODERS | TIMESTAMP_QUERY_INSIDE_PASSES | MAPPABLE_PRIMARY_BUFFERS | TEXTURE_BINDING_ARRAY | BUFFER_BINDING_ARRAY | STORAGE_RESOURCE_BINDING_ARRAY | SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING | STORAGE_TEXTURE_ARRAY_NON_UNIFORM_INDEXING | PARTIALLY_BOUND_BINDING_ARRAY | MULTI_DRAW_INDIRECT_COUNT | ADDRESS_MODE_CLAMP_TO_ZERO | ADDRESS_MODE_CLAMP_TO_BORDER | POLYGON_MODE_LINE | POLYGON_MODE_POINT | CONSERVATIVE_RASTERIZATION | VERTEX_WRITABLE_STORAGE | CLEAR_TEXTURE | MULTIVIEW | TEXTURE_ATOMIC | TEXTURE_FORMAT_NV12 | TEXTURE_FORMAT_P010 | EXPERIMENTAL_RAY_QUERY | SHADER_F64 | SHADER_I16 | SHADER_EARLY_DEPTH_TEST | SHADER_INT64 | SUBGROUP | SUBGROUP_VERTEX | SUBGROUP_BARRIER | PIPELINE_CACHE | SHADER_INT64_ATOMIC_MIN_MAX | SHADER_INT64_ATOMIC_ALL_OPS | VULKAN_EXTERNAL_MEMORY_WIN32 | TEXTURE_INT64_ATOMIC | EXPERIMENTAL_MESH_SHADER | EXPERIMENTAL_RAY_HIT_VERTEX_RETURN | EXPERIMENTAL_MESH_SHADER_MULTIVIEW | EXTENDED_ACCELERATION_STRUCTURE_VERTEX_FORMATS | PASSTHROUGH_SHADERS | SHADER_BARYCENTRICS | SELECTIVE_MULTIVIEW | EXPERIMENTAL_MESH_SHADER_POINTS | MULTISAMPLE_ARRAY | EXPERIMENTAL_COOPERATIVE_MATRIX | SHADER_PER_VERTEX | SHADER_DRAW_INDEX | ACCELERATION_STRUCTURE_BINDING_ARRAY | MEMORY_DECORATION_COHERENT | MEMORY_DECORATION_VOLATILE), features_webgpu: FeaturesWebGPU(DEPTH_CLIP_CONTROL | DEPTH32FLOAT_STENCIL8 | TEXTURE_COMPRESSION_BC | TEXTURE_COMPRESSION_BC_SLICED_3D | TIMESTAMP_QUERY | INDIRECT_FIRST_INSTANCE | SHADER_F16 | RG11B10UFLOAT_RENDERABLE | BGRA8UNORM_STORAGE | FLOAT32_FILTERABLE | FLOAT32_BLENDABLE | DUAL_SOURCE_BLENDING | CLIP_DISTANCES | IMMEDIATES | PRIMITIVE_INDEX) }", + "enabled_features": "Features { features_wgpu: FeaturesWGPU(TEXTURE_BINDING_ARRAY | SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING | EXPERIMENTAL_RAY_QUERY), features_webgpu: FeaturesWebGPU(TIMESTAMP_QUERY) }" + }, + "canonical_runs": [ + { + "mode": "pt_progressive", + "repeat": 1, + "mean_rgba": 0.134697, + "mean_rgb": 0.179596, + "outlier_percent": 0.038147, + "max_error": 69, + "ssim": 0.997543544, + "render_ms": 1363 + }, + { + "mode": "pt_progressive", + "repeat": 2, + "mean_rgba": 0.134697, + "mean_rgb": 0.179596, + "outlier_percent": 0.038147, + "max_error": 69, + "ssim": 0.997543544, + "render_ms": 239 + }, + { + "mode": "pt_progressive", + "repeat": 3, + "mean_rgba": 0.134697, + "mean_rgb": 0.179596, + "outlier_percent": 0.038147, + "max_error": 69, + "ssim": 0.997543544, + "render_ms": 239 + }, + { + "mode": "pt_realtime_motion", + "repeat": 1, + "mean_rgba": 0.060707, + "mean_rgb": 0.080943, + "outlier_percent": 0.012207, + "max_error": 48, + "ssim": 0.999237806, + "render_ms": 70 + }, + { + "mode": "pt_realtime_motion", + "repeat": 2, + "mean_rgba": 0.060707, + "mean_rgb": 0.080943, + "outlier_percent": 0.012207, + "max_error": 48, + "ssim": 0.999237806, + "render_ms": 70 + }, + { + "mode": "pt_realtime_motion", + "repeat": 3, + "mean_rgba": 0.060707, + "mean_rgb": 0.080943, + "outlier_percent": 0.012207, + "max_error": 48, + "ssim": 0.999237806, + "render_ms": 71 + } + ], + "negative_controls": [ + { + "test": "pt_progressive", + "git_commit": "e8e08b90fc7cbad3e060d5ddb5c3ddb87aeb7add", + "os": "windows", + "arch": "x86_64", + "width": 256, + "height": 256, + "mean_abs_rgba": 5.307064056, + "mean_abs_rgb": 7.076085409, + "max_abs": 78, + "outlier_pixel_fraction": 0.000839233, + "outlier_channel_fraction": 0.000278473, + "gated_outlier_kind": "pixel", + "gated_outlier_fraction": 0.000839233, + "ssim_luminance": 0.995034659, + "seed": 0, + "sample_index_start": 0, + "camera_frame_start": 0, + "jitter_sequence": "disabled", + "fault_injection": "brdf-energy", + "repeat_index": 0, + "repeat_count": 1, + "frames": 300, + "spp": 296, + "render_time_ms": 241, + "adapter": { + "name": "AMD Radeon 760M Graphics", + "backend": "Vulkan", + "device_type": "IntegratedGpu", + "driver": "AMD proprietary driver", + "driver_info": "24.12.1 (LLPC)", + "supported_features": "Features { features_wgpu: FeaturesWGPU(SHADER_FLOAT32_ATOMIC | TEXTURE_FORMAT_16BIT_NORM | TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES | PIPELINE_STATISTICS_QUERY | TIMESTAMP_QUERY_INSIDE_ENCODERS | TIMESTAMP_QUERY_INSIDE_PASSES | MAPPABLE_PRIMARY_BUFFERS | TEXTURE_BINDING_ARRAY | BUFFER_BINDING_ARRAY | STORAGE_RESOURCE_BINDING_ARRAY | SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING | STORAGE_TEXTURE_ARRAY_NON_UNIFORM_INDEXING | PARTIALLY_BOUND_BINDING_ARRAY | MULTI_DRAW_INDIRECT_COUNT | ADDRESS_MODE_CLAMP_TO_ZERO | ADDRESS_MODE_CLAMP_TO_BORDER | POLYGON_MODE_LINE | POLYGON_MODE_POINT | CONSERVATIVE_RASTERIZATION | VERTEX_WRITABLE_STORAGE | CLEAR_TEXTURE | MULTIVIEW | TEXTURE_ATOMIC | TEXTURE_FORMAT_NV12 | TEXTURE_FORMAT_P010 | EXPERIMENTAL_RAY_QUERY | SHADER_F64 | SHADER_I16 | SHADER_EARLY_DEPTH_TEST | SHADER_INT64 | SUBGROUP | SUBGROUP_VERTEX | SUBGROUP_BARRIER | PIPELINE_CACHE | SHADER_INT64_ATOMIC_MIN_MAX | SHADER_INT64_ATOMIC_ALL_OPS | VULKAN_EXTERNAL_MEMORY_WIN32 | TEXTURE_INT64_ATOMIC | EXPERIMENTAL_MESH_SHADER | EXPERIMENTAL_RAY_HIT_VERTEX_RETURN | EXPERIMENTAL_MESH_SHADER_MULTIVIEW | EXTENDED_ACCELERATION_STRUCTURE_VERTEX_FORMATS | PASSTHROUGH_SHADERS | SHADER_BARYCENTRICS | SELECTIVE_MULTIVIEW | EXPERIMENTAL_MESH_SHADER_POINTS | MULTISAMPLE_ARRAY | EXPERIMENTAL_COOPERATIVE_MATRIX | SHADER_PER_VERTEX | SHADER_DRAW_INDEX | ACCELERATION_STRUCTURE_BINDING_ARRAY | MEMORY_DECORATION_COHERENT | MEMORY_DECORATION_VOLATILE), features_webgpu: FeaturesWebGPU(DEPTH_CLIP_CONTROL | DEPTH32FLOAT_STENCIL8 | TEXTURE_COMPRESSION_BC | TEXTURE_COMPRESSION_BC_SLICED_3D | TIMESTAMP_QUERY | INDIRECT_FIRST_INSTANCE | SHADER_F16 | RG11B10UFLOAT_RENDERABLE | BGRA8UNORM_STORAGE | FLOAT32_FILTERABLE | FLOAT32_BLENDABLE | DUAL_SOURCE_BLENDING | CLIP_DISTANCES | IMMEDIATES | PRIMITIVE_INDEX) }", + "enabled_features": "Features { features_wgpu: FeaturesWGPU(TEXTURE_BINDING_ARRAY | SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING | EXPERIMENTAL_RAY_QUERY), features_webgpu: FeaturesWebGPU(TIMESTAMP_QUERY) }" + } + }, + { + "test": "pt_realtime_motion", + "git_commit": "e8e08b90fc7cbad3e060d5ddb5c3ddb87aeb7add", + "os": "windows", + "arch": "x86_64", + "width": 256, + "height": 256, + "mean_abs_rgba": 6.41450119, + "mean_abs_rgb": 8.552668254, + "max_abs": 146, + "outlier_pixel_fraction": 0.060714722, + "outlier_channel_fraction": 0.044906616, + "gated_outlier_kind": "pixel", + "gated_outlier_fraction": 0.060714722, + "ssim_luminance": 0.910611715, + "seed": 0, + "sample_index_start": 0, + "camera_frame_start": 0, + "jitter_sequence": "disabled", + "fault_injection": "reprojection", + "repeat_index": 0, + "repeat_count": 1, + "frames": 48, + "spp": 1, + "render_time_ms": 66, + "adapter": { + "name": "AMD Radeon 760M Graphics", + "backend": "Vulkan", + "device_type": "IntegratedGpu", + "driver": "AMD proprietary driver", + "driver_info": "24.12.1 (LLPC)", + "supported_features": "Features { features_wgpu: FeaturesWGPU(SHADER_FLOAT32_ATOMIC | TEXTURE_FORMAT_16BIT_NORM | TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES | PIPELINE_STATISTICS_QUERY | TIMESTAMP_QUERY_INSIDE_ENCODERS | TIMESTAMP_QUERY_INSIDE_PASSES | MAPPABLE_PRIMARY_BUFFERS | TEXTURE_BINDING_ARRAY | BUFFER_BINDING_ARRAY | STORAGE_RESOURCE_BINDING_ARRAY | SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING | STORAGE_TEXTURE_ARRAY_NON_UNIFORM_INDEXING | PARTIALLY_BOUND_BINDING_ARRAY | MULTI_DRAW_INDIRECT_COUNT | ADDRESS_MODE_CLAMP_TO_ZERO | ADDRESS_MODE_CLAMP_TO_BORDER | POLYGON_MODE_LINE | POLYGON_MODE_POINT | CONSERVATIVE_RASTERIZATION | VERTEX_WRITABLE_STORAGE | CLEAR_TEXTURE | MULTIVIEW | TEXTURE_ATOMIC | TEXTURE_FORMAT_NV12 | TEXTURE_FORMAT_P010 | EXPERIMENTAL_RAY_QUERY | SHADER_F64 | SHADER_I16 | SHADER_EARLY_DEPTH_TEST | SHADER_INT64 | SUBGROUP | SUBGROUP_VERTEX | SUBGROUP_BARRIER | PIPELINE_CACHE | SHADER_INT64_ATOMIC_MIN_MAX | SHADER_INT64_ATOMIC_ALL_OPS | VULKAN_EXTERNAL_MEMORY_WIN32 | TEXTURE_INT64_ATOMIC | EXPERIMENTAL_MESH_SHADER | EXPERIMENTAL_RAY_HIT_VERTEX_RETURN | EXPERIMENTAL_MESH_SHADER_MULTIVIEW | EXTENDED_ACCELERATION_STRUCTURE_VERTEX_FORMATS | PASSTHROUGH_SHADERS | SHADER_BARYCENTRICS | SELECTIVE_MULTIVIEW | EXPERIMENTAL_MESH_SHADER_POINTS | MULTISAMPLE_ARRAY | EXPERIMENTAL_COOPERATIVE_MATRIX | SHADER_PER_VERTEX | SHADER_DRAW_INDEX | ACCELERATION_STRUCTURE_BINDING_ARRAY | MEMORY_DECORATION_COHERENT | MEMORY_DECORATION_VOLATILE), features_webgpu: FeaturesWebGPU(DEPTH_CLIP_CONTROL | DEPTH32FLOAT_STENCIL8 | TEXTURE_COMPRESSION_BC | TEXTURE_COMPRESSION_BC_SLICED_3D | TIMESTAMP_QUERY | INDIRECT_FIRST_INSTANCE | SHADER_F16 | RG11B10UFLOAT_RENDERABLE | BGRA8UNORM_STORAGE | FLOAT32_FILTERABLE | FLOAT32_BLENDABLE | DUAL_SOURCE_BLENDING | CLIP_DISTANCES | IMMEDIATES | PRIMITIVE_INDEX) }", + "enabled_features": "Features { features_wgpu: FeaturesWGPU(TEXTURE_BINDING_ARRAY | SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING | EXPERIMENTAL_RAY_QUERY), features_webgpu: FeaturesWebGPU(TIMESTAMP_QUERY) }" + } + } + ], + "focused_temporal_tests": { + "passed": 4, + "failed": 0, + "non_finite_hdr_pixels": 0, + "moving_texels": 855, + "retained_history_texels": 45, + "rejected_history_texels": 810, + "unclassified_moving_texels": 0, + "severe_trail_frames": 0, + "camera_cut_max_delta": 0, + "pt_toggle_max_delta": 0, + "non_seed_history_texels": 0 + }, + "baselines_changed": false, + "limitations": [ + "This is a PT correctness qualification, not an RTX 4080 or full nine-scene performance qualification.", + "The first broader golden batch failed its Vulkan HAL texture-counter gate and later silently skipped raster cases; it is not an accepted broad-corpus pass." + ], + "cpu_reference": { + "source": "0c5d1cc", + "unchanged_reference_source_since_oracle": true, + "metadata": { + "camera": { + "fov_y_deg": 50.0, + "position": [ + 5.0, + 4.0, + 7.0 + ], + "target": [ + 0.0, + 0.5, + 0.0 + ], + "up": [ + 0.0, + 1.0, + 0.0 + ] + }, + "environment": "bloom-pt-golden-analytic", + "height": 256, + "max_bounces": 8, + "output": "C:\\Users\\Ralph\\projects\\bloom\\engine-quality-radeon\\tools\\quality\\out\\windows-engine-plan\\pt-cpu-reference\\pt-reference.png", + "render_seconds": 2.0054651, + "renderer": "bloom-reference", + "scene": "", + "seed": 0, + "spp": 256, + "sun": { + "color": [ + 1.0, + 0.9800000190734863, + 0.9300000071525574 + ], + "direction_to_light": [ + 0.4319342076778412, + 0.8638684153556824, + 0.2591605484485626 + ], + "intensity": 1.2000000476837158 + }, + "triangles": 84, + "width": 256 + }, + "repeats": 2, + "pngs_byte_identical": true, + "sha256": "c0ef46e620f317a26fb695ce6b6e893abc90a81664bae8b71867503695423fee", + "visual_sanity": "Six cube placements, silhouettes, material-color ordering and shadow direction agree; model/display differences documented separately.", + "display_luminance_samples": { + "sample_box_size": 5, + "yellow": { + "xy": [ + 48, + 146 + ], + "cpu": 144.83124549, + "gpu": 133.37090365 + }, + "cyan": { + "xy": [ + 117, + 157 + ], + "cpu": 122.14981482, + "gpu": 116.6606463 + }, + "red": { + "xy": [ + 198, + 151 + ], + "cpu": 79.44307156, + "gpu": 89.05323387 + }, + "shadow_floor": { + "xy": [ + 86, + 176 + ], + "cpu": 152.91444424, + "gpu": 129.86561228 + }, + "nearby_floor": { + "xy": [ + 74, + 180 + ], + "cpu": 157.91949203, + "gpu": 132.45716602 + } + } + }, + "followup_qualification": { + "source": "dc3ca7eeb03938d0ec9ca1866f3ed4b66955195b", + "dirty_scope": "Documentation changes only; code and tests match the committed source.", + "canonical_runs": [ + { + "mode": "pt_progressive", + "repeat": 1, + "mean_rgba": 0.134697, + "mean_rgb": 0.179596, + "outlier_pixel_percent": 0.038147, + "max_error": 69, + "ssim": 0.997543544, + "render_ms": 274 + }, + { + "mode": "pt_progressive", + "repeat": 2, + "mean_rgba": 0.134697, + "mean_rgb": 0.179596, + "outlier_pixel_percent": 0.038147, + "max_error": 69, + "ssim": 0.997543544, + "render_ms": 273 + }, + { + "mode": "pt_progressive", + "repeat": 3, + "mean_rgba": 0.134697, + "mean_rgb": 0.179596, + "outlier_pixel_percent": 0.038147, + "max_error": 69, + "ssim": 0.997543544, + "render_ms": 269 + }, + { + "mode": "pt_realtime_motion", + "repeat": 1, + "mean_rgba": 0.060707, + "mean_rgb": 0.080943, + "outlier_pixel_percent": 0.012207, + "max_error": 48, + "ssim": 0.999237806, + "render_ms": 86 + }, + { + "mode": "pt_realtime_motion", + "repeat": 2, + "mean_rgba": 0.060707, + "mean_rgb": 0.080943, + "outlier_pixel_percent": 0.012207, + "max_error": 48, + "ssim": 0.999237806, + "render_ms": 86 + }, + { + "mode": "pt_realtime_motion", + "repeat": 3, + "mean_rgba": 0.060707, + "mean_rgb": 0.080943, + "outlier_pixel_percent": 0.012207, + "max_error": 48, + "ssim": 0.999237806, + "render_ms": 87 + } + ], + "negative_controls_rejected": [ + "brdf-energy", + "reprojection" + ], + "result": "pass", + "elapsed_seconds": 4.04 + } +} diff --git a/docs/evidence/issue-127-windows-vulkan-v1.md b/docs/evidence/issue-127-windows-vulkan-v1.md new file mode 100644 index 00000000..5d5797dc --- /dev/null +++ b/docs/evidence/issue-127-windows-vulkan-v1.md @@ -0,0 +1,99 @@ +# Issue #127 Windows/Vulkan path-tracing qualification + +The AMD Radeon 760M passes the canonical PT hardware oracle and all four focused +temporal tests on Windows/Vulkan at clean source +`e8e08b90fc7cbad3e060d5ddb5c3ddb87aeb7add`. Native adapter metadata identifies +`AMD Radeon 760M Graphics`, `IntegratedGpu`, AMD driver `24.12.1 (LLPC)`, with +hardware ray query enabled. No approved golden, tolerance, or shader was changed. + +## Canonical oracle + +```powershell +$env:BLOOM_REQUIRE_RAY_QUERY = '1' +$env:BLOOM_GOLDEN_DIAGNOSTICS = '1' +cargo test --release --target-dir native/windows/target ` + --manifest-path native/shared/Cargo.toml --test golden_render ` + qualify_pt_oracle_hardware -- --ignored --exact --nocapture +``` + +The test passed with a real Vulkan ray-query device. It retains one device for +three progressive runs, three realtime camera-motion runs, and both fault controls. + +| Mode | Repeats | Mean RGBA | Outlier pixels | Max error | SSIM | Render milliseconds | +| --- | --- | --- | --- | --- | --- | --- | +| Progressive, 300 frames | 3 | 0.134697 | 0.038147% | 69 | 0.997543544 | 1363, 239, 239 | +| Realtime camera motion, 48 frames | 3 | 0.060707 | 0.012207% | 48 | 0.999237806 | 70, 70, 71 | + +Every image metric is identical across the three repeats. These short test +durations include different warm-up conditions and are not performance budgets. + +Both seeded faults were rejected: BRDF energy produced mean error 5.307064 above +4.0; reprojection produced mean error 6.414501 above 6.0 and 6.071472% outlier +pixels above 1.0%. Their expected panic diagnostics are caught by the oracle; +the overall test exits zero only after normal runs pass and both faults fail. + +The archive preserves normal accumulated/denoised output and intermediates +(radiance, albedo, normal, depth, visibility, motion, history, variance), plus +separate negative-control expected/actual/diff/heatmap images and JSON. Visual +inspection of both normal outputs found coherent geometry and illumination, +without black workgroup blocks or persistent block trails. Black background +outside scene geometry is intentional in this fixture. + +## Focused temporal corpus + +```powershell +$env:BLOOM_KEEP_TEMPORAL_DIAGNOSTICS = '1' +cargo test --release --target-dir native/windows/target ` + --manifest-path native/shared/Cargo.toml --test golden_render ` + realtime_path_tracing -- --nocapture --test-threads=1 +``` + +All four tests passed. SVGF reported 10,984 accepted-history texels, 10,989 valid +and accumulated reprojections, and zero non-finite HDR pixels. All 855 moving +texels were classified: 45 retained history and 810 rejected it. Rigid motion +had zero severe-trail frames and zero frame-four coherent outliers. Camera reset +and PT off/on reproduced fresh seeds byte-for-byte. Lighting changes in both +directions had zero frame-12 coherent outliers. + +This supplies the Vulkan hardware evidence missing from the existing +[Metal report](issue-127-pt-motion-requalification-v1.md). It does not establish +a pass for the full nine-scene quality corpus or the RTX 4080 profile. A later +broader golden batch found a Vulkan HAL counter defect and raster adapter skips; +that batch is explicitly not accepted as a broad-corpus pass. + +Machine-readable metrics: [issue-127-windows-vulkan-v1.json](issue-127-windows-vulkan-v1.json). +Raw commands, logs, and captures are preserved in +`tools/quality/out/windows-engine-plan/issue-127-windows-vulkan-v1.zip` with a +SHA-256 sidecar. Publication and the complete issue acceptance audit remain +separate from these passing local hardware checks. + +## CPU reference sanity check + +The procedural `pt-golden` CPU reference was rendered twice at 256 x 256, +256 spp, eight bounces, seed zero, camera `(5,4,7)` toward `(0,0.5,0)`, 50-degree +vertical FOV, and sun direction `(0.5,1,0.3)` at intensity 1.2. Both PNGs are +byte-identical, SHA-256 +`c0ef46e620f317a26fb695ce6b6e893abc90a81664bae8b71867503695423fee`. +The scene has 84 triangles and four materials. The first render took 2.0054651 +seconds, excluding compilation. Command and metadata are retained under +`tools/quality/out/windows-engine-plan/pt-cpu-reference/`. + +Visual comparison with the accepted GPU progressive output agrees on all six +cube placements, silhouettes, material colors, and shadow direction. Five-pixel +square samples inside front-facing yellow/cyan/red surfaces preserve the same +display-luminance ordering: CPU 144.83/122.15/79.44, GPU 133.37/116.66/89.05. +A floor sample beside the front cube's shadow is darker than a nearby lit floor +sample in both renders (CPU 152.91 versus 157.92; GPU 129.87 versus 132.46). +These display-space samples support an energy/occlusion sanity check, not a +cross-renderer numeric golden. + +The [documented model differences](../pt/PT-6-7-8-skinned-tlas-motion-oracle.md) +remain visible: the CPU renders the environment on primary misses, uses +environment NEE/MIS, and applies fixed ACES/sRGB; the GPU preserves raster sky +and uses its engine post pipeline. No baseline was changed for this comparison. + +The canonical oracle was also rerun after the renderer/harness fixes at code +commit `dc3ca7e` (only report/document edits were uncommitted). All six image +metric sets match the original clean run exactly; both negative controls are +rejected again. The full command finishes in 4.04 seconds. The follow-up log and +captures are retained under `tools/quality/out/windows-engine-plan/pt-final/`. diff --git a/docs/evidence/issue-153-radeon760m-windows-vulkan.md b/docs/evidence/issue-153-radeon760m-windows-vulkan.md index 721710b3..b797f223 100644 --- a/docs/evidence/issue-153-radeon760m-windows-vulkan.md +++ b/docs/evidence/issue-153-radeon760m-windows-vulkan.md @@ -59,7 +59,7 @@ A subsequent [owner-isolation investigation](issue-153-radeon760m-owner-isolatio - Reproducibility: PASS, 257/257 artifact comparisons byte-identical. - Repository file-line gate still fails in nine unchanged files at the issue's pinned base; none of the edited files is a new violation. The exact output is included. -[First final run](../../tools/quality/out/windows-radeon760m-vulkan-corrected/summary.html), [repeat](../../tools/quality/out/windows-radeon760m-vulkan-repeat/summary.html), [reproducibility result](../../tools/quality/out/windows-radeon760m-repro/result.json). +The [published evidence archive](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-153-radeon760m-20260910) contains the first final run (`windows-radeon760m-vulkan-corrected/summary.html`), repeat (`windows-radeon760m-vulkan-repeat/summary.html`), and reproducibility result (`windows-radeon760m-repro/result.json`). These generated files are not part of a source checkout. The ZIP preserves repository-relative paths, approved references, all complete run directories, early failed-capture diagnostics, console/build/test logs, host inventory, the manifest, and a source patch. Generated toolchain binaries and third-party scene assets are excluded; their versions/revisions and hashes are recorded for fetching them again. diff --git a/docs/evidence/windows-ssgi-stationary-v1.md b/docs/evidence/windows-ssgi-stationary-v1.md new file mode 100644 index 00000000..5ec619e5 --- /dev/null +++ b/docs/evidence/windows-ssgi-stationary-v1.md @@ -0,0 +1,85 @@ +# Windows/Vulkan stationary software SSGI + +The Radeon 760M exposed phase-dependent flicker in a stationary scene even though +scene color, depth, geometry, and the complete 16-phase radiance history were +unchanged. The first changing stage was the probe temporal/spatial accumulator. +A current eight-ray neighborhood repeatedly clamped the converged history and +fed that change into the following frame. + +The fix recognizes repeated software phases on the same world surface. Once all +16 phases repeat, the renderer uses the complete integral without the noisy +current-neighborhood clamp. Any changed phase revokes that state; geometry +reprojection and the existing responsive blend/clamp remain in effect during +changes. Current software integrals are explicitly rounded to their retained +RGBA16F precision before comparison and reduction, so texture-store rounding +cannot keep an otherwise identical phase from matching. The hardware cache's +coherence rules are preserved. No texture, buffer, pass, ray count, golden, +threshold, or existing performance budget is added or increased. + +## Correctness evidence + +Adapter: AMD Radeon 760M Graphics, Vulkan, integrated GPU, AMD 24.12.1 (LLPC), +Windows 11. The renderer fix is commit +`5499bc1057a726397f53aebc40c885f06a0d64f5`. The frozen comparison uses its shader +changes over `3767b23`; executable and shader hashes are retained in +`tools/quality/out/windows-engine-plan/ssgi-isolation/frozen/receipt.json`. + +| Static output | Previous shader SSIM | Fixed shader SSIM | +| --- | ---: | ---: | +| 256 x 256 | 0.998438092 | 1.0 | +| 1280 x 720 | 0.982902534 | 1.0 | + +The five independent runs at each size reproduce these image metrics. Raw ray +realizations still advance. In 34 raw frame captures, all 228 valid probes have +stationary integrals and reconstructed output; the 28 invalid/sky probes remain +invalid. Full-precision header and RGBA16F trace/history dumps retain the first +stage evidence. + +A new regression exercises bright, dim, and bright-again lighting without a +history reset between states. Each state's complete 16-phase output cycle is +byte-identical after 32 frames, and the returning bright state matches a fresh +64-frame reference exactly. Dimming changes mean RGB by 5.31909, ruling out +frozen or disabled GI. All captures contain finite HDR radiance. The previous +shader fails this same regression at the first stationary phase. + +The final golden batch passes 89 tests, with 4 explicitly ignored and 2 optional +cases returning for absent external Bistro inputs. There are no unavailable-GPU +skips. This includes the independent TAA check and lighting regression. Raw log: +`ssgi-isolation/frozen-independent-taa/golden-final.log` (122.82 seconds). +The shared library also passes 484 tests with one existing ignored test after +updating the two shader-source assertions for repeated phase validation. + +## Isolated GPU timing + +Five alternating before/after process pairs per size, each with 24 warm-up and +120 profiled frames. No other local GPU test ran concurrently. These figures +cover the sum of five probe passes, not complete frame time. Profiling uses +blocking GPU readback, so wall-clock throughput is not a performance claim. + +| Resolution | Before median (range), ms | After median (range), ms | Median delta | +| --- | --- | --- | --- | +| 256 x 256 | 0.097502 (0.095466-0.134262) | 0.108033 (0.105692-0.115772) | +0.010531 ms | +| 1280 x 720 | 0.388353 (0.382489-0.396046) | 0.432540 (0.428538-0.452480) | +0.044187 ms | + +Most of the change is in phase-owner validation in the temporal pass: medians +0.039791 to 0.049613 ms at 256 square, and 0.195805 to 0.239524 ms at HD. This is +about an 11% increase in probe-pass cost on this fixture. The initial cold +256-square before run has visibly larger timing noise; all observations are +retained. These measurements do not establish a different adapter's budget. + +## Open limits + +- The HD TAA-jitter check independently fails with both shaders: mean RGB is + 1.48060330 before and 1.48050926 after, above its existing 0.75 bound. SSIM is + 0.97931148 before and 0.97931067 after. Both shaders pass this check at 256 + square. The HD failure predates this fix and remains open; the static HD pass + is not a claim that HD temporal qualification is complete. +- The combined golden batch emitted invalid GPU timestamp totals. Its timing is + excluded from this comparison; profiler reliability still needs investigation. +- The two portable-baseline Windows discrepancies, representative Bistro motion, + other backends, and named RTX 4080 acceptance remain open. + +Raw logs, commands, frozen executables, shader sources, phase captures, and +machine-readable timing observations are under +`tools/quality/out/windows-engine-plan/ssgi-isolation/`. Existing approved +baselines and thresholds remain unchanged. diff --git a/docs/pt/PT-6-7-8-skinned-tlas-motion-oracle.md b/docs/pt/PT-6-7-8-skinned-tlas-motion-oracle.md index e562f73c..5d5df95f 100644 --- a/docs/pt/PT-6-7-8-skinned-tlas-motion-oracle.md +++ b/docs/pt/PT-6-7-8-skinned-tlas-motion-oracle.md @@ -230,7 +230,7 @@ floor/cube geometry, materials, camera, sun, and deterministic seed: ```shell cd tools/bloom-reference -cargo run --release -- \ +cargo run --release --bin bloom-reference -- \ --builtin pt-golden \ --out ../../native/shared/target/golden-artifacts/pt-reference.png \ --metadata ../../native/shared/target/golden-artifacts/pt-reference.json \ diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md new file mode 100644 index 00000000..22eadad2 --- /dev/null +++ b/docs/windows-engine-plan.md @@ -0,0 +1,64 @@ +# Engine completion plan + +User objective: work through the plan until all of it is finished. The working +host is Windows with an AMD Radeon 760M; an RTX 4080 is unavailable. This plan +preserves both the immediate qualification sequence and the unfinished engine +areas discussed on 2026-09-10. A completed local test does not close a broader +platform, packaging, usability, or hardware requirement. + +The existing renderer work is in draft PR #147. Windows execution fixes and the +first nine-scene Radeon evidence are in draft PR #154. Follow-up work starts at +`e8e08b90fc7cbad3e060d5ddb5c3ddb87aeb7add` on `codex/windows-engine-plan`. + +## Qualification and integration sequence + +| Work | Required completion evidence | Current state | +| --- | --- | --- | +| #127 Vulkan PT correctness | Three deterministic progressive and motion runs, both negative controls, finite intermediates, reset/lighting/rigid-motion checks, retained report | Canonical hardware gate, all four focused temporal tests, and CPU reference sanity check pass on Radeon/Vulkan; [report retained](evidence/issue-127-windows-vulkan-v1.md), archive publication pending | +| #128 Windows image discrepancies | Identify the first incorrect stage or document a reviewed backend-specific baseline decision; rerun the full strict corpus and reproducibility checks | Sponza and skinned/alpha still fail against portable baselines; original baseline source reproduces both failures | +| #135 / #149 temporal reconstruction | Enforced motion/producer/quality-preset corpus, representative scenes, fractional/native and frozen A/B timing, memory/resize checks, platform evidence | Device lifetime and resource gates repaired; [stationary SSGI fix](evidence/windows-ssgi-stationary-v1.md) passes 89 golden tests with 4 ignored and 2 optional external-input skips; complete-phase/lighting control and frozen A/B recorded. Existing HD TAA-jitter failure and invalid batch timestamps remain open | +| #140 integration gates | Same required local/hosted lanes pass on exact source; release package startup and all-example evidence | Local contracts, formatting, and 484 shared tests pass. Hosted CI passes all mobile target builds, native/web builds, and browser startup. Metal texture-byte counter availability was the last failure; its repair and final source are awaiting CI | +| #138 capability fallback | Actual constrained-adapter startup and relevant forced-tier corpus, truthful capability outputs | Existing implementation/evidence preserved; physical constrained-limit acceptance still needs proof | +| PR integration | Reviewable changes, passing required checks, full issue evidence, merge-ready rendering branch | #147, #154, and follow-up [#155](https://github.com/Bloom-Engine/engine/pull/155) remain drafts; no merge performed | + +## Engine work retained in scope + +Each linked issue's complete design, acceptance criteria, compatibility contract, +and verification commands remain required. The issue snapshots used for this +audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json`. + +| Work | Remaining outcome | +| --- | --- | +| #27 visibility shading | Full compatibility/effects/occlusion corpus; admitted-workload performance improvement without low-overdraw regression; integrated and discrete evidence before activation | +| #131 virtual geometry | Required discrete-adapter motion qualification with no holes, cracks, page flashes, or unbounded trails; preserve existing integrated-GPU evidence | +| #148 shared glTF instances | Deterministic placement/bounds/material verification, mirrored-transform and fallback coverage, full images and uncapped timing | +| #137 asset/world streaming | Async state machine, priorities, cancellation, bounded CPU/GPU memory and uploads, slow-IO/churn tests, HLOD/world activation, native/web/mobile paths | +| #141 API generation | One schema governing manifests/Rust/TypeScript/docs, safe collections and packed buffers, UTF-8, generated parity and migration coverage | +| #142 starter/examples, #74 startup | One-command native and browser starter, all canonical examples compiled, real startup/render smoke, validated manifests, actionable setup failures | +| #143 component facade | Optional components and lifecycle, deterministic fixed update, prefab round trip, ownership/cleanup and async-destruction safety, 10k-entity profile | +| #144 runtime UI | Retained layout/clip/scroll, focus and pointer capture, mouse/touch/keyboard/gamepad navigation, UTF-8 entry, accessibility/capabilities, DPI and 1k-widget tests | +| #145 packaging | Versioned shader-runtime dependencies, clean Windows install and shader compilation, missing-dependency behavior, UTF-8 round trips, validated platform packages | +| #153 original hardware contract | RTX 4080-specific strict qualification remains unperformed; Radeon observations do not satisfy this named hardware requirement | + +## Completion discipline + +- Preserve approved images, thresholds, existing performance budgets, and noise + bounds unless a separate reviewed change is justified. +- Require the intended adapter/backend and reject test infrastructure failures; + a silently skipped supported GPU test is not evidence of completion. +- Keep raw commands, exact source identity, outputs, captures, and artifact hashes + with each qualification report. +- Continue work that this machine can perform while hardware-specific items wait. +- Mark the overall plan complete only after every applicable issue requirement + above is proven against current code and external state. + +## Current next steps + +1. Retain the stationary software-SSGI evidence with the final #155 source. The + previous shader fails the new regression; the fix passes at measured cost. +2. Obtain passing hosted checks for the final #155 source and retain the exact + commits and run links. The combined golden batch also exposed invalid GPU + timestamp totals; investigate those before accepting batch timing evidence. +3. Resume the two Windows portable-baseline discrepancies and the representative + temporal/geometry corpus, then continue the engine work retained above. + Hardware-specific acceptance remains open while local work progresses. diff --git a/native/android/Cargo.lock b/native/android/Cargo.lock index e63992ca..d8c3e6f1 100644 --- a/native/android/Cargo.lock +++ b/native/android/Cargo.lock @@ -82,6 +82,26 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f09c37bc0e9f0924b7dae9988265ef3c76c88538f41a3b06caf4bed07cee5226" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bit-set" version = "0.9.1" @@ -144,11 +164,20 @@ dependencies = [ "sha2", ] +[[package]] +name = "bloom-scene-format" +version = "0.1.0" +dependencies = [ + "bincode", + "sha2", +] + [[package]] name = "bloom-shared" version = "0.1.0" dependencies = [ "bloom-geometry-format", + "bloom-scene-format", "bytemuck", "cmake", "earcutr", @@ -161,8 +190,10 @@ dependencies = [ "libc", "log", "minimp3", + "naga", "raw-window-handle", "serde_json", + "sha2", "web-sys", "wgpu", ] @@ -883,6 +914,7 @@ dependencies = [ "log", "num-traits", "once_cell", + "pp-rs", "rustc-hash", "spirv", "thiserror 2.0.18", @@ -1125,6 +1157,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "pp-rs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee" +dependencies = [ + "unicode-xid", +] + [[package]] name = "presser" version = "0.3.1" @@ -1515,6 +1556,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "urlencoding" version = "2.1.3" @@ -1527,6 +1580,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" diff --git a/native/ios/Cargo.lock b/native/ios/Cargo.lock index 2707f6bd..68a5374d 100644 --- a/native/ios/Cargo.lock +++ b/native/ios/Cargo.lock @@ -56,6 +56,26 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f09c37bc0e9f0924b7dae9988265ef3c76c88538f41a3b06caf4bed07cee5226" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bit-set" version = "0.9.1" @@ -115,11 +135,20 @@ dependencies = [ "wgpu", ] +[[package]] +name = "bloom-scene-format" +version = "0.1.0" +dependencies = [ + "bincode", + "sha2", +] + [[package]] name = "bloom-shared" version = "0.1.0" dependencies = [ "bloom-geometry-format", + "bloom-scene-format", "bytemuck", "cmake", "earcutr", @@ -132,8 +161,10 @@ dependencies = [ "libc", "log", "minimp3", + "naga", "raw-window-handle", "serde_json", + "sha2", "web-sys", "wgpu", ] @@ -820,6 +851,7 @@ dependencies = [ "log", "num-traits", "once_cell", + "pp-rs", "rustc-hash", "spirv", "thiserror 2.0.18", @@ -996,6 +1028,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "pp-rs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee" +dependencies = [ + "unicode-xid", +] + [[package]] name = "presser" version = "0.3.1" @@ -1312,6 +1353,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "urlencoding" version = "2.1.3" @@ -1324,6 +1377,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "wasm-bindgen" version = "0.2.122" diff --git a/native/shared/shaders/virtual_geometry/traversal.wgsl b/native/shared/shaders/virtual_geometry/traversal.wgsl new file mode 100644 index 00000000..bd306863 --- /dev/null +++ b/native/shared/shaders/virtual_geometry/traversal.wgsl @@ -0,0 +1,822 @@ + +const NO_RELATION: u32 = 0xffffffffu; +const ALL_SOURCE_MESHES: u32 = 0xffffffffu; +// Keep private per-invocation storage bounded tightly enough to remain in fast +// GPU local storage. Overflow retains the complete resident parent, so this is +// a quality fallback rather than a coverage failure. +const TRAVERSAL_GROUP_STACK_CAPACITY: u32 = 32u; +const VIRTUAL_CLUSTER_UNIFORM_CHILD_RANGE: u32 = 0x08000000u; +const INSTANCE_CONE_CULL_SAFE: u32 = 1u; +const INSTANCE_PREVIOUS_HIZ_ELIGIBLE: u32 = 4u; + +struct GpuVirtualMeshEntry { + mesh_id: u32, + page_table_base: u32, + page_count: u32, + cluster_table_base: u32, + cluster_count: u32, + root_cluster_count: u32, + page_stride_bytes: u32, + vertex_encoding: u32, + format_version: u32, + flags: u32, + reserved: vec2, +}; +struct GpuVirtualPageEntry { + slot_plus_one: u32, + payload_bytes: u32, + mesh_id: u32, + flags: u32, +}; +struct GpuVirtualClusterEntry { + aabb_min_error: vec4, + aabb_max_radius: vec4, + sphere: vec4, + normal_cone: vec4, + identity: vec4, + page_lod_counts: vec4, + payload: vec4, + relations: vec4, +}; +struct GpuVirtualInstance { + model: mat4x4, + normal_rows: array, 3>, + instance_info: vec4, + root_span: vec4, + previous_model: mat4x4, + model_tint: vec4, +}; +struct GpuSelectedVirtualCluster { + mesh_id: u32, + instance_index: u32, + cluster_table_index: u32, + physical_page_base: u32, + lod_level: u32, + triangle_count: u32, + material_id: u32, + flags: u32, +}; +struct GpuVirtualPageRequest { + mesh_id: u32, + page_index: u32, + priority_bits: u32, + source_cluster: u32, +}; +struct GpuVirtualPageUse { + mesh_id: u32, + source_cluster: u32, + priority_bits: u32, +}; +struct MeshTable { records: array, }; +struct PageTable { records: array, }; +struct ClusterTable { records: array, }; +struct InstanceTable { records: array, }; +struct SelectedTable { records: array, }; +struct RequestTable { records: array, }; +struct PageUseTable { records: array, }; +struct TraversalCounters { + selected_count: atomic, + page_request_count: atomic, + visible_groups: atomic, + frustum_culled_groups: atomic, + cone_culled_clusters: atomic, + refined_groups: atomic, + fallback_groups: atomic, + missing_current_pages: atomic, + selected_overflow: atomic, + request_overflow: atomic, + invalid_records: atomic, + depth_limit_fallbacks: atomic, + occlusion_culled_groups: atomic, + occlusion_uncertain_groups: atomic, + page_use_count: atomic, + page_use_overflow: atomic, +}; +struct TraversalParams { + planes: array, 6>, + view_projection: mat4x4, + camera_projection: vec4, + thresholds: vec4, + dispatch: vec4, + limits: vec4, +}; +struct WorldSphere { + center: vec3, + radius: f32, +}; +struct HiZParams { + previous_view_projection: mat4x4, + previous_view: mat4x4, + current_view_projection: mat4x4, + current_view: mat4x4, + extent: vec4, + thresholds: vec4, +}; +struct ProjectedBounds { + uv_min: vec2, + uv_max: vec2, + nearest_depth: f32, + valid: u32, +}; + +@group(0) @binding(0) var meshes: MeshTable; +@group(0) @binding(1) var pages: PageTable; +@group(0) @binding(2) var clusters: ClusterTable; +@group(0) @binding(3) var instances: InstanceTable; +@group(0) @binding(4) var selected: SelectedTable; +@group(0) @binding(5) var requests: RequestTable; +@group(0) @binding(6) var counters: TraversalCounters; +@group(0) @binding(7) var page_uses: PageUseTable; +@group(0) @binding(8) var params: TraversalParams; +@group(1) @binding(0) var hiz_params: HiZParams; +@group(1) @binding(1) var hiz_0: texture_2d; +@group(1) @binding(2) var hiz_1: texture_2d; +@group(1) @binding(3) var hiz_2: texture_2d; +@group(1) @binding(4) var hiz_3: texture_2d; +@group(1) @binding(5) var hiz_4: texture_2d; +@group(1) @binding(6) var hiz_5: texture_2d; +@group(1) @binding(7) var hiz_6: texture_2d; +@group(1) @binding(8) var hiz_7: texture_2d; +@group(1) @binding(9) var hiz_8: texture_2d; + +fn valid_cluster(mesh: GpuVirtualMeshEntry, local_index: u32) -> bool { + return local_index < mesh.cluster_count + && mesh.cluster_table_base + local_index < arrayLength(&clusters.records); +} + +fn valid_page(mesh: GpuVirtualMeshEntry, local_index: u32) -> bool { + return local_index < mesh.page_count + && mesh.page_table_base + local_index < arrayLength(&pages.records); +} + +fn scale_bound(model: mat4x4) -> f32 { + let c0 = model[0].xyz; + let c1 = model[1].xyz; + let c2 = model[2].xyz; + let g0 = vec3(dot(c0, c0), dot(c0, c1), dot(c0, c2)); + let g1 = vec3(g0.y, dot(c1, c1), dot(c1, c2)); + let g2 = vec3(g0.z, g1.z, dot(c2, c2)); + let eigen_upper = max( + dot(abs(g0), vec3(1.0)), + max(dot(abs(g1), vec3(1.0)), dot(abs(g2), vec3(1.0))) + ); + return sqrt(max(eigen_upper, 0.0)); +} + +fn world_sphere( + cluster: GpuVirtualClusterEntry, + instance: GpuVirtualInstance, + scale: f32, +) -> WorldSphere { + return WorldSphere( + (instance.model * vec4(cluster.sphere.xyz, 1.0)).xyz, + cluster.aabb_max_radius.w * scale + ); +} + +fn project_hiz_bounds( + local_min: vec3, + local_max: vec3, + model: mat4x4, + view_projection: mat4x4, + view: mat4x4, +) -> ProjectedBounds { + var uv_min = vec2(1.0e30); + var uv_max = vec2(-1.0e30); + var nearest_depth = 1.0e30; + for (var corner = 0u; corner < 8u; corner++) { + let local = vec3( + select(local_min.x, local_max.x, (corner & 1u) != 0u), + select(local_min.y, local_max.y, (corner & 2u) != 0u), + select(local_min.z, local_max.z, (corner & 4u) != 0u) + ); + let world = model * vec4(local, 1.0); + let clip = view_projection * world; + if (clip.w <= 0.05 || clip.w != clip.w || any(abs(clip.xyz) > vec3(1.0e30))) { + return ProjectedBounds(uv_min, uv_max, 0.0, 0u); + } + let ndc = clip.xy / clip.w; + let uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5); + uv_min = min(uv_min, uv); + uv_max = max(uv_max, uv); + nearest_depth = min(nearest_depth, -(view * world).z); + } + if (nearest_depth != nearest_depth || nearest_depth <= 0.0) { + return ProjectedBounds(uv_min, uv_max, nearest_depth, 0u); + } + return ProjectedBounds(uv_min, uv_max, nearest_depth, 1u); +} + +fn hiz_depth(mip: u32, coordinate: vec2) -> f32 { + switch mip { + case 0u: { return textureLoad(hiz_0, coordinate, 0).r; } + case 1u: { return textureLoad(hiz_1, coordinate, 0).r; } + case 2u: { return textureLoad(hiz_2, coordinate, 0).r; } + case 3u: { return textureLoad(hiz_3, coordinate, 0).r; } + case 4u: { return textureLoad(hiz_4, coordinate, 0).r; } + case 5u: { return textureLoad(hiz_5, coordinate, 0).r; } + case 6u: { return textureLoad(hiz_6, coordinate, 0).r; } + case 7u: { return textureLoad(hiz_7, coordinate, 0).r; } + default: { return textureLoad(hiz_8, coordinate, 0).r; } + } +} + +// 0 = proven occluded, 1 = sampled and visible, 2 = uncertain/visible. +fn previous_hiz_group_result( + local_min: vec3, + local_max: vec3, + instance: GpuVirtualInstance, +) -> u32 { + if (hiz_params.extent.w == 0u + || (instance.instance_info.z & INSTANCE_PREVIOUS_HIZ_ELIGIBLE) == 0u) { + return 2u; + } + let previous = project_hiz_bounds( + local_min, + local_max, + instance.previous_model, + hiz_params.previous_view_projection, + hiz_params.previous_view + ); + let current = project_hiz_bounds( + local_min, + local_max, + instance.model, + hiz_params.current_view_projection, + hiz_params.current_view + ); + if (previous.valid == 0u || current.valid == 0u) { return 2u; } + if (previous.uv_max.x <= 0.0 || previous.uv_min.x >= 1.0 + || previous.uv_max.y <= 0.0 || previous.uv_min.y >= 1.0 + || current.uv_max.x <= 0.0 || current.uv_min.x >= 1.0 + || current.uv_max.y <= 0.0 || current.uv_min.y >= 1.0) { + return 2u; + } + let minimum_delta = abs(previous.uv_min - current.uv_min); + let maximum_delta = abs(previous.uv_max - current.uv_max); + let screen_delta = max( + max(minimum_delta.x, minimum_delta.y), + max(maximum_delta.x, maximum_delta.y) + ); + if (screen_delta > max(hiz_params.thresholds.x, hiz_params.thresholds.y)) { + return 2u; + } + + let expansion = hiz_params.thresholds.xy * 2.0; + let uv_min = clamp(min(previous.uv_min, current.uv_min) - expansion, vec2(0.0), vec2(1.0)); + let uv_max = clamp(max(previous.uv_max, current.uv_max) + expansion, vec2(0.0), vec2(1.0)); + let base_span = max( + (uv_max.x - uv_min.x) * f32(hiz_params.extent.x), + (uv_max.y - uv_min.y) * f32(hiz_params.extent.y) + ); + var mip = 0u; + var span = base_span; + while (span > 2.0 && mip + 1u < hiz_params.extent.z) { + span *= 0.5; + mip++; + } + let divisor = 1u << mip; + let dimensions = max( + vec2(1u), + (hiz_params.extent.xy + vec2(divisor - 1u)) / divisor + ); + let maximum_coordinate = vec2(dimensions - vec2(1u)); + let first = clamp(vec2(floor(uv_min * vec2(dimensions))), vec2(0), maximum_coordinate); + let last = clamp(vec2(floor(uv_max * vec2(dimensions))), vec2(0), maximum_coordinate); + var maximum_depth = 0.0; + for (var y = first.y; y <= last.y; y++) { + for (var x = first.x; x <= last.x; x++) { + maximum_depth = max(maximum_depth, hiz_depth(mip, vec2(x, y))); + } + } + let nearest_depth = min(previous.nearest_depth, current.nearest_depth); + let occluded = nearest_depth + > maximum_depth * (1.0 + hiz_params.thresholds.z) + hiz_params.thresholds.w; + return select(1u, 0u, occluded); +} + +fn cluster_frustum_outside_mask( + cluster: GpuVirtualClusterEntry, + instance: GpuVirtualInstance, + sphere: WorldSphere, +) -> u32 { + let local_center = (cluster.aabb_min_error.xyz + cluster.aabb_max_radius.xyz) * 0.5; + let local_extent = (cluster.aabb_max_radius.xyz - cluster.aabb_min_error.xyz) * 0.5; + let world_center = (instance.model * vec4(local_center, 1.0)).xyz; + var outside_mask = 0u; + for (var plane_index = 0u; plane_index < 6u; plane_index++) { + let plane = params.planes[plane_index]; + let projected_radius = dot( + abs(vec3( + dot(plane.xyz, instance.model[0].xyz), + dot(plane.xyz, instance.model[1].xyz), + dot(plane.xyz, instance.model[2].xyz) + )), + local_extent + ); + if (dot(plane.xyz, world_center) + plane.w < -projected_radius) { + outside_mask |= 1u << plane_index; + } + } + let clip = params.view_projection * vec4(sphere.center, 1.0); + let w_gradient = vec3( + params.view_projection[0].w, + params.view_projection[1].w, + params.view_projection[2].w + ); + let nearest_w = clip.w - sphere.radius * length(w_gradient); + if (nearest_w <= params.thresholds.z) { + outside_mask &= 0x30u; + } + return outside_mask; +} + +fn projected_error( + cluster: GpuVirtualClusterEntry, + sphere: WorldSphere, + scale: f32, +) -> f32 { + let world_error = cluster.aabb_min_error.w * scale; + if (world_error <= 0.0) { + return 0.0; + } + let clip = params.view_projection * vec4(sphere.center, 1.0); + let clip_w_gradient = vec3( + params.view_projection[0].w, + params.view_projection[1].w, + params.view_projection[2].w + ); + let nearest_w = clip.w - sphere.radius * length(clip_w_gradient); + // Preserve refinement at the near plane without collapsing every + // near-intersecting group onto one source-order tie. + return world_error * params.camera_projection.w / max(nearest_w, params.thresholds.z); +} + +fn cone_culled( + cluster: GpuVirtualClusterEntry, + instance: GpuVirtualInstance, + sphere: WorldSphere, +) -> bool { + let cutoff = cluster.normal_cone.w; + if (cutoff <= 0.0 || (instance.instance_info.z & INSTANCE_CONE_CULL_SAFE) == 0u) { + return false; + } + var axis = vec3( + dot(instance.normal_rows[0].xyz, cluster.normal_cone.xyz), + dot(instance.normal_rows[1].xyz, cluster.normal_cone.xyz), + dot(instance.normal_rows[2].xyz, cluster.normal_cone.xyz) + ); + let axis_length = length(axis); + let to_camera = params.camera_projection.xyz - sphere.center; + let distance = length(to_camera); + if (axis_length <= 1.0e-8 || distance <= sphere.radius || distance <= 1.0e-8) { + return false; + } + axis /= axis_length; + let view_direction = to_camera / distance; + let sin_theta = sqrt(max(1.0 - cutoff * cutoff, 0.0)); + let sin_phi = clamp(sphere.radius / distance, 0.0, 1.0); + let cos_phi = sqrt(max(1.0 - sin_phi * sin_phi, 0.0)); + let conservative_threshold = -(sin_theta * cos_phi + cutoff * sin_phi); + return dot(axis, view_direction) <= conservative_threshold; +} + +fn group_is_resident(mesh: GpuVirtualMeshEntry, first: u32, count: u32) -> bool { + for (var offset = 0u; offset < count; offset++) { + let local_cluster = first + offset; + if (!valid_cluster(mesh, local_cluster)) { + return false; + } + let cluster = clusters.records[mesh.cluster_table_base + local_cluster]; + let page_index = cluster.page_lod_counts.x; + if (!valid_page(mesh, page_index)) { + return false; + } + let page = pages.records[mesh.page_table_base + page_index]; + if (page.slot_plus_one == 0u || page.mesh_id != mesh.mesh_id || (page.flags & 1u) == 0u) { + return false; + } + } + return true; +} + +fn emit_missing_requests( + mesh: GpuVirtualMeshEntry, + first: u32, + count: u32, + priority_bits: u32, +) { + for (var offset = 0u; offset < count; offset++) { + let local_cluster = first + offset; + if (!valid_cluster(mesh, local_cluster)) { + atomicAdd(&counters.invalid_records, 1u); + continue; + } + let cluster = clusters.records[mesh.cluster_table_base + local_cluster]; + let page_index = cluster.page_lod_counts.x; + if (!valid_page(mesh, page_index)) { + atomicAdd(&counters.invalid_records, 1u); + continue; + } + let page = pages.records[mesh.page_table_base + page_index]; + if (page.slot_plus_one != 0u && page.mesh_id == mesh.mesh_id && (page.flags & 1u) != 0u) { + continue; + } + var duplicate = false; + for (var previous = 0u; previous < offset; previous++) { + let previous_cluster = clusters.records[mesh.cluster_table_base + first + previous]; + if (previous_cluster.page_lod_counts.x == page_index) { + duplicate = true; + break; + } + } + if (!duplicate) { + let output_index = atomicAdd(&counters.page_request_count, 1u); + if (output_index < params.dispatch.w) { + requests.records[output_index] = GpuVirtualPageRequest( + mesh.mesh_id, + page_index, + priority_bits, + first + ); + } else { + atomicAdd(&counters.request_overflow, 1u); + } + } + } +} + +fn select_group( + mesh: GpuVirtualMeshEntry, + instance_index: u32, + instance: GpuVirtualInstance, + first: u32, + count: u32, + scale: f32, + priority_bits: u32, +) { + // One final group identifies the entire selected hierarchy path. The CPU + // owns the validated archive metadata and protects this group plus every + // ancestor, avoiding an atomic feedback write for every intermediate group. + let page_use_index = atomicAdd(&counters.page_use_count, 1u); + if (page_use_index < params.dispatch.w) { + page_uses.records[page_use_index] = GpuVirtualPageUse( + mesh.mesh_id, + first, + priority_bits + ); + } else { + atomicAdd(&counters.page_use_overflow, 1u); + } + for (var offset = 0u; offset < count; offset++) { + let local_cluster = first + offset; + if (!valid_cluster(mesh, local_cluster)) { + atomicAdd(&counters.invalid_records, 1u); + continue; + } + let cluster = clusters.records[mesh.cluster_table_base + local_cluster]; + let sphere = world_sphere(cluster, instance, scale); + if (cluster_frustum_outside_mask(cluster, instance, sphere) != 0u) { + continue; + } + if (cone_culled(cluster, instance, sphere)) { + atomicAdd(&counters.cone_culled_clusters, 1u); + continue; + } + let page_index = cluster.page_lod_counts.x; + if (!valid_page(mesh, page_index)) { + atomicAdd(&counters.invalid_records, 1u); + continue; + } + let page = pages.records[mesh.page_table_base + page_index]; + if (page.slot_plus_one == 0u || page.mesh_id != mesh.mesh_id || (page.flags & 1u) == 0u) { + atomicAdd(&counters.missing_current_pages, 1u); + emit_missing_requests(mesh, local_cluster, 1u, priority_bits); + continue; + } + let output_index = atomicAdd(&counters.selected_count, 1u); + if (output_index < params.dispatch.z) { + selected.records[output_index] = GpuSelectedVirtualCluster( + mesh.mesh_id, + instance_index, + mesh.cluster_table_base + local_cluster, + (page.slot_plus_one - 1u) * mesh.page_stride_bytes, + cluster.page_lod_counts.y, + cluster.page_lod_counts.w, + cluster.identity.z, + (cluster.identity.w & ~VIRTUAL_CLUSTER_UNIFORM_CHILD_RANGE) + | (mesh.vertex_encoding << 28u) + ); + } else { + atomicAdd(&counters.selected_overflow, 1u); + } + } +} + +@compute @workgroup_size(64) +fn select_virtual_clusters(@builtin(global_invocation_id) gid: vec3) { + let instance_index = gid.y; + let root_ordinal = gid.x; + if (instance_index >= params.dispatch.x || root_ordinal >= params.dispatch.y) { + return; + } + if (instance_index >= arrayLength(&instances.records)) { + atomicAdd(&counters.invalid_records, 1u); + return; + } + let instance = instances.records[instance_index]; + if (root_ordinal >= instance.root_span.y) { + return; + } + let root_index = instance.root_span.x + root_ordinal; + let descriptor_index = instance.instance_info.x & 0xfffffu; + if (descriptor_index == 0u || descriptor_index - 1u >= arrayLength(&meshes.records)) { + atomicAdd(&counters.invalid_records, 1u); + return; + } + let mesh = meshes.records[descriptor_index - 1u]; + if (mesh.mesh_id != instance.instance_info.x || root_index >= mesh.root_cluster_count) { + return; + } + if (!valid_cluster(mesh, root_index)) { + atomicAdd(&counters.invalid_records, 1u); + return; + } + + let root = clusters.records[mesh.cluster_table_base + root_index]; + let source_mesh_filter = instance.instance_info.w; + if (source_mesh_filter != ALL_SOURCE_MESHES && root.identity.x != source_mesh_filter) { + return; + } + var root_group_first = root_index; + var root_group_count = 1u; + if (root.relations.w != 0u && valid_cluster(mesh, root.relations.z)) { + let first_child = clusters.records[mesh.cluster_table_base + root.relations.z]; + if (first_child.relations.x != NO_RELATION && first_child.relations.y != 0u) { + root_group_first = first_child.relations.x; + root_group_count = first_child.relations.y; + } + } + if (root_index != root_group_first) { + return; + } + + let scale = scale_bound(instance.model); + // Hierarchies branch whenever one coarse group replaces several lower + // atomic ranges. A bounded depth-first stack follows every branch in this + // invocation. Stack overflow fails closed to the complete resident parent, + // preserving coverage without another pass or allocation. + var group_stack: array, TRAVERSAL_GROUP_STACK_CAPACITY>; + var stack_count = 1u; + group_stack[0] = vec4( + root_group_first, + root_group_count, + bitcast(1.0e30), + 0u + ); + loop { + if (stack_count == 0u) { + break; + } + stack_count -= 1u; + let pending = group_stack[stack_count]; + let group_first = pending.x; + let group_count = pending.y; + let group_priority_bits = pending.z; + let depth = pending.w; + if (depth >= params.limits.x) { + atomicAdd(&counters.depth_limit_fallbacks, 1u); + select_group( + mesh, + instance_index, + instance, + group_first, + group_count, + scale, + group_priority_bits + ); + continue; + } + if (group_count == 0u || group_count > params.limits.y + || group_first + group_count > mesh.cluster_count) { + atomicAdd(&counters.invalid_records, 1u); + continue; + } + + var common_outside_mask = 0x3fu; + var has_intersecting_cluster = false; + var occlusion_visible = hiz_params.extent.w == 0u; + var occlusion_uncertain = false; + var intersecting_error = 0.0; + var group_error = 0.0; + var intersecting_min = vec3(1.0e30); + var intersecting_max = vec3(-1.0e30); + for (var offset = 0u; offset < group_count; offset++) { + let local_cluster = group_first + offset; + if (!valid_cluster(mesh, local_cluster)) { + atomicAdd(&counters.invalid_records, 1u); + continue; + } + let cluster = clusters.records[mesh.cluster_table_base + local_cluster]; + let sphere = world_sphere(cluster, instance, scale); + let outside_mask = cluster_frustum_outside_mask(cluster, instance, sphere); + let error = projected_error(cluster, sphere, scale); + common_outside_mask &= outside_mask; + group_error = max(group_error, error); + if (outside_mask == 0u) { + has_intersecting_cluster = true; + intersecting_error = max(intersecting_error, error); + intersecting_min = min(intersecting_min, cluster.aabb_min_error.xyz); + intersecting_max = max(intersecting_max, cluster.aabb_max_radius.xyz); + } + } + if (common_outside_mask != 0u) { + atomicAdd(&counters.frustum_culled_groups, 1u); + continue; + } + let maximum_error = select(group_error, intersecting_error, has_intersecting_cluster); + let refinement_priority_bits = bitcast(max(maximum_error, 0.0)); + if (hiz_params.extent.w != 0u) { + if (has_intersecting_cluster) { + let hiz_result = previous_hiz_group_result( + intersecting_min, + intersecting_max, + instance + ); + occlusion_visible = hiz_result != 0u; + occlusion_uncertain = hiz_result == 2u; + } else { + occlusion_visible = true; + occlusion_uncertain = true; + } + } + if (!occlusion_visible) { + atomicAdd(&counters.occlusion_culled_groups, 1u); + continue; + } + if (occlusion_uncertain) { + atomicAdd(&counters.occlusion_uncertain_groups, 1u); + } + atomicAdd(&counters.visible_groups, 1u); + let first_cluster = clusters.records[mesh.cluster_table_base + group_first]; + let uniform_child_range = + (first_cluster.identity.w & VIRTUAL_CLUSTER_UNIFORM_CHILD_RANGE) != 0u; + var child_group_count = 0u; + var previous_child_first = NO_RELATION; + var previous_child_count = 0u; + var has_children = false; + var has_terminal_clusters = false; + var children_valid = true; + var children_resident = true; + if (uniform_child_range) { + let child_first = first_cluster.relations.z; + let child_count = first_cluster.relations.w; + if (child_first == NO_RELATION || child_count == 0u) { + has_terminal_clusters = true; + } else if (child_count > params.limits.y + || child_first + child_count > mesh.cluster_count) { + children_valid = false; + } else { + has_children = true; + child_group_count += 1u; + children_resident = group_is_resident(mesh, child_first, child_count); + previous_child_first = child_first; + previous_child_count = child_count; + } + } else { + for (var offset = 0u; offset < group_count; offset++) { + let cluster = clusters.records[mesh.cluster_table_base + group_first + offset]; + let child_first = cluster.relations.z; + let child_count = cluster.relations.w; + if (child_first == NO_RELATION || child_count == 0u) { + has_terminal_clusters = true; + continue; + } + has_children = true; + if (child_count > params.limits.y + || child_first + child_count > mesh.cluster_count) { + children_valid = false; + continue; + } + if (child_first != previous_child_first || child_count != previous_child_count) { + child_group_count += 1u; + children_resident = children_resident + && group_is_resident(mesh, child_first, child_count); + previous_child_first = child_first; + previous_child_count = child_count; + } + } + } + if (!children_valid || (has_children && has_terminal_clusters)) { + atomicAdd(&counters.invalid_records, 1u); + select_group( + mesh, + instance_index, + instance, + group_first, + group_count, + scale, + group_priority_bits + ); + continue; + } + let wants_refinement = maximum_error > params.thresholds.x && has_children; + if (!wants_refinement) { + select_group( + mesh, + instance_index, + instance, + group_first, + group_count, + scale, + group_priority_bits + ); + continue; + } + if (!children_resident) { + atomicAdd(&counters.fallback_groups, 1u); + if (uniform_child_range) { + emit_missing_requests( + mesh, + first_cluster.relations.z, + first_cluster.relations.w, + refinement_priority_bits + ); + } else { + previous_child_first = NO_RELATION; + previous_child_count = 0u; + for (var offset = 0u; offset < group_count; offset++) { + let cluster = clusters.records[mesh.cluster_table_base + group_first + offset]; + let child_first = cluster.relations.z; + let child_count = cluster.relations.w; + if (child_first != NO_RELATION && child_count != 0u + && (child_first != previous_child_first + || child_count != previous_child_count)) { + emit_missing_requests( + mesh, + child_first, + child_count, + refinement_priority_bits + ); + previous_child_first = child_first; + previous_child_count = child_count; + } + } + } + select_group( + mesh, + instance_index, + instance, + group_first, + group_count, + scale, + group_priority_bits + ); + continue; + } + if (stack_count + child_group_count > TRAVERSAL_GROUP_STACK_CAPACITY) { + atomicAdd(&counters.depth_limit_fallbacks, 1u); + select_group( + mesh, + instance_index, + instance, + group_first, + group_count, + scale, + group_priority_bits + ); + continue; + } + atomicAdd(&counters.refined_groups, 1u); + if (uniform_child_range) { + group_stack[stack_count] = vec4( + first_cluster.relations.z, + first_cluster.relations.w, + refinement_priority_bits, + depth + 1u + ); + stack_count += 1u; + } else { + previous_child_first = NO_RELATION; + previous_child_count = 0u; + for (var offset = 0u; offset < group_count; offset++) { + let cluster = clusters.records[mesh.cluster_table_base + group_first + offset]; + let child_first = cluster.relations.z; + let child_count = cluster.relations.w; + if (child_first != NO_RELATION && child_count != 0u + && (child_first != previous_child_first + || child_count != previous_child_count)) { + group_stack[stack_count] = vec4( + child_first, + child_count, + refinement_priority_bits, + depth + 1u + ); + stack_count += 1u; + previous_child_first = child_first; + previous_child_count = child_count; + } + } + } + } +} diff --git a/native/shared/src/models.rs b/native/shared/src/models.rs index c722e1be..023cd6d1 100644 --- a/native/shared/src/models.rs +++ b/native/shared/src/models.rs @@ -1695,390 +1695,6 @@ impl ModelAnimation { } } -/// Sample one clip into a local TRS pose, rest pose as the fallback for -/// joints the clip does not animate. -fn sample_local_pose( - skeleton: &SkeletonData, - anim: &AnimationData, - time: f32, - strip_root: bool, -) -> LocalPose { - let joint_count = skeleton.joints.len(); - let mut t: Vec<[f32; 3]> = skeleton.joints.iter().map(|j| j.rest_translation).collect(); - let mut r: Vec<[f32; 4]> = skeleton.joints.iter().map(|j| j.rest_rotation).collect(); - let mut s: Vec<[f32; 3]> = skeleton.joints.iter().map(|j| j.rest_scale).collect(); - - let time = if anim.duration > 0.0 { - time.rem_euclid(anim.duration) - } else { - 0.0 - }; - - for channel in &anim.channels { - let ji = channel.joint_index; - if ji >= joint_count { - continue; - } - if !channel.translations.is_empty() && !channel.timestamps.is_empty() { - t[ji] = sample_vec3(&channel.timestamps, &channel.translations, time); - } - if !channel.rotations.is_empty() { - let ts = if !channel.rotation_timestamps.is_empty() { - &channel.rotation_timestamps - } else { - &channel.timestamps - }; - if !ts.is_empty() { - r[ji] = sample_quat(ts, &channel.rotations, time); - } - } - if !channel.scales.is_empty() { - let ts = if !channel.scale_timestamps.is_empty() { - &channel.scale_timestamps - } else { - &channel.timestamps - }; - if !ts.is_empty() { - s[ji] = sample_vec3(ts, &channel.scales, time); - } - } - } - - if strip_root && joint_count > 0 { - t[0] = skeleton.joints[0].rest_translation; - } - (t, r, s) -} - -/// The root joint's authored translation at `time` — the raw channel value, -/// *not* the rest-locked one, which is the whole point of root motion. -fn root_translation_at(skeleton: &SkeletonData, anim: &AnimationData, time: f32) -> [f32; 3] { - if skeleton.joints.is_empty() { - return [0.0; 3]; - } - let time = if anim.duration > 0.0 { - time.clamp(0.0, anim.duration) - } else { - 0.0 - }; - for channel in &anim.channels { - if channel.joint_index == 0 - && !channel.translations.is_empty() - && !channel.timestamps.is_empty() - { - return sample_vec3(&channel.timestamps, &channel.translations, time); - } - } - skeleton.joints[0].rest_translation -} - -/// `dst = lerp(dst, src, w * mask[j])`. Rotations use nlerp with a -/// hemisphere fix — without the dot-sign flip, two clips whose quaternions -/// land on opposite hemispheres blend the *long* way round and the limb -/// visibly swings through the body. -fn blend_pose(dst: &mut LocalPose, src: &LocalPose, w: f32, mask: Option<&[f32]>) { - let n = dst.0.len().min(src.0.len()); - for j in 0..n { - let jw = match mask { - Some(m) => w * m.get(j).copied().unwrap_or(0.0), - None => w, - }; - if jw <= 0.0 { - continue; - } - let jw = jw.min(1.0); - for k in 0..3 { - dst.0[j][k] = dst.0[j][k] + (src.0[j][k] - dst.0[j][k]) * jw; - dst.2[j][k] = dst.2[j][k] + (src.2[j][k] - dst.2[j][k]) * jw; - } - let a = dst.1[j]; - let mut b = src.1[j]; - let dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; - if dot < 0.0 { - b = [-b[0], -b[1], -b[2], -b[3]]; - } - let mut q = [ - a[0] + (b[0] - a[0]) * jw, - a[1] + (b[1] - a[1]) * jw, - a[2] + (b[2] - a[2]) * jw, - a[3] + (b[3] - a[3]) * jw, - ]; - let len = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt(); - if len > 1e-6 { - q = [q[0] / len, q[1] / len, q[2] / len, q[3] / len]; - } else { - q = a; - } - dst.1[j] = q; - } -} - -/// 1.0 for every joint at or below `root`, 0.0 elsewhere. `root < 0` means -/// "whole skeleton" so a layer with no mask is a plain full-body override. -fn build_mask_weights(skeleton: &SkeletonData, root: i32) -> Vec { - let n = skeleton.joints.len(); - if root < 0 || (root as usize) >= n { - return vec![1.0; n]; - } - let mut w = vec![0.0f32; n]; - let mut stack = vec![root as usize]; - while let Some(j) = stack.pop() { - if j >= n || w[j] > 0.0 { - continue; - } - w[j] = 1.0; - for &c in &skeleton.joints[j].children { - stack.push(c); - } - } - w -} - -// ============================================================ -// Matrix / quaternion helpers for skeletal animation -// ============================================================ - -fn mat4_identity() -> [[f32; 4]; 4] { - [ - [1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ] -} - -fn mat4_mul(a: &[[f32; 4]; 4], b: &[[f32; 4]; 4]) -> [[f32; 4]; 4] { - let mut out = [[0.0f32; 4]; 4]; - for col in 0..4 { - for row in 0..4 { - out[col][row] = a[0][row] * b[col][0] - + a[1][row] * b[col][1] - + a[2][row] * b[col][2] - + a[3][row] * b[col][3]; - } - } - out -} - -fn mat4_from_trs(t: &[f32; 3], r: &[f32; 4], s: &[f32; 3]) -> [[f32; 4]; 4] { - let (x, y, z, w) = (r[0], r[1], r[2], r[3]); - let x2 = x + x; - let y2 = y + y; - let z2 = z + z; - let xx = x * x2; - let xy = x * y2; - let xz = x * z2; - let yy = y * y2; - let yz = y * z2; - let zz = z * z2; - let wx = w * x2; - let wy = w * y2; - let wz = w * z2; - - // Column-major: m[col][row] - [ - [ - (1.0 - (yy + zz)) * s[0], - (xy + wz) * s[0], - (xz - wy) * s[0], - 0.0, - ], // column 0 - [ - (xy - wz) * s[1], - (1.0 - (xx + zz)) * s[1], - (yz + wx) * s[1], - 0.0, - ], // column 1 - [ - (xz + wy) * s[2], - (yz - wx) * s[2], - (1.0 - (xx + yy)) * s[2], - 0.0, - ], // column 2 - [t[0], t[1], t[2], 1.0], // column 3 (translation) - ] -} - -fn quat_slerp(a: &[f32; 4], b: &[f32; 4], t: f32) -> [f32; 4] { - let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; - let mut b2 = *b; - if dot < 0.0 { - dot = -dot; - b2 = [-b[0], -b[1], -b[2], -b[3]]; - } - if dot > 0.9995 { - let mut out = [ - a[0] + t * (b2[0] - a[0]), - a[1] + t * (b2[1] - a[1]), - a[2] + t * (b2[2] - a[2]), - a[3] + t * (b2[3] - a[3]), - ]; - let len = (out[0] * out[0] + out[1] * out[1] + out[2] * out[2] + out[3] * out[3]).sqrt(); - if len > 0.0 { - for v in &mut out { - *v /= len; - } - } - return out; - } - let theta = dot.acos(); - let sin_theta = theta.sin(); - let wa = ((1.0 - t) * theta).sin() / sin_theta; - let wb = (t * theta).sin() / sin_theta; - [ - wa * a[0] + wb * b2[0], - wa * a[1] + wb * b2[1], - wa * a[2] + wb * b2[2], - wa * a[3] + wb * b2[3], - ] -} - -fn lerp_vec3(a: &[f32; 3], b: &[f32; 3], t: f32) -> [f32; 3] { - [ - a[0] + t * (b[0] - a[0]), - a[1] + t * (b[1] - a[1]), - a[2] + t * (b[2] - a[2]), - ] -} - -fn find_keyframe_pair(timestamps: &[f32], time: f32) -> (usize, usize, f32) { - if timestamps.len() <= 1 { - return (0, 0, 0.0); - } - if time <= timestamps[0] { - return (0, 0, 0.0); - } - if time >= timestamps[timestamps.len() - 1] { - let last = timestamps.len() - 1; - return (last, last, 0.0); - } - for i in 0..timestamps.len() - 1 { - if time >= timestamps[i] && time < timestamps[i + 1] { - let dt = timestamps[i + 1] - timestamps[i]; - let t = if dt > 0.0 { - (time - timestamps[i]) / dt - } else { - 0.0 - }; - return (i, i + 1, t); - } - } - let last = timestamps.len() - 1; - (last, last, 0.0) -} - -fn sample_vec3(timestamps: &[f32], values: &[[f32; 3]], time: f32) -> [f32; 3] { - if values.is_empty() { - return [0.0; 3]; - } - if values.len() == 1 { - return values[0]; - } - let (i0, i1, t) = find_keyframe_pair(timestamps, time); - if i0 >= values.len() { - return values[values.len() - 1]; - } - if i1 >= values.len() { - return values[values.len() - 1]; - } - lerp_vec3(&values[i0], &values[i1], t) -} - -fn sample_quat(timestamps: &[f32], values: &[[f32; 4]], time: f32) -> [f32; 4] { - if values.is_empty() { - return [0.0, 0.0, 0.0, 1.0]; - } - if values.len() == 1 { - return values[0]; - } - let (i0, i1, t) = find_keyframe_pair(timestamps, time); - if i0 >= values.len() { - return values[values.len() - 1]; - } - if i1 >= values.len() { - return values[values.len() - 1]; - } - quat_slerp(&values[i0], &values[i1], t) -} - -fn compute_joint_transforms( - skeleton: &SkeletonData, - joint_idx: usize, - parent_transform: &[[f32; 4]; 4], - translations: &[[f32; 3]], - rotations: &[[f32; 4]], - scales: &[[f32; 3]], - world_transforms: &mut [[[f32; 4]; 4]], -) { - if joint_idx >= skeleton.joints.len() { - return; - } - let local = mat4_from_trs( - &translations[joint_idx], - &rotations[joint_idx], - &scales[joint_idx], - ); - let world = mat4_mul(parent_transform, &local); - world_transforms[joint_idx] = world; - let children = skeleton.joints[joint_idx].children.clone(); - for &child in &children { - compute_joint_transforms( - skeleton, - child, - &world, - translations, - rotations, - scales, - world_transforms, - ); - } -} - -// ---- Catmull-Rom spline helpers (Q9) ---- - -fn catmull_rom_point(points: &[f32], n: usize, segment: usize, t: f32) -> [f32; 3] { - // Indices: p0 = segment - 1, p1 = segment, p2 = segment + 1, p3 = segment + 2. - // Clamp at boundaries. - let i0 = if segment > 0 { segment - 1 } else { 0 }; - let i1 = segment; - let i2 = if segment + 1 < n { segment + 1 } else { n - 1 }; - let i3 = if segment + 2 < n { segment + 2 } else { n - 1 }; - - let p0 = [points[i0 * 3], points[i0 * 3 + 1], points[i0 * 3 + 2]]; - let p1 = [points[i1 * 3], points[i1 * 3 + 1], points[i1 * 3 + 2]]; - let p2 = [points[i2 * 3], points[i2 * 3 + 1], points[i2 * 3 + 2]]; - let p3 = [points[i3 * 3], points[i3 * 3 + 1], points[i3 * 3 + 2]]; - - let t2 = t * t; - let t3 = t2 * t; - let mut out = [0.0f32; 3]; - for k in 0..3 { - out[k] = 0.5 - * ((2.0 * p1[k]) - + (-p0[k] + p2[k]) * t - + (2.0 * p0[k] - 5.0 * p1[k] + 4.0 * p2[k] - p3[k]) * t2 - + (-p0[k] + 3.0 * p1[k] - 3.0 * p2[k] + p3[k]) * t3); - } - out -} - -fn update_bounds(bmin: &mut [f32; 3], bmax: &mut [f32; 3], x: f32, y: f32, z: f32) { - if x < bmin[0] { - bmin[0] = x; - } - if y < bmin[1] { - bmin[1] = y; - } - if z < bmin[2] { - bmin[2] = z; - } - if x > bmax[0] { - bmax[0] = x; - } - if y > bmax[1] { - bmax[1] = y; - } - if z > bmax[2] { - bmax[2] = z; - } -} +#[path = "models_animation_math.rs"] +mod animation_math; +use animation_math::*; diff --git a/native/shared/src/models_animation_math.rs b/native/shared/src/models_animation_math.rs new file mode 100644 index 00000000..623446eb --- /dev/null +++ b/native/shared/src/models_animation_math.rs @@ -0,0 +1,395 @@ +//! Local-pose sampling, transforms, and animation interpolation. + +use super::*; + +/// Sample one clip into a local TRS pose, rest pose as the fallback for +/// joints the clip does not animate. +pub(super) fn sample_local_pose( + skeleton: &SkeletonData, + anim: &AnimationData, + time: f32, + strip_root: bool, +) -> LocalPose { + let joint_count = skeleton.joints.len(); + let mut t: Vec<[f32; 3]> = skeleton.joints.iter().map(|j| j.rest_translation).collect(); + let mut r: Vec<[f32; 4]> = skeleton.joints.iter().map(|j| j.rest_rotation).collect(); + let mut s: Vec<[f32; 3]> = skeleton.joints.iter().map(|j| j.rest_scale).collect(); + + let time = if anim.duration > 0.0 { + time.rem_euclid(anim.duration) + } else { + 0.0 + }; + + for channel in &anim.channels { + let ji = channel.joint_index; + if ji >= joint_count { + continue; + } + if !channel.translations.is_empty() && !channel.timestamps.is_empty() { + t[ji] = sample_vec3(&channel.timestamps, &channel.translations, time); + } + if !channel.rotations.is_empty() { + let ts = if !channel.rotation_timestamps.is_empty() { + &channel.rotation_timestamps + } else { + &channel.timestamps + }; + if !ts.is_empty() { + r[ji] = sample_quat(ts, &channel.rotations, time); + } + } + if !channel.scales.is_empty() { + let ts = if !channel.scale_timestamps.is_empty() { + &channel.scale_timestamps + } else { + &channel.timestamps + }; + if !ts.is_empty() { + s[ji] = sample_vec3(ts, &channel.scales, time); + } + } + } + + if strip_root && joint_count > 0 { + t[0] = skeleton.joints[0].rest_translation; + } + (t, r, s) +} + +/// The root joint's authored translation at `time` — the raw channel value, +/// *not* the rest-locked one, which is the whole point of root motion. +pub(super) fn root_translation_at( + skeleton: &SkeletonData, + anim: &AnimationData, + time: f32, +) -> [f32; 3] { + if skeleton.joints.is_empty() { + return [0.0; 3]; + } + let time = if anim.duration > 0.0 { + time.clamp(0.0, anim.duration) + } else { + 0.0 + }; + for channel in &anim.channels { + if channel.joint_index == 0 + && !channel.translations.is_empty() + && !channel.timestamps.is_empty() + { + return sample_vec3(&channel.timestamps, &channel.translations, time); + } + } + skeleton.joints[0].rest_translation +} + +/// `dst = lerp(dst, src, w * mask[j])`. Rotations use nlerp with a +/// hemisphere fix — without the dot-sign flip, two clips whose quaternions +/// land on opposite hemispheres blend the *long* way round and the limb +/// visibly swings through the body. +pub(super) fn blend_pose(dst: &mut LocalPose, src: &LocalPose, w: f32, mask: Option<&[f32]>) { + let n = dst.0.len().min(src.0.len()); + for j in 0..n { + let jw = match mask { + Some(m) => w * m.get(j).copied().unwrap_or(0.0), + None => w, + }; + if jw <= 0.0 { + continue; + } + let jw = jw.min(1.0); + for k in 0..3 { + dst.0[j][k] = dst.0[j][k] + (src.0[j][k] - dst.0[j][k]) * jw; + dst.2[j][k] = dst.2[j][k] + (src.2[j][k] - dst.2[j][k]) * jw; + } + let a = dst.1[j]; + let mut b = src.1[j]; + let dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; + if dot < 0.0 { + b = [-b[0], -b[1], -b[2], -b[3]]; + } + let mut q = [ + a[0] + (b[0] - a[0]) * jw, + a[1] + (b[1] - a[1]) * jw, + a[2] + (b[2] - a[2]) * jw, + a[3] + (b[3] - a[3]) * jw, + ]; + let len = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt(); + if len > 1e-6 { + q = [q[0] / len, q[1] / len, q[2] / len, q[3] / len]; + } else { + q = a; + } + dst.1[j] = q; + } +} + +/// 1.0 for every joint at or below `root`, 0.0 elsewhere. `root < 0` means +/// "whole skeleton" so a layer with no mask is a plain full-body override. +pub(super) fn build_mask_weights(skeleton: &SkeletonData, root: i32) -> Vec { + let n = skeleton.joints.len(); + if root < 0 || (root as usize) >= n { + return vec![1.0; n]; + } + let mut w = vec![0.0f32; n]; + let mut stack = vec![root as usize]; + while let Some(j) = stack.pop() { + if j >= n || w[j] > 0.0 { + continue; + } + w[j] = 1.0; + for &c in &skeleton.joints[j].children { + stack.push(c); + } + } + w +} + +// ============================================================ +// Matrix / quaternion helpers for skeletal animation +// ============================================================ + +pub(super) fn mat4_identity() -> [[f32; 4]; 4] { + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] +} + +pub(super) fn mat4_mul(a: &[[f32; 4]; 4], b: &[[f32; 4]; 4]) -> [[f32; 4]; 4] { + let mut out = [[0.0f32; 4]; 4]; + for col in 0..4 { + for row in 0..4 { + out[col][row] = a[0][row] * b[col][0] + + a[1][row] * b[col][1] + + a[2][row] * b[col][2] + + a[3][row] * b[col][3]; + } + } + out +} + +pub(super) fn mat4_from_trs(t: &[f32; 3], r: &[f32; 4], s: &[f32; 3]) -> [[f32; 4]; 4] { + let (x, y, z, w) = (r[0], r[1], r[2], r[3]); + let x2 = x + x; + let y2 = y + y; + let z2 = z + z; + let xx = x * x2; + let xy = x * y2; + let xz = x * z2; + let yy = y * y2; + let yz = y * z2; + let zz = z * z2; + let wx = w * x2; + let wy = w * y2; + let wz = w * z2; + + // Column-major: m[col][row] + [ + [ + (1.0 - (yy + zz)) * s[0], + (xy + wz) * s[0], + (xz - wy) * s[0], + 0.0, + ], // column 0 + [ + (xy - wz) * s[1], + (1.0 - (xx + zz)) * s[1], + (yz + wx) * s[1], + 0.0, + ], // column 1 + [ + (xz + wy) * s[2], + (yz - wx) * s[2], + (1.0 - (xx + yy)) * s[2], + 0.0, + ], // column 2 + [t[0], t[1], t[2], 1.0], // column 3 (translation) + ] +} + +pub(super) fn quat_slerp(a: &[f32; 4], b: &[f32; 4], t: f32) -> [f32; 4] { + let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; + let mut b2 = *b; + if dot < 0.0 { + dot = -dot; + b2 = [-b[0], -b[1], -b[2], -b[3]]; + } + if dot > 0.9995 { + let mut out = [ + a[0] + t * (b2[0] - a[0]), + a[1] + t * (b2[1] - a[1]), + a[2] + t * (b2[2] - a[2]), + a[3] + t * (b2[3] - a[3]), + ]; + let len = (out[0] * out[0] + out[1] * out[1] + out[2] * out[2] + out[3] * out[3]).sqrt(); + if len > 0.0 { + for v in &mut out { + *v /= len; + } + } + return out; + } + let theta = dot.acos(); + let sin_theta = theta.sin(); + let wa = ((1.0 - t) * theta).sin() / sin_theta; + let wb = (t * theta).sin() / sin_theta; + [ + wa * a[0] + wb * b2[0], + wa * a[1] + wb * b2[1], + wa * a[2] + wb * b2[2], + wa * a[3] + wb * b2[3], + ] +} + +pub(super) fn lerp_vec3(a: &[f32; 3], b: &[f32; 3], t: f32) -> [f32; 3] { + [ + a[0] + t * (b[0] - a[0]), + a[1] + t * (b[1] - a[1]), + a[2] + t * (b[2] - a[2]), + ] +} + +pub(super) fn find_keyframe_pair(timestamps: &[f32], time: f32) -> (usize, usize, f32) { + if timestamps.len() <= 1 { + return (0, 0, 0.0); + } + if time <= timestamps[0] { + return (0, 0, 0.0); + } + if time >= timestamps[timestamps.len() - 1] { + let last = timestamps.len() - 1; + return (last, last, 0.0); + } + for i in 0..timestamps.len() - 1 { + if time >= timestamps[i] && time < timestamps[i + 1] { + let dt = timestamps[i + 1] - timestamps[i]; + let t = if dt > 0.0 { + (time - timestamps[i]) / dt + } else { + 0.0 + }; + return (i, i + 1, t); + } + } + let last = timestamps.len() - 1; + (last, last, 0.0) +} + +pub(super) fn sample_vec3(timestamps: &[f32], values: &[[f32; 3]], time: f32) -> [f32; 3] { + if values.is_empty() { + return [0.0; 3]; + } + if values.len() == 1 { + return values[0]; + } + let (i0, i1, t) = find_keyframe_pair(timestamps, time); + if i0 >= values.len() { + return values[values.len() - 1]; + } + if i1 >= values.len() { + return values[values.len() - 1]; + } + lerp_vec3(&values[i0], &values[i1], t) +} + +pub(super) fn sample_quat(timestamps: &[f32], values: &[[f32; 4]], time: f32) -> [f32; 4] { + if values.is_empty() { + return [0.0, 0.0, 0.0, 1.0]; + } + if values.len() == 1 { + return values[0]; + } + let (i0, i1, t) = find_keyframe_pair(timestamps, time); + if i0 >= values.len() { + return values[values.len() - 1]; + } + if i1 >= values.len() { + return values[values.len() - 1]; + } + quat_slerp(&values[i0], &values[i1], t) +} + +pub(super) fn compute_joint_transforms( + skeleton: &SkeletonData, + joint_idx: usize, + parent_transform: &[[f32; 4]; 4], + translations: &[[f32; 3]], + rotations: &[[f32; 4]], + scales: &[[f32; 3]], + world_transforms: &mut [[[f32; 4]; 4]], +) { + if joint_idx >= skeleton.joints.len() { + return; + } + let local = mat4_from_trs( + &translations[joint_idx], + &rotations[joint_idx], + &scales[joint_idx], + ); + let world = mat4_mul(parent_transform, &local); + world_transforms[joint_idx] = world; + let children = skeleton.joints[joint_idx].children.clone(); + for &child in &children { + compute_joint_transforms( + skeleton, + child, + &world, + translations, + rotations, + scales, + world_transforms, + ); + } +} + +// ---- Catmull-Rom spline helpers (Q9) ---- + +pub(super) fn catmull_rom_point(points: &[f32], n: usize, segment: usize, t: f32) -> [f32; 3] { + // Indices: p0 = segment - 1, p1 = segment, p2 = segment + 1, p3 = segment + 2. + // Clamp at boundaries. + let i0 = if segment > 0 { segment - 1 } else { 0 }; + let i1 = segment; + let i2 = if segment + 1 < n { segment + 1 } else { n - 1 }; + let i3 = if segment + 2 < n { segment + 2 } else { n - 1 }; + + let p0 = [points[i0 * 3], points[i0 * 3 + 1], points[i0 * 3 + 2]]; + let p1 = [points[i1 * 3], points[i1 * 3 + 1], points[i1 * 3 + 2]]; + let p2 = [points[i2 * 3], points[i2 * 3 + 1], points[i2 * 3 + 2]]; + let p3 = [points[i3 * 3], points[i3 * 3 + 1], points[i3 * 3 + 2]]; + + let t2 = t * t; + let t3 = t2 * t; + let mut out = [0.0f32; 3]; + for k in 0..3 { + out[k] = 0.5 + * ((2.0 * p1[k]) + + (-p0[k] + p2[k]) * t + + (2.0 * p0[k] - 5.0 * p1[k] + 4.0 * p2[k] - p3[k]) * t2 + + (-p0[k] + 3.0 * p1[k] - 3.0 * p2[k] + p3[k]) * t3); + } + out +} + +pub(super) fn update_bounds(bmin: &mut [f32; 3], bmax: &mut [f32; 3], x: f32, y: f32, z: f32) { + if x < bmin[0] { + bmin[0] = x; + } + if y < bmin[1] { + bmin[1] = y; + } + if z < bmin[2] { + bmin[2] = z; + } + if x > bmax[0] { + bmax[0] = x; + } + if y > bmax[1] { + bmax[1] = y; + } + if z > bmax[2] { + bmax[2] = z; + } +} diff --git a/native/shared/src/renderer/shaders/core.rs b/native/shared/src/renderer/shaders/core.rs index a858a29c..5d8131b0 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -1387,642 +1387,6 @@ fn fs_transparent_scene( "# ); -/// Build the dedicated imported-transmission scene shader without changing -/// the shader compiled for ordinary scene materials. -/// -/// Desktop/native backends add group 4 and sample the render-graph-owned -/// pre-translucency color/depth snapshots. Four-bind-group targets -/// (`fold_scene_inputs`: WebGPU/Android) compile the same physical lobe but -/// source transmitted radiance from the environment map instead. -pub(in crate::renderer) fn scene_refractive_shader_source( - base_scene_shader: &str, - folded_scene_inputs: bool, - screen_space_reflections: bool, - secondary_uv: bool, -) -> String { - assert!( - !folded_scene_inputs || !screen_space_reflections, - "folded four-bind-group targets cannot add native reflection inputs" - ); - const JOINT_DECLARATION: &str = - "@group(3) @binding(1) var joints_prev: JointMatrices;"; - let scene_inputs = if folded_scene_inputs { - "" - } else if screen_space_reflections { - r#" -struct RefractiveReflectionParams { - view: mat4x4, - proj: mat4x4, - params: vec4, - planar_plane: vec4, -}; - -@group(4) @binding(0) var refractive_scene_color_tex: texture_2d; -@group(4) @binding(1) var refractive_scene_color_samp: sampler; -@group(4) @binding(2) var refractive_scene_depth_tex: texture_depth_2d; -@group(4) @binding(3) var refractive_reflection: RefractiveReflectionParams; -@group(4) @binding(4) var refractive_planar_tex: texture_2d; -"# - } else { - r#" -@group(4) @binding(0) var refractive_scene_color_tex: texture_2d; -@group(4) @binding(1) var refractive_scene_color_samp: sampler; -@group(4) @binding(2) var refractive_scene_depth_tex: texture_depth_2d; -"# - }; - let physical_declarations = format!( - r#"{JOINT_DECLARATION} - -struct TransmissionFactors {{ - transmission: vec4, - attenuation: vec4, - transmission_uv: vec4, - transmission_rotation: vec4, - thickness_uv: vec4, - thickness_rotation: vec4, -}}; - -@group(2) @binding(11) var transmission_tex: texture_2d; -@group(2) @binding(12) var transmission_samp: sampler; -@group(2) @binding(13) var thickness_tex: texture_2d; -@group(2) @binding(14) var thickness_samp: sampler; -@group(2) @binding(15) var transmission_material: TransmissionFactors; -{scene_inputs}"# - ); - let mut source = base_scene_shader.replacen(JOINT_DECLARATION, &physical_declarations, 1); - assert_ne!( - source, base_scene_shader, - "scene shader joint declaration changed; refractive ABI injection must be updated" - ); - let layered_secondary_uv = - secondary_uv && base_scene_shader.contains("fn layered_secondary_uv(in:"); - let model_scale_location = if layered_secondary_uv { 8 } else { 7 }; - // The ordinary per-draw group is vertex-visible only. Carry model scale as - // a refractive-variant-only interpolant instead of widening that established - // layout to the fragment stage for every scene material. - source = source.replacen( - " @location(6) prev_clip: vec4,", - &format!( - " @location(6) prev_clip: vec4,\n @location({model_scale_location}) model_scale: f32," - ), - 1, - ); - source = source.replacen( - " o.prev_clip = u.prev_mvp * prev_world4;", - " o.prev_clip = u.prev_mvp * prev_world4;\n\ - o.model_scale = (length(u.model[0].xyz) + length(u.model[1].xyz) \ - + length(u.model[2].xyz)) / 3.0;", - 1, - ); - source = source.replacen( - " out.prev_clip = u.prev_mvp * vec4(prev_local, 1.0);", - " out.prev_clip = u.prev_mvp * vec4(prev_local, 1.0);\n\ - out.model_scale = (length(u.model[0].xyz) + length(u.model[1].xyz) \ - + length(u.model[2].xyz)) / 3.0;", - 1, - ); - if secondary_uv && !layered_secondary_uv { - source = source.replacen( - " @location(6) tangent: vec4,\n};", - " @location(6) tangent: vec4,\n\ - @location(7) secondary_uv: vec2,\n\ - };", - 1, - ); - source = source.replacen( - " @location(7) model_scale: f32,", - " @location(7) model_scale: f32,\n\ - @location(8) secondary_uv: vec2,", - 1, - ); - source = source.replacen( - " o.uv = in.uv;", - " o.uv = in.uv;\n o.secondary_uv = in.secondary_uv;", - 1, - ); - source = source.replacen( - " out.uv = in.uv;", - " out.uv = in.uv;\n out.secondary_uv = in.secondary_uv;", - 1, - ); - assert!( - source.contains("@location(8) secondary_uv"), - "scene vertex ABI changed; refractive UV1 injection must be updated" - ); - } else if layered_secondary_uv { - assert!( - source.contains("@location(7) secondary_uv") - && source.contains("@location(8) model_scale"), - "layered refractive vertex ABI changed; specialization must be updated" - ); - } - - let transmitted_radiance = if folded_scene_inputs { - r#" - // Constrained four-bind-group fallback: preserve the refractive material - // type and its Fresnel/absorption response, but source off-screen - // transmitted radiance from the prefiltered environment. - let max_transmission_mip = max(f32(textureNumLevels(env_tex)) - 1.0, 0.0); - let transmitted_radiance = env_sample_lod( - refracted_direction, - roughness * max_transmission_mip, - ); - let undistorted_radiance = env_sample_lod(-v, roughness * max_transmission_mip); -"# - } else { - r#" - let scene_dimensions_u = textureDimensions(refractive_scene_color_tex, 0); - let scene_dimensions = vec2(scene_dimensions_u); - let current_ndc = in.curr_clip.xy / max(abs(in.curr_clip.w), 0.000001); - let current_uv = clamp( - vec2(current_ndc.x * 0.5 + 0.5, 0.5 - current_ndc.y * 0.5), - vec2(0.0001), - vec2(0.9999), - ); - - // Convert the refracted world-space direction into a stable screen-space - // travel distance using the fragment's world-position derivatives. The - // material factor already carries static glTF node scale baked by the - // importer; the interpolant adds the later draw/instance scale. A 64-pixel - // cap prevents pathological assets from sampling unrelated frame regions. - let world_dx = dpdx(in.world_pos); - let world_dy = dpdy(in.world_pos); - let world_dx_len = max(length(world_dx), 0.000001); - let world_dy_len = max(length(world_dy), 0.000001); - let screen_tangent_x = world_dx / world_dx_len; - let screen_tangent_y = world_dy / world_dy_len; - let ray_distance = thickness_world - / max(abs(dot(refracted_direction, n)), 0.15); - var offset_pixels = vec2( - dot(refracted_direction, screen_tangent_x) * ray_distance / world_dx_len, - dot(refracted_direction, screen_tangent_y) * ray_distance / world_dy_len, - ); - offset_pixels = clamp(offset_pixels, vec2(-64.0), vec2(64.0)); - var refracted_uv = clamp( - current_uv + offset_pixels / scene_dimensions, - vec2(0.0001), - vec2(0.9999), - ); - - // Reject offsets that cross in front of this glass surface. This keeps a - // nearby opaque silhouette from being pulled through the refractor. - let candidate_pixel = clamp( - vec2(refracted_uv * scene_dimensions), - vec2(0), - vec2(scene_dimensions_u) - vec2(1), - ); - let candidate_depth = textureLoad( - refractive_scene_depth_tex, - candidate_pixel, - 0, - ); - if (candidate_depth + 0.0005 < in.clip_position.z) { - refracted_uv = current_uv; - } - - var transmitted_radiance = textureSampleLevel( - refractive_scene_color_tex, - refractive_scene_color_samp, - refracted_uv, - 0.0, - ).rgb; - // Deterministic five-tap rough transmission. Smooth glass stays at one - // fetch; rough glass integrates a bounded footprint without temporal noise. - if (roughness > 0.08) { - let blur_uv = vec2(8.0 * roughness * roughness) / scene_dimensions; - transmitted_radiance = ( - transmitted_radiance * 4.0 - + textureSampleLevel( - refractive_scene_color_tex, - refractive_scene_color_samp, - refracted_uv + vec2(blur_uv.x, 0.0), - 0.0, - ).rgb - + textureSampleLevel( - refractive_scene_color_tex, - refractive_scene_color_samp, - refracted_uv - vec2(blur_uv.x, 0.0), - 0.0, - ).rgb - + textureSampleLevel( - refractive_scene_color_tex, - refractive_scene_color_samp, - refracted_uv + vec2(0.0, blur_uv.y), - 0.0, - ).rgb - + textureSampleLevel( - refractive_scene_color_tex, - refractive_scene_color_samp, - refracted_uv - vec2(0.0, blur_uv.y), - 0.0, - ).rgb - ) * 0.125; - } - let undistorted_radiance = textureSampleLevel( - refractive_scene_color_tex, - refractive_scene_color_samp, - current_uv, - 0.0, - ).rgb; -"# - }; - - let reflection_helpers = if screen_space_reflections { - r#" -fn refractive_screen_reflection( - in: VertexOutputScene, - reflected_direction: vec3, - roughness: f32, - environment_fallback: vec3, -) -> vec3 { - // The ordinary SSR target cannot be reused here: it was traced from the - // opaque surface behind this fragment and therefore owns a different - // normal/reflection ray. Launch one bounded ray from the glass fragment - // against the immutable opaque snapshots instead. - if (refractive_reflection.params.x < 0.5 - || roughness >= refractive_reflection.params.w) { - return environment_fallback; - } - - let dimensions_u = textureDimensions(refractive_scene_color_tex, 0); - let dimensions = vec2(dimensions_u); - let step_count = u32(refractive_reflection.params.z); - let max_distance = refractive_reflection.params.y; - let start_view = ( - refractive_reflection.view * vec4(in.world_pos, 1.0) - ).xyz; - let reflected_view = normalize(( - refractive_reflection.view * vec4(reflected_direction, 0.0) - ).xyz); - let start_clip = refractive_reflection.proj * vec4(start_view, 1.0); - var previous_ray_depth = start_clip.z / max(abs(start_clip.w), 0.000001); - var hit_uv = vec2(-1.0); - var hit_confidence = 0.0; - - // Quadratic spacing keeps the nearest samples dense enough for window - // frames and props while still reaching the same architectural range as - // the established opaque SSR pass. The loop bound and every texture read - // are fixed by the lazy uniform (currently eight). - for (var step = 0u; step < step_count; step = step + 1u) { - let fraction = f32(step + 1u) / f32(step_count); - let distance = max_distance * fraction * fraction; - let ray_view = start_view + reflected_view * distance; - let ray_clip = refractive_reflection.proj * vec4(ray_view, 1.0); - if (ray_clip.w <= 0.000001) { - break; - } - let ray_ndc = ray_clip.xyz / ray_clip.w; - if (ray_ndc.x <= -1.0 || ray_ndc.x >= 1.0 - || ray_ndc.y <= -1.0 || ray_ndc.y >= 1.0 - || ray_ndc.z <= 0.0 || ray_ndc.z >= 1.0) { - break; - } - let ray_uv = vec2( - ray_ndc.x * 0.5 + 0.5, - 0.5 - ray_ndc.y * 0.5, - ); - let pixel = clamp( - vec2(ray_uv * dimensions), - vec2(0), - vec2(dimensions_u) - vec2(1), - ); - let scene_depth = textureLoad(refractive_scene_depth_tex, pixel, 0); - let depth_delta = ray_ndc.z - scene_depth; - let depth_stride = abs(ray_ndc.z - previous_ray_depth); - let thickness = max(depth_stride * 2.0, 0.00075); - if (scene_depth < 0.9999 - && depth_delta >= 0.0 - && depth_delta <= thickness) { - hit_uv = ray_uv; - hit_confidence = 1.0 - smoothstep( - thickness * 0.25, - thickness, - depth_delta, - ); - break; - } - previous_ray_depth = ray_ndc.z; - } - - if (hit_uv.x < 0.0) { - return environment_fallback; - } - // Suppress the screen boundary before it can pop. Rough glass fades to - // the prefiltered environment rather than returning an incorrectly sharp - // scene-color tap (the snapshot deliberately has no mip chain). - let edge_pixels = min( - min(hit_uv.x, 1.0 - hit_uv.x) * dimensions.x, - min(hit_uv.y, 1.0 - hit_uv.y) * dimensions.y, - ); - let edge_weight = smoothstep(0.0, 8.0, edge_pixels); - let roughness_weight = 1.0 - smoothstep( - refractive_reflection.params.w * 0.45, - refractive_reflection.params.w, - roughness, - ); - let raw = textureSampleLevel( - refractive_scene_color_tex, - refractive_scene_color_samp, - hit_uv, - 0.0, - ).rgb; - let screen_radiance = select(vec3(0.0), raw, raw == raw); - let source_weight = clamp( - edge_weight * roughness_weight * hit_confidence, - 0.0, - 1.0, - ); - return mix(environment_fallback, screen_radiance, source_weight); -} - -fn refractive_planar_sample( - in: VertexOutputScene, - roughness: f32, -) -> vec4 { - let plane = refractive_reflection.planar_plane; - if (dot(plane.xyz, plane.xyz) < 0.5) { - return vec4(0.0, 0.0, 0.0, -1.0); - } - // A plane crossing unrelated vertical glass must not make the global - // first-probe choice leak onto that surface. Use the unperturbed vertex - // normal so authored water waves can still perturb the sampled reflection. - if (abs(dot(normalize(plane.xyz), normalize(in.normal))) < 0.8) { - return vec4(0.0, 0.0, 0.0, -1.0); - } - let plane_distance = abs(dot(plane.xyz, in.world_pos) - plane.w); - if (plane_distance > 0.075) { - return vec4(0.0, 0.0, 0.0, -1.0); - } - let ndc = in.curr_clip.xy / max(abs(in.curr_clip.w), 0.000001); - let uv = clamp( - vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5), - vec2(0.0001), - vec2(0.9999), - ); - let planar = textureSampleLevel( - refractive_planar_tex, - refractive_scene_color_samp, - uv, - 0.0, - ); - let planar_safe = select(vec3(0.0), planar.rgb, planar.rgb == planar.rgb); - // The existing planar capture has no mip chain. Fade its exact reflection - // into the lower tiers as roughness grows instead of returning an - // incorrectly sharp image. Alpha zero is the probe's explicit miss value. - let roughness_weight = 1.0 - smoothstep(0.18, 0.45, roughness); - let source_weight = clamp(planar.a * roughness_weight, 0.0, 1.0); - return vec4(planar_safe, source_weight); -} -"# - } else { - "" - }; - let reflected_radiance = if screen_space_reflections { - r#" - let reflected_environment = env_sample_lod( - reflected_direction, - roughness * max(f32(textureNumLevels(env_tex)) - 1.0, 0.0), - ); - let planar_reflected = refractive_planar_sample(in, roughness); - var reflected = mix( - reflected_environment, - planar_reflected.rgb, - max(planar_reflected.a, 0.0), - ); - // A matching explicit probe is authoritative for its plane: alpha zero is - // its documented geometry miss and therefore reveals the environment sky. - // Only glass without an applicable probe pays for the bounded screen march. - if (planar_reflected.a < 0.0) { - reflected = refractive_screen_reflection( - in, - reflected_direction, - roughness, - reflected_environment, - ); - } -"# - } else { - r#" - let reflected = env_sample_lod( - reflected_direction, - roughness * max(f32(textureNumLevels(env_tex)) - 1.0, 0.0), - ); -"# - }; - - let transmission_source_uv = if secondary_uv { - "select(\n in.uv,\n in.secondary_uv,\n transmission_material.transmission_rotation.w > 0.5,\n )" - } else { - "in.uv" - }; - let thickness_source_uv = if secondary_uv { - "select(\n in.uv,\n in.secondary_uv,\n transmission_material.thickness_rotation.z > 0.5,\n )" - } else { - "in.uv" - }; - source.push_str(&format!( - r#" - -{reflection_helpers} - -fn physical_texture_uv( - uv: vec2, - offset_scale: vec4, - rotation: vec2, -) -> vec2 {{ - let scaled = uv * offset_scale.zw; - let rotated = vec2( - rotation.x * scaled.x - rotation.y * scaled.y, - rotation.y * scaled.x + rotation.x * scaled.y, - ); - return offset_scale.xy + rotated; -}} - -fn refractive_scene_normal(in: VertexOutputScene, front_facing: bool) -> vec3 {{ - var n = normalize(in.normal); - let normal_uv = material_uv( - in.uv, material.uv_transforms[2], material.uv_transforms[3], - ); - let normal_sample = textureSampleBias( - normal_tex, - normal_samp, - normal_uv, - 1.0 + lighting.shadow_cascade_splits.w, - ).xyz * 2.0 - 1.0; - let scaled_normal_sample = vec3( - normal_sample.xy * vec2( - material.uv_transforms[2].w, - material.uv_transforms[3].w, - ), - normal_sample.z, - ); - let mapped = scaled_normal_sample / max(length(scaled_normal_sample), 0.000001); - let tangent_len2 = dot(in.tangent.xyz, in.tangent.xyz); - if (tangent_len2 > 0.0001) {{ - let tangent = normalize(in.tangent.xyz); - let tangent_ortho = normalize(tangent - n * dot(n, tangent)); - let bitangent = cross(n, tangent_ortho) * in.tangent.w; - n = normalize( - tangent_ortho * mapped.x + bitangent * mapped.y + n * mapped.z, - ); - }} else {{ - let tbn = compute_tbn( - dpdx(in.world_pos), - dpdy(in.world_pos), - dpdx(normal_uv), - dpdy(normal_uv), - n, - ); - n = normalize(tbn * mapped); - }} - if (!front_facing) {{ - n = -n; - }} - return n; -}} - -struct RefractiveSceneOut {{ - @location(0) color: vec4, - @location(1) velocity: vec2, -}}; - -@fragment -fn fs_refractive_scene( - in: VertexOutputScene, - @builtin(front_facing) front_facing: bool, -) -> RefractiveSceneOut {{ - // Reuse the established direct/IBL PBR evaluation for the non-transmitted - // energy, then split the dielectric lobe below. This also preserves MASK - // discard semantics for the unusual but legal MASK+transmission case. - let surface = shade_main_scene(in, front_facing); - let n = refractive_scene_normal(in, front_facing); - let v = normalize(lighting.camera_pos.xyz - in.world_pos); - - let base_uv = material_uv( - in.uv, material.uv_transforms[0], material.uv_transforms[1], - ); - let mr_uv = material_uv( - in.uv, material.uv_transforms[4], material.uv_transforms[5], - ); - let base_texel = textureSample(base_color_tex, base_color_samp, base_uv); - var base_color = srgb_to_linear_v(base_texel.rgb) * in.color.rgb; - let base_alpha = base_texel.a * in.color.a; - let mr_texel = textureSample(mr_tex, mr_samp, mr_uv); - let has_mr = material.metal_rough.z > 0.5 && material.metal_rough.z < 1.5; - let has_spec_gloss = material.metal_rough.z > 1.5; - var metallic = select( - clamp(material.metal_rough.x, 0.0, 1.0), - clamp(mr_texel.b * material.metal_rough.x, 0.0, 1.0), - has_mr, - ); - var roughness = select( - clamp(material.metal_rough.y, 0.045, 1.0), - clamp(mr_texel.g * material.metal_rough.y, 0.045, 1.0), - has_mr, - ); - if (has_spec_gloss) {{ - let authored_specular = srgb_to_linear_v(mr_texel.rgb) * - material.spec_gloss.rgb; - let converted = specgloss_to_metalrough_pixel(base_color, authored_specular); - base_color = converted.rgb; - metallic = converted.a; - roughness = clamp( - 1.0 - mr_texel.a * material.spec_gloss.a, - 0.045, - 1.0, - ); - }} - - let transmission_uv = physical_texture_uv( - {transmission_source_uv}, - transmission_material.transmission_uv, - transmission_material.transmission_rotation.xy, - ); - let texture_transmission = select( - 1.0, - textureSample( - transmission_tex, - transmission_samp, - transmission_uv, - ).r, - transmission_material.transmission.w > 0.5, - ); - let dielectric_weight = 1.0 - metallic; - let transmission_weight = clamp( - transmission_material.transmission.x - * texture_transmission - * dielectric_weight, - 0.0, - 1.0, - ); - - let thickness_uv = physical_texture_uv( - {thickness_source_uv}, - transmission_material.thickness_uv, - transmission_material.thickness_rotation.xy, - ); - let texture_thickness = select( - 1.0, - textureSample( - thickness_tex, - thickness_samp, - thickness_uv, - ).g, - transmission_material.transmission_rotation.z > 0.5, - ); - let mean_model_scale = max(in.model_scale, 0.0); - let thickness_world = max( - transmission_material.transmission.z - * texture_thickness - * mean_model_scale, - 0.0, - ); - - let ior = max(transmission_material.transmission.y, 1.0); - let eta = 1.0 / ior; - var refracted_direction = refract(-v, n, eta); - if (dot(refracted_direction, refracted_direction) < 0.000001) {{ - refracted_direction = reflect(-v, n); - }} - refracted_direction = normalize(refracted_direction); - -{transmitted_radiance} - - var absorption = vec3(1.0); - if (transmission_material.attenuation.w > 0.0 && thickness_world > 0.0) {{ - let optical_distance = thickness_world - / transmission_material.attenuation.w; - absorption = pow( - max(transmission_material.attenuation.rgb, vec3(0.000001)), - vec3(optical_distance), - ); - }} - let transmitted = transmitted_radiance * base_color * absorption; - - let f0_scalar = pow((ior - 1.0) / (ior + 1.0), 2.0); - let n_dot_v = clamp(dot(n, v), 0.0, 1.0); - let fresnel = f0_scalar - + (1.0 - f0_scalar) * pow(1.0 - n_dot_v, 5.0); - let reflected_direction = reflect(-v, n); -{reflected_radiance} - - // Energy partition: the ordinary PBR surface owns the opaque fraction; - // the transmission fraction is split exactly between Fresnel reflection - // and absorbed transmitted radiance. - let dielectric_transmission = mix(transmitted, reflected, fresnel); - var hdr = surface.color.rgb * (1.0 - transmission_weight) - + dielectric_transmission * transmission_weight; - - // glTF BLEND+transmission additionally applies base-color alpha. Because - // this fragment already composites against the snapshot, write alpha=1 - // to avoid applying the background a second time in fixed-function blend. - if (material.metal_rough.w < 0.0) {{ - hdr = mix(undistorted_radiance, hdr, clamp(base_alpha, 0.0, 1.0)); - }} - hdr = select(vec3(0.0), hdr, hdr == hdr); - return RefractiveSceneOut(vec4(hdr, 1.0), surface.velocity); -}} -"# - )); - source -} +#[path = "refractive.rs"] +mod refractive; +pub(in crate::renderer) use refractive::scene_refractive_shader_source; diff --git a/native/shared/src/renderer/shaders/mod.rs b/native/shared/src/renderer/shaders/mod.rs index 7dc913a5..56c76029 100644 --- a/native/shared/src/renderer/shaders/mod.rs +++ b/native/shared/src/renderer/shaders/mod.rs @@ -211,10 +211,13 @@ mod ray_query_variant_tests { assert!(SSGI_PROBE_TEMPORAL_WGSL.contains("mean_luminance * 5.0")); assert!(SSGI_PROBE_TEMPORAL_WGSL.contains("concentrated for this sampling density")); assert!(!SSGI_PROBE_TEMPORAL_WGSL.contains("spatially_filter_current_sample")); + // One pair validates the repeated current phase; the other retains + // the remaining phase radiance and world owners. Neither reads old + // directional lanes as matching temporal samples. assert_eq!( SSGI_PROBE_TEMPORAL_WGSL.matches("history_in,").count(), - 2, - "only phase radiance and its paired world owner may load temporal texture history", + 4, + "only current-phase validation and retained phase/owner pairs may load history", ); assert!(SSGI_PROBE_TEMPORAL_WGSL.contains("i32(48u + phase_slot)")); assert!(PROBE_HELPERS_WGSL.contains("fn probe_trace_direction(")); @@ -242,15 +245,15 @@ mod ray_query_variant_tests { assert!(SSGI_PROBE_TEMPORAL_WGSL.contains("fn cs_spatial(")); assert!(SSGI_PROBE_TEMPORAL_WGSL.contains("same surface")); assert!(SSGI_PROBE_TEMPORAL_WGSL.contains("sample.diffuse.rgb * weight")); - // History is bounded by neighborhood mean ± spread, never a hard - // min/max of one frame's binomially noisy realizations. + // Unsettled software history is bounded by neighborhood mean ± spread, + // never a hard min/max of one frame's binomially noisy realizations. assert!(!SSGI_PROBE_TEMPORAL_WGSL .contains("clamp(center.diffuse.rgb, current_min, current_max)")); assert!(SSGI_PROBE_TEMPORAL_WGSL .contains("clamp(\n center.diffuse.rgb,\n current_mean - slack,\n current_mean + slack,\n )")); - assert!( - SSGI_PROBE_TEMPORAL_WGSL.contains("if (u.confidence.x < 0.5 && weight_sum > 0.0001)") - ); + assert!(SSGI_PROBE_TEMPORAL_WGSL.contains( + "if (u.confidence.x < 0.5 && center.current_diffuse.w < 0.5 && weight_sum > 0.0001)" + )); assert!(SSGI_PROBE_TEMPORAL_WGSL.contains("abs(current_mean) * 0.02")); assert!(SSGI_PROBE_TEMPORAL_WGSL.contains("let hardware_history = u.confidence.x > 0.5;")); assert!(SSGI_PROBE_TEMPORAL_WGSL.contains("select(1, 2, confidence_error > 0.08)")); diff --git a/native/shared/src/renderer/shaders/post.rs b/native/shared/src/renderer/shaders/post.rs index 581f3c06..f74be638 100644 --- a/native/shared/src/renderer/shaders/post.rs +++ b/native/shared/src/renderer/shaders/post.rs @@ -683,708 +683,9 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { } "; -/// TAA shader. Reads `composed_rt` (scene HDR + post-effects + fog + -/// shafts already merged upstream) and performs only temporal -/// reprojection with neighborhood clamp, blending against the -/// history RT. For static scenes the blend converges in ~10 frames -/// to a fully sub-pixel-resolved image. -pub(in crate::renderer) const TAA_SHADER_WGSL: &str = concat!( - include_str!("taa_reconstruction.wgsl"), - " -struct TaaParams { - /// abs(x) = blend factor (current-frame weight); sign(x) = whether the - /// unjittered camera transform moved; yz = the CURRENT frame's - /// jitter as a composed-texture UV offset (see the unjitter note at the - /// current-frame sample); abs(w) = render scale, with positive sign for - /// perspective depth and negative sign for orthographic depth. - params: vec4, - /// Inverse of the current-frame view-projection matrix — - /// reconstructs world-space position for history reprojection. - inv_vp: mat4x4, - /// Previous-frame view-projection — projects world pos into - /// history UV. - prev_vp: mat4x4, -}; - -@group(0) @binding(0) var u: TaaParams; -@group(0) @binding(1) var composed_tex: texture_2d; -@group(0) @binding(2) var composed_samp: sampler; -@group(0) @binding(3) var history_tex: texture_2d; -@group(0) @binding(4) var history_samp: sampler; -@group(0) @binding(5) var depth_tex: texture_depth_2d; -@group(0) @binding(6) var depth_samp: sampler; -@group(0) @binding(7) var velocity_tex: texture_2d; -@group(0) @binding(8) var velocity_samp: sampler; -@group(0) @binding(9) var history_depth_tex: texture_2d; - -struct VsOut { - @builtin(position) clip_pos: vec4, - @location(0) uv: vec2, -}; - -struct TaaOut { - @location(0) color: vec4, - /// R = geometric depth; G packs persistent history confidence and the - /// independent detail lock, with a negative encoded range retaining prior - /// reactive coverage. - @location(1) provenance_history: vec2, -}; - -@vertex -fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut { - let x = f32((vid & 1u) * 4u) - 1.0; - let y = f32((vid >> 1u) * 4u) - 1.0; - var out: VsOut; - out.clip_pos = vec4(x, y, 0.0, 1.0); - out.uv = vec2((x + 1.0) * 0.5, (1.0 - y) * 0.5); - return out; -} - -// RGB <-> YCoCg conversions. Reversible, linear, cheap (no matrix -// multiply). Used by the TAA neighborhood clamp so we can bound -// history's luma (Y) against the source neighborhood's statistical -// range while leaving chroma (Co, Cg) alone — the per-channel RGB -// clamp was causing chromatic sparkle on grazing-angle stone. -fn rgb_to_ycocg(c: vec3) -> vec3 { - let Co = c.r - c.b; - let tmp = c.b + Co * 0.5; - let Cg = c.g - tmp; - let Y = tmp + Cg * 0.5; - return vec3(Y, Co, Cg); -} -fn ycocg_to_rgb(c: vec3) -> vec3 { - let tmp = c.x - c.z * 0.5; - let g = c.z + tmp; - let b = tmp - c.y * 0.5; - let r = c.y + b; - return vec3(r, g, b); -} - -// History lives at output resolution. A bilinear lookup is exact while a -// reprojected coordinate remains on an output texel centre, but under camera -// or object motion it averages four already-filtered history pixels. Repeating -// that every frame progressively removes texture and silhouette detail. Keep -// one history fetch, but compress its bilinear phase continuously toward the -// nearest completed output sample. This retains more detail without the hard -// half-texel transitions of point history sampling. Renderer-known camera -// motion selects this path; object-only reprojection retains its established -// behavior until it has an independently qualified phase policy. The -// stationary path is -// byte-for-byte the original lookup so settled temporal supersampling is not -// disturbed. Later variance, depth, and reactive guards still decide whether -// a moving sample belongs to the current surface. -fn sample_history_reprojected(uv: vec2, camera_moving: bool) -> vec4 { - if (!camera_moving) { - return textureSampleLevel(history_tex, history_samp, uv, 0.0); - } - let dims = vec2(textureDimensions(history_tex)); - let history_pixel = uv * dims - vec2(0.5); - let base = floor(history_pixel); - let phase = fract(history_pixel); - let compressed_phase = phase * phase * (vec2(3.0) - 2.0 * phase); - let compressed_uv = (base + compressed_phase + vec2(0.5)) / dims; - return textureSampleLevel(history_tex, history_samp, compressed_uv, 0.0); -} - -@fragment -fn fs_main(in: VsOut) -> TaaOut { - // composed_tex already carries HDR + SSR + SSGI*albedo + bloom + - // fog + shafts — TAA only needs to reproject history and blend. - // Alpha carries `indirect_weight` (see scene_compose) which the - // composite pass reads to apply AO only to indirect-dominated - // pixels; pass it through blended with the colour so history - // stays consistent. - // Both current reconstruction and neighborhood statistics operate in the - // same input texture domain. Compute its size/reciprocal once per output - // pixel and share it; the previous shader repeated the dimension queries - // and reciprocal divisions in both stages. - let input_size = vec2(textureDimensions(composed_tex)); - let input_texel = 1.0 / input_size; - var current_weight = abs(u.params.x); - let camera_moving = u.params.x < 0.0; - let reconstruction_scale = clamp(abs(u.params.w), 0.5, 1.0); - // The material-aware variant replaces this compile-time constant. The - // ordinary lazy topology consumes prior reactive provenance immediately. - let preserve_reactive_history = false; - - // Fractional reconstruction reads the current color at the jitter-corrected - // source phase. Select depth and velocity from that same input position; - // using the unshifted output UV could cross a low-resolution texel boundary - // and make reprojection/provenance belong to a different surface sample. - // Native and half-scale TAA deliberately keep their established geometry - // lookups byte-for-byte. The default 0.75 tier owns this correction during - // real camera motion, where the moving native-reference corpus qualifies - // it independently from settled and legacy-half reconstruction. - let reconstruction_geometry_phase = - reconstruction_scale >= 0.75 && reconstruction_scale < 0.95 && camera_moving; - var geometry_uv = in.uv; - if (reconstruction_geometry_phase) { - geometry_uv = geometry_uv + u.params.yz; - } - - // Closest-depth velocity dilation. Sampling the low-resolution velocity - // buffer linearly mixed foreground motion with zero/background motion at - // silhouettes. The resulting half-vector reprojected into neither - // surface, producing translucent gray trails while turning the camera. - // Select the closest sample in a bounded 3x3 input footprint so thin - // foreground geometry owns the history lookup around its edge. - let depth_dims = vec2(textureDimensions(depth_tex)); - let depth_max_coord = depth_dims - vec2(1); - let center_coord = clamp( - vec2(floor(geometry_uv * vec2(depth_dims))), - vec2(0), - depth_max_coord, - ); - var closest_coord = center_coord; - var depth = textureLoad(depth_tex, center_coord, 0); - let dilation_offsets = array, 4>( - vec2(-1, 0), vec2(1, 0), - vec2(0, -1), vec2(0, 1), - ); - for (var i = 0; i < 4; i = i + 1) { - let coord = clamp(center_coord + dilation_offsets[i], vec2(0), depth_max_coord); - let candidate_depth = textureLoad(depth_tex, coord, 0); - if (candidate_depth < depth) { - depth = candidate_depth; - closest_coord = coord; - } - } - var selected_depth_uv = in.uv; - if (reconstruction_geometry_phase) { - selected_depth_uv = - (vec2(closest_coord) + vec2(0.5)) * input_texel; - } - let ndc = vec4( - selected_depth_uv.x * 2.0 - 1.0, - (1.0 - selected_depth_uv.y) * 2.0 - 1.0, - depth, - 1.0, - ); - let world_h = u.inv_vp * ndc; - let world = world_h.xyz / world_h.w; - - // Persist a geometric depth key beside color history. Perspective clip-W - // is positive linear view distance; orthographic clip-W is constant, so - // that projection stores NDC depth instead. Sky gets an explicit far key. - let perspective = u.params.w > 0.0; - let current_depth_key = select( - depth, - select(1.0 / max(abs(world_h.w), 0.000001), 10000.0, depth >= 0.9999), - perspective, - ); - let prev_world_clip = u.prev_vp * vec4(world, 1.0); - var expected_prev_depth = select( - prev_world_clip.z / max(abs(prev_world_clip.w), 0.000001), - prev_world_clip.w, - perspective, - ); - - let vel = textureLoad(velocity_tex, closest_coord, 0).rg; - var velocity_divergence = 0.0; - if (any(closest_coord != center_coord)) { - let center_vel = textureLoad(velocity_tex, center_coord, 0).rg; - velocity_divergence = length(vel - center_vel); - } - let vel_len = length(vel); - let motion_alpha = smoothstep(0.0005, 0.008, vel_len); - - var prev_uv: vec2; - if (depth >= 0.9999) { - // Sky / far plane: the positional reconstruction divides by a - // near-zero w and reprojects sky pixels onto arbitrary scene - // points — the luma-only history clamp then locks that wrong - // chroma in forever (uniform green/red sky tint). The sky is at - // infinity, so reproject the view DIRECTION instead: exact under - // camera rotation, translation-invariant by definition. - let dir = world_h.xyz; // w ~ 0 at the far plane: xyz IS the direction - let prev_clip = u.prev_vp * vec4(dir, 0.0); - if (prev_clip.w > 0.00001) { - let prev_ndc = prev_clip.xyz / prev_clip.w; - prev_uv = vec2(prev_ndc.x * 0.5 + 0.5, 1.0 - (prev_ndc.y * 0.5 + 0.5)); - } else { - prev_uv = in.uv; - } - expected_prev_depth = 10000.0; - } else { - // Geometry always owns a velocity texel, and a zero vector is a - // meaningful result for a static surface. Falling back to prev_vp - // when velocity was zero reintroduced the previous projection jitter - // into an already unjittered full-resolution history surface. Each - // static frame then sampled history at a different subpixel offset and - // progressively averaged fine texture detail into a broad blur. - prev_uv = vec2(in.uv.x - vel.x, in.uv.y + vel.y); - } - - // Keep a static native-resolution sample on its OUTPUT pixel. The velocity - // reference projection already reapplies the current jitter (EN-022), so - // static history maps to that same pixel; fully undoing jitter here - // low-pass filtered native labels and texture grain before accumulation. - // Fractional-resolution reconstruction keeps the established alignment, - // since one input pixel covers more than one output pixel. Blend only over - // the final five percent of render scale so custom near-native scales do - // not cross a hard sampling discontinuity. During actual motion restore - // full current-frame alignment immediately; it bounds slow-pan phase crawl - // while reprojection supplies the temporal sample location. Sky has no - // geometry velocity, so include its actual directional reprojection - // distance in the same motion classification. - let reprojection_motion = max(vel_len, length(prev_uv - in.uv)); - var jitter_alignment = 1.0; - // Uniform branch: fractional tiers keep the former shader path (and its - // cost) exactly. Only near-native tiers pay for the motion classifier. - if (reconstruction_scale > 0.95) { - let static_jitter_alignment = - 1.0 - smoothstep(0.95, 1.0, reconstruction_scale); - // The static path is deliberately narrow: even a very slow camera pan - // must use the established aligned sample rather than intermittently - // mixing between sharp and aligned phases across the image. - let alignment_motion = smoothstep(0.0000001, 0.000001, reprojection_motion); - jitter_alignment = mix(static_jitter_alignment, 1.0, alignment_motion); - } - let src_uv = in.uv + u.params.yz * jitter_alignment; - var current_sample: vec4; - var center_rgb: vec3; - var fractional_coverage = 1.0; - var fractional_mean = vec3(0.0); - var fractional_stddev = vec3(0.0); - var current_feature_lock = 0.0; - var use_fractional_statistics = false; - // This is uniform across the draw. Keep native reconstruction and its - // already-qualified material response byte-for-byte unchanged. - if ( - reconstruction_scale >= 0.5 && - reconstruction_scale < 0.95 && - current_weight < 0.999 - ) { - let reconstructed = sample_fractional_lanczos2( - src_uv, - input_size, - input_texel, - reconstruction_scale, - ); - current_sample = reconstructed.value; - center_rgb = reconstructed.center.rgb; - fractional_mean = reconstructed.mean; - fractional_stddev = reconstructed.stddev; - // Seed a persistent detail lock from statistics already required by - // rectification. Restrict it to real camera motion: settled output is - // byte-for-byte unchanged, while moving high-frequency detail can - // retain history through a transient source-phase excursion. - current_feature_lock = select( - 0.0, - 1.0, - camera_moving && - reconstructed.stddev.x > max(abs(reconstructed.mean.x) * 0.05, 0.002), - ); - // At half scale, the Lanczos taps move substantially with source - // phase. Reusing their moments as a history-clamp neighborhood makes - // the clamp breathe as the Halton phase advances. Keep history - // validation on the stable output-footprint cross below 0.75 while - // retaining the sharper sample itself. - use_fractional_statistics = reconstruction_scale >= 0.75; - // Preserve the separable kernel's phase coverage through accumulation. - // At 0.75 its average raw weight is approximately 0.74 over a jitter - // cycle; normalizing around that mean keeps Bloom's authored temporal - // window while low-coverage phases contribute proportionally less. - // The 0.5 path freezes coverage at the measured mean over all sixteen - // Halton phases and both output-pixel parities. Per-phase coverage - // changes are useful at 0.75, but at half scale they modulate alpha - // enough to make a slow pan alternate between sharp and soft frames. - fractional_coverage = select( - 0.784, - clamp(reconstructed.weight / 0.74, 0.25, 2.0), - reconstruction_scale >= 0.75, - ); - current_weight = 1.0 - pow(1.0 - current_weight, fractional_coverage); - } else { - // History resets and the four-frame bootstrap retain the qualified - // cubic. A single Lanczos phase is intentionally sharp; it is only - // representative once temporal accumulation can combine phases. - current_sample = sample_catmull_rom(src_uv, input_size, input_texel); - center_rgb = textureSampleLevel(composed_tex, composed_samp, src_uv, 0.0).rgb; - } - let current = current_sample.rgb; - let current_w = current_sample.a; - - var history = current; - var history_w = current_w; - var history_depth = current_depth_key; - var history_confidence = 0.0; - var history_reactive = 0.0; - var history_feature_lock = 0.0; - let history_in_bounds = - prev_uv.x >= 0.0 && prev_uv.x <= 1.0 && - prev_uv.y >= 0.0 && prev_uv.y <= 1.0; - if (history_in_bounds) { - let h_sample = sample_history_reprojected(prev_uv, camera_moving); - history = h_sample.rgb; - history_w = h_sample.a; - let history_depth_dims = vec2(textureDimensions(history_depth_tex)); - let history_depth_coord = clamp( - vec2(floor(prev_uv * vec2(history_depth_dims))), - vec2(0), - history_depth_dims - vec2(1), - ); - let history_provenance = textureLoad(history_depth_tex, history_depth_coord, 0).rg; - history_depth = history_provenance.r; - let temporal_history = unpack_temporal_history(history_provenance.g); - // Negative provenance came from a reactive frame. The ordinary path - // resets its confidence and detail lock, consuming current color if the - // last reactive contributor disappeared with its lazy topology. - let history_payload_usable = select( - 1.0 - temporal_history.reactive, - 1.0, - preserve_reactive_history, - ); - history_confidence = temporal_history.confidence * history_payload_usable; - history_feature_lock = temporal_history.feature_lock * history_payload_usable; - history_reactive = temporal_history.reactive * - select(0.0, 1.0, preserve_reactive_history); - } - - // Reject history whose geometric provenance no longer matches the world - // point reprojected into the previous camera. This is the missing guard - // that color variance cannot provide: a pale counter and pale wall can be - // statistically similar while belonging to different surfaces, creating - // the translucent gray haze seen during camera motion. - // Variance clamp in YCoCg (Karis 2014). Per-channel RGB min/max - // clamping was producing chromatic sparkle on the stone floor - // at grazing angles: high-frequency normal-map specular makes - // each jittered frame's Cg/Co vary significantly, and clamping - // each channel independently lets history's chroma get pinned - // to whatever the current frame's specific Cg/Co range was. - // Clamping only the *luma* axis (Y) preserves chroma stability - // across frames; the 1σ variance range is a statistical clamp - // that absorbs single-pixel outliers without collapsing to a - // hard min/max bound. - // Define the clipping neighborhood from the OUTPUT-pixel footprint mapped - // into input texels. At 0.75 scale the old one-input-texel offsets mixed a - // 33% wider spatial neighborhood into every history decision and softened - // detail after confidence converged. Using the exact output footprint at - // 0.75 and above remains stable in the slow-pan corpus. Lower tiers keep - // the proven 0.8-input-pixel floor, where adjacent output positions are - // too strongly correlated for a tighter statistical window. Packing scale - // into the existing projection flag adds no uniform bytes, bindings, - // samples, or passes. - let statistics_footprint = select( - 0.80, - reconstruction_scale, - reconstruction_scale >= 0.75, - ); - let statistics_texel = input_texel * statistics_footprint; - // Keep the center statistical sample bilinear. The reconstructed current - // contains the cubic filter's negative-lobe response; feeding that into - // the variance estimate makes the clamp breathe with the reconstruction - // phase even at native scale. This lookup existed in the baseline path, - // so retaining it does not add a performance cost versus shipped TAA. - var mean: vec3; - var stddev: vec3; - if (use_fractional_statistics) { - mean = fractional_mean; - stddev = fractional_stddev; - } else { - var m1 = rgb_to_ycocg(center_rgb); - var m2 = m1 * m1; - let statistics_offsets = array, 4>( - vec2(-1.0, 0.0), vec2(1.0, 0.0), - vec2(0.0, -1.0), vec2(0.0, 1.0), - ); - for (var i = 0; i < 4; i = i + 1) { - let s_uv = src_uv + statistics_offsets[i] * statistics_texel; - let s_rgb = textureSampleLevel(composed_tex, composed_samp, s_uv, 0.0).rgb; - let s = rgb_to_ycocg(s_rgb); - m1 = m1 + s; - m2 = m2 + s * s; - } - let n_samples = 5.0; - mean = m1 / n_samples; - // A five-sample cross measures 3/5 of the first-order variance measured by - // the former 3x3 grid over the same radius (2/5 versus 2/3 per axis). - // Correct that known sampling bias so the history clip preserves the same - // linear-ramp bandwidth while spending the four saved reads on the exact - // cubic's diagonal lobes. - let variance = - max(m2 / n_samples - mean * mean, vec3(0.0)) * (5.0 / 3.0); - stddev = sqrt(variance); - } - - // Motion-aware γ + alpha. At rest γ=1.25 lets sub-pixel jitter - // history through for smooth accumulation. Under any camera - // motion γ collapses fast to 0.25 — forces reprojected - // history within a quarter-sigma of the neighborhood mean, - // which is tight enough to reject the 'dark column in - // history, bright wall in current' case that the wider band - // let slip. alpha ramps to 0.85 at the same time so remaining - // history contributes only 15 %. - let gamma = mix(1.25, 0.25, motion_alpha); - let y_min = mean.x - gamma * stddev.x; - let y_max = mean.x + gamma * stddev.x; - - let history_ycocg = rgb_to_ycocg(history); - let history_y_clamped = clamp(history_ycocg.x, y_min, y_max); - // Chroma is clamped too, but at 3x the luma band (flicker fix). - // Fully unclamped chroma let stale history colour bleed through on - // high-contrast edges — green terrain fringing crawling along cloud - // and canopy silhouettes during camera motion. The loose band keeps - // the anti-sparkle intent of the luma-only design (a hard - // per-channel clamp caused chromatic sparkle on grazing stone) - // while bounding gross cross-object colour bleed. - let c_gamma = gamma * 3.0; - let co_clamped = clamp(history_ycocg.y, - mean.y - c_gamma * stddev.y, mean.y + c_gamma * stddev.y); - let cg_clamped = clamp(history_ycocg.z, - mean.z - c_gamma * stddev.z, mean.z + c_gamma * stddev.z); - let clamped_history = ycocg_to_rgb(vec3(history_y_clamped, co_clamped, cg_clamped)); - - // Color-change rejection has two deliberately different operating - // ranges. During motion, a tight clamped-luma test helps flush a sample - // that reprojected onto the wrong side of a textured silhouette. At rest, - // however, jitter phases are SUPPOSED to differ inside the source - // footprint: treating a perfectly ordinary 0.6-sigma texture excursion as - // a disocclusion forced the low-resolution current sample into most Bistro - // pixels every frame and prevented temporal super-resolution from ever - // accumulating its sub-pixel samples. - let history_dist = abs(history_y_clamped - mean.x); - - // A stationary scene can still change illumination/emissive state or - // inherit poisoned chroma, so it is not safe to disable color rejection - // outright. Collapse luma and half-weighted chroma into one max-norm - // distance. This deliberately avoids a chroma vector length (sqrt) and - // multiple smoothstep evaluations in the full-output-resolution pass. - // Coherent lighting/material changes on a flat surface cross the broad - // band immediately; valid jittered texture samples stay inside it and are - // variance-clipped before blending. - let raw_color_delta = abs(history_ycocg - mean); - let gross_color_dist = max( - raw_color_delta.x, - max(raw_color_delta.y, raw_color_delta.z) * 0.5, - ); - let gross_color_sigma = max( - stddev.x, - max(stddev.y, stddev.z) * 0.5, - ); - let color_dist = mix(gross_color_dist, history_dist, motion_alpha); - let reject_lo = mix( - max(gross_color_sigma * 3.0, 0.02), - stddev.x * 0.25, - motion_alpha, - ); - let reject_hi = mix( - max(gross_color_sigma * 6.0, 0.06), - max(stddev.x, 0.0001), - motion_alpha, - ); - let disocclusion = smoothstep(reject_lo, reject_hi, color_dist); - - // Depth provenance needs a screen gradient to tolerate a real geometric - // edge. Static color-change classification needs the same quad footprint - // below. Take both derivatives as one vector pair so the successful narrow - // phase-strip detector does not add another derivative pair to full-screen - // TAA. - let temporal_gradients = - abs(dpdx(vec2(expected_prev_depth, disocclusion))) + - abs(dpdy(vec2(expected_prev_depth, disocclusion))); - let depth_base_tolerance = 0.02 + abs(expected_prev_depth) * 0.005; - let depth_tolerance = max( - depth_base_tolerance, - min(temporal_gradients.x * 2.0, depth_base_tolerance * 4.0), - ); - let depth_error = abs(history_depth - expected_prev_depth); - let raw_depth_disocclusion = select( - 0.0, - smoothstep(depth_tolerance, depth_tolerance * 2.0, depth_error), - history_in_bounds, - ); - - // A stationary projection still jitters the low-resolution depth raster. - // Thin geometry and grazing, finely tessellated surfaces can therefore - // alternate which depth owns an output pixel even though their temporal - // color footprint remains compatible. Treating that coverage change as a - // geometric disocclusion repeatedly discarded settled history and made - // native and fractional Bistro detail flicker with a zero motion buffer. - // - // Suppress depth-only rejection only for the exact static case. Camera - // motion keeps the renderer-owned camera flag; object motion keeps its - // velocity or the closest-depth divergence signal; topology/material - // changes still use reactive and broad color rejection. This adds no - // samples or resources and does not weaken moving silhouettes. - let static_zero_velocity = !camera_moving - && vel_len < 0.0000001 - && velocity_divergence < 0.0000001; - let jitter_coverage_compatible = gross_color_dist <= reject_hi; - let depth_disocclusion = select( - raw_depth_disocclusion, - 0.0, - static_zero_velocity && jitter_coverage_compatible, - ); - - // Divergent neighbor motion marks a silhouette/disocclusion footprint. - // Prefer the current frame there even when both individual vectors are - // small, rather than allowing a long-lived cross-surface history average. - let divergence_alpha = smoothstep(0.00025, 0.003, velocity_divergence); - // Moving high-frequency reconstruction must not accumulate a long-lived - // phase lag. Its existing detail classifier is already paid for by the - // fractional rectification footprint; give those pixels a modest current - // floor while leaving smooth surfaces and every stationary path unchanged. - let feature_motion_floor = current_feature_lock * - select(0.0, 0.15, vel_len >= 0.0005); - let motion_ramped = max( - max(mix(current_weight, 0.85, motion_alpha), divergence_alpha), - feature_motion_floor, - ); - // Reactive coverage is injected by the material-aware TAA variant. Keep a - // concrete zero in the base shader so confidence policy is identical for - // both variants and diagnostics can report the real persistent lock. - let current_reactive = 0.0; - let reactive = 0.0; - // Confidence represents geometric/material continuity. Do not erase that - // persistent fact for a luma-only neighborhood excursion: sub-pixel normal - // and texture detail can cross the color-disocclusion threshold at rest, - // and repeatedly unlocking those valid pixels turns detail into shimmer. - // Color disocclusion still raises alpha below; it simply does not destroy - // the longer-lived geometric lock. - let temporal_rejection = max(depth_disocclusion, max(divergence_alpha, reactive)); - - // A confidence value of 1 represents sixteen compatible samples. At rest, - // enforce the unbiased running-average weight 1/(N+1) until lock; after - // lock, the authored steady-state alpha takes over. Under even sub-pixel - // motion the established motion policy already bounds stale history, so - // disable bootstrap before 0.04 output pixels/frame rather than turning a - // safe resolve into visibly noisier current samples during a slow pan. - let history_usable = history_in_bounds && current_weight < 0.999; - let history_sample_count = history_confidence * 16.0; - let bootstrap_running_alpha = select( - 1.0, - fractional_coverage / (history_sample_count + fractional_coverage), - history_usable, - ); - let bootstrap_static = 1.0 - smoothstep(0.00001, 0.0001, vel_len); - let bootstrap_alpha = mix(current_weight, bootstrap_running_alpha, bootstrap_static); - // A locked native pixel sees the same finite jitter phases repeat. The - // authored 10% current weight makes them shimmer even when a slow pan has - // valid, coherent reprojection. Keep every geometric/reactive guard and - // the static 24-frame window. Compatible color excursions retain that - // window; changes outside the band still unlock, while gradual lighting - // converges normally. Coherent slow motion uses the qualified 8.5% cap. - let settled_coherent_lock = select( - 0.0, - 1.0, - history_confidence >= 0.999 && - (!camera_moving || reconstruction_scale > 0.95) && - reprojection_motion < 0.00025 && - jitter_coverage_compatible && - current_weight >= 0.095 && - temporal_rejection <= 0.01, - ); - // A zero-velocity finite-depth surface is not disoccluding merely because - // the next finite jitter phase shades a different point inside the same - // output pixel. Restrict this protection to a narrow color-disocclusion - // footprint: ordinary static pixels keep the authored 24-frame window, - // broad lighting/material changes keep normal color rejection, and the - // procedural sky remains free to animate without geometry velocity. - let settled_static_phase_candidate = select( - 0.0, - 1.0, - history_confidence >= 0.999 && - static_zero_velocity && - depth < 0.9999 && - current_weight >= 0.095 && - temporal_rejection <= 0.01, - ); - let rejected_color_phase = select(0.0, 1.0, disocclusion >= 0.10); - let narrow_color_phase = - rejected_color_phase * smoothstep(0.05, 0.50, temporal_gradients.y); - let settled_static_phase_lock = settled_static_phase_candidate * narrow_color_phase; - let settled_lock = max(settled_coherent_lock, settled_static_phase_lock); - let static_current_cap = mix(0.041666667, 0.015625, settled_static_phase_lock); - let settled_current_cap = select(static_current_cap, 0.085, camera_moving); - let color_motion_ramped = max(motion_ramped, disocclusion); - let settled_motion_ramped = mix( - color_motion_ramped, - min(color_motion_ramped, settled_current_cap), - settled_lock, - ); - let settled_bootstrap_alpha = mix( - bootstrap_alpha, - min(bootstrap_alpha, settled_current_cap), - settled_lock, - ); - let alpha = max( - max(settled_motion_ramped, max(depth_disocclusion, reactive)), - settled_bootstrap_alpha, - ); - let accepted_history = select(0.0, 1.0 - temporal_rejection, history_usable); - let next_history_confidence = - min(history_confidence + 1.0 / 16.0, 1.0) * accepted_history; - // Keep structural lifetime independent from ordinary color confidence. - // Existing geometric/reactive acceptance, the broad color band, and a - // stationary camera kill a stale lock; compatible moving detail seeds or - // renews it. The lock only protects rectification, leaving the qualified - // accumulation alpha intact. - let feature_shading_stable = gross_color_dist <= reject_hi * 4.0; - let protected_feature_lock = select( - 0.0, - history_feature_lock, - camera_moving && accepted_history >= 0.99 && feature_shading_stable, - ); - let next_feature_lock = max( - select(current_feature_lock, 0.0, current_reactive > 0.01), - protected_feature_lock, - ); - let rectification_lock = max(settled_static_phase_lock, protected_feature_lock); - let stable_history = mix(clamped_history, history, rectification_lock); - var blended = mix(stable_history, current, alpha); - // The temporal average suppresses some of the reconstruction's - // source-phase residual. On a settled stationary surface, feed a bounded - // part of that already-computed current-vs-linear residual through the - // temporal update: tying it to alpha lets history accumulate the detail - // instead of exposing one source phase at full strength. During camera - // motion, provenance now follows the same jitter-corrected input texel as - // color; retain only two percent of the bounded current residual to avoid - // replacing that stability with history lag. Object-only motion keeps its - // established response. Render-scale changes rebuild the targets and - // reset history, so policies cannot splice across one accumulation epoch. - // This adds no samples and vanishes at native scale. - let settled_static = history_confidence - * (1.0 - motion_alpha) - * select(1.0, 0.0, camera_moving); - let fractional_reconstruction = 1.0 - smoothstep(0.95, 1.0, reconstruction_scale); - let reconstruction_detail = clamp( - current - center_rgb, - vec3(-0.08), - vec3(0.08), - ); - let reconstruction_detail_weight = select( - 0.20, - 3.0 * alpha, - reconstruction_scale >= 0.75, - ); - // Retain the qualified two-percent moving residual on every fractional - // surface. Pixels already admitted by the production high-frequency - // detail classifier receive a bounded additional four percent. This - // targets alpha-cutout foliage and authored microtexture without changing - // smooth surfaces, adding samples, or widening the classifier. - let moving_reconstruction_detail_weight = select( - 0.0, - 0.02 + 0.04 * current_feature_lock, - reconstruction_geometry_phase, - ); - blended = max( - blended + reconstruction_detail * - ((reconstruction_detail_weight * settled_static + - moving_reconstruction_detail_weight) * fractional_reconstruction), - vec3(0.0), - ); - let blended_w = mix(history_w, current_w, alpha); - return TaaOut( - vec4(blended, blended_w), - vec2( - current_depth_key, - pack_temporal_history(next_history_confidence, 0.0, next_feature_lock), - ), - ); -} -", -); +#[path = "temporal_aa.rs"] +mod temporal_aa; +pub(in crate::renderer) use temporal_aa::TAA_SHADER_WGSL; /// Auto-exposure update shader. Runs at 1×1 viewport → single /// fragment. Samples hdr_rt at a 4×4 grid (16 taps), averages diff --git a/native/shared/src/renderer/shaders/refractive.rs b/native/shared/src/renderer/shaders/refractive.rs new file mode 100644 index 00000000..c73f3407 --- /dev/null +++ b/native/shared/src/renderer/shaders/refractive.rs @@ -0,0 +1,641 @@ +//! Imported transmission shader specialization. + +/// Build the dedicated imported-transmission scene shader without changing +/// the shader compiled for ordinary scene materials. +/// +/// Desktop/native backends add group 4 and sample the render-graph-owned +/// pre-translucency color/depth snapshots. Four-bind-group targets +/// (`fold_scene_inputs`: WebGPU/Android) compile the same physical lobe but +/// source transmitted radiance from the environment map instead. +pub(in crate::renderer) fn scene_refractive_shader_source( + base_scene_shader: &str, + folded_scene_inputs: bool, + screen_space_reflections: bool, + secondary_uv: bool, +) -> String { + assert!( + !folded_scene_inputs || !screen_space_reflections, + "folded four-bind-group targets cannot add native reflection inputs" + ); + const JOINT_DECLARATION: &str = + "@group(3) @binding(1) var joints_prev: JointMatrices;"; + let scene_inputs = if folded_scene_inputs { + "" + } else if screen_space_reflections { + r#" +struct RefractiveReflectionParams { + view: mat4x4, + proj: mat4x4, + params: vec4, + planar_plane: vec4, +}; + +@group(4) @binding(0) var refractive_scene_color_tex: texture_2d; +@group(4) @binding(1) var refractive_scene_color_samp: sampler; +@group(4) @binding(2) var refractive_scene_depth_tex: texture_depth_2d; +@group(4) @binding(3) var refractive_reflection: RefractiveReflectionParams; +@group(4) @binding(4) var refractive_planar_tex: texture_2d; +"# + } else { + r#" +@group(4) @binding(0) var refractive_scene_color_tex: texture_2d; +@group(4) @binding(1) var refractive_scene_color_samp: sampler; +@group(4) @binding(2) var refractive_scene_depth_tex: texture_depth_2d; +"# + }; + let physical_declarations = format!( + r#"{JOINT_DECLARATION} + +struct TransmissionFactors {{ + transmission: vec4, + attenuation: vec4, + transmission_uv: vec4, + transmission_rotation: vec4, + thickness_uv: vec4, + thickness_rotation: vec4, +}}; + +@group(2) @binding(11) var transmission_tex: texture_2d; +@group(2) @binding(12) var transmission_samp: sampler; +@group(2) @binding(13) var thickness_tex: texture_2d; +@group(2) @binding(14) var thickness_samp: sampler; +@group(2) @binding(15) var transmission_material: TransmissionFactors; +{scene_inputs}"# + ); + let mut source = base_scene_shader.replacen(JOINT_DECLARATION, &physical_declarations, 1); + assert_ne!( + source, base_scene_shader, + "scene shader joint declaration changed; refractive ABI injection must be updated" + ); + let layered_secondary_uv = + secondary_uv && base_scene_shader.contains("fn layered_secondary_uv(in:"); + let model_scale_location = if layered_secondary_uv { 8 } else { 7 }; + // The ordinary per-draw group is vertex-visible only. Carry model scale as + // a refractive-variant-only interpolant instead of widening that established + // layout to the fragment stage for every scene material. + source = source.replacen( + " @location(6) prev_clip: vec4,", + &format!( + " @location(6) prev_clip: vec4,\n @location({model_scale_location}) model_scale: f32," + ), + 1, + ); + source = source.replacen( + " o.prev_clip = u.prev_mvp * prev_world4;", + " o.prev_clip = u.prev_mvp * prev_world4;\n\ + o.model_scale = (length(u.model[0].xyz) + length(u.model[1].xyz) \ + + length(u.model[2].xyz)) / 3.0;", + 1, + ); + source = source.replacen( + " out.prev_clip = u.prev_mvp * vec4(prev_local, 1.0);", + " out.prev_clip = u.prev_mvp * vec4(prev_local, 1.0);\n\ + out.model_scale = (length(u.model[0].xyz) + length(u.model[1].xyz) \ + + length(u.model[2].xyz)) / 3.0;", + 1, + ); + if secondary_uv && !layered_secondary_uv { + source = source.replacen( + " @location(6) tangent: vec4,\n};", + " @location(6) tangent: vec4,\n\ + @location(7) secondary_uv: vec2,\n\ + };", + 1, + ); + source = source.replacen( + " @location(7) model_scale: f32,", + " @location(7) model_scale: f32,\n\ + @location(8) secondary_uv: vec2,", + 1, + ); + source = source.replacen( + " o.uv = in.uv;", + " o.uv = in.uv;\n o.secondary_uv = in.secondary_uv;", + 1, + ); + source = source.replacen( + " out.uv = in.uv;", + " out.uv = in.uv;\n out.secondary_uv = in.secondary_uv;", + 1, + ); + assert!( + source.contains("@location(8) secondary_uv"), + "scene vertex ABI changed; refractive UV1 injection must be updated" + ); + } else if layered_secondary_uv { + assert!( + source.contains("@location(7) secondary_uv") + && source.contains("@location(8) model_scale"), + "layered refractive vertex ABI changed; specialization must be updated" + ); + } + + let transmitted_radiance = if folded_scene_inputs { + r#" + // Constrained four-bind-group fallback: preserve the refractive material + // type and its Fresnel/absorption response, but source off-screen + // transmitted radiance from the prefiltered environment. + let max_transmission_mip = max(f32(textureNumLevels(env_tex)) - 1.0, 0.0); + let transmitted_radiance = env_sample_lod( + refracted_direction, + roughness * max_transmission_mip, + ); + let undistorted_radiance = env_sample_lod(-v, roughness * max_transmission_mip); +"# + } else { + r#" + let scene_dimensions_u = textureDimensions(refractive_scene_color_tex, 0); + let scene_dimensions = vec2(scene_dimensions_u); + let current_ndc = in.curr_clip.xy / max(abs(in.curr_clip.w), 0.000001); + let current_uv = clamp( + vec2(current_ndc.x * 0.5 + 0.5, 0.5 - current_ndc.y * 0.5), + vec2(0.0001), + vec2(0.9999), + ); + + // Convert the refracted world-space direction into a stable screen-space + // travel distance using the fragment's world-position derivatives. The + // material factor already carries static glTF node scale baked by the + // importer; the interpolant adds the later draw/instance scale. A 64-pixel + // cap prevents pathological assets from sampling unrelated frame regions. + let world_dx = dpdx(in.world_pos); + let world_dy = dpdy(in.world_pos); + let world_dx_len = max(length(world_dx), 0.000001); + let world_dy_len = max(length(world_dy), 0.000001); + let screen_tangent_x = world_dx / world_dx_len; + let screen_tangent_y = world_dy / world_dy_len; + let ray_distance = thickness_world + / max(abs(dot(refracted_direction, n)), 0.15); + var offset_pixels = vec2( + dot(refracted_direction, screen_tangent_x) * ray_distance / world_dx_len, + dot(refracted_direction, screen_tangent_y) * ray_distance / world_dy_len, + ); + offset_pixels = clamp(offset_pixels, vec2(-64.0), vec2(64.0)); + var refracted_uv = clamp( + current_uv + offset_pixels / scene_dimensions, + vec2(0.0001), + vec2(0.9999), + ); + + // Reject offsets that cross in front of this glass surface. This keeps a + // nearby opaque silhouette from being pulled through the refractor. + let candidate_pixel = clamp( + vec2(refracted_uv * scene_dimensions), + vec2(0), + vec2(scene_dimensions_u) - vec2(1), + ); + let candidate_depth = textureLoad( + refractive_scene_depth_tex, + candidate_pixel, + 0, + ); + if (candidate_depth + 0.0005 < in.clip_position.z) { + refracted_uv = current_uv; + } + + var transmitted_radiance = textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + refracted_uv, + 0.0, + ).rgb; + // Deterministic five-tap rough transmission. Smooth glass stays at one + // fetch; rough glass integrates a bounded footprint without temporal noise. + if (roughness > 0.08) { + let blur_uv = vec2(8.0 * roughness * roughness) / scene_dimensions; + transmitted_radiance = ( + transmitted_radiance * 4.0 + + textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + refracted_uv + vec2(blur_uv.x, 0.0), + 0.0, + ).rgb + + textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + refracted_uv - vec2(blur_uv.x, 0.0), + 0.0, + ).rgb + + textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + refracted_uv + vec2(0.0, blur_uv.y), + 0.0, + ).rgb + + textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + refracted_uv - vec2(0.0, blur_uv.y), + 0.0, + ).rgb + ) * 0.125; + } + let undistorted_radiance = textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + current_uv, + 0.0, + ).rgb; +"# + }; + + let reflection_helpers = if screen_space_reflections { + r#" +fn refractive_screen_reflection( + in: VertexOutputScene, + reflected_direction: vec3, + roughness: f32, + environment_fallback: vec3, +) -> vec3 { + // The ordinary SSR target cannot be reused here: it was traced from the + // opaque surface behind this fragment and therefore owns a different + // normal/reflection ray. Launch one bounded ray from the glass fragment + // against the immutable opaque snapshots instead. + if (refractive_reflection.params.x < 0.5 + || roughness >= refractive_reflection.params.w) { + return environment_fallback; + } + + let dimensions_u = textureDimensions(refractive_scene_color_tex, 0); + let dimensions = vec2(dimensions_u); + let step_count = u32(refractive_reflection.params.z); + let max_distance = refractive_reflection.params.y; + let start_view = ( + refractive_reflection.view * vec4(in.world_pos, 1.0) + ).xyz; + let reflected_view = normalize(( + refractive_reflection.view * vec4(reflected_direction, 0.0) + ).xyz); + let start_clip = refractive_reflection.proj * vec4(start_view, 1.0); + var previous_ray_depth = start_clip.z / max(abs(start_clip.w), 0.000001); + var hit_uv = vec2(-1.0); + var hit_confidence = 0.0; + + // Quadratic spacing keeps the nearest samples dense enough for window + // frames and props while still reaching the same architectural range as + // the established opaque SSR pass. The loop bound and every texture read + // are fixed by the lazy uniform (currently eight). + for (var step = 0u; step < step_count; step = step + 1u) { + let fraction = f32(step + 1u) / f32(step_count); + let distance = max_distance * fraction * fraction; + let ray_view = start_view + reflected_view * distance; + let ray_clip = refractive_reflection.proj * vec4(ray_view, 1.0); + if (ray_clip.w <= 0.000001) { + break; + } + let ray_ndc = ray_clip.xyz / ray_clip.w; + if (ray_ndc.x <= -1.0 || ray_ndc.x >= 1.0 + || ray_ndc.y <= -1.0 || ray_ndc.y >= 1.0 + || ray_ndc.z <= 0.0 || ray_ndc.z >= 1.0) { + break; + } + let ray_uv = vec2( + ray_ndc.x * 0.5 + 0.5, + 0.5 - ray_ndc.y * 0.5, + ); + let pixel = clamp( + vec2(ray_uv * dimensions), + vec2(0), + vec2(dimensions_u) - vec2(1), + ); + let scene_depth = textureLoad(refractive_scene_depth_tex, pixel, 0); + let depth_delta = ray_ndc.z - scene_depth; + let depth_stride = abs(ray_ndc.z - previous_ray_depth); + let thickness = max(depth_stride * 2.0, 0.00075); + if (scene_depth < 0.9999 + && depth_delta >= 0.0 + && depth_delta <= thickness) { + hit_uv = ray_uv; + hit_confidence = 1.0 - smoothstep( + thickness * 0.25, + thickness, + depth_delta, + ); + break; + } + previous_ray_depth = ray_ndc.z; + } + + if (hit_uv.x < 0.0) { + return environment_fallback; + } + // Suppress the screen boundary before it can pop. Rough glass fades to + // the prefiltered environment rather than returning an incorrectly sharp + // scene-color tap (the snapshot deliberately has no mip chain). + let edge_pixels = min( + min(hit_uv.x, 1.0 - hit_uv.x) * dimensions.x, + min(hit_uv.y, 1.0 - hit_uv.y) * dimensions.y, + ); + let edge_weight = smoothstep(0.0, 8.0, edge_pixels); + let roughness_weight = 1.0 - smoothstep( + refractive_reflection.params.w * 0.45, + refractive_reflection.params.w, + roughness, + ); + let raw = textureSampleLevel( + refractive_scene_color_tex, + refractive_scene_color_samp, + hit_uv, + 0.0, + ).rgb; + let screen_radiance = select(vec3(0.0), raw, raw == raw); + let source_weight = clamp( + edge_weight * roughness_weight * hit_confidence, + 0.0, + 1.0, + ); + return mix(environment_fallback, screen_radiance, source_weight); +} + +fn refractive_planar_sample( + in: VertexOutputScene, + roughness: f32, +) -> vec4 { + let plane = refractive_reflection.planar_plane; + if (dot(plane.xyz, plane.xyz) < 0.5) { + return vec4(0.0, 0.0, 0.0, -1.0); + } + // A plane crossing unrelated vertical glass must not make the global + // first-probe choice leak onto that surface. Use the unperturbed vertex + // normal so authored water waves can still perturb the sampled reflection. + if (abs(dot(normalize(plane.xyz), normalize(in.normal))) < 0.8) { + return vec4(0.0, 0.0, 0.0, -1.0); + } + let plane_distance = abs(dot(plane.xyz, in.world_pos) - plane.w); + if (plane_distance > 0.075) { + return vec4(0.0, 0.0, 0.0, -1.0); + } + let ndc = in.curr_clip.xy / max(abs(in.curr_clip.w), 0.000001); + let uv = clamp( + vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5), + vec2(0.0001), + vec2(0.9999), + ); + let planar = textureSampleLevel( + refractive_planar_tex, + refractive_scene_color_samp, + uv, + 0.0, + ); + let planar_safe = select(vec3(0.0), planar.rgb, planar.rgb == planar.rgb); + // The existing planar capture has no mip chain. Fade its exact reflection + // into the lower tiers as roughness grows instead of returning an + // incorrectly sharp image. Alpha zero is the probe's explicit miss value. + let roughness_weight = 1.0 - smoothstep(0.18, 0.45, roughness); + let source_weight = clamp(planar.a * roughness_weight, 0.0, 1.0); + return vec4(planar_safe, source_weight); +} +"# + } else { + "" + }; + let reflected_radiance = if screen_space_reflections { + r#" + let reflected_environment = env_sample_lod( + reflected_direction, + roughness * max(f32(textureNumLevels(env_tex)) - 1.0, 0.0), + ); + let planar_reflected = refractive_planar_sample(in, roughness); + var reflected = mix( + reflected_environment, + planar_reflected.rgb, + max(planar_reflected.a, 0.0), + ); + // A matching explicit probe is authoritative for its plane: alpha zero is + // its documented geometry miss and therefore reveals the environment sky. + // Only glass without an applicable probe pays for the bounded screen march. + if (planar_reflected.a < 0.0) { + reflected = refractive_screen_reflection( + in, + reflected_direction, + roughness, + reflected_environment, + ); + } +"# + } else { + r#" + let reflected = env_sample_lod( + reflected_direction, + roughness * max(f32(textureNumLevels(env_tex)) - 1.0, 0.0), + ); +"# + }; + + let transmission_source_uv = if secondary_uv { + "select(\n in.uv,\n in.secondary_uv,\n transmission_material.transmission_rotation.w > 0.5,\n )" + } else { + "in.uv" + }; + let thickness_source_uv = if secondary_uv { + "select(\n in.uv,\n in.secondary_uv,\n transmission_material.thickness_rotation.z > 0.5,\n )" + } else { + "in.uv" + }; + source.push_str(&format!( + r#" + +{reflection_helpers} + +fn physical_texture_uv( + uv: vec2, + offset_scale: vec4, + rotation: vec2, +) -> vec2 {{ + let scaled = uv * offset_scale.zw; + let rotated = vec2( + rotation.x * scaled.x - rotation.y * scaled.y, + rotation.y * scaled.x + rotation.x * scaled.y, + ); + return offset_scale.xy + rotated; +}} + +fn refractive_scene_normal(in: VertexOutputScene, front_facing: bool) -> vec3 {{ + var n = normalize(in.normal); + let normal_uv = material_uv( + in.uv, material.uv_transforms[2], material.uv_transforms[3], + ); + let normal_sample = textureSampleBias( + normal_tex, + normal_samp, + normal_uv, + 1.0 + lighting.shadow_cascade_splits.w, + ).xyz * 2.0 - 1.0; + let scaled_normal_sample = vec3( + normal_sample.xy * vec2( + material.uv_transforms[2].w, + material.uv_transforms[3].w, + ), + normal_sample.z, + ); + let mapped = scaled_normal_sample / max(length(scaled_normal_sample), 0.000001); + let tangent_len2 = dot(in.tangent.xyz, in.tangent.xyz); + if (tangent_len2 > 0.0001) {{ + let tangent = normalize(in.tangent.xyz); + let tangent_ortho = normalize(tangent - n * dot(n, tangent)); + let bitangent = cross(n, tangent_ortho) * in.tangent.w; + n = normalize( + tangent_ortho * mapped.x + bitangent * mapped.y + n * mapped.z, + ); + }} else {{ + let tbn = compute_tbn( + dpdx(in.world_pos), + dpdy(in.world_pos), + dpdx(normal_uv), + dpdy(normal_uv), + n, + ); + n = normalize(tbn * mapped); + }} + if (!front_facing) {{ + n = -n; + }} + return n; +}} + +struct RefractiveSceneOut {{ + @location(0) color: vec4, + @location(1) velocity: vec2, +}}; + +@fragment +fn fs_refractive_scene( + in: VertexOutputScene, + @builtin(front_facing) front_facing: bool, +) -> RefractiveSceneOut {{ + // Reuse the established direct/IBL PBR evaluation for the non-transmitted + // energy, then split the dielectric lobe below. This also preserves MASK + // discard semantics for the unusual but legal MASK+transmission case. + let surface = shade_main_scene(in, front_facing); + let n = refractive_scene_normal(in, front_facing); + let v = normalize(lighting.camera_pos.xyz - in.world_pos); + + let base_uv = material_uv( + in.uv, material.uv_transforms[0], material.uv_transforms[1], + ); + let mr_uv = material_uv( + in.uv, material.uv_transforms[4], material.uv_transforms[5], + ); + let base_texel = textureSample(base_color_tex, base_color_samp, base_uv); + var base_color = srgb_to_linear_v(base_texel.rgb) * in.color.rgb; + let base_alpha = base_texel.a * in.color.a; + let mr_texel = textureSample(mr_tex, mr_samp, mr_uv); + let has_mr = material.metal_rough.z > 0.5 && material.metal_rough.z < 1.5; + let has_spec_gloss = material.metal_rough.z > 1.5; + var metallic = select( + clamp(material.metal_rough.x, 0.0, 1.0), + clamp(mr_texel.b * material.metal_rough.x, 0.0, 1.0), + has_mr, + ); + var roughness = select( + clamp(material.metal_rough.y, 0.045, 1.0), + clamp(mr_texel.g * material.metal_rough.y, 0.045, 1.0), + has_mr, + ); + if (has_spec_gloss) {{ + let authored_specular = srgb_to_linear_v(mr_texel.rgb) * + material.spec_gloss.rgb; + let converted = specgloss_to_metalrough_pixel(base_color, authored_specular); + base_color = converted.rgb; + metallic = converted.a; + roughness = clamp( + 1.0 - mr_texel.a * material.spec_gloss.a, + 0.045, + 1.0, + ); + }} + + let transmission_uv = physical_texture_uv( + {transmission_source_uv}, + transmission_material.transmission_uv, + transmission_material.transmission_rotation.xy, + ); + let texture_transmission = select( + 1.0, + textureSample( + transmission_tex, + transmission_samp, + transmission_uv, + ).r, + transmission_material.transmission.w > 0.5, + ); + let dielectric_weight = 1.0 - metallic; + let transmission_weight = clamp( + transmission_material.transmission.x + * texture_transmission + * dielectric_weight, + 0.0, + 1.0, + ); + + let thickness_uv = physical_texture_uv( + {thickness_source_uv}, + transmission_material.thickness_uv, + transmission_material.thickness_rotation.xy, + ); + let texture_thickness = select( + 1.0, + textureSample( + thickness_tex, + thickness_samp, + thickness_uv, + ).g, + transmission_material.transmission_rotation.z > 0.5, + ); + let mean_model_scale = max(in.model_scale, 0.0); + let thickness_world = max( + transmission_material.transmission.z + * texture_thickness + * mean_model_scale, + 0.0, + ); + + let ior = max(transmission_material.transmission.y, 1.0); + let eta = 1.0 / ior; + var refracted_direction = refract(-v, n, eta); + if (dot(refracted_direction, refracted_direction) < 0.000001) {{ + refracted_direction = reflect(-v, n); + }} + refracted_direction = normalize(refracted_direction); + +{transmitted_radiance} + + var absorption = vec3(1.0); + if (transmission_material.attenuation.w > 0.0 && thickness_world > 0.0) {{ + let optical_distance = thickness_world + / transmission_material.attenuation.w; + absorption = pow( + max(transmission_material.attenuation.rgb, vec3(0.000001)), + vec3(optical_distance), + ); + }} + let transmitted = transmitted_radiance * base_color * absorption; + + let f0_scalar = pow((ior - 1.0) / (ior + 1.0), 2.0); + let n_dot_v = clamp(dot(n, v), 0.0, 1.0); + let fresnel = f0_scalar + + (1.0 - f0_scalar) * pow(1.0 - n_dot_v, 5.0); + let reflected_direction = reflect(-v, n); +{reflected_radiance} + + // Energy partition: the ordinary PBR surface owns the opaque fraction; + // the transmission fraction is split exactly between Fresnel reflection + // and absorbed transmitted radiance. + let dielectric_transmission = mix(transmitted, reflected, fresnel); + var hdr = surface.color.rgb * (1.0 - transmission_weight) + + dielectric_transmission * transmission_weight; + + // glTF BLEND+transmission additionally applies base-color alpha. Because + // this fragment already composites against the snapshot, write alpha=1 + // to avoid applying the background a second time in fixed-function blend. + if (material.metal_rough.w < 0.0) {{ + hdr = mix(undistorted_radiance, hdr, clamp(base_alpha, 0.0, 1.0)); + }} + hdr = select(vec3(0.0), hdr, hdr == hdr); + return RefractiveSceneOut(vec4(hdr, 1.0), surface.velocity); +}} +"# + )); + source +} diff --git a/native/shared/src/renderer/shaders/ssgi.rs b/native/shared/src/renderer/shaders/ssgi.rs index 5a229859..02e10644 100644 --- a/native/shared/src/renderer/shaders/ssgi.rs +++ b/native/shared/src/renderer/shaders/ssgi.rs @@ -35,7 +35,8 @@ struct ProbeHeader { diffuse: vec4, // Unfiltered current-frame estimate. Spatial reconstruction uses this to // bound temporal history without feeding neighboring writes back into the - // same dispatch. + // same dispatch. W marks a complete stationary phase ring, whose converged + // radiance must not be clipped to one noisy eight-ray realization. current_diffuse: vec4, // Prior placement at this screen-probe slot. Temporal history is only // retained when both placements describe the same surface. @@ -1516,693 +1517,6 @@ fn cs_main( } "; -/// Probe temporal accumulator. Directional samples are integrated only within -/// the current frame. Sixteen complete phase estimates are retained after -/// world-surface reprojection and averaged as a finite ring, so changing the -/// angular sampling phase cannot reinterpret old radiance as a different -/// direction and a static scene becomes stationary after one complete cycle. -pub(in crate::renderer) const SSGI_PROBE_TEMPORAL_WGSL: &str = " -struct TemporalParams { - // x = phase-ring reciprocal (1/16), - // y = force_refresh (1 → seed every phase with current), - // z = grid_w, w = grid_h - params: vec4, - // x = half_w, y = half_h, z = tile_size, w = projection p00 - size: vec4, - // x = hardware ray provenance/confidence available, - // y = current angular phase [0, 15], - // z = short output-blend current weight, w = camera moving. - confidence: vec4, - // x = world-cache capacity, y = static scene/lighting signature, - // z = monotonic probe frame, w = cache writes allowed. - world_cache: vec4, -}; - -struct ProbeWorldCacheEntry { - key: atomic, - sequence: atomic, - padding: vec2, - world_pos: vec4, - normal: vec4, - diffuse: vec4, -}; - -@group(0) @binding(0) var u: TemporalParams; -@group(0) @binding(1) var radiance_in: texture_3d; -@group(0) @binding(2) var history_in: texture_3d; -@group(0) @binding(3) var history_out: texture_storage_3d; -@group(0) @binding(4) var probes: array; -@group(0) @binding(5) var velocity_tex: texture_2d; -@group(0) @binding(6) var world_cache: array; - -fn world_cache_mix(hash_in: u32, value: u32) -> u32 { - var hash = (hash_in ^ value) * 0x45d9f3bu; - hash = (hash ^ (hash >> 16u)) * 0x45d9f3bu; - return hash ^ (hash >> 16u); -} - -fn world_cache_key(world_pos: vec3, normal: vec3) -> u32 { - let quantized_pos = vec3(round(world_pos * 100.0)); - let encoded_normal = vec2(round(oct_encode(normal) * 255.0)); - var hash = max(u.world_cache.y, 1u); - hash = world_cache_mix(hash, bitcast(quantized_pos.x)); - hash = world_cache_mix(hash, bitcast(quantized_pos.y)); - hash = world_cache_mix(hash, bitcast(quantized_pos.z)); - hash = world_cache_mix(hash, encoded_normal.x | (encoded_normal.y << 8u)); - return max(hash, 1u); -} - -// The trace stores eight equal-weight samples drawn from the receiver's cosine -// hemisphere. Average those current samples and publish the temporally -// filtered diffuse convolution through ProbeHeader. -var diffuse_radiance: array, 64>; -var diffuse_luminance: array; -var confidence_error_samples: array; -var independent_estimator_error: f32; -var current_integrated_shared: vec3; -var phase_ring_settled_shared: u32; -var world_cache_hit_shared: u32; -var world_cache_diffuse_shared: vec3; -var reprojected_history_probe: u32; -var reprojected_history_valid: u32; - -@compute @workgroup_size(8, 2, 1) -fn cs_main( - @builtin(workgroup_id) wg: vec3, - @builtin(local_invocation_id) lid: vec3, -) { - let grid_w = u32(u.params.z); - let grid_h = u32(u.params.w); - if (wg.x >= grid_w || wg.y >= grid_h) { return; } - - let probe_index = wg.y * grid_w + wg.x; - let coord = vec3(i32(wg.x), i32(wg.y), i32(lid.y * PROBE_OCT_SIZE + lid.x)); - let lane = lid.y * PROBE_OCT_SIZE + lid.x; - // Trace owns eight cosine-hemisphere rays. The other eight lanes own the - // remaining integrated-phase slots; they never read stale trace layers. - var current_sample = vec4(0.0); - if (lane < PROBE_TRACE_RAYS) { - let raw_current_sample = textureLoad(radiance_in, coord, 0); - current_sample = vec4( - bounded_probe_history(raw_current_sample.rgb), - max(raw_current_sample.a, 0.0), - ); - } - let curr = current_sample.rgb; - confidence_error_samples[lane] = 0.0; - - if (lane == 0u) { - reprojected_history_probe = probe_index; - reprojected_history_valid = 0u; - let current_world_pos = probes[probe_index].world_pos; - let current_normal = probes[probe_index].normal; - if (current_world_pos.w >= 0.5) { - let current_phase = u32(round(u.confidence.y)) & 15u; - let placement_motion = select(0.0, 1.0, u.confidence.w > 0.5); - let current_jitter = probe_lattice_jitter(current_phase) * placement_motion; - let current_uv = ( - vec2(wg.xy) * u.size.z + - u.size.z * (vec2(0.5) + current_jitter) - ) / u.size.xy; - let velocity_size = vec2(textureDimensions(velocity_tex)); - let velocity_coord = clamp( - vec2(current_uv * vec2(velocity_size)), - vec2(0), - velocity_size - vec2(1), - ); - let velocity = textureLoad(velocity_tex, velocity_coord, 0).xy; - // Velocity stores current-minus-previous NDC. UV's Y axis is - // flipped, matching the established TAA and SSR reprojection. - let previous_uv = vec2( - current_uv.x - velocity.x, - current_uv.y + velocity.y, - ); - if (all(previous_uv >= vec2(0.0)) && - all(previous_uv <= vec2(1.0))) { - let previous_phase = (current_phase + 15u) & 15u; - // A start/stop transition can differ by at most half a tile; - // the existing 3x3 same-plane search below covers it. During - // continuous motion this is the exact prior lattice phase. - let previous_jitter = - probe_lattice_jitter(previous_phase) * placement_motion; - let previous_grid_position = - previous_uv * u.size.xy / u.size.z - - vec2(0.5) - previous_jitter; - let previous_grid_center = vec2( - floor(previous_grid_position + vec2(0.5)), - ); - - // The nearest prior grid sample can sit half a tile from - // this surface point. Derive a world-space acceptance - // radius from that footprint instead of using a fixed - // tolerance that collapses as resolution/depth changes. - let probe_world_spacing = - 2.0 * max(current_normal.w, 0.1) * u.size.z / - max(abs(u.size.w) * u.size.x, 0.0001); - let maximum_world_shift = 0.05 + probe_world_spacing * 0.9; - // A lateral screen-probe shift may span most of one footprint, - // but movement through the surface normal must stay within a - // thin same-plane slab. Otherwise parallel foreground detail - // can donate its bright history to the wall behind it. - let maximum_plane_shift = 0.025 + probe_world_spacing * 0.08; - var best_score = 1e30; - for (var dy = -1; dy <= 1; dy = dy + 1) { - for (var dx = -1; dx <= 1; dx = dx + 1) { - let candidate_xy = previous_grid_center + vec2(dx, dy); - if (candidate_xy.x < 0 || candidate_xy.y < 0 || - candidate_xy.x >= i32(grid_w) || candidate_xy.y >= i32(grid_h)) { - continue; - } - let candidate_index = - u32(candidate_xy.y) * grid_w + u32(candidate_xy.x); - let previous_world_pos = probes[candidate_index].previous_world_pos; - let previous_normal = probes[candidate_index].previous_normal; - if (!probe_history_geometry_values_valid( - current_world_pos, - current_normal, - previous_world_pos, - previous_normal, - maximum_world_shift, - maximum_plane_shift, - )) { - continue; - } - let world_shift = distance( - current_world_pos.xyz, - previous_world_pos.xyz, - ); - let normal_penalty = 1.0 - clamp(dot( - current_normal.xyz, - previous_normal.xyz, - ), 0.0, 1.0); - let score = world_shift + normal_penalty * maximum_world_shift; - if (score < best_score) { - best_score = score; - reprojected_history_probe = candidate_index; - reprojected_history_valid = 1u; - } - } - } - } - } - } - workgroupBarrier(); - - // Read a fully converged value previously published for this exact world - // surface. The sequence is sampled on both sides of the payload so a - // concurrent refresh can never expose a torn record. - if (u.world_cache.x > 0u) { - if (lane == 0u) { - world_cache_hit_shared = 0u; - world_cache_diffuse_shared = vec3(0.0); - let current_world_pos = probes[probe_index].world_pos; - let current_normal = probes[probe_index].normal; - if (current_world_pos.w >= 0.5) { - let key = world_cache_key(current_world_pos.xyz, current_normal.xyz); - let first_slot = key % u.world_cache.x; - for (var attempt = 0u; attempt < 8u; attempt = attempt + 1u) { - let slot = (first_slot + attempt) % u.world_cache.x; - if (atomicLoad(&world_cache[slot].key) != key) { continue; } - let sequence_before = atomicLoad(&world_cache[slot].sequence); - if (sequence_before == 0u || (sequence_before & 1u) != 0u) { continue; } - let cached_world_pos = world_cache[slot].world_pos; - let cached_normal = world_cache[slot].normal; - let cached_diffuse = world_cache[slot].diffuse.rgb; - let sequence_after = atomicLoad(&world_cache[slot].sequence); - if (sequence_before == sequence_after && - distance(current_world_pos.xyz, cached_world_pos.xyz) <= 0.035 && - dot(current_normal.xyz, cached_normal.xyz) >= 0.98) { - world_cache_hit_shared = 1u; - world_cache_diffuse_shared = bounded_probe_history(cached_diffuse); - break; - } - } - } - } - workgroupBarrier(); - } else if (lane == 0u) { - world_cache_hit_shared = 0u; - world_cache_diffuse_shared = vec3(0.0); - } - - // Direction samples change phase every frame. They are current-frame - // Monte-Carlo samples, not temporal history slots. Accumulating them by - // octel would assign old radiance to a new direction and turn a sparse - // bright hit into a persistent projector-shaped strip. - diffuse_radiance[lane] = curr; - diffuse_luminance[lane] = select( - 0.0, - dot(curr, vec3(0.2126, 0.7152, 0.0722)), - lane < PROBE_TRACE_RAYS, - ); - workgroupBarrier(); - if (lane < 4u) { - diffuse_luminance[lane] = - diffuse_luminance[lane] + diffuse_luminance[lane + 4u]; - } - workgroupBarrier(); - if (lane < 2u) { - diffuse_luminance[lane] = - diffuse_luminance[lane] + diffuse_luminance[lane + 2u]; - } - workgroupBarrier(); - if (lane == 0u) { - diffuse_luminance[0] = - diffuse_luminance[0] + diffuse_luminance[1]; - } - workgroupBarrier(); - - // One fixed ray that happens to intersect a tiny bright texture or lamp - // can represent far more solid angle than the source covers. Prevent that - // quadrature outlier from becoming a whole probe streak. A 5x mean cap - // preserves broad sky/sun fields and only winsorizes energy too - // concentrated for this sampling density. - let ray_luminance = dot( - diffuse_radiance[lane], - vec3(0.2126, 0.7152, 0.0722), - ); - let mean_luminance = diffuse_luminance[0] / f32(PROBE_TRACE_RAYS); - let solid_angle_cap = mean_luminance * 5.0; - let angular_outlier = max( - ray_luminance - mean_luminance * 2.5, - 0.0, - ) / (0.05 + ray_luminance); - if (lane < PROBE_TRACE_RAYS) { - confidence_error_samples[lane] = max( - confidence_error_samples[lane], - angular_outlier * angular_outlier, - ); - if (ray_luminance > solid_angle_cap && ray_luminance > 0.0) { - diffuse_radiance[lane] = - diffuse_radiance[lane] * (solid_angle_cap / ray_luminance); - } - } - workgroupBarrier(); - if (lane == 0u) { - var sample_sum = vec3(0.0); - for (var ray = 0u; ray < PROBE_TRACE_RAYS; ray = ray + 1u) { - sample_sum = sample_sum + diffuse_radiance[ray]; - } - let sample_mean = sample_sum / f32(PROBE_TRACE_RAYS); - var estimator_variance = 0.0; - for (var ray = 0u; ray < PROBE_TRACE_RAYS; ray = ray + 1u) { - let delta = diffuse_radiance[ray] - sample_mean; - estimator_variance = estimator_variance + dot(delta, delta); - } - // Relative standard error of the eight-ray mean drives only the - // geometry-aware reconstruction footprint. Temporal phase accumulation - // supplies 128 angular samples per receiver over a complete cycle. - independent_estimator_error = - sqrt(estimator_variance / 64.0) / - (0.05 + length(sample_mean)); - current_integrated_shared = bounded_probe_history(sample_mean); - probes[probe_index].current_diffuse = vec4(current_integrated_shared, 1.0); - } - workgroupBarrier(); - - // Layers 32..47 are a ring of complete, equal-weight diffuse estimates, - // one for each angular phase. - // Layers 0..7 remain current directional samples for capture-only - // diagnostics. Reproject the integrated ring as a - // unit onto the compatible prior world surface; never read a directional - // lane as history. Invalid history seeds the whole ring with the current - // estimate, avoiding both stale light and a dark 16-frame warm-up. - if (lane < 16u) { - let phase_slot = lane; - let current_phase = u32(u.confidence.y) & 15u; - var phase_integrated = current_integrated_shared; - // A seeded slot supplies stable first-frame output but is not a real - // sample of this angular phase. Its owner.w remains zero until that - // phase is traced at the current surface. - var phase_owner = vec4(0.0); - if (u.world_cache.x > 0u) { - phase_owner = vec4(probes[probe_index].world_pos.xyz, 0.0); - if (phase_slot == current_phase) { - // A hardware phase becomes publishable only after the - // TLAS/card-light field is coherent. Existing pre-coherence - // slots age out over the following complete 16-phase cycle. - phase_owner.w = select(0.0, 1.0, u.world_cache.w != 0u); - } - } - if (u.params.y <= 0.5 && reprojected_history_valid != 0u && - phase_slot != current_phase) { - let history_x = i32(reprojected_history_probe % grid_w); - let history_y = i32(reprojected_history_probe / grid_w); - let history_layer = i32(32u + phase_slot); - phase_integrated = bounded_probe_history(textureLoad( - history_in, - vec3(history_x, history_y, history_layer), - 0, - ).rgb); - if (u.world_cache.x > 0u) { - phase_owner = textureLoad( - history_in, - vec3(history_x, history_y, i32(48u + phase_slot)), - 0, - ); - } - } - diffuse_radiance[32u + phase_slot] = phase_integrated; - // The luminance reduction is dead after current integration. Reuse - // its existing 64 floats for sixteen vec4 phase owners instead of - // increasing workgroup memory and reducing occupancy on software GPUs. - let owner_base = phase_slot * 4u; - diffuse_luminance[owner_base] = phase_owner.x; - diffuse_luminance[owner_base + 1u] = phase_owner.y; - diffuse_luminance[owner_base + 2u] = phase_owner.z; - diffuse_luminance[owner_base + 3u] = phase_owner.w; - textureStore( - history_out, - vec3(i32(wg.x), i32(wg.y), i32(32u + phase_slot)), - vec4(phase_integrated, 1.0), - ); - } - workgroupBarrier(); - - if (u.world_cache.x > 0u) { - if (lane == 0u) { - phase_ring_settled_shared = 1u; - let current_world_pos = probes[probe_index].world_pos; - if (current_world_pos.w < 0.5) { - phase_ring_settled_shared = 0u; - } else { - for (var phase = 0u; phase < 16u; phase = phase + 1u) { - let owner_base = phase * 4u; - let owner = vec4( - diffuse_luminance[owner_base], - diffuse_luminance[owner_base + 1u], - diffuse_luminance[owner_base + 2u], - diffuse_luminance[owner_base + 3u], - ); - // Owner positions are stored in rgba16float, whose absolute - // precision is about 1.6 cm around a 20 m Bistro coordinate. - if (owner.w < 0.5 || - distance(owner.xyz, current_world_pos.xyz) > 0.05) { - phase_ring_settled_shared = 0u; - } - } - } - } - workgroupBarrier(); - } else if (lane == 0u) { - phase_ring_settled_shared = 0u; - } - - if (lane == 0u) { - var phase_sum = vec3(0.0); - for (var phase = 0u; phase < 16u; phase = phase + 1u) { - phase_sum = phase_sum + diffuse_radiance[32u + phase]; - } - let phase_integrated = bounded_probe_history(phase_sum * u.params.x); - var integrated = phase_integrated; - if (u.confidence.x < 0.5 && u.params.y <= 0.5 && - reprojected_history_valid != 0u) { - // Screen/SDF traces can still change at Hi-Z silhouettes even - // after their angular ring is complete. Preserve the established - // short output blend on those approximate backends. Hardware - // queries are deterministic for a fixed world receiver and use - // the phase-stationary ring directly. - let previous_integrated = bounded_probe_history( - probes[reprojected_history_probe].previous_diffuse.rgb, - ); - integrated = mix( - previous_integrated, - phase_integrated, - u.confidence.z, - ); - } - // A returning surface can recover its fully converged value while the - // screen-owned angular ring refills. Never publish transient path - // samples back to the cache: all sixteen phase owners must first agree - // with this receiver. - if (phase_ring_settled_shared == 0u && world_cache_hit_shared != 0u) { - integrated = world_cache_diffuse_shared; - } - // Confidence is populated below on the hardware path. Zero is the - // well-converged default for Hi-Z/SDF; one would incorrectly request - // the widest reconstruction footprint every frame. - probes[probe_index].diffuse = vec4(integrated, 0.0); - - if (phase_ring_settled_shared != 0u && u.world_cache.w != 0u && - u.world_cache.x > 0u) { - let current_world_pos = probes[probe_index].world_pos; - let current_normal = probes[probe_index].normal; - let key = world_cache_key(current_world_pos.xyz, current_normal.xyz); - let first_slot = key % u.world_cache.x; - var destination = u.world_cache.x; - for (var attempt = 0u; attempt < 8u; attempt = attempt + 1u) { - let slot = (first_slot + attempt) % u.world_cache.x; - let existing_key = atomicLoad(&world_cache[slot].key); - if (existing_key == key) { - // The completed phase ring is stationary within this - // scene/light signature. Keep the first coherent value - // immutable so cache hits never race a redundant refresh. - destination = u.world_cache.x; - break; - } - if (existing_key == 0u) { - let claim = atomicCompareExchangeWeak( - &world_cache[slot].key, - 0u, - key, - ); - if (claim.exchanged || claim.old_value == key) { - destination = slot; - break; - } - } - } - if (destination < u.world_cache.x) { - let sequence_odd = u.world_cache.z * 2u + 1u; - let sequence_even = sequence_odd + 1u; - let old_sequence = atomicLoad(&world_cache[destination].sequence); - if ((old_sequence & 1u) == 0u) { - let lock = atomicCompareExchangeWeak( - &world_cache[destination].sequence, - old_sequence, - sequence_odd, - ); - if (lock.exchanged) { - world_cache[destination].world_pos = current_world_pos; - world_cache[destination].normal = current_normal; - world_cache[destination].diffuse = vec4( - phase_integrated, - 1.0, - ); - atomicStore( - &world_cache[destination].sequence, - sequence_even, - ); - } - } - } - } - } - - workgroupBarrier(); - if (lane < 4u) { - confidence_error_samples[lane] = confidence_error_samples[lane] + confidence_error_samples[lane + 4u]; - } - workgroupBarrier(); - if (lane < 2u) { - confidence_error_samples[lane] = confidence_error_samples[lane] + confidence_error_samples[lane + 2u]; - } - workgroupBarrier(); - if (lane == 0u && u.confidence.x > 0.5 && - probes[probe_index].world_pos.w >= 0.5) { - let rms_disagreement = sqrt( - (confidence_error_samples[0] + confidence_error_samples[1]) / - f32(PROBE_TRACE_RAYS), - ); - let confidence_error = max( - rms_disagreement, - independent_estimator_error, - ); - // `diffuse.w` is not sampled as radiance. Preserve the uncertainty for - // the bounded spatial footprint and capture-only diagnostics. - probes[probe_index].diffuse.w = confidence_error; - } - - // Preserve current samples for capture-only diagnostics. They are never - // interpreted as matching temporal directions by the production path. - // Layers 32..47 were written above by the integrated phase ring. - if (lane < PROBE_TRACE_RAYS) { - textureStore(history_out, coord, current_sample); - } - if (lane < 16u) { - let owner_base = lane * 4u; - textureStore( - history_out, - vec3(i32(wg.x), i32(wg.y), i32(48u + lane)), - vec4( - diffuse_luminance[owner_base], - diffuse_luminance[owner_base + 1u], - diffuse_luminance[owner_base + 2u], - diffuse_luminance[owner_base + 3u], - ), - ); - } -} - -// Filter the completed irradiance estimate in probe space, after every probe -// has published its current result. Doing this in a second tiny dispatch avoids -// cross-workgroup races and costs only one invocation per 8x8 half-resolution -// tile. The geometry-clamped reconstruction occupies layer zero of history_out. -// ProbeHeader.previous_diffuse retains unfiltered temporal RGB plus a scalar -// reconstruction energy ratio for fallback resolve. This dispatch reads the -// prior diffuse/current values, not that destination field, so the write has no -// cross-workgroup race and spatial filtering never feeds back into history. -@compute @workgroup_size(8, 8, 1) -fn cs_spatial(@builtin(global_invocation_id) gid: vec3) { - let grid_w = u32(u.params.z); - let grid_h = u32(u.params.w); - if (gid.x >= grid_w || gid.y >= grid_h) { return; } - - let center_index = gid.y * grid_w + gid.x; - let center = probes[center_index]; - let output_coord = vec3(i32(gid.x), i32(gid.y), 0); - if (center.world_pos.w < 0.5) { - probes[center_index].previous_diffuse = vec4(0.0); - textureStore(history_out, output_coord, vec4(0.0)); - return; - } - - let confidence_error = max(center.diffuse.w, 0.0); - // Hardware's complete 128-direction phase ring is intrinsically - // low-frequency. Let the full geometry-clamped 5x5 footprint own it; - // retaining a portion of the screen-tiled centre leaves a camera-fixed - // lattice. Approximate Hi-Z/SDF histories remain variance-adaptive: their - // current-neighborhood clamp changes every angular phase, so forcing the - // widest filter there amplifies rather than suppresses settled variation. - // This is still a tiny probe-domain dispatch (one invocation per 8x8 - // half-res tile), and the normal/plane tests below keep every contribution - // on the same surface and preserve its boundaries. - let hardware_history = u.confidence.x > 0.5; - let radius = select( - select(1, 2, confidence_error > 0.08), - 2, - hardware_history, - ); - let filter_strength = select( - clamp(0.30 + confidence_error * 4.0, 0.30, 1.0), - 1.0, - hardware_history, - ); - let probe_world_spacing = - 2.0 * max(center.normal.w, 0.1) * u.size.z / - max(abs(u.size.w) * u.size.x, 0.0001); - let plane_sigma = 0.02 + probe_world_spacing * 0.16; - - var accum = vec3(0.0); - var weight_sum = 0.0; - var current_first = vec3(0.0); - var current_second = vec3(0.0); - for (var dy = -2; dy <= 2; dy = dy + 1) { - for (var dx = -2; dx <= 2; dx = dx + 1) { - if (abs(dx) > radius || abs(dy) > radius) { continue; } - let sample_xy = vec2(gid.xy) + vec2(dx, dy); - if (sample_xy.x < 0 || sample_xy.y < 0 || - sample_xy.x >= i32(grid_w) || sample_xy.y >= i32(grid_h)) { - continue; - } - let sample_index = u32(sample_xy.y) * grid_w + u32(sample_xy.x); - let sample = probes[sample_index]; - if (sample.world_pos.w < 0.5) { continue; } - - let normal_similarity = clamp( - dot(center.normal.xyz, sample.normal.xyz), - 0.0, - 1.0, - ); - if (normal_similarity < 0.85) { continue; } - let world_delta = sample.world_pos.xyz - center.world_pos.xyz; - let plane_error = max( - abs(dot(world_delta, center.normal.xyz)), - abs(dot(world_delta, sample.normal.xyz)), - ); - if (plane_error > plane_sigma * 2.5) { continue; } - - let offset2 = f32(dx * dx + dy * dy); - let spatial_weight = exp(-0.5 * offset2 / 1.96); - let plane_weight = exp( - -0.5 * plane_error * plane_error / - max(plane_sigma * plane_sigma, 0.000001), - ); - let weight = spatial_weight * plane_weight * pow(normal_similarity, 12.0); - // Spatially reconstruct the world-reprojected integral, not the - // raw 32-ray sample set. Filtering current samples here discarded - // most of the temporal estimator every frame, so camera motion - // exposed a fresh screen-tile pattern even when history had found - // the same wall. The final neighborhood clamp below still bounds - // every retained value by current-frame evidence. - accum = accum + sample.diffuse.rgb * weight; - weight_sum = weight_sum + weight; - let current_neighbor = bounded_probe_history(sample.current_diffuse.rgb); - current_first = current_first + current_neighbor * weight; - current_second = current_second + current_neighbor * current_neighbor * weight; - } - } - - // Approximate Hi-Z/SDF histories still need a current-frame safety bound. - // Bound them by the current neighborhood's mean and spread rather than its - // hard min/max. A sparse estimate of a bright - // nearby source (the sun-lit Bistro awnings under their façade) is - // binomially noisy per probe, so a min/max clamp repeatedly crushed the - // converged EMA toward whichever realization the current frame produced — - // the red bounce pumped in and out as the camera moved. With variance - // bounds, a noisy-but-consistent neighborhood keeps its converged mean, - // while genuinely stale history (disocclusion ghosts, lighting changes) - // still gets pulled to current evidence because agreement between - // neighbors shrinks the spread toward zero. Hardware ray queries already - // maintain a geometry-reprojected finite phase ring; clamping that stable - // 128-direction estimate to the fresh eight-ray screen cells nearly - // doubles its measured motion error and reintroduces the probe lattice. - // The ring seeds current on disocclusion and refreshes one complete phase - // per frame, so the hardware path must not apply this second estimator. - // Keep the software-path floor relative to - // HDR signal scale: the former absolute 0.005 allowance exceeded the - // complete indirect signal on many Bistro facade probes and therefore - // admitted old path-dependent light without clipping it at all. - var history_clamped = center.diffuse.rgb; - if (u.confidence.x < 0.5 && weight_sum > 0.0001) { - let current_mean = current_first / weight_sum; - let current_sigma = sqrt(max( - current_second / weight_sum - current_mean * current_mean, - vec3(0.0), - )); - let slack = current_sigma - + abs(current_mean) * 0.02 - + vec3(0.0001); - history_clamped = clamp( - center.diffuse.rgb, - current_mean - slack, - current_mean + slack, - ); - } - let spatial = select( - history_clamped, - accum / max(weight_sum, 0.0001), - weight_sum > 0.0001, - ); - let reconstructed = bounded_probe_history(mix( - history_clamped, - spatial, - filter_strength, - )); - // This is the authoritative state consumed next frame. Previously only - // `reconstructed` was clamped while `ProbeHeader.diffuse` kept the raw EMA; - // a bright card hit could therefore remain hidden for seconds and become - // visible again when a later current-frame bound happened to include it. - let luminance_weights = vec3(0.2126, 0.7152, 0.0722); - let history_luminance = dot(history_clamped, luminance_weights); - let reconstructed_luminance = dot(reconstructed, luminance_weights); - let reconstructed_energy_ratio = select( - 1.0, - clamp(reconstructed_luminance / history_luminance, 0.0, 4.0), - history_luminance > 0.000001, - ); - probes[center_index].previous_diffuse = - vec4(history_clamped, reconstructed_energy_ratio); - textureStore(history_out, output_coord, vec4(reconstructed, 1.0)); -} -"; +#[path = "ssgi_temporal.rs"] +mod temporal; +pub(in crate::renderer) use temporal::SSGI_PROBE_TEMPORAL_WGSL; diff --git a/native/shared/src/renderer/shaders/ssgi_temporal.rs b/native/shared/src/renderer/shaders/ssgi_temporal.rs new file mode 100644 index 00000000..8fc70e4b --- /dev/null +++ b/native/shared/src/renderer/shaders/ssgi_temporal.rs @@ -0,0 +1,718 @@ +//! World-surface probe history accumulation. + +/// Probe temporal accumulator. Directional samples are integrated only within +/// the current frame. Sixteen complete phase estimates are retained after +/// world-surface reprojection and averaged as a finite ring, so changing the +/// angular sampling phase cannot reinterpret old radiance as a different +/// direction. Repeated complete phases establish a stationary estimate without +/// clamping it to a noisy current-frame neighborhood. +pub(in crate::renderer) const SSGI_PROBE_TEMPORAL_WGSL: &str = " +struct TemporalParams { + // x = phase-ring reciprocal (1/16), + // y = force_refresh (1 → seed every phase with current), + // z = grid_w, w = grid_h + params: vec4, + // x = half_w, y = half_h, z = tile_size, w = projection p00 + size: vec4, + // x = hardware ray provenance/confidence available, + // y = current angular phase [0, 15], + // z = short output-blend current weight, w = camera moving. + confidence: vec4, + // x = world-cache capacity, y = static scene/lighting signature, + // z = monotonic probe frame, w = cache writes allowed. + world_cache: vec4, +}; + +struct ProbeWorldCacheEntry { + key: atomic, + sequence: atomic, + padding: vec2, + world_pos: vec4, + normal: vec4, + diffuse: vec4, +}; + +@group(0) @binding(0) var u: TemporalParams; +@group(0) @binding(1) var radiance_in: texture_3d; +@group(0) @binding(2) var history_in: texture_3d; +@group(0) @binding(3) var history_out: texture_storage_3d; +@group(0) @binding(4) var probes: array; +@group(0) @binding(5) var velocity_tex: texture_2d; +@group(0) @binding(6) var world_cache: array; + +fn world_cache_mix(hash_in: u32, value: u32) -> u32 { + var hash = (hash_in ^ value) * 0x45d9f3bu; + hash = (hash ^ (hash >> 16u)) * 0x45d9f3bu; + return hash ^ (hash >> 16u); +} + +fn world_cache_key(world_pos: vec3, normal: vec3) -> u32 { + let quantized_pos = vec3(round(world_pos * 100.0)); + let encoded_normal = vec2(round(oct_encode(normal) * 255.0)); + var hash = max(u.world_cache.y, 1u); + hash = world_cache_mix(hash, bitcast(quantized_pos.x)); + hash = world_cache_mix(hash, bitcast(quantized_pos.y)); + hash = world_cache_mix(hash, bitcast(quantized_pos.z)); + hash = world_cache_mix(hash, encoded_normal.x | (encoded_normal.y << 8u)); + return max(hash, 1u); +} + +// The trace stores eight equal-weight samples drawn from the receiver's cosine +// hemisphere. Average those current samples and publish the temporally +// filtered diffuse convolution through ProbeHeader. +var diffuse_radiance: array, 64>; +var diffuse_luminance: array; +var confidence_error_samples: array; +var independent_estimator_error: f32; +var current_integrated_shared: vec3; +var phase_ring_settled_shared: u32; +var world_cache_hit_shared: u32; +var world_cache_diffuse_shared: vec3; +var reprojected_history_probe: u32; +var reprojected_history_valid: u32; + +@compute @workgroup_size(8, 2, 1) +fn cs_main( + @builtin(workgroup_id) wg: vec3, + @builtin(local_invocation_id) lid: vec3, +) { + let grid_w = u32(u.params.z); + let grid_h = u32(u.params.w); + if (wg.x >= grid_w || wg.y >= grid_h) { return; } + + let probe_index = wg.y * grid_w + wg.x; + let coord = vec3(i32(wg.x), i32(wg.y), i32(lid.y * PROBE_OCT_SIZE + lid.x)); + let lane = lid.y * PROBE_OCT_SIZE + lid.x; + // Trace owns eight cosine-hemisphere rays. The other eight lanes own the + // remaining integrated-phase slots; they never read stale trace layers. + var current_sample = vec4(0.0); + if (lane < PROBE_TRACE_RAYS) { + let raw_current_sample = textureLoad(radiance_in, coord, 0); + current_sample = vec4( + bounded_probe_history(raw_current_sample.rgb), + max(raw_current_sample.a, 0.0), + ); + } + let curr = current_sample.rgb; + confidence_error_samples[lane] = 0.0; + + if (lane == 0u) { + reprojected_history_probe = probe_index; + reprojected_history_valid = 0u; + let current_world_pos = probes[probe_index].world_pos; + let current_normal = probes[probe_index].normal; + if (current_world_pos.w >= 0.5) { + let current_phase = u32(round(u.confidence.y)) & 15u; + let placement_motion = select(0.0, 1.0, u.confidence.w > 0.5); + let current_jitter = probe_lattice_jitter(current_phase) * placement_motion; + let current_uv = ( + vec2(wg.xy) * u.size.z + + u.size.z * (vec2(0.5) + current_jitter) + ) / u.size.xy; + let velocity_size = vec2(textureDimensions(velocity_tex)); + let velocity_coord = clamp( + vec2(current_uv * vec2(velocity_size)), + vec2(0), + velocity_size - vec2(1), + ); + let velocity = textureLoad(velocity_tex, velocity_coord, 0).xy; + // Velocity stores current-minus-previous NDC. UV's Y axis is + // flipped, matching the established TAA and SSR reprojection. + let previous_uv = vec2( + current_uv.x - velocity.x, + current_uv.y + velocity.y, + ); + if (all(previous_uv >= vec2(0.0)) && + all(previous_uv <= vec2(1.0))) { + let previous_phase = (current_phase + 15u) & 15u; + // A start/stop transition can differ by at most half a tile; + // the existing 3x3 same-plane search below covers it. During + // continuous motion this is the exact prior lattice phase. + let previous_jitter = + probe_lattice_jitter(previous_phase) * placement_motion; + let previous_grid_position = + previous_uv * u.size.xy / u.size.z - + vec2(0.5) - previous_jitter; + let previous_grid_center = vec2( + floor(previous_grid_position + vec2(0.5)), + ); + + // The nearest prior grid sample can sit half a tile from + // this surface point. Derive a world-space acceptance + // radius from that footprint instead of using a fixed + // tolerance that collapses as resolution/depth changes. + let probe_world_spacing = + 2.0 * max(current_normal.w, 0.1) * u.size.z / + max(abs(u.size.w) * u.size.x, 0.0001); + let maximum_world_shift = 0.05 + probe_world_spacing * 0.9; + // A lateral screen-probe shift may span most of one footprint, + // but movement through the surface normal must stay within a + // thin same-plane slab. Otherwise parallel foreground detail + // can donate its bright history to the wall behind it. + let maximum_plane_shift = 0.025 + probe_world_spacing * 0.08; + var best_score = 1e30; + for (var dy = -1; dy <= 1; dy = dy + 1) { + for (var dx = -1; dx <= 1; dx = dx + 1) { + let candidate_xy = previous_grid_center + vec2(dx, dy); + if (candidate_xy.x < 0 || candidate_xy.y < 0 || + candidate_xy.x >= i32(grid_w) || candidate_xy.y >= i32(grid_h)) { + continue; + } + let candidate_index = + u32(candidate_xy.y) * grid_w + u32(candidate_xy.x); + let previous_world_pos = probes[candidate_index].previous_world_pos; + let previous_normal = probes[candidate_index].previous_normal; + if (!probe_history_geometry_values_valid( + current_world_pos, + current_normal, + previous_world_pos, + previous_normal, + maximum_world_shift, + maximum_plane_shift, + )) { + continue; + } + let world_shift = distance( + current_world_pos.xyz, + previous_world_pos.xyz, + ); + let normal_penalty = 1.0 - clamp(dot( + current_normal.xyz, + previous_normal.xyz, + ), 0.0, 1.0); + let score = world_shift + normal_penalty * maximum_world_shift; + if (score < best_score) { + best_score = score; + reprojected_history_probe = candidate_index; + reprojected_history_valid = 1u; + } + } + } + } + } + } + workgroupBarrier(); + + // Read a fully converged value previously published for this exact world + // surface. The sequence is sampled on both sides of the payload so a + // concurrent refresh can never expose a torn record. + if (u.world_cache.x > 0u) { + if (lane == 0u) { + world_cache_hit_shared = 0u; + world_cache_diffuse_shared = vec3(0.0); + let current_world_pos = probes[probe_index].world_pos; + let current_normal = probes[probe_index].normal; + if (current_world_pos.w >= 0.5) { + let key = world_cache_key(current_world_pos.xyz, current_normal.xyz); + let first_slot = key % u.world_cache.x; + for (var attempt = 0u; attempt < 8u; attempt = attempt + 1u) { + let slot = (first_slot + attempt) % u.world_cache.x; + if (atomicLoad(&world_cache[slot].key) != key) { continue; } + let sequence_before = atomicLoad(&world_cache[slot].sequence); + if (sequence_before == 0u || (sequence_before & 1u) != 0u) { continue; } + let cached_world_pos = world_cache[slot].world_pos; + let cached_normal = world_cache[slot].normal; + let cached_diffuse = world_cache[slot].diffuse.rgb; + let sequence_after = atomicLoad(&world_cache[slot].sequence); + if (sequence_before == sequence_after && + distance(current_world_pos.xyz, cached_world_pos.xyz) <= 0.035 && + dot(current_normal.xyz, cached_normal.xyz) >= 0.98) { + world_cache_hit_shared = 1u; + world_cache_diffuse_shared = bounded_probe_history(cached_diffuse); + break; + } + } + } + } + workgroupBarrier(); + } else if (lane == 0u) { + world_cache_hit_shared = 0u; + world_cache_diffuse_shared = vec3(0.0); + } + + // Direction samples change phase every frame. They are current-frame + // Monte-Carlo samples, not temporal history slots. Accumulating them by + // octel would assign old radiance to a new direction and turn a sparse + // bright hit into a persistent projector-shaped strip. + diffuse_radiance[lane] = curr; + diffuse_luminance[lane] = select( + 0.0, + dot(curr, vec3(0.2126, 0.7152, 0.0722)), + lane < PROBE_TRACE_RAYS, + ); + workgroupBarrier(); + if (lane < 4u) { + diffuse_luminance[lane] = + diffuse_luminance[lane] + diffuse_luminance[lane + 4u]; + } + workgroupBarrier(); + if (lane < 2u) { + diffuse_luminance[lane] = + diffuse_luminance[lane] + diffuse_luminance[lane + 2u]; + } + workgroupBarrier(); + if (lane == 0u) { + diffuse_luminance[0] = + diffuse_luminance[0] + diffuse_luminance[1]; + } + workgroupBarrier(); + + // One fixed ray that happens to intersect a tiny bright texture or lamp + // can represent far more solid angle than the source covers. Prevent that + // quadrature outlier from becoming a whole probe streak. A 5x mean cap + // preserves broad sky/sun fields and only winsorizes energy too + // concentrated for this sampling density. + let ray_luminance = dot( + diffuse_radiance[lane], + vec3(0.2126, 0.7152, 0.0722), + ); + let mean_luminance = diffuse_luminance[0] / f32(PROBE_TRACE_RAYS); + let solid_angle_cap = mean_luminance * 5.0; + let angular_outlier = max( + ray_luminance - mean_luminance * 2.5, + 0.0, + ) / (0.05 + ray_luminance); + if (lane < PROBE_TRACE_RAYS) { + confidence_error_samples[lane] = max( + confidence_error_samples[lane], + angular_outlier * angular_outlier, + ); + if (ray_luminance > solid_angle_cap && ray_luminance > 0.0) { + diffuse_radiance[lane] = + diffuse_radiance[lane] * (solid_angle_cap / ray_luminance); + } + } + workgroupBarrier(); + if (lane == 0u) { + var sample_sum = vec3(0.0); + for (var ray = 0u; ray < PROBE_TRACE_RAYS; ray = ray + 1u) { + sample_sum = sample_sum + diffuse_radiance[ray]; + } + let sample_mean = sample_sum / f32(PROBE_TRACE_RAYS); + var estimator_variance = 0.0; + for (var ray = 0u; ray < PROBE_TRACE_RAYS; ray = ray + 1u) { + let delta = diffuse_radiance[ray] - sample_mean; + estimator_variance = estimator_variance + dot(delta, delta); + } + // Relative standard error of the eight-ray mean drives only the + // geometry-aware reconstruction footprint. Temporal phase accumulation + // supplies 128 angular samples per receiver over a complete cycle. + independent_estimator_error = + sqrt(estimator_variance / 64.0) / + (0.05 + length(sample_mean)); + current_integrated_shared = bounded_probe_history(sample_mean); + probes[probe_index].current_diffuse = vec4(current_integrated_shared, 1.0); + } + workgroupBarrier(); + + // Layers 32..47 are a ring of complete, equal-weight diffuse estimates, + // one for each angular phase. + // Layers 0..7 remain current directional samples for capture-only + // diagnostics. Reproject the integrated ring as a + // unit onto the compatible prior world surface; never read a directional + // lane as history. Invalid history seeds the whole ring with the current + // estimate, avoiding both stale light and a dark 16-frame warm-up. + if (lane < 16u) { + let phase_slot = lane; + let current_phase = u32(u.confidence.y) & 15u; + var phase_integrated = current_integrated_shared; + if (u.confidence.x < 0.5) { + // Match the retained phases' precision before comparison and + // reduction. Texture stores may round differently from pack on + // different adapters; an already representable value is exact. + phase_integrated = vec3( + unpack2x16float(pack2x16float(phase_integrated.xy)), + unpack2x16float(pack2x16float(vec2(phase_integrated.z, 0.0))).x, + ); + } + // A seeded slot supplies stable first-frame output but is not a real + // sample of this angular phase. Its owner.w remains zero until that + // phase is traced at the current surface. + var phase_owner = vec4(probes[probe_index].world_pos.xyz, 0.0); + if (phase_slot == current_phase && probes[probe_index].world_pos.w >= 0.5) { + if (u.confidence.x > 0.5) { + // A hardware phase becomes publishable only after the + // TLAS/card-light field is coherent. Existing pre-coherence + // slots age out over the following complete 16-phase cycle. + phase_owner.w = select(0.0, 1.0, u.world_cache.w != 0u); + } else { + // A software phase is stationary only after the same surface + // reproduces its previous complete estimate at rgba16float + // storage precision. A changed phase immediately revokes that + // state, retaining the responsive current-neighborhood clamp. + phase_owner.w = 1.0; + if (u.params.y <= 0.5 && reprojected_history_valid != 0u) { + let history_xy = vec2( + i32(reprojected_history_probe % grid_w), + i32(reprojected_history_probe / grid_w), + ); + let old_phase = textureLoad(history_in, + vec3(history_xy, i32(32u + phase_slot)), 0); + let old_owner = textureLoad(history_in, + vec3(history_xy, i32(48u + phase_slot)), 0); + let same_radiance = all(old_phase.rgb == phase_integrated); + if (old_owner.w >= 1.0 && same_radiance && + distance(old_owner.xyz, phase_owner.xyz) <= 0.05) { + phase_owner.w = 2.0; + } + } + } + } + if (u.params.y <= 0.5 && reprojected_history_valid != 0u && + phase_slot != current_phase) { + let history_x = i32(reprojected_history_probe % grid_w); + let history_y = i32(reprojected_history_probe / grid_w); + let history_layer = i32(32u + phase_slot); + phase_integrated = bounded_probe_history(textureLoad( + history_in, + vec3(history_x, history_y, history_layer), + 0, + ).rgb); + phase_owner = textureLoad( + history_in, + vec3(history_x, history_y, i32(48u + phase_slot)), + 0, + ); + } + diffuse_radiance[32u + phase_slot] = phase_integrated; + // The luminance reduction is dead after current integration. Reuse + // its existing 64 floats for sixteen vec4 phase owners instead of + // increasing workgroup memory and reducing occupancy on software GPUs. + let owner_base = phase_slot * 4u; + diffuse_luminance[owner_base] = phase_owner.x; + diffuse_luminance[owner_base + 1u] = phase_owner.y; + diffuse_luminance[owner_base + 2u] = phase_owner.z; + diffuse_luminance[owner_base + 3u] = phase_owner.w; + textureStore( + history_out, + vec3(i32(wg.x), i32(wg.y), i32(32u + phase_slot)), + vec4(phase_integrated, 1.0), + ); + } + workgroupBarrier(); + + if (lane == 0u) { + phase_ring_settled_shared = 1u; + let current_world_pos = probes[probe_index].world_pos; + if (current_world_pos.w < 0.5) { + phase_ring_settled_shared = 0u; + } else { + for (var phase = 0u; phase < 16u; phase = phase + 1u) { + let owner_base = phase * 4u; + let owner = vec4( + diffuse_luminance[owner_base], + diffuse_luminance[owner_base + 1u], + diffuse_luminance[owner_base + 2u], + diffuse_luminance[owner_base + 3u], + ); + // Owner positions are stored in rgba16float, whose absolute + // precision is about 1.6 cm around a 20 m Bistro coordinate. + let required_owner = select(1.5, 0.5, u.confidence.x > 0.5); + if (owner.w < required_owner || + distance(owner.xyz, current_world_pos.xyz) > 0.05) { + phase_ring_settled_shared = 0u; + } + } + } + } + workgroupBarrier(); + + if (lane == 0u) { + var phase_sum = vec3(0.0); + for (var phase = 0u; phase < 16u; phase = phase + 1u) { + phase_sum = phase_sum + diffuse_radiance[32u + phase]; + } + let phase_integrated = bounded_probe_history(phase_sum * u.params.x); + var integrated = phase_integrated; + probes[probe_index].current_diffuse.w = f32(phase_ring_settled_shared); + if (u.confidence.x < 0.5 && u.params.y <= 0.5 && + reprojected_history_valid != 0u && phase_ring_settled_shared == 0u) { + // Screen/SDF traces can still change at Hi-Z silhouettes even + // after their angular ring is complete. Preserve the established + // short output blend on those approximate backends. Hardware + // queries are deterministic for a fixed world receiver and use + // the phase-stationary ring directly. + let previous_integrated = bounded_probe_history( + probes[reprojected_history_probe].previous_diffuse.rgb, + ); + integrated = mix( + previous_integrated, + phase_integrated, + u.confidence.z, + ); + } + // A returning surface can recover its fully converged value while the + // screen-owned angular ring refills. Never publish transient path + // samples back to the cache: all sixteen phase owners must first agree + // with this receiver. + if (phase_ring_settled_shared == 0u && world_cache_hit_shared != 0u) { + integrated = world_cache_diffuse_shared; + } + // Confidence is populated below on the hardware path. Zero is the + // well-converged default for Hi-Z/SDF; one would incorrectly request + // the widest reconstruction footprint every frame. + probes[probe_index].diffuse = vec4(integrated, 0.0); + + if (phase_ring_settled_shared != 0u && u.world_cache.w != 0u && + u.world_cache.x > 0u) { + let current_world_pos = probes[probe_index].world_pos; + let current_normal = probes[probe_index].normal; + let key = world_cache_key(current_world_pos.xyz, current_normal.xyz); + let first_slot = key % u.world_cache.x; + var destination = u.world_cache.x; + for (var attempt = 0u; attempt < 8u; attempt = attempt + 1u) { + let slot = (first_slot + attempt) % u.world_cache.x; + let existing_key = atomicLoad(&world_cache[slot].key); + if (existing_key == key) { + // The completed phase ring is stationary within this + // scene/light signature. Keep the first coherent value + // immutable so cache hits never race a redundant refresh. + destination = u.world_cache.x; + break; + } + if (existing_key == 0u) { + let claim = atomicCompareExchangeWeak( + &world_cache[slot].key, + 0u, + key, + ); + if (claim.exchanged || claim.old_value == key) { + destination = slot; + break; + } + } + } + if (destination < u.world_cache.x) { + let sequence_odd = u.world_cache.z * 2u + 1u; + let sequence_even = sequence_odd + 1u; + let old_sequence = atomicLoad(&world_cache[destination].sequence); + if ((old_sequence & 1u) == 0u) { + let lock = atomicCompareExchangeWeak( + &world_cache[destination].sequence, + old_sequence, + sequence_odd, + ); + if (lock.exchanged) { + world_cache[destination].world_pos = current_world_pos; + world_cache[destination].normal = current_normal; + world_cache[destination].diffuse = vec4( + phase_integrated, + 1.0, + ); + atomicStore( + &world_cache[destination].sequence, + sequence_even, + ); + } + } + } + } + } + + workgroupBarrier(); + if (lane < 4u) { + confidence_error_samples[lane] = confidence_error_samples[lane] + confidence_error_samples[lane + 4u]; + } + workgroupBarrier(); + if (lane < 2u) { + confidence_error_samples[lane] = confidence_error_samples[lane] + confidence_error_samples[lane + 2u]; + } + workgroupBarrier(); + if (lane == 0u && u.confidence.x > 0.5 && + probes[probe_index].world_pos.w >= 0.5) { + let rms_disagreement = sqrt( + (confidence_error_samples[0] + confidence_error_samples[1]) / + f32(PROBE_TRACE_RAYS), + ); + let confidence_error = max( + rms_disagreement, + independent_estimator_error, + ); + // `diffuse.w` is not sampled as radiance. Preserve the uncertainty for + // the bounded spatial footprint and capture-only diagnostics. + probes[probe_index].diffuse.w = confidence_error; + } + + // Preserve current samples for capture-only diagnostics. They are never + // interpreted as matching temporal directions by the production path. + // Layers 32..47 were written above by the integrated phase ring. + if (lane < PROBE_TRACE_RAYS) { + textureStore(history_out, coord, current_sample); + } + if (lane < 16u) { + let owner_base = lane * 4u; + textureStore( + history_out, + vec3(i32(wg.x), i32(wg.y), i32(48u + lane)), + vec4( + diffuse_luminance[owner_base], + diffuse_luminance[owner_base + 1u], + diffuse_luminance[owner_base + 2u], + diffuse_luminance[owner_base + 3u], + ), + ); + } +} + +// Filter the completed irradiance estimate in probe space, after every probe +// has published its current result. Doing this in a second tiny dispatch avoids +// cross-workgroup races and costs only one invocation per 8x8 half-resolution +// tile. The geometry-clamped reconstruction occupies layer zero of history_out. +// ProbeHeader.previous_diffuse retains unfiltered temporal RGB plus a scalar +// reconstruction energy ratio for fallback resolve. This dispatch reads the +// prior diffuse/current values, not that destination field, so the write has no +// cross-workgroup race and spatial filtering never feeds back into history. +@compute @workgroup_size(8, 8, 1) +fn cs_spatial(@builtin(global_invocation_id) gid: vec3) { + let grid_w = u32(u.params.z); + let grid_h = u32(u.params.w); + if (gid.x >= grid_w || gid.y >= grid_h) { return; } + + let center_index = gid.y * grid_w + gid.x; + let center = probes[center_index]; + let output_coord = vec3(i32(gid.x), i32(gid.y), 0); + if (center.world_pos.w < 0.5) { + probes[center_index].previous_diffuse = vec4(0.0); + textureStore(history_out, output_coord, vec4(0.0)); + return; + } + + let confidence_error = max(center.diffuse.w, 0.0); + // Hardware's complete 128-direction phase ring is intrinsically + // low-frequency. Let the full geometry-clamped 5x5 footprint own it; + // retaining a portion of the screen-tiled centre leaves a camera-fixed + // lattice. Approximate Hi-Z/SDF histories remain variance-adaptive: their + // current-neighborhood clamp changes every angular phase, so forcing the + // widest filter there amplifies rather than suppresses settled variation. + // This is still a tiny probe-domain dispatch (one invocation per 8x8 + // half-res tile), and the normal/plane tests below keep every contribution + // on the same surface and preserve its boundaries. + let hardware_history = u.confidence.x > 0.5; + let radius = select( + select(1, 2, confidence_error > 0.08), + 2, + hardware_history, + ); + let filter_strength = select( + clamp(0.30 + confidence_error * 4.0, 0.30, 1.0), + 1.0, + hardware_history, + ); + let probe_world_spacing = + 2.0 * max(center.normal.w, 0.1) * u.size.z / + max(abs(u.size.w) * u.size.x, 0.0001); + let plane_sigma = 0.02 + probe_world_spacing * 0.16; + + var accum = vec3(0.0); + var weight_sum = 0.0; + var current_first = vec3(0.0); + var current_second = vec3(0.0); + for (var dy = -2; dy <= 2; dy = dy + 1) { + for (var dx = -2; dx <= 2; dx = dx + 1) { + if (abs(dx) > radius || abs(dy) > radius) { continue; } + let sample_xy = vec2(gid.xy) + vec2(dx, dy); + if (sample_xy.x < 0 || sample_xy.y < 0 || + sample_xy.x >= i32(grid_w) || sample_xy.y >= i32(grid_h)) { + continue; + } + let sample_index = u32(sample_xy.y) * grid_w + u32(sample_xy.x); + let sample = probes[sample_index]; + if (sample.world_pos.w < 0.5) { continue; } + + let normal_similarity = clamp( + dot(center.normal.xyz, sample.normal.xyz), + 0.0, + 1.0, + ); + if (normal_similarity < 0.85) { continue; } + let world_delta = sample.world_pos.xyz - center.world_pos.xyz; + let plane_error = max( + abs(dot(world_delta, center.normal.xyz)), + abs(dot(world_delta, sample.normal.xyz)), + ); + if (plane_error > plane_sigma * 2.5) { continue; } + + let offset2 = f32(dx * dx + dy * dy); + let spatial_weight = exp(-0.5 * offset2 / 1.96); + let plane_weight = exp( + -0.5 * plane_error * plane_error / + max(plane_sigma * plane_sigma, 0.000001), + ); + let weight = spatial_weight * plane_weight * pow(normal_similarity, 12.0); + // Spatially reconstruct the world-reprojected integral, not the + // raw 32-ray sample set. Filtering current samples here discarded + // most of the temporal estimator every frame, so camera motion + // exposed a fresh screen-tile pattern even when history had found + // the same wall. The final neighborhood clamp below still bounds + // every retained value by current-frame evidence. + accum = accum + sample.diffuse.rgb * weight; + weight_sum = weight_sum + weight; + let current_neighbor = bounded_probe_history(sample.current_diffuse.rgb); + current_first = current_first + current_neighbor * weight; + current_second = current_second + current_neighbor * current_neighbor * weight; + } + } + + // Approximate Hi-Z/SDF histories still need a current-frame safety bound. + // Bound them by the current neighborhood's mean and spread rather than its + // hard min/max. A sparse estimate of a bright + // nearby source (the sun-lit Bistro awnings under their façade) is + // binomially noisy per probe, so a min/max clamp repeatedly crushed the + // converged EMA toward whichever realization the current frame produced — + // the red bounce pumped in and out as the camera moved. With variance + // bounds, a noisy-but-consistent neighborhood keeps its converged mean, + // while genuinely stale history (disocclusion ghosts, lighting changes) + // still gets pulled to current evidence because agreement between + // neighbors shrinks the spread toward zero. Hardware ray queries already + // maintain a geometry-reprojected finite phase ring; clamping that stable + // 128-direction estimate to the fresh eight-ray screen cells nearly + // doubles its measured motion error and reintroduces the probe lattice. + // The ring seeds current on disocclusion and refreshes one complete phase + // per frame, so the hardware path must not apply this second estimator. + // Keep the software-path floor relative to + // HDR signal scale: the former absolute 0.005 allowance exceeded the + // complete indirect signal on many Bistro facade probes and therefore + // admitted old path-dependent light without clipping it at all. + var history_clamped = center.diffuse.rgb; + if (u.confidence.x < 0.5 && center.current_diffuse.w < 0.5 && weight_sum > 0.0001) { + let current_mean = current_first / weight_sum; + let current_sigma = sqrt(max( + current_second / weight_sum - current_mean * current_mean, + vec3(0.0), + )); + let slack = current_sigma + + abs(current_mean) * 0.02 + + vec3(0.0001); + history_clamped = clamp( + center.diffuse.rgb, + current_mean - slack, + current_mean + slack, + ); + } + let spatial = select( + history_clamped, + accum / max(weight_sum, 0.0001), + weight_sum > 0.0001, + ); + let reconstructed = bounded_probe_history(mix( + history_clamped, + spatial, + filter_strength, + )); + // This is the authoritative state consumed next frame. Previously only + // `reconstructed` was clamped while `ProbeHeader.diffuse` kept the raw EMA; + // a bright card hit could therefore remain hidden for seconds and become + // visible again when a later current-frame bound happened to include it. + let luminance_weights = vec3(0.2126, 0.7152, 0.0722); + let history_luminance = dot(history_clamped, luminance_weights); + let reconstructed_luminance = dot(reconstructed, luminance_weights); + let reconstructed_energy_ratio = select( + 1.0, + clamp(reconstructed_luminance / history_luminance, 0.0, 4.0), + history_luminance > 0.000001, + ); + probes[center_index].previous_diffuse = + vec4(history_clamped, reconstructed_energy_ratio); + textureStore(history_out, output_coord, vec4(reconstructed, 1.0)); +} +"; diff --git a/native/shared/src/renderer/shaders/temporal_aa.rs b/native/shared/src/renderer/shaders/temporal_aa.rs new file mode 100644 index 00000000..8e284454 --- /dev/null +++ b/native/shared/src/renderer/shaders/temporal_aa.rs @@ -0,0 +1,704 @@ +//! Temporal antialiasing and reconstruction shader. + +/// TAA shader. Reads `composed_rt` (scene HDR + post-effects + fog + +/// shafts already merged upstream) and performs only temporal +/// reprojection with neighborhood clamp, blending against the +/// history RT. For static scenes the blend converges in ~10 frames +/// to a fully sub-pixel-resolved image. +pub(in crate::renderer) const TAA_SHADER_WGSL: &str = concat!( + include_str!("taa_reconstruction.wgsl"), + " +struct TaaParams { + /// abs(x) = blend factor (current-frame weight); sign(x) = whether the + /// unjittered camera transform moved; yz = the CURRENT frame's + /// jitter as a composed-texture UV offset (see the unjitter note at the + /// current-frame sample); abs(w) = render scale, with positive sign for + /// perspective depth and negative sign for orthographic depth. + params: vec4, + /// Inverse of the current-frame view-projection matrix — + /// reconstructs world-space position for history reprojection. + inv_vp: mat4x4, + /// Previous-frame view-projection — projects world pos into + /// history UV. + prev_vp: mat4x4, +}; + +@group(0) @binding(0) var u: TaaParams; +@group(0) @binding(1) var composed_tex: texture_2d; +@group(0) @binding(2) var composed_samp: sampler; +@group(0) @binding(3) var history_tex: texture_2d; +@group(0) @binding(4) var history_samp: sampler; +@group(0) @binding(5) var depth_tex: texture_depth_2d; +@group(0) @binding(6) var depth_samp: sampler; +@group(0) @binding(7) var velocity_tex: texture_2d; +@group(0) @binding(8) var velocity_samp: sampler; +@group(0) @binding(9) var history_depth_tex: texture_2d; + +struct VsOut { + @builtin(position) clip_pos: vec4, + @location(0) uv: vec2, +}; + +struct TaaOut { + @location(0) color: vec4, + /// R = geometric depth; G packs persistent history confidence and the + /// independent detail lock, with a negative encoded range retaining prior + /// reactive coverage. + @location(1) provenance_history: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut { + let x = f32((vid & 1u) * 4u) - 1.0; + let y = f32((vid >> 1u) * 4u) - 1.0; + var out: VsOut; + out.clip_pos = vec4(x, y, 0.0, 1.0); + out.uv = vec2((x + 1.0) * 0.5, (1.0 - y) * 0.5); + return out; +} + +// RGB <-> YCoCg conversions. Reversible, linear, cheap (no matrix +// multiply). Used by the TAA neighborhood clamp so we can bound +// history's luma (Y) against the source neighborhood's statistical +// range while leaving chroma (Co, Cg) alone — the per-channel RGB +// clamp was causing chromatic sparkle on grazing-angle stone. +fn rgb_to_ycocg(c: vec3) -> vec3 { + let Co = c.r - c.b; + let tmp = c.b + Co * 0.5; + let Cg = c.g - tmp; + let Y = tmp + Cg * 0.5; + return vec3(Y, Co, Cg); +} +fn ycocg_to_rgb(c: vec3) -> vec3 { + let tmp = c.x - c.z * 0.5; + let g = c.z + tmp; + let b = tmp - c.y * 0.5; + let r = c.y + b; + return vec3(r, g, b); +} + +// History lives at output resolution. A bilinear lookup is exact while a +// reprojected coordinate remains on an output texel centre, but under camera +// or object motion it averages four already-filtered history pixels. Repeating +// that every frame progressively removes texture and silhouette detail. Keep +// one history fetch, but compress its bilinear phase continuously toward the +// nearest completed output sample. This retains more detail without the hard +// half-texel transitions of point history sampling. Renderer-known camera +// motion selects this path; object-only reprojection retains its established +// behavior until it has an independently qualified phase policy. The +// stationary path is +// byte-for-byte the original lookup so settled temporal supersampling is not +// disturbed. Later variance, depth, and reactive guards still decide whether +// a moving sample belongs to the current surface. +fn sample_history_reprojected(uv: vec2, camera_moving: bool) -> vec4 { + if (!camera_moving) { + return textureSampleLevel(history_tex, history_samp, uv, 0.0); + } + let dims = vec2(textureDimensions(history_tex)); + let history_pixel = uv * dims - vec2(0.5); + let base = floor(history_pixel); + let phase = fract(history_pixel); + let compressed_phase = phase * phase * (vec2(3.0) - 2.0 * phase); + let compressed_uv = (base + compressed_phase + vec2(0.5)) / dims; + return textureSampleLevel(history_tex, history_samp, compressed_uv, 0.0); +} + +@fragment +fn fs_main(in: VsOut) -> TaaOut { + // composed_tex already carries HDR + SSR + SSGI*albedo + bloom + + // fog + shafts — TAA only needs to reproject history and blend. + // Alpha carries `indirect_weight` (see scene_compose) which the + // composite pass reads to apply AO only to indirect-dominated + // pixels; pass it through blended with the colour so history + // stays consistent. + // Both current reconstruction and neighborhood statistics operate in the + // same input texture domain. Compute its size/reciprocal once per output + // pixel and share it; the previous shader repeated the dimension queries + // and reciprocal divisions in both stages. + let input_size = vec2(textureDimensions(composed_tex)); + let input_texel = 1.0 / input_size; + var current_weight = abs(u.params.x); + let camera_moving = u.params.x < 0.0; + let reconstruction_scale = clamp(abs(u.params.w), 0.5, 1.0); + // The material-aware variant replaces this compile-time constant. The + // ordinary lazy topology consumes prior reactive provenance immediately. + let preserve_reactive_history = false; + + // Fractional reconstruction reads the current color at the jitter-corrected + // source phase. Select depth and velocity from that same input position; + // using the unshifted output UV could cross a low-resolution texel boundary + // and make reprojection/provenance belong to a different surface sample. + // Native and half-scale TAA deliberately keep their established geometry + // lookups byte-for-byte. The default 0.75 tier owns this correction during + // real camera motion, where the moving native-reference corpus qualifies + // it independently from settled and legacy-half reconstruction. + let reconstruction_geometry_phase = + reconstruction_scale >= 0.75 && reconstruction_scale < 0.95 && camera_moving; + var geometry_uv = in.uv; + if (reconstruction_geometry_phase) { + geometry_uv = geometry_uv + u.params.yz; + } + + // Closest-depth velocity dilation. Sampling the low-resolution velocity + // buffer linearly mixed foreground motion with zero/background motion at + // silhouettes. The resulting half-vector reprojected into neither + // surface, producing translucent gray trails while turning the camera. + // Select the closest sample in a bounded 3x3 input footprint so thin + // foreground geometry owns the history lookup around its edge. + let depth_dims = vec2(textureDimensions(depth_tex)); + let depth_max_coord = depth_dims - vec2(1); + let center_coord = clamp( + vec2(floor(geometry_uv * vec2(depth_dims))), + vec2(0), + depth_max_coord, + ); + var closest_coord = center_coord; + var depth = textureLoad(depth_tex, center_coord, 0); + let dilation_offsets = array, 4>( + vec2(-1, 0), vec2(1, 0), + vec2(0, -1), vec2(0, 1), + ); + for (var i = 0; i < 4; i = i + 1) { + let coord = clamp(center_coord + dilation_offsets[i], vec2(0), depth_max_coord); + let candidate_depth = textureLoad(depth_tex, coord, 0); + if (candidate_depth < depth) { + depth = candidate_depth; + closest_coord = coord; + } + } + var selected_depth_uv = in.uv; + if (reconstruction_geometry_phase) { + selected_depth_uv = + (vec2(closest_coord) + vec2(0.5)) * input_texel; + } + let ndc = vec4( + selected_depth_uv.x * 2.0 - 1.0, + (1.0 - selected_depth_uv.y) * 2.0 - 1.0, + depth, + 1.0, + ); + let world_h = u.inv_vp * ndc; + let world = world_h.xyz / world_h.w; + + // Persist a geometric depth key beside color history. Perspective clip-W + // is positive linear view distance; orthographic clip-W is constant, so + // that projection stores NDC depth instead. Sky gets an explicit far key. + let perspective = u.params.w > 0.0; + let current_depth_key = select( + depth, + select(1.0 / max(abs(world_h.w), 0.000001), 10000.0, depth >= 0.9999), + perspective, + ); + let prev_world_clip = u.prev_vp * vec4(world, 1.0); + var expected_prev_depth = select( + prev_world_clip.z / max(abs(prev_world_clip.w), 0.000001), + prev_world_clip.w, + perspective, + ); + + let vel = textureLoad(velocity_tex, closest_coord, 0).rg; + var velocity_divergence = 0.0; + if (any(closest_coord != center_coord)) { + let center_vel = textureLoad(velocity_tex, center_coord, 0).rg; + velocity_divergence = length(vel - center_vel); + } + let vel_len = length(vel); + let motion_alpha = smoothstep(0.0005, 0.008, vel_len); + + var prev_uv: vec2; + if (depth >= 0.9999) { + // Sky / far plane: the positional reconstruction divides by a + // near-zero w and reprojects sky pixels onto arbitrary scene + // points — the luma-only history clamp then locks that wrong + // chroma in forever (uniform green/red sky tint). The sky is at + // infinity, so reproject the view DIRECTION instead: exact under + // camera rotation, translation-invariant by definition. + let dir = world_h.xyz; // w ~ 0 at the far plane: xyz IS the direction + let prev_clip = u.prev_vp * vec4(dir, 0.0); + if (prev_clip.w > 0.00001) { + let prev_ndc = prev_clip.xyz / prev_clip.w; + prev_uv = vec2(prev_ndc.x * 0.5 + 0.5, 1.0 - (prev_ndc.y * 0.5 + 0.5)); + } else { + prev_uv = in.uv; + } + expected_prev_depth = 10000.0; + } else { + // Geometry always owns a velocity texel, and a zero vector is a + // meaningful result for a static surface. Falling back to prev_vp + // when velocity was zero reintroduced the previous projection jitter + // into an already unjittered full-resolution history surface. Each + // static frame then sampled history at a different subpixel offset and + // progressively averaged fine texture detail into a broad blur. + prev_uv = vec2(in.uv.x - vel.x, in.uv.y + vel.y); + } + + // Keep a static native-resolution sample on its OUTPUT pixel. The velocity + // reference projection already reapplies the current jitter (EN-022), so + // static history maps to that same pixel; fully undoing jitter here + // low-pass filtered native labels and texture grain before accumulation. + // Fractional-resolution reconstruction keeps the established alignment, + // since one input pixel covers more than one output pixel. Blend only over + // the final five percent of render scale so custom near-native scales do + // not cross a hard sampling discontinuity. During actual motion restore + // full current-frame alignment immediately; it bounds slow-pan phase crawl + // while reprojection supplies the temporal sample location. Sky has no + // geometry velocity, so include its actual directional reprojection + // distance in the same motion classification. + let reprojection_motion = max(vel_len, length(prev_uv - in.uv)); + var jitter_alignment = 1.0; + // Uniform branch: fractional tiers keep the former shader path (and its + // cost) exactly. Only near-native tiers pay for the motion classifier. + if (reconstruction_scale > 0.95) { + let static_jitter_alignment = + 1.0 - smoothstep(0.95, 1.0, reconstruction_scale); + // The static path is deliberately narrow: even a very slow camera pan + // must use the established aligned sample rather than intermittently + // mixing between sharp and aligned phases across the image. + let alignment_motion = smoothstep(0.0000001, 0.000001, reprojection_motion); + jitter_alignment = mix(static_jitter_alignment, 1.0, alignment_motion); + } + let src_uv = in.uv + u.params.yz * jitter_alignment; + var current_sample: vec4; + var center_rgb: vec3; + var fractional_coverage = 1.0; + var fractional_mean = vec3(0.0); + var fractional_stddev = vec3(0.0); + var current_feature_lock = 0.0; + var use_fractional_statistics = false; + // This is uniform across the draw. Keep native reconstruction and its + // already-qualified material response byte-for-byte unchanged. + if ( + reconstruction_scale >= 0.5 && + reconstruction_scale < 0.95 && + current_weight < 0.999 + ) { + let reconstructed = sample_fractional_lanczos2( + src_uv, + input_size, + input_texel, + reconstruction_scale, + ); + current_sample = reconstructed.value; + center_rgb = reconstructed.center.rgb; + fractional_mean = reconstructed.mean; + fractional_stddev = reconstructed.stddev; + // Seed a persistent detail lock from statistics already required by + // rectification. Restrict it to real camera motion: settled output is + // byte-for-byte unchanged, while moving high-frequency detail can + // retain history through a transient source-phase excursion. + current_feature_lock = select( + 0.0, + 1.0, + camera_moving && + reconstructed.stddev.x > max(abs(reconstructed.mean.x) * 0.05, 0.002), + ); + // At half scale, the Lanczos taps move substantially with source + // phase. Reusing their moments as a history-clamp neighborhood makes + // the clamp breathe as the Halton phase advances. Keep history + // validation on the stable output-footprint cross below 0.75 while + // retaining the sharper sample itself. + use_fractional_statistics = reconstruction_scale >= 0.75; + // Preserve the separable kernel's phase coverage through accumulation. + // At 0.75 its average raw weight is approximately 0.74 over a jitter + // cycle; normalizing around that mean keeps Bloom's authored temporal + // window while low-coverage phases contribute proportionally less. + // The 0.5 path freezes coverage at the measured mean over all sixteen + // Halton phases and both output-pixel parities. Per-phase coverage + // changes are useful at 0.75, but at half scale they modulate alpha + // enough to make a slow pan alternate between sharp and soft frames. + fractional_coverage = select( + 0.784, + clamp(reconstructed.weight / 0.74, 0.25, 2.0), + reconstruction_scale >= 0.75, + ); + current_weight = 1.0 - pow(1.0 - current_weight, fractional_coverage); + } else { + // History resets and the four-frame bootstrap retain the qualified + // cubic. A single Lanczos phase is intentionally sharp; it is only + // representative once temporal accumulation can combine phases. + current_sample = sample_catmull_rom(src_uv, input_size, input_texel); + center_rgb = textureSampleLevel(composed_tex, composed_samp, src_uv, 0.0).rgb; + } + let current = current_sample.rgb; + let current_w = current_sample.a; + + var history = current; + var history_w = current_w; + var history_depth = current_depth_key; + var history_confidence = 0.0; + var history_reactive = 0.0; + var history_feature_lock = 0.0; + let history_in_bounds = + prev_uv.x >= 0.0 && prev_uv.x <= 1.0 && + prev_uv.y >= 0.0 && prev_uv.y <= 1.0; + if (history_in_bounds) { + let h_sample = sample_history_reprojected(prev_uv, camera_moving); + history = h_sample.rgb; + history_w = h_sample.a; + let history_depth_dims = vec2(textureDimensions(history_depth_tex)); + let history_depth_coord = clamp( + vec2(floor(prev_uv * vec2(history_depth_dims))), + vec2(0), + history_depth_dims - vec2(1), + ); + let history_provenance = textureLoad(history_depth_tex, history_depth_coord, 0).rg; + history_depth = history_provenance.r; + let temporal_history = unpack_temporal_history(history_provenance.g); + // Negative provenance came from a reactive frame. The ordinary path + // resets its confidence and detail lock, consuming current color if the + // last reactive contributor disappeared with its lazy topology. + let history_payload_usable = select( + 1.0 - temporal_history.reactive, + 1.0, + preserve_reactive_history, + ); + history_confidence = temporal_history.confidence * history_payload_usable; + history_feature_lock = temporal_history.feature_lock * history_payload_usable; + history_reactive = temporal_history.reactive * + select(0.0, 1.0, preserve_reactive_history); + } + + // Reject history whose geometric provenance no longer matches the world + // point reprojected into the previous camera. This is the missing guard + // that color variance cannot provide: a pale counter and pale wall can be + // statistically similar while belonging to different surfaces, creating + // the translucent gray haze seen during camera motion. + // Variance clamp in YCoCg (Karis 2014). Per-channel RGB min/max + // clamping was producing chromatic sparkle on the stone floor + // at grazing angles: high-frequency normal-map specular makes + // each jittered frame's Cg/Co vary significantly, and clamping + // each channel independently lets history's chroma get pinned + // to whatever the current frame's specific Cg/Co range was. + // Clamping only the *luma* axis (Y) preserves chroma stability + // across frames; the 1σ variance range is a statistical clamp + // that absorbs single-pixel outliers without collapsing to a + // hard min/max bound. + // Define the clipping neighborhood from the OUTPUT-pixel footprint mapped + // into input texels. At 0.75 scale the old one-input-texel offsets mixed a + // 33% wider spatial neighborhood into every history decision and softened + // detail after confidence converged. Using the exact output footprint at + // 0.75 and above remains stable in the slow-pan corpus. Lower tiers keep + // the proven 0.8-input-pixel floor, where adjacent output positions are + // too strongly correlated for a tighter statistical window. Packing scale + // into the existing projection flag adds no uniform bytes, bindings, + // samples, or passes. + let statistics_footprint = select( + 0.80, + reconstruction_scale, + reconstruction_scale >= 0.75, + ); + let statistics_texel = input_texel * statistics_footprint; + // Keep the center statistical sample bilinear. The reconstructed current + // contains the cubic filter's negative-lobe response; feeding that into + // the variance estimate makes the clamp breathe with the reconstruction + // phase even at native scale. This lookup existed in the baseline path, + // so retaining it does not add a performance cost versus shipped TAA. + var mean: vec3; + var stddev: vec3; + if (use_fractional_statistics) { + mean = fractional_mean; + stddev = fractional_stddev; + } else { + var m1 = rgb_to_ycocg(center_rgb); + var m2 = m1 * m1; + let statistics_offsets = array, 4>( + vec2(-1.0, 0.0), vec2(1.0, 0.0), + vec2(0.0, -1.0), vec2(0.0, 1.0), + ); + for (var i = 0; i < 4; i = i + 1) { + let s_uv = src_uv + statistics_offsets[i] * statistics_texel; + let s_rgb = textureSampleLevel(composed_tex, composed_samp, s_uv, 0.0).rgb; + let s = rgb_to_ycocg(s_rgb); + m1 = m1 + s; + m2 = m2 + s * s; + } + let n_samples = 5.0; + mean = m1 / n_samples; + // A five-sample cross measures 3/5 of the first-order variance measured by + // the former 3x3 grid over the same radius (2/5 versus 2/3 per axis). + // Correct that known sampling bias so the history clip preserves the same + // linear-ramp bandwidth while spending the four saved reads on the exact + // cubic's diagonal lobes. + let variance = + max(m2 / n_samples - mean * mean, vec3(0.0)) * (5.0 / 3.0); + stddev = sqrt(variance); + } + + // Motion-aware γ + alpha. At rest γ=1.25 lets sub-pixel jitter + // history through for smooth accumulation. Under any camera + // motion γ collapses fast to 0.25 — forces reprojected + // history within a quarter-sigma of the neighborhood mean, + // which is tight enough to reject the 'dark column in + // history, bright wall in current' case that the wider band + // let slip. alpha ramps to 0.85 at the same time so remaining + // history contributes only 15 %. + let gamma = mix(1.25, 0.25, motion_alpha); + let y_min = mean.x - gamma * stddev.x; + let y_max = mean.x + gamma * stddev.x; + + let history_ycocg = rgb_to_ycocg(history); + let history_y_clamped = clamp(history_ycocg.x, y_min, y_max); + // Chroma is clamped too, but at 3x the luma band (flicker fix). + // Fully unclamped chroma let stale history colour bleed through on + // high-contrast edges — green terrain fringing crawling along cloud + // and canopy silhouettes during camera motion. The loose band keeps + // the anti-sparkle intent of the luma-only design (a hard + // per-channel clamp caused chromatic sparkle on grazing stone) + // while bounding gross cross-object colour bleed. + let c_gamma = gamma * 3.0; + let co_clamped = clamp(history_ycocg.y, + mean.y - c_gamma * stddev.y, mean.y + c_gamma * stddev.y); + let cg_clamped = clamp(history_ycocg.z, + mean.z - c_gamma * stddev.z, mean.z + c_gamma * stddev.z); + let clamped_history = ycocg_to_rgb(vec3(history_y_clamped, co_clamped, cg_clamped)); + + // Color-change rejection has two deliberately different operating + // ranges. During motion, a tight clamped-luma test helps flush a sample + // that reprojected onto the wrong side of a textured silhouette. At rest, + // however, jitter phases are SUPPOSED to differ inside the source + // footprint: treating a perfectly ordinary 0.6-sigma texture excursion as + // a disocclusion forced the low-resolution current sample into most Bistro + // pixels every frame and prevented temporal super-resolution from ever + // accumulating its sub-pixel samples. + let history_dist = abs(history_y_clamped - mean.x); + + // A stationary scene can still change illumination/emissive state or + // inherit poisoned chroma, so it is not safe to disable color rejection + // outright. Collapse luma and half-weighted chroma into one max-norm + // distance. This deliberately avoids a chroma vector length (sqrt) and + // multiple smoothstep evaluations in the full-output-resolution pass. + // Coherent lighting/material changes on a flat surface cross the broad + // band immediately; valid jittered texture samples stay inside it and are + // variance-clipped before blending. + let raw_color_delta = abs(history_ycocg - mean); + let gross_color_dist = max( + raw_color_delta.x, + max(raw_color_delta.y, raw_color_delta.z) * 0.5, + ); + let gross_color_sigma = max( + stddev.x, + max(stddev.y, stddev.z) * 0.5, + ); + let color_dist = mix(gross_color_dist, history_dist, motion_alpha); + let reject_lo = mix( + max(gross_color_sigma * 3.0, 0.02), + stddev.x * 0.25, + motion_alpha, + ); + let reject_hi = mix( + max(gross_color_sigma * 6.0, 0.06), + max(stddev.x, 0.0001), + motion_alpha, + ); + let disocclusion = smoothstep(reject_lo, reject_hi, color_dist); + + // Depth provenance needs a screen gradient to tolerate a real geometric + // edge. Static color-change classification needs the same quad footprint + // below. Take both derivatives as one vector pair so the successful narrow + // phase-strip detector does not add another derivative pair to full-screen + // TAA. + let temporal_gradients = + abs(dpdx(vec2(expected_prev_depth, disocclusion))) + + abs(dpdy(vec2(expected_prev_depth, disocclusion))); + let depth_base_tolerance = 0.02 + abs(expected_prev_depth) * 0.005; + let depth_tolerance = max( + depth_base_tolerance, + min(temporal_gradients.x * 2.0, depth_base_tolerance * 4.0), + ); + let depth_error = abs(history_depth - expected_prev_depth); + let raw_depth_disocclusion = select( + 0.0, + smoothstep(depth_tolerance, depth_tolerance * 2.0, depth_error), + history_in_bounds, + ); + + // A stationary projection still jitters the low-resolution depth raster. + // Thin geometry and grazing, finely tessellated surfaces can therefore + // alternate which depth owns an output pixel even though their temporal + // color footprint remains compatible. Treating that coverage change as a + // geometric disocclusion repeatedly discarded settled history and made + // native and fractional Bistro detail flicker with a zero motion buffer. + // + // Suppress depth-only rejection only for the exact static case. Camera + // motion keeps the renderer-owned camera flag; object motion keeps its + // velocity or the closest-depth divergence signal; topology/material + // changes still use reactive and broad color rejection. This adds no + // samples or resources and does not weaken moving silhouettes. + let static_zero_velocity = !camera_moving + && vel_len < 0.0000001 + && velocity_divergence < 0.0000001; + let jitter_coverage_compatible = gross_color_dist <= reject_hi; + let depth_disocclusion = select( + raw_depth_disocclusion, + 0.0, + static_zero_velocity && jitter_coverage_compatible, + ); + + // Divergent neighbor motion marks a silhouette/disocclusion footprint. + // Prefer the current frame there even when both individual vectors are + // small, rather than allowing a long-lived cross-surface history average. + let divergence_alpha = smoothstep(0.00025, 0.003, velocity_divergence); + // Moving high-frequency reconstruction must not accumulate a long-lived + // phase lag. Its existing detail classifier is already paid for by the + // fractional rectification footprint; give those pixels a modest current + // floor while leaving smooth surfaces and every stationary path unchanged. + let feature_motion_floor = current_feature_lock * + select(0.0, 0.15, vel_len >= 0.0005); + let motion_ramped = max( + max(mix(current_weight, 0.85, motion_alpha), divergence_alpha), + feature_motion_floor, + ); + // Reactive coverage is injected by the material-aware TAA variant. Keep a + // concrete zero in the base shader so confidence policy is identical for + // both variants and diagnostics can report the real persistent lock. + let current_reactive = 0.0; + let reactive = 0.0; + // Confidence represents geometric/material continuity. Do not erase that + // persistent fact for a luma-only neighborhood excursion: sub-pixel normal + // and texture detail can cross the color-disocclusion threshold at rest, + // and repeatedly unlocking those valid pixels turns detail into shimmer. + // Color disocclusion still raises alpha below; it simply does not destroy + // the longer-lived geometric lock. + let temporal_rejection = max(depth_disocclusion, max(divergence_alpha, reactive)); + + // A confidence value of 1 represents sixteen compatible samples. At rest, + // enforce the unbiased running-average weight 1/(N+1) until lock; after + // lock, the authored steady-state alpha takes over. Under even sub-pixel + // motion the established motion policy already bounds stale history, so + // disable bootstrap before 0.04 output pixels/frame rather than turning a + // safe resolve into visibly noisier current samples during a slow pan. + let history_usable = history_in_bounds && current_weight < 0.999; + let history_sample_count = history_confidence * 16.0; + let bootstrap_running_alpha = select( + 1.0, + fractional_coverage / (history_sample_count + fractional_coverage), + history_usable, + ); + let bootstrap_static = 1.0 - smoothstep(0.00001, 0.0001, vel_len); + let bootstrap_alpha = mix(current_weight, bootstrap_running_alpha, bootstrap_static); + // A locked native pixel sees the same finite jitter phases repeat. The + // authored 10% current weight makes them shimmer even when a slow pan has + // valid, coherent reprojection. Keep every geometric/reactive guard and + // the static 24-frame window. Compatible color excursions retain that + // window; changes outside the band still unlock, while gradual lighting + // converges normally. Coherent slow motion uses the qualified 8.5% cap. + let settled_coherent_lock = select( + 0.0, + 1.0, + history_confidence >= 0.999 && + (!camera_moving || reconstruction_scale > 0.95) && + reprojection_motion < 0.00025 && + jitter_coverage_compatible && + current_weight >= 0.095 && + temporal_rejection <= 0.01, + ); + // A zero-velocity finite-depth surface is not disoccluding merely because + // the next finite jitter phase shades a different point inside the same + // output pixel. Restrict this protection to a narrow color-disocclusion + // footprint: ordinary static pixels keep the authored 24-frame window, + // broad lighting/material changes keep normal color rejection, and the + // procedural sky remains free to animate without geometry velocity. + let settled_static_phase_candidate = select( + 0.0, + 1.0, + history_confidence >= 0.999 && + static_zero_velocity && + depth < 0.9999 && + current_weight >= 0.095 && + temporal_rejection <= 0.01, + ); + let rejected_color_phase = select(0.0, 1.0, disocclusion >= 0.10); + let narrow_color_phase = + rejected_color_phase * smoothstep(0.05, 0.50, temporal_gradients.y); + let settled_static_phase_lock = settled_static_phase_candidate * narrow_color_phase; + let settled_lock = max(settled_coherent_lock, settled_static_phase_lock); + let static_current_cap = mix(0.041666667, 0.015625, settled_static_phase_lock); + let settled_current_cap = select(static_current_cap, 0.085, camera_moving); + let color_motion_ramped = max(motion_ramped, disocclusion); + let settled_motion_ramped = mix( + color_motion_ramped, + min(color_motion_ramped, settled_current_cap), + settled_lock, + ); + let settled_bootstrap_alpha = mix( + bootstrap_alpha, + min(bootstrap_alpha, settled_current_cap), + settled_lock, + ); + let alpha = max( + max(settled_motion_ramped, max(depth_disocclusion, reactive)), + settled_bootstrap_alpha, + ); + let accepted_history = select(0.0, 1.0 - temporal_rejection, history_usable); + let next_history_confidence = + min(history_confidence + 1.0 / 16.0, 1.0) * accepted_history; + // Keep structural lifetime independent from ordinary color confidence. + // Existing geometric/reactive acceptance, the broad color band, and a + // stationary camera kill a stale lock; compatible moving detail seeds or + // renews it. The lock only protects rectification, leaving the qualified + // accumulation alpha intact. + let feature_shading_stable = gross_color_dist <= reject_hi * 4.0; + let protected_feature_lock = select( + 0.0, + history_feature_lock, + camera_moving && accepted_history >= 0.99 && feature_shading_stable, + ); + let next_feature_lock = max( + select(current_feature_lock, 0.0, current_reactive > 0.01), + protected_feature_lock, + ); + let rectification_lock = max(settled_static_phase_lock, protected_feature_lock); + let stable_history = mix(clamped_history, history, rectification_lock); + var blended = mix(stable_history, current, alpha); + // The temporal average suppresses some of the reconstruction's + // source-phase residual. On a settled stationary surface, feed a bounded + // part of that already-computed current-vs-linear residual through the + // temporal update: tying it to alpha lets history accumulate the detail + // instead of exposing one source phase at full strength. During camera + // motion, provenance now follows the same jitter-corrected input texel as + // color; retain only two percent of the bounded current residual to avoid + // replacing that stability with history lag. Object-only motion keeps its + // established response. Render-scale changes rebuild the targets and + // reset history, so policies cannot splice across one accumulation epoch. + // This adds no samples and vanishes at native scale. + let settled_static = history_confidence + * (1.0 - motion_alpha) + * select(1.0, 0.0, camera_moving); + let fractional_reconstruction = 1.0 - smoothstep(0.95, 1.0, reconstruction_scale); + let reconstruction_detail = clamp( + current - center_rgb, + vec3(-0.08), + vec3(0.08), + ); + let reconstruction_detail_weight = select( + 0.20, + 3.0 * alpha, + reconstruction_scale >= 0.75, + ); + // Retain the qualified two-percent moving residual on every fractional + // surface. Pixels already admitted by the production high-frequency + // detail classifier receive a bounded additional four percent. This + // targets alpha-cutout foliage and authored microtexture without changing + // smooth surfaces, adding samples, or widening the classifier. + let moving_reconstruction_detail_weight = select( + 0.0, + 0.02 + 0.04 * current_feature_lock, + reconstruction_geometry_phase, + ); + blended = max( + blended + reconstruction_detail * + ((reconstruction_detail_weight * settled_static + + moving_reconstruction_detail_weight) * fractional_reconstruction), + vec3(0.0), + ); + let blended_w = mix(history_w, current_w, alpha); + return TaaOut( + vec4(blended, blended_w), + vec2( + current_depth_key, + pack_temporal_history(next_history_confidence, 0.0, next_feature_lock), + ), + ); +} +", +); diff --git a/native/shared/src/renderer/visibility_buffer.rs b/native/shared/src/renderer/visibility_buffer.rs index 193ae648..15fab82a 100644 --- a/native/shared/src/renderer/visibility_buffer.rs +++ b/native/shared/src/renderer/visibility_buffer.rs @@ -1255,816 +1255,5 @@ fn perspective_barycentrics(point: [f32; 2], clip: [[f32; 4]; 3]) -> Option<[f32 } #[cfg(test)] -mod tests { - use super::super::{gpu_driven::GpuDrawRecord, Uniforms3D, Vertex3D}; - use super::*; - use wgpu::util::DeviceExt; - - fn assert_close(actual: f32, expected: f32) { - assert!( - (actual - expected).abs() <= 1.0e-6, - "{actual} != {expected}" - ); - } - - #[test] - fn packed_record_is_exactly_one_rg32uint_texel() { - assert_eq!(std::mem::size_of::(), 8); - assert_eq!(std::mem::align_of::(), 4); - assert_eq!(VISIBILITY_BYTES_PER_PIXEL, 8); - assert_eq!(VISIBILITY_FORMAT, wgpu::TextureFormat::Rg32Uint); - assert_eq!(target_bytes(1_920, 1_080), Some(16_588_800)); - assert_eq!(target_bytes(u32::MAX, u32::MAX), None); - - let report = contract_json(); - assert!(report.starts_with("{\"format\":\"rg32uint\"")); - assert!(report.contains("\"native_1080p_bytes\":16588800")); - assert!(report.contains("\"required_feature\":\"primitive-index\"")); - assert!(report.contains("\"vertex_stride_bytes\":96")); - assert!(report.contains("\"shipping_enabled\":false")); - } - - #[test] - fn ids_and_face_orientation_round_trip_without_background_collision() { - for (draw, primitive, front) in [ - (0, 0, false), - (17, 42, true), - (DRAW_INDEX_MASK, PRIMITIVE_ID_MASK, false), - ] { - let encoded = VisibilityRecord::encode(draw, primitive, front).unwrap(); - assert_eq!(encoded.decode(), Some((draw, primitive, front))); - } - assert_eq!(VisibilityRecord::BACKGROUND.decode(), None); - assert_eq!(VisibilityRecord::encode(INVALID_DRAW_ID, 0, true), None); - assert_eq!(VisibilityRecord::encode(VIRTUAL_DRAW_BIT, 0, true), None); - assert_eq!(VisibilityRecord::encode(0, FRONT_FACE_BIT, true), None); - } - - #[test] - fn perspective_reconstruction_matches_vertices_and_known_depth_weighting() { - let clip = [ - [-1.0, -1.0, 0.2, 1.0], - [2.0, -2.0, 0.4, 2.0], - [0.0, 4.0, 0.8, 4.0], - ]; - for (point, expected) in [ - ([-1.0, -1.0], [1.0, 0.0, 0.0]), - ([1.0, -1.0], [0.0, 1.0, 0.0]), - ([0.0, 1.0], [0.0, 0.0, 1.0]), - ] { - let actual = perspective_barycentrics(point, clip).unwrap(); - for lane in 0..3 { - assert_close(actual[lane], expected[lane]); - } - } - - let center = perspective_barycentrics([0.0, -1.0 / 3.0], clip).unwrap(); - assert_close(center[0], 4.0 / 7.0); - assert_close(center[1], 2.0 / 7.0); - assert_close(center[2], 1.0 / 7.0); - assert_close(center.iter().sum(), 1.0); - } - - #[test] - fn shared_reconstruction_header_parses_and_keeps_the_cpu_abi_constants() { - wgpu::naga::front::wgsl::parse_str(RECONSTRUCTION_WGSL) - .unwrap_or_else(|error| panic!("visibility reconstruction WGSL failed: {error:?}")); - wgpu::naga::front::wgsl::parse_str(GEOMETRY_WGSL) - .unwrap_or_else(|error| panic!("visibility geometry WGSL failed: {error:?}")); - assert!(RECONSTRUCTION_WGSL - .contains("const BLOOM_VISIBILITY_FRONT_FACE_BIT: u32 = 0x80000000u")); - assert!(RECONSTRUCTION_WGSL.contains("fn bloom_perspective_barycentrics(")); - assert!(GEOMETRY_WGSL.contains("const BLOOM_VERTEX3D_WORDS: u32 = 24u")); - assert_eq!(std::mem::size_of::(), 96); - } - - #[test] - fn runtime_modes_request_only_the_explicit_optional_feature() { - assert_eq!(parse_runtime_mode(None), RuntimeMode::Off); - assert_eq!(parse_runtime_mode(Some("off")), RuntimeMode::Off); - assert_eq!(parse_runtime_mode(Some("validate")), RuntimeMode::Validate); - assert_eq!(parse_runtime_mode(Some("DEBUG")), RuntimeMode::Debug); - assert_eq!(parse_runtime_mode(Some("pbr")), RuntimeMode::Shade); - assert!(RuntimeMode::Shade.shades()); - - let supported = wgpu::Features::PRIMITIVE_INDEX | wgpu::Features::TIMESTAMP_QUERY; - let mut required = wgpu::Features::empty(); - request_feature_for_mode(RuntimeMode::Off, supported, &mut required); - assert!(required.is_empty()); - request_feature_for_mode(RuntimeMode::Validate, supported, &mut required); - assert_eq!(required, wgpu::Features::PRIMITIVE_INDEX); - request_feature_for_mode(RuntimeMode::Shade, supported, &mut required); - assert_eq!(required, wgpu::Features::PRIMITIVE_INDEX); - - let mut unsupported = wgpu::Features::empty(); - request_feature_for_mode( - RuntimeMode::Debug, - wgpu::Features::TIMESTAMP_QUERY, - &mut unsupported, - ); - assert!(unsupported.is_empty()); - - let disabled = VisibilityBufferRuntime::disabled(RuntimeMode::Off, "not-requested"); - assert!(!disabled.enabled()); - assert!(disabled.resources.is_none()); - assert!(disabled.report_json().contains("\"allocated_bytes\":0")); - } - - #[test] - fn runtime_raster_reconstruction_and_overlay_shaders_parse() { - let generated = - super::super::gpu_driven::make_gpu_scene_shader(super::super::shaders::SCENE_SHADER); - let raster = make_visibility_raster_shader(&generated); - let depth = super::super::visibility_shading::make_visibility_depth_shader(&generated); - wgpu::naga::front::wgsl::parse_str(&raster) - .unwrap_or_else(|error| panic!("visibility runtime raster WGSL failed: {error:?}")); - wgpu::naga::front::wgsl::parse_str(&depth) - .unwrap_or_else(|error| panic!("visibility depth WGSL failed: {error:?}")); - wgpu::naga::front::wgsl::parse_str(RUNTIME_RECONSTRUCT_WGSL).unwrap_or_else(|error| { - panic!("visibility runtime reconstruction WGSL failed: {error:?}") - }); - wgpu::naga::front::wgsl::parse_str(DEBUG_OVERLAY_WGSL) - .unwrap_or_else(|error| panic!("visibility debug overlay WGSL failed: {error:?}")); - assert!(raster.starts_with("enable primitive_index;")); - assert!(raster.contains("out.draw_id = draw_index")); - assert!(raster.contains("(in.draw_flags & 2u) == 0u")); - assert!(depth.contains("return vec2(0xffffffffu, 0xffffffffu)")); - assert!(RUNTIME_RECONSTRUCT_WGSL.contains("visibility_vertex_count()")); - assert!(RUNTIME_RECONSTRUCT_WGSL.contains("@group(0) @binding(6)")); - } - - #[test] - fn large_vertex_arenas_split_at_aligned_storage_binding_boundaries() { - let mut limits = wgpu::Limits::downlevel_defaults(); - limits.max_storage_buffer_binding_size = 128 * 1024 * 1024; - limits.min_storage_buffer_offset_alignment = 256; - let vertex_stride = std::mem::size_of::() as u64; - let used_bytes = 1_738_262 * vertex_stride; - let ranges = visibility_vertex_binding_ranges(&limits, 256 * 1024 * 1024, used_bytes); - assert_eq!(ranges[0], (0, 134_217_216)); - assert_eq!(ranges[1], (134_217_216, used_bytes - 134_217_216)); - assert_eq!(ranges[2], (0, vertex_stride)); - assert!(ranges - .iter() - .all(|&(_, size)| size <= u64::from(limits.max_storage_buffer_binding_size))); - assert!(ranges.iter().all(|&(offset, _)| offset - % u64::from(limits.min_storage_buffer_offset_alignment) - == 0)); - } - - #[cfg(not(target_arch = "wasm32"))] - fn try_device(required_features: wgpu::Features) -> Option<(wgpu::Device, wgpu::Queue)> { - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { - backends: wgpu::Backends::all(), - ..wgpu::InstanceDescriptor::new_without_display_handle() - }); - let adapter = - pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) - .ok()?; - if !adapter.features().contains(required_features) { - eprintln!("adapter lacks required visibility-oracle features"); - return None; - } - pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - label: Some("visibility_buffer_oracle_device"), - required_features, - required_limits: wgpu::Limits::downlevel_defaults(), - ..Default::default() - })) - .ok() - } - - #[cfg(not(target_arch = "wasm32"))] - fn readback(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec { - let slice = buffer.slice(..); - let (sender, receiver) = std::sync::mpsc::channel(); - slice.map_async(wgpu::MapMode::Read, move |result| { - let _ = sender.send(result); - }); - let _ = device.poll(wgpu::PollType::Wait { - submission_index: None, - timeout: None, - }); - receiver - .recv() - .expect("visibility readback callback dropped") - .expect("visibility readback mapping failed"); - let mapped = slice.get_mapped_range(); - let bytes = mapped.to_vec(); - drop(mapped); - buffer.unmap(); - bytes - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn gpu_raster_ids_faces_and_reconstruction_match_the_cpu_oracle() { - const WIDTH: u32 = 32; - const HEIGHT: u32 = 16; - const VISIBILITY_ROW_BYTES: u32 = 256; - const BARYCENTRIC_ROW_BYTES: u32 = WIDTH * 16; - let Some((device, queue)) = try_device(wgpu::Features::PRIMITIVE_INDEX) else { - eprintln!("no GPU adapter — skipping visibility raster oracle"); - return; - }; - - let clip = [ - [-0.9, -0.8, 0.5, 1.0], - [-0.2, -1.6, 1.0, 2.0], - [-2.0, 3.2, 2.0, 4.0], - [0.1, -0.8, 0.5, 1.0], - [2.0, 3.2, 2.0, 4.0], - [1.8, -1.6, 1.0, 2.0], - ]; - let shader_source = format!( - "enable primitive_index;\n\ - {RECONSTRUCTION_WGSL}\n\ - struct VertexOut {{ @builtin(position) position: vec4, }};\n\ - struct FragmentOut {{\n\ - @location(0) visibility: vec2,\n\ - @location(1) barycentrics: vec4,\n\ - }};\n\ - fn clip_position(index: u32) -> vec4 {{\n\ - var positions = array, 6>(\n\ - vec4(-0.9, -0.8, 0.5, 1.0),\n\ - vec4(-0.2, -1.6, 1.0, 2.0),\n\ - vec4(-2.0, 3.2, 2.0, 4.0),\n\ - vec4(0.1, -0.8, 0.5, 1.0),\n\ - vec4(2.0, 3.2, 2.0, 4.0),\n\ - vec4(1.8, -1.6, 1.0, 2.0),\n\ - );\n\ - return positions[index];\n\ - }}\n\ - @vertex fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut {{\n\ - var out: VertexOut;\n\ - out.position = clip_position(index);\n\ - return out;\n\ - }}\n\ - @fragment fn fs_main(\n\ - in: VertexOut,\n\ - @builtin(primitive_index) primitive_id: u32,\n\ - @builtin(front_facing) front_facing: bool,\n\ - ) -> FragmentOut {{\n\ - let first = primitive_id * 3u;\n\ - let point_ndc = vec2(\n\ - in.position.x / {WIDTH}.0 * 2.0 - 1.0,\n\ - 1.0 - in.position.y / {HEIGHT}.0 * 2.0,\n\ - );\n\ - let barycentrics = bloom_perspective_barycentrics(\n\ - point_ndc,\n\ - clip_position(first),\n\ - clip_position(first + 1u),\n\ - clip_position(first + 2u),\n\ - );\n\ - var out: FragmentOut;\n\ - out.visibility = bloom_encode_visibility(7u, primitive_id, front_facing);\n\ - out.barycentrics = vec4(barycentrics, 1.0);\n\ - return out;\n\ - }}" - ); - wgpu::naga::front::wgsl::parse_str(&shader_source) - .unwrap_or_else(|error| panic!("visibility raster oracle WGSL failed: {error:?}")); - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("visibility_buffer_oracle_shader"), - source: wgpu::ShaderSource::Wgsl(shader_source.into()), - }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("visibility_buffer_oracle_pipeline_layout"), - bind_group_layouts: &[], - immediate_size: 0, - }); - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("visibility_buffer_oracle_pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs_main"), - targets: &[ - Some(wgpu::ColorTargetState { - format: VISIBILITY_FORMAT, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - }), - Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba32Float, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - }), - ], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - ..Default::default() - }, - depth_stencil: None, - multisample: Default::default(), - multiview_mask: None, - cache: None, - }); - - let make_target = |label, format| { - device.create_texture(&wgpu::TextureDescriptor { - label: Some(label), - size: wgpu::Extent3d { - width: WIDTH, - height: HEIGHT, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }) - }; - let visibility = make_target("visibility_buffer_oracle_ids", VISIBILITY_FORMAT); - let barycentrics = make_target( - "visibility_buffer_oracle_barycentrics", - wgpu::TextureFormat::Rgba32Float, - ); - let visibility_view = visibility.create_view(&Default::default()); - let barycentric_view = barycentrics.create_view(&Default::default()); - let visibility_readback = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("visibility_buffer_oracle_id_readback"), - size: (VISIBILITY_ROW_BYTES * HEIGHT) as u64, - usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, - mapped_at_creation: false, - }); - let barycentric_readback = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("visibility_buffer_oracle_barycentric_readback"), - size: (BARYCENTRIC_ROW_BYTES * HEIGHT) as u64, - usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, - mapped_at_creation: false, - }); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("visibility_buffer_oracle_encoder"), - }); - { - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("visibility_buffer_oracle_pass"), - color_attachments: &[ - Some(wgpu::RenderPassColorAttachment { - view: &visibility_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: u32::MAX as f64, - g: u32::MAX as f64, - b: 0.0, - a: 0.0, - }), - store: wgpu::StoreOp::Store, - }, - }), - Some(wgpu::RenderPassColorAttachment { - view: &barycentric_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), - store: wgpu::StoreOp::Store, - }, - }), - ], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&pipeline); - pass.draw(0..6, 0..1); - } - for (texture, buffer, bytes_per_row) in [ - (&visibility, &visibility_readback, VISIBILITY_ROW_BYTES), - (&barycentrics, &barycentric_readback, BARYCENTRIC_ROW_BYTES), - ] { - encoder.copy_texture_to_buffer( - wgpu::TexelCopyTextureInfo { - texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyBufferInfo { - buffer, - layout: wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(bytes_per_row), - rows_per_image: Some(HEIGHT), - }, - }, - wgpu::Extent3d { - width: WIDTH, - height: HEIGHT, - depth_or_array_layers: 1, - }, - ); - } - queue.submit(std::iter::once(encoder.finish())); - - let id_bytes = readback(&device, &visibility_readback); - let barycentric_bytes = readback(&device, &barycentric_readback); - let mut primitive_pixels = [0usize; 2]; - let mut primitive_faces = [None; 2]; - let mut background_pixels = 0usize; - for y in 0..HEIGHT { - for x in 0..WIDTH { - let id_offset = (y * VISIBILITY_ROW_BYTES + x * 8) as usize; - let words: &[u32] = bytemuck::cast_slice(&id_bytes[id_offset..id_offset + 8]); - let record = VisibilityRecord { - draw_id: words[0], - primitive_and_face: words[1], - }; - let Some((draw_id, primitive_id, front_facing)) = record.decode() else { - background_pixels += 1; - continue; - }; - assert_eq!(draw_id, 7); - assert!(primitive_id < 2); - let primitive = primitive_id as usize; - primitive_pixels[primitive] += 1; - match primitive_faces[primitive] { - Some(expected) => assert_eq!(front_facing, expected), - None => primitive_faces[primitive] = Some(front_facing), - } - - let point_ndc = [ - (x as f32 + 0.5) / WIDTH as f32 * 2.0 - 1.0, - 1.0 - (y as f32 + 0.5) / HEIGHT as f32 * 2.0, - ]; - let first = primitive * 3; - let expected = perspective_barycentrics( - point_ndc, - [clip[first], clip[first + 1], clip[first + 2]], - ) - .unwrap(); - let bary_offset = (y * BARYCENTRIC_ROW_BYTES + x * 16) as usize; - let actual: &[f32] = - bytemuck::cast_slice(&barycentric_bytes[bary_offset..bary_offset + 16]); - for lane in 0..3 { - assert!( - (actual[lane] - expected[lane]).abs() <= 2.0e-5, - "pixel ({x},{y}) primitive {primitive}: GPU {:?}, CPU {:?}", - &actual[..3], - expected, - ); - } - assert_close(actual[0] + actual[1] + actual[2], 1.0); - } - } - assert!(background_pixels > 0, "clear sentinel was not preserved"); - assert!(primitive_pixels.iter().all(|pixels| *pixels > 0)); - assert_ne!( - primitive_faces[0], primitive_faces[1], - "opposite winding must preserve distinct front-face bits" - ); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn gpu_pulls_shared_geometry_and_reconstructs_every_vertex_lane() { - const OUTPUT_WORDS: usize = 100; - let Some((device, queue)) = try_device(wgpu::Features::empty()) else { - eprintln!("no GPU adapter — skipping shared-geometry oracle"); - return; - }; - - let padding = Vertex3D { - position: [-99.0; 3], - normal: [-98.0; 3], - color: [-97.0; 4], - uv: [-96.0; 2], - joints: [-95.0; 4], - weights: [-94.0; 4], - tangent: [-93.0; 4], - }; - let vertices = [ - padding, - Vertex3D { - position: [-0.9, -0.8, 1.0], - normal: [0.1, 0.2, 0.3], - color: [0.4, 0.5, 0.6, 0.7], - uv: [0.8, 0.9], - joints: [1.0, 2.0, 3.0, 4.0], - weights: [0.1, 0.2, 0.3, 0.4], - tangent: [0.7, 0.2, 0.1, -1.0], - }, - Vertex3D { - position: [-0.2, -1.6, 2.0], - normal: [1.1, 1.2, 1.3], - color: [1.4, 1.5, 1.6, 1.7], - uv: [1.8, 1.9], - joints: [5.0, 6.0, 7.0, 8.0], - weights: [0.4, 0.3, 0.2, 0.1], - tangent: [0.1, 0.6, 0.3, 1.0], - }, - Vertex3D { - position: [-2.0, 3.2, 4.0], - normal: [2.1, 2.2, 2.3], - color: [2.4, 2.5, 2.6, 2.7], - uv: [2.8, 2.9], - joints: [9.0, 10.0, 11.0, 12.0], - weights: [0.25, 0.25, 0.25, 0.25], - tangent: [0.4, 0.2, 0.8, -1.0], - }, - ]; - let mvp = [ - [1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - [0.0, 0.0, 0.5, 0.0], - ]; - let identity = [ - [1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ]; - let draw = GpuDrawRecord { - uniforms: Uniforms3D { - mvp, - model: identity, - prev_mvp: identity, - model_tint: [1.0; 4], - misc: [0.0; 4], - }, - bounds_min: [-2.0, -1.6, 1.0, 0.0], - bounds_max: [-0.2, 3.2, 4.0, 0.0], - draw: [3, 3, 1_i32 as u32, 1_234], - }; - let indices = [91u32, 92, 93, 0, 1, 2]; - let point_ndc = [-0.45f32, -0.1, 0.0, 0.0]; - let visibility_record = VisibilityRecord::encode(0, 0, true).unwrap(); - - let shader_source = [ - RECONSTRUCTION_WGSL, - GEOMETRY_WGSL, - r#" -struct Uniforms3D { - mvp: mat4x4, - model: mat4x4, - prev_mvp: mat4x4, - model_tint: vec4, - misc: vec4, -}; -struct GpuDrawRecord { - uniforms: Uniforms3D, - bounds_min: vec4, - bounds_max: vec4, - draw: vec4, -}; -struct VertexTable { records: array, }; -struct IndexTable { values: array, }; -struct DrawTable { records: array, }; -struct OutputTable { words: array, }; - -@group(0) @binding(0) var visibility_texture: texture_2d; -@group(0) @binding(1) var vertices: VertexTable; -@group(0) @binding(2) var indices: IndexTable; -@group(0) @binding(3) var draws: DrawTable; -@group(0) @binding(4) var point_ndc: vec4; -@group(0) @binding(5) var output: OutputTable; - -fn write_vertex(offset: u32, vertex: BloomVertex3D) { - output.words[offset + 0u] = bitcast(vertex.position.x); - output.words[offset + 1u] = bitcast(vertex.position.y); - output.words[offset + 2u] = bitcast(vertex.position.z); - output.words[offset + 3u] = bitcast(vertex.normal.x); - output.words[offset + 4u] = bitcast(vertex.normal.y); - output.words[offset + 5u] = bitcast(vertex.normal.z); - output.words[offset + 6u] = bitcast(vertex.color.x); - output.words[offset + 7u] = bitcast(vertex.color.y); - output.words[offset + 8u] = bitcast(vertex.color.z); - output.words[offset + 9u] = bitcast(vertex.color.w); - output.words[offset + 10u] = bitcast(vertex.uv.x); - output.words[offset + 11u] = bitcast(vertex.uv.y); - output.words[offset + 12u] = bitcast(vertex.joints.x); - output.words[offset + 13u] = bitcast(vertex.joints.y); - output.words[offset + 14u] = bitcast(vertex.joints.z); - output.words[offset + 15u] = bitcast(vertex.joints.w); - output.words[offset + 16u] = bitcast(vertex.weights.x); - output.words[offset + 17u] = bitcast(vertex.weights.y); - output.words[offset + 18u] = bitcast(vertex.weights.z); - output.words[offset + 19u] = bitcast(vertex.weights.w); - output.words[offset + 20u] = bitcast(vertex.tangent.x); - output.words[offset + 21u] = bitcast(vertex.tangent.y); - output.words[offset + 22u] = bitcast(vertex.tangent.z); - output.words[offset + 23u] = bitcast(vertex.tangent.w); -} - -@compute @workgroup_size(1) -fn cs_main() { - let raw_visibility = textureLoad(visibility_texture, vec2(0, 0), 0).xy; - if (!bloom_visibility_valid(raw_visibility)) { - output.words[96] = BLOOM_VISIBILITY_INVALID_DRAW_ID; - return; - } - let visibility = bloom_decode_visibility(raw_visibility); - let draw = draws.records[visibility.draw_id]; - let first_index = draw.draw.y + visibility.primitive_id * 3u; - let base_vertex = bitcast(draw.draw.z); - let index0 = u32(i32(indices.values[first_index]) + base_vertex); - let index1 = u32(i32(indices.values[first_index + 1u]) + base_vertex); - let index2 = u32(i32(indices.values[first_index + 2u]) + base_vertex); - let vertex0 = bloom_decode_vertex3d(vertices.records[index0]); - let vertex1 = bloom_decode_vertex3d(vertices.records[index1]); - let vertex2 = bloom_decode_vertex3d(vertices.records[index2]); - write_vertex(0u, vertex0); - write_vertex(24u, vertex1); - write_vertex(48u, vertex2); - - let clip0 = draw.uniforms.mvp * vec4(vertex0.position, 1.0); - let clip1 = draw.uniforms.mvp * vec4(vertex1.position, 1.0); - let clip2 = draw.uniforms.mvp * vec4(vertex2.position, 1.0); - let bary = bloom_perspective_barycentrics(point_ndc.xy, clip0, clip1, clip2); - let interpolated = BloomVertex3D( - bloom_interpolate3(vertex0.position, vertex1.position, vertex2.position, bary), - bloom_interpolate3(vertex0.normal, vertex1.normal, vertex2.normal, bary), - bloom_interpolate4(vertex0.color, vertex1.color, vertex2.color, bary), - bloom_interpolate2(vertex0.uv, vertex1.uv, vertex2.uv, bary), - bloom_interpolate4(vertex0.joints, vertex1.joints, vertex2.joints, bary), - bloom_interpolate4(vertex0.weights, vertex1.weights, vertex2.weights, bary), - bloom_interpolate4(vertex0.tangent, vertex1.tangent, vertex2.tangent, bary), - ); - write_vertex(72u, interpolated); - output.words[96] = visibility.draw_id; - output.words[97] = visibility.primitive_id; - output.words[98] = select(0u, 1u, visibility.front_facing); - output.words[99] = draw.draw.w; -} -"#, - ] - .concat(); - wgpu::naga::front::wgsl::parse_str(&shader_source) - .unwrap_or_else(|error| panic!("shared-geometry oracle WGSL failed: {error:?}")); - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("visibility_shared_geometry_oracle_shader"), - source: wgpu::ShaderSource::Wgsl(shader_source.into()), - }); - let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("visibility_shared_geometry_oracle_pipeline"), - layout: None, - module: &shader, - entry_point: Some("cs_main"), - compilation_options: Default::default(), - cache: None, - }); - - let visibility = device.create_texture(&wgpu::TextureDescriptor { - label: Some("visibility_shared_geometry_oracle_ids"), - size: wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: VISIBILITY_FORMAT, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &visibility, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - bytemuck::bytes_of(&visibility_record), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(VISIBILITY_BYTES_PER_PIXEL as u32), - rows_per_image: Some(1), - }, - wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - ); - - let make_storage = |label, contents: &[u8]| { - device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some(label), - contents, - usage: wgpu::BufferUsages::STORAGE, - }) - }; - let vertex_buffer = make_storage( - "visibility_shared_geometry_oracle_vertices", - bytemuck::cast_slice(&vertices), - ); - let index_buffer = make_storage( - "visibility_shared_geometry_oracle_indices", - bytemuck::cast_slice(&indices), - ); - let draw_buffer = make_storage( - "visibility_shared_geometry_oracle_draws", - bytemuck::bytes_of(&draw), - ); - let point_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("visibility_shared_geometry_oracle_point"), - contents: bytemuck::cast_slice(&point_ndc), - usage: wgpu::BufferUsages::UNIFORM, - }); - let output_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("visibility_shared_geometry_oracle_output"), - size: (OUTPUT_WORDS * 4) as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, - mapped_at_creation: false, - }); - let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("visibility_shared_geometry_oracle_readback"), - size: (OUTPUT_WORDS * 4) as u64, - usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, - mapped_at_creation: false, - }); - let visibility_view = visibility.create_view(&Default::default()); - let layout = pipeline.get_bind_group_layout(0); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("visibility_shared_geometry_oracle_bind_group"), - layout: &layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&visibility_view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: vertex_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: index_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: draw_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 4, - resource: point_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 5, - resource: output_buffer.as_entire_binding(), - }, - ], - }); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("visibility_shared_geometry_oracle_encoder"), - }); - { - let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { - label: Some("visibility_shared_geometry_oracle_pass"), - timestamp_writes: None, - }); - pass.set_pipeline(&pipeline); - pass.set_bind_group(0, &bind_group, &[]); - pass.dispatch_workgroups(1, 1, 1); - } - encoder.copy_buffer_to_buffer( - &output_buffer, - 0, - &readback_buffer, - 0, - (OUTPUT_WORDS * 4) as u64, - ); - queue.submit(std::iter::once(encoder.finish())); - - let output_bytes = readback(&device, &readback_buffer); - let output: &[u32] = bytemuck::cast_slice(&output_bytes); - let expected_raw: &[u32] = bytemuck::cast_slice(&vertices[1..]); - assert_eq!(&output[..72], expected_raw); - - let clip = [ - [-0.9, -0.8, 0.5, 1.0], - [-0.2, -1.6, 0.5, 2.0], - [-2.0, 3.2, 0.5, 4.0], - ]; - let bary = perspective_barycentrics([point_ndc[0], point_ndc[1]], clip).unwrap(); - let source: &[f32] = bytemuck::cast_slice(&vertices[1..]); - for lane in 0..24 { - let expected = - source[lane] * bary[0] + source[24 + lane] * bary[1] + source[48 + lane] * bary[2]; - let actual = f32::from_bits(output[72 + lane]); - assert!( - (actual - expected).abs() <= 2.0e-5, - "interpolated Vertex3D lane {lane}: GPU {actual}, CPU {expected}" - ); - } - assert_eq!(&output[96..100], &[0, 0, 1, 1_234]); - } -} +#[path = "visibility_buffer_tests.rs"] +mod tests; diff --git a/native/shared/src/renderer/visibility_buffer_tests.rs b/native/shared/src/renderer/visibility_buffer_tests.rs new file mode 100644 index 00000000..b4e98ba5 --- /dev/null +++ b/native/shared/src/renderer/visibility_buffer_tests.rs @@ -0,0 +1,811 @@ +use super::super::{gpu_driven::GpuDrawRecord, Uniforms3D, Vertex3D}; +use super::*; +use wgpu::util::DeviceExt; + +fn assert_close(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() <= 1.0e-6, + "{actual} != {expected}" + ); +} + +#[test] +fn packed_record_is_exactly_one_rg32uint_texel() { + assert_eq!(std::mem::size_of::(), 8); + assert_eq!(std::mem::align_of::(), 4); + assert_eq!(VISIBILITY_BYTES_PER_PIXEL, 8); + assert_eq!(VISIBILITY_FORMAT, wgpu::TextureFormat::Rg32Uint); + assert_eq!(target_bytes(1_920, 1_080), Some(16_588_800)); + assert_eq!(target_bytes(u32::MAX, u32::MAX), None); + + let report = contract_json(); + assert!(report.starts_with("{\"format\":\"rg32uint\"")); + assert!(report.contains("\"native_1080p_bytes\":16588800")); + assert!(report.contains("\"required_feature\":\"primitive-index\"")); + assert!(report.contains("\"vertex_stride_bytes\":96")); + assert!(report.contains("\"shipping_enabled\":false")); +} + +#[test] +fn ids_and_face_orientation_round_trip_without_background_collision() { + for (draw, primitive, front) in [ + (0, 0, false), + (17, 42, true), + (DRAW_INDEX_MASK, PRIMITIVE_ID_MASK, false), + ] { + let encoded = VisibilityRecord::encode(draw, primitive, front).unwrap(); + assert_eq!(encoded.decode(), Some((draw, primitive, front))); + } + assert_eq!(VisibilityRecord::BACKGROUND.decode(), None); + assert_eq!(VisibilityRecord::encode(INVALID_DRAW_ID, 0, true), None); + assert_eq!(VisibilityRecord::encode(VIRTUAL_DRAW_BIT, 0, true), None); + assert_eq!(VisibilityRecord::encode(0, FRONT_FACE_BIT, true), None); +} + +#[test] +fn perspective_reconstruction_matches_vertices_and_known_depth_weighting() { + let clip = [ + [-1.0, -1.0, 0.2, 1.0], + [2.0, -2.0, 0.4, 2.0], + [0.0, 4.0, 0.8, 4.0], + ]; + for (point, expected) in [ + ([-1.0, -1.0], [1.0, 0.0, 0.0]), + ([1.0, -1.0], [0.0, 1.0, 0.0]), + ([0.0, 1.0], [0.0, 0.0, 1.0]), + ] { + let actual = perspective_barycentrics(point, clip).unwrap(); + for lane in 0..3 { + assert_close(actual[lane], expected[lane]); + } + } + + let center = perspective_barycentrics([0.0, -1.0 / 3.0], clip).unwrap(); + assert_close(center[0], 4.0 / 7.0); + assert_close(center[1], 2.0 / 7.0); + assert_close(center[2], 1.0 / 7.0); + assert_close(center.iter().sum(), 1.0); +} + +#[test] +fn shared_reconstruction_header_parses_and_keeps_the_cpu_abi_constants() { + wgpu::naga::front::wgsl::parse_str(RECONSTRUCTION_WGSL) + .unwrap_or_else(|error| panic!("visibility reconstruction WGSL failed: {error:?}")); + wgpu::naga::front::wgsl::parse_str(GEOMETRY_WGSL) + .unwrap_or_else(|error| panic!("visibility geometry WGSL failed: {error:?}")); + assert!( + RECONSTRUCTION_WGSL.contains("const BLOOM_VISIBILITY_FRONT_FACE_BIT: u32 = 0x80000000u") + ); + assert!(RECONSTRUCTION_WGSL.contains("fn bloom_perspective_barycentrics(")); + assert!(GEOMETRY_WGSL.contains("const BLOOM_VERTEX3D_WORDS: u32 = 24u")); + assert_eq!(std::mem::size_of::(), 96); +} + +#[test] +fn runtime_modes_request_only_the_explicit_optional_feature() { + assert_eq!(parse_runtime_mode(None), RuntimeMode::Off); + assert_eq!(parse_runtime_mode(Some("off")), RuntimeMode::Off); + assert_eq!(parse_runtime_mode(Some("validate")), RuntimeMode::Validate); + assert_eq!(parse_runtime_mode(Some("DEBUG")), RuntimeMode::Debug); + assert_eq!(parse_runtime_mode(Some("pbr")), RuntimeMode::Shade); + assert!(RuntimeMode::Shade.shades()); + + let supported = wgpu::Features::PRIMITIVE_INDEX | wgpu::Features::TIMESTAMP_QUERY; + let mut required = wgpu::Features::empty(); + request_feature_for_mode(RuntimeMode::Off, supported, &mut required); + assert!(required.is_empty()); + request_feature_for_mode(RuntimeMode::Validate, supported, &mut required); + assert_eq!(required, wgpu::Features::PRIMITIVE_INDEX); + request_feature_for_mode(RuntimeMode::Shade, supported, &mut required); + assert_eq!(required, wgpu::Features::PRIMITIVE_INDEX); + + let mut unsupported = wgpu::Features::empty(); + request_feature_for_mode( + RuntimeMode::Debug, + wgpu::Features::TIMESTAMP_QUERY, + &mut unsupported, + ); + assert!(unsupported.is_empty()); + + let disabled = VisibilityBufferRuntime::disabled(RuntimeMode::Off, "not-requested"); + assert!(!disabled.enabled()); + assert!(disabled.resources.is_none()); + assert!(disabled.report_json().contains("\"allocated_bytes\":0")); +} + +#[test] +fn runtime_raster_reconstruction_and_overlay_shaders_parse() { + let generated = + super::super::gpu_driven::make_gpu_scene_shader(super::super::shaders::SCENE_SHADER); + let raster = make_visibility_raster_shader(&generated); + let depth = super::super::visibility_shading::make_visibility_depth_shader(&generated); + wgpu::naga::front::wgsl::parse_str(&raster) + .unwrap_or_else(|error| panic!("visibility runtime raster WGSL failed: {error:?}")); + wgpu::naga::front::wgsl::parse_str(&depth) + .unwrap_or_else(|error| panic!("visibility depth WGSL failed: {error:?}")); + wgpu::naga::front::wgsl::parse_str(RUNTIME_RECONSTRUCT_WGSL) + .unwrap_or_else(|error| panic!("visibility runtime reconstruction WGSL failed: {error:?}")); + wgpu::naga::front::wgsl::parse_str(DEBUG_OVERLAY_WGSL) + .unwrap_or_else(|error| panic!("visibility debug overlay WGSL failed: {error:?}")); + assert!(raster.starts_with("enable primitive_index;")); + assert!(raster.contains("out.draw_id = draw_index")); + assert!(raster.contains("(in.draw_flags & 2u) == 0u")); + assert!(depth.contains("return vec2(0xffffffffu, 0xffffffffu)")); + assert!(RUNTIME_RECONSTRUCT_WGSL.contains("visibility_vertex_count()")); + assert!(RUNTIME_RECONSTRUCT_WGSL.contains("@group(0) @binding(6)")); +} + +#[test] +fn large_vertex_arenas_split_at_aligned_storage_binding_boundaries() { + let mut limits = wgpu::Limits::downlevel_defaults(); + limits.max_storage_buffer_binding_size = 128 * 1024 * 1024; + limits.min_storage_buffer_offset_alignment = 256; + let vertex_stride = std::mem::size_of::() as u64; + let used_bytes = 1_738_262 * vertex_stride; + let ranges = visibility_vertex_binding_ranges(&limits, 256 * 1024 * 1024, used_bytes); + assert_eq!(ranges[0], (0, 134_217_216)); + assert_eq!(ranges[1], (134_217_216, used_bytes - 134_217_216)); + assert_eq!(ranges[2], (0, vertex_stride)); + assert!(ranges + .iter() + .all(|&(_, size)| size <= u64::from(limits.max_storage_buffer_binding_size))); + assert!(ranges + .iter() + .all(|&(offset, _)| offset % u64::from(limits.min_storage_buffer_offset_alignment) == 0)); +} + +#[cfg(not(target_arch = "wasm32"))] +fn try_device(required_features: wgpu::Features) -> Option<(wgpu::Device, wgpu::Queue)> { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + let adapter = + pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .ok()?; + if !adapter.features().contains(required_features) { + eprintln!("adapter lacks required visibility-oracle features"); + return None; + } + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("visibility_buffer_oracle_device"), + required_features, + required_limits: wgpu::Limits::downlevel_defaults(), + ..Default::default() + })) + .ok() +} + +#[cfg(not(target_arch = "wasm32"))] +fn readback(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec { + let slice = buffer.slice(..); + let (sender, receiver) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + let _ = sender.send(result); + }); + let _ = device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }); + receiver + .recv() + .expect("visibility readback callback dropped") + .expect("visibility readback mapping failed"); + let mapped = slice.get_mapped_range(); + let bytes = mapped.to_vec(); + drop(mapped); + buffer.unmap(); + bytes +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_raster_ids_faces_and_reconstruction_match_the_cpu_oracle() { + const WIDTH: u32 = 32; + const HEIGHT: u32 = 16; + const VISIBILITY_ROW_BYTES: u32 = 256; + const BARYCENTRIC_ROW_BYTES: u32 = WIDTH * 16; + let Some((device, queue)) = try_device(wgpu::Features::PRIMITIVE_INDEX) else { + eprintln!("no GPU adapter — skipping visibility raster oracle"); + return; + }; + + let clip = [ + [-0.9, -0.8, 0.5, 1.0], + [-0.2, -1.6, 1.0, 2.0], + [-2.0, 3.2, 2.0, 4.0], + [0.1, -0.8, 0.5, 1.0], + [2.0, 3.2, 2.0, 4.0], + [1.8, -1.6, 1.0, 2.0], + ]; + let shader_source = format!( + "enable primitive_index;\n\ + {RECONSTRUCTION_WGSL}\n\ + struct VertexOut {{ @builtin(position) position: vec4, }};\n\ + struct FragmentOut {{\n\ + @location(0) visibility: vec2,\n\ + @location(1) barycentrics: vec4,\n\ + }};\n\ + fn clip_position(index: u32) -> vec4 {{\n\ + var positions = array, 6>(\n\ + vec4(-0.9, -0.8, 0.5, 1.0),\n\ + vec4(-0.2, -1.6, 1.0, 2.0),\n\ + vec4(-2.0, 3.2, 2.0, 4.0),\n\ + vec4(0.1, -0.8, 0.5, 1.0),\n\ + vec4(2.0, 3.2, 2.0, 4.0),\n\ + vec4(1.8, -1.6, 1.0, 2.0),\n\ + );\n\ + return positions[index];\n\ + }}\n\ + @vertex fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut {{\n\ + var out: VertexOut;\n\ + out.position = clip_position(index);\n\ + return out;\n\ + }}\n\ + @fragment fn fs_main(\n\ + in: VertexOut,\n\ + @builtin(primitive_index) primitive_id: u32,\n\ + @builtin(front_facing) front_facing: bool,\n\ + ) -> FragmentOut {{\n\ + let first = primitive_id * 3u;\n\ + let point_ndc = vec2(\n\ + in.position.x / {WIDTH}.0 * 2.0 - 1.0,\n\ + 1.0 - in.position.y / {HEIGHT}.0 * 2.0,\n\ + );\n\ + let barycentrics = bloom_perspective_barycentrics(\n\ + point_ndc,\n\ + clip_position(first),\n\ + clip_position(first + 1u),\n\ + clip_position(first + 2u),\n\ + );\n\ + var out: FragmentOut;\n\ + out.visibility = bloom_encode_visibility(7u, primitive_id, front_facing);\n\ + out.barycentrics = vec4(barycentrics, 1.0);\n\ + return out;\n\ + }}" + ); + wgpu::naga::front::wgsl::parse_str(&shader_source) + .unwrap_or_else(|error| panic!("visibility raster oracle WGSL failed: {error:?}")); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("visibility_buffer_oracle_shader"), + source: wgpu::ShaderSource::Wgsl(shader_source.into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("visibility_buffer_oracle_pipeline_layout"), + bind_group_layouts: &[], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("visibility_buffer_oracle_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[ + Some(wgpu::ColorTargetState { + format: VISIBILITY_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba32Float, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + ..Default::default() + }, + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); + + let make_target = |label, format| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: WIDTH, + height: HEIGHT, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }) + }; + let visibility = make_target("visibility_buffer_oracle_ids", VISIBILITY_FORMAT); + let barycentrics = make_target( + "visibility_buffer_oracle_barycentrics", + wgpu::TextureFormat::Rgba32Float, + ); + let visibility_view = visibility.create_view(&Default::default()); + let barycentric_view = barycentrics.create_view(&Default::default()); + let visibility_readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("visibility_buffer_oracle_id_readback"), + size: (VISIBILITY_ROW_BYTES * HEIGHT) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let barycentric_readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("visibility_buffer_oracle_barycentric_readback"), + size: (BARYCENTRIC_ROW_BYTES * HEIGHT) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("visibility_buffer_oracle_encoder"), + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("visibility_buffer_oracle_pass"), + color_attachments: &[ + Some(wgpu::RenderPassColorAttachment { + view: &visibility_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: u32::MAX as f64, + g: u32::MAX as f64, + b: 0.0, + a: 0.0, + }), + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: &barycentric_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + }), + ], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&pipeline); + pass.draw(0..6, 0..1); + } + for (texture, buffer, bytes_per_row) in [ + (&visibility, &visibility_readback, VISIBILITY_ROW_BYTES), + (&barycentrics, &barycentric_readback, BARYCENTRIC_ROW_BYTES), + ] { + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: Some(HEIGHT), + }, + }, + wgpu::Extent3d { + width: WIDTH, + height: HEIGHT, + depth_or_array_layers: 1, + }, + ); + } + queue.submit(std::iter::once(encoder.finish())); + + let id_bytes = readback(&device, &visibility_readback); + let barycentric_bytes = readback(&device, &barycentric_readback); + let mut primitive_pixels = [0usize; 2]; + let mut primitive_faces = [None; 2]; + let mut background_pixels = 0usize; + for y in 0..HEIGHT { + for x in 0..WIDTH { + let id_offset = (y * VISIBILITY_ROW_BYTES + x * 8) as usize; + let words: &[u32] = bytemuck::cast_slice(&id_bytes[id_offset..id_offset + 8]); + let record = VisibilityRecord { + draw_id: words[0], + primitive_and_face: words[1], + }; + let Some((draw_id, primitive_id, front_facing)) = record.decode() else { + background_pixels += 1; + continue; + }; + assert_eq!(draw_id, 7); + assert!(primitive_id < 2); + let primitive = primitive_id as usize; + primitive_pixels[primitive] += 1; + match primitive_faces[primitive] { + Some(expected) => assert_eq!(front_facing, expected), + None => primitive_faces[primitive] = Some(front_facing), + } + + let point_ndc = [ + (x as f32 + 0.5) / WIDTH as f32 * 2.0 - 1.0, + 1.0 - (y as f32 + 0.5) / HEIGHT as f32 * 2.0, + ]; + let first = primitive * 3; + let expected = perspective_barycentrics( + point_ndc, + [clip[first], clip[first + 1], clip[first + 2]], + ) + .unwrap(); + let bary_offset = (y * BARYCENTRIC_ROW_BYTES + x * 16) as usize; + let actual: &[f32] = + bytemuck::cast_slice(&barycentric_bytes[bary_offset..bary_offset + 16]); + for lane in 0..3 { + assert!( + (actual[lane] - expected[lane]).abs() <= 2.0e-5, + "pixel ({x},{y}) primitive {primitive}: GPU {:?}, CPU {:?}", + &actual[..3], + expected, + ); + } + assert_close(actual[0] + actual[1] + actual[2], 1.0); + } + } + assert!(background_pixels > 0, "clear sentinel was not preserved"); + assert!(primitive_pixels.iter().all(|pixels| *pixels > 0)); + assert_ne!( + primitive_faces[0], primitive_faces[1], + "opposite winding must preserve distinct front-face bits" + ); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_pulls_shared_geometry_and_reconstructs_every_vertex_lane() { + const OUTPUT_WORDS: usize = 100; + let Some((device, queue)) = try_device(wgpu::Features::empty()) else { + eprintln!("no GPU adapter — skipping shared-geometry oracle"); + return; + }; + + let padding = Vertex3D { + position: [-99.0; 3], + normal: [-98.0; 3], + color: [-97.0; 4], + uv: [-96.0; 2], + joints: [-95.0; 4], + weights: [-94.0; 4], + tangent: [-93.0; 4], + }; + let vertices = [ + padding, + Vertex3D { + position: [-0.9, -0.8, 1.0], + normal: [0.1, 0.2, 0.3], + color: [0.4, 0.5, 0.6, 0.7], + uv: [0.8, 0.9], + joints: [1.0, 2.0, 3.0, 4.0], + weights: [0.1, 0.2, 0.3, 0.4], + tangent: [0.7, 0.2, 0.1, -1.0], + }, + Vertex3D { + position: [-0.2, -1.6, 2.0], + normal: [1.1, 1.2, 1.3], + color: [1.4, 1.5, 1.6, 1.7], + uv: [1.8, 1.9], + joints: [5.0, 6.0, 7.0, 8.0], + weights: [0.4, 0.3, 0.2, 0.1], + tangent: [0.1, 0.6, 0.3, 1.0], + }, + Vertex3D { + position: [-2.0, 3.2, 4.0], + normal: [2.1, 2.2, 2.3], + color: [2.4, 2.5, 2.6, 2.7], + uv: [2.8, 2.9], + joints: [9.0, 10.0, 11.0, 12.0], + weights: [0.25, 0.25, 0.25, 0.25], + tangent: [0.4, 0.2, 0.8, -1.0], + }, + ]; + let mvp = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + [0.0, 0.0, 0.5, 0.0], + ]; + let identity = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + let draw = GpuDrawRecord { + uniforms: Uniforms3D { + mvp, + model: identity, + prev_mvp: identity, + model_tint: [1.0; 4], + misc: [0.0; 4], + }, + bounds_min: [-2.0, -1.6, 1.0, 0.0], + bounds_max: [-0.2, 3.2, 4.0, 0.0], + draw: [3, 3, 1_i32 as u32, 1_234], + }; + let indices = [91u32, 92, 93, 0, 1, 2]; + let point_ndc = [-0.45f32, -0.1, 0.0, 0.0]; + let visibility_record = VisibilityRecord::encode(0, 0, true).unwrap(); + + let shader_source = [ + RECONSTRUCTION_WGSL, + GEOMETRY_WGSL, + r#" +struct Uniforms3D { + mvp: mat4x4, + model: mat4x4, + prev_mvp: mat4x4, + model_tint: vec4, + misc: vec4, +}; +struct GpuDrawRecord { + uniforms: Uniforms3D, + bounds_min: vec4, + bounds_max: vec4, + draw: vec4, +}; +struct VertexTable { records: array, }; +struct IndexTable { values: array, }; +struct DrawTable { records: array, }; +struct OutputTable { words: array, }; + +@group(0) @binding(0) var visibility_texture: texture_2d; +@group(0) @binding(1) var vertices: VertexTable; +@group(0) @binding(2) var indices: IndexTable; +@group(0) @binding(3) var draws: DrawTable; +@group(0) @binding(4) var point_ndc: vec4; +@group(0) @binding(5) var output: OutputTable; + +fn write_vertex(offset: u32, vertex: BloomVertex3D) { + output.words[offset + 0u] = bitcast(vertex.position.x); + output.words[offset + 1u] = bitcast(vertex.position.y); + output.words[offset + 2u] = bitcast(vertex.position.z); + output.words[offset + 3u] = bitcast(vertex.normal.x); + output.words[offset + 4u] = bitcast(vertex.normal.y); + output.words[offset + 5u] = bitcast(vertex.normal.z); + output.words[offset + 6u] = bitcast(vertex.color.x); + output.words[offset + 7u] = bitcast(vertex.color.y); + output.words[offset + 8u] = bitcast(vertex.color.z); + output.words[offset + 9u] = bitcast(vertex.color.w); + output.words[offset + 10u] = bitcast(vertex.uv.x); + output.words[offset + 11u] = bitcast(vertex.uv.y); + output.words[offset + 12u] = bitcast(vertex.joints.x); + output.words[offset + 13u] = bitcast(vertex.joints.y); + output.words[offset + 14u] = bitcast(vertex.joints.z); + output.words[offset + 15u] = bitcast(vertex.joints.w); + output.words[offset + 16u] = bitcast(vertex.weights.x); + output.words[offset + 17u] = bitcast(vertex.weights.y); + output.words[offset + 18u] = bitcast(vertex.weights.z); + output.words[offset + 19u] = bitcast(vertex.weights.w); + output.words[offset + 20u] = bitcast(vertex.tangent.x); + output.words[offset + 21u] = bitcast(vertex.tangent.y); + output.words[offset + 22u] = bitcast(vertex.tangent.z); + output.words[offset + 23u] = bitcast(vertex.tangent.w); +} + +@compute @workgroup_size(1) +fn cs_main() { + let raw_visibility = textureLoad(visibility_texture, vec2(0, 0), 0).xy; + if (!bloom_visibility_valid(raw_visibility)) { + output.words[96] = BLOOM_VISIBILITY_INVALID_DRAW_ID; + return; + } + let visibility = bloom_decode_visibility(raw_visibility); + let draw = draws.records[visibility.draw_id]; + let first_index = draw.draw.y + visibility.primitive_id * 3u; + let base_vertex = bitcast(draw.draw.z); + let index0 = u32(i32(indices.values[first_index]) + base_vertex); + let index1 = u32(i32(indices.values[first_index + 1u]) + base_vertex); + let index2 = u32(i32(indices.values[first_index + 2u]) + base_vertex); + let vertex0 = bloom_decode_vertex3d(vertices.records[index0]); + let vertex1 = bloom_decode_vertex3d(vertices.records[index1]); + let vertex2 = bloom_decode_vertex3d(vertices.records[index2]); + write_vertex(0u, vertex0); + write_vertex(24u, vertex1); + write_vertex(48u, vertex2); + + let clip0 = draw.uniforms.mvp * vec4(vertex0.position, 1.0); + let clip1 = draw.uniforms.mvp * vec4(vertex1.position, 1.0); + let clip2 = draw.uniforms.mvp * vec4(vertex2.position, 1.0); + let bary = bloom_perspective_barycentrics(point_ndc.xy, clip0, clip1, clip2); + let interpolated = BloomVertex3D( + bloom_interpolate3(vertex0.position, vertex1.position, vertex2.position, bary), + bloom_interpolate3(vertex0.normal, vertex1.normal, vertex2.normal, bary), + bloom_interpolate4(vertex0.color, vertex1.color, vertex2.color, bary), + bloom_interpolate2(vertex0.uv, vertex1.uv, vertex2.uv, bary), + bloom_interpolate4(vertex0.joints, vertex1.joints, vertex2.joints, bary), + bloom_interpolate4(vertex0.weights, vertex1.weights, vertex2.weights, bary), + bloom_interpolate4(vertex0.tangent, vertex1.tangent, vertex2.tangent, bary), + ); + write_vertex(72u, interpolated); + output.words[96] = visibility.draw_id; + output.words[97] = visibility.primitive_id; + output.words[98] = select(0u, 1u, visibility.front_facing); + output.words[99] = draw.draw.w; +} +"#, + ] + .concat(); + wgpu::naga::front::wgsl::parse_str(&shader_source) + .unwrap_or_else(|error| panic!("shared-geometry oracle WGSL failed: {error:?}")); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("visibility_shared_geometry_oracle_shader"), + source: wgpu::ShaderSource::Wgsl(shader_source.into()), + }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("visibility_shared_geometry_oracle_pipeline"), + layout: None, + module: &shader, + entry_point: Some("cs_main"), + compilation_options: Default::default(), + cache: None, + }); + + let visibility = device.create_texture(&wgpu::TextureDescriptor { + label: Some("visibility_shared_geometry_oracle_ids"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: VISIBILITY_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &visibility, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + bytemuck::bytes_of(&visibility_record), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(VISIBILITY_BYTES_PER_PIXEL as u32), + rows_per_image: Some(1), + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + + let make_storage = |label, contents: &[u8]| { + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some(label), + contents, + usage: wgpu::BufferUsages::STORAGE, + }) + }; + let vertex_buffer = make_storage( + "visibility_shared_geometry_oracle_vertices", + bytemuck::cast_slice(&vertices), + ); + let index_buffer = make_storage( + "visibility_shared_geometry_oracle_indices", + bytemuck::cast_slice(&indices), + ); + let draw_buffer = make_storage( + "visibility_shared_geometry_oracle_draws", + bytemuck::bytes_of(&draw), + ); + let point_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("visibility_shared_geometry_oracle_point"), + contents: bytemuck::cast_slice(&point_ndc), + usage: wgpu::BufferUsages::UNIFORM, + }); + let output_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("visibility_shared_geometry_oracle_output"), + size: (OUTPUT_WORDS * 4) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("visibility_shared_geometry_oracle_readback"), + size: (OUTPUT_WORDS * 4) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let visibility_view = visibility.create_view(&Default::default()); + let layout = pipeline.get_bind_group_layout(0); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("visibility_shared_geometry_oracle_bind_group"), + layout: &layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&visibility_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: vertex_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: index_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: draw_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: point_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: output_buffer.as_entire_binding(), + }, + ], + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("visibility_shared_geometry_oracle_encoder"), + }); + { + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("visibility_shared_geometry_oracle_pass"), + timestamp_writes: None, + }); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.dispatch_workgroups(1, 1, 1); + } + encoder.copy_buffer_to_buffer( + &output_buffer, + 0, + &readback_buffer, + 0, + (OUTPUT_WORDS * 4) as u64, + ); + queue.submit(std::iter::once(encoder.finish())); + + let output_bytes = readback(&device, &readback_buffer); + let output: &[u32] = bytemuck::cast_slice(&output_bytes); + let expected_raw: &[u32] = bytemuck::cast_slice(&vertices[1..]); + assert_eq!(&output[..72], expected_raw); + + let clip = [ + [-0.9, -0.8, 0.5, 1.0], + [-0.2, -1.6, 0.5, 2.0], + [-2.0, 3.2, 0.5, 4.0], + ]; + let bary = perspective_barycentrics([point_ndc[0], point_ndc[1]], clip).unwrap(); + let source: &[f32] = bytemuck::cast_slice(&vertices[1..]); + for lane in 0..24 { + let expected = + source[lane] * bary[0] + source[24 + lane] * bary[1] + source[48 + lane] * bary[2]; + let actual = f32::from_bits(output[72 + lane]); + assert!( + (actual - expected).abs() <= 2.0e-5, + "interpolated Vertex3D lane {lane}: GPU {actual}, CPU {expected}" + ); + } + assert_eq!(&output[96..100], &[0, 0, 1, 1_234]); +} diff --git a/native/shared/src/scene.rs b/native/shared/src/scene.rs index 045c2ec5..4b7cfe34 100644 --- a/native/shared/src/scene.rs +++ b/native/shared/src/scene.rs @@ -2394,587 +2394,10 @@ impl SceneGraph { pass.draw_indexed(0..index_count, 0, 0..1); } } - - pub fn has_transparent_nodes(&self) -> bool { - self.has_transparent_nodes - } - - pub(crate) fn has_layered_transparent_nodes(&self) -> bool { - self.has_layered_transparent_nodes - } - - pub fn has_refractive_nodes(&self) -> bool { - self.has_refractive_nodes - } - - /// Constant-time guard for renderer paths that only need to inspect - /// physical-transmission metadata when at least one retained GI instance - /// can contribute it. - pub(crate) fn has_transmission_gi_nodes(&self) -> bool { - self.has_transmission_gi_nodes - } - - pub(crate) fn transparent_gi_instance_count(&self) -> u32 { - if !self.has_transmission_gi_nodes { - return 0; - } - self.nodes - .iter() - .filter(|(_handle, node)| { - node.visible - && node.render_layer == 0 - && node.card_first_slot.is_some() - && !node.indices().is_empty() - && node.material.has_gi_transmission() - }) - .count() - .min(u32::MAX as usize) as u32 - } - - /// O(n) only after the O(1) refractive gate succeeds. Shadow visibility is - /// deliberately independent of the camera frustum: off-screen glass may - /// project into a visible receiver. - pub(crate) fn has_transmitted_shadow_casters(&self) -> bool { - self.has_refractive_nodes - && self.nodes.iter().any(|(_handle, node)| { - node.visible - && !node.gi_only - && node.cast_shadow - && !node.indices().is_empty() - && node.material.transmission.is_active() - && node.gpu_refractive_material_bg.is_some() - }) - } - - pub(crate) fn has_visible_opaque_iridescence(&self, imported_refraction_enabled: bool) -> bool { - self.nodes.iter().any(|(_handle, node)| { - node.visible - && !node.gi_only - && !node.indices().is_empty() - && node.in_view_frustum - && !node.occluded - && node.material.alpha_mode != MaterialAlphaMode::Blend - && !(imported_refraction_enabled && node.material.transmission.is_active()) - && node.material.layered_pbr.has_iridescence() - && node.gpu_layered_material_bg.is_some() - }) - } - - pub(crate) fn append_opaque_iridescence_draws<'a>( - &'a self, - out: &mut Vec>, - ) { - for (_handle, node) in self.nodes.iter() { - if !node.visible - || node.gi_only - || node.indices().is_empty() - || !node.in_view_frustum - || node.occluded - || node.material.alpha_mode == MaterialAlphaMode::Blend - || (self.imported_refraction_enabled && node.material.transmission.is_active()) - || !node.material.layered_pbr.has_iridescence() - { - continue; - } - let Some(uniforms) = node.gpu_uniform_bg.as_ref() else { - continue; - }; - let Some(material) = node.gpu_layered_material_bg.as_ref() else { - continue; - }; - let uses_uv1 = node.gpu_layered_uses_uv1; - let (vertex, index, index_count, secondary_uv) = match node - .lods - .get(node.active_lod.max(0) as usize) - .filter(|_| node.active_lod >= 0) - .filter(|lod| !uses_uv1 || lod.gpu_secondary_uv_vb.is_some()) - .and_then(|lod| { - Some(( - lod.gpu_vb.as_ref()?, - lod.gpu_ib.as_ref()?, - lod.gpu_index_count, - if uses_uv1 { - Some(lod.gpu_secondary_uv_vb.as_ref()?) - } else { - None - }, - )) - }) { - Some(lod) => lod, - None => { - let Some(vertex) = node.gpu_vb.as_ref() else { - continue; - }; - let Some(index) = node.gpu_ib.as_ref() else { - continue; - }; - let secondary_uv = if uses_uv1 { - let Some(buffer) = node.gpu_secondary_uv_vb.as_ref() else { - continue; - }; - Some(buffer) - } else { - None - }; - (vertex, index, node.gpu_index_count, secondary_uv) - } - }; - out.push(ImportedIridescenceDrawRef { - uniforms, - material, - mesh: MeshDrawRef { - vertex, - index, - first_index: 0, - index_count, - base_vertex: 0, - }, - secondary_uv, - vertex_byte_offset: 0, - index_byte_offset: 0, - }); - } - } - - pub(crate) fn visible_transparent_node_count( - &self, - imported_refraction_enabled: bool, - ) -> usize { - if !self.has_transparent_nodes { - return 0; - } - self.nodes - .iter() - .filter(|(_handle, node)| { - node.visible - && !node.gi_only - && !node.indices().is_empty() - && node.in_view_frustum - && !node.occluded - && node.material.alpha_mode == MaterialAlphaMode::Blend - && !(imported_refraction_enabled && node.material.transmission.is_active()) - }) - .count() - } - - pub(crate) fn visible_refractive_node_count(&self) -> usize { - if !self.has_refractive_nodes { - return 0; - } - self.nodes - .iter() - .filter(|(_handle, node)| { - node.visible - && !node.gi_only - && !node.indices().is_empty() - && node.in_view_frustum - && !node.occluded - && node.material.transmission.is_active() - }) - .count() - } - - pub(crate) fn append_refractive_draws<'a>( - &'a self, - out: &mut Vec>, - view_projection: &[[f32; 4]; 4], - stable_id_base: usize, - ) { - for (node_index, (_handle, node)) in self.nodes.iter().enumerate() { - if !node.visible - || node.gi_only - || node.indices().is_empty() - || !node.in_view_frustum - || node.occluded - || !node.material.transmission.is_active() - { - continue; - } - let uses_uv1 = node.gpu_refractive_uses_uv1; - let (vertex, index, index_count, secondary_uv) = match node - .lods - .get(node.active_lod.max(0) as usize) - .filter(|_| node.active_lod >= 0) - .filter(|lod| !uses_uv1 || lod.gpu_secondary_uv_vb.is_some()) - .and_then(|lod| { - Some(( - lod.gpu_vb.as_ref()?, - lod.gpu_ib.as_ref()?, - lod.gpu_index_count, - if uses_uv1 { - Some(lod.gpu_secondary_uv_vb.as_ref()?) - } else { - None - }, - )) - }) { - Some(lod) => lod, - None => { - let Some(vertex) = &node.gpu_vb else { - continue; - }; - let Some(index) = &node.gpu_ib else { - continue; - }; - let secondary_uv = if uses_uv1 { - let Some(buffer) = node.gpu_secondary_uv_vb.as_ref() else { - continue; - }; - Some(buffer) - } else { - None - }; - (vertex, index, node.gpu_index_count, secondary_uv) - } - }; - let Some(uniforms) = &node.gpu_uniform_bg else { - continue; - }; - let Some(material) = &node.gpu_refractive_material_bg else { - continue; - }; - let center = if node.world_bounds_min[0] <= node.world_bounds_max[0] { - [ - (node.world_bounds_min[0] + node.world_bounds_max[0]) * 0.5, - (node.world_bounds_min[1] + node.world_bounds_max[1]) * 0.5, - (node.world_bounds_min[2] + node.world_bounds_max[2]) * 0.5, - ] - } else { - let transform = node.world_transform(); - [transform[3][0], transform[3][1], transform[3][2]] - }; - let pivot = crate::renderer::mat4_mul_vec4( - view_projection, - &[center[0], center[1], center[2], 1.0], - ); - out.push(ImportedRefractiveDrawRef { - view_depth: pivot[3], - stable_id: stable_id_base + node_index, - double_sided: node.material.double_sided, - layered: node.gpu_refractive_layered, - uniforms, - material, - mesh: MeshDrawRef { - vertex, - index, - first_index: 0, - index_count, - base_vertex: 0, - }, - secondary_uv, - vertex_byte_offset: 0, - index_byte_offset: 0, - }); - } - } - - /// Render glTF BLEND nodes back-to-front into the forward translucent pass. - /// Opaque depth remains read-only; these nodes never enter the depth prepass. - pub fn render_transparent<'a>( - &'a self, - pass: &mut wgpu::RenderPass<'a>, - single_sided_pipeline: &'a wgpu::RenderPipeline, - double_sided_pipeline: &'a wgpu::RenderPipeline, - view_projection: &[[f32; 4]; 4], - ) { - self.render_transparent_with_refraction( - pass, - single_sided_pipeline, - double_sided_pipeline, - view_projection, - false, - ); - } - - pub(crate) fn render_transparent_with_refraction<'a>( - &'a self, - pass: &mut wgpu::RenderPass<'a>, - single_sided_pipeline: &'a wgpu::RenderPipeline, - double_sided_pipeline: &'a wgpu::RenderPipeline, - view_projection: &[[f32; 4]; 4], - imported_refraction_enabled: bool, - ) { - let mut draws = Vec::new(); - self.append_transparent_draws(&mut draws, view_projection, 0, imported_refraction_enabled); - draws.sort_by(|left, right| { - right - .view_depth - .total_cmp(&left.view_depth) - .then_with(|| left.stable_id.cmp(&right.stable_id)) - }); - let mut current_double_sided = None; - for draw in draws { - if current_double_sided != Some(draw.double_sided) { - pass.set_pipeline(if draw.double_sided { - double_sided_pipeline - } else { - single_sided_pipeline - }); - current_double_sided = Some(draw.double_sided); - } - pass.set_bind_group(0, draw.uniforms, &[]); - pass.set_bind_group(2, draw.material, &[]); - pass.set_vertex_buffer(0, draw.mesh.vertex.slice(..)); - pass.set_index_buffer(draw.mesh.index.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(draw.mesh.index_range(), draw.mesh.base_vertex, 0..1); - } - } - - pub(crate) fn append_transparent_draws<'a>( - &'a self, - out: &mut Vec>, - view_projection: &[[f32; 4]; 4], - stable_id_base: usize, - imported_refraction_enabled: bool, - ) { - for (node_index, (_handle, node)) in self.nodes.iter().enumerate() { - if !node.visible - || node.gi_only - || node.indices().is_empty() - || !node.in_view_frustum - || node.occluded - || node.material.alpha_mode != MaterialAlphaMode::Blend - || (imported_refraction_enabled && node.material.transmission.is_active()) - { - continue; - } - let Some(uniforms) = &node.gpu_uniform_bg else { - continue; - }; - let Some(base_material) = &node.gpu_material_bg else { - continue; - }; - let layered_material = node.gpu_layered_material_bg.as_ref(); - let uses_uv1 = layered_material.is_some() && node.gpu_layered_uses_uv1; - let (vb, ib, index_count, secondary_uv) = match node - .lods - .get(node.active_lod.max(0) as usize) - .filter(|_| node.active_lod >= 0) - .filter(|lod| !uses_uv1 || lod.gpu_secondary_uv_vb.is_some()) - .and_then(|lod| { - Some(( - lod.gpu_vb.as_ref()?, - lod.gpu_ib.as_ref()?, - lod.gpu_index_count, - if uses_uv1 { - Some(lod.gpu_secondary_uv_vb.as_ref()?) - } else { - None - }, - )) - }) { - Some(lod) => lod, - None => { - let Some(vb) = &node.gpu_vb else { continue }; - let Some(ib) = &node.gpu_ib else { continue }; - let secondary_uv = if uses_uv1 { - let Some(buffer) = node.gpu_secondary_uv_vb.as_ref() else { - continue; - }; - Some(buffer) - } else { - None - }; - (vb, ib, node.gpu_index_count, secondary_uv) - } - }; - let center = if node.world_bounds_min[0] <= node.world_bounds_max[0] { - [ - (node.world_bounds_min[0] + node.world_bounds_max[0]) * 0.5, - (node.world_bounds_min[1] + node.world_bounds_max[1]) * 0.5, - (node.world_bounds_min[2] + node.world_bounds_max[2]) * 0.5, - ] - } else { - let transform = node.world_transform(); - [transform[3][0], transform[3][1], transform[3][2]] - }; - let pivot = crate::renderer::mat4_mul_vec4( - view_projection, - &[center[0], center[1], center[2], 1.0], - ); - out.push(ImportedTransparentDrawRef { - view_depth: pivot[3], - stable_id: stable_id_base + node_index, - double_sided: node.material.double_sided, - layered: layered_material.is_some(), - uniforms, - material: layered_material.unwrap_or(base_material), - mesh: MeshDrawRef { - vertex: vb, - index: ib, - first_index: 0, - index_count, - base_vertex: 0, - }, - secondary_uv, - vertex_byte_offset: 0, - index_byte_offset: 0, - }); - } - } - - /// Append retained-node records directly into the renderer's reused - /// draw scratch. Off-frustum nodes remain in the list so the compute - /// culler, rather than the CPU submission loop, owns visibility. - pub(crate) fn append_gpu_driven_draws(&self, out: &mut Vec) -> [u32; 3] { - let mut compatibility = 0u32; - let mut visible = 0u32; - let mut culled = 0u32; - if !self.gpu_driven_scene_active { - compatibility = self - .nodes - .iter() - .filter(|(_, node)| { - node.visible - && !node.gi_only - && node.render_layer == 0 - && !node.indices().is_empty() - && node.in_view_frustum - && !node.occluded - }) - .count() as u32; - return [compatibility, visible, culled]; - } - for (_handle, node) in self.nodes.iter() { - if !node.visible - || node.gi_only - || node.render_layer != 0 - || node.indices().is_empty() - || node.occluded - { - continue; - } - if !scene_node_gpu_driven_ready(node, self.imported_refraction_enabled) { - compatibility += u32::from(node.in_view_frustum); - continue; - } - let Some(slice) = node.gpu_geometry else { - compatibility += u32::from(node.in_view_frustum); - continue; - }; - let Some(slot) = node.uniform_slot else { - compatibility += u32::from(node.in_view_frustum); - continue; - }; - let offset = slot as usize * NODE_UNIFORM_STRIDE as usize; - let end = offset + std::mem::size_of::(); - let Some(bytes) = self.scratch.get(offset..end) else { - compatibility += u32::from(node.in_view_frustum); - continue; - }; - let uniforms = bytemuck::pod_read_unaligned::(bytes); - out.push(GpuDrawRecord { - uniforms, - bounds_min: [ - node.world_bounds_min[0], - node.world_bounds_min[1], - node.world_bounds_min[2], - f32::from_bits(gpu_driven::draw_flags(node.material.double_sided, true)), - ], - bounds_max: [ - node.world_bounds_max[0], - node.world_bounds_max[1], - node.world_bounds_max[2], - 0.0, - ], - draw: [ - node.gpu_index_count, - slice.first_index, - slice.base_vertex as u32, - node.gpu_material_id.raw(), - ], - }); - if node.in_view_frustum { - visible += 1; - } else { - culled += 1; - } - } - [compatibility, visible, culled] - } - - pub fn node_count(&self) -> usize { - self.nodes.iter().count() - } - - pub(crate) fn has_visible_opaque_nodes_in_layer(&self, render_layer: u32) -> bool { - self.nodes.iter().any(|(_, node)| { - node.visible - && !node.gi_only - && node.render_layer == render_layer - && !node.indices().is_empty() - && node.material.alpha_mode != MaterialAlphaMode::Blend - && !(self.imported_refraction_enabled && node.material.transmission.is_active()) - }) - } - - /// Draw list for the planar-reflection probe: every visible node with - /// uploaded geometry as (vb, ib, index_count, material_bg, transform). - /// Frustum / occlusion flags are intentionally ignored — they were - /// computed for the MAIN camera and the mirrored probe camera sees a - /// different set. Base geometry only (no LOD swap): the probe is - /// half-res and consumed through a perturbed water lookup, where a - /// LOD pop would be more visible than the detail it saves. - /// (Treats the composed public × imported transform as world.) - pub fn reflect_draw_list( - &self, - ) -> Vec<( - &wgpu::Buffer, - &wgpu::Buffer, - u32, - &wgpu::BindGroup, - [[f32; 4]; 4], - [f32; 3], - [f32; 3], - )> { - self.reflect_draw_list_with_refraction(false) - } - - pub(crate) fn reflect_draw_list_with_refraction( - &self, - imported_refraction_enabled: bool, - ) -> Vec<( - &wgpu::Buffer, - &wgpu::Buffer, - u32, - &wgpu::BindGroup, - [[f32; 4]; 4], - [f32; 3], - [f32; 3], - )> { - let mut out = Vec::new(); - for (_handle, node) in self.nodes.iter() { - if !node.visible - || node.gi_only - || node.material.alpha_mode == MaterialAlphaMode::Blend - || (imported_refraction_enabled && node.material.transmission.is_active()) - || node.indices().is_empty() - { - continue; - } - let Some(vb) = &node.gpu_vb else { continue }; - let Some(ib) = &node.gpu_ib else { continue }; - let Some(mat_bg) = &node.gpu_material_bg else { - continue; - }; - // World bounds ride along so the probe pass can frustum-cull - // against the MIRRORED camera (main-camera cull flags don't - // apply there). Sentinel (min > max) = not yet computed → - // never culled. - out.push(( - vb, - ib, - node.gpu_index_count, - mat_bg, - node.world_transform(), - node.world_bounds_min, - node.world_bounds_max, - )); - } - out - } } mod helpers; use helpers::*; pub(crate) use helpers::{aabb_outside_frustum, extract_frustum_planes}; + +mod material_draws; diff --git a/native/shared/src/scene/material_draws.rs b/native/shared/src/scene/material_draws.rs new file mode 100644 index 00000000..8265a68a --- /dev/null +++ b/native/shared/src/scene/material_draws.rs @@ -0,0 +1,584 @@ +//! Retained material routing and compatibility-pass draws. + +use super::*; + +impl SceneGraph { + pub fn has_transparent_nodes(&self) -> bool { + self.has_transparent_nodes + } + + pub(crate) fn has_layered_transparent_nodes(&self) -> bool { + self.has_layered_transparent_nodes + } + + pub fn has_refractive_nodes(&self) -> bool { + self.has_refractive_nodes + } + + /// Constant-time guard for renderer paths that only need to inspect + /// physical-transmission metadata when at least one retained GI instance + /// can contribute it. + pub(crate) fn has_transmission_gi_nodes(&self) -> bool { + self.has_transmission_gi_nodes + } + + pub(crate) fn transparent_gi_instance_count(&self) -> u32 { + if !self.has_transmission_gi_nodes { + return 0; + } + self.nodes + .iter() + .filter(|(_handle, node)| { + node.visible + && node.render_layer == 0 + && node.card_first_slot.is_some() + && !node.indices().is_empty() + && node.material.has_gi_transmission() + }) + .count() + .min(u32::MAX as usize) as u32 + } + + /// O(n) only after the O(1) refractive gate succeeds. Shadow visibility is + /// deliberately independent of the camera frustum: off-screen glass may + /// project into a visible receiver. + pub(crate) fn has_transmitted_shadow_casters(&self) -> bool { + self.has_refractive_nodes + && self.nodes.iter().any(|(_handle, node)| { + node.visible + && !node.gi_only + && node.cast_shadow + && !node.indices().is_empty() + && node.material.transmission.is_active() + && node.gpu_refractive_material_bg.is_some() + }) + } + + pub(crate) fn has_visible_opaque_iridescence(&self, imported_refraction_enabled: bool) -> bool { + self.nodes.iter().any(|(_handle, node)| { + node.visible + && !node.gi_only + && !node.indices().is_empty() + && node.in_view_frustum + && !node.occluded + && node.material.alpha_mode != MaterialAlphaMode::Blend + && !(imported_refraction_enabled && node.material.transmission.is_active()) + && node.material.layered_pbr.has_iridescence() + && node.gpu_layered_material_bg.is_some() + }) + } + + pub(crate) fn append_opaque_iridescence_draws<'a>( + &'a self, + out: &mut Vec>, + ) { + for (_handle, node) in self.nodes.iter() { + if !node.visible + || node.gi_only + || node.indices().is_empty() + || !node.in_view_frustum + || node.occluded + || node.material.alpha_mode == MaterialAlphaMode::Blend + || (self.imported_refraction_enabled && node.material.transmission.is_active()) + || !node.material.layered_pbr.has_iridescence() + { + continue; + } + let Some(uniforms) = node.gpu_uniform_bg.as_ref() else { + continue; + }; + let Some(material) = node.gpu_layered_material_bg.as_ref() else { + continue; + }; + let uses_uv1 = node.gpu_layered_uses_uv1; + let (vertex, index, index_count, secondary_uv) = match node + .lods + .get(node.active_lod.max(0) as usize) + .filter(|_| node.active_lod >= 0) + .filter(|lod| !uses_uv1 || lod.gpu_secondary_uv_vb.is_some()) + .and_then(|lod| { + Some(( + lod.gpu_vb.as_ref()?, + lod.gpu_ib.as_ref()?, + lod.gpu_index_count, + if uses_uv1 { + Some(lod.gpu_secondary_uv_vb.as_ref()?) + } else { + None + }, + )) + }) { + Some(lod) => lod, + None => { + let Some(vertex) = node.gpu_vb.as_ref() else { + continue; + }; + let Some(index) = node.gpu_ib.as_ref() else { + continue; + }; + let secondary_uv = if uses_uv1 { + let Some(buffer) = node.gpu_secondary_uv_vb.as_ref() else { + continue; + }; + Some(buffer) + } else { + None + }; + (vertex, index, node.gpu_index_count, secondary_uv) + } + }; + out.push(ImportedIridescenceDrawRef { + uniforms, + material, + mesh: MeshDrawRef { + vertex, + index, + first_index: 0, + index_count, + base_vertex: 0, + }, + secondary_uv, + vertex_byte_offset: 0, + index_byte_offset: 0, + }); + } + } + + pub(crate) fn visible_transparent_node_count( + &self, + imported_refraction_enabled: bool, + ) -> usize { + if !self.has_transparent_nodes { + return 0; + } + self.nodes + .iter() + .filter(|(_handle, node)| { + node.visible + && !node.gi_only + && !node.indices().is_empty() + && node.in_view_frustum + && !node.occluded + && node.material.alpha_mode == MaterialAlphaMode::Blend + && !(imported_refraction_enabled && node.material.transmission.is_active()) + }) + .count() + } + + pub(crate) fn visible_refractive_node_count(&self) -> usize { + if !self.has_refractive_nodes { + return 0; + } + self.nodes + .iter() + .filter(|(_handle, node)| { + node.visible + && !node.gi_only + && !node.indices().is_empty() + && node.in_view_frustum + && !node.occluded + && node.material.transmission.is_active() + }) + .count() + } + + pub(crate) fn append_refractive_draws<'a>( + &'a self, + out: &mut Vec>, + view_projection: &[[f32; 4]; 4], + stable_id_base: usize, + ) { + for (node_index, (_handle, node)) in self.nodes.iter().enumerate() { + if !node.visible + || node.gi_only + || node.indices().is_empty() + || !node.in_view_frustum + || node.occluded + || !node.material.transmission.is_active() + { + continue; + } + let uses_uv1 = node.gpu_refractive_uses_uv1; + let (vertex, index, index_count, secondary_uv) = match node + .lods + .get(node.active_lod.max(0) as usize) + .filter(|_| node.active_lod >= 0) + .filter(|lod| !uses_uv1 || lod.gpu_secondary_uv_vb.is_some()) + .and_then(|lod| { + Some(( + lod.gpu_vb.as_ref()?, + lod.gpu_ib.as_ref()?, + lod.gpu_index_count, + if uses_uv1 { + Some(lod.gpu_secondary_uv_vb.as_ref()?) + } else { + None + }, + )) + }) { + Some(lod) => lod, + None => { + let Some(vertex) = &node.gpu_vb else { + continue; + }; + let Some(index) = &node.gpu_ib else { + continue; + }; + let secondary_uv = if uses_uv1 { + let Some(buffer) = node.gpu_secondary_uv_vb.as_ref() else { + continue; + }; + Some(buffer) + } else { + None + }; + (vertex, index, node.gpu_index_count, secondary_uv) + } + }; + let Some(uniforms) = &node.gpu_uniform_bg else { + continue; + }; + let Some(material) = &node.gpu_refractive_material_bg else { + continue; + }; + let center = if node.world_bounds_min[0] <= node.world_bounds_max[0] { + [ + (node.world_bounds_min[0] + node.world_bounds_max[0]) * 0.5, + (node.world_bounds_min[1] + node.world_bounds_max[1]) * 0.5, + (node.world_bounds_min[2] + node.world_bounds_max[2]) * 0.5, + ] + } else { + let transform = node.world_transform(); + [transform[3][0], transform[3][1], transform[3][2]] + }; + let pivot = crate::renderer::mat4_mul_vec4( + view_projection, + &[center[0], center[1], center[2], 1.0], + ); + out.push(ImportedRefractiveDrawRef { + view_depth: pivot[3], + stable_id: stable_id_base + node_index, + double_sided: node.material.double_sided, + layered: node.gpu_refractive_layered, + uniforms, + material, + mesh: MeshDrawRef { + vertex, + index, + first_index: 0, + index_count, + base_vertex: 0, + }, + secondary_uv, + vertex_byte_offset: 0, + index_byte_offset: 0, + }); + } + } + + /// Render glTF BLEND nodes back-to-front into the forward translucent pass. + /// Opaque depth remains read-only; these nodes never enter the depth prepass. + pub fn render_transparent<'a>( + &'a self, + pass: &mut wgpu::RenderPass<'a>, + single_sided_pipeline: &'a wgpu::RenderPipeline, + double_sided_pipeline: &'a wgpu::RenderPipeline, + view_projection: &[[f32; 4]; 4], + ) { + self.render_transparent_with_refraction( + pass, + single_sided_pipeline, + double_sided_pipeline, + view_projection, + false, + ); + } + + pub(crate) fn render_transparent_with_refraction<'a>( + &'a self, + pass: &mut wgpu::RenderPass<'a>, + single_sided_pipeline: &'a wgpu::RenderPipeline, + double_sided_pipeline: &'a wgpu::RenderPipeline, + view_projection: &[[f32; 4]; 4], + imported_refraction_enabled: bool, + ) { + let mut draws = Vec::new(); + self.append_transparent_draws(&mut draws, view_projection, 0, imported_refraction_enabled); + draws.sort_by(|left, right| { + right + .view_depth + .total_cmp(&left.view_depth) + .then_with(|| left.stable_id.cmp(&right.stable_id)) + }); + let mut current_double_sided = None; + for draw in draws { + if current_double_sided != Some(draw.double_sided) { + pass.set_pipeline(if draw.double_sided { + double_sided_pipeline + } else { + single_sided_pipeline + }); + current_double_sided = Some(draw.double_sided); + } + pass.set_bind_group(0, draw.uniforms, &[]); + pass.set_bind_group(2, draw.material, &[]); + pass.set_vertex_buffer(0, draw.mesh.vertex.slice(..)); + pass.set_index_buffer(draw.mesh.index.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(draw.mesh.index_range(), draw.mesh.base_vertex, 0..1); + } + } + + pub(crate) fn append_transparent_draws<'a>( + &'a self, + out: &mut Vec>, + view_projection: &[[f32; 4]; 4], + stable_id_base: usize, + imported_refraction_enabled: bool, + ) { + for (node_index, (_handle, node)) in self.nodes.iter().enumerate() { + if !node.visible + || node.gi_only + || node.indices().is_empty() + || !node.in_view_frustum + || node.occluded + || node.material.alpha_mode != MaterialAlphaMode::Blend + || (imported_refraction_enabled && node.material.transmission.is_active()) + { + continue; + } + let Some(uniforms) = &node.gpu_uniform_bg else { + continue; + }; + let Some(base_material) = &node.gpu_material_bg else { + continue; + }; + let layered_material = node.gpu_layered_material_bg.as_ref(); + let uses_uv1 = layered_material.is_some() && node.gpu_layered_uses_uv1; + let (vb, ib, index_count, secondary_uv) = match node + .lods + .get(node.active_lod.max(0) as usize) + .filter(|_| node.active_lod >= 0) + .filter(|lod| !uses_uv1 || lod.gpu_secondary_uv_vb.is_some()) + .and_then(|lod| { + Some(( + lod.gpu_vb.as_ref()?, + lod.gpu_ib.as_ref()?, + lod.gpu_index_count, + if uses_uv1 { + Some(lod.gpu_secondary_uv_vb.as_ref()?) + } else { + None + }, + )) + }) { + Some(lod) => lod, + None => { + let Some(vb) = &node.gpu_vb else { continue }; + let Some(ib) = &node.gpu_ib else { continue }; + let secondary_uv = if uses_uv1 { + let Some(buffer) = node.gpu_secondary_uv_vb.as_ref() else { + continue; + }; + Some(buffer) + } else { + None + }; + (vb, ib, node.gpu_index_count, secondary_uv) + } + }; + let center = if node.world_bounds_min[0] <= node.world_bounds_max[0] { + [ + (node.world_bounds_min[0] + node.world_bounds_max[0]) * 0.5, + (node.world_bounds_min[1] + node.world_bounds_max[1]) * 0.5, + (node.world_bounds_min[2] + node.world_bounds_max[2]) * 0.5, + ] + } else { + let transform = node.world_transform(); + [transform[3][0], transform[3][1], transform[3][2]] + }; + let pivot = crate::renderer::mat4_mul_vec4( + view_projection, + &[center[0], center[1], center[2], 1.0], + ); + out.push(ImportedTransparentDrawRef { + view_depth: pivot[3], + stable_id: stable_id_base + node_index, + double_sided: node.material.double_sided, + layered: layered_material.is_some(), + uniforms, + material: layered_material.unwrap_or(base_material), + mesh: MeshDrawRef { + vertex: vb, + index: ib, + first_index: 0, + index_count, + base_vertex: 0, + }, + secondary_uv, + vertex_byte_offset: 0, + index_byte_offset: 0, + }); + } + } + + /// Append retained-node records directly into the renderer's reused + /// draw scratch. Off-frustum nodes remain in the list so the compute + /// culler, rather than the CPU submission loop, owns visibility. + pub(crate) fn append_gpu_driven_draws(&self, out: &mut Vec) -> [u32; 3] { + let mut compatibility = 0u32; + let mut visible = 0u32; + let mut culled = 0u32; + if !self.gpu_driven_scene_active { + compatibility = self + .nodes + .iter() + .filter(|(_, node)| { + node.visible + && !node.gi_only + && node.render_layer == 0 + && !node.indices().is_empty() + && node.in_view_frustum + && !node.occluded + }) + .count() as u32; + return [compatibility, visible, culled]; + } + for (_handle, node) in self.nodes.iter() { + if !node.visible + || node.gi_only + || node.render_layer != 0 + || node.indices().is_empty() + || node.occluded + { + continue; + } + if !scene_node_gpu_driven_ready(node, self.imported_refraction_enabled) { + compatibility += u32::from(node.in_view_frustum); + continue; + } + let Some(slice) = node.gpu_geometry else { + compatibility += u32::from(node.in_view_frustum); + continue; + }; + let Some(slot) = node.uniform_slot else { + compatibility += u32::from(node.in_view_frustum); + continue; + }; + let offset = slot as usize * NODE_UNIFORM_STRIDE as usize; + let end = offset + std::mem::size_of::(); + let Some(bytes) = self.scratch.get(offset..end) else { + compatibility += u32::from(node.in_view_frustum); + continue; + }; + let uniforms = bytemuck::pod_read_unaligned::(bytes); + out.push(GpuDrawRecord { + uniforms, + bounds_min: [ + node.world_bounds_min[0], + node.world_bounds_min[1], + node.world_bounds_min[2], + f32::from_bits(gpu_driven::draw_flags(node.material.double_sided, true)), + ], + bounds_max: [ + node.world_bounds_max[0], + node.world_bounds_max[1], + node.world_bounds_max[2], + 0.0, + ], + draw: [ + node.gpu_index_count, + slice.first_index, + slice.base_vertex as u32, + node.gpu_material_id.raw(), + ], + }); + if node.in_view_frustum { + visible += 1; + } else { + culled += 1; + } + } + [compatibility, visible, culled] + } + + pub fn node_count(&self) -> usize { + self.nodes.iter().count() + } + + pub(crate) fn has_visible_opaque_nodes_in_layer(&self, render_layer: u32) -> bool { + self.nodes.iter().any(|(_, node)| { + node.visible + && !node.gi_only + && node.render_layer == render_layer + && !node.indices().is_empty() + && node.material.alpha_mode != MaterialAlphaMode::Blend + && !(self.imported_refraction_enabled && node.material.transmission.is_active()) + }) + } + + /// Draw list for the planar-reflection probe: every visible node with + /// uploaded geometry as (vb, ib, index_count, material_bg, transform). + /// Frustum / occlusion flags are intentionally ignored — they were + /// computed for the MAIN camera and the mirrored probe camera sees a + /// different set. Base geometry only (no LOD swap): the probe is + /// half-res and consumed through a perturbed water lookup, where a + /// LOD pop would be more visible than the detail it saves. + /// (Treats the composed public × imported transform as world.) + pub fn reflect_draw_list( + &self, + ) -> Vec<( + &wgpu::Buffer, + &wgpu::Buffer, + u32, + &wgpu::BindGroup, + [[f32; 4]; 4], + [f32; 3], + [f32; 3], + )> { + self.reflect_draw_list_with_refraction(false) + } + + pub(crate) fn reflect_draw_list_with_refraction( + &self, + imported_refraction_enabled: bool, + ) -> Vec<( + &wgpu::Buffer, + &wgpu::Buffer, + u32, + &wgpu::BindGroup, + [[f32; 4]; 4], + [f32; 3], + [f32; 3], + )> { + let mut out = Vec::new(); + for (_handle, node) in self.nodes.iter() { + if !node.visible + || node.gi_only + || node.material.alpha_mode == MaterialAlphaMode::Blend + || (imported_refraction_enabled && node.material.transmission.is_active()) + || node.indices().is_empty() + { + continue; + } + let Some(vb) = &node.gpu_vb else { continue }; + let Some(ib) = &node.gpu_ib else { continue }; + let Some(mat_bg) = &node.gpu_material_bg else { + continue; + }; + // World bounds ride along so the probe pass can frustum-cull + // against the MIRRORED camera (main-camera cull flags don't + // apply there). Sentinel (min > max) = not yet computed → + // never culled. + out.push(( + vb, + ib, + node.gpu_index_count, + mat_bg, + node.world_transform(), + node.world_bounds_min, + node.world_bounds_max, + )); + } + out + } +} diff --git a/native/shared/src/virtual_geometry/hierarchy_tests.rs b/native/shared/src/virtual_geometry/hierarchy_tests.rs new file mode 100644 index 00000000..bd37eec1 --- /dev/null +++ b/native/shared/src/virtual_geometry/hierarchy_tests.rs @@ -0,0 +1,584 @@ +use super::*; + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_hierarchy_selector_matches_cpu_across_lod_and_frustum_decisions() { + let Some((device, queue)) = try_traversal_device() else { + eprintln!("no eight-storage-buffer GPU adapter — skipping hierarchy selector oracle"); + return; + }; + let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); + let mesh = pool + .register_mesh(&queue, hierarchy_asset(hierarchy_archive())) + .unwrap(); + make_hierarchy_fully_resident(&mut pool, &queue, mesh); + let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); + let instances = [GpuVirtualInstance::identity(mesh, 17)]; + + let (leaf, requests, counters) = assert_traversal_matches_cpu( + &device, + &queue, + &pool, + &selector, + &instances, + traversal_view(50.0), + ); + assert_eq!( + leaf.iter() + .map(|record| record.cluster_table_index) + .collect::>(), + [4, 5, 6, 7] + ); + assert!(requests.is_empty()); + assert_eq!(counters.refined_groups, 4); + assert_eq!(counters.fallback_groups, 0); + + let (middle, _, _) = assert_traversal_matches_cpu( + &device, + &queue, + &pool, + &selector, + &instances, + traversal_view(150.0), + ); + assert_eq!( + middle + .iter() + .map(|record| record.cluster_table_index) + .collect::>(), + [2, 3] + ); + + let (coarse, _, _) = assert_traversal_matches_cpu( + &device, + &queue, + &pool, + &selector, + &instances, + traversal_view(250.0), + ); + assert_eq!( + coarse + .iter() + .map(|record| record.cluster_table_index) + .collect::>(), + [0, 1] + ); + + let mut outside = traversal_view(50.0); + outside.frustum_planes[0] = [1.0, 0.0, 0.0, -100.0]; + let (culled, requests, counters) = + assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &instances, outside); + assert!(culled.is_empty()); + assert!(requests.is_empty()); + assert_eq!(counters.frustum_culled_groups, 2); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_hierarchy_selector_follows_every_branched_child_range() { + let Some((device, queue)) = try_traversal_device() else { + eprintln!("no eight-storage-buffer GPU adapter — skipping branched hierarchy oracle"); + return; + }; + let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); + let mesh = pool + .register_mesh(&queue, hierarchy_asset(branching_hierarchy_archive())) + .unwrap(); + assert_ne!( + pool.cluster_entry(mesh, 0).unwrap().identity[3] + & super::gpu_pool::GPU_VIRTUAL_CLUSTER_UNIFORM_CHILD_RANGE, + 0, + "the root group should retain the uniform-child fast path" + ); + assert_eq!( + pool.cluster_entry(mesh, 2).unwrap().identity[3] + & super::gpu_pool::GPU_VIRTUAL_CLUSTER_UNIFORM_CHILD_RANGE, + 0, + "the genuinely branched group must take the complete child scan" + ); + make_hierarchy_fully_resident(&mut pool, &queue, mesh); + pool.make_group_resident(&queue, mesh, 8).unwrap(); + assert_eq!( + pool.protect_group_pages(mesh, 6, 1.0f32.to_bits()).unwrap(), + 4, + "leaf feedback must retain its leaf, complete branched parent, and root pages" + ); + let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); + let (selected, requests, counters) = assert_traversal_matches_cpu( + &device, + &queue, + &pool, + &selector, + &[GpuVirtualInstance::identity(mesh, 171)], + traversal_view(50.0), + ); + + assert_eq!( + selected + .iter() + .map(|record| record.cluster_table_index) + .collect::>(), + [6, 7, 8, 9] + ); + assert!(requests.is_empty()); + assert_eq!(counters.refined_groups, 2); + assert_eq!(counters.fallback_groups, 0); + assert_eq!(counters.invalid_records, 0); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_hierarchy_selector_refines_atomic_groups_that_straddle_frustum_planes() { + let Some((device, queue)) = try_traversal_device() else { + eprintln!("no eight-storage-buffer GPU adapter — skipping group-frustum oracle"); + return; + }; + let mut archive = hierarchy_archive(); + for root in &mut archive.clusters[0..2] { + root.first_child = 2; + root.child_count = 2; + } + for middle in &mut archive.clusters[2..4] { + middle.parent = 0; + middle.parent_count = 2; + middle.first_child = 4; + middle.child_count = 4; + } + for leaf in &mut archive.clusters[4..8] { + leaf.parent = 2; + leaf.parent_count = 2; + } + archive.clusters[2].aabb_min = [-2.0, 0.0, 0.0]; + archive.clusters[2].aabb_max = [-1.0, 1.0, 1.0]; + archive.clusters[2].sphere_center = [-1.5, 0.5, 0.5]; + archive.clusters[3].aabb_min = [2.0, 0.0, 0.0]; + archive.clusters[3].aabb_max = [3.0, 1.0, 1.0]; + archive.clusters[3].sphere_center = [2.5, 0.5, 0.5]; + + let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); + let mesh = pool + .register_mesh(&queue, hierarchy_asset(archive)) + .unwrap(); + make_hierarchy_fully_resident(&mut pool, &queue, mesh); + let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); + let instances = [GpuVirtualInstance::identity(mesh, 19)]; + let mut view = traversal_view(50.0); + view.frustum_planes = [[0.0, 0.0, 0.0, 1.0]; 6]; + view.frustum_planes[0] = [1.0, 0.0, 0.0, 0.0]; + view.frustum_planes[1] = [-1.0, 0.0, 0.0, 1.0]; + + let (selected, requests, counters) = + assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &instances, view); + assert_eq!( + selected + .iter() + .map(|record| record.cluster_table_index) + .collect::>(), + [4, 5, 6, 7] + ); + assert!(requests.is_empty()); + assert_eq!(counters.frustum_culled_groups, 0); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_hierarchy_selector_fails_open_laterally_for_near_clipped_groups() { + let Some((device, queue)) = try_traversal_device() else { + eprintln!("no eight-storage-buffer GPU adapter — skipping near-clip frustum oracle"); + return; + }; + let mut archive = hierarchy_archive(); + for cluster in &mut archive.clusters { + cluster.aabb_min = [0.02, -0.005, -0.03]; + cluster.aabb_max = [0.03, 0.005, 0.0]; + cluster.sphere_center = [0.025, 0.0, -0.015]; + cluster.sphere_radius = 0.0175; + } + + let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); + let mesh = pool + .register_mesh(&queue, hierarchy_asset(archive)) + .unwrap(); + make_hierarchy_fully_resident(&mut pool, &queue, mesh); + let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); + let view_projection = + crate::renderer::mat4_perspective(60.0_f32.to_radians(), 1.0, 0.01, 1_000.0); + let view = VirtualGeometryView { + frustum_planes: crate::scene::extract_frustum_planes(&view_projection), + view_projection, + camera_position: [0.0; 3], + projection_scale: 100.0, + target_error_pixels: 1.0, + }; + + let near_clipped = [GpuVirtualInstance::identity(mesh, 20)]; + let (selected, requests, counters) = + assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &near_clipped, view); + assert_eq!( + selected + .iter() + .map(|record| record.cluster_table_index) + .collect::>(), + [4, 5, 6, 7] + ); + assert!(requests.is_empty()); + assert_eq!(counters.frustum_culled_groups, 0); + + let mut far_offscreen_model = crate::renderer::IDENTITY_MAT4; + far_offscreen_model[3][0] = 5.0; + far_offscreen_model[3][2] = -2.0; + let far_offscreen = [GpuVirtualInstance::new(mesh, 21, far_offscreen_model).unwrap()]; + let (selected, requests, counters) = + assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &far_offscreen, view); + assert!(selected.is_empty()); + assert!(requests.is_empty()); + assert_eq!(counters.frustum_culled_groups, 2); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_hierarchy_selector_preserves_near_clipped_priority_order() { + let Some((device, queue)) = try_traversal_device() else { + eprintln!("no eight-storage-buffer GPU adapter — skipping near-clip priority oracle"); + return; + }; + let mut archive = hierarchy_archive(); + for cluster in &mut archive.clusters { + cluster.aabb_min = [0.02, -0.005, -0.03]; + cluster.aabb_max = [0.03, 0.005, 0.0]; + cluster.sphere_center = [0.025, 0.0, -0.015]; + cluster.sphere_radius = 0.0175; + } + archive.clusters[2].geometric_error = 1.0; + archive.clusters[3].geometric_error = 2.0; + + let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(3)).unwrap(); + let mesh = pool + .register_mesh(&queue, hierarchy_asset(archive)) + .unwrap(); + bind_test_materials(&mut pool, &queue, mesh); + pool.begin_frame(2); + pool.make_group_resident(&queue, mesh, 2).unwrap(); + pool.make_group_resident(&queue, mesh, 3).unwrap(); + let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); + let view_projection = + crate::renderer::mat4_perspective(60.0_f32.to_radians(), 1.0, 0.01, 1_000.0); + let view = VirtualGeometryView { + frustum_planes: crate::scene::extract_frustum_planes(&view_projection), + view_projection, + camera_position: [0.0; 3], + projection_scale: 100.0, + target_error_pixels: 1.0, + }; + + let (_, requests, counters) = assert_traversal_matches_cpu( + &device, + &queue, + &pool, + &selector, + &[GpuVirtualInstance::identity(mesh, 22)], + view, + ); + assert_eq!(requests.len(), 2); + let priorities = requests + .iter() + .map(|request| (request.page_index, f32::from_bits(request.priority_bits))) + .collect::>(); + assert!(priorities[&3].is_finite()); + assert!(priorities[&4].is_finite()); + assert!(priorities[&4] > priorities[&3]); + assert_eq!(counters.fallback_groups, 2); + assert_eq!(counters.frustum_culled_groups, 0); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_hierarchy_selector_keeps_resident_ancestors_and_requests_missing_pages() { + let Some((device, queue)) = try_traversal_device() else { + eprintln!("no eight-storage-buffer GPU adapter — skipping hierarchy fallback oracle"); + return; + }; + let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(3)).unwrap(); + let mesh = pool + .register_mesh(&queue, hierarchy_asset(hierarchy_archive())) + .unwrap(); + bind_test_materials(&mut pool, &queue, mesh); + pool.begin_frame(2); + pool.make_group_resident(&queue, mesh, 2).unwrap(); + pool.make_group_resident(&queue, mesh, 3).unwrap(); + let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); + let instances = [GpuVirtualInstance::identity(mesh, 23)]; + + let (selected, requests, counters) = assert_traversal_matches_cpu( + &device, + &queue, + &pool, + &selector, + &instances, + traversal_view(50.0), + ); + assert_eq!( + selected + .iter() + .map(|record| record.cluster_table_index) + .collect::>(), + [2, 3] + ); + assert_eq!( + requests + .iter() + .map(|request| request.page_index) + .collect::>(), + [3, 4] + ); + assert_eq!(counters.refined_groups, 2); + assert_eq!(counters.fallback_groups, 2); + assert_eq!(counters.missing_current_pages, 0); + assert_eq!(counters.invalid_records, 0); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_hierarchy_selector_keeps_request_and_residency_priorities_consistent() { + let Some((device, queue)) = try_traversal_device() else { + eprintln!("no eight-storage-buffer GPU adapter — skipping priority consistency oracle"); + return; + }; + let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); + let mesh = pool + .register_mesh(&queue, hierarchy_asset(hierarchy_archive())) + .unwrap(); + bind_test_materials(&mut pool, &queue, mesh); + pool.begin_frame(2); + pool.make_group_resident(&queue, mesh, 2).unwrap(); + pool.make_group_resident(&queue, mesh, 3).unwrap(); + let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); + let instances = [GpuVirtualInstance::identity(mesh, 24)]; + + let (_, requests, _) = assert_traversal_matches_cpu( + &device, + &queue, + &pool, + &selector, + &instances, + traversal_view(50.0), + ); + let requested_priorities = requests + .iter() + .map(|request| (request.source_cluster, request.priority_bits)) + .collect::>(); + assert_eq!(requested_priorities.len(), 2); + + pool.begin_frame(3); + pool.make_group_resident(&queue, mesh, 4).unwrap(); + pool.make_group_resident(&queue, mesh, 6).unwrap(); + let (_, requests, counters) = assert_traversal_matches_cpu( + &device, + &queue, + &pool, + &selector, + &instances, + traversal_view(50.0), + ); + assert!(requests.is_empty()); + let page_use_bytes = read_gpu_buffer( + &device, + &queue, + selector.page_use_buffer(), + selector.page_use_buffer().size(), + ); + let page_uses = decode_records::( + &page_use_bytes, + counters + .page_use_count + .min(selector.config().max_page_requests) as usize, + ); + let resident_priorities = page_uses + .iter() + .filter(|page_use| requested_priorities.contains_key(&page_use.source_cluster)) + .map(|page_use| (page_use.source_cluster, page_use.priority_bits)) + .collect::>(); + assert_eq!(resident_priorities, requested_priorities); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_hierarchy_selector_reports_bounded_output_overflow_without_overwriting() { + let Some((device, queue)) = try_traversal_device() else { + eprintln!("no eight-storage-buffer GPU adapter — skipping hierarchy overflow oracle"); + return; + }; + let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); + let mesh = pool + .register_mesh(&queue, hierarchy_asset(hierarchy_archive())) + .unwrap(); + make_hierarchy_fully_resident(&mut pool, &queue, mesh); + let selector = GpuVirtualHierarchySelector::new( + &device, + &pool, + GpuVirtualTraversalConfig { + max_instances: 1, + max_selected_clusters: 2, + max_page_requests: 1, + }, + ) + .unwrap(); + let instances = [GpuVirtualInstance::identity(mesh, 29)]; + let (selected, requests, counters) = run_traversal( + &device, + &queue, + &pool, + &selector, + &instances, + traversal_view(50.0), + ); + assert_eq!(selected.len(), 2); + assert!(selected + .iter() + .all(|record| (4..=7).contains(&record.cluster_table_index))); + assert!(requests.is_empty()); + assert_eq!(counters.selected_count, 4); + assert_eq!(counters.selected_overflow, 2); + assert_eq!(counters.request_overflow, 0); + + let mut partial_pool = GpuVirtualGeometryPool::new(&device, gpu_config(3)).unwrap(); + let partial_mesh = partial_pool + .register_mesh(&queue, hierarchy_asset(hierarchy_archive())) + .unwrap(); + bind_test_materials(&mut partial_pool, &queue, partial_mesh); + partial_pool.begin_frame(2); + partial_pool + .make_group_resident(&queue, partial_mesh, 2) + .unwrap(); + partial_pool + .make_group_resident(&queue, partial_mesh, 3) + .unwrap(); + let partial_selector = GpuVirtualHierarchySelector::new( + &device, + &partial_pool, + GpuVirtualTraversalConfig { + max_instances: 1, + max_selected_clusters: 4, + max_page_requests: 1, + }, + ) + .unwrap(); + let partial_instances = [GpuVirtualInstance::identity(partial_mesh, 31)]; + let (_, requests, counters) = run_traversal( + &device, + &queue, + &partial_pool, + &partial_selector, + &partial_instances, + traversal_view(50.0), + ); + assert_eq!(requests.len(), 1); + assert!([3, 4].contains(&requests[0].page_index)); + assert_eq!(counters.page_request_count, 2); + assert_eq!(counters.request_overflow, 1); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_hierarchy_selector_cone_culling_is_conservative_for_transform_class() { + let Some((device, queue)) = try_traversal_device() else { + eprintln!("no eight-storage-buffer GPU adapter — skipping hierarchy cone oracle"); + return; + }; + let mut archive = hierarchy_archive(); + for cluster in &mut archive.clusters { + cluster.normal_cone_axis = [0.0, 0.0, 1.0]; + cluster.normal_cone_cutoff = 1.0; + } + let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(1)).unwrap(); + let mesh = pool + .register_mesh(&queue, hierarchy_asset(archive)) + .unwrap(); + bind_test_materials(&mut pool, &queue, mesh); + let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); + + let front = [GpuVirtualInstance::identity(mesh, 41)]; + let mut front_view = traversal_view(1_000.0); + front_view.camera_position = [0.5, 0.5, 10.0]; + let (selected, _, counters) = + assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &front, front_view); + assert_eq!(selected.len(), 2); + assert_eq!(counters.cone_culled_clusters, 0); + + let mut back_view = front_view; + back_view.camera_position = [0.5, 0.5, -10.0]; + let (selected, _, counters) = + assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &front, back_view); + assert!(selected.is_empty()); + assert_eq!(counters.cone_culled_clusters, 2); + + let non_uniform = [GpuVirtualInstance::new( + mesh, + 43, + [ + [2.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.5, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + ) + .unwrap()]; + assert!(!non_uniform[0].cone_cull_safe()); + let (selected, _, counters) = + assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &non_uniform, back_view); + assert_eq!(selected.len(), 2); + assert_eq!(counters.cone_culled_clusters, 0); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gpu_hierarchy_selector_is_stateless_across_camera_cuts_and_fast_instance_motion() { + let Some((device, queue)) = try_traversal_device() else { + eprintln!("no eight-storage-buffer GPU adapter — skipping hierarchy motion oracle"); + return; + }; + let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); + let mesh = pool + .register_mesh(&queue, hierarchy_asset(hierarchy_archive())) + .unwrap(); + make_hierarchy_fully_resident(&mut pool, &queue, mesh); + let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); + let translated = |instance_id, x| { + GpuVirtualInstance::new( + mesh, + instance_id, + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [x, 0.0, 0.0, 1.0], + ], + ) + .unwrap() + }; + + let before = [translated(51, 0.0), translated(53, 10.0)]; + let (_, requests, counters) = assert_traversal_matches_cpu( + &device, + &queue, + &pool, + &selector, + &before, + traversal_view(50.0), + ); + assert!(requests.is_empty()); + assert_eq!(counters.selected_count, 8); + assert_eq!(counters.selected_overflow, 0); + + let after = [translated(51, -15.0), translated(53, 25.0)]; + let mut cut_view = traversal_view(50.0); + cut_view.camera_position = [-100.0, 80.0, -60.0]; + let (selected, requests, counters) = + assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &after, cut_view); + assert_eq!(selected.len(), 8); + assert!(requests.is_empty()); + assert_eq!(counters.selected_count, 8); + assert_eq!(counters.missing_current_pages, 0); + assert_eq!(counters.invalid_records, 0); +} diff --git a/native/shared/src/virtual_geometry/tests.rs b/native/shared/src/virtual_geometry/tests.rs index 6a5e405d..8fb6bcfb 100644 --- a/native/shared/src/virtual_geometry/tests.rs +++ b/native/shared/src/virtual_geometry/tests.rs @@ -1368,588 +1368,8 @@ fn gpu_virtual_draw_emission_suppresses_the_whole_batch_on_selection_overflow() assert_eq!(state.selector_invalid_or_missing, 0); } -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn gpu_hierarchy_selector_matches_cpu_across_lod_and_frustum_decisions() { - let Some((device, queue)) = try_traversal_device() else { - eprintln!("no eight-storage-buffer GPU adapter — skipping hierarchy selector oracle"); - return; - }; - let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); - let mesh = pool - .register_mesh(&queue, hierarchy_asset(hierarchy_archive())) - .unwrap(); - make_hierarchy_fully_resident(&mut pool, &queue, mesh); - let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); - let instances = [GpuVirtualInstance::identity(mesh, 17)]; - - let (leaf, requests, counters) = assert_traversal_matches_cpu( - &device, - &queue, - &pool, - &selector, - &instances, - traversal_view(50.0), - ); - assert_eq!( - leaf.iter() - .map(|record| record.cluster_table_index) - .collect::>(), - [4, 5, 6, 7] - ); - assert!(requests.is_empty()); - assert_eq!(counters.refined_groups, 4); - assert_eq!(counters.fallback_groups, 0); - - let (middle, _, _) = assert_traversal_matches_cpu( - &device, - &queue, - &pool, - &selector, - &instances, - traversal_view(150.0), - ); - assert_eq!( - middle - .iter() - .map(|record| record.cluster_table_index) - .collect::>(), - [2, 3] - ); - - let (coarse, _, _) = assert_traversal_matches_cpu( - &device, - &queue, - &pool, - &selector, - &instances, - traversal_view(250.0), - ); - assert_eq!( - coarse - .iter() - .map(|record| record.cluster_table_index) - .collect::>(), - [0, 1] - ); - - let mut outside = traversal_view(50.0); - outside.frustum_planes[0] = [1.0, 0.0, 0.0, -100.0]; - let (culled, requests, counters) = - assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &instances, outside); - assert!(culled.is_empty()); - assert!(requests.is_empty()); - assert_eq!(counters.frustum_culled_groups, 2); -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn gpu_hierarchy_selector_follows_every_branched_child_range() { - let Some((device, queue)) = try_traversal_device() else { - eprintln!("no eight-storage-buffer GPU adapter — skipping branched hierarchy oracle"); - return; - }; - let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); - let mesh = pool - .register_mesh(&queue, hierarchy_asset(branching_hierarchy_archive())) - .unwrap(); - assert_ne!( - pool.cluster_entry(mesh, 0).unwrap().identity[3] - & super::gpu_pool::GPU_VIRTUAL_CLUSTER_UNIFORM_CHILD_RANGE, - 0, - "the root group should retain the uniform-child fast path" - ); - assert_eq!( - pool.cluster_entry(mesh, 2).unwrap().identity[3] - & super::gpu_pool::GPU_VIRTUAL_CLUSTER_UNIFORM_CHILD_RANGE, - 0, - "the genuinely branched group must take the complete child scan" - ); - make_hierarchy_fully_resident(&mut pool, &queue, mesh); - pool.make_group_resident(&queue, mesh, 8).unwrap(); - assert_eq!( - pool.protect_group_pages(mesh, 6, 1.0f32.to_bits()).unwrap(), - 4, - "leaf feedback must retain its leaf, complete branched parent, and root pages" - ); - let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); - let (selected, requests, counters) = assert_traversal_matches_cpu( - &device, - &queue, - &pool, - &selector, - &[GpuVirtualInstance::identity(mesh, 171)], - traversal_view(50.0), - ); - - assert_eq!( - selected - .iter() - .map(|record| record.cluster_table_index) - .collect::>(), - [6, 7, 8, 9] - ); - assert!(requests.is_empty()); - assert_eq!(counters.refined_groups, 2); - assert_eq!(counters.fallback_groups, 0); - assert_eq!(counters.invalid_records, 0); -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn gpu_hierarchy_selector_refines_atomic_groups_that_straddle_frustum_planes() { - let Some((device, queue)) = try_traversal_device() else { - eprintln!("no eight-storage-buffer GPU adapter — skipping group-frustum oracle"); - return; - }; - let mut archive = hierarchy_archive(); - for root in &mut archive.clusters[0..2] { - root.first_child = 2; - root.child_count = 2; - } - for middle in &mut archive.clusters[2..4] { - middle.parent = 0; - middle.parent_count = 2; - middle.first_child = 4; - middle.child_count = 4; - } - for leaf in &mut archive.clusters[4..8] { - leaf.parent = 2; - leaf.parent_count = 2; - } - archive.clusters[2].aabb_min = [-2.0, 0.0, 0.0]; - archive.clusters[2].aabb_max = [-1.0, 1.0, 1.0]; - archive.clusters[2].sphere_center = [-1.5, 0.5, 0.5]; - archive.clusters[3].aabb_min = [2.0, 0.0, 0.0]; - archive.clusters[3].aabb_max = [3.0, 1.0, 1.0]; - archive.clusters[3].sphere_center = [2.5, 0.5, 0.5]; - - let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); - let mesh = pool - .register_mesh(&queue, hierarchy_asset(archive)) - .unwrap(); - make_hierarchy_fully_resident(&mut pool, &queue, mesh); - let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); - let instances = [GpuVirtualInstance::identity(mesh, 19)]; - let mut view = traversal_view(50.0); - view.frustum_planes = [[0.0, 0.0, 0.0, 1.0]; 6]; - view.frustum_planes[0] = [1.0, 0.0, 0.0, 0.0]; - view.frustum_planes[1] = [-1.0, 0.0, 0.0, 1.0]; - - let (selected, requests, counters) = - assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &instances, view); - assert_eq!( - selected - .iter() - .map(|record| record.cluster_table_index) - .collect::>(), - [4, 5, 6, 7] - ); - assert!(requests.is_empty()); - assert_eq!(counters.frustum_culled_groups, 0); -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn gpu_hierarchy_selector_fails_open_laterally_for_near_clipped_groups() { - let Some((device, queue)) = try_traversal_device() else { - eprintln!("no eight-storage-buffer GPU adapter — skipping near-clip frustum oracle"); - return; - }; - let mut archive = hierarchy_archive(); - for cluster in &mut archive.clusters { - cluster.aabb_min = [0.02, -0.005, -0.03]; - cluster.aabb_max = [0.03, 0.005, 0.0]; - cluster.sphere_center = [0.025, 0.0, -0.015]; - cluster.sphere_radius = 0.0175; - } - - let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); - let mesh = pool - .register_mesh(&queue, hierarchy_asset(archive)) - .unwrap(); - make_hierarchy_fully_resident(&mut pool, &queue, mesh); - let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); - let view_projection = - crate::renderer::mat4_perspective(60.0_f32.to_radians(), 1.0, 0.01, 1_000.0); - let view = VirtualGeometryView { - frustum_planes: crate::scene::extract_frustum_planes(&view_projection), - view_projection, - camera_position: [0.0; 3], - projection_scale: 100.0, - target_error_pixels: 1.0, - }; - - let near_clipped = [GpuVirtualInstance::identity(mesh, 20)]; - let (selected, requests, counters) = - assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &near_clipped, view); - assert_eq!( - selected - .iter() - .map(|record| record.cluster_table_index) - .collect::>(), - [4, 5, 6, 7] - ); - assert!(requests.is_empty()); - assert_eq!(counters.frustum_culled_groups, 0); - - let mut far_offscreen_model = crate::renderer::IDENTITY_MAT4; - far_offscreen_model[3][0] = 5.0; - far_offscreen_model[3][2] = -2.0; - let far_offscreen = [GpuVirtualInstance::new(mesh, 21, far_offscreen_model).unwrap()]; - let (selected, requests, counters) = - assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &far_offscreen, view); - assert!(selected.is_empty()); - assert!(requests.is_empty()); - assert_eq!(counters.frustum_culled_groups, 2); -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn gpu_hierarchy_selector_preserves_near_clipped_priority_order() { - let Some((device, queue)) = try_traversal_device() else { - eprintln!("no eight-storage-buffer GPU adapter — skipping near-clip priority oracle"); - return; - }; - let mut archive = hierarchy_archive(); - for cluster in &mut archive.clusters { - cluster.aabb_min = [0.02, -0.005, -0.03]; - cluster.aabb_max = [0.03, 0.005, 0.0]; - cluster.sphere_center = [0.025, 0.0, -0.015]; - cluster.sphere_radius = 0.0175; - } - archive.clusters[2].geometric_error = 1.0; - archive.clusters[3].geometric_error = 2.0; - - let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(3)).unwrap(); - let mesh = pool - .register_mesh(&queue, hierarchy_asset(archive)) - .unwrap(); - bind_test_materials(&mut pool, &queue, mesh); - pool.begin_frame(2); - pool.make_group_resident(&queue, mesh, 2).unwrap(); - pool.make_group_resident(&queue, mesh, 3).unwrap(); - let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); - let view_projection = - crate::renderer::mat4_perspective(60.0_f32.to_radians(), 1.0, 0.01, 1_000.0); - let view = VirtualGeometryView { - frustum_planes: crate::scene::extract_frustum_planes(&view_projection), - view_projection, - camera_position: [0.0; 3], - projection_scale: 100.0, - target_error_pixels: 1.0, - }; - - let (_, requests, counters) = assert_traversal_matches_cpu( - &device, - &queue, - &pool, - &selector, - &[GpuVirtualInstance::identity(mesh, 22)], - view, - ); - assert_eq!(requests.len(), 2); - let priorities = requests - .iter() - .map(|request| (request.page_index, f32::from_bits(request.priority_bits))) - .collect::>(); - assert!(priorities[&3].is_finite()); - assert!(priorities[&4].is_finite()); - assert!(priorities[&4] > priorities[&3]); - assert_eq!(counters.fallback_groups, 2); - assert_eq!(counters.frustum_culled_groups, 0); -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn gpu_hierarchy_selector_keeps_resident_ancestors_and_requests_missing_pages() { - let Some((device, queue)) = try_traversal_device() else { - eprintln!("no eight-storage-buffer GPU adapter — skipping hierarchy fallback oracle"); - return; - }; - let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(3)).unwrap(); - let mesh = pool - .register_mesh(&queue, hierarchy_asset(hierarchy_archive())) - .unwrap(); - bind_test_materials(&mut pool, &queue, mesh); - pool.begin_frame(2); - pool.make_group_resident(&queue, mesh, 2).unwrap(); - pool.make_group_resident(&queue, mesh, 3).unwrap(); - let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); - let instances = [GpuVirtualInstance::identity(mesh, 23)]; - - let (selected, requests, counters) = assert_traversal_matches_cpu( - &device, - &queue, - &pool, - &selector, - &instances, - traversal_view(50.0), - ); - assert_eq!( - selected - .iter() - .map(|record| record.cluster_table_index) - .collect::>(), - [2, 3] - ); - assert_eq!( - requests - .iter() - .map(|request| request.page_index) - .collect::>(), - [3, 4] - ); - assert_eq!(counters.refined_groups, 2); - assert_eq!(counters.fallback_groups, 2); - assert_eq!(counters.missing_current_pages, 0); - assert_eq!(counters.invalid_records, 0); -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn gpu_hierarchy_selector_keeps_request_and_residency_priorities_consistent() { - let Some((device, queue)) = try_traversal_device() else { - eprintln!("no eight-storage-buffer GPU adapter — skipping priority consistency oracle"); - return; - }; - let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); - let mesh = pool - .register_mesh(&queue, hierarchy_asset(hierarchy_archive())) - .unwrap(); - bind_test_materials(&mut pool, &queue, mesh); - pool.begin_frame(2); - pool.make_group_resident(&queue, mesh, 2).unwrap(); - pool.make_group_resident(&queue, mesh, 3).unwrap(); - let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); - let instances = [GpuVirtualInstance::identity(mesh, 24)]; - - let (_, requests, _) = assert_traversal_matches_cpu( - &device, - &queue, - &pool, - &selector, - &instances, - traversal_view(50.0), - ); - let requested_priorities = requests - .iter() - .map(|request| (request.source_cluster, request.priority_bits)) - .collect::>(); - assert_eq!(requested_priorities.len(), 2); - - pool.begin_frame(3); - pool.make_group_resident(&queue, mesh, 4).unwrap(); - pool.make_group_resident(&queue, mesh, 6).unwrap(); - let (_, requests, counters) = assert_traversal_matches_cpu( - &device, - &queue, - &pool, - &selector, - &instances, - traversal_view(50.0), - ); - assert!(requests.is_empty()); - let page_use_bytes = read_gpu_buffer( - &device, - &queue, - selector.page_use_buffer(), - selector.page_use_buffer().size(), - ); - let page_uses = decode_records::( - &page_use_bytes, - counters - .page_use_count - .min(selector.config().max_page_requests) as usize, - ); - let resident_priorities = page_uses - .iter() - .filter(|page_use| requested_priorities.contains_key(&page_use.source_cluster)) - .map(|page_use| (page_use.source_cluster, page_use.priority_bits)) - .collect::>(); - assert_eq!(resident_priorities, requested_priorities); -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn gpu_hierarchy_selector_reports_bounded_output_overflow_without_overwriting() { - let Some((device, queue)) = try_traversal_device() else { - eprintln!("no eight-storage-buffer GPU adapter — skipping hierarchy overflow oracle"); - return; - }; - let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); - let mesh = pool - .register_mesh(&queue, hierarchy_asset(hierarchy_archive())) - .unwrap(); - make_hierarchy_fully_resident(&mut pool, &queue, mesh); - let selector = GpuVirtualHierarchySelector::new( - &device, - &pool, - GpuVirtualTraversalConfig { - max_instances: 1, - max_selected_clusters: 2, - max_page_requests: 1, - }, - ) - .unwrap(); - let instances = [GpuVirtualInstance::identity(mesh, 29)]; - let (selected, requests, counters) = run_traversal( - &device, - &queue, - &pool, - &selector, - &instances, - traversal_view(50.0), - ); - assert_eq!(selected.len(), 2); - assert!(selected - .iter() - .all(|record| (4..=7).contains(&record.cluster_table_index))); - assert!(requests.is_empty()); - assert_eq!(counters.selected_count, 4); - assert_eq!(counters.selected_overflow, 2); - assert_eq!(counters.request_overflow, 0); - - let mut partial_pool = GpuVirtualGeometryPool::new(&device, gpu_config(3)).unwrap(); - let partial_mesh = partial_pool - .register_mesh(&queue, hierarchy_asset(hierarchy_archive())) - .unwrap(); - bind_test_materials(&mut partial_pool, &queue, partial_mesh); - partial_pool.begin_frame(2); - partial_pool - .make_group_resident(&queue, partial_mesh, 2) - .unwrap(); - partial_pool - .make_group_resident(&queue, partial_mesh, 3) - .unwrap(); - let partial_selector = GpuVirtualHierarchySelector::new( - &device, - &partial_pool, - GpuVirtualTraversalConfig { - max_instances: 1, - max_selected_clusters: 4, - max_page_requests: 1, - }, - ) - .unwrap(); - let partial_instances = [GpuVirtualInstance::identity(partial_mesh, 31)]; - let (_, requests, counters) = run_traversal( - &device, - &queue, - &partial_pool, - &partial_selector, - &partial_instances, - traversal_view(50.0), - ); - assert_eq!(requests.len(), 1); - assert!([3, 4].contains(&requests[0].page_index)); - assert_eq!(counters.page_request_count, 2); - assert_eq!(counters.request_overflow, 1); -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn gpu_hierarchy_selector_cone_culling_is_conservative_for_transform_class() { - let Some((device, queue)) = try_traversal_device() else { - eprintln!("no eight-storage-buffer GPU adapter — skipping hierarchy cone oracle"); - return; - }; - let mut archive = hierarchy_archive(); - for cluster in &mut archive.clusters { - cluster.normal_cone_axis = [0.0, 0.0, 1.0]; - cluster.normal_cone_cutoff = 1.0; - } - let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(1)).unwrap(); - let mesh = pool - .register_mesh(&queue, hierarchy_asset(archive)) - .unwrap(); - bind_test_materials(&mut pool, &queue, mesh); - let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); - - let front = [GpuVirtualInstance::identity(mesh, 41)]; - let mut front_view = traversal_view(1_000.0); - front_view.camera_position = [0.5, 0.5, 10.0]; - let (selected, _, counters) = - assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &front, front_view); - assert_eq!(selected.len(), 2); - assert_eq!(counters.cone_culled_clusters, 0); - - let mut back_view = front_view; - back_view.camera_position = [0.5, 0.5, -10.0]; - let (selected, _, counters) = - assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &front, back_view); - assert!(selected.is_empty()); - assert_eq!(counters.cone_culled_clusters, 2); - - let non_uniform = [GpuVirtualInstance::new( - mesh, - 43, - [ - [2.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 0.5, 0.0], - [0.0, 0.0, 0.0, 1.0], - ], - ) - .unwrap()]; - assert!(!non_uniform[0].cone_cull_safe()); - let (selected, _, counters) = - assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &non_uniform, back_view); - assert_eq!(selected.len(), 2); - assert_eq!(counters.cone_culled_clusters, 0); -} - -#[cfg(not(target_arch = "wasm32"))] -#[test] -fn gpu_hierarchy_selector_is_stateless_across_camera_cuts_and_fast_instance_motion() { - let Some((device, queue)) = try_traversal_device() else { - eprintln!("no eight-storage-buffer GPU adapter — skipping hierarchy motion oracle"); - return; - }; - let mut pool = GpuVirtualGeometryPool::new(&device, gpu_config(5)).unwrap(); - let mesh = pool - .register_mesh(&queue, hierarchy_asset(hierarchy_archive())) - .unwrap(); - make_hierarchy_fully_resident(&mut pool, &queue, mesh); - let selector = GpuVirtualHierarchySelector::new(&device, &pool, traversal_config()).unwrap(); - let translated = |instance_id, x| { - GpuVirtualInstance::new( - mesh, - instance_id, - [ - [1.0, 0.0, 0.0, 0.0], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], - [x, 0.0, 0.0, 1.0], - ], - ) - .unwrap() - }; - - let before = [translated(51, 0.0), translated(53, 10.0)]; - let (_, requests, counters) = assert_traversal_matches_cpu( - &device, - &queue, - &pool, - &selector, - &before, - traversal_view(50.0), - ); - assert!(requests.is_empty()); - assert_eq!(counters.selected_count, 8); - assert_eq!(counters.selected_overflow, 0); - - let after = [translated(51, -15.0), translated(53, 25.0)]; - let mut cut_view = traversal_view(50.0); - cut_view.camera_position = [-100.0, 80.0, -60.0]; - let (selected, requests, counters) = - assert_traversal_matches_cpu(&device, &queue, &pool, &selector, &after, cut_view); - assert_eq!(selected.len(), 8); - assert!(requests.is_empty()); - assert_eq!(counters.selected_count, 8); - assert_eq!(counters.missing_current_pages, 0); - assert_eq!(counters.invalid_records, 0); -} +#[path = "hierarchy_tests.rs"] +mod hierarchy_tests; #[cfg(not(target_arch = "wasm32"))] #[test] diff --git a/native/shared/src/virtual_geometry/traversal.rs b/native/shared/src/virtual_geometry/traversal.rs index 7869307c..a4b80419 100644 --- a/native/shared/src/virtual_geometry/traversal.rs +++ b/native/shared/src/virtual_geometry/traversal.rs @@ -1490,829 +1490,7 @@ fn cpu_cone_culled( dot3(axis, view_direction) <= threshold } -const TRAVERSAL_SHADER: &str = r#" -const NO_RELATION: u32 = 0xffffffffu; -const ALL_SOURCE_MESHES: u32 = 0xffffffffu; -// Keep private per-invocation storage bounded tightly enough to remain in fast -// GPU local storage. Overflow retains the complete resident parent, so this is -// a quality fallback rather than a coverage failure. -const TRAVERSAL_GROUP_STACK_CAPACITY: u32 = 32u; -const VIRTUAL_CLUSTER_UNIFORM_CHILD_RANGE: u32 = 0x08000000u; -const INSTANCE_CONE_CULL_SAFE: u32 = 1u; -const INSTANCE_PREVIOUS_HIZ_ELIGIBLE: u32 = 4u; - -struct GpuVirtualMeshEntry { - mesh_id: u32, - page_table_base: u32, - page_count: u32, - cluster_table_base: u32, - cluster_count: u32, - root_cluster_count: u32, - page_stride_bytes: u32, - vertex_encoding: u32, - format_version: u32, - flags: u32, - reserved: vec2, -}; -struct GpuVirtualPageEntry { - slot_plus_one: u32, - payload_bytes: u32, - mesh_id: u32, - flags: u32, -}; -struct GpuVirtualClusterEntry { - aabb_min_error: vec4, - aabb_max_radius: vec4, - sphere: vec4, - normal_cone: vec4, - identity: vec4, - page_lod_counts: vec4, - payload: vec4, - relations: vec4, -}; -struct GpuVirtualInstance { - model: mat4x4, - normal_rows: array, 3>, - instance_info: vec4, - root_span: vec4, - previous_model: mat4x4, - model_tint: vec4, -}; -struct GpuSelectedVirtualCluster { - mesh_id: u32, - instance_index: u32, - cluster_table_index: u32, - physical_page_base: u32, - lod_level: u32, - triangle_count: u32, - material_id: u32, - flags: u32, -}; -struct GpuVirtualPageRequest { - mesh_id: u32, - page_index: u32, - priority_bits: u32, - source_cluster: u32, -}; -struct GpuVirtualPageUse { - mesh_id: u32, - source_cluster: u32, - priority_bits: u32, -}; -struct MeshTable { records: array, }; -struct PageTable { records: array, }; -struct ClusterTable { records: array, }; -struct InstanceTable { records: array, }; -struct SelectedTable { records: array, }; -struct RequestTable { records: array, }; -struct PageUseTable { records: array, }; -struct TraversalCounters { - selected_count: atomic, - page_request_count: atomic, - visible_groups: atomic, - frustum_culled_groups: atomic, - cone_culled_clusters: atomic, - refined_groups: atomic, - fallback_groups: atomic, - missing_current_pages: atomic, - selected_overflow: atomic, - request_overflow: atomic, - invalid_records: atomic, - depth_limit_fallbacks: atomic, - occlusion_culled_groups: atomic, - occlusion_uncertain_groups: atomic, - page_use_count: atomic, - page_use_overflow: atomic, -}; -struct TraversalParams { - planes: array, 6>, - view_projection: mat4x4, - camera_projection: vec4, - thresholds: vec4, - dispatch: vec4, - limits: vec4, -}; -struct WorldSphere { - center: vec3, - radius: f32, -}; -struct HiZParams { - previous_view_projection: mat4x4, - previous_view: mat4x4, - current_view_projection: mat4x4, - current_view: mat4x4, - extent: vec4, - thresholds: vec4, -}; -struct ProjectedBounds { - uv_min: vec2, - uv_max: vec2, - nearest_depth: f32, - valid: u32, -}; - -@group(0) @binding(0) var meshes: MeshTable; -@group(0) @binding(1) var pages: PageTable; -@group(0) @binding(2) var clusters: ClusterTable; -@group(0) @binding(3) var instances: InstanceTable; -@group(0) @binding(4) var selected: SelectedTable; -@group(0) @binding(5) var requests: RequestTable; -@group(0) @binding(6) var counters: TraversalCounters; -@group(0) @binding(7) var page_uses: PageUseTable; -@group(0) @binding(8) var params: TraversalParams; -@group(1) @binding(0) var hiz_params: HiZParams; -@group(1) @binding(1) var hiz_0: texture_2d; -@group(1) @binding(2) var hiz_1: texture_2d; -@group(1) @binding(3) var hiz_2: texture_2d; -@group(1) @binding(4) var hiz_3: texture_2d; -@group(1) @binding(5) var hiz_4: texture_2d; -@group(1) @binding(6) var hiz_5: texture_2d; -@group(1) @binding(7) var hiz_6: texture_2d; -@group(1) @binding(8) var hiz_7: texture_2d; -@group(1) @binding(9) var hiz_8: texture_2d; - -fn valid_cluster(mesh: GpuVirtualMeshEntry, local_index: u32) -> bool { - return local_index < mesh.cluster_count - && mesh.cluster_table_base + local_index < arrayLength(&clusters.records); -} - -fn valid_page(mesh: GpuVirtualMeshEntry, local_index: u32) -> bool { - return local_index < mesh.page_count - && mesh.page_table_base + local_index < arrayLength(&pages.records); -} - -fn scale_bound(model: mat4x4) -> f32 { - let c0 = model[0].xyz; - let c1 = model[1].xyz; - let c2 = model[2].xyz; - let g0 = vec3(dot(c0, c0), dot(c0, c1), dot(c0, c2)); - let g1 = vec3(g0.y, dot(c1, c1), dot(c1, c2)); - let g2 = vec3(g0.z, g1.z, dot(c2, c2)); - let eigen_upper = max( - dot(abs(g0), vec3(1.0)), - max(dot(abs(g1), vec3(1.0)), dot(abs(g2), vec3(1.0))) - ); - return sqrt(max(eigen_upper, 0.0)); -} - -fn world_sphere( - cluster: GpuVirtualClusterEntry, - instance: GpuVirtualInstance, - scale: f32, -) -> WorldSphere { - return WorldSphere( - (instance.model * vec4(cluster.sphere.xyz, 1.0)).xyz, - cluster.aabb_max_radius.w * scale - ); -} - -fn project_hiz_bounds( - local_min: vec3, - local_max: vec3, - model: mat4x4, - view_projection: mat4x4, - view: mat4x4, -) -> ProjectedBounds { - var uv_min = vec2(1.0e30); - var uv_max = vec2(-1.0e30); - var nearest_depth = 1.0e30; - for (var corner = 0u; corner < 8u; corner++) { - let local = vec3( - select(local_min.x, local_max.x, (corner & 1u) != 0u), - select(local_min.y, local_max.y, (corner & 2u) != 0u), - select(local_min.z, local_max.z, (corner & 4u) != 0u) - ); - let world = model * vec4(local, 1.0); - let clip = view_projection * world; - if (clip.w <= 0.05 || clip.w != clip.w || any(abs(clip.xyz) > vec3(1.0e30))) { - return ProjectedBounds(uv_min, uv_max, 0.0, 0u); - } - let ndc = clip.xy / clip.w; - let uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5); - uv_min = min(uv_min, uv); - uv_max = max(uv_max, uv); - nearest_depth = min(nearest_depth, -(view * world).z); - } - if (nearest_depth != nearest_depth || nearest_depth <= 0.0) { - return ProjectedBounds(uv_min, uv_max, nearest_depth, 0u); - } - return ProjectedBounds(uv_min, uv_max, nearest_depth, 1u); -} - -fn hiz_depth(mip: u32, coordinate: vec2) -> f32 { - switch mip { - case 0u: { return textureLoad(hiz_0, coordinate, 0).r; } - case 1u: { return textureLoad(hiz_1, coordinate, 0).r; } - case 2u: { return textureLoad(hiz_2, coordinate, 0).r; } - case 3u: { return textureLoad(hiz_3, coordinate, 0).r; } - case 4u: { return textureLoad(hiz_4, coordinate, 0).r; } - case 5u: { return textureLoad(hiz_5, coordinate, 0).r; } - case 6u: { return textureLoad(hiz_6, coordinate, 0).r; } - case 7u: { return textureLoad(hiz_7, coordinate, 0).r; } - default: { return textureLoad(hiz_8, coordinate, 0).r; } - } -} - -// 0 = proven occluded, 1 = sampled and visible, 2 = uncertain/visible. -fn previous_hiz_group_result( - local_min: vec3, - local_max: vec3, - instance: GpuVirtualInstance, -) -> u32 { - if (hiz_params.extent.w == 0u - || (instance.instance_info.z & INSTANCE_PREVIOUS_HIZ_ELIGIBLE) == 0u) { - return 2u; - } - let previous = project_hiz_bounds( - local_min, - local_max, - instance.previous_model, - hiz_params.previous_view_projection, - hiz_params.previous_view - ); - let current = project_hiz_bounds( - local_min, - local_max, - instance.model, - hiz_params.current_view_projection, - hiz_params.current_view - ); - if (previous.valid == 0u || current.valid == 0u) { return 2u; } - if (previous.uv_max.x <= 0.0 || previous.uv_min.x >= 1.0 - || previous.uv_max.y <= 0.0 || previous.uv_min.y >= 1.0 - || current.uv_max.x <= 0.0 || current.uv_min.x >= 1.0 - || current.uv_max.y <= 0.0 || current.uv_min.y >= 1.0) { - return 2u; - } - let minimum_delta = abs(previous.uv_min - current.uv_min); - let maximum_delta = abs(previous.uv_max - current.uv_max); - let screen_delta = max( - max(minimum_delta.x, minimum_delta.y), - max(maximum_delta.x, maximum_delta.y) - ); - if (screen_delta > max(hiz_params.thresholds.x, hiz_params.thresholds.y)) { - return 2u; - } - - let expansion = hiz_params.thresholds.xy * 2.0; - let uv_min = clamp(min(previous.uv_min, current.uv_min) - expansion, vec2(0.0), vec2(1.0)); - let uv_max = clamp(max(previous.uv_max, current.uv_max) + expansion, vec2(0.0), vec2(1.0)); - let base_span = max( - (uv_max.x - uv_min.x) * f32(hiz_params.extent.x), - (uv_max.y - uv_min.y) * f32(hiz_params.extent.y) - ); - var mip = 0u; - var span = base_span; - while (span > 2.0 && mip + 1u < hiz_params.extent.z) { - span *= 0.5; - mip++; - } - let divisor = 1u << mip; - let dimensions = max( - vec2(1u), - (hiz_params.extent.xy + vec2(divisor - 1u)) / divisor - ); - let maximum_coordinate = vec2(dimensions - vec2(1u)); - let first = clamp(vec2(floor(uv_min * vec2(dimensions))), vec2(0), maximum_coordinate); - let last = clamp(vec2(floor(uv_max * vec2(dimensions))), vec2(0), maximum_coordinate); - var maximum_depth = 0.0; - for (var y = first.y; y <= last.y; y++) { - for (var x = first.x; x <= last.x; x++) { - maximum_depth = max(maximum_depth, hiz_depth(mip, vec2(x, y))); - } - } - let nearest_depth = min(previous.nearest_depth, current.nearest_depth); - let occluded = nearest_depth - > maximum_depth * (1.0 + hiz_params.thresholds.z) + hiz_params.thresholds.w; - return select(1u, 0u, occluded); -} - -fn cluster_frustum_outside_mask( - cluster: GpuVirtualClusterEntry, - instance: GpuVirtualInstance, - sphere: WorldSphere, -) -> u32 { - let local_center = (cluster.aabb_min_error.xyz + cluster.aabb_max_radius.xyz) * 0.5; - let local_extent = (cluster.aabb_max_radius.xyz - cluster.aabb_min_error.xyz) * 0.5; - let world_center = (instance.model * vec4(local_center, 1.0)).xyz; - var outside_mask = 0u; - for (var plane_index = 0u; plane_index < 6u; plane_index++) { - let plane = params.planes[plane_index]; - let projected_radius = dot( - abs(vec3( - dot(plane.xyz, instance.model[0].xyz), - dot(plane.xyz, instance.model[1].xyz), - dot(plane.xyz, instance.model[2].xyz) - )), - local_extent - ); - if (dot(plane.xyz, world_center) + plane.w < -projected_radius) { - outside_mask |= 1u << plane_index; - } - } - let clip = params.view_projection * vec4(sphere.center, 1.0); - let w_gradient = vec3( - params.view_projection[0].w, - params.view_projection[1].w, - params.view_projection[2].w - ); - let nearest_w = clip.w - sphere.radius * length(w_gradient); - if (nearest_w <= params.thresholds.z) { - outside_mask &= 0x30u; - } - return outside_mask; -} - -fn projected_error( - cluster: GpuVirtualClusterEntry, - sphere: WorldSphere, - scale: f32, -) -> f32 { - let world_error = cluster.aabb_min_error.w * scale; - if (world_error <= 0.0) { - return 0.0; - } - let clip = params.view_projection * vec4(sphere.center, 1.0); - let clip_w_gradient = vec3( - params.view_projection[0].w, - params.view_projection[1].w, - params.view_projection[2].w - ); - let nearest_w = clip.w - sphere.radius * length(clip_w_gradient); - // Preserve refinement at the near plane without collapsing every - // near-intersecting group onto one source-order tie. - return world_error * params.camera_projection.w / max(nearest_w, params.thresholds.z); -} - -fn cone_culled( - cluster: GpuVirtualClusterEntry, - instance: GpuVirtualInstance, - sphere: WorldSphere, -) -> bool { - let cutoff = cluster.normal_cone.w; - if (cutoff <= 0.0 || (instance.instance_info.z & INSTANCE_CONE_CULL_SAFE) == 0u) { - return false; - } - var axis = vec3( - dot(instance.normal_rows[0].xyz, cluster.normal_cone.xyz), - dot(instance.normal_rows[1].xyz, cluster.normal_cone.xyz), - dot(instance.normal_rows[2].xyz, cluster.normal_cone.xyz) - ); - let axis_length = length(axis); - let to_camera = params.camera_projection.xyz - sphere.center; - let distance = length(to_camera); - if (axis_length <= 1.0e-8 || distance <= sphere.radius || distance <= 1.0e-8) { - return false; - } - axis /= axis_length; - let view_direction = to_camera / distance; - let sin_theta = sqrt(max(1.0 - cutoff * cutoff, 0.0)); - let sin_phi = clamp(sphere.radius / distance, 0.0, 1.0); - let cos_phi = sqrt(max(1.0 - sin_phi * sin_phi, 0.0)); - let conservative_threshold = -(sin_theta * cos_phi + cutoff * sin_phi); - return dot(axis, view_direction) <= conservative_threshold; -} - -fn group_is_resident(mesh: GpuVirtualMeshEntry, first: u32, count: u32) -> bool { - for (var offset = 0u; offset < count; offset++) { - let local_cluster = first + offset; - if (!valid_cluster(mesh, local_cluster)) { - return false; - } - let cluster = clusters.records[mesh.cluster_table_base + local_cluster]; - let page_index = cluster.page_lod_counts.x; - if (!valid_page(mesh, page_index)) { - return false; - } - let page = pages.records[mesh.page_table_base + page_index]; - if (page.slot_plus_one == 0u || page.mesh_id != mesh.mesh_id || (page.flags & 1u) == 0u) { - return false; - } - } - return true; -} - -fn emit_missing_requests( - mesh: GpuVirtualMeshEntry, - first: u32, - count: u32, - priority_bits: u32, -) { - for (var offset = 0u; offset < count; offset++) { - let local_cluster = first + offset; - if (!valid_cluster(mesh, local_cluster)) { - atomicAdd(&counters.invalid_records, 1u); - continue; - } - let cluster = clusters.records[mesh.cluster_table_base + local_cluster]; - let page_index = cluster.page_lod_counts.x; - if (!valid_page(mesh, page_index)) { - atomicAdd(&counters.invalid_records, 1u); - continue; - } - let page = pages.records[mesh.page_table_base + page_index]; - if (page.slot_plus_one != 0u && page.mesh_id == mesh.mesh_id && (page.flags & 1u) != 0u) { - continue; - } - var duplicate = false; - for (var previous = 0u; previous < offset; previous++) { - let previous_cluster = clusters.records[mesh.cluster_table_base + first + previous]; - if (previous_cluster.page_lod_counts.x == page_index) { - duplicate = true; - break; - } - } - if (!duplicate) { - let output_index = atomicAdd(&counters.page_request_count, 1u); - if (output_index < params.dispatch.w) { - requests.records[output_index] = GpuVirtualPageRequest( - mesh.mesh_id, - page_index, - priority_bits, - first - ); - } else { - atomicAdd(&counters.request_overflow, 1u); - } - } - } -} - -fn select_group( - mesh: GpuVirtualMeshEntry, - instance_index: u32, - instance: GpuVirtualInstance, - first: u32, - count: u32, - scale: f32, - priority_bits: u32, -) { - // One final group identifies the entire selected hierarchy path. The CPU - // owns the validated archive metadata and protects this group plus every - // ancestor, avoiding an atomic feedback write for every intermediate group. - let page_use_index = atomicAdd(&counters.page_use_count, 1u); - if (page_use_index < params.dispatch.w) { - page_uses.records[page_use_index] = GpuVirtualPageUse( - mesh.mesh_id, - first, - priority_bits - ); - } else { - atomicAdd(&counters.page_use_overflow, 1u); - } - for (var offset = 0u; offset < count; offset++) { - let local_cluster = first + offset; - if (!valid_cluster(mesh, local_cluster)) { - atomicAdd(&counters.invalid_records, 1u); - continue; - } - let cluster = clusters.records[mesh.cluster_table_base + local_cluster]; - let sphere = world_sphere(cluster, instance, scale); - if (cluster_frustum_outside_mask(cluster, instance, sphere) != 0u) { - continue; - } - if (cone_culled(cluster, instance, sphere)) { - atomicAdd(&counters.cone_culled_clusters, 1u); - continue; - } - let page_index = cluster.page_lod_counts.x; - if (!valid_page(mesh, page_index)) { - atomicAdd(&counters.invalid_records, 1u); - continue; - } - let page = pages.records[mesh.page_table_base + page_index]; - if (page.slot_plus_one == 0u || page.mesh_id != mesh.mesh_id || (page.flags & 1u) == 0u) { - atomicAdd(&counters.missing_current_pages, 1u); - emit_missing_requests(mesh, local_cluster, 1u, priority_bits); - continue; - } - let output_index = atomicAdd(&counters.selected_count, 1u); - if (output_index < params.dispatch.z) { - selected.records[output_index] = GpuSelectedVirtualCluster( - mesh.mesh_id, - instance_index, - mesh.cluster_table_base + local_cluster, - (page.slot_plus_one - 1u) * mesh.page_stride_bytes, - cluster.page_lod_counts.y, - cluster.page_lod_counts.w, - cluster.identity.z, - (cluster.identity.w & ~VIRTUAL_CLUSTER_UNIFORM_CHILD_RANGE) - | (mesh.vertex_encoding << 28u) - ); - } else { - atomicAdd(&counters.selected_overflow, 1u); - } - } -} - -@compute @workgroup_size(64) -fn select_virtual_clusters(@builtin(global_invocation_id) gid: vec3) { - let instance_index = gid.y; - let root_ordinal = gid.x; - if (instance_index >= params.dispatch.x || root_ordinal >= params.dispatch.y) { - return; - } - if (instance_index >= arrayLength(&instances.records)) { - atomicAdd(&counters.invalid_records, 1u); - return; - } - let instance = instances.records[instance_index]; - if (root_ordinal >= instance.root_span.y) { - return; - } - let root_index = instance.root_span.x + root_ordinal; - let descriptor_index = instance.instance_info.x & 0xfffffu; - if (descriptor_index == 0u || descriptor_index - 1u >= arrayLength(&meshes.records)) { - atomicAdd(&counters.invalid_records, 1u); - return; - } - let mesh = meshes.records[descriptor_index - 1u]; - if (mesh.mesh_id != instance.instance_info.x || root_index >= mesh.root_cluster_count) { - return; - } - if (!valid_cluster(mesh, root_index)) { - atomicAdd(&counters.invalid_records, 1u); - return; - } - - let root = clusters.records[mesh.cluster_table_base + root_index]; - let source_mesh_filter = instance.instance_info.w; - if (source_mesh_filter != ALL_SOURCE_MESHES && root.identity.x != source_mesh_filter) { - return; - } - var root_group_first = root_index; - var root_group_count = 1u; - if (root.relations.w != 0u && valid_cluster(mesh, root.relations.z)) { - let first_child = clusters.records[mesh.cluster_table_base + root.relations.z]; - if (first_child.relations.x != NO_RELATION && first_child.relations.y != 0u) { - root_group_first = first_child.relations.x; - root_group_count = first_child.relations.y; - } - } - if (root_index != root_group_first) { - return; - } - - let scale = scale_bound(instance.model); - // Hierarchies branch whenever one coarse group replaces several lower - // atomic ranges. A bounded depth-first stack follows every branch in this - // invocation. Stack overflow fails closed to the complete resident parent, - // preserving coverage without another pass or allocation. - var group_stack: array, TRAVERSAL_GROUP_STACK_CAPACITY>; - var stack_count = 1u; - group_stack[0] = vec4( - root_group_first, - root_group_count, - bitcast(1.0e30), - 0u - ); - loop { - if (stack_count == 0u) { - break; - } - stack_count -= 1u; - let pending = group_stack[stack_count]; - let group_first = pending.x; - let group_count = pending.y; - let group_priority_bits = pending.z; - let depth = pending.w; - if (depth >= params.limits.x) { - atomicAdd(&counters.depth_limit_fallbacks, 1u); - select_group( - mesh, - instance_index, - instance, - group_first, - group_count, - scale, - group_priority_bits - ); - continue; - } - if (group_count == 0u || group_count > params.limits.y - || group_first + group_count > mesh.cluster_count) { - atomicAdd(&counters.invalid_records, 1u); - continue; - } - - var common_outside_mask = 0x3fu; - var has_intersecting_cluster = false; - var occlusion_visible = hiz_params.extent.w == 0u; - var occlusion_uncertain = false; - var intersecting_error = 0.0; - var group_error = 0.0; - var intersecting_min = vec3(1.0e30); - var intersecting_max = vec3(-1.0e30); - for (var offset = 0u; offset < group_count; offset++) { - let local_cluster = group_first + offset; - if (!valid_cluster(mesh, local_cluster)) { - atomicAdd(&counters.invalid_records, 1u); - continue; - } - let cluster = clusters.records[mesh.cluster_table_base + local_cluster]; - let sphere = world_sphere(cluster, instance, scale); - let outside_mask = cluster_frustum_outside_mask(cluster, instance, sphere); - let error = projected_error(cluster, sphere, scale); - common_outside_mask &= outside_mask; - group_error = max(group_error, error); - if (outside_mask == 0u) { - has_intersecting_cluster = true; - intersecting_error = max(intersecting_error, error); - intersecting_min = min(intersecting_min, cluster.aabb_min_error.xyz); - intersecting_max = max(intersecting_max, cluster.aabb_max_radius.xyz); - } - } - if (common_outside_mask != 0u) { - atomicAdd(&counters.frustum_culled_groups, 1u); - continue; - } - let maximum_error = select(group_error, intersecting_error, has_intersecting_cluster); - let refinement_priority_bits = bitcast(max(maximum_error, 0.0)); - if (hiz_params.extent.w != 0u) { - if (has_intersecting_cluster) { - let hiz_result = previous_hiz_group_result( - intersecting_min, - intersecting_max, - instance - ); - occlusion_visible = hiz_result != 0u; - occlusion_uncertain = hiz_result == 2u; - } else { - occlusion_visible = true; - occlusion_uncertain = true; - } - } - if (!occlusion_visible) { - atomicAdd(&counters.occlusion_culled_groups, 1u); - continue; - } - if (occlusion_uncertain) { - atomicAdd(&counters.occlusion_uncertain_groups, 1u); - } - atomicAdd(&counters.visible_groups, 1u); - let first_cluster = clusters.records[mesh.cluster_table_base + group_first]; - let uniform_child_range = - (first_cluster.identity.w & VIRTUAL_CLUSTER_UNIFORM_CHILD_RANGE) != 0u; - var child_group_count = 0u; - var previous_child_first = NO_RELATION; - var previous_child_count = 0u; - var has_children = false; - var has_terminal_clusters = false; - var children_valid = true; - var children_resident = true; - if (uniform_child_range) { - let child_first = first_cluster.relations.z; - let child_count = first_cluster.relations.w; - if (child_first == NO_RELATION || child_count == 0u) { - has_terminal_clusters = true; - } else if (child_count > params.limits.y - || child_first + child_count > mesh.cluster_count) { - children_valid = false; - } else { - has_children = true; - child_group_count += 1u; - children_resident = group_is_resident(mesh, child_first, child_count); - previous_child_first = child_first; - previous_child_count = child_count; - } - } else { - for (var offset = 0u; offset < group_count; offset++) { - let cluster = clusters.records[mesh.cluster_table_base + group_first + offset]; - let child_first = cluster.relations.z; - let child_count = cluster.relations.w; - if (child_first == NO_RELATION || child_count == 0u) { - has_terminal_clusters = true; - continue; - } - has_children = true; - if (child_count > params.limits.y - || child_first + child_count > mesh.cluster_count) { - children_valid = false; - continue; - } - if (child_first != previous_child_first || child_count != previous_child_count) { - child_group_count += 1u; - children_resident = children_resident - && group_is_resident(mesh, child_first, child_count); - previous_child_first = child_first; - previous_child_count = child_count; - } - } - } - if (!children_valid || (has_children && has_terminal_clusters)) { - atomicAdd(&counters.invalid_records, 1u); - select_group( - mesh, - instance_index, - instance, - group_first, - group_count, - scale, - group_priority_bits - ); - continue; - } - let wants_refinement = maximum_error > params.thresholds.x && has_children; - if (!wants_refinement) { - select_group( - mesh, - instance_index, - instance, - group_first, - group_count, - scale, - group_priority_bits - ); - continue; - } - if (!children_resident) { - atomicAdd(&counters.fallback_groups, 1u); - if (uniform_child_range) { - emit_missing_requests( - mesh, - first_cluster.relations.z, - first_cluster.relations.w, - refinement_priority_bits - ); - } else { - previous_child_first = NO_RELATION; - previous_child_count = 0u; - for (var offset = 0u; offset < group_count; offset++) { - let cluster = clusters.records[mesh.cluster_table_base + group_first + offset]; - let child_first = cluster.relations.z; - let child_count = cluster.relations.w; - if (child_first != NO_RELATION && child_count != 0u - && (child_first != previous_child_first - || child_count != previous_child_count)) { - emit_missing_requests( - mesh, - child_first, - child_count, - refinement_priority_bits - ); - previous_child_first = child_first; - previous_child_count = child_count; - } - } - } - select_group( - mesh, - instance_index, - instance, - group_first, - group_count, - scale, - group_priority_bits - ); - continue; - } - if (stack_count + child_group_count > TRAVERSAL_GROUP_STACK_CAPACITY) { - atomicAdd(&counters.depth_limit_fallbacks, 1u); - select_group( - mesh, - instance_index, - instance, - group_first, - group_count, - scale, - group_priority_bits - ); - continue; - } - atomicAdd(&counters.refined_groups, 1u); - if (uniform_child_range) { - group_stack[stack_count] = vec4( - first_cluster.relations.z, - first_cluster.relations.w, - refinement_priority_bits, - depth + 1u - ); - stack_count += 1u; - } else { - previous_child_first = NO_RELATION; - previous_child_count = 0u; - for (var offset = 0u; offset < group_count; offset++) { - let cluster = clusters.records[mesh.cluster_table_base + group_first + offset]; - let child_first = cluster.relations.z; - let child_count = cluster.relations.w; - if (child_first != NO_RELATION && child_count != 0u - && (child_first != previous_child_first - || child_count != previous_child_count)) { - group_stack[stack_count] = vec4( - child_first, - child_count, - refinement_priority_bits, - depth + 1u - ); - stack_count += 1u; - previous_child_first = child_first; - previous_child_count = child_count; - } - } - } - } -} -"#; +const TRAVERSAL_SHADER: &str = include_str!("../../shaders/virtual_geometry/traversal.wgsl"); #[cfg(test)] #[path = "traversal_shader_tests.rs"] diff --git a/native/shared/tests/golden_render.rs b/native/shared/tests/golden_render.rs index 88e68434..c2db6366 100644 --- a/native/shared/tests/golden_render.rs +++ b/native/shared/tests/golden_render.rs @@ -11,6 +11,8 @@ //! - Runs on a non-CPU GPU adapter and skips gracefully without one. //! - Most scenes disable TAA; fixed warm-up counts settle temporal passes. //! - Tolerances absorb GPU-family rasterization differences. +//! - `BLOOM_REQUIRE_GPU=1` makes a missing physical raster adapter fail; +//! `WGPU_BACKEND` constrains both raster and PT device selection. use bloom_shared::engine::EngineState; use bloom_shared::models::{ @@ -23,6 +25,10 @@ use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard, OnceLock}; use std::time::Instant; +#[path = "golden_render/device.rs"] +mod golden_device; +use golden_device::{requested_backends, try_engine, try_isolated_engine}; + #[path = "golden_render/metrics.rs"] mod metrics; use metrics::{calculate_diff_metrics, select_outlier_gate}; @@ -377,38 +383,6 @@ fn capture_realtime_diagnostics(eng: &mut EngineState, final_rgba: &[u8]) { } eng.renderer.set_path_tracing_debug_view(0); } -fn try_engine() -> Option { - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { - backends: wgpu::Backends::all(), - ..wgpu::InstanceDescriptor::new_without_display_handle() - }); - let adapter = - pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) - .ok()?; - // Software rasterizers (WARP on the Windows CI runners, llvmpipe on - // Linux) are not regression targets — WARP crashes outright in the - // surface-less path, and software fidelity differs from the real - // GPUs the goldens were generated on. Real-GPU coverage comes from - // the macos-14 runners. - if adapter.get_info().device_type == wgpu::DeviceType::Cpu { - return None; - } - let required_features = adapter.features() & wgpu::Features::TIMESTAMP_QUERY; - let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - required_features, - required_limits: adapter.limits(), - ..Default::default() - })) - .ok()?; - let renderer = Renderer::new_headless(device, queue, W, H); - let mut eng = EngineState::new(renderer); - // Deterministic native-resolution output: TAA and resolution are - // independent controls, so the golden harness sets both explicitly. - eng.renderer.set_taa_enabled(false); - eng.renderer.set_render_scale(1.0); - Some(eng) -} - /// Render `frames` frames of `draw`, capturing the last one as RGBA. fn render( eng: &mut EngineState, @@ -1713,19 +1687,20 @@ fn lock_rt_goldens() -> MutexGuard<'static, ()> { /// PT golden is not applicable. A ray-query adapter that fails device creation /// is an infrastructure/test failure, not a passing skip. fn create_rt_device_context() -> Result, String> { + let backends = requested_backends(); let mut backend_options = wgpu::BackendOptions::default(); backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::DynamicDxc { dxc_path: String::from("dxcompiler.dll"), }; let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { - backends: wgpu::Backends::all(), + backends, backend_options, ..wgpu::InstanceDescriptor::new_without_display_handle() }); let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY; // The default adapter pick may be an FXC-capped DX12 view of a GPU // whose Vulkan view traces fine — enumerate and prefer ray query. - let mut adapters = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all())); + let mut adapters = pollster::block_on(instance.enumerate_adapters(backends)); for adapter in &adapters { let info = adapter.get_info(); eprintln!( diff --git a/native/shared/tests/golden_render/device.rs b/native/shared/tests/golden_render/device.rs new file mode 100644 index 00000000..9391c05d --- /dev/null +++ b/native/shared/tests/golden_render/device.rs @@ -0,0 +1,117 @@ +//! Raster test device ownership and mandatory hardware qualification. +//! +//! Keep a device alive across renderer lifetimes, as the PT oracle already does. +//! Creating a new native instance/device for every capture can exhaust backend +//! resources halfway through the corpus. Each renderer still owns fresh scene, +//! history, and render-graph state. Memory accounting uses an isolated instance. + +use super::{EngineState, Renderer, H, W}; +use std::sync::OnceLock; + +struct RasterContext { + instance: wgpu::Instance, + device: wgpu::Device, + queue: wgpu::Queue, +} + +static RASTER_DEVICE: OnceLock, String>> = OnceLock::new(); + +pub(super) fn requested_backends() -> wgpu::Backends { + let backends = wgpu::Backends::from_env().unwrap_or(wgpu::Backends::all()); + assert!( + !backends.is_empty(), + "WGPU_BACKEND selects no valid backend" + ); + backends +} + +fn hardware_required() -> bool { + ["BLOOM_REQUIRE_GPU", "BLOOM_REQUIRE_RAY_QUERY"] + .iter() + .any(|name| { + std::env::var(name) + .is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) + }) +} + +fn create_context() -> Result, String> { + let backends = requested_backends(); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends, + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + let mut adapters = pollster::block_on(instance.enumerate_adapters(backends)); + let adapter = if let Some(index) = adapters + .iter() + .position(|adapter| adapter.get_info().device_type != wgpu::DeviceType::Cpu) + { + Some(adapters.swap_remove(index)) + } else { + // Some Metal headless configurations enumerate no devices but still + // provide a physical adapter through request_adapter. + pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .ok() + .filter(|adapter| adapter.get_info().device_type != wgpu::DeviceType::Cpu) + }; + let Some(adapter) = adapter else { + return Ok(None); + }; + let info = adapter.get_info(); + let required_features = adapter.features() & wgpu::Features::TIMESTAMP_QUERY; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_features, + required_limits: adapter.limits(), + ..Default::default() + })) + .map_err(|error| { + format!( + "raster adapter '{}' ({:?}) exists but device creation failed: {error}", + info.name, info.backend + ) + })?; + eprintln!( + "Raster golden adapter: {} ({:?}, {:?}), driver={} {}", + info.name, info.backend, info.device_type, info.driver, info.driver_info + ); + Ok(Some(RasterContext { + instance, + device, + queue, + })) +} + +fn fresh_engine(context: &RasterContext) -> EngineState { + let renderer = Renderer::new_headless(context.device.clone(), context.queue.clone(), W, H); + let mut eng = EngineState::new(renderer); + eng.renderer.set_taa_enabled(false); + eng.renderer.set_render_scale(1.0); + eng +} + +fn unavailable() -> Option { + assert!( + !hardware_required(), + "hardware qualification requires a non-CPU raster adapter for {:?}", + requested_backends() + ); + None +} + +pub(super) fn try_engine() -> Option { + match RASTER_DEVICE.get_or_init(create_context) { + Ok(Some(context)) => Some(fresh_engine(context)), + Ok(None) => unavailable(), + Err(error) => panic!("{error}"), + } +} + +pub(super) fn try_isolated_engine() -> Option<(EngineState, wgpu::Instance)> { + match create_context() { + Ok(Some(context)) => Some((fresh_engine(&context), context.instance)), + Ok(None) => { + unavailable(); + None + } + Err(error) => panic!("{error}"), + } +} diff --git a/native/shared/tests/golden_render/lighting_upload.rs b/native/shared/tests/golden_render/lighting_upload.rs index 3f57edd6..90a75864 100644 --- a/native/shared/tests/golden_render/lighting_upload.rs +++ b/native/shared/tests/golden_render/lighting_upload.rs @@ -17,16 +17,24 @@ struct LiveGpuObjects { fences: isize, buffer_memory: isize, texture_memory: isize, + texture_memory_available: bool, acceleration_structure_memory: isize, memory_allocations: isize, } -fn live_gpu_objects(device: &wgpu::Device) -> LiveGpuObjects { +fn live_gpu_objects(device: &wgpu::Device, instance: &wgpu::Instance) -> LiveGpuObjects { let counters = device.get_internal_counters(); let hal = counters.hal; + // wgpu-hal 29.0.1 Vulkan decrements its texture counter on destruction + // without incrementing it for ordinary texture creation. Use the native + // registry's live user-owned textures instead. HAL texture bytes remain + // governed below, including allocations retained through views/bind groups. + let report = instance + .generate_report() + .expect("native resource registry"); LiveGpuObjects { buffers: hal.buffers.read(), - textures: hal.textures.read(), + textures: report.hub.textures.num_kept_from_user as isize, texture_views: hal.texture_views.read(), bind_groups: hal.bind_groups.read(), bind_group_layouts: hal.bind_group_layouts.read(), @@ -40,6 +48,12 @@ fn live_gpu_objects(device: &wgpu::Device) -> LiveGpuObjects { fences: hal.fences.read(), buffer_memory: hal.buffer_memory.read(), texture_memory: hal.texture_memory.read(), + // wgpu-hal 29.0.1 implements texture allocation bytes only on these + // backends. Metal/GLES still expose live object counts, not byte usage. + texture_memory_available: matches!( + device.adapter_info().backend, + wgpu::Backend::Vulkan | wgpu::Backend::Dx12 + ), acceleration_structure_memory: hal.acceleration_structure_memory.read(), memory_allocations: hal.memory_allocations.read(), } @@ -109,7 +123,7 @@ fn wait_for_gpu(device: &wgpu::Device) { #[test] fn static_ultra_scene_has_stable_renderer_owned_memory_for_1000_frames() { - let Some(mut eng) = try_engine() else { + let Some((mut eng, instance)) = try_isolated_engine() else { eprintln!("skip: no GPU adapter"); return; }; @@ -161,7 +175,7 @@ fn static_ultra_scene_has_stable_renderer_owned_memory_for_1000_frames() { const POOL_WARMUP_FRAMES: u32 = 256; run_frames(&mut eng, POOL_WARMUP_FRAMES); wait_for_gpu(&eng.renderer.device); - let before = live_gpu_objects(&eng.renderer.device); + let before = live_gpu_objects(&eng.renderer.device, &instance); assert!( before.buffers > 0 && before.textures > 0, "wgpu test counters are disabled; this would be a vacuous memory gate: {before:?}" @@ -173,7 +187,7 @@ fn static_ultra_scene_has_stable_renderer_owned_memory_for_1000_frames() { run_frames(&mut eng, 1_000); wait_for_gpu(&eng.renderer.device); - let after = live_gpu_objects(&eng.renderer.device); + let after = live_gpu_objects(&eng.renderer.device, &instance); let paths_after: serde_json::Value = serde_json::from_str(&eng.renderer.quality_runtime_paths_json()) .expect("post-run runtime paths are valid JSON"); @@ -213,6 +227,53 @@ fn static_ultra_scene_has_stable_renderer_owned_memory_for_1000_frames() { ); } +#[test] +fn texture_accounting_detects_live_allocations_and_releases() { + let Some((eng, instance)) = try_isolated_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + let device = &eng.renderer.device; + wait_for_gpu(device); + let before = live_gpu_objects(device, &instance); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("memory-gate-negative-control"), + size: wgpu::Extent3d { + width: 64, + height: 64, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let live = live_gpu_objects(device, &instance); + assert_eq!(live.textures, before.textures + 1); + if live.texture_memory_available { + assert!(live.texture_memory > before.texture_memory); + } else { + assert_eq!(before.texture_memory, 0); + assert_eq!(live.texture_memory, 0); + eprintln!( + "memory-counter-availability: {}", + serde_json::json!({ + "backend": format!("{:?}", device.adapter_info().backend), + "texture_bytes": false, + "texture_count": true, + "reason": "wgpu-hal-29.0.1-backend-byte-counter-unimplemented", + }) + ); + } + drop(texture); + wait_for_gpu(device); + let released = live_gpu_objects(device, &instance); + assert_eq!(released.textures, before.textures); + assert_eq!(released.texture_memory, before.texture_memory); +} + #[test] fn golden_many_point_lights() { let Some(mut eng) = try_engine() else { diff --git a/native/shared/tests/golden_render/ssgi_phase_diagnostics.rs b/native/shared/tests/golden_render/ssgi_phase_diagnostics.rs new file mode 100644 index 00000000..0e8654a1 --- /dev/null +++ b/native/shared/tests/golden_render/ssgi_phase_diagnostics.rs @@ -0,0 +1,89 @@ +//! Explicit raw phase captures for locating the first changing SSGI stage. + +use super::*; + +pub(super) fn capture(eng: &EngineState, root: &Path) { + let renderer = &eng.renderer; + let directory = root.join(format!("frame-{:04}", renderer.probe_frame_index)); + std::fs::create_dir_all(&directory).expect("phase diagnostics directory"); + let width = renderer.probe_grid_w; + let height = renderer.probe_grid_h; + let header_bytes = u64::from(width * height) * 112; + let row_bytes = (width * 8).div_ceil(256) * 256; + let texture_bytes = u64::from(row_bytes * height * 64); + let device = &renderer.device; + let staging = |label, size| { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }) + }; + let headers = staging("ssgi-phase-headers", header_bytes); + let trace = staging("ssgi-phase-trace", texture_bytes); + let history = staging("ssgi-phase-history", texture_bytes); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("ssgi-phase-diagnostics"), + }); + encoder.copy_buffer_to_buffer(&renderer.probe_header_buffer, 0, &headers, 0, header_bytes); + let latest_history = 1 - renderer.probe_history_idx; + for (texture, buffer) in [ + (&renderer.probe_trace_tex, &trace), + (&renderer.probe_history_textures[latest_history], &history), + ] { + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(row_bytes), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 64, + }, + ); + } + renderer.queue.submit([encoder.finish()]); + for (name, buffer) in [ + ("headers", &headers), + ("trace", &trace), + ("history", &history), + ] { + let slice = buffer.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| { + tx.send(result).expect("phase map result"); + }); + device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }) + .expect("phase readback completion"); + rx.recv().expect("phase map callback").expect("phase map"); + std::fs::write( + directory.join(format!("{name}.bin")), + &slice.get_mapped_range(), + ) + .expect("phase buffer capture"); + buffer.unmap(); + } + let metadata = serde_json::json!({ + "frame_index_after_submit": renderer.probe_frame_index, + "traced_phase": renderer.probe_frame_index.wrapping_sub(1) & 15, + "width": width, "height": height, "layers": 64, + "bytes_per_row": row_bytes, "header_bytes": 112, + "latest_history": latest_history, + }); + std::fs::write( + directory.join("layout.json"), + serde_json::to_vec_pretty(&metadata).expect("phase metadata"), + ) + .expect("phase metadata capture"); +} diff --git a/native/shared/tests/golden_render/ssgi_quality.rs b/native/shared/tests/golden_render/ssgi_quality.rs index 087da428..3e57d67f 100644 --- a/native/shared/tests/golden_render/ssgi_quality.rs +++ b/native/shared/tests/golden_render/ssgi_quality.rs @@ -1,5 +1,11 @@ use super::super::*; +#[path = "ssgi_phase_diagnostics.rs"] +mod phase_diagnostics; + +#[path = "ssgi_stationary.rs"] +mod stationary; + #[test] fn two_sided_mesh_cards_do_not_light_the_hidden_face_with_the_front_normal() { let _guard = lock_rt_goldens(); @@ -506,12 +512,8 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { staging.unmap(); } -#[test] -fn ssgi_hiz_immediate_scene_produces_finite_indirect_radiance() { - let Some(mut eng) = try_engine() else { - eprintln!("skip: no GPU adapter"); - return; - }; +fn hiz_immediate_engine() -> Option { + let mut eng = try_engine()?; let r = &mut eng.renderer; if std::env::var_os("BLOOM_SSGI_PROFILE_HD").is_some() { r.resize(1280, 720, 1280, 720); @@ -524,22 +526,30 @@ fn ssgi_hiz_immediate_scene_produces_finite_indirect_radiance() { r.set_auto_exposure(false); r.set_shadows_enabled(false); - let draw = |eng: &mut EngineState| { - let r = &mut eng.renderer; - r.set_clear_color(6.0, 8.0, 15.0, 255.0); - r.begin_mode_3d(4.0, 3.0, 6.0, 0.0, 0.6, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); - r.set_ambient_light(15.0, 18.0, 28.0, 0.2); - r.add_directional_light(-0.5, -1.0, -0.3, 1.0, 0.85, 0.7, 1.8); - r.draw_cube(0.0, -0.1, 0.0, 12.0, 0.2, 12.0, 90.0, 96.0, 107.0, 255.0); - r.draw_cube(0.0, 2.0, -3.0, 8.0, 4.0, 0.2, 230.0, 166.0, 31.0, 255.0); - r.draw_cube(-1.1, 1.0, 0.0, 1.8, 2.0, 1.8, 230.0, 45.0, 25.0, 255.0); - r.draw_sphere(1.1, 0.9, -0.8, 0.9, 30.0, 110.0, 240.0, 255.0); - }; - let capture = |eng: &mut EngineState| { - eng.begin_frame(); - draw(eng); - eng.end_frame(); + Some(eng) +} + +fn capture_hiz_immediate(eng: &mut EngineState) { + eng.begin_frame(); + let r = &mut eng.renderer; + r.set_clear_color(6.0, 8.0, 15.0, 255.0); + r.begin_mode_3d(4.0, 3.0, 6.0, 0.0, 0.6, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.set_ambient_light(15.0, 18.0, 28.0, 0.2); + r.add_directional_light(-0.5, -1.0, -0.3, 1.0, 0.85, 0.7, 1.8); + r.draw_cube(0.0, -0.1, 0.0, 12.0, 0.2, 12.0, 90.0, 96.0, 107.0, 255.0); + r.draw_cube(0.0, 2.0, -3.0, 8.0, 4.0, 0.2, 230.0, 166.0, 31.0, 255.0); + r.draw_cube(-1.1, 1.0, 0.0, 1.8, 2.0, 1.8, 230.0, 45.0, 25.0, 255.0); + r.draw_sphere(1.1, 0.9, -0.8, 0.9, 30.0, 110.0, 240.0, 255.0); + eng.end_frame(); +} + +#[test] +fn ssgi_hiz_immediate_scene_produces_finite_indirect_radiance() { + let Some(mut eng) = hiz_immediate_engine() else { + eprintln!("skip: no GPU adapter"); + return; }; + let capture = capture_hiz_immediate; eng.renderer.reset_temporal_history(); for _ in 0..24 { @@ -556,6 +566,9 @@ fn ssgi_hiz_immediate_scene_produces_finite_indirect_radiance() { .filter_map(|(label, _, gpu)| label.starts_with("probe_").then_some((label, gpu?))) .collect::>(); let probe_total_gpu_us = probe_gpu_us.iter().map(|(_, gpu)| gpu).sum::(); + eprintln!( + "ssgi-profile probe_total_gpu_us={probe_total_gpu_us:.3} probe_gpu_us={probe_gpu_us:?}" + ); eng.profiler.set_enabled(false); let directory = std::env::temp_dir().join(format!("bloom-ssgi-hiz-{}", std::process::id())); let _ = std::fs::remove_dir_all(&directory); @@ -572,8 +585,19 @@ fn ssgi_hiz_immediate_scene_produces_finite_indirect_radiance() { let next_directory = std::env::temp_dir().join(format!("bloom-ssgi-hiz-next-{}", std::process::id())); let _ = std::fs::remove_dir_all(&next_directory); + let phase_dump = std::env::var_os("BLOOM_SSGI_PHASE_DUMP_DIR").map(PathBuf::from); + if let Some(root) = &phase_dump { + phase_diagnostics::capture(&eng, root); + } eng.renderer.pending_quality_capture_dir = Some(next_directory.to_string_lossy().into_owned()); capture(&mut eng); + if let Some(root) = &phase_dump { + phase_diagnostics::capture(&eng, root); + for _ in 0..32 { + capture(&mut eng); + phase_diagnostics::capture(&eng, root); + } + } let current_radiance = std::fs::read(directory.join("ssgi-current-radiance.png")) .expect("Hi-Z SSGI capture did not emit current radiance"); let next_current_radiance = std::fs::read(next_directory.join("ssgi-current-radiance.png")) @@ -594,6 +618,7 @@ fn ssgi_hiz_immediate_scene_produces_finite_indirect_radiance() { settled_ssgi.width(), settled_ssgi.height(), ); + eprintln!("hiz-corpus settled SSGI={settled_metrics:?}"); assert!( settled_metrics.ssim >= 0.9985, "temporally rotated rays destabilized settled SSGI: {settled_metrics:?}" @@ -628,6 +653,22 @@ fn ssgi_hiz_immediate_scene_produces_finite_indirect_radiance() { retained={retained}, max_luma={max_luminance:.6}" ); + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept Hi-Z SSGI diagnostics at {directory:?} and {next_directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + let _ = std::fs::remove_dir_all(next_directory); + } +} + +#[test] +fn ssgi_hiz_stationary_taa_bounds_subpixel_variation() { + let Some(mut eng) = hiz_immediate_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + let capture = capture_hiz_immediate; + // TAA jitters the primary projection even at a stationary camera. SSGI's // world-owned angular phase must respond continuously to that subpixel // displacement; a quantized world-cell hash can replace all probe rays at @@ -665,9 +706,8 @@ fn ssgi_hiz_immediate_scene_produces_finite_indirect_radiance() { // Halton phases correctly move geometric silhouettes by a subpixel, so a // near-identity whole-image SSIM would reject the intended sampling // aperture rather than GI instability. Bound the low-frequency color - // change, edge change, and affected-pixel footprint; the non-jittered - // settled-radiance comparison above remains the strict 0.999 angular- - // stability gate. + // change, edge change, and affected-pixel footprint. The separate + // non-jittered test retains the strict 0.9985 angular-stability gate. assert!( taa_metrics.mean_rgb <= 0.75 && taa_metrics.mean_edge_delta <= 0.004 @@ -678,13 +718,8 @@ fn ssgi_hiz_immediate_scene_produces_finite_indirect_radiance() { ); if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { - eprintln!( - "kept Hi-Z SSGI diagnostics at {directory:?}, {next_directory:?}, \ - {taa_directory:?}, and {taa_next_directory:?}" - ); + eprintln!("kept TAA SSGI diagnostics at {taa_directory:?} and {taa_next_directory:?}"); } else { - let _ = std::fs::remove_dir_all(directory); - let _ = std::fs::remove_dir_all(next_directory); let _ = std::fs::remove_dir_all(taa_directory); let _ = std::fs::remove_dir_all(taa_next_directory); } diff --git a/native/shared/tests/golden_render/ssgi_stationary.rs b/native/shared/tests/golden_render/ssgi_stationary.rs new file mode 100644 index 00000000..0b2bd1ef --- /dev/null +++ b/native/shared/tests/golden_render/ssgi_stationary.rs @@ -0,0 +1,107 @@ +//! A complete stationary angular cycle must still respond to changed lighting. + +use super::*; + +#[test] +fn software_ssgi_settles_each_phase_and_recovers_after_light_changes() { + let Some(mut eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + let renderer = &mut eng.renderer; + renderer.set_taa_enabled(false); + renderer.set_ssao_enabled(false); + renderer.set_ssr_enabled(false); + renderer.set_ssgi_enabled(true); + renderer.set_bloom_enabled(false); + renderer.set_auto_exposure(false); + renderer.set_shadows_enabled(false); + + let root = std::env::temp_dir().join(format!( + "bloom-ssgi-light-cycle-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + let frame = |eng: &mut EngineState, light: f32, output: Option<&Path>| { + eng.begin_frame(); + let r = &mut eng.renderer; + r.pending_quality_capture_dir = output.map(|path| path.to_string_lossy().into_owned()); + r.set_clear_color(6.0, 8.0, 15.0, 255.0); + r.begin_mode_3d(4.0, 3.0, 6.0, 0.0, 0.6, 0.0, 0.0, 1.0, 0.0, 48.0, 0.0); + r.set_ambient_light(15.0, 18.0, 28.0, 0.2); + r.add_directional_light(-0.5, -1.0, -0.3, 1.0, 0.85, 0.7, light); + r.draw_cube(0.0, -0.1, 0.0, 12.0, 0.2, 12.0, 90.0, 96.0, 107.0, 255.0); + r.draw_cube(0.0, 2.0, -3.0, 8.0, 4.0, 0.2, 230.0, 166.0, 31.0, 255.0); + r.draw_cube(-1.1, 1.0, 0.0, 1.8, 2.0, 1.8, 230.0, 45.0, 25.0, 255.0); + r.draw_sphere(1.1, 0.9, -0.8, 0.9, 30.0, 110.0, 240.0, 255.0); + eng.end_frame(); + }; + let capture = |eng: &mut EngineState, light: f32, label: &str| { + let directory = root.join(label); + frame(eng, light, Some(&directory)); + let metrics: serde_json::Value = serde_json::from_slice( + &std::fs::read(directory.join("ssgi.metrics.json")).expect("SSGI HDR metrics"), + ) + .unwrap(); + assert_eq!(metrics["non_finite_pixels"], 0); + image::open(directory.join("ssgi.png")) + .expect("resolved SSGI capture") + .to_rgba8() + }; + + // Exercise both directions without resetting history between them. At 32 + // frames every angular phase has observed the changed light twice; the + // following complete cycle must agree with an independently seeded epoch. + let mut settled_states = Vec::new(); + for (label, light) in [("bright", 1.8), ("dim", 0.0), ("bright-again", 1.8)] { + for _ in 0..32 { + frame(&mut eng, light, None); + } + let settled = capture(&mut eng, light, &format!("{label}-settled")); + for phase in 0..16 { + let current = capture(&mut eng, light, &format!("{label}-phase-{phase:02}")); + assert!( + settled.as_raw() == current.as_raw(), + "{label} resolved SSGI changes at stationary angular phase {phase}" + ); + } + settled_states.push(settled); + } + let change = calculate_diff_metrics( + settled_states[0].as_raw(), + settled_states[1].as_raw(), + settled_states[0].width(), + settled_states[0].height(), + ); + assert!( + change.mean_rgb > 1.0, + "lighting change did not affect indirect radiance: {change:?}" + ); + assert!( + settled_states[0] == settled_states[2], + "returning light retained stale indirect radiance" + ); + + eng.renderer.reset_temporal_history(); + for _ in 0..64 { + frame(&mut eng, 1.8, None); + } + let fresh = capture(&mut eng, 1.8, "fresh-reference"); + assert!( + settled_states[2] == fresh, + "light recovery differs from a fresh converged history" + ); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"ssgi_trace_backend\":\"hiz-screen\"")); + eprintln!("ssgi-light-cycle stationary_frames=48 light_change={change:?}"); + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept software SSGI lighting diagnostics at {root:?}"); + } else { + std::fs::remove_dir_all(root).expect("remove SSGI lighting captures"); + } +} diff --git a/native/shared/tests/golden_render/temporal_history.rs b/native/shared/tests/golden_render/temporal_history.rs index 4e5366c7..fc4d1568 100644 --- a/native/shared/tests/golden_render/temporal_history.rs +++ b/native/shared/tests/golden_render/temporal_history.rs @@ -287,434 +287,8 @@ fn exposure_history_seeds_each_enable_epoch_without_advancing_while_off() { .contains("\"exposure_valid\":true,\"exposure_index\":1")); } -#[test] -fn path_tracing_mode_transitions_reset_incompatible_history() { - let _rt_guard = lock_rt_goldens(); - let (mut eng, _) = match try_engine_rt() { - Ok(Some(pair)) => pair, - Ok(None) => { - skip_rt_golden("pt_history_lifetime", "no-non-cpu-ray-query-adapter"); - return; - } - Err(err) => panic!("{err}"), - }; - build_pt_scene(&mut eng); - - eng.renderer.set_path_tracing(2); - let _ = render(&mut eng, 1, draw_pt_static_frame); - assert!(eng.renderer.path_tracing_sample_count() > 0); - assert!(eng - .renderer - .quality_runtime_paths_json() - .contains("\"ray_scene_preparation\":\"ssgi+pt\"")); - - eng.renderer.set_path_tracing(1); - assert_eq!(eng.renderer.path_tracing_sample_count(), 0); - assert!(eng - .renderer - .quality_runtime_paths_json() - .contains("\"pt_samples\":0,\"pt_index\":0")); - - eng.renderer.set_path_tracing(0); - assert_eq!(eng.renderer.path_tracing_sample_count(), 0); -} - -#[test] -fn realtime_path_tracing_capture_exposes_svgf_history_without_normal_frame_resources() { - let _rt_guard = lock_rt_goldens(); - let (mut eng, _) = match try_engine_rt() { - Ok(Some(pair)) => pair, - Ok(None) => { - skip_rt_golden("pt_temporal_capture", "no-non-cpu-ray-query-adapter"); - return; - } - Err(err) => panic!("{err}"), - }; - build_pt_scene(&mut eng); - let r = &mut eng.renderer; - r.set_taa_enabled(false); - r.set_ssao_enabled(false); - r.set_ssr_enabled(false); - r.set_ssgi_enabled(false); - r.set_bloom_enabled(false); - r.set_auto_exposure(false); - r.set_path_tracing(2); - r.set_path_tracing_debug_view(0); - r.set_path_tracing_seed(0); - r.reset_path_tracing_history(0); - - let mut frame = 0u32; - let _ = render(&mut eng, 24, |eng| { - draw_pt_motion_frame(eng, frame); - frame += 1; - }); - let samples_before_capture = eng.renderer.path_tracing_sample_count(); - assert!( - samples_before_capture >= 8, - "realtime PT reached only {samples_before_capture} history frames before capture" - ); - let normal_paths = eng.renderer.quality_runtime_paths_json(); - assert!(normal_paths.contains("\"ray_scene_preparation\":\"pt\"")); - assert!(normal_paths.contains("\"pt_diagnostic_persistent_bytes\":0")); - assert!(normal_paths.contains("\"pt_diagnostic_resources_live\":false")); - - let directory = - std::env::temp_dir().join(format!("bloom-pt-diagnostics-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&directory); - eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); - eng.begin_frame(); - draw_pt_motion_frame(&mut eng, frame); - eng.end_frame(); - assert!( - eng.renderer.path_tracing_sample_count() > samples_before_capture, - "qualification frame did not execute the realtime PT pass" - ); - - let reasons = image::open(directory.join("pt-rejection-reason.png")) - .expect("PT capture did not emit temporal rejection reasons") - .to_rgb8(); - let accepted = reasons - .pixels() - .filter(|pixel| { - (pixel[0] < 40 && pixel[1] > 140 && pixel[2] < 60) || (pixel[0] < 40 && pixel[2] > 200) - }) - .count(); - let motion = image::open(directory.join("pt-motion.png")) - .expect("PT capture did not emit motion vectors") - .to_rgb8(); - let reprojection = image::open(directory.join("pt-reprojected-uv.png")) - .expect("PT capture did not emit reprojected UVs") - .to_rgb8(); - let valid_reprojection = reprojection.pixels().filter(|pixel| pixel[2] > 200).count(); - let confidence = image::open(directory.join("pt-temporal-confidence.png")) - .expect("PT capture did not emit temporal confidence") - .to_rgb8(); - let accumulated = confidence - .pixels() - .filter(|pixel| pixel[1] > 16 && pixel[2] > 16) - .count(); - let metrics: serde_json::Value = serde_json::from_slice( - &std::fs::read(directory.join("hdr-scene.metrics.json")) - .expect("PT capture did not emit raw HDR metrics"), - ) - .unwrap(); - let non_finite = metrics["non_finite_pixels"].as_u64().unwrap(); - let max_luminance = metrics["max_luminance"].as_f64().unwrap(); - eprintln!( - "temporal-corpus pt-svgf accepted={accepted} valid_reprojection={valid_reprojection} \ - accumulated={accumulated} non_finite={non_finite} max_luma={max_luminance:.4} total={}", - reasons.width() * reasons.height() - ); - assert!( - accepted >= 100 && valid_reprojection >= 100 && accumulated >= 100, - "settled realtime PT exposed no accepted, reprojected, accumulated history" - ); - assert_eq!(non_finite, 0, "realtime PT emitted non-finite HDR radiance"); - assert!(max_luminance > 0.0001, "realtime PT produced no radiance"); - assert_eq!(reasons.dimensions(), motion.dimensions()); - assert_eq!(reasons.dimensions(), reprojection.dimensions()); - assert_eq!(reasons.dimensions(), confidence.dimensions()); - - let paths = eng.renderer.quality_runtime_paths_json(); - assert!(paths.contains("\"pt_diagnostic_persistent_bytes\":0")); - assert!(paths.contains("\"pt_diagnostic_capture_passes\":1")); - assert!(paths.contains("\"pt_diagnostic_resources_live\":false")); - if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { - eprintln!("kept PT diagnostics at {directory:?}"); - } else { - let _ = std::fs::remove_dir_all(directory); - } -} - -#[test] -fn realtime_path_tracing_rigid_motion_bounds_trails_and_keeps_history() { - fn transform(x: f32, angle: f32) -> [[f32; 4]; 4] { - let (sin, cos) = angle.sin_cos(); - [ - [cos, 0.0, -sin, 0.0], - [0.0, 1.4, 0.0, 0.0], - [sin, 0.0, cos, 0.0], - [x, 1.0, -0.4, 1.0], - ] - } - - let _rt_guard = lock_rt_goldens(); - let (mut eng, _) = match try_engine_rt() { - Ok(Some(pair)) => pair, - Ok(None) => { - skip_rt_golden("pt_rigid_motion", "no-non-cpu-ray-query-adapter"); - return; - } - Err(err) => panic!("{err}"), - }; - build_pt_scene(&mut eng); - let (vertices, indices) = cube_verts(0.7, [0.95, 0.06, 0.02, 1.0]); - let node = eng.scene.create_node(); - eng.scene.update_geometry(node, vertices, indices); - eng.scene.set_material_pbr(node, 0.15, 0.3); - eng.scene.set_material_color(node, 0.95, 0.06, 0.02, 1.0); - eng.scene.set_transform(node, transform(-2.0, -0.65)); - - let r = &mut eng.renderer; - r.set_taa_enabled(false); - r.set_ssao_enabled(false); - r.set_ssr_enabled(false); - r.set_ssgi_enabled(false); - r.set_bloom_enabled(false); - r.set_auto_exposure(false); - r.set_path_tracing(2); - r.set_path_tracing_debug_view(0); - r.set_path_tracing_seed(0); - r.reset_path_tracing_history(0); - let _ = render(&mut eng, 24, draw_pt_static_frame); - let old_pose = render(&mut eng, 1, draw_pt_static_frame).2; - - eng.scene.set_transform(node, transform(2.0, 0.8)); - let directory = - std::env::temp_dir().join(format!("bloom-pt-rigid-motion-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&directory); - eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); - let mut frames = Vec::new(); - for _ in 0..24 { - frames.push(render(&mut eng, 1, draw_pt_static_frame).2); - } - evaluate_motion_recovery("pt-rigid", &old_pose, &frames); - - let motion = image::open(directory.join("pt-motion.png")) - .expect("moving PT capture did not emit motion vectors") - .to_rgb8(); - let moving = motion.pixels().filter(|pixel| pixel[2] > 16).count(); - let reasons = image::open(directory.join("pt-rejection-reason.png")) - .expect("moving PT capture did not emit rejection reasons") - .to_rgb8(); - let mut motion_history = 0usize; - let mut motion_rejected = 0usize; - let mut motion_flip = 0usize; - for (motion, reason) in motion.pixels().zip(reasons.pixels()) { - if motion[2] <= 16 { - continue; - } - motion_history += - usize::from(reason[0] < 40 && reason[1] > 40 && reason[1] < 100 && reason[2] > 220); - motion_rejected += - usize::from(reason[0] > 220 && reason[1] < 40 && (reason[2] > 160 || reason[2] < 40)); - motion_flip += usize::from(reason[0] < 40 && reason[1] > 200 && reason[2] > 220); - } - let classified_motion = motion_history + motion_rejected + motion_flip; - eprintln!( - "temporal-corpus pt-rigid moving={moving} retained={motion_history} \ - rejected={motion_rejected} footprint_flip={motion_flip} total={}", - motion.width() * motion.height() - ); - assert!(moving >= 100, "rigid PT motion wrote no velocity coverage"); - assert!( - motion_history >= 25, - "overlapping rigid PT motion retained no reprojected history" - ); - assert!( - classified_motion * 10 >= moving * 9, - "moving PT texels were neither retained nor explicitly rejected" - ); - if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { - eprintln!("kept PT rigid-motion diagnostics at {directory:?}"); - } else { - let _ = std::fs::remove_dir_all(directory); - } -} - -#[test] -fn realtime_path_tracing_lighting_changes_converge_without_reset_or_lag() { - fn evaluate_lighting(label: &str, previous: &[u8], frames: &[Vec]) { - let stable = average_rgba(&frames[12..]); - let change = calculate_diff_metrics(previous, &stable, W, H); - let recovery = frames[..13] - .iter() - .map(|frame| calculate_diff_metrics(&stable, frame, W, H)) - .collect::>(); - let stable_flicker = frames[12..] - .iter() - .map(|frame| calculate_diff_metrics(&stable, frame, W, H).mean_rgb) - .sum::() - / (frames.len() - 12) as f64; - eprintln!( - "temporal-corpus {label} change_mean={:.4} initial_mean={:.4} \ - frame4_mean={:.4} frame8_mean={:.4} frame12_outliers={:.4}% \ - stable_flicker={stable_flicker:.4}", - change.mean_rgb, - recovery[0].mean_rgb, - recovery[4].mean_rgb, - recovery[8].mean_rgb, - recovery[12].outlier_pixel_fraction * 100.0, - ); - assert!( - change.mean_rgb >= 1.0 && change.outlier_pixel_fraction >= 0.01, - "{label} negative control did not produce a visible lighting change" - ); - assert!( - recovery[8].mean_rgb <= recovery[0].mean_rgb * 0.65 + 0.25, - "{label} retained stale lighting beyond eight frames" - ); - assert!( - recovery[12].outlier_pixel_fraction <= 0.02, - "{label} retained coherent stale lighting after twelve frames" - ); - assert!( - stable_flicker <= 2.0, - "{label} did not settle to a stable stochastic estimate" - ); - } - - let _rt_guard = lock_rt_goldens(); - let (mut eng, _) = match try_engine_rt() { - Ok(Some(pair)) => pair, - Ok(None) => { - skip_rt_golden("pt_lighting_change", "no-non-cpu-ray-query-adapter"); - return; - } - Err(err) => panic!("{err}"), - }; - build_pt_scene(&mut eng); - let r = &mut eng.renderer; - r.set_taa_enabled(false); - r.set_ssao_enabled(false); - r.set_ssr_enabled(false); - r.set_ssgi_enabled(false); - r.set_bloom_enabled(false); - r.set_auto_exposure(false); - r.set_path_tracing(2); - r.set_path_tracing_debug_view(0); - r.set_path_tracing_seed(0); - r.reset_path_tracing_history(0); - let draw = |eng: &mut EngineState, bright: bool| { - draw_pt_static_frame(eng); - eng.renderer.set_directional_light( - 0.5, - 1.0, - 0.3, - 255.0, - 242.25, - 229.5, - if bright { 2.4 } else { 0.15 }, - ); - }; - let capture_state = - |eng: &mut EngineState, bright: bool| render(eng, 1, |eng| draw(eng, bright)).2; - - let _ = render(&mut eng, 24, |eng| draw(eng, false)); - let dark = capture_state(&mut eng, false); - let before_bright = eng.renderer.path_tracing_sample_count(); - let mut bright_frames = Vec::new(); - for _ in 0..24 { - bright_frames.push(capture_state(&mut eng, true)); - } - assert_eq!( - eng.renderer.path_tracing_sample_count(), - before_bright + 24, - "lighting change reset realtime PT history" - ); - evaluate_lighting("pt-light-on", &dark, &bright_frames); - - let bright = average_rgba(&bright_frames[12..]); - let before_dark = eng.renderer.path_tracing_sample_count(); - let mut dark_frames = Vec::new(); - for _ in 0..24 { - dark_frames.push(capture_state(&mut eng, false)); - } - assert_eq!( - eng.renderer.path_tracing_sample_count(), - before_dark + 24, - "lighting removal reset realtime PT history" - ); - evaluate_lighting("pt-light-off", &bright, &dark_frames); -} - -#[test] -fn realtime_path_tracing_resets_are_byte_exact_fresh_seeds() { - let _rt_guard = lock_rt_goldens(); - let (mut eng, _) = match try_engine_rt() { - Ok(Some(pair)) => pair, - Ok(None) => { - skip_rt_golden("pt_reset_seed", "no-non-cpu-ray-query-adapter"); - return; - } - Err(err) => panic!("{err}"), - }; - build_pt_scene(&mut eng); - let r = &mut eng.renderer; - r.set_taa_enabled(false); - r.set_ssao_enabled(false); - r.set_ssr_enabled(false); - r.set_ssgi_enabled(false); - r.set_bloom_enabled(false); - r.set_auto_exposure(false); - r.set_path_tracing(2); - r.set_path_tracing_debug_view(0); - r.set_path_tracing_seed(0); - let draw = |eng: &mut EngineState, camera: [f32; 3]| { - let r = &mut eng.renderer; - r.set_clear_color(0.05, 0.07, 0.1, 1.0); - r.begin_mode_3d( - camera[0], camera[1], camera[2], 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 50.0, 0.0, - ); - r.set_directional_light(0.5, 1.0, 0.3, 255.0, 242.25, 229.5, 1.2); - }; - let capture = - |eng: &mut EngineState, camera: [f32; 3]| render(eng, 1, |eng| draw(eng, camera)).2; - let camera_a = [-5.5, 3.2, 5.0]; - let camera_b = [5.0, 4.0, 7.0]; - - // Drain shared card/TLAS warm-up before establishing the seed oracle. - let _ = render(&mut eng, 8, |eng| draw(eng, camera_b)); - eng.renderer.reset_temporal_history(); - let fresh_b = capture(&mut eng, camera_b); - assert_eq!(eng.renderer.path_tracing_sample_count(), 1); - - let _ = render(&mut eng, 16, |eng| draw(eng, camera_a)); - eng.renderer.reset_temporal_history(); - let directory = - std::env::temp_dir().join(format!("bloom-pt-reset-seed-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&directory); - eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); - let cut_b = capture(&mut eng, camera_b); - let cut_metrics = calculate_diff_metrics(&fresh_b, &cut_b, W, H); - assert_eq!( - cut_metrics.max_diff, 0, - "explicit PT reset retained pixels from the prior camera" - ); - assert_eq!(eng.renderer.path_tracing_sample_count(), 1); - let reasons = image::open(directory.join("pt-rejection-reason.png")) - .expect("PT reset capture did not emit rejection reasons") - .to_rgb8(); - let non_seed = reasons - .pixels() - .filter(|pixel| pixel[0].abs_diff(pixel[1]) > 2 || pixel[1].abs_diff(pixel[2]) > 2) - .count(); - assert_eq!( - non_seed, 0, - "fresh PT history was not entirely classified as seed/sky" - ); - - let _ = render(&mut eng, 16, |eng| draw(eng, camera_a)); - eng.renderer.set_path_tracing(0); - let _ = capture(&mut eng, camera_a); - eng.renderer.set_path_tracing(2); - let toggled_b = capture(&mut eng, camera_b); - let toggle_metrics = calculate_diff_metrics(&fresh_b, &toggled_b, W, H); - eprintln!( - "temporal-corpus pt-reset cut_max={} toggle_max={} non_seed={non_seed}", - cut_metrics.max_diff, toggle_metrics.max_diff, - ); - assert_eq!( - toggle_metrics.max_diff, 0, - "PT off/on transition retained pixels from the prior ownership epoch" - ); - assert_eq!(eng.renderer.path_tracing_sample_count(), 1); - if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { - eprintln!("kept PT reset diagnostics at {directory:?}"); - } else { - let _ = std::fs::remove_dir_all(directory); - } -} +#[path = "temporal_history_pt.rs"] +mod path_tracing; #[test] fn common_camera_cut_reset_invalidates_every_temporal_owner() { diff --git a/native/shared/tests/golden_render/temporal_history_pt.rs b/native/shared/tests/golden_render/temporal_history_pt.rs new file mode 100644 index 00000000..535eaca6 --- /dev/null +++ b/native/shared/tests/golden_render/temporal_history_pt.rs @@ -0,0 +1,430 @@ +use super::*; + +#[test] +fn path_tracing_mode_transitions_reset_incompatible_history() { + let _rt_guard = lock_rt_goldens(); + let (mut eng, _) = match try_engine_rt() { + Ok(Some(pair)) => pair, + Ok(None) => { + skip_rt_golden("pt_history_lifetime", "no-non-cpu-ray-query-adapter"); + return; + } + Err(err) => panic!("{err}"), + }; + build_pt_scene(&mut eng); + + eng.renderer.set_path_tracing(2); + let _ = render(&mut eng, 1, draw_pt_static_frame); + assert!(eng.renderer.path_tracing_sample_count() > 0); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"ray_scene_preparation\":\"ssgi+pt\"")); + + eng.renderer.set_path_tracing(1); + assert_eq!(eng.renderer.path_tracing_sample_count(), 0); + assert!(eng + .renderer + .quality_runtime_paths_json() + .contains("\"pt_samples\":0,\"pt_index\":0")); + + eng.renderer.set_path_tracing(0); + assert_eq!(eng.renderer.path_tracing_sample_count(), 0); +} + +#[test] +fn realtime_path_tracing_capture_exposes_svgf_history_without_normal_frame_resources() { + let _rt_guard = lock_rt_goldens(); + let (mut eng, _) = match try_engine_rt() { + Ok(Some(pair)) => pair, + Ok(None) => { + skip_rt_golden("pt_temporal_capture", "no-non-cpu-ray-query-adapter"); + return; + } + Err(err) => panic!("{err}"), + }; + build_pt_scene(&mut eng); + let r = &mut eng.renderer; + r.set_taa_enabled(false); + r.set_ssao_enabled(false); + r.set_ssr_enabled(false); + r.set_ssgi_enabled(false); + r.set_bloom_enabled(false); + r.set_auto_exposure(false); + r.set_path_tracing(2); + r.set_path_tracing_debug_view(0); + r.set_path_tracing_seed(0); + r.reset_path_tracing_history(0); + + let mut frame = 0u32; + let _ = render(&mut eng, 24, |eng| { + draw_pt_motion_frame(eng, frame); + frame += 1; + }); + let samples_before_capture = eng.renderer.path_tracing_sample_count(); + assert!( + samples_before_capture >= 8, + "realtime PT reached only {samples_before_capture} history frames before capture" + ); + let normal_paths = eng.renderer.quality_runtime_paths_json(); + assert!(normal_paths.contains("\"ray_scene_preparation\":\"pt\"")); + assert!(normal_paths.contains("\"pt_diagnostic_persistent_bytes\":0")); + assert!(normal_paths.contains("\"pt_diagnostic_resources_live\":false")); + + let directory = + std::env::temp_dir().join(format!("bloom-pt-diagnostics-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + eng.begin_frame(); + draw_pt_motion_frame(&mut eng, frame); + eng.end_frame(); + assert!( + eng.renderer.path_tracing_sample_count() > samples_before_capture, + "qualification frame did not execute the realtime PT pass" + ); + + let reasons = image::open(directory.join("pt-rejection-reason.png")) + .expect("PT capture did not emit temporal rejection reasons") + .to_rgb8(); + let accepted = reasons + .pixels() + .filter(|pixel| { + (pixel[0] < 40 && pixel[1] > 140 && pixel[2] < 60) || (pixel[0] < 40 && pixel[2] > 200) + }) + .count(); + let motion = image::open(directory.join("pt-motion.png")) + .expect("PT capture did not emit motion vectors") + .to_rgb8(); + let reprojection = image::open(directory.join("pt-reprojected-uv.png")) + .expect("PT capture did not emit reprojected UVs") + .to_rgb8(); + let valid_reprojection = reprojection.pixels().filter(|pixel| pixel[2] > 200).count(); + let confidence = image::open(directory.join("pt-temporal-confidence.png")) + .expect("PT capture did not emit temporal confidence") + .to_rgb8(); + let accumulated = confidence + .pixels() + .filter(|pixel| pixel[1] > 16 && pixel[2] > 16) + .count(); + let metrics: serde_json::Value = serde_json::from_slice( + &std::fs::read(directory.join("hdr-scene.metrics.json")) + .expect("PT capture did not emit raw HDR metrics"), + ) + .unwrap(); + let non_finite = metrics["non_finite_pixels"].as_u64().unwrap(); + let max_luminance = metrics["max_luminance"].as_f64().unwrap(); + eprintln!( + "temporal-corpus pt-svgf accepted={accepted} valid_reprojection={valid_reprojection} \ + accumulated={accumulated} non_finite={non_finite} max_luma={max_luminance:.4} total={}", + reasons.width() * reasons.height() + ); + assert!( + accepted >= 100 && valid_reprojection >= 100 && accumulated >= 100, + "settled realtime PT exposed no accepted, reprojected, accumulated history" + ); + assert_eq!(non_finite, 0, "realtime PT emitted non-finite HDR radiance"); + assert!(max_luminance > 0.0001, "realtime PT produced no radiance"); + assert_eq!(reasons.dimensions(), motion.dimensions()); + assert_eq!(reasons.dimensions(), reprojection.dimensions()); + assert_eq!(reasons.dimensions(), confidence.dimensions()); + + let paths = eng.renderer.quality_runtime_paths_json(); + assert!(paths.contains("\"pt_diagnostic_persistent_bytes\":0")); + assert!(paths.contains("\"pt_diagnostic_capture_passes\":1")); + assert!(paths.contains("\"pt_diagnostic_resources_live\":false")); + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept PT diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} + +#[test] +fn realtime_path_tracing_rigid_motion_bounds_trails_and_keeps_history() { + fn transform(x: f32, angle: f32) -> [[f32; 4]; 4] { + let (sin, cos) = angle.sin_cos(); + [ + [cos, 0.0, -sin, 0.0], + [0.0, 1.4, 0.0, 0.0], + [sin, 0.0, cos, 0.0], + [x, 1.0, -0.4, 1.0], + ] + } + + let _rt_guard = lock_rt_goldens(); + let (mut eng, _) = match try_engine_rt() { + Ok(Some(pair)) => pair, + Ok(None) => { + skip_rt_golden("pt_rigid_motion", "no-non-cpu-ray-query-adapter"); + return; + } + Err(err) => panic!("{err}"), + }; + build_pt_scene(&mut eng); + let (vertices, indices) = cube_verts(0.7, [0.95, 0.06, 0.02, 1.0]); + let node = eng.scene.create_node(); + eng.scene.update_geometry(node, vertices, indices); + eng.scene.set_material_pbr(node, 0.15, 0.3); + eng.scene.set_material_color(node, 0.95, 0.06, 0.02, 1.0); + eng.scene.set_transform(node, transform(-2.0, -0.65)); + + let r = &mut eng.renderer; + r.set_taa_enabled(false); + r.set_ssao_enabled(false); + r.set_ssr_enabled(false); + r.set_ssgi_enabled(false); + r.set_bloom_enabled(false); + r.set_auto_exposure(false); + r.set_path_tracing(2); + r.set_path_tracing_debug_view(0); + r.set_path_tracing_seed(0); + r.reset_path_tracing_history(0); + let _ = render(&mut eng, 24, draw_pt_static_frame); + let old_pose = render(&mut eng, 1, draw_pt_static_frame).2; + + eng.scene.set_transform(node, transform(2.0, 0.8)); + let directory = + std::env::temp_dir().join(format!("bloom-pt-rigid-motion-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + let mut frames = Vec::new(); + for _ in 0..24 { + frames.push(render(&mut eng, 1, draw_pt_static_frame).2); + } + evaluate_motion_recovery("pt-rigid", &old_pose, &frames); + + let motion = image::open(directory.join("pt-motion.png")) + .expect("moving PT capture did not emit motion vectors") + .to_rgb8(); + let moving = motion.pixels().filter(|pixel| pixel[2] > 16).count(); + let reasons = image::open(directory.join("pt-rejection-reason.png")) + .expect("moving PT capture did not emit rejection reasons") + .to_rgb8(); + let mut motion_history = 0usize; + let mut motion_rejected = 0usize; + let mut motion_flip = 0usize; + for (motion, reason) in motion.pixels().zip(reasons.pixels()) { + if motion[2] <= 16 { + continue; + } + motion_history += + usize::from(reason[0] < 40 && reason[1] > 40 && reason[1] < 100 && reason[2] > 220); + motion_rejected += + usize::from(reason[0] > 220 && reason[1] < 40 && (reason[2] > 160 || reason[2] < 40)); + motion_flip += usize::from(reason[0] < 40 && reason[1] > 200 && reason[2] > 220); + } + let classified_motion = motion_history + motion_rejected + motion_flip; + eprintln!( + "temporal-corpus pt-rigid moving={moving} retained={motion_history} \ + rejected={motion_rejected} footprint_flip={motion_flip} total={}", + motion.width() * motion.height() + ); + assert!(moving >= 100, "rigid PT motion wrote no velocity coverage"); + assert!( + motion_history >= 25, + "overlapping rigid PT motion retained no reprojected history" + ); + assert!( + classified_motion * 10 >= moving * 9, + "moving PT texels were neither retained nor explicitly rejected" + ); + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept PT rigid-motion diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} + +#[test] +fn realtime_path_tracing_lighting_changes_converge_without_reset_or_lag() { + fn evaluate_lighting(label: &str, previous: &[u8], frames: &[Vec]) { + let stable = average_rgba(&frames[12..]); + let change = calculate_diff_metrics(previous, &stable, W, H); + let recovery = frames[..13] + .iter() + .map(|frame| calculate_diff_metrics(&stable, frame, W, H)) + .collect::>(); + let stable_flicker = frames[12..] + .iter() + .map(|frame| calculate_diff_metrics(&stable, frame, W, H).mean_rgb) + .sum::() + / (frames.len() - 12) as f64; + eprintln!( + "temporal-corpus {label} change_mean={:.4} initial_mean={:.4} \ + frame4_mean={:.4} frame8_mean={:.4} frame12_outliers={:.4}% \ + stable_flicker={stable_flicker:.4}", + change.mean_rgb, + recovery[0].mean_rgb, + recovery[4].mean_rgb, + recovery[8].mean_rgb, + recovery[12].outlier_pixel_fraction * 100.0, + ); + assert!( + change.mean_rgb >= 1.0 && change.outlier_pixel_fraction >= 0.01, + "{label} negative control did not produce a visible lighting change" + ); + assert!( + recovery[8].mean_rgb <= recovery[0].mean_rgb * 0.65 + 0.25, + "{label} retained stale lighting beyond eight frames" + ); + assert!( + recovery[12].outlier_pixel_fraction <= 0.02, + "{label} retained coherent stale lighting after twelve frames" + ); + assert!( + stable_flicker <= 2.0, + "{label} did not settle to a stable stochastic estimate" + ); + } + + let _rt_guard = lock_rt_goldens(); + let (mut eng, _) = match try_engine_rt() { + Ok(Some(pair)) => pair, + Ok(None) => { + skip_rt_golden("pt_lighting_change", "no-non-cpu-ray-query-adapter"); + return; + } + Err(err) => panic!("{err}"), + }; + build_pt_scene(&mut eng); + let r = &mut eng.renderer; + r.set_taa_enabled(false); + r.set_ssao_enabled(false); + r.set_ssr_enabled(false); + r.set_ssgi_enabled(false); + r.set_bloom_enabled(false); + r.set_auto_exposure(false); + r.set_path_tracing(2); + r.set_path_tracing_debug_view(0); + r.set_path_tracing_seed(0); + r.reset_path_tracing_history(0); + let draw = |eng: &mut EngineState, bright: bool| { + draw_pt_static_frame(eng); + eng.renderer.set_directional_light( + 0.5, + 1.0, + 0.3, + 255.0, + 242.25, + 229.5, + if bright { 2.4 } else { 0.15 }, + ); + }; + let capture_state = + |eng: &mut EngineState, bright: bool| render(eng, 1, |eng| draw(eng, bright)).2; + + let _ = render(&mut eng, 24, |eng| draw(eng, false)); + let dark = capture_state(&mut eng, false); + let before_bright = eng.renderer.path_tracing_sample_count(); + let mut bright_frames = Vec::new(); + for _ in 0..24 { + bright_frames.push(capture_state(&mut eng, true)); + } + assert_eq!( + eng.renderer.path_tracing_sample_count(), + before_bright + 24, + "lighting change reset realtime PT history" + ); + evaluate_lighting("pt-light-on", &dark, &bright_frames); + + let bright = average_rgba(&bright_frames[12..]); + let before_dark = eng.renderer.path_tracing_sample_count(); + let mut dark_frames = Vec::new(); + for _ in 0..24 { + dark_frames.push(capture_state(&mut eng, false)); + } + assert_eq!( + eng.renderer.path_tracing_sample_count(), + before_dark + 24, + "lighting removal reset realtime PT history" + ); + evaluate_lighting("pt-light-off", &bright, &dark_frames); +} + +#[test] +fn realtime_path_tracing_resets_are_byte_exact_fresh_seeds() { + let _rt_guard = lock_rt_goldens(); + let (mut eng, _) = match try_engine_rt() { + Ok(Some(pair)) => pair, + Ok(None) => { + skip_rt_golden("pt_reset_seed", "no-non-cpu-ray-query-adapter"); + return; + } + Err(err) => panic!("{err}"), + }; + build_pt_scene(&mut eng); + let r = &mut eng.renderer; + r.set_taa_enabled(false); + r.set_ssao_enabled(false); + r.set_ssr_enabled(false); + r.set_ssgi_enabled(false); + r.set_bloom_enabled(false); + r.set_auto_exposure(false); + r.set_path_tracing(2); + r.set_path_tracing_debug_view(0); + r.set_path_tracing_seed(0); + let draw = |eng: &mut EngineState, camera: [f32; 3]| { + let r = &mut eng.renderer; + r.set_clear_color(0.05, 0.07, 0.1, 1.0); + r.begin_mode_3d( + camera[0], camera[1], camera[2], 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 50.0, 0.0, + ); + r.set_directional_light(0.5, 1.0, 0.3, 255.0, 242.25, 229.5, 1.2); + }; + let capture = + |eng: &mut EngineState, camera: [f32; 3]| render(eng, 1, |eng| draw(eng, camera)).2; + let camera_a = [-5.5, 3.2, 5.0]; + let camera_b = [5.0, 4.0, 7.0]; + + // Drain shared card/TLAS warm-up before establishing the seed oracle. + let _ = render(&mut eng, 8, |eng| draw(eng, camera_b)); + eng.renderer.reset_temporal_history(); + let fresh_b = capture(&mut eng, camera_b); + assert_eq!(eng.renderer.path_tracing_sample_count(), 1); + + let _ = render(&mut eng, 16, |eng| draw(eng, camera_a)); + eng.renderer.reset_temporal_history(); + let directory = + std::env::temp_dir().join(format!("bloom-pt-reset-seed-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + eng.renderer.pending_quality_capture_dir = Some(directory.to_string_lossy().into_owned()); + let cut_b = capture(&mut eng, camera_b); + let cut_metrics = calculate_diff_metrics(&fresh_b, &cut_b, W, H); + assert_eq!( + cut_metrics.max_diff, 0, + "explicit PT reset retained pixels from the prior camera" + ); + assert_eq!(eng.renderer.path_tracing_sample_count(), 1); + let reasons = image::open(directory.join("pt-rejection-reason.png")) + .expect("PT reset capture did not emit rejection reasons") + .to_rgb8(); + let non_seed = reasons + .pixels() + .filter(|pixel| pixel[0].abs_diff(pixel[1]) > 2 || pixel[1].abs_diff(pixel[2]) > 2) + .count(); + assert_eq!( + non_seed, 0, + "fresh PT history was not entirely classified as seed/sky" + ); + + let _ = render(&mut eng, 16, |eng| draw(eng, camera_a)); + eng.renderer.set_path_tracing(0); + let _ = capture(&mut eng, camera_a); + eng.renderer.set_path_tracing(2); + let toggled_b = capture(&mut eng, camera_b); + let toggle_metrics = calculate_diff_metrics(&fresh_b, &toggled_b, W, H); + eprintln!( + "temporal-corpus pt-reset cut_max={} toggle_max={} non_seed={non_seed}", + cut_metrics.max_diff, toggle_metrics.max_diff, + ); + assert_eq!( + toggle_metrics.max_diff, 0, + "PT off/on transition retained pixels from the prior ownership epoch" + ); + assert_eq!(eng.renderer.path_tracing_sample_count(), 1); + if std::env::var_os("BLOOM_KEEP_TEMPORAL_DIAGNOSTICS").is_some() { + eprintln!("kept PT reset diagnostics at {directory:?}"); + } else { + let _ = std::fs::remove_dir_all(directory); + } +} diff --git a/native/tvos/Cargo.lock b/native/tvos/Cargo.lock index fa34a4e3..8e805491 100644 --- a/native/tvos/Cargo.lock +++ b/native/tvos/Cargo.lock @@ -56,6 +56,26 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f09c37bc0e9f0924b7dae9988265ef3c76c88538f41a3b06caf4bed07cee5226" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bit-set" version = "0.9.1" @@ -103,11 +123,20 @@ dependencies = [ "sha2", ] +[[package]] +name = "bloom-scene-format" +version = "0.1.0" +dependencies = [ + "bincode", + "sha2", +] + [[package]] name = "bloom-shared" version = "0.1.0" dependencies = [ "bloom-geometry-format", + "bloom-scene-format", "bytemuck", "cmake", "earcutr", @@ -120,8 +149,10 @@ dependencies = [ "libc", "log", "minimp3", + "naga", "raw-window-handle", "serde_json", + "sha2", "web-sys", "wgpu", ] @@ -820,6 +851,7 @@ dependencies = [ "log", "num-traits", "once_cell", + "pp-rs", "rustc-hash", "spirv", "thiserror 2.0.18", @@ -996,6 +1028,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "pp-rs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee" +dependencies = [ + "unicode-xid", +] + [[package]] name = "presser" version = "0.3.1" @@ -1312,6 +1353,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "urlencoding" version = "2.1.3" @@ -1324,6 +1377,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "wasm-bindgen" version = "0.2.118" diff --git a/native/visionos/Cargo.lock b/native/visionos/Cargo.lock index c2113cfa..b2538023 100644 --- a/native/visionos/Cargo.lock +++ b/native/visionos/Cargo.lock @@ -56,6 +56,26 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f09c37bc0e9f0924b7dae9988265ef3c76c88538f41a3b06caf4bed07cee5226" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bit-set" version = "0.9.1" @@ -103,11 +123,20 @@ dependencies = [ "sha2", ] +[[package]] +name = "bloom-scene-format" +version = "0.1.0" +dependencies = [ + "bincode", + "sha2", +] + [[package]] name = "bloom-shared" version = "0.1.0" dependencies = [ "bloom-geometry-format", + "bloom-scene-format", "bytemuck", "cmake", "earcutr", @@ -120,8 +149,10 @@ dependencies = [ "libc", "log", "minimp3", + "naga", "raw-window-handle", "serde_json", + "sha2", "web-sys", "wgpu", ] @@ -819,6 +850,7 @@ dependencies = [ "log", "num-traits", "once_cell", + "pp-rs", "rustc-hash", "spirv", "thiserror 2.0.18", @@ -995,6 +1027,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "pp-rs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee" +dependencies = [ + "unicode-xid", +] + [[package]] name = "presser" version = "0.3.1" @@ -1311,6 +1352,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "urlencoding" version = "2.1.3" @@ -1323,6 +1376,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "wasm-bindgen" version = "0.2.125" diff --git a/tools/file-lines-baseline.json b/tools/file-lines-baseline.json index 214707f2..ef8d2c62 100644 --- a/tools/file-lines-baseline.json +++ b/tools/file-lines-baseline.json @@ -2,10 +2,7 @@ "native/shared/src/physics_jolt.rs": 3858, "native/shared/src/renderer/material_system.rs": 2335, "native/shared/src/renderer/mod.rs": 14649, - "native/shared/src/renderer/shaders/post.rs": 2026, - "native/shared/src/renderer/shaders/ssgi.rs": 2014, - "native/shared/src/scene.rs": 2778, + "native/shared/src/scene.rs": 2403, "native/shared/tests/golden_render.rs": 2249, - "native/shared/tests/golden_render/temporal_history.rs": 2070, "native/web/src/lib.rs": 2349 } diff --git a/tools/validate-docs.js b/tools/validate-docs.js index 7075756b..14f91440 100644 --- a/tools/validate-docs.js +++ b/tools/validate-docs.js @@ -21,10 +21,11 @@ function walkMarkdown(relative = "") { const entries = fs.readdirSync(absolute, { withFileTypes: true }); const result = []; for (const entry of entries) { - const child = path.join(relative, entry.name); + const child = path.posix.join(relative, entry.name); if (entry.isDirectory()) { if ([".git", "node_modules", "target"].includes(entry.name)) continue; if (child === "native/third_party" || child === "native/tvos/metal-patched") continue; + if (child === "tools/quality/out") continue; result.push(...walkMarkdown(child)); } else if (entry.name.endsWith(".md")) { result.push(child); @@ -126,12 +127,27 @@ if (pack.status !== 0) { } } -const help = spawnSync("bash", ["native/web/build.sh", "--help"], { +function bashExecutable() { + if (process.env.BLOOM_BASH) return process.env.BLOOM_BASH; + if (process.platform === "win32") { + // Git for Windows normally exposes git.exe through cmd/, while its Bash + // executable is deliberately absent from PATH. Use that same installation. + const gitPaths = spawnSync("where.exe", ["git"], { encoding: "utf8" }); + for (const gitPath of (gitPaths.stdout || "").trim().split(/\r?\n/)) { + if (!gitPath) continue; + const candidate = path.resolve(path.dirname(gitPath), "..", "bin", "bash.exe"); + if (fs.existsSync(candidate)) return candidate; + } + } + return "bash"; +} + +const help = spawnSync(bashExecutable(), ["native/web/build.sh", "--help"], { cwd: root, encoding: "utf8", }); if (help.status !== 0 || !help.stdout.includes("--output")) { - fail("bloom-web help/argument parsing is not usable"); + fail(`bloom-web help/argument parsing is not usable: ${help.error?.message || help.stderr?.trim() || `exit ${help.status}`}`); } console.log(`${markdownFiles.length} Markdown files checked; ${failures} failures`);