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;