Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions docs/evidence/windows-ssgi-surface-v1.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 29 additions & 9 deletions docs/windows-engine-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
35 changes: 28 additions & 7 deletions native/shared/src/renderer/shaders/ssgi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,28 @@ fn bounded_probe_history(value: vec3<f32>) -> vec3<f32> {
);
}

fn probe_surface_normal(right: vec3<f32>, up: vec3<f32>) -> vec3<f32> {
// 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<f32>(0.0, 0.0, 1.0);
}
return safe_probe_direction(
cross(right / right_scale, up / up_scale),
vec3<f32>(0.0, 0.0, 1.0),
);
}

fn probe_depth_uv(uv: vec2<f32>, size: vec2<u32>) -> vec2<f32> {
// 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<f32>(size);
let pixel = clamp(floor(uv * dimensions), vec2<f32>(0.0), dimensions - 1.0);
return (pixel + 0.5) / dimensions;
}

fn safe_probe_direction(value: vec3<f32>, fallback: vec3<f32>) -> vec3<f32> {
let clean = bounded_probe_history(value);
let len2 = dot(clean, clean);
Expand Down Expand Up @@ -306,7 +328,9 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3<u32>) {
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<f32>(px_x / half_w, px_y / half_h);
let uv = probe_depth_uv(
vec2<f32>(px_x / half_w, px_y / half_h), textureDimensions(hiz0),
);

let linear_z = textureSampleLevel(hiz0, hiz_samp, uv, 0.0).r;

Expand All @@ -327,16 +351,13 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3<u32>) {
// texel to the right and one up. Uses the same Hi-Z mip 0 the
// center tap read from.
let texel = vec2<f32>(1.0 / half_w, 1.0 / half_h);
let uv_r = uv + vec2<f32>(texel.x, 0.0);
let uv_u = uv + vec2<f32>(0.0, -texel.y);
let uv_r = probe_depth_uv(uv + vec2<f32>(texel.x, 0.0), textureDimensions(hiz0));
let uv_u = probe_depth_uv(uv + vec2<f32>(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<f32>(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<f32>(P, 1.0)).xyz;
let N_world = safe_probe_direction(
Expand Down
8 changes: 3 additions & 5 deletions native/shared/src/renderer/shaders/ssgi_resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ fn continuous_probe_coordinate(t: f32) -> f32 {
return t * t * (3.0 - 2.0 * t);
}

fn resolve_pixel(uv: vec2<f32>) -> vec4<f32> {
fn resolve_pixel(output_uv: vec2<f32>) -> vec4<f32> {
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<f32>(0.0);
Expand All @@ -76,10 +77,7 @@ fn resolve_pixel(uv: vec2<f32>) -> vec4<f32> {
let zu = textureSampleLevel(hiz0, hiz_samp, uv + vec2<f32>(0.0, -texel.y), 0.0).r;
let Pr = view_pos_from_linear(uv + vec2<f32>(texel.x, 0.0), zr, p00, p11, p20, p21);
let Pu = view_pos_from_linear(uv + vec2<f32>(0.0, -texel.y), zu, p00, p11, p20, p21);
let N_vs = safe_probe_direction(
cross(Pr - P_vs, Pu - P_vs),
vec3<f32>(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<f32>(N_vs, 0.0)).xyz,
vec3<f32>(0.0, 1.0, 0.0),
Expand Down
10 changes: 5 additions & 5 deletions native/shared/src/renderer/ssgi_resolve_diagnostics.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ fn cs_resolve_support(@builtin(global_invocation_id) gid: vec3<u32>) {
let coord = vec2<i32>(gid.xy);
let half_w = f32(u.size.x);
let half_h = f32(u.size.y);
let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(half_w, half_h);
let uv = probe_depth_uv(
(vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(half_w, half_h),
textureDimensions(hiz0),
);
let hiz_dimensions = vec2<i32>(textureDimensions(hiz0));
let hiz_coord = clamp(
vec2<i32>(uv * vec2<f32>(hiz_dimensions)),
Expand Down Expand Up @@ -77,10 +80,7 @@ fn cs_resolve_support(@builtin(global_invocation_id) gid: vec3<u32>) {
let zu = textureLoad(hiz0, up_coord, 0).r;
let Pr = view_pos_from_linear(uv + vec2<f32>(texel.x, 0.0), zr, p00, p11, p20, p21);
let Pu = view_pos_from_linear(uv + vec2<f32>(0.0, -texel.y), zu, p00, p11, p20, p21);
let N_vs = safe_probe_direction(
cross(Pr - P_vs, Pu - P_vs),
vec3<f32>(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<f32>(N_vs, 0.0)).xyz,
vec3<f32>(0.0, 1.0, 0.0),
Expand Down
2 changes: 2 additions & 0 deletions native/shared/tests/golden_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
20 changes: 17 additions & 3 deletions native/shared/tests/golden_render/ssgi_quality.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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());
Expand Down
Loading
Loading