diff --git a/LIGHTING.md b/LIGHTING.md index 2122d3df..39ba5cb4 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -20,6 +20,8 @@ behind each constant. | 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` | | Daylight and doorways | calibrated fixtures at doors, windows and open boundaries carry daylight into a room, and doorways between rooms bleed light | `interiorDaylight`, env `WOW_DAYLIGHT`, `WOW_BLEED` | +| Terrain in torch shadows | the ground casts into an outdoor fire's cube map, so a hill or bank blocks the fire (settled exterior slots only; `torch_terrain.rs` gathers the resident MCNK chunks inside the cube's 48 yd) | `torchTerrainShadows` (default 0, High 1), env `WOW_TORCH_TERRAIN=0\|1` | +| City day floor | `interiorDaylight`'s room floor also reaches Stormwind/Ironforge rooms that the portal graph connects to the sky (an exterior-facing portal, or one hop from one; `district_sky_rooms` in `daylight.rs`); before this no city room could take it | `interiorDaylight` (default 0, suggested High 0.10) | | Spell and firework lights | fire, holy and fel effects light their surroundings for their lifetime; frost, nature, arcane and shadow do not | `spellLightGain`, env `WOW_SPELL_LIGHT=0` | | 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` | @@ -47,6 +49,9 @@ is its own module, documented in `WATER.md`. `character_shadow.rs`, `world_shadow.rs`, `blob_shadow.rs`, `entities/carried_light.rs`, `entities/spell_fx/lifecycle.rs`, `dynamic_interior.rs`, `debug_panel/lighting_controls.rs`, and the cvars in `cvars.rs` / `video.rs`. +- **Census**: `cargo test -p benilla-world --lib lighting::daylight::census -- --ignored --nocapture` + (`WOW_CENSUS_WMO`, `WOW_CENSUS_PLACE=map,tx,ty,uid`) prints, per interior room of a city WMO, + whether a daylight fixture, a bleed fixture or neither reaches it, and the sky-room count. - **Tools**: `benilla-extract wmolights [--verts ]`, `wmolamps`, `m2firescan` print the inputs the system works from (groups, batch classes, portals, claims, flame emitters). diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index ff57a2af..84f27f1a 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -1404,6 +1404,11 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ Scenario { name: "daylight-if-gate", map: Some(MAP_AZEROTH), eye: [-4975.0, -895.0, 503.5], look: [-5005.0, -852.0, 506.0], minute: 720, ui: None, + }, // Goldshire's lamps at midnight: terrain receiving (and, with `torchTerrainShadows`, casting) + // torch cube shadows. + Scenario { + name: "daylight-torch-goldshire", map: Some(MAP_AZEROTH), + eye: [-9460.0, 70.0, 58.0], look: [-9450.0, 20.0, 61.0], minute: 0, ui: None, }, ]; diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs index e34976f6..d8385bce 100644 --- a/crates/benilla-app/src/dynamic_interior.rs +++ b/crates/benilla-app/src/dynamic_interior.rs @@ -104,7 +104,9 @@ fn bridge( // MONKEY (enclosed day floor): the daylight floor rides the same bridge, but is consumed in // the SHADER (it rides the packed `wmo_fog_params.w` fraction) rather than folded on the // CPU — the packer's only job is to put it in the lane. - daylight: video.interior_daylight, + // MONKEY (daylight): `WOW_INTERIOR_DAYLIGHT=<0..1>` overrides it in a hermetic capture, + // which reads no config.toml (dev builds only; read once). + daylight: capture_daylight().unwrap_or(video.interior_daylight), // MONKEY (bake floor): the bake floor rides the same bridge. Half CPU, half shader: the // packer folds `interiorGain` in and puts the product in the `sh_c16.w` fraction, and the // two interior lanes read it from there — so `SetCVar("interiorBakeFloor", 0)` restores @@ -116,6 +118,21 @@ fn bridge( } } +/// MONKEY (daylight): the capture-only `interiorDaylight` override (see the bridge above). +fn capture_daylight() -> Option { + static V: std::sync::OnceLock> = std::sync::OnceLock::new(); + *V.get_or_init(|| { + if !cfg!(feature = "dev") || std::env::var_os("WOW_CAPTURE").is_none() { + return None; + } + std::env::var("WOW_INTERIOR_DAYLIGHT") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| v.is_finite()) + .map(|v| v.clamp(0.0, 1.0)) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-world/src/lighting/daylight.rs b/crates/benilla-world/src/lighting/daylight.rs index 014df956..b7db527c 100644 --- a/crates/benilla-world/src/lighting/daylight.rs +++ b/crates/benilla-world/src/lighting/daylight.rs @@ -300,6 +300,74 @@ pub fn daylight_budget(groups: &[WmoGroupInfo]) -> usize { } } +/// MONKEY (daylight: district sky rooms): how many portal hops from an exterior-facing opening a +/// DISTRICT room may sit and still count as lit by the day (see [`district_sky_rooms`]). +const DISTRICT_SKY_HOPS: u8 = 1; + +/// MONKEY (daylight: district sky rooms): **which rooms of a CITY-scale placement take the +/// enclosed day floor** (`interiorDaylight`), as one flag per group; empty for a building. +/// +/// MEASURED (`daylight::census`, 2026-09-25): the floor's own test, +/// [`benilla_formats::room_claim::enclosed_by_building_shell`], is false for EVERY interior group +/// of Stormwind (190) and Ironforge (103), because a district shell is refused as a building by +/// design. So `interiorDaylight` never reached a city room at any value, while 35 Stormwind rooms +/// are reached by no daylight or bleed fixture at all and many more only by a fixture whose 6-20 yd +/// pool dies half-way into a 40 yd hall. +/// +/// The rule is the portal graph's, not a box test: a room is SKY-CONNECTED when it authors an +/// exterior-facing portal (the same relation the PORTAL seed stands the sun in) or sits within +/// [`DISTRICT_SKY_HOPS`] interior portals of such a room. That lights the shops, taverns and their +/// back rooms and leaves what the data says is deep inside alone: the Stormwind canal tunnels, +/// the Slaughtered Lamb's cellar, and all of Ironforge except the gate halls (the city's ONE +/// exterior portal is `p0`, into `g7`). +pub fn district_sky_rooms(groups: &[WmoGroupInfo], portals: PortalGraph<'_>) -> Vec { + if groups.iter().filter(|g| g.interior).count() <= DAYLIGHT_DISTRICT_ROOMS { + return Vec::new(); + } + // Every portal's sides, as the PORTAL seed collects them. + let mut sides: HashMap> = HashMap::new(); + for (gi, (start, count)) in portals.slices.iter().enumerate() { + let (start, count) = (usize::from(*start), usize::from(*count)); + for r in portals.refs.get(start..start + count).unwrap_or(&[]) { + for g in [gi as u16, r.group] { + let e = sides.entry(r.portal).or_default(); + if !e.contains(&g) { + e.push(g); + } + } + } + } + let interior = |g: u16| groups.get(usize::from(g)).is_some_and(|g| g.interior); + let mut hop: Vec> = vec![None; groups.len()]; + let mut edges: Vec<(u16, u16)> = Vec::new(); + for gs in sides.values() { + let ins: Vec = gs.iter().copied().filter(|g| interior(*g)).collect(); + match ins.as_slice() { + [g] if gs.len() == 1 || gs.iter().any(|o| !interior(*o)) => { + if let Some(h) = hop.get_mut(usize::from(*g)) { + *h = Some(0); + } + } + [a, b] => edges.push((*a, *b)), + _ => {} + } + } + for level in 0..DISTRICT_SKY_HOPS { + let mut next = Vec::new(); + for &(a, b) in &edges { + for (from, to) in [(a, b), (b, a)] { + if hop[usize::from(from)] == Some(level) && hop[usize::from(to)].is_none() { + next.push(to); + } + } + } + for g in next { + hop[usize::from(g)] = Some(level + 1); + } + } + hop.iter().map(Option::is_some).collect() +} + /// The EFFECTIVE reach (yd) of an opening `diag` yards across — the radius `R` the shader actually /// windows the pool with: `clamp(1.5*diag + 4, 6, 20)`. The linear term says a wide doorway throws /// light further in than an arrow slit; the `+4` floor keeps even a small window's pool bigger than @@ -2053,6 +2121,39 @@ mod tests { } } + /// MONKEY (daylight: district sky rooms): a building gets nothing; a district flags the room + /// with an exterior portal and its one-hop neighbour, and not the room two hops in. + #[test] + fn district_sky_rooms_follow_the_portal_graph() { + let lo = [0.0; 3]; + let hi = [1.0; 3]; + // g0 exterior shell, g1..=g3 a chain of rooms, padded with sealed rooms to district size. + let mut groups = vec![group(false, lo, hi)]; + groups.extend((0..40).map(|_| group(true, lo, hi))); + let vertices = [[0.0f32; 3]; 4]; + let infos: Vec = (0..3) + .map(|_| WmoPortalInfo { start_vertex: 0, count: 4, plane: [1.0, 0.0, 0.0, 0.0] }) + .collect(); + // p0: g1<->g0 (exterior), p1: g1<->g2, p2: g2<->g3. + let refs = [ + WmoPortalRef { portal: 0, group: 0, side: 1 }, + WmoPortalRef { portal: 1, group: 2, side: 1 }, + WmoPortalRef { portal: 1, group: 1, side: -1 }, + WmoPortalRef { portal: 2, group: 3, side: 1 }, + WmoPortalRef { portal: 2, group: 2, side: -1 }, + ]; + let mut slices = vec![(0u16, 0u16); groups.len()]; + slices[1] = (0, 2); + slices[2] = (2, 2); + slices[3] = (4, 1); + let portals = PortalGraph { vertices: &vertices, infos: &infos, refs: &refs, slices: &slices }; + let sky = district_sky_rooms(&groups, portals); + assert!(sky[1] && sky[2], "the doorway room and its neighbour"); + assert!(!sky[3], "two hops in stays dark"); + assert!(!sky[0], "the shell is not a room"); + assert!(district_sky_rooms(&groups[..5], portals).is_empty(), "a building is untouched"); + } + /// The reach formula: linear in the opening's diagonal, floored at 6 and capped at 20. #[test] fn reach_is_the_clamped_opening_width() { diff --git a/crates/benilla-world/src/lighting/daylight/census.rs b/crates/benilla-world/src/lighting/daylight/census.rs index 7dac8fe7..b81f29fd 100644 --- a/crates/benilla-world/src/lighting/daylight/census.rs +++ b/crates/benilla-world/src/lighting/daylight/census.rs @@ -192,6 +192,18 @@ fn census(path: &str) { ); } println!("interior rooms: {n_day} reached by daylight, {n_bleed} by bleed only, {n_none} by neither"); + let sky = district_sky_rooms(&l.groups, graph); + let enclosed = (0..l.groups.len() as u16) + .filter(|g| benilla_formats::room_claim::enclosed_by_building_shell(&l.groups, *g)) + .count(); + let dark_sky = (0..l.groups.len()) + .filter(|g| sky.get(*g).copied().unwrap_or(false)) + .filter(|g| !day_claim.contains_key(&(*g as u16))) + .count(); + println!( + "day floor: {enclosed} rooms enclosed by a building shell; {} district sky rooms ({dark_sky} of them reached by no daylight fixture)", + sky.iter().filter(|b| **b).count() + ); } /// `WOW_CENSUS_PLACE=map,tx,ty,uid`: the MODF placement, for world coordinates in the table. diff --git a/crates/benilla-world/src/lighting/mod.rs b/crates/benilla-world/src/lighting/mod.rs index 027a4ce0..2605f50c 100644 --- a/crates/benilla-world/src/lighting/mod.rs +++ b/crates/benilla-world/src/lighting/mod.rs @@ -26,6 +26,8 @@ pub use blob::LightBlob; pub use daylight::{ daylight_claims, daylight_intensity, daylight_lane, daylight_point_light, daylight_reach, daylight_rooms, daylight_seeds, daylight_target, bleed_seeds, placement_openings, BleedFixture, + // MONKEY (daylight: district sky rooms) + district_sky_rooms, BleedSeed, DaylightFixture, DaylightHow, DaylightSeed, BLEED_K, MAX_DAYLIGHT_PER_PLACEMENT, }; diff --git a/crates/benilla-world/src/static_gx/mod.rs b/crates/benilla-world/src/static_gx/mod.rs index 22858cd9..02222794 100644 --- a/crates/benilla-world/src/static_gx/mod.rs +++ b/crates/benilla-world/src/static_gx/mod.rs @@ -357,6 +357,9 @@ pub enum GxSite<'a> { instance: Entity, groups: &'a [u16], bounds: &'a [benilla_formats::WmoGroupInfo], + /// MONKEY (daylight: district sky rooms): per ABSOLUTE group, a city room connected to + /// the sky by the portal graph (`lighting::district_sky_rooms`); empty for a building. + sky: &'a [bool], }, /// A WMO doodad prop (B4, decision 1433 — 1418's lane 3, absorbed): the building's /// instance entity, the referrer set of rooms that name the prop, and the interior diff --git a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs index 0691ba19..fa58849d 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs @@ -322,6 +322,7 @@ pub fn spawn_model_entities( instance, groups, bounds, + sky, } if is_wmo && class.merges() => { groups.get(batch_idx).map(|&g| { @@ -346,7 +347,10 @@ pub fn spawn_model_entities( // in hand, and the answer rides to the shader as a record bit. enclosed: benilla_formats::room_claim::enclosed_by_building_shell( bounds, g, - ), + ) + // MONKEY (daylight: district sky rooms): …or a city room + // the portal graph connects to the sky. + || sky.get(usize::from(g)).copied().unwrap_or(false), class: sub.wmo_batch, sidn: sub.sidn, window: sub.window, diff --git a/crates/benilla-world/src/terrain_stream/spawn/mod.rs b/crates/benilla-world/src/terrain_stream/spawn/mod.rs index 0a13ea97..aa3abb96 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs @@ -330,6 +330,17 @@ pub(super) fn spawn_loaded_placements( }); // The portal-cull instance, spawned before the batches: the retained pass keys // its region on it, and the region lives as long as it does. + // MONKEY (daylight: district sky rooms): which city rooms take the day floor. + let sky_slices = fx::portal_slices(m); + let sky = crate::lighting::district_sky_rooms( + &m.group_bounds, + benilla_formats::PortalGraph { + vertices: &m.portal_vertices, + infos: &m.portal_infos, + refs: &m.portal_refs, + slices: &sky_slices, + }, + ); let instance = (has_portals || m.wmo_id != 0).then(|| { commands .spawn(WmoPortalInstance { @@ -405,6 +416,7 @@ pub(super) fn spawn_loaded_placements( // MONKEY (ext-class night law): the MOGI table, so the // assembler can read each batch's group CLASS + BOX. bounds: &m.group_bounds, + sky: &sky, }, ) })