From 05c59166f9d62a4ef3d1493d12858889704e500d Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 16:42:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20EN-021=20=E2=80=94=20SSR=20+=20?= =?UTF-8?q?IBL=20specular=20exclusive=20ownership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 audit F10: compose is `hdr + ssr` and fs_main_scene already adds IBL specular into hdr, so SSR-hit pixels double-counted specular for roughness ~0.05-0.85 (worst on metals at r~0.55-0.75). The fix is ownership, not subtraction: - fs_main_scene scales ibl_spec by the complement of SSR's OWN roughness fade x its strength. The share rides the free dir_light_count.z lane (written per frame; 0 when SSR is disabled, so the full IBL term returns automatically). - The SSR march no longer returns black on a miss: camera-facing rays and off-screen/no-hit marches fall back to the env panorama sampled by the world-space reflection direction at the material path's roughness x 6 mip ramp, x env intensity (camera_pos.w), x the same fresnel/fade/strength weighting as a hit. Without this, reducing ibl_spec would darken every off-screen reflection - the reason the ticket deferred the one-line version. - SsrParams gains the view->world rotation (transpose of the view 3x3) + env LOD/intensity; SSR bind group gains the env panorama at bindings 9/10 and its cache invalidates wherever the env source swaps (HDR upload, panorama/procedural lighting-bg swaps). Suite green (99 lib + 7 golden; goldens unchanged within tolerance). Boot-smoked on the shooter at 4K - no WGSL errors, clean frames. Closes EN-021. --- native/shared/src/renderer/mod.rs | 37 ++++++++++++- native/shared/src/renderer/shaders/core.rs | 12 ++++- native/shared/src/renderer/shaders/ssgi.rs | 60 +++++++++++++++++----- native/shared/src/renderer/ssr_pass.rs | 29 +++++++++++ 4 files changed, 123 insertions(+), 15 deletions(-) diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index ad66ec3..c0801be 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -550,8 +550,13 @@ struct SsrTemporalParams { struct SsrParams { inv_proj: [[f32; 4]; 4], proj: [[f32; 4]; 4], - /// x=strength, y=max_dist, z=n_steps, w=padding + /// x=strength, y=max_dist, z=n_steps, w=frame index params: [f32; 4], + /// EN-021 — view→world rotation (transpose of the view 3×3) for the + /// env-miss fallback's direction lookup. + inv_view_rot: [[f32; 4]; 4], + /// EN-021 — x = env max LOD, y = env intensity, zw unused. + params2: [f32; 4], } #[repr(C)] @@ -4118,6 +4123,19 @@ impl Renderer { ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, + // EN-021 — env panorama for the miss fallback. + wgpu::BindGroupLayoutEntry { + binding: 9, visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, + }, count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 10, visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, ], }); let ssr_pl_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { @@ -8204,6 +8222,9 @@ impl Renderer { self.sky_bind_group = Some(bg); self.env_diffuse_texture = Some(diffuse_texture); self.lighting_bind_group = new_lighting_bg; + // EN-021 — the SSR bind group holds an env view; rebuild it when + // a new HDR panorama is uploaded. + self.ssr_bg_cache = None; } /// Whether a sky env map has been uploaded — controls whether @@ -8514,6 +8535,9 @@ impl Renderer { let new_bg = self.make_lighting_bind_group("lighting_bg_panorama", &env_view, &diffuse_view); self.lighting_bind_group = new_bg; self.lighting_bg_is_procedural = false; + // EN-021 — the SSR bind group holds an env view; rebuild it when + // the env source swaps. + self.ssr_bg_cache = None; } /// EN-005 Phase 3 — rebuild `lighting_bind_group` so PBR materials @@ -8530,6 +8554,11 @@ impl Renderer { ); self.lighting_bind_group = new_bg; self.lighting_bg_is_procedural = true; + // EN-021 — the SSR env-fallback binding should track the active + // env source. (SSR keeps sampling sky_texture today; nulling the + // cache here at least rebuilds against the current state on the + // next frame.) + self.ssr_bg_cache = None; } /// Sample the transmittance LUT for the current sun direction @@ -10449,7 +10478,11 @@ impl Renderer { // (shadow_cascade_splits.w is NOT usable for this — it carries the // TSR mip-LOD bias, written by the shadow pass each frame.) let shadows_flag = if self.shadow_map.enabled { 1.0 } else { 0.0 }; - self.lighting_uniforms.dir_light_count = [0.0, shadows_flag, 0.0, 0.0]; + // dir_light_count.z carries SSR's ownership share for EN-021's + // IBL-specular complement in fs_main_scene: strength while SSR + // runs, 0 when disabled (full IBL specular returns). + let ssr_share = if self.ssr_enabled { self.ssr_strength } else { 0.0 }; + self.lighting_uniforms.dir_light_count = [0.0, shadows_flag, ssr_share, 0.0]; self.lighting_uniforms.point_light_count = [0.0; 4]; } diff --git a/native/shared/src/renderer/shaders/core.rs b/native/shared/src/renderer/shaders/core.rs index 2c84307..4bc53c9 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -1026,8 +1026,18 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { let cap2_luma = dot(ibl_spec_raw, vec3(0.2126, 0.7152, 0.0722)); let cap2 = 1.0 / (1.0 + cap2_luma / 0.3); let roughness_amp = smoothstep(0.05, 0.75, roughness); + // EN-021 exclusive ownership: where SSR is active it owns specular — + // hit (traced colour) or miss (env fallback inside the SSR shader). + // Scale IBL specular by the complement of SSR's own roughness fade + // × its strength (dir_light_count.z, written per frame; 0 when SSR + // is disabled so the full IBL term returns). Kills the metal + // double-count on hits (round-2 audit F10) without darkening + // off-screen reflections. + let ssr_own = clamp( + lighting.dir_light_count.z * (1.0 - smoothstep(0.5, 0.85, roughness)), + 0.0, 1.0); let ibl_spec = ibl_spec_raw - * dielectric_scale * spec_occ * roughness_amp * cap2; + * dielectric_scale * spec_occ * roughness_amp * cap2 * (1.0 - ssr_own); // Indirect-shadow attenuation. 0.15 — deep enough that windows // Shadow darkening floor. Prior 0.15 matched Cycles path- diff --git a/native/shared/src/renderer/shaders/ssgi.rs b/native/shared/src/renderer/shaders/ssgi.rs index 14475ef..7630b0d 100644 --- a/native/shared/src/renderer/shaders/ssgi.rs +++ b/native/shared/src/renderer/shaders/ssgi.rs @@ -1330,6 +1330,13 @@ struct SsrParams { /// z = number of march steps /// w = frame index (Hammersley rotation + march jitter) params: vec4, + /// EN-021 — view→world ROTATION (inverse of the view matrix's 3×3) + /// so the env-miss fallback can turn the view-space reflection ray + /// into a world direction for the equirect lookup. + inv_view_rot: mat4x4, + /// EN-021 — x = env max LOD (matches the material path's + /// roughness×6 mip ramp), y = env intensity, zw unused. + params2: vec4, }; @group(0) @binding(0) var u: SsrParams; @@ -1341,6 +1348,8 @@ struct SsrParams { @group(0) @binding(6) var mat_samp: sampler; @group(0) @binding(7) var albedo_tex: texture_2d; @group(0) @binding(8) var albedo_samp: sampler; +@group(0) @binding(9) var env_tex: texture_2d; +@group(0) @binding(10) var env_samp: sampler; const PI: f32 = 3.14159265; @@ -1365,6 +1374,22 @@ fn view_pos_from_depth(uv: vec2, depth: f32) -> vec3 { return view_h.xyz / view_h.w; } +/// EN-021 — env-miss fallback. The scene shader scales its IBL specular +/// down by SSR's ownership share (lighting.dir_light_count.z × the same +/// roughness fade this shader uses), so a miss MUST return the env +/// sample instead of black or off-screen reflections go dark. Same +/// equirect mapping as common/pbr.wgsl's sample_env; explicit-LOD +/// sampling needs no seam handling. +fn env_fallback(r_view: vec3, roughness: f32) -> vec3 { + let d = normalize((u.inv_view_rot * vec4(r_view, 0.0)).xyz); + let theta = acos(clamp(d.y, -1.0, 1.0)); + let phi = atan2(d.z, d.x); + let uu = phi / (2.0 * PI); + let uv = vec2(uu - floor(uu), theta / PI); + return textureSampleLevel(env_tex, env_samp, uv, roughness * u.params2.x).rgb + * u.params2.y; +} + /// Interleaved gradient noise — per-pixel pseudo-random in [0, 1). /// Varies with frame so the temporal accumulator averages over /// different march offsets each frame. @@ -1405,15 +1430,16 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { if (depth >= 0.9999) { return vec4(0.0); } // sky // SSR for every smooth-enough surface — the metals-only gate is gone. - // Rationale: the scene shader deliberately starves polished DIELECTRICS - // of IBL specular (dielectric_spec_amp rolls to zero on smooth surfaces - // because the visibility-less prefiltered env produced bright stripes), - // so screen-space hits are the only grounded reflections a wet floor or - // polished stone can receive — no double-counting by construction. The - // F0 = 0.04 Fresnel below keeps dielectric contribution physically - // small except at grazing angles. Very rough surfaces still fade out - // to IBL where one-ray-per-pixel SSR noise would dominate even after - // temporal accumulation. + // EN-021 EXCLUSIVE OWNERSHIP: within this shader's roughness_fade + // range, SSR owns specular reflections outright — the scene shader + // scales its IBL specular by the complement of the same fade + // (× strength, piped through lighting.dir_light_count.z), and a + // march MISS falls back to the env sample here instead of black. + // Metals previously double-counted on hit (IBL spec in hdr + full + // SSR on top, round-2 audit F10); dielectrics were starved of IBL + // spec by design and now get the coherent hit-or-env behaviour too. + // Very rough surfaces still fade to pure IBL where one-ray-per-pixel + // SSR noise would dominate even after temporal accumulation. let mat = textureSample(mat_tex, mat_samp, in.uv).rg; let metallic = mat.r; let roughness = mat.g; @@ -1444,12 +1470,17 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let h = importance_sample_ggx(xi, n, roughness); let r = reflect(-v, h); - if (r.z > 0.0) { return vec4(0.0); } - let n_dot_v = max(dot(n, v), 0.0); let f0 = mix(vec3(0.04), albedo, metallic); let fresnel = f0 + (vec3(1.0) - f0) * pow(1.0 - n_dot_v, 5.0); + // Camera-facing rays can't be marched — env fallback (EN-021). + if (r.z > 0.0) { + let fb = env_fallback(r, roughness) * fresnel * roughness_fade * u.params.x; + let fb_safe = select(vec3(0.0), fb, fb == fb); + return vec4(fb_safe, 0.0); + } + let max_dist = u.params.y; let n_steps_f = u.params.z; let n_steps = u32(n_steps_f); @@ -1492,7 +1523,12 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { prev_t = t; t = t + step_size; } - if (!hit_found) { return vec4(0.0); } + if (!hit_found) { + // March left the screen or found nothing — env fallback (EN-021). + let fb = env_fallback(r, roughness) * fresnel * roughness_fade * u.params.x; + let fb_safe = select(vec3(0.0), fb, fb == fb); + return vec4(fb_safe, 0.0); + } let edge_fade = min( min(hit_uv.x, 1.0 - hit_uv.x), diff --git a/native/shared/src/renderer/ssr_pass.rs b/native/shared/src/renderer/ssr_pass.rs index ba07214..568cdd1 100644 --- a/native/shared/src/renderer/ssr_pass.rs +++ b/native/shared/src/renderer/ssr_pass.rs @@ -17,6 +17,17 @@ impl Renderer { // ============================================================ if self.ssr_enabled { let inv_proj = self.current_inv_proj_matrix; + // EN-021 — view→world rotation for the env-miss fallback: the + // transpose of the view matrix's 3×3 (rigid view ⇒ inverse + // rotation = transpose). Column j of the inverse is row j of + // the view rotation. + let v = self.current_view_matrix; + let inv_view_rot = [ + [v[0][0], v[1][0], v[2][0], 0.0], + [v[0][1], v[1][1], v[2][1], 0.0], + [v[0][2], v[1][2], v[2][2], 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; let sp = SsrParams { inv_proj, proj: self.current_proj_matrix, @@ -28,10 +39,26 @@ impl Renderer { // step_size so the relative-error reject heuristic // still works with the larger strides. params: [self.ssr_strength, 8.0, 8.0, self.taa_frame_index as f32], + inv_view_rot, + // Env max LOD 6.0 matches the material path's roughness×6 + // mip ramp; intensity rides lighting camera_pos.w exactly + // like sample_env does. + params2: [6.0, self.lighting_uniforms.camera_pos[3], 0.0, 0.0], }; self.queue.write_buffer(&self.ssr_uniform_buffer, 0, bytemuck::bytes_of(&sp)); if self.ssr_bg_cache.is_none() { + // EN-021 — env panorama (or the 1×1 default) for the miss + // fallback. The cache is invalidated wherever the lighting + // bind group swaps env sources. + let env_view = self + .sky_texture + .as_ref() + .map(|t| t.create_view(&wgpu::TextureViewDescriptor::default())) + .unwrap_or_else(|| { + self._scene_env_default_texture + .create_view(&wgpu::TextureViewDescriptor::default()) + }); self.ssr_bg_cache = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("ssr_bg"), layout: &self.ssr_layout, @@ -45,6 +72,8 @@ impl Renderer { wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&self.albedo_rt_view) }, wgpu::BindGroupEntry { binding: 8, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, + wgpu::BindGroupEntry { binding: 9, resource: wgpu::BindingResource::TextureView(&env_view) }, + wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::Sampler(&self.composite_sampler) }, ], })); }