From 944445cdbf179b5ea0b01841c3763b951f4b5b58 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 16:58:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(gi):=20EN-023=20groundwork=20=E2=80=94=20S?= =?UTF-8?q?DF=20card=20lookup=20fixed,=20WSRC=20ground=20bounce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 audit F4: on non-RT adapters (the dev 760M) SSGI's SDF path degraded every transformed instance to flat gray and the SW radiance cache was light-source-only — colored bounce structurally impossible. Fixed in this PR: - instance data now carries WORLD-space AABBs alongside the object- space ones (all four shader layout mirrors updated). The SDF broad- phase compares its world-space clipmap hits against the world boxes — the old object-space comparison only ever matched assets whose vertices were already in world space (Sponza). HW paths keep using the object-space boxes with hit.world_to_object, unchanged. - broad-phase picks the SMALLEST containing box, not the first: the shooter's +/-140 m terrain proxy otherwise swallowed every hit (walls and trees included) and its mostly-empty side cards actively darkened the bounce. - the SW WSRC bake gains a ground-bounce term for below-horizon octels (scene-average instance albedo x sun-shadowed irradiance + half sky) — it previously returned ~black for every downward miss ray, dropping the strongest real bounce source entirely. Measured honesty (GI pose A/B, sky-normalized, settled): - ship intensity 1.0: deltas within noise (<=1%), hue unchanged - intensity 4.0: +1.4-2.1% luma in shaded receivers, hue still unchanged (G/R 0.9456 vs 0.9460) The data path is now correct, but visible colored bounce on the SW tier remains capped downstream (probe resolve magnitude, composite AO multiply on exactly the shaded receivers, radius/hit-rate at the receivers). EN-023 stays open pointing at the resolve/integration stage; decision D2-B (bank the ~1.6 ms on iGPU) remains sensible until that lands. Suite green (99 lib + 7 golden). Boot-smoked at 4K; A/B runs above were on the live game. --- native/shared/src/renderer/mod.rs | 52 +++++++++++++++++++++- native/shared/src/renderer/shaders/gi.rs | 20 ++++++++- native/shared/src/renderer/shaders/ssgi.rs | 44 +++++++++++++----- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index ad66ec3..0b7ee02 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -467,6 +467,10 @@ struct WsrcBakeParams { shadow_splits: [f32; 4], /// x = shadow bias, y = shadows_enabled (0/1), zw unused. flags: [f32; 4], + /// EN-023 — xyz = scene-average albedo (mean of card-instance flat + /// albedos) for the SW bake's ground-bounce term; w unused. The HW + /// bake ignores it (it traces real geometry). + ground_albedo: [f32; 4], } /// Uniform struct for the card-lighting compute pass. Matches @@ -532,10 +536,20 @@ struct InstanceGiDataCpu { /// `card_slot.w` = flag (1.0 = card captured, 0.0 = no card → fall /// back to `albedo` flat value). card_slot: [f32; 4], - /// Object-space AABB min (xyz) + unused pad (w). + /// Object-space AABB min (xyz) + unused pad (w). The HW paths + /// transform hits into object space (hit.world_to_object) and + /// compare against THESE — do not world-ify them. card_aabb_min: [f32; 4], /// Object-space AABB max (xyz) + unused pad (w). card_aabb_max: [f32; 4], + /// EN-023 — WORLD-space AABB min/max. The SDF trace has no + /// world_to_object (it marches a world-space clipmap), so its + /// broad-phase compares the world hit against these. With the old + /// object-space-only bounds, every transformed instance fell + /// through to the flat-gray analytic fallback — zero colored + /// bounce on non-RT adapters (round-2 audit F4). + world_aabb_min: [f32; 4], + world_aabb_max: [f32; 4], } #[repr(C)] @@ -979,6 +993,10 @@ pub struct Renderer { pub ssgi_radius: f32, /// SSGI master switch. pub ssgi_enabled: bool, + /// EN-023 — mean flat albedo over the card instances; feeds the SW + /// WSRC bake's ground-bounce term. Neutral mid-gray until the first + /// instance-data upload computes the real scene average. + pub gi_scene_avg_albedo: [f32; 3], /// One-shot log guard: which SSGI trace backend was last reported to /// stderr (hw-ray-query / sdf-clipmap / hiz-screen). The silent HW→SW /// fallback made "why is bounce gray?" a debugger question during the @@ -6028,6 +6046,7 @@ impl Renderer { ssgi_radius: 20.0, ssgi_enabled: true, ssgi_backend_logged: None, + gi_scene_avg_albedo: [0.35, 0.35, 0.35], probe_grid_w, probe_grid_h, probe_header_buffer, @@ -7190,6 +7209,12 @@ impl Renderer { c as f32, 0.0, ], + ground_albedo: [ + self.gi_scene_avg_albedo[0], + self.gi_scene_avg_albedo[1], + self.gi_scene_avg_albedo[2], + 0.0, + ], }; self.queue.write_buffer( &self.wsrc_bake_uniform, @@ -7527,6 +7552,9 @@ impl Renderer { let mut instance_data: Vec = Vec::with_capacity(instance_count as usize); + // EN-023 — running mean of instance albedos feeds the SW WSRC + // bake's ground-bounce term. + let mut albedo_sum = [0.0f32; 3]; for &h in instance_handles { let n = scene.nodes.get(h).unwrap(); let e = n.material.emissive; @@ -7534,6 +7562,18 @@ impl Renderer { Some(s) => (s as f32, 1.0_f32), None => (0.0, 0.0), }; + // EN-023 — world AABB for the SDF broad-phase. The scene's + // bounds pass keeps world_bounds fresh; the sentinel + // (min.x > max.x = not yet computed) falls back to the local + // box, which matches the old behaviour for identity nodes. + let (wmin, wmax) = if n.world_bounds_min[0] <= n.world_bounds_max[0] { + (n.world_bounds_min, n.world_bounds_max) + } else { + (n.bounds_min, n.bounds_max) + }; + albedo_sum[0] += n.flat_albedo[0]; + albedo_sum[1] += n.flat_albedo[1]; + albedo_sum[2] += n.flat_albedo[2]; instance_data.push(InstanceGiDataCpu { albedo: n.flat_albedo, emissive_luma: (e[0] + e[1] + e[2]) * (1.0 / 3.0), @@ -7542,8 +7582,18 @@ impl Renderer { card_slot: [first_slot, 0.0, 0.0, has_card], card_aabb_min: [n.bounds_min[0], n.bounds_min[1], n.bounds_min[2], 0.0], card_aabb_max: [n.bounds_max[0], n.bounds_max[1], n.bounds_max[2], 0.0], + world_aabb_min: [wmin[0], wmin[1], wmin[2], 0.0], + world_aabb_max: [wmax[0], wmax[1], wmax[2], 0.0], }); } + if !instance_data.is_empty() { + let inv = 1.0 / instance_data.len() as f32; + self.gi_scene_avg_albedo = [ + albedo_sum[0] * inv, + albedo_sum[1] * inv, + albedo_sum[2] * inv, + ]; + } if !instance_data.is_empty() { self.queue.write_buffer( self.tlas_instance_data_buffer.as_ref().unwrap(), diff --git a/native/shared/src/renderer/shaders/gi.rs b/native/shared/src/renderer/shaders/gi.rs index 4630d64..0a2aa3d 100644 --- a/native/shared/src/renderer/shaders/gi.rs +++ b/native/shared/src/renderer/shaders/gi.rs @@ -340,6 +340,8 @@ struct WsrcBakeParams { // output z-slice so this pipeline can be dispatched once per // cascade with the same layout. flags: vec4, + // EN-023 — xyz = scene-average albedo for the ground-bounce term. + ground_albedo: vec4, }; @group(0) @binding(0) var u: WsrcBakeParams; @@ -441,7 +443,17 @@ fn cs_main( let up = clamp(dir.y * 0.5 + 0.5, 0.0, 1.0); let sky = u.sky_color.xyz * up * up; - let radiance = sun + sky; + // EN-023 — ground bounce for below-horizon octels. This envelope + // was light-source-only: any miss ray pointing downward returned + // ~black, so shaded receivers lost the strongest real bounce + // source (sunlit ground). Approximate it as the scene-average + // albedo lit by the sun (shadowed at the probe) + half the sky. + let down = clamp(-dir.y, 0.0, 1.0); + let ground_irr = u.sun_color.xyz * max(u.sun_dir.y, 0.0) * shadow + + u.sky_color.xyz * 0.5; + let ground = u.ground_albedo.xyz * ground_irr * down * down; + + let radiance = sun + sky + ground; // V13 — cascade index in flags.z offsets the output z-slice. let cascade_idx = u32(u.flags.z); @@ -485,6 +497,9 @@ struct WsrcBakeParams { shadow_vps: array, 3>, shadow_splits: vec4, flags: vec4, + // EN-023 — layout mirror; the HW bake traces real geometry and + // ignores the scene-average ground albedo. + ground_albedo: vec4, }; struct HwBakeInstanceGiData { @@ -495,6 +510,9 @@ struct HwBakeInstanceGiData { card_slot: vec4, card_aabb_min: vec4, card_aabb_max: vec4, + // EN-023 — world-space AABB (SDF path only; layout mirror). + world_aabb_min: vec4, + world_aabb_max: vec4, }; const HW_BAKE_CARD_SLOTS_PER_ROW: f32 = 64.0; diff --git a/native/shared/src/renderer/shaders/ssgi.rs b/native/shared/src/renderer/shaders/ssgi.rs index 14475ef..714fe22 100644 --- a/native/shared/src/renderer/shaders/ssgi.rs +++ b/native/shared/src/renderer/shaders/ssgi.rs @@ -402,6 +402,9 @@ struct InstanceGiData { // Object-space AABB min (xyz) / max (xyz). card_aabb_min: vec4, card_aabb_max: vec4, + // EN-023 — world-space AABB (SDF path only; layout mirror). + world_aabb_min: vec4, + world_aabb_max: vec4, }; const CARD_SLOTS_PER_ROW: f32 = 64.0; @@ -715,6 +718,12 @@ struct SdfInstanceGiData { card_slot: vec4, card_aabb_min: vec4, card_aabb_max: vec4, + // EN-023 — world-space AABB. This trace marches a WORLD-space + // clipmap and has no world_to_object; comparing world hits against + // the object-space box above only ever worked for identity- + // transform assets (Sponza). + world_aabb_min: vec4, + world_aabb_max: vec4, }; const SDF_CARD_SLOTS_PER_ROW: f32 = 64.0; @@ -913,16 +922,29 @@ fn cs_main( // geometry, etc.). let count = arrayLength(&instance_data); var picked: i32 = -1; + var picked_vol: f32 = 1e30; for (var i: u32 = 0u; i < count; i = i + 1u) { let ad = instance_data[i]; if (ad.card_slot.w < 0.5) { continue; } - let bmin = ad.card_aabb_min.xyz - vec3(0.05); - let bmax = ad.card_aabb_max.xyz + vec3(0.05); + // EN-023 — compare the WORLD hit against the WORLD AABB. + // The old object-space comparison only matched assets whose + // vertices were already in world space; every transformed + // instance fell through to the gray analytic fallback. + // Pick the SMALLEST containing box, not the first: a scene- + // spanning instance (the shooter's ±140 m terrain proxy) + // otherwise swallows every hit — walls and trees included — + // and its mostly-empty side cards darken the bounce. + let bmin = ad.world_aabb_min.xyz - vec3(0.05); + let bmax = ad.world_aabb_max.xyz + vec3(0.05); if (hit_pos.x >= bmin.x && hit_pos.x <= bmax.x && hit_pos.y >= bmin.y && hit_pos.y <= bmax.y && hit_pos.z >= bmin.z && hit_pos.z <= bmax.z) { - picked = i32(i); - break; + let ext = bmax - bmin; + let vol = ext.x * ext.y * ext.z; + if (vol < picked_vol) { + picked = i32(i); + picked_vol = vol; + } } } @@ -947,13 +969,13 @@ fn cs_main( let slot_x = slot % 64u; let slot_y = slot / 64u; - let bmin = ad.card_aabb_min.xyz; - let bmax = ad.card_aabb_max.xyz; - // Hit is in world space and Sponza meshes are in world - // space too (no per-instance transform beyond identity on - // the Sponza asset). For now use world-space hit directly; - // instances with a non-identity transform would need the - // transform stored on `instance_data` to round-trip. + // EN-023 — project against the WORLD AABB, consistent with + // the world-space hit. Exact for the translate+scale (yaw-0) + // instances the GI proxies use; a yaw-rotated instance would + // sample its card with rotated UVs — hue still right, which + // is what the probe integral actually consumes at 64² cards. + let bmin = ad.world_aabb_min.xyz; + let bmax = ad.world_aabb_max.xyz; var u_os: f32; var v_os: f32; var u_lo: f32; var u_hi: f32;