MONKEY (reviewfix-a): zone grading visible in captures; follows the rendered clock

Found by the first runtime capture of the grade: a capture has no avatar, so
CurrentArea stayed None and no zone was ever graded; and the day/night weight
read WorldTime (the server clock, noon offline), so a manual/capture night
graded with the day LUT. Adds CaptureCameraArea (area under the camera, only
under WOW_CAPTURE, never read by the area authority) and grades by the
rendered GameClock minute. LIGHTING.md notes the preset/capture changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-26 02:25:15 +01:00
co-authored by Claude Opus 5.5
parent ddc001c849
commit ac9a1ef390
4 changed files with 89 additions and 5 deletions
+11 -1
View File
@@ -31,7 +31,7 @@ behind each constant.
| Sky quality | Enhanced: the five Light.dbc sky stops through a smooth monotone curve in linear light (no bands at the rings), a soft sun glow tinted by sun and fog colour (fades at night and under cloud), a procedural star field with twinkle and a faint Milky Way over the stock `Stars.m2`. High adds domain-warped cloud detail and sun-lit clouds (self-shadow, silver lining; technique from WarcraftXL, see `THIRD-PARTY.md`). Classic is the reference sky unchanged | Advanced Graphics → Sky Quality, cvar `skyQuality` 0 Classic / 1 Enhanced / 2 High, env `WOW_SKY_QUALITY` | | Sky quality | Enhanced: the five Light.dbc sky stops through a smooth monotone curve in linear light (no bands at the rings), a soft sun glow tinted by sun and fog colour (fades at night and under cloud), a procedural star field with twinkle and a faint Milky Way over the stock `Stars.m2`. High adds domain-warped cloud detail and sun-lit clouds (self-shadow, silver lining; technique from WarcraftXL, see `THIRD-PARTY.md`). Classic is the reference sky unchanged | Advanced Graphics → Sky Quality, cvar `skyQuality` 0 Classic / 1 Enhanced / 2 High, env `WOW_SKY_QUALITY` |
| 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` | | 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` | | 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 | | 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` (dev build + `WOW_CAPTURE` only); 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; 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 | | 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` | | 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` | | 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` |
@@ -76,6 +76,16 @@ and High volumetric fog. No rung decides `shadowDistance` or `interiorShadowSoft
column at startup (`Cvars::seed_graphics_preset`) on every row the file does not carry; the rows' column at startup (`Cvars::seed_graphics_preset`) on every row the file does not carry; the rows'
registered defaults stay their per-lane ones (`farclip` the reference's 350), and captures and 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. tests, which never seed, keep them. The Advanced page's Defaults button lands on High too.
The seeded boot value that leaves the reference (`farclip` 777 against 350) is declared in
`cvars::SEEDED_DEVIATIONS`, and a test walks the whole High column against the reference rows.
Session-owned rows (env levers such as `WOW_FARCLIP`) are skipped when both labels are derived, so
an env override never persists `Custom` (MONKEY (reviewfix-a)).
**Capture fixtures** (MONKEY (reviewfix-a)). A capture reads CVars only from
`WOW_CAPTURE_CVARS=<config.toml path>` (read-only; nothing is saved), never from `BENILLA_HOME`.
`WOW_FOLIAGE_WIND` is honoured only in a dev build under `WOW_CAPTURE`. A capture has no avatar,
so zone grading reads `terrain_stream::CaptureCameraArea` (the area under the camera, written only
under `WOW_CAPTURE`) and the grade's day/night weight follows the rendered `GameClock` minute.
**Saved presets and new rows** (MONKEY (followups)). On load, `Cvars::reapply_saved_presets` **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 writes the saved `graphicsQuality` rung (and the saved `lightingQuality` rung, else the Graphics
+36 -2
View File
@@ -282,14 +282,19 @@ fn update_views(
video: Res<VideoConfig>, video: Res<VideoConfig>,
catalog: Option<Res<GradeCatalog>>, catalog: Option<Res<GradeCatalog>>,
area: Res<CurrentArea>, area: Res<CurrentArea>,
// MONKEY (reviewfix-a): a capture has no avatar; it grades by the area under the camera.
capture_area: Option<Res<benilla_world::terrain_stream::CaptureCameraArea>>,
areas: Option<Res<crate::area::AreaTableRes>>, areas: Option<Res<crate::area::AreaTableRes>>,
time: Res<WorldTime>, time: Res<WorldTime>,
// MONKEY (reviewfix-a): the minute the lighting actually rendered (server, or the manual /
// capture clock); `WorldTime` alone is the server's and stays at its noon default offline.
rendered: Option<Res<benilla_world::lighting::GameClock>>,
clock: Res<Time>, clock: Res<Time>,
mut blend: ResMut<GradeBlend>, mut blend: ResMut<GradeBlend>,
cameras: Query<(Entity, &Camera, Option<&GradeView>), With<WorldCamera>>, cameras: Query<(Entity, &Camera, Option<&GradeView>), With<WorldCamera>>,
) { ) {
let selected = catalog.as_ref().and_then(|catalog| { let selected = catalog.as_ref().and_then(|catalog| {
let leaf = area.0?; let leaf = area.0.or_else(|| capture_area.as_ref().and_then(|a| a.0))?;
let zone = areas let zone = areas
.as_ref() .as_ref()
.and_then(|areas| areas.0.top_zone(leaf)) .and_then(|areas| areas.0.top_zone(leaf))
@@ -313,6 +318,7 @@ fn update_views(
}) })
}); });
blend.step(selected, clock.delta_secs(), !video.color_grading); blend.step(selected, clock.delta_secs(), !video.color_grading);
let minute = rendered_minute(&time, rendered.as_deref());
let selected = catalog.as_ref().and_then(|catalog| { let selected = catalog.as_ref().and_then(|catalog| {
if blend.current.is_none() && blend.previous.is_none() { if blend.current.is_none() && blend.previous.is_none() {
return None; return None;
@@ -332,7 +338,7 @@ fn update_views(
prev_night, prev_night,
control: Vec4::new( control: Vec4::new(
strength, strength,
night_weight(time.minute_f), night_weight(minute),
blend.fade, blend.fade,
prev_strength, prev_strength,
), ),
@@ -354,6 +360,15 @@ fn update_views(
} }
} }
/// MONKEY (reviewfix-a): the server's fractional minute while it is the minute being rendered,
/// else the rendered (manual or capture) minute.
fn rendered_minute(time: &WorldTime, rendered: Option<&benilla_world::lighting::GameClock>) -> f32 {
match rendered {
Some(c) if time.minute_f.floor() as u32 != c.minute => c.minute as f32,
_ => time.minute_f,
}
}
fn night_weight(minute: f32) -> f32 { fn night_weight(minute: f32) -> f32 {
let dawn = smoothstep(330.0, 450.0, minute); let dawn = smoothstep(330.0, 450.0, minute);
let dusk = smoothstep(1110.0, 1230.0, minute); let dusk = smoothstep(1110.0, 1230.0, minute);
@@ -538,6 +553,25 @@ impl ViewNode for GradeNode {
mod tests { mod tests {
use super::*; use super::*;
/// MONKEY (reviewfix-a): offline (capture / manual clock) the grade follows the rendered
/// minute, not the server's noon default; live it keeps the server's fraction.
#[test]
fn grade_night_weight_follows_the_rendered_minute() {
let server = WorldTime::default();
let manual = benilla_world::lighting::GameClock {
minute: 1260,
..Default::default()
};
assert_eq!(rendered_minute(&server, Some(&manual)), 1260.0);
assert_eq!(night_weight(rendered_minute(&server, Some(&manual))), 1.0);
let live = WorldTime {
minute_f: 1260.5,
..Default::default()
};
assert_eq!(rendered_minute(&live, Some(&manual)), 1260.5);
assert_eq!(rendered_minute(&live, None), 1260.5);
}
#[test] #[test]
fn identity_volume_has_exact_axes() { fn identity_volume_has_exact_axes() {
let cube = identity_cube(); let cube = identity_cube();
+5 -2
View File
@@ -59,7 +59,7 @@ pub use spawn::{
use queries::update_current_area; use queries::update_current_area;
pub use queries::{ pub use queries::{
area_id_under, doodad_ground_shade, ground_effect_under, terrain_height_under, area_id_under, doodad_ground_shade, ground_effect_under, terrain_height_under,
terrain_height_under_cached, AreaAuthoritySet, CurrentArea, ShadeResolve, terrain_height_under_cached, AreaAuthoritySet, CaptureCameraArea, CurrentArea, ShadeResolve,
}; };
/// Wall-clock per frame for spawning streamed tiles and placements before the rest waits a frame; /// Wall-clock per frame for spawning streamed tiles and placements before the rest waits a frame;
@@ -389,6 +389,8 @@ impl Plugin for TerrainPlugin {
.init_resource::<StaticMerge>() .init_resource::<StaticMerge>()
.init_resource::<crate::model_forms::ModelForms>() .init_resource::<crate::model_forms::ModelForms>()
.init_resource::<CurrentArea>() .init_resource::<CurrentArea>()
// MONKEY (reviewfix-a): the capture-only camera area (zone grading in captures).
.init_resource::<CaptureCameraArea>()
// In `WorldStage::Stream`, between the teleport snap (Input) and the loading cover // In `WorldStage::Stream`, between the teleport snap (Input) and the loading cover
// (Present), so a swap never renders uncovered. `finish_colliders` heads the chain so // (Present), so a swap never renders uncovered. `finish_colliders` heads the chain so
// the collider queue read downstream is this frame's. // the collider queue read downstream is this frame's.
@@ -444,7 +446,8 @@ impl Plugin for TerrainPlugin {
update_current_area update_current_area
.after(crate::wmo_portal::WmoPvsSet) .after(crate::wmo_portal::WmoPvsSet)
.in_set(AreaAuthoritySet), .in_set(AreaAuthoritySet),
); )
.add_systems(Update, queries::update_capture_camera_area.in_set(AreaAuthoritySet));
} }
} }
@@ -14,6 +14,43 @@ use super::TerrainStreamer;
#[derive(Resource, Default, PartialEq, Eq)] #[derive(Resource, Default, PartialEq, Eq)]
pub struct CurrentArea(pub Option<u32>); pub struct CurrentArea(pub Option<u32>);
/// MONKEY (reviewfix-a): the area under the CAMERA, written only under `WOW_CAPTURE` while there
/// is no avatar. A deterministic capture has no body, so [`CurrentArea`] stays `None`; area-keyed
/// look lanes (zone grading) read this as their capture fallback. The area authority (music,
/// channels, names) never reads it.
#[derive(Resource, Default, PartialEq, Eq)]
pub struct CaptureCameraArea(pub Option<u32>);
/// MONKEY (reviewfix-a): see [`CaptureCameraArea`].
pub(super) fn update_capture_camera_area(
mut out: ResMut<CaptureCameraArea>,
focus: Res<crate::terrain_stream::ViewFocus>,
streamer: Res<TerrainStreamer>,
adt_tiles: Res<Assets<AdtTile>>,
camera: Query<&GlobalTransform, With<crate::view::WorldCamera>>,
mut capture: Local<Option<bool>>,
) {
if !*capture.get_or_insert_with(|| std::env::var_os("WOW_CAPTURE").is_some())
|| focus.body_pos().is_some()
{
return;
}
let Some(eye) = camera.iter().next().map(GlobalTransform::translation) else {
return;
};
let wow = bevy_to_wow(eye);
let (tx, ty) = world_to_tile(wow[0], wow[1]);
let found = streamer
.tiles
.get(&(tx as i32, ty as i32))
.and_then(|ts| adt_tiles.get(&ts.handle))
.and_then(|adt| benilla_formats::area_id_at(&adt.chunks, wow))
.filter(|&id| id != 0);
if found.is_some() && out.0 != found {
out.0 = found;
}
}
/// The set of [`update_current_area`]. Every consumer that acts on the area orders after it, so /// The set of [`update_current_area`]. Every consumer that acts on the area orders after it, so
/// leaf, indoor bit and names come from one frame (the reference resolves them in one pass). /// leaf, indoor bit and names come from one frame (the reference resolves them in one pass).
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]