From 9eda0a738f5bef66146d6d5bb68e3169e1a4dc5e Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 10:37:13 +0100
Subject: [PATCH] MONKEY (ao): diagnostic views, tuned gain/bias/strength,
longer distance fade for outdoor contacts
Co-Authored-By: Claude Opus 5.5
---
LIGHTING.md | 2 +-
crates/benilla-app/src/shaders/ssao.wgsl | 14 ++++++-
crates/benilla-app/src/ssao.rs | 49 ++++++++++++++++++++----
3 files changed, 54 insertions(+), 11 deletions(-)
diff --git a/LIGHTING.md b/LIGHTING.md
index 6dd43bc3..af157726 100644
--- a/LIGHTING.md
+++ b/LIGHTING.md
@@ -24,7 +24,7 @@ behind each constant.
| Moon shadows | at night the same shadow rig re-aims at the moon and casts a faint shadow; dims only the night sky term, never point lights | `moonShadowStrength` |
| Ground-effect spells | Flamestrike, Rain of Fire, Consecration, Flare and fire traps light the ground for their duration; frost and nature areas stay dark | `spellLightGain` |
| Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors) and sun/moon light shafts through gaps, sampled from the shadow map; own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` |
-| Ambient occlusion | soft contact shadows under props, in wall-floor corners and around feet; depth-only SSAO at half resolution (depth-reconstructed normals, 6/12 spiral taps, 4x4 depth-aware blur, bilateral upsample) multiplied into the opaque scene after the opaque pass and before the water copy, so water, particles and UI are untouched; fades out 35-80 yd, spares sky and bright pixels (`benilla-app/src/ssao.rs`, `benilla-app/src/shaders/ssao.wgsl`) | Advanced Graphics → Ambient Occlusion (Off/Low/High; default Off, High preset 2), cvar `ambientOcclusion`, env `WOW_AO=0\|1\|2`, `WOW_AO_DEBUG=1` shows the AO term alone |
+| Ambient occlusion | soft contact shadows under props, in wall-floor corners and around feet; depth-only SSAO at half resolution (depth-reconstructed normals, 6/12 spiral taps, 4x4 depth-aware blur, bilateral upsample) multiplied into the opaque scene after the opaque pass and before the water copy, so water, particles and UI are untouched; fades out 45-90 yd (Low) / 60-120 yd (High), spares sky and bright pixels (`benilla-app/src/ssao.rs`, `benilla-app/src/shaders/ssao.wgsl`) | Advanced Graphics → Ambient Occlusion (Off/Low/High; default Off, High preset 2), cvar `ambientOcclusion`, env `WOW_AO=0\|1\|2`, `WOW_AO_DEBUG=1..4` diagnostic views (factor, protection, distance, raw occlusion), `WOW_AO_GAIN`/`_RADIUS`/`_BIAS`/`_STRENGTH` tuning |
| Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` |
Players reach all of it from **Options -> Advanced Graphics** (a Lighting Quality preset Off / Low / Medium / High plus the individual rows; Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default /
diff --git a/crates/benilla-app/src/shaders/ssao.wgsl b/crates/benilla-app/src/shaders/ssao.wgsl
index 2cc07f9e..c3459723 100644
--- a/crates/benilla-app/src/shaders/ssao.wgsl
+++ b/crates/benilla-app/src/shaders/ssao.wgsl
@@ -13,6 +13,8 @@ struct Ao {
params: vec4,
// Distance fade start and end in yards, protection luma ramp start and end.
fade: vec4,
+ // x: debug view (0 off, 1 factor, 2 protection, 3 distance/50, 4 raw occlusion); y: gain.
+ debug: vec4,
}
const SKY_DIST: f32 = 60000.0;
@@ -116,7 +118,7 @@ fn ao_main(@builtin(position) frag: vec4) -> @location(0) vec4 {
let w = saturate(1.0 - vv / r2);
occ += w * max(0.0, dot(v, n) * inverseSqrt(vv + 1e-6) - ao.params.w);
}
- occ = saturate(2.0 * occ / f32(count)) * fade;
+ occ = saturate(ao.debug.y * occ / f32(count)) * fade;
return vec4(occ, dist, protect, 1.0);
}
#endif
@@ -148,7 +150,11 @@ fn blur_main(@builtin(position) frag: vec4) -> @location(0) vec4 {
fn apply_main(@builtin(position) frag: vec4) -> @location(0) vec4 {
let p = vec2(frag.xy);
let z = depth_at(p);
- if (is_sky(z)) { return vec4(1.0); }
+ let mode = u32(ao.debug.x);
+ if (is_sky(z)) {
+ // Sky is magenta in the diagnostic views, so "depth reads as sky" is visible.
+ return select(vec4(1.0), vec4(1.0, 0.0, 1.0, 1.0), mode >= 2u);
+ }
let dist = -view_at(p, z).z;
let size = vec2(textureDimensions(source));
let hc = frag.xy * 0.5 - 0.5;
@@ -168,6 +174,10 @@ fn apply_main(@builtin(position) frag: vec4) -> @location(0) vec4 {
}
occ /= weight;
protect /= weight;
+ if (mode == 2u) { return vec4(protect, protect, protect, 1.0); }
+ // Red = positive view distance, green = negative (a sign error), both / 50 yd.
+ if (mode == 3u) { return vec4(saturate(dist / 50.0), saturate(-dist / 50.0), 0.0, 1.0); }
+ if (mode == 4u) { return vec4(1.0 - occ, 1.0 - occ, 1.0 - occ, 1.0); }
let k = 1.0 - ao.params.y * occ * (1.0 - protect);
return vec4(k, k, k, 1.0);
}
diff --git a/crates/benilla-app/src/ssao.rs b/crates/benilla-app/src/ssao.rs
index a7c8c81d..d81d75e6 100644
--- a/crates/benilla-app/src/ssao.rs
+++ b/crates/benilla-app/src/ssao.rs
@@ -6,11 +6,12 @@
//! blur, then a joint-bilateral upsample MULTIPLIED into the main colour attachment (the MSAA
//! attachment when multisampled, so the resolve and every later pass see it). It runs before
//! the water depth/colour copy and before transparents, so water, particles and UI are
-//! untouched and refraction sees the darkened bed. Sky, distant pixels (fade 35-80 yd) and
-//! bright pixels (lit windows, flames, sunlit sand) are protected.
+//! untouched and refraction sees the darkened bed. Sky, distant pixels and
+//! bright pixels (lit windows, flames, sunlit sand) are protected; fade 45-90 yd (Low) / 60-120 yd (High).
//!
//! cvar `ambientOcclusion`: 0 Off (pass not scheduled, image unchanged), 1 Low, 2 High.
-//! `WOW_AO=0|1|2` overrides it for the session; `WOW_AO_DEBUG=1` writes the AO term itself.
+//! `WOW_AO=0|1|2` overrides it for the session; `WOW_AO_DEBUG=1..4` writes a diagnostic view
+//! (factor, protection, distance, raw occlusion); `WOW_AO_GAIN/RADIUS/BIAS/STRENGTH` tune it.
use crate::video::VideoConfig;
use benilla_world::{liquid::WaterDepthLabel, view::WorldCamera};
use bevy::{
@@ -49,21 +50,53 @@ struct AoView {
params: Vec4,
/// Distance fade start and end (yd), bright-pixel protection ramp (gamma max channel).
fade: Vec4,
+ /// x = debug view (0 off, 1 AO factor, 2 protection, 3 distance/50, 4 raw occlusion),
+ /// y = occlusion gain.
+ debug: Vec4,
+}
+
+/// Dev tuning knob `WOW_AO_=`, read once per name; `None` keeps the tier value.
+fn tuning(name: &'static str) -> Option {
+ static KNOBS: std::sync::Mutex)>> = std::sync::Mutex::new(Vec::new());
+ let mut knobs = KNOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+ if let Some((_, v)) = knobs.iter().find(|(n, _)| *n == name) {
+ return *v;
+ }
+ let v = std::env::var(format!("WOW_AO_{name}"))
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ .filter(|v| v.is_finite() && *v >= 0.0);
+ knobs.push((name, v));
+ v
}
impl AoView {
fn tier(tier: u8) -> Self {
- let (radius, strength, samples) = if tier >= 2 { (1.5, 0.55, 12.0) } else { (1.2, 0.5, 6.0) };
+ let (radius, strength, samples) = if tier >= 2 { (1.5, 0.7, 12.0) } else { (1.2, 0.65, 6.0) };
+ // Outdoor contacts (house bases, trunks) sit 30-60 yd out; Low stops sooner.
+ let fade = if tier >= 2 { (60.0, 120.0) } else { (45.0, 90.0) };
+ let radius = tuning("RADIUS").unwrap_or(radius);
+ let strength = tuning("STRENGTH").unwrap_or(strength).min(1.0);
+ let bias = tuning("BIAS").unwrap_or(0.05);
+ let gain = tuning("GAIN").unwrap_or(6.0);
Self {
- params: Vec4::new(radius, strength, samples, 0.1),
- fade: Vec4::new(35.0, 80.0, 0.7, 0.95),
+ params: Vec4::new(radius, strength, samples, bias),
+ fade: Vec4::new(fade.0, fade.1, 0.7, 0.95),
+ debug: Vec4::new(debug_mode() as f32, gain, 0.0, 0.0),
}
}
}
+/// `WOW_AO_DEBUG=1..4`: write a diagnostic term instead of darkening the scene.
+fn debug_mode() -> u8 {
+ static MODE: std::sync::OnceLock = std::sync::OnceLock::new();
+ *MODE.get_or_init(|| {
+ std::env::var("WOW_AO_DEBUG").ok().and_then(|v| v.parse().ok()).unwrap_or(0).min(4)
+ })
+}
+
fn debug_view() -> bool {
- static ON: std::sync::OnceLock = std::sync::OnceLock::new();
- *ON.get_or_init(|| std::env::var("WOW_AO_DEBUG").as_deref() == Ok("1"))
+ debug_mode() != 0
}
impl Plugin for AmbientOcclusionPlugin {