MONKEY (daylight): city rooms the portal graph connects to the sky take the interiorDaylight floor; capture knobs + torch/goldshire scenario; LIGHTING.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 04:38:01 +01:00
co-authored by Claude Opus 5.5
parent 3f1637b521
commit 310bed8941
9 changed files with 163 additions and 2 deletions
+5
View File
@@ -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 <Data> wmolights <wmo> [--verts <group>]`, `wmolamps`, `m2firescan`
print the inputs the system works from (groups, batch classes, portals, claims, flame emitters).
@@ -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,
},
];
+18 -1
View File
@@ -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<f32> {
static V: std::sync::OnceLock<Option<f32>> = 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::<f32>().ok())
.filter(|v| v.is_finite())
.map(|v| v.clamp(0.0, 1.0))
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -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<bool> {
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<u16, Vec<u16>> = 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<Option<u8>> = vec![None; groups.len()];
let mut edges: Vec<(u16, u16)> = Vec::new();
for gs in sides.values() {
let ins: Vec<u16> = 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<WmoPortalInfo> = (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() {
@@ -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.
+2
View File
@@ -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,
};
@@ -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
@@ -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,
@@ -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,
},
)
})