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` |
| 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 |
| 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 |
| 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` |
@@ -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'
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.
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`
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>,
catalog: Option<Res<GradeCatalog>>,
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>>,
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>,
mut blend: ResMut<GradeBlend>,
cameras: Query<(Entity, &Camera, Option<&GradeView>), With<WorldCamera>>,
) {
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
.as_ref()
.and_then(|areas| areas.0.top_zone(leaf))
@@ -313,6 +318,7 @@ fn update_views(
})
});
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| {
if blend.current.is_none() && blend.previous.is_none() {
return None;
@@ -332,7 +338,7 @@ fn update_views(
prev_night,
control: Vec4::new(
strength,
night_weight(time.minute_f),
night_weight(minute),
blend.fade,
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 {
let dawn = smoothstep(330.0, 450.0, minute);
let dusk = smoothstep(1110.0, 1230.0, minute);
@@ -538,6 +553,25 @@ impl ViewNode for GradeNode {
mod tests {
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]
fn identity_volume_has_exact_axes() {
let cube = identity_cube();
+5 -2
View File
@@ -59,7 +59,7 @@ pub use spawn::{
use queries::update_current_area;
pub use queries::{
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;
@@ -389,6 +389,8 @@ impl Plugin for TerrainPlugin {
.init_resource::<StaticMerge>()
.init_resource::<crate::model_forms::ModelForms>()
.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
// (Present), so a swap never renders uncovered. `finish_colliders` heads the chain so
// the collider queue read downstream is this frame's.
@@ -444,7 +446,8 @@ impl Plugin for TerrainPlugin {
update_current_area
.after(crate::wmo_portal::WmoPvsSet)
.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)]
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
/// leaf, indoor bit and names come from one frame (the reference resolves them in one pass).
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]