diff --git a/LIGHTING.md b/LIGHTING.md index 70b76924..ff447c35 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -15,7 +15,7 @@ behind each constant. | Lane | What you see | Main switch | |---|---|---| | Sun shadows, characters | real silhouettes for units instead of the oval blob | `characterShadows` | -| Sun shadows, world | trees, buildings and alpha-tested foliage cast; baked terrain shadows step aside | `worldShadows` | +| Sun shadows, world | trees, buildings and alpha-tested foliage cast; baked terrain shadows step aside; the static caster cache also refreshes when streamed geometry arrives, so a standing camera picks up late trees (`world_shadow.rs::refresh_streamed_shadows`, independent of volumetric fog) | `worldShadows` | | Dynamic interiors | WMO rooms are lit per fragment by their own fixtures with a soft falloff, gated per room so light does not leak through walls | `interiorLight`, `interiorRoomGate` | | Synthesised lights | torches, braziers, lanterns, campfires and lampposts emit a light in the colour of their flame, with flicker | `fireLightGain`, `fireFlicker` | | Torch shadows | cube-map shadows from point lights onto buildings, models and terrain, with a contact-hardening penumbra and a shadow floor | `interiorShadows`, `exteriorShadows`, `torchShadowStrength`, `interiorShadowSoft` | @@ -32,7 +32,7 @@ behind each constant. | Modern fog | MONKEY (fog): radial distance fog with a gradual exponential curve, a daylight sun lobe, and an end colour shared by the world, WDL hull, sky horizon and volumetric haze. Each zone keeps its authored fog end up to the reference view-distance limit; optional `LightFogBand.dbc` rows provide height, sun and end-fog controls. Interior WMO fog stays classic | Advanced Graphics → Modern Fog, cvar `fogModel` 0 Classic (default) / 1 Modern (High = 1), env `WOW_FOGMODEL=0\|1`; `fog_hook.wgsl`, `lighting/fog_model.rs`, `light/fog_band.rs` | | Rain on surfaces | MONKEY (wet): rain gradually darkens and saturates exposed terrain and models, adds puddles and a sky/sun sheen, and produces rings on exterior Enhanced water. Wetness rises over roughly 90 seconds and dries over roughly four minutes; MONKEY (rainshelter): a camera-centred rain-occlusion map (128² cells of 0.75 yd, filled by vertical collision rays) keeps ground, walls and water under roofs, porches and bridges dry, with a ~1.5 yd drip margin and a soft edge; tree canopies without colliders do not shelter | Advanced Graphics → Wet Surfaces in Rain, cvar `rainSurfaces` 0/1 (default and High 1), env `WOW_RAIN_SURFACES`, `WOW_WETNESS`, `WOW_WET_T`; `weather/wetness.rs`, `weather/shelter.rs`, `wet_hook.wgsl` | | Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND`; field and benders in `benilla-world/src/wind/`. The wave phase is the CPU-integrated travel (speed integrated over time, wrapped seamlessly), and the waves' spatial term uses the fixed profile heading; the veer turns only the bend. Tree exile copies sway through material marker bit 14 (`FOLIAGE_WIND_MARKER`). Capture offset `WOW_CAPTURE_WIND_T` (s). Known: shadow casters (world-shadow proxies, `torch_depth.wgsl`) do not apply the sway, so leaves sample a static shadow; a future world-camera DepthPrepass must call the same offsets | -| 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 | +| 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; on alpha-tested foliage (a dense window of two-sided depth spikes) the blur widens its depth tolerance and the AO drops 40 %, which removes the leaf grain while planes, silhouettes and contact creases keep full AO (`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..5` diagnostic views (factor, protection, distance, raw occlusion, foliage mask), `WOW_AO_GAIN`/`_RADIUS`/`_BIAS`/`_STRENGTH` tuning | | Lamp fog | MONKEY (lampfog): the fog fullscreen pass selects the nearest resolved exterior and interior point-table sources and analytically integrates their softened inverse-square light over the camera-to-scene-depth ray. A near-field haze floor makes lamps visible inside the ordinary volumetric haze's 10 yd clear zone; it is zero by day, stronger in rain/fog, and deliberately unshadowed. The table colour already includes intensity, flame flicker and `fireLightGain`. Halo output uses destination headroom and stays below 1.0, so a later bloom pass cannot blow it out | Advanced Graphics → Lamp Fog, cvar `lampFog` 0 Off (default, unchanged) / 1 Low (16 lamps) / 2 High (32 lamps; Graphics preset High = 2), env `WOW_LAMPFOG=0\|1\|2`; `benilla-app/src/volumetric_fog.rs` | | City window daylight | MONKEY (fix-daylight/integration): a district WMO's oversized window batch (e.g. the Cathedral of Light's stained-glass walls) is split into window-sized daylight apertures, so the nave takes daylight pane by pane. Applies to WMOs loaded after a change | Advanced Graphics → City Window Daylight, cvar `daylightWindowSplit` 0/1 (default 1, the merged behaviour); `benilla-world/src/lighting/daylight.rs` (`set_window_split`), bridge `benilla-app/src/dynamic_interior.rs` | | Zone skyboxes | the painted sky a zone's `LightParams` names (clear-weather slot, storm slot lerped by the storm blend) for the living, fading in by the Light sphere falloff with the modern client's crossfade (ghost > WMO > zone); extended `LightSkybox.dbc` flags `0x1` day cycle, `0x2` keep sun/moon/stars/clouds, `0x4` fog-colour horizon cone, plus a celestial second model; every skybox now plays its bones, texture transforms, colour and alpha tracks (`benilla-world/src/skybox.rs`, `skybox_anim.rs`, `benilla-formats/src/light/skybox.rs`) | Advanced Graphics → Zone Skyboxes, cvar `zoneSkyboxes` (0 Off default, 1 On; High = 1), env `WOW_ZONE_SKYBOXES=0\|1` | @@ -77,6 +77,12 @@ column at startup (`Cvars::seed_graphics_preset`) on every row the file does not registered defaults stay their per-lane ones (`farclip` the reference's 350), and captures and tests, which never seed, keep them. The Advanced page's Defaults button lands on High too. +**Saved presets and new rows** (MONKEY (followups)). On load, `Cvars::reapply_saved_presets` +writes the saved `graphicsQuality` rung (and the saved `lightingQuality` rung, else the Graphics +rung's) over every governed row the file does NOT carry, so a row registered after the save gets +the preset's value, not its registered default. Rows the file carries and session-owned rows stay; +`Custom` writes nothing. + **Render Distance** is `farclip`, the same cvar as the Graphics page's Terrain Distance (which keeps the stock 177–777 slider). Its clamp (`view::FARCLIP_RANGE`) now reaches `FARCLIP_MAX` = 1497 yd, the 1.12 grid of 60 carried on. Terrain residency (`terrain_stream/window.rs`), the diff --git a/crates/benilla-app/src/shaders/ssao.wgsl b/crates/benilla-app/src/shaders/ssao.wgsl index c3459723..6b5b9bb9 100644 --- a/crates/benilla-app/src/shaders/ssao.wgsl +++ b/crates/benilla-app/src/shaders/ssao.wgsl @@ -5,7 +5,8 @@ // STAGE_BLUR half resolution: 4x4 depth-aware box; the 4x4 Bayer rotation cancels inside it. // STAGE_APPLY full resolution: joint-bilateral upsample, output multiplied into the scene. // Stored texel: r = occlusion 0..1 (fade applied), g = view distance (SKY_DIST for sky), -// b = bright-pixel protection 0..1. +// b = bright-pixel protection 0..1, a (MONKEY (followups)) = surface smoothness 0..1 after +// STAGE_AO (0 where both neighbours on an axis jump in depth), 1 - foliage mask after STAGE_BLUR. #import bevy_render::view::View struct Ao { @@ -13,7 +14,7 @@ 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. + // x: debug view (0 off, 1 factor, 2 protection, 3 distance/50, 4 raw occlusion, 5 smoothness); y: gain. debug: vec4, } @@ -99,6 +100,20 @@ fn ao_main(@builtin(position) frag: vec4) -> @location(0) vec4 { let dy = select(p - pu, pd - p, abs(pd.z - p.z) < abs(p.z - pu.z)); var n = normalize(cross(dx, dy)); if (dot(n, p) > 0.0) { n = -n; } + // MONKEY (followups): cutout leaves scatter depth spikes through a canopy, and there the taps + // are noise. The spike is the smaller of the two one-sided steps two pixels out, capped by the + // second difference, which is ~0 on any plane (even at grazing angles) and on a silhouette + // (one side continuous). Contact creases stay smooth, so contact shadows keep full strength. + // The blur turns the spike DENSITY of its window into the foliage mask. + let ax = neighbour(f + vec2(2, 0), p).z - p.z; + let bx = p.z - neighbour(f - vec2(2, 0), p).z; + let ay = neighbour(f + vec2(0, 2), p).z - p.z; + let by = p.z - neighbour(f - vec2(0, 2), p).z; + let spike = max( + min(min(abs(ax), abs(bx)), abs(ax - bx)), + min(min(abs(ay), abs(by)), abs(ay - by)), + ); + let smooth_surface = 1.0 - smoothstep(0.01, 0.04, spike / dist); let count = u32(ao.params.z); let rot = (bayer4(h) + 0.5) * (6.2831853 / 16.0); @@ -119,7 +134,7 @@ fn ao_main(@builtin(position) frag: vec4) -> @location(0) vec4 { occ += w * max(0.0, dot(v, n) * inverseSqrt(vv + 1e-6) - ao.params.w); } occ = saturate(ao.debug.y * occ / f32(count)) * fade; - return vec4(occ, dist, protect, 1.0); + return vec4(occ, dist, protect, smooth_surface); } #endif @@ -130,7 +145,17 @@ fn blur_main(@builtin(position) frag: vec4) -> @location(0) vec4 { let size = vec2(textureDimensions(raw)); let c = textureLoad(raw, h, 0); if (c.g >= SKY_DIST) { return c; } - let tolerance = 0.04 * c.g + 0.05; + // MONKEY (followups): foliage = spike density over the window. There the depth test would + // reject every leaf neighbour and keep the grain, so the tolerance widens and the leftover + // occlusion is scaled down; a window of smooth surfaces keeps the tight edge and full AO. + var spikes = 0.0; + for (var y = -2; y <= 1; y += 1) { + for (var x = -2; x <= 1; x += 1) { + spikes += 1.0 - textureLoad(raw, clamp(h + vec2(x, y), vec2(0), size - vec2(1)), 0).a; + } + } + let foliage = saturate(spikes / 16.0 * 4.0); + let tolerance = (0.04 * c.g + 0.05) * (1.0 + 10.0 * foliage); var sum = 0.0; var weight = 0.0; for (var y = -2; y <= 1; y += 1) { @@ -141,7 +166,7 @@ fn blur_main(@builtin(position) frag: vec4) -> @location(0) vec4 { weight += w; } } - return vec4(sum / max(weight, 1e-4), c.g, c.b, 1.0); + return vec4(sum / max(weight, 1e-4) * (1.0 - 0.4 * foliage), c.g, c.b, 1.0 - foliage); } #endif @@ -162,6 +187,7 @@ fn apply_main(@builtin(position) frag: vec4) -> @location(0) vec4 { let t = hc - floor(hc); var occ = 0.0; var protect = 0.0; + var smooth_surface = 0.0; var weight = 0.0; for (var j = 0; j < 4; j += 1) { let o = vec2(j & 1, j >> 1u); @@ -170,6 +196,7 @@ fn apply_main(@builtin(position) frag: vec4) -> @location(0) vec4 { let w = b.x * b.y / (0.02 + abs(s.g - dist) / dist) + 1e-5; occ += s.r * w; protect += s.b * w; + smooth_surface += s.a * w; weight += w; } occ /= weight; @@ -178,6 +205,8 @@ fn apply_main(@builtin(position) frag: vec4) -> @location(0) vec4 { // 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); } + // MONKEY (followups): 5 = foliage mask (black = treated as foliage). + if (mode == 5u) { let m = smooth_surface / weight; return vec4(m, m, m, 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 c997cc96..a7803742 100644 --- a/crates/benilla-app/src/ssao.rs +++ b/crates/benilla-app/src/ssao.rs @@ -10,7 +10,7 @@ //! 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..4` writes a diagnostic view +//! `WOW_AO=0|1|2` overrides it for the session; `WOW_AO_DEBUG=1..5` 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}; @@ -50,8 +50,8 @@ 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. + /// x = debug view (0 off, 1 AO factor, 2 protection, 3 distance/50, 4 raw occlusion, + /// 5 surface smoothness, MONKEY (followups)), y = occlusion gain. debug: Vec4, } @@ -87,11 +87,11 @@ impl AoView { } } -/// `WOW_AO_DEBUG=1..4`: write a diagnostic term instead of darkening the scene. +/// `WOW_AO_DEBUG=1..5`: 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) + std::env::var("WOW_AO_DEBUG").ok().and_then(|v| v.parse().ok()).unwrap_or(0).min(5) }) }