From d610d6a1ad4ab0ea9c7768c1a30acd2cce3fc2e4 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 00:39:10 +0200 Subject: [PATCH] fix: reconstruct SSGI receivers consistently across pixel footprints --- docs/evidence/windows-ssgi-surface-v1.md | 84 ++++++++++ docs/windows-engine-plan.md | 38 ++++- native/shared/src/renderer/shaders/ssgi.rs | 35 +++- .../src/renderer/shaders/ssgi_resolve.rs | 8 +- .../renderer/ssgi_resolve_diagnostics.wgsl | 10 +- native/shared/tests/golden_render.rs | 2 + .../tests/golden_render/ssgi_quality.rs | 20 ++- .../tests/golden_render/ssgi_surface.rs | 157 ++++++++++++++++++ 8 files changed, 325 insertions(+), 29 deletions(-) create mode 100644 docs/evidence/windows-ssgi-surface-v1.md create mode 100644 native/shared/tests/golden_render/ssgi_surface.rs diff --git a/docs/evidence/windows-ssgi-surface-v1.md b/docs/evidence/windows-ssgi-surface-v1.md new file mode 100644 index 00000000..d781cced --- /dev/null +++ b/docs/evidence/windows-ssgi-surface-v1.md @@ -0,0 +1,84 @@ +# SSGI depth receivers and resolution-independent normals + +The Radeon 760M/Vulkan HD stationary-TAA fixture exposed two surface +reconstruction errors. Correcting them together passes the existing 16-frame +warm-up and image-change bounds and removes the horizontal bands in the +captured indirect-light buffer. Neither the warm-up nor its thresholds changed. +Full-corpus and cross-platform qualification is still in progress. + +## Cause and correction + +Probe placement requested depth at fractional screen coordinates and then used +those coordinates to reconstruct a position. Point sampling returns the depth +of a particular texel; that depth belongs to its center. On an inclined plane, +using a different coordinate moves the reconstructed receiver off the surface. +Placement now reconstructs from the selected texel center. Resolve and its +capture-only geometry diagnostics use the same coordinate convention. + +The normal came from the cross product of two finite-difference surface edges. +Its magnitude shrinks with pixel area, but the normalization guard compared its +squared length against a fixed `1e-6`. Valid small footprints could therefore +receive a camera-facing fallback normal. The correction scales each edge by +its largest absolute component before taking the cross product. The existing +degeneracy guard then measures angular degeneracy independently of footprint +scale; other uses of the direction helper retain their existing behavior. + +Both placement and resolve use this surface-normal helper. The diagnostic +resolve pass also uses it, so reported geometry matches production. + +## Isolated controls + +These HD captures use the same scene, original 16-frame warm-up, Vulkan adapter, +and software Hi-Z GI path. The measured buffer is raw half-resolution SSGI +before final TAA, at 640×360 for a 1280×720 render. + +| Variant | Mean RGB change | Edge change | Outlier pixel fraction | SSIM | Existing gate | +| --- | ---: | ---: | ---: | ---: | --- | +| Original | 1.480509259 | 0.003520461 | 0.001032986 | 0.979310670 | Fail | +| Texel centers only | 1.529759838 | 0.003519964 | 0.000746528 | 0.976575446 | Fail | +| Scaled surface normal only | 0.879440104 | 0.003198458 | 0.003294271 | 0.973574972 | Fail | +| Both corrections | 0.336552373 | 0.001203517 | 0.000881076 | 0.990864689 | Pass | + +The original fails at 16 frames and passes a 32-frame diagnostic control. +Increasing warm-up would leave its incorrect surface geometry and visible bands intact. +The combined correction also passes the original 256×256 stationary-TAA +fixture and preserves a byte-identical settled, non-jittered angular cycle. + +## Regression coverage and remaining verification + +The HD fixture is now a regular GPU regression, rather than depending on the +optional `BLOOM_SSGI_PROFILE_HD` environment variable. A second regression runs +the actual production placement pipeline against an analytic inclined plane. +It checks receiver positions and normals at four placement phases for depth +extents 128×128, 640×360, 641×361, and 1920×1080. On Radeon/Vulkan all 154,720 +receiver checks pass: maximum plane error is below `4.8e-7` world units and +maximum normal-component error is below `1.4e-4`. + +Restoring the old coordinates independently fails the analytic test with a +`0.016759634` world-unit plane error. Restoring the old normal guard independently +fails with a `0.6` normal-component error. Both temporary controls were reverted. +Verified Radeon/DX12 runs also pass all 154,720 analytic checks and all three +Hi-Z fixtures. DX12's HD mean RGB change is `0.401780961`, edge change +`0.001269536`, outlier fraction `0.000703125`, and SSIM `0.985344129`. + +The golden harness selects its adapter using `WGPU_BACKEND`. Earlier nominal +DX12 diagnostic runs set only `BLOOM_WGPU_BACKEND` and actually used Vulkan; +those runs do not qualify DX12. The subsequent verification records both +selectors and asserts the actual logged adapter is `Dx12`. + +Diagnostic commands, source patches, per-variant logs, and captures are retained +under `tools/quality/out/windows-engine-plan/hd-depth-coordinates/` and +`hd-depth-normal-control/`. Validation of the production correction is retained +under `ssgi-surface-fix/`. The isolated experiments record parent source +`662a44f85b23fd91cff53b6479e6373f208dc321` plus their exact temporary patches; +their original source bytes were restored after each experiment. + +The complete local shared suite passes, including all 488 library tests and +93 reported golden passes. Four goldens remain ignored, and optional external +fixtures do not establish acceptance when they return early. The existing +bright/dim/bright lighting-cycle regression retains 48 byte-identical settled +frames and recovers the independent fresh-reference image. + +Broader scene-image coverage, other backends, and timing qualification remain +separate checks. This report does not claim that +the entire #135/#149 temporal reconstruction acceptance contract is complete. diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index 00786854..6297f121 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -15,11 +15,11 @@ first nine-scene Radeon evidence are in draft PR #154. Follow-up work starts at | 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](evidence/issue-127-windows-vulkan-v1.md) and [raw evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-155-windows-vulkan-20260910) published | -| #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 | [Cutout phase correction](evidence/windows-alpha-phase-v1.md) makes both focused Windows images pass unchanged portable baselines; full strict corpus and reproducibility checks are pending | -| #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) and [profiler correction](evidence/windows-profiler-integrity-v1.md) pass 90 golden tests with 4 ignored and 2 optional external-input skips. Complete-phase/lighting control and corrected frozen A/B recorded. HD TAA-jitter and the full representative corpus remain open | -| #140 integration gates | Same required local/hosted lanes pass on exact source; release package startup and all-example evidence | All 23 hosted checks pass at #155 source `64d5eed`, including macOS shared/golden tests, all mobile target builds, native/web builds, and browser startup. Scheduled physical-hardware checks, all-example compilation, and release-install acceptance remain separate requirements | +| #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 | [Cutout phase correction](evidence/windows-alpha-phase-v1.md) passes all nine Radeon image gates in two complete runs and both hosted Metal images. Reproducibility passes with 257 byte-identical artifacts. Both strict runs fail postflight host-load checks, so their timing remains unqualified | +| #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/resource, stationary SSGI, and profiler fixes are retained. The [surface reconstruction correction](evidence/windows-ssgi-surface-v1.md) passes the original HD startup limits on Radeon/Vulkan; all 93 local goldens that run pass, including lighting recovery. Full scene-image, timing, and platform qualification of that correction remain open, as does the wider representative corpus | +| #140 integration gates | Same required local/hosted lanes pass on exact source; release package startup and all-example evidence | All 24 hosted checks pass at #158 source `662a44f`, including macOS shared/golden tests, mobile target builds, native/web builds, browser startup, and canonical Metal images. Scheduled physical-hardware checks, all-example compilation, and release-install acceptance remain separate requirements | | #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 | +| PR integration | Reviewable changes, passing required checks, full issue evidence, merge-ready rendering branch | #147 and the stacked fixes #154–#158 remain drafts; no merge performed | ## Engine work retained in scope @@ -92,13 +92,33 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json extent: Metal's observed extent predicts every inspected threshold away from integer LOD boundaries. The [integer phase correction](evidence/windows-alpha-phase-v1.md) preserves that approved grid on both backends. Focused Windows images now - pass at SSIM 0.986160457 and 0.990073442; full strict corpus and repeated-run - validation follow. Raw export leaves unmodified Windows final PNGs byte-identical. + pass at SSIM 0.986160457 and 0.990073442. At `662a44f`, all nine Radeon image + gates pass twice and reproducibility passes with 257 byte-identical artifacts. + Both full runs fail their unchanged postflight host-load checks: System CPU + exceeds the 75% per-process limit on Bistro, and in the second run also on + draw/light stress and weighted transparency. Those timing windows remain + unqualified. The [#158 evidence archive](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-alpha-phase-20260910) + retains both failed strict runs and their separate passing image/repro checks. + All 24 hosted checks pass; the new cutout GPU regression actually executes on + Vulkan, DX12, and Metal. Raw export leaves unmodified Windows final PNGs byte-identical. Shared-runner timing cannot qualify hardware budgets. - Separately, the HD TAA fixture fails at its required 16-frame warm-up but - passes diagnostic controls at 32, 64, and 128 frames. The startup/settling - requirement remains open; its warm-up and thresholds are unchanged. + The HD TAA fixture originally failed at its required 16-frame warm-up and + passed diagnostic controls at 32, 64, and 128 frames. The follow-up + [surface correction](evidence/windows-ssgi-surface-v1.md) repairs depth-texel + coordinates and resolution-dependent normal reconstruction. It passes the + original 16-frame HD limits, removes the observed horizontal GI bands, and + preserves the existing lighting-recovery regression on Radeon/Vulkan. + Both isolated partial corrections fail. Full scene and platform checks of + the combined change remain in progress; no threshold or warm-up was relaxed. 4. Continue starter/all-example and release-install checks, asset/world streaming, schema-generated APIs, components, and runtime UI against each issue's full acceptance criteria. Hardware-specific acceptance remains open while local work progresses. + +The immediate order is to repair the HD temporal startup failure, obtain valid +full Radeon timing windows, and finish all-example startup and clean Windows +installation checks. Then complete the wider temporal/geometry corpus and the +engine API, streaming, component, and UI requirements above. These remaining +outcomes include both implementation work and acceptance evidence; they are not +a claim that each subsystem is absent. None requires waiting for an RTX 4080 to +continue local work. diff --git a/native/shared/src/renderer/shaders/ssgi.rs b/native/shared/src/renderer/shaders/ssgi.rs index 02e10644..89e259c3 100644 --- a/native/shared/src/renderer/shaders/ssgi.rs +++ b/native/shared/src/renderer/shaders/ssgi.rs @@ -253,6 +253,28 @@ fn bounded_probe_history(value: vec3) -> vec3 { ); } +fn probe_surface_normal(right: vec3, up: vec3) -> vec3 { + // The cross product's magnitude shrinks with pixel area. Apply the + // degeneracy guard to its angle, not its resolution-dependent area. + let right_scale = max(max(abs(right.x), abs(right.y)), abs(right.z)); + let up_scale = max(max(abs(up.x), abs(up.y)), abs(up.z)); + if (right_scale <= 0.0 || up_scale <= 0.0) { + return vec3(0.0, 0.0, 1.0); + } + return safe_probe_direction( + cross(right / right_scale, up / up_scale), + vec3(0.0, 0.0, 1.0), + ); +} + +fn probe_depth_uv(uv: vec2, size: vec2) -> vec2 { + // A point-sampled depth belongs to the texel center. Reconstructing it at + // the requested fractional UV moves the receiver off its actual plane. + let dimensions = vec2(size); + let pixel = clamp(floor(uv * dimensions), vec2(0.0), dimensions - 1.0); + return (pixel + 0.5) / dimensions; +} + fn safe_probe_direction(value: vec3, fallback: vec3) -> vec3 { let clean = bounded_probe_history(value); let len2 = dot(clean, clean); @@ -306,7 +328,9 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { select(0.0, 1.0, u.params.z > 0.5); let px_x = f32(gid.x) * tile + tile * (0.5 + placement_jitter.x); let px_y = f32(gid.y) * tile + tile * (0.5 + placement_jitter.y); - let uv = vec2(px_x / half_w, px_y / half_h); + let uv = probe_depth_uv( + vec2(px_x / half_w, px_y / half_h), textureDimensions(hiz0), + ); let linear_z = textureSampleLevel(hiz0, hiz_samp, uv, 0.0).r; @@ -327,16 +351,13 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { // texel to the right and one up. Uses the same Hi-Z mip 0 the // center tap read from. let texel = vec2(1.0 / half_w, 1.0 / half_h); - let uv_r = uv + vec2(texel.x, 0.0); - let uv_u = uv + vec2(0.0, -texel.y); + let uv_r = probe_depth_uv(uv + vec2(texel.x, 0.0), textureDimensions(hiz0)); + let uv_u = probe_depth_uv(uv + vec2(0.0, -texel.y), textureDimensions(hiz0)); let zr = textureSampleLevel(hiz0, hiz_samp, uv_r, 0.0).r; let zu = textureSampleLevel(hiz0, hiz_samp, uv_u, 0.0).r; let P_r = view_pos_from_linear(uv_r, zr, p00, p11, p20, p21); let P_u = view_pos_from_linear(uv_u, zu, p00, p11, p20, p21); - let N_vs = safe_probe_direction( - cross(P_r - P, P_u - P), - vec3(0.0, 0.0, 1.0), - ); + let N_vs = probe_surface_normal(P_r - P, P_u - P); let sampled_world_pos = (u.inv_view * vec4(P, 1.0)).xyz; let N_world = safe_probe_direction( diff --git a/native/shared/src/renderer/shaders/ssgi_resolve.rs b/native/shared/src/renderer/shaders/ssgi_resolve.rs index 86a5ff46..a53f8259 100644 --- a/native/shared/src/renderer/shaders/ssgi_resolve.rs +++ b/native/shared/src/renderer/shaders/ssgi_resolve.rs @@ -51,7 +51,8 @@ fn continuous_probe_coordinate(t: f32) -> f32 { return t * t * (3.0 - 2.0 * t); } -fn resolve_pixel(uv: vec2) -> vec4 { +fn resolve_pixel(output_uv: vec2) -> vec4 { + let uv = probe_depth_uv(output_uv, textureDimensions(hiz0)); let linear_z = textureSampleLevel(hiz0, hiz_samp, uv, 0.0).r; if (linear_z >= HIZ_SKY_Z * 0.5) { return vec4(0.0); @@ -76,10 +77,7 @@ fn resolve_pixel(uv: vec2) -> vec4 { let zu = textureSampleLevel(hiz0, hiz_samp, uv + vec2(0.0, -texel.y), 0.0).r; let Pr = view_pos_from_linear(uv + vec2(texel.x, 0.0), zr, p00, p11, p20, p21); let Pu = view_pos_from_linear(uv + vec2(0.0, -texel.y), zu, p00, p11, p20, p21); - let N_vs = safe_probe_direction( - cross(Pr - P_vs, Pu - P_vs), - vec3(0.0, 0.0, 1.0), - ); + let N_vs = probe_surface_normal(Pr - P_vs, Pu - P_vs); let N_ws = safe_probe_direction( (u.inv_view * vec4(N_vs, 0.0)).xyz, vec3(0.0, 1.0, 0.0), diff --git a/native/shared/src/renderer/ssgi_resolve_diagnostics.wgsl b/native/shared/src/renderer/ssgi_resolve_diagnostics.wgsl index 8ddd4d84..57e1127d 100644 --- a/native/shared/src/renderer/ssgi_resolve_diagnostics.wgsl +++ b/native/shared/src/renderer/ssgi_resolve_diagnostics.wgsl @@ -30,7 +30,10 @@ fn cs_resolve_support(@builtin(global_invocation_id) gid: vec3) { let coord = vec2(gid.xy); let half_w = f32(u.size.x); let half_h = f32(u.size.y); - let uv = (vec2(gid.xy) + vec2(0.5)) / vec2(half_w, half_h); + let uv = probe_depth_uv( + (vec2(gid.xy) + vec2(0.5)) / vec2(half_w, half_h), + textureDimensions(hiz0), + ); let hiz_dimensions = vec2(textureDimensions(hiz0)); let hiz_coord = clamp( vec2(uv * vec2(hiz_dimensions)), @@ -77,10 +80,7 @@ fn cs_resolve_support(@builtin(global_invocation_id) gid: vec3) { let zu = textureLoad(hiz0, up_coord, 0).r; let Pr = view_pos_from_linear(uv + vec2(texel.x, 0.0), zr, p00, p11, p20, p21); let Pu = view_pos_from_linear(uv + vec2(0.0, -texel.y), zu, p00, p11, p20, p21); - let N_vs = safe_probe_direction( - cross(Pr - P_vs, Pu - P_vs), - vec3(0.0, 0.0, 1.0), - ); + let N_vs = probe_surface_normal(Pr - P_vs, Pu - P_vs); let N_ws = safe_probe_direction( (u.inv_view * vec4(N_vs, 0.0)).xyz, vec3(0.0, 1.0, 0.0), diff --git a/native/shared/tests/golden_render.rs b/native/shared/tests/golden_render.rs index 98f5f132..59559c34 100644 --- a/native/shared/tests/golden_render.rs +++ b/native/shared/tests/golden_render.rs @@ -48,6 +48,8 @@ mod motion_producer_audit; mod profiler_integrity; #[path = "golden_render/quality_presets.rs"] mod quality_presets; +#[path = "golden_render/ssgi_surface.rs"] +mod ssgi_surface; #[path = "golden_render/temporal_history.rs"] mod temporal_history; #[path = "golden_render/transparency.rs"] diff --git a/native/shared/tests/golden_render/ssgi_quality.rs b/native/shared/tests/golden_render/ssgi_quality.rs index 3dd33d84..985af6d5 100644 --- a/native/shared/tests/golden_render/ssgi_quality.rs +++ b/native/shared/tests/golden_render/ssgi_quality.rs @@ -680,10 +680,22 @@ fn ssgi_hiz_immediate_scene_produces_finite_indirect_radiance() { #[test] fn ssgi_hiz_stationary_taa_bounds_subpixel_variation() { + assert_hiz_stationary_taa_bounds_subpixel_variation(false); +} + +#[test] +fn ssgi_hiz_hd_stationary_taa_bounds_subpixel_variation() { + assert_hiz_stationary_taa_bounds_subpixel_variation(true); +} + +fn assert_hiz_stationary_taa_bounds_subpixel_variation(hd: bool) { let Some(mut eng) = hiz_immediate_engine() else { eprintln!("skip: no GPU adapter"); return; }; + if hd { + eng.renderer.resize(1280, 720, 1280, 720); + } let capture = capture_hiz_immediate; // TAA jitters the primary projection even at a stationary camera. SSGI's @@ -696,9 +708,11 @@ fn ssgi_hiz_stationary_taa_bounds_subpixel_variation() { capture(&mut eng); } let taa_directory = - std::env::temp_dir().join(format!("bloom-ssgi-hiz-taa-{}", std::process::id())); - let taa_next_directory = - std::env::temp_dir().join(format!("bloom-ssgi-hiz-taa-next-{}", std::process::id())); + std::env::temp_dir().join(format!("bloom-ssgi-hiz-taa-{hd}-{}", std::process::id())); + let taa_next_directory = std::env::temp_dir().join(format!( + "bloom-ssgi-hiz-taa-next-{hd}-{}", + std::process::id() + )); let _ = std::fs::remove_dir_all(&taa_directory); let _ = std::fs::remove_dir_all(&taa_next_directory); eng.renderer.pending_quality_capture_dir = Some(taa_directory.to_string_lossy().into_owned()); diff --git a/native/shared/tests/golden_render/ssgi_surface.rs b/native/shared/tests/golden_render/ssgi_surface.rs new file mode 100644 index 00000000..dc26b197 --- /dev/null +++ b/native/shared/tests/golden_render/ssgi_surface.rs @@ -0,0 +1,157 @@ +//! Analytic surface reconstruction through the production probe placement pass. + +use super::*; +use wgpu::util::DeviceExt; + +#[test] +fn ssgi_probe_receivers_stay_on_their_plane_at_every_resolution() { + let Some(eng) = try_engine() else { + eprintln!("skip: no GPU adapter"); + return; + }; + let renderer = &eng.renderer; + let device = &renderer.device; + let queue = &renderer.queue; + let sampler = device.create_sampler(&wgpu::SamplerDescriptor::default()); + let mut checked = 0; + for (width, height) in [(128_u32, 128_u32), (640, 360), (641, 361), (1920, 1080)] { + // Analytic plane: dot((0, 0.6, 0.8), position) == -2, with unit + // projection diagonals and an identity view. Every depth sample is + // evaluated at its own texel center, independently of probe placement. + let depth: Vec = (0..width * height) + .map(|index| { + let ndc_y = 1.0 - 2.0 * ((index / width) as f32 + 0.5) / height as f32; + 2.0 / (0.8 - 0.6 * ndc_y) + }) + .collect(); + let extent = wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("analytic_probe_plane"), + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + texture.as_image_copy(), + bytemuck::cast_slice(&depth), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width * 4), + rows_per_image: Some(height), + }, + extent, + ); + let view = texture.create_view(&Default::default()); + let grid_w = width.div_ceil(8); + let grid_h = height.div_ceil(8); + let size = u64::from(grid_w * grid_h) * 112; + let probes = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("analytic_plane_probes"), + size, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("analytic_plane_readback"), + size, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + for phase in [0_f32, 5.0, 10.0, 15.0] { + // PlaceParams: inverse view, projection diagonals/offsets, extents, + // and phase/tile/moving. Four phases exercise fractional positions + // in both axes, including either side of a texel center. + let mut uniform = [0_u32; 28]; + for index in [0, 5, 10, 15, 16, 17, 26] { + uniform[index] = 1_f32.to_bits(); + } + uniform[20..24].copy_from_slice(&[width, height, grid_w, grid_h]); + uniform[24] = phase.to_bits(); + uniform[25] = 8_f32.to_bits(); + let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("analytic_plane_params"), + contents: bytemuck::cast_slice(&uniform), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bindings = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("analytic_plane_placement"), + layout: &renderer.probe_place_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: params.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: probes.as_entire_binding(), + }, + ], + }); + let mut encoder = device.create_command_encoder(&Default::default()); + { + let mut pass = encoder.begin_compute_pass(&Default::default()); + pass.set_pipeline(&renderer.probe_place_pipeline); + pass.set_bind_group(0, &bindings, &[]); + pass.dispatch_workgroups(grid_w.div_ceil(8), grid_h.div_ceil(8), 1); + } + encoder.copy_buffer_to_buffer(&probes, 0, &staging, 0, size); + queue.submit([encoder.finish()]); + let slice = staging.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |result| tx.send(result).unwrap()); + device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }) + .unwrap(); + rx.recv().unwrap().expect("analytic plane readback"); + let bytes = slice.get_mapped_range(); + let values: &[f32] = bytemuck::cast_slice(&bytes); + let mut max_plane_error = 0_f32; + let mut max_normal_error = 0_f32; + // Interior receivers have all three depth taps. Border taps can + // coincide after clamping and intentionally use a fallback normal. + for y in 1..grid_h - 1 { + for x in 1..grid_w - 1 { + let offset = ((y * grid_w + x) * 28) as usize; + let p = &values[offset..offset + 8]; + assert!(p.iter().all(|value| value.is_finite())); + assert_eq!(p[3], 1.0, "analytic plane receiver is invalid"); + max_plane_error = max_plane_error.max((0.6 * p[1] + 0.8 * p[2] + 2.0).abs()); + max_normal_error = max_normal_error + .max(p[4].abs().max((p[5] - 0.6).abs()).max((p[6] - 0.8).abs())); + checked += 1; + } + } + eprintln!("probe plane {width}x{height} phase={phase}: position_error={max_plane_error} normal_error={max_normal_error}"); + assert!( + max_plane_error <= 0.00001, + "probe receiver moved off its depth plane" + ); + assert!( + max_normal_error <= 0.0015, + "probe normal changed with pixel footprint" + ); + drop(bytes); + staging.unmap(); + } + } + eprintln!("analytic probe plane: {checked} receiver checks passed"); +}