diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index f754af7..84445a0 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -885,8 +885,10 @@ pub struct Renderer { pub ssao_blur_uniform_buffer: wgpu::Buffer, /// Strength multiplier for SSAO (0 = off, 1 = full). Default 1.0. pub ssao_strength: f32, - /// Sample radius in UV units (default ~0.005, gives a soft AO - /// signal a few pixels wide on a 1024-tall surface). + /// Sample radius in WORLD-SPACE METERS (default 2.0; the shader + /// projects it to a screen radius per pixel via depth and clamps the + /// result — see `clamped_radius` in ao.rs). NOTE: earlier docs called + /// this "UV units ~0.005" — stale from before the world-space refactor. pub ssao_radius: f32, /// Skip the SSAO + bilateral-blur passes entirely when false; the /// blur RT gets a WHITE clear (no occlusion) so composite stays @@ -6122,7 +6124,10 @@ impl Renderer { ssao_blur_pipeline, ssao_blur_layout, ssao_blur_uniform_buffer, - ssao_strength: 1.0, + // 0.7, not full 1.0: with the screen-radius now capped to a + // local contact term (see ao.rs), full strength read as harsh + // near-black creases. 0.7 keeps AO as a grounding cue. + ssao_strength: 0.7, ssao_enabled: true, bloom_enabled: true, ssao_bg_cache: [None, None], @@ -6706,9 +6711,10 @@ impl Renderer { self.ssao_strength = strength.max(0.0); } - /// SSAO sample radius in UV units. 0.006 (~0.6% of viewport - /// height) is the default — wider radius catches larger AO - /// features but also blurs detail and increases halo risk. + /// SSAO sample radius in WORLD-SPACE METERS (default 2.0). The shader + /// converts it to a per-pixel screen radius (scaled by depth) and caps + /// that at 0.05 of the viewport, so a large world radius still stays a + /// local contact term rather than a scene-wide dimming wash. pub fn set_ssao_radius(&mut self, radius: f32) { self.ssao_radius = radius.max(0.0001); } diff --git a/native/shared/src/renderer/shaders/ao.rs b/native/shared/src/renderer/shaders/ao.rs index 9b43e4a..747c8a2 100644 --- a/native/shared/src/renderer/shaders/ao.rs +++ b/native/shared/src/renderer/shaders/ao.rs @@ -153,6 +153,14 @@ const N_PHASES: u32 = 4u; const N_STEPS: u32 = 8u; const PI: f32 = 3.14159265; const HIZ_MAX_MIP: i32 = 4; +// Screen-space contact shadows (the SSAO 'G' channel). Disabled: this +// 12-step march is half-res, unblurred, and — unlike the horizon AO — has +// NO temporal accumulation, so over the dense wind-animated grass it hits +// swaying blades a little differently every frame, producing a sharp, +// pixelated, dark flickering layer. It is also independent of ssao_radius / +// ssao_strength, which is why tuning those changed nothing. Off until it can +// be gated to exclude foliage. Flip to true to restore. +const CONTACT_SHADOWS_ENABLED: bool = false; fn hiz_sample(uv: vec2, mip: i32) -> f32 { @@ -220,7 +228,13 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { let proj_scale_x = abs(u.proj_row01.x); let proj_scale_y = abs(u.proj_row01.y); let screen_radius = radius_ws * 0.5 * (proj_scale_x + proj_scale_y) / abs(P.z); - let clamped_radius = clamp(screen_radius, 2.0 * max(inv_sz.x, inv_sz.y), 0.25); + // Cap the screen-space radius. The old 0.25 ceiling let a near-field + // pixel gather horizon occlusion across a QUARTER of the screen — over + // dense instanced grass that pulls thousands of blades into every AO + // sample, painting a broad, blotchy dark layer that also shimmers with + // the grass wind. 0.05 keeps AO a local contact/crevice term (what it's + // for) instead of a scene-wide dimming wash. + let clamped_radius = clamp(screen_radius, 2.0 * max(inv_sz.x, inv_sz.y), 0.05); var ao_sum = 0.0; let step_size = clamped_radius / f32(N_STEPS); @@ -320,7 +334,7 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { let light_vs = normalize(u.light_dir_vs.xyz); var contact = 1.0; let n_dot_l = dot(N, light_vs); - if (n_dot_l > 0.1) { + if (CONTACT_SHADOWS_ENABLED && n_dot_l > 0.1) { let cs_steps = 12u; let cs_max_dist = 0.2; let cs_step = cs_max_dist / f32(cs_steps); diff --git a/native/shared/src/shadows.rs b/native/shared/src/shadows.rs index a4aa4e3..1f92671 100644 --- a/native/shared/src/shadows.rs +++ b/native/shared/src/shadows.rs @@ -630,41 +630,37 @@ impl ShadowMap { let c_near = splits[c]; let c_far = splits[c + 1]; - // Build a sub-projection that matches the camera's projection - // but with the near/far planes replaced by cascade limits. - // For a perspective projection, that means re-deriving the - // z-mapping while keeping the x/y fields intact. - let mut sub_proj = camera_proj; - // camera_proj is column-major, standard wgpu perspective layout: - // col2 row2 = (far+near)/(near-far) - // col3 row2 = 2*far*near/(near-far) - // col2 row3 = -1 - let nf = 1.0 / (c_near - c_far); - sub_proj[2][2] = (c_far + c_near) * nf; - sub_proj[3][2] = 2.0 * c_far * c_near * nf; - - let sub_inv_vp = crate::renderer::mat4_invert( - crate::renderer::mat4_multiply(sub_proj, camera_view), - ); - - // The 8 NDC corners of a clip cube. wgpu uses z in [0,1]. - let ndc_corners: [[f32; 4]; 8] = [ - [-1.0, -1.0, 0.0, 1.0], - [ 1.0, -1.0, 0.0, 1.0], - [-1.0, 1.0, 0.0, 1.0], - [ 1.0, 1.0, 0.0, 1.0], - [-1.0, -1.0, 1.0, 1.0], - [ 1.0, -1.0, 1.0, 1.0], - [-1.0, 1.0, 1.0, 1.0], - [ 1.0, 1.0, 1.0, 1.0], - ]; - - // Unproject to world space + // Frustum-slice corners for [c_near, c_far], computed DIRECTLY in + // view space from the camera FOV, then transformed to world by the + // (affine, well-conditioned) view inverse. + // + // We deliberately do NOT invert the perspective projection here. + // `mat4_perspective` uses the OpenGL [-1,1] NDC-z convention, so + // unprojecting its NDC clip corners handed the near plane a NEGATIVE + // homogeneous w — every corner then divided down onto the z=-1 plane + // and the frustum bounding sphere collapsed to ~0.12 m. The cascade + // ortho covered almost nothing, so terrain a few metres from the + // camera projected outside the shadow frustum and self-shadowed: + // the reported "moving dark patch" that scaled disproportionately + // with the camera. Half-extents per unit view depth come straight + // from the projection (no inversion, no convention hazard): + // tan(fovy/2) = 1 / proj[1][1] + // tan(fovy/2) * aspect = 1 / proj[0][0] + let inv_view = crate::renderer::mat4_invert(camera_view); + let half_w_per_d = 1.0 / camera_proj[0][0]; + let half_h_per_d = 1.0 / camera_proj[1][1]; let mut world_corners = [[0.0f32; 3]; 8]; - for i in 0..8 { - let h = crate::renderer::mat4_mul_vec4(&sub_inv_vp, &ndc_corners[i]); - let w = if h[3].abs() > 1e-8 { h[3] } else { 1.0 }; - world_corners[i] = [h[0] / w, h[1] / w, h[2] / w]; + let mut ci = 0usize; + for &d in [c_near, c_far].iter() { + let hw = d * half_w_per_d; + let hh = d * half_h_per_d; + for &(sx, sy) in [(-1.0f32, -1.0f32), (1.0, -1.0), (-1.0, 1.0), (1.0, 1.0)].iter() { + // View space looks down -Z, so this slice sits at z = -d. + let vp = [sx * hw, sy * hh, -d, 1.0]; + let wc = crate::renderer::mat4_mul_vec4(&inv_view, &vp); + world_corners[ci] = [wc[0], wc[1], wc[2]]; + ci += 1; + } } // Bounding sphere of this cascade's frustum slice. Sphere