Skip to content
Closed
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
37 changes: 35 additions & 2 deletions native/shared/src/renderer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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];
}

Expand Down
12 changes: 11 additions & 1 deletion native/shared/src/renderer/shaders/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1026,8 +1026,18 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut {
let cap2_luma = dot(ibl_spec_raw, vec3<f32>(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-
Expand Down
60 changes: 48 additions & 12 deletions native/shared/src/renderer/shaders/ssgi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,13 @@ struct SsrParams {
/// z = number of march steps
/// w = frame index (Hammersley rotation + march jitter)
params: vec4<f32>,
/// 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<f32>,
/// EN-021 — x = env max LOD (matches the material path's
/// roughness×6 mip ramp), y = env intensity, zw unused.
params2: vec4<f32>,
};

@group(0) @binding(0) var<uniform> u: SsrParams;
Expand All @@ -1341,6 +1348,8 @@ struct SsrParams {
@group(0) @binding(6) var mat_samp: sampler;
@group(0) @binding(7) var albedo_tex: texture_2d<f32>;
@group(0) @binding(8) var albedo_samp: sampler;
@group(0) @binding(9) var env_tex: texture_2d<f32>;
@group(0) @binding(10) var env_samp: sampler;

const PI: f32 = 3.14159265;

Expand All @@ -1365,6 +1374,22 @@ fn view_pos_from_depth(uv: vec2<f32>, depth: f32) -> vec3<f32> {
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<f32>, roughness: f32) -> vec3<f32> {
let d = normalize((u.inv_view_rot * vec4<f32>(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<f32>(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.
Expand Down Expand Up @@ -1405,15 +1430,16 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
if (depth >= 0.9999) { return vec4<f32>(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;
Expand Down Expand Up @@ -1444,12 +1470,17 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let h = importance_sample_ggx(xi, n, roughness);
let r = reflect(-v, h);

if (r.z > 0.0) { return vec4<f32>(0.0); }

let n_dot_v = max(dot(n, v), 0.0);
let f0 = mix(vec3<f32>(0.04), albedo, metallic);
let fresnel = f0 + (vec3<f32>(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<f32>(0.0), fb, fb == fb);
return vec4<f32>(fb_safe, 0.0);
}

let max_dist = u.params.y;
let n_steps_f = u.params.z;
let n_steps = u32(n_steps_f);
Expand Down Expand Up @@ -1492,7 +1523,12 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
prev_t = t;
t = t + step_size;
}
if (!hit_found) { return vec4<f32>(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<f32>(0.0), fb, fb == fb);
return vec4<f32>(fb_safe, 0.0);
}

let edge_fade = min(
min(hit_uv.x, 1.0 - hit_uv.x),
Expand Down
29 changes: 29 additions & 0 deletions native/shared/src/renderer/ssr_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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) },
],
}));
}
Expand Down