diff --git a/LIGHTING.md b/LIGHTING.md index 96f891a0..f09f04d2 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` | @@ -56,6 +58,9 @@ is its own module, documented in `WATER.md`. `entities/spell_fx/lifecycle.rs`, `dynamic_interior.rs`, `debug_panel/lighting_controls.rs`, and the cvars in `cvars.rs` / `video.rs`. - **Sky** (`crates/benilla-world/src`): `sky_fx.rs` (tier, clock, glow inputs), `shaders/sky_fx.wgsl` (gradient curve, glow, stars, cloud noise), the tier branches in `shaders/sky.wgsl` and `shaders/cloud.wgsl`, `clouds/layer.rs` (`update_cloud_fx`); the cvar bridge is `benilla-app/src/sky_quality.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/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index aab5f0be..fc95db89 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -519,6 +519,7 @@ interiorLight = "Dynamic Interior Lighting", interiorShadows = "Indoor Torch Shadows", exteriorShadows = "Outdoor Torch Shadows", + torchTerrainShadows = "Terrain Blocks Torchlight", torchSoft = "Torch Shadow Softness", fireLight = "Fire Light Brightness", fireFlicker = "Fire Flicker", @@ -599,6 +600,13 @@ "The same for fires out in the open: campfires, braziers and lampposts " .. "cast real shadows after dark.\n\n" .. "It does nothing by day and does not require Dynamic Interior Lighting.", + TORCH_TERRAIN_SHADOWS = + "Lets the ground itself take part in outdoor torch shadows: a hill or a " + .. "bank between a fire and the slope behind it blocks the fire's light. + +" + .. "Adds the nearby terrain to each outdoor fire's shadow map when it is " + .. "rebuilt; the per-frame cost is small. Needs Outdoor Torch Shadows.", TORCH_SOFT = "How soft the edge of a torch shadow is where the object meets the " .. "ground. Low is hard and crisp; high spreads the edge the way a real " @@ -668,6 +676,7 @@ interiorLight = "Динамическое освещение помещений", interiorShadows = "Тени факелов в помещениях", exteriorShadows = "Тени огней под открытым небом", + torchTerrainShadows = "Рельеф заслоняет огни", torchSoft = "Мягкость теней от огня", fireLight = "Яркость огня", fireFlicker = "Мерцание огня", @@ -756,6 +765,14 @@ "То же для огней под открытым небом: костры, жаровни и фонари " .. "отбрасывают настоящие тени после наступления темноты.\n\n" .. "Днём не действует и не требует динамического освещения помещений.", + TORCH_TERRAIN_SHADOWS = + "Земля сама участвует в тенях от огней снаружи: холм или склон между " + .. "костром и местностью за ним заслоняет свет огня. + +" + .. "Добавляет ближний рельеф в карту теней каждого уличного огня при её " + .. "перестройке; затраты на кадр невелики. Требует теней огней под " + .. "открытым небом.", TORCH_SOFT = "Насколько мягок край тени от огня там, где предмет касается пола. " .. "Низкое значение — резкий край, высокое — размытый, как от " @@ -853,7 +870,9 @@ "RowBloom", "RowSunShafts", "RowColorGrading", "RowCharacterShadows", "RowWorldShadows", "RowShadowDistance", "RowShadowResolution", "RowMoonShadows", - "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft", + "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", + -- MONKEY (daylight: terrain torch casters) + "RowTorchTerrainShadows", "RowTorchSoft", "RowFireLight", "RowFireFlicker", "RowSpellLights", "RowLavaGlow", "RowNightDarkness", "RowInteriorDarkness", -- MONKEY (p0 skyDither) @@ -1372,6 +1391,9 @@ local torch = (interior and GetCVar("interiorShadows") ~= "0") or GetCVar("exteriorShadows") ~= "0" OptionsRow_SetEnabled(getglobal(base .. "RowTorchSoft"), torch) + -- MONKEY (daylight: terrain torch casters): the ground only casts for outdoor fires. + OptionsRow_SetEnabled(getglobal(base .. "RowTorchTerrainShadows"), + GetCVar("exteriorShadows") ~= "0") return end if key == "Controls" then @@ -2833,11 +2855,23 @@ "BENILLA_TOOLTIP_EXTERIOR_SHADOWS") - + + + + OptionsRow_OnLoad(self, "torchTerrainShadows", + BENILLA_ADVGFX.torchTerrainShadows, + "BENILLA_TOOLTIP_TORCH_TERRAIN_SHADOWS") + + + + + + + -- 0.5..3, the CVar's own clamp verbatim: it is a -- SCALE on the PCF tap radius at a contact, so the diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 1bed39f5..2b9d643a 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -1380,6 +1380,47 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ look: LAVA_RIVER_LOOK, minute: 0, ui: None, + }, // MONKEY (daylight): city interiors by day and terrain torch shadows at night. World coords + // from the census (`lighting::daylight::census`, Stormwind uid 10047, Ironforge uid 7706). + // The Gilded Rose ground floor (group g268, portal + aperture seeds). + Scenario { + name: "daylight-sw-inn", map: Some(MAP_AZEROTH), + eye: [-8871.0, 681.0, 99.8], look: [-8858.0, 668.0, 98.6], minute: 720, ui: None, + }, + // The Cathedral of Light nave (groups g135/g146, lit only by two EXT-class window batches). + Scenario { + name: "daylight-sw-cathedral", map: Some(MAP_AZEROTH), + eye: [-8556.0, 826.0, 109.0], look: [-8515.0, 862.0, 112.0], minute: 720, ui: None, + }, + // A Trade District house (group g68 NEH02, one exterior portal). + Scenario { + name: "daylight-sw-shop", map: Some(MAP_AZEROTH), + eye: [-8799.0, 696.0, 104.3], look: [-8786.0, 707.0, 103.0], minute: 720, ui: None, + }, + // Ironforge: the Great Forge hall (g64, no opening to the sky) and the gate hall (g7, the + // city's one exterior portal). + Scenario { + name: "daylight-if-forge", map: Some(MAP_AZEROTH), + eye: [-4930.0, -945.0, 503.5], look: [-4890.0, -985.0, 503.0], minute: 720, ui: None, + }, + 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, + }, // Two Elwynn road lampposts whose own light the terrain blocks most (the census's + // `lamp_terrain_occlusion_scan`: 18 % and 16 % of the ground within 25 yd), at midnight, framed + // across the lamp toward the blocked side. + Scenario { + name: "daylight-torch-hill-a", map: Some(MAP_AZEROTH), + eye: [-9314.8, 134.1, 70.0], look: [-9320.4, 165.6, 64.0], minute: 0, ui: None, + }, + Scenario { + name: "daylight-torch-hill-b", map: Some(MAP_AZEROTH), + eye: [-9163.1, 181.1, 77.0], look: [-9147.1, 153.4, 71.0], minute: 0, ui: None, }, // MONKEY (post): lane-specific A/B and perf viewpoints. Kept at the end for merge isolation. Scenario { diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 83d03925..936c40cc 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -1087,6 +1087,12 @@ pub(crate) const REGISTERED: &[Registered] = &[ "1", "benilla's own: outdoor fire lights (campfires, braziers, lampposts) cast real shadows at night; no effect by day", ), + // MONKEY (daylight: terrain torch casters): the ground as a torch caster. + ours( + "torchTerrainShadows", + "0", + "benilla's own: the ground casts into outdoor fire shadows (hills and banks block a fire's light); needs exteriorShadows", + ), // MONKEY (static torch cache): residency and per-frame work have separate live budgets. ours( "interiorShadowCasters", @@ -2943,7 +2949,7 @@ mod tests { let shadows = VideoConfig::default(); let flag = |b: bool| if b { 1.0 } else { 0.0 }; // MONKEY (volumetric fog): include the atmospheric tier in this fixed-size default table. - let lighting: [(&str, f32); 36] = [ + let lighting: [(&str, f32); 37] = [ ("waterQuality", shadows.water_quality as f32), // MONKEY (volumetric fog): weld registry and renderer defaults. ("volumetricFog", shadows.volumetric_fog as f32), @@ -2975,6 +2981,8 @@ mod tests { // MONKEY (torch shadows): the cube-map lane, indoors and out. ("interiorShadows", flag(shadows.interior_shadows)), ("exteriorShadows", flag(shadows.exterior_shadows)), + // MONKEY (daylight: terrain torch casters) + ("torchTerrainShadows", flag(shadows.torch_terrain_shadows)), ( "interiorShadowCasters", shadows.interior_shadow_casters as f32, @@ -3006,7 +3014,7 @@ mod tests { // row the client does not have; the length is the other half — 36 rows, 36 welds. let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect(); // MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld. - assert_eq!(welded.len(), 36, "the lighting lane welds 36 distinct rows"); + assert_eq!(welded.len(), 37, "the lighting lane welds 37 distinct rows"); for name in &welded { assert!( REGISTERED.iter().any(|r| r.name == *name), diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs index 1fbb6fa8..edb6aa9e 100644 --- a/crates/benilla-app/src/dynamic_interior.rs +++ b/crates/benilla-app/src/dynamic_interior.rs @@ -111,7 +111,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 @@ -123,6 +125,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-app/src/torch_shadow.rs b/crates/benilla-app/src/torch_shadow.rs index 26d8f3d7..621cc18a 100644 --- a/crates/benilla-app/src/torch_shadow.rs +++ b/crates/benilla-app/src/torch_shadow.rs @@ -645,6 +645,23 @@ struct EntityCasters<'w, 's> { /// bundle rather than being a 17th system param because `update_torch_shadows` already sits at /// Bevy's 16-param ceiling — the same reason `distance` is here. lighting: Res<'w, WowLighting>, + /// MONKEY (daylight: terrain torch casters): the resident ADT tiles, for the ground half of an + /// exterior slot's static caster mesh (`torchTerrainShadows`). `Option` so a scene with no + /// terrain streamer (the glue, a booth) is simply terrain-less. + terrain: Option>, + adt_tiles: Option>>, +} + +/// MONKEY (daylight: terrain torch casters): does an EXTERIOR slot's static map include the ground? +/// The cvar `torchTerrainShadows`, or `WOW_TORCH_TERRAIN=0|1` (read once) for hermetic captures. +fn terrain_casters_on(video: &VideoConfig) -> bool { + static ENV: std::sync::OnceLock> = std::sync::OnceLock::new(); + ENV.get_or_init(|| match std::env::var("WOW_TORCH_TERRAIN").ok().as_deref() { + Some("1") => Some(true), + Some("0") => Some(false), + _ => None, + }) + .unwrap_or(video.torch_terrain_shadows) } /// The INTERIOR lane is active when the dynamic-interior lane is on AND its shadow toggle is on. @@ -1123,9 +1140,22 @@ fn update_torch_shadows( // While this pair is unchanged the fixture-local source set cannot have changed either, so the // slot's walk is skipped outright — which is what turns "a full scene scan every frame, // forever, in a room where nothing ever moves" into "a scan the frame something does". + // MONKEY (daylight: terrain torch casters): the resident terrain set joins the retained + // scene's stamp, so a tile streaming in re-checks the exterior slots near it; the switch itself + // is folded in so toggling the cvar re-keys every slot. + let terrain_on = terrain_casters_on(&video); + let terrain_gen = match (&ents.terrain, &ents.adt_tiles) { + (Some(t), Some(a)) if terrain_on => { + benilla_world::terrain_stream::terrain_torch_generation(t, a) ^ 0x7e44_a1d0 + } + _ => 0, + }; let census = (slot_count > 0) .then(|| PartCensus { - gx: gx.as_ref().map_or(0, |gx| gx.torch_residency_generation()), + gx: gx + .as_ref() + .map_or(0, |gx| gx.torch_residency_generation()) + .wrapping_add(terrain_gen), parts: static_part_census(&ents), }) .unwrap_or_default(); @@ -1210,6 +1240,12 @@ fn update_torch_shadows( entity_key = entity_key.wrapping_add(part_hash.finish()); } entity_key.hash(&mut hash); + // MONKEY (daylight: terrain torch casters): settled EXTERIOR slots only. An interior + // fixture's room already occludes the ground, and a moving slot regathers every frame. + let with_terrain = terrain_gen != 0 && slot.exterior && !moving; + if with_terrain { + terrain_gen.hash(&mut hash); + } let key = hash.finish(); let dirty = slot.geometry_key != Some(key); if !dirty { @@ -1235,6 +1271,17 @@ fn update_torch_shadows( }); let (_, part_excluded) = collect_torch_entities(&ents, true, &gather, &mut positions, &mut indices); + if with_terrain { + if let (Some(t), Some(a)) = (&ents.terrain, &ents.adt_tiles) { + let t0 = std::time::Instant::now(); + let tris = benilla_world::terrain_stream::append_terrain_torch_triangles( + t, a, slot.pos, range, &mut positions, &mut indices); + if std::env::var_os("WOW_TORCH_TRACE").is_some() { + info!("torch-terrain: slot {i} +{tris} ground tris within {range:.0} yd in {:.3} ms", + t0.elapsed().as_secs_f64() * 1e3); + } + } + } slot.trace_excluded = (gx_excluded, part_excluded); // MONKEY (moving fixture): a live slot MUTATES its mesh in place and keeps its asset // id; only a settled rebuild takes a fresh one. Two reasons. A new `Mesh` asset every diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 812352c0..41062c64 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -3053,6 +3053,11 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { "BENILLA_TOOLTIP_EXTERIOR_SHADOWS", "AdvancedGraphicsRowExteriorShadows", ), + // MONKEY (daylight: terrain torch casters) + ( + "BENILLA_TOOLTIP_TORCH_TERRAIN_SHADOWS", + "AdvancedGraphicsRowTorchTerrainShadows", + ), ("BENILLA_TOOLTIP_TORCH_SOFT", "AdvancedGraphicsRowTorchSoft"), ("BENILLA_TOOLTIP_FIRE_LIGHT", "AdvancedGraphicsRowFireLight"), ( @@ -3137,7 +3142,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { // and Interior Darkness. The three rows that MOVED onto it are already in the 81: a row // changing pages does not change this count, only its entry in BENILLA_OWNED. 81 -> 93. // Water Quality and Lava Glow add two more: 93 -> 95. - assert_eq!(checked, 95, "every tipped row carries a live key"); + // MONKEY (daylight: terrain torch casters): Terrain Blocks Torchlight. 95 -> 96. + assert_eq!(checked, 96, "every tipped row carries a live key"); assert_eq!( untipped, vec![ @@ -3269,7 +3275,8 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() { // whose descriptions are all benilla's. // Water Quality and Lava Glow add two more: 93 -> 95. // MONKEY (volumetric fog): the atmosphere dropdown adds one more described row. - assert_eq!(raised, 96, "every row but Auto Loot raises a description"); + // MONKEY (daylight: terrain torch casters): …and Terrain Blocks Torchlight one more. + assert_eq!(raised, 97, "every row but Auto Loot raises a description"); } /// The **Combat page** (decision 1134) — the first rows in this window whose store is a diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index fd6a1e68..709c56d0 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -322,6 +322,11 @@ pub(crate) struct VideoConfig { /// It shares `interior_shadow_casters`' sixteen resident cube slots, capped at half of them /// (`torch_shadow::exterior_budget`) so a village square cannot evict an inn's candles. pub(crate) exterior_shadows: bool, + /// MONKEY (daylight: terrain torch casters): whether the GROUND casts into an exterior torch's + /// cube map (`torchTerrainShadows`, default off; High = on) — a hill or bank between a fire and + /// the slope behind it blocks the fire. Only settled exterior slots gather it + /// (`torch_shadow`); `0` leaves the torch lane exactly as it was. + pub(crate) torch_terrain_shadows: bool, /// MONKEY (static torch cache): resident fixture budget (1..16, default 12). Static /// geometry renders only on promotion/residency changes; lowering this fades extra slots out. pub(crate) interior_shadow_casters: u32, @@ -447,6 +452,7 @@ impl Default for VideoConfig { // MONKEY (outdoor torch shadows): on — a night campfire with no shadow is the thing // this lane exists to fix, and it costs nothing whenever the sun is up. exterior_shadows: true, + torch_terrain_shadows: false, // MONKEY (static torch cache): 12 resident maps, four moving-caster overlays. interior_shadow_casters: 12, interior_shadow_dynamic: 4, @@ -622,6 +628,8 @@ pub(crate) fn on_cvar( // reads `VideoConfig` every frame, so `0` fades the outdoor shadows out (the slots evict // through the same cross-fade a walked-away fixture does) and `1` fades them back in. "exteriorshadows" => cfg.exterior_shadows = ev.flag(), + // MONKEY (daylight: terrain torch casters) + "torchterrainshadows" => cfg.torch_terrain_shadows = ev.flag(), // MONKEY (torch caster selection): the working-set size and the PCF radius, clamped at the // edge like every other numeric row. `casters` floors at 1, not 0 — `interiorShadows 0` is // already the off switch, and a 0 here would be a second, confusing one. diff --git a/crates/benilla-world/src/lighting/daylight.rs b/crates/benilla-world/src/lighting/daylight.rs index 27ee4a3c..2fed0fbf 100644 --- a/crates/benilla-world/src/lighting/daylight.rs +++ b/crates/benilla-world/src/lighting/daylight.rs @@ -300,6 +300,90 @@ 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 relation the PORTAL seed stands the sun in) or an EXT-class batch (the +/// APERTURE population: a window), 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<'a, I>( + groups: &[WmoGroupInfo], + portals: PortalGraph<'_>, + batches: I, +) -> Vec +where + I: IntoIterator, +{ + 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)), + _ => {} + } + } + // …and a room with an EXT-class batch — a window or an open side (the APERTURE population). + for (g, ext_class, _) in batches { + if ext_class && interior(g) { + if let Some(h) = hop.get_mut(usize::from(g)) { + *h = Some(0); + } + } + } + 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 @@ -622,6 +706,9 @@ where let portal_seeds = out.len(); // --- 2. APERTURE seeds: an EXT-class batch of an INTERIOR group (see the module doc). + // MONKEY (daylight: window split): on a DISTRICT placement an EXT batch wider than + // [`APERTURE_MAX_DIAG`] is split into its spatial clusters first ([`window_boxes`]). + let district = groups.iter().filter(|g| g.interior).count() > DAYLIGHT_DISTRICT_ROOMS; if want_apertures { for &(gi, ext_class, positions) in &batches { if !ext_class || interior_of(gi) != Some(true) { @@ -630,35 +717,34 @@ where let Some(g) = groups.get(usize::from(gi)) else { continue; }; - let Some((lo, hi)) = bounds(positions.iter().copied()) else { - continue; - }; - let diag = diagonal(lo, hi); - if !(APERTURE_MIN_DIAG..=APERTURE_MAX_DIAG).contains(&diag) { - continue; + for (lo, hi) in window_boxes(positions, district) { + let diag = diagonal(lo, hi); + if !(APERTURE_MIN_DIAG..=APERTURE_MAX_DIAG).contains(&diag) { + continue; + } + let (c, hz) = seed_point(lo, hi); + let pos = nudge_inward(c, g, None); + // The authored portal wins a shared opening — see [`SEED_DEDUPE_YD`]. + let dup = out[..portal_seeds].iter().any(|s| { + (s.pos[0] - pos[0]).powi(2) + + (s.pos[1] - pos[1]).powi(2) + + (s.pos[2] - pos[2]).powi(2) + < SEED_DEDUPE_YD * SEED_DEDUPE_YD + }); + if dup { + continue; + } + out.push(DaylightSeed { + group: gi, + portal: None, + how: DaylightHow::Aperture, + pos, + diag, + area: opening_area(lo, hi), + hz, + round: 0, + }); } - let (c, hz) = seed_point(lo, hi); - let pos = nudge_inward(c, g, None); - // The authored portal wins a shared opening — see [`SEED_DEDUPE_YD`]. - let dup = out[..portal_seeds].iter().any(|s| { - (s.pos[0] - pos[0]).powi(2) - + (s.pos[1] - pos[1]).powi(2) - + (s.pos[2] - pos[2]).powi(2) - < SEED_DEDUPE_YD * SEED_DEDUPE_YD - }); - if dup { - continue; - } - out.push(DaylightSeed { - group: gi, - portal: None, - how: DaylightHow::Aperture, - pos, - diag, - area: opening_area(lo, hi), - hz, - round: 0, - }); } } @@ -710,6 +796,108 @@ where out } +/// MONKEY (daylight: window split): the openings one EXT-class batch stands for, as boxes. +/// +/// MEASURED (`benilla-extract wmolights` on Stormwind, 2026-09-25): the Cathedral of Light's nave +/// authors ALL its stained-glass windows as ONE EXT batch per wall — `g135 b11` spans +/// 15 x 44 x 20 yd (diag 51), `g146 b10` 30 x 44 x 10 yd (diag 54) — so [`APERTURE_MAX_DIAG`] +/// refused the batch whole and the nave took no daylight through a single window. Its windows are +/// separate panes between pillars, so the batch's vertices fall into separate clusters at the +/// plan linkage of [`plan_clusters`], and each window-sized cluster is an aperture. +/// +/// District placements only, and only for an oversized batch: every building (the inn, the abbey) +/// keeps its calibrated seed list byte for byte, and a batch that is already window-sized is one +/// box as before. At most [`WINDOW_SPLIT_MAX`] boxes per batch, largest first. +fn window_boxes(positions: &[[f32; 3]], district: bool) -> Vec<([f32; 3], [f32; 3])> { + let Some((lo, hi)) = bounds(positions.iter().copied()) else { + return Vec::new(); + }; + if !district || diagonal(lo, hi) <= APERTURE_MAX_DIAG { + return vec![(lo, hi)]; + } + let mut boxes: Vec<([f32; 3], [f32; 3])> = plan_clusters(positions) + .into_iter() + .filter(|(l, h)| (APERTURE_MIN_DIAG..=APERTURE_MAX_DIAG).contains(&diagonal(*l, *h))) + .collect(); + // Stable: `cluster_boxes` is already sorted, and ties keep that order. + boxes.sort_by(|a, b| opening_area(b.0, b.1).total_cmp(&opening_area(a.0, a.1))); + boxes.truncate(WINDOW_SPLIT_MAX); + boxes +} + +/// MONKEY (daylight: window split): single-link clustering of `points` in PLAN (x, y) at +/// [`WINDOW_GAP_YD`], one 3-D `(lo, hi)` box per cluster. In plan because a pane's top and bottom +/// corners share their plan position however tall it is (a stained-glass pane is often one quad, +/// its vertices 8 yd apart vertically), while two panes of one wall are split by a pillar. +fn plan_clusters(points: &[[f32; 3]]) -> Vec<([f32; 3], [f32; 3])> { + fn find(parent: &mut [usize], mut i: usize) -> usize { + while parent[i] != i { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + i + } + let cell = |p: [f32; 3]| { + [ + (p[0] / WINDOW_GAP_YD).floor() as i32, + (p[1] / WINDOW_GAP_YD).floor() as i32, + ] + }; + let mut index: HashMap<[i32; 2], usize> = HashMap::new(); + let mut cells: Vec<[i32; 2]> = Vec::new(); + let of_point: Vec = points + .iter() + .map(|p| { + let c = cell(*p); + *index.entry(c).or_insert_with(|| { + cells.push(c); + cells.len() - 1 + }) + }) + .collect(); + let mut parent: Vec = (0..cells.len()).collect(); + for (i, c) in cells.iter().enumerate() { + for dx in -1..=1 { + for dy in -1..=1 { + if let Some(&j) = index.get(&[c[0] + dx, c[1] + dy]) { + let (a, b) = (find(&mut parent, i), find(&mut parent, j)); + if a != b { + parent[a] = b; + } + } + } + } + } + let mut boxes: HashMap = HashMap::new(); + for (p, ci) in points.iter().zip(of_point.iter()) { + let root = find(&mut parent, *ci); + let e = boxes.entry(root).or_insert((*p, *p)); + for a in 0..3 { + e.0[a] = e.0[a].min(p[a]); + e.1[a] = e.1[a].max(p[a]); + } + } + let mut out: Vec<([f32; 3], [f32; 3])> = boxes.into_values().collect(); + // Deterministic: a `HashMap` walk is not. + out.sort_by(|a, b| { + a.0[0] + .total_cmp(&b.0[0]) + .then(a.0[1].total_cmp(&b.0[1])) + .then(a.0[2].total_cmp(&b.0[2])) + }); + out +} + +/// MONKEY (daylight: window split): the plan-cell size (yd) that links one pane's vertices and no +/// more: two points in neighbouring cells are at most ~4 yd apart, and a pillar between two panes +/// is wider than the one-cell gap it takes to split them. +const WINDOW_GAP_YD: f32 = 1.5; + +/// MONKEY (daylight: window split): the most apertures one EXT batch may become. A nave wall is +/// three to five panes; the cap keeps a pathological batch (a lattice of posts) from flooding the +/// placement's quota, which ranks one room's second opening after every room's first anyway. +const WINDOW_SPLIT_MAX: usize = 6; + /// MONKEY (daylight fixtures: boundary): every stitched interior<->exterior opening of one root, as /// `(interior group, cluster lo, cluster hi, inward unit direction)` in WMO model space. /// @@ -2035,6 +2223,10 @@ pub(super) fn register(app: &mut App) { ); } +// MONKEY (daylight): the city census instrument. +#[cfg(test)] +mod census; + #[cfg(test)] mod tests { use super::*; @@ -2049,6 +2241,64 @@ 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 none: [(u16, bool, &[[f32; 3]]); 0] = []; + let sky = district_sky_rooms(&groups, portals, none); + 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, none).is_empty(), "a building is untouched"); + // A window (EXT-class batch) makes g3 a sky room too. + let window = [[0.0f32; 3], [1.0, 1.0, 1.0]]; + let sky = district_sky_rooms(&groups, portals, [(3u16, true, &window[..])]); + assert!(sky[3], "a room with a window"); + } + + /// MONKEY (daylight: window split): a district's wall-long EXT batch becomes its panes; a + /// building's stays one box (and is then refused as before). + #[test] + fn a_district_window_wall_splits_into_panes() { + // Three 2 x 0.2 x 4 yd panes, 10 yd apart along +Y: one batch, diag > 24. + let mut pts = Vec::new(); + for i in 0..3 { + let y = i as f32 * 12.0; + for (dy, dz) in [(0.0, 0.0), (2.0, 0.0), (0.0, 4.0), (2.0, 4.0), (1.0, 2.0)] { + pts.push([0.0, y + dy, dz]); + pts.push([0.2, y + dy, dz]); + } + } + pts.push([0.0, 0.0, 0.0]); + assert_eq!(window_boxes(&pts, false).len(), 1, "a building keeps the whole batch"); + let panes = window_boxes(&pts, true); + assert_eq!(panes.len(), 3, "a district splits it into its panes"); + assert!(panes.iter().all(|(l, h)| diagonal(*l, *h) < 6.0)); + } + /// 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 new file mode 100644 index 00000000..71101c25 --- /dev/null +++ b/crates/benilla-world/src/lighting/daylight/census.rs @@ -0,0 +1,343 @@ +//! MONKEY (daylight): the city daylight census (test-only instrument). Loads a WMO root from the +//! install, runs the live selection rule over it and prints which interior rooms get a daylight +//! seed, which do not, and what each unseeded room authors (batch classes, WINDOW / SIDN +//! materials, portal neighbours), plus the cost of each seed pass. +//! +//! `WOW_DATA= cargo test -p benilla-world --lib lighting::daylight::census -- --ignored --nocapture` +//! `WOW_CENSUS_WMO=` picks the root (default: Stormwind then Ironforge). + +use super::*; +use benilla_formats::{open_chain, parse_wmo_portals, parse_wmo_root, wmo_group_header}; + +struct Loaded { + groups: Vec, + names: Vec, + portals: benilla_formats::WmoPortals, + slices: Vec<(u16, u16)>, + /// `(group, class, window, sidn, positions)` per render batch. + batches: Vec<( + u16, + benilla_formats::WmoBatchClass, + bool, + bool, + Vec<[f32; 3]>, + )>, +} + +fn group_names(bytes: &[u8]) -> Vec { + let (mut mogn, mut mogi) = (None, None); + let mut o = 0usize; + while o + 8 <= bytes.len() { + let tag = [bytes[o + 3], bytes[o + 2], bytes[o + 1], bytes[o]]; + let n = u32::from_le_bytes(bytes[o + 4..o + 8].try_into().unwrap()) as usize; + let body = bytes.get(o + 8..o + 8 + n); + match (&tag, body) { + (b"MOGN", Some(b)) => mogn = Some(b), + (b"MOGI", Some(b)) => mogi = Some(b), + _ => {} + } + o += 8 + n; + } + let (Some(mogn), Some(mogi)) = (mogn, mogi) else { + return Vec::new(); + }; + mogi.chunks_exact(32) + .map(|r| { + let off = i32::from_le_bytes(r[28..32].try_into().unwrap()); + usize::try_from(off) + .ok() + .and_then(|off| mogn.get(off..)) + .map(|rest| { + let end = rest.iter().position(|&b| b == 0).unwrap_or(rest.len()); + String::from_utf8_lossy(&rest[..end]).into_owned() + }) + .unwrap_or_default() + }) + .collect() +} + +fn load(path: &str) -> Loaded { + let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); + let mut chain = open_chain(&data).expect("open MPQ chain"); + let root_path = path.replace('/', "\\").to_ascii_lowercase(); + let bytes = chain.read_file(&root_path).expect("read root"); + let root = parse_wmo_root(&bytes).expect("parse root"); + let stem = root_path.strip_suffix(".wmo").unwrap().to_string(); + let mut slices = Vec::new(); + let mut batches = Vec::new(); + for gi in 0..root.group_count() { + let Ok(gbytes) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else { + slices.push((0, 0)); + continue; + }; + let h = wmo_group_header(&gbytes); + slices.push(h.map_or((0, 0), |h| (h.portal_ref_start, h.portal_ref_count))); + for s in benilla_formats::wmo_group_submeshes(&gbytes, &root).unwrap_or_default() { + if let Some(class) = s.wmo_batch { + batches.push((gi as u16, class, s.window, s.sidn.is_some(), s.positions)); + } + } + } + Loaded { + groups: root.group_infos().to_vec(), + names: group_names(&bytes), + portals: parse_wmo_portals(&bytes), + slices, + batches, + } +} + +fn census(path: &str) { + let l = load(path); + let graph = PortalGraph { + vertices: &l.portals.vertices, + infos: &l.portals.infos, + refs: &l.portals.refs, + slices: &l.slices, + }; + let feed: Vec<(u16, bool, &[[f32; 3]])> = l + .batches + .iter() + .map(|(g, c, _, _, p)| (*g, matches!(c, benilla_formats::WmoBatchClass::Ext), &p[..])) + .collect(); + let verts: usize = feed.iter().map(|b| b.2.len()).sum(); + let rooms = l.groups.iter().filter(|g| g.interior).count(); + println!( + "=== {path}: {} groups, {rooms} interior, {} batches, {verts} batch verts", + l.groups.len(), + feed.len() + ); + + let t = std::time::Instant::now(); + let ranked = daylight_seeds_ranked(&l.groups, graph, feed.iter().copied()); + let t_ranked = t.elapsed(); + let t = std::time::Instant::now(); + let bounds = boundary_clusters(&l.groups, &feed); + let t_bound = t.elapsed(); + let (day, bleed) = placement_openings(&l.groups, graph, feed.iter().copied()); + println!( + "live ranked seeds {} ({:.1} ms); boundary clusters if forced {} ({:.1} ms); kept {} day + {} bleed of budget {}", + ranked.len(), + t_ranked.as_secs_f64() * 1e3, + bounds.len(), + t_bound.as_secs_f64() * 1e3, + day.len(), + bleed.len(), + daylight_budget(&l.groups), + ); + let mut by_how: HashMap<&str, usize> = HashMap::new(); + for s in &day { + *by_how.entry(s.how.tag()).or_default() += 1; + } + println!("kept daylight by rule: {by_how:?}"); + // Which rooms each lane actually REACHES: the fixture's own claim set (containment, portal hop, + // split), the same set the packer's room gate enforces. + let mut day_claim: HashMap> = HashMap::new(); + for s in &day { + for c in daylight_claims(&l.groups, graph, s) { + day_claim.entry(c.group).or_default().push(format!( + "{}@g{}{:?}", + s.how.tag(), + s.group, + c.how + )); + } + } + let mut bleed_claim: HashSet = HashSet::new(); + for s in &bleed { + for c in s.claims(&l.groups, graph) { + bleed_claim.insert(c.group); + } + } + let bound_groups: HashSet = bounds.iter().map(|b| b.0).collect(); + let place = placement(); + let (mut n_day, mut n_bleed, mut n_none) = (0, 0, 0); + for (gi, g) in l.groups.iter().enumerate() { + if !g.interior { + continue; + } + let gid = gi as u16; + let tag = if day_claim.contains_key(&gid) { + n_day += 1; + "DAY " + } else if bleed_claim.contains(&gid) { + n_bleed += 1; + "bleed" + } else { + n_none += 1; + "NONE " + }; + let (start, count) = l.slices[gi]; + let mut nb = Vec::new(); + for r in l + .portals + .refs + .iter() + .skip(start as usize) + .take(count as usize) + { + let other = + l.groups + .get(r.group as usize) + .map_or('?', |o| if o.interior { 'i' } else { 'e' }); + let area = benilla_formats::room_claim::portal_area(&graph, r.portal).unwrap_or(0.0); + nb.push(format!("p{}->g{}{}({:.0})", r.portal, r.group, other, area)); + } + let (mut e, mut w) = (0, 0); + for (bg, c, win, _, _) in &l.batches { + if usize::from(*bg) == gi { + e += usize::from(matches!(c, benilla_formats::WmoBatchClass::Ext)); + w += usize::from(*win); + } + } + let c = [ + 0.5 * (g.bbox_min[0] + g.bbox_max[0]), + 0.5 * (g.bbox_min[1] + g.bbox_max[1]), + 0.5 * (g.bbox_min[2] + g.bbox_max[2]), + ]; + let wc = place.map_or(c, |m| { + benilla_assets::coords::bevy_to_wow( + m.transform_point3(benilla_assets::coords::wow_to_bevy(c)), + ) + }); + println!( + " {tag} g{gi:<3} {:<18} at ({:8.1},{:7.1},{:6.1}) size ({:5.1},{:5.1},{:5.1}) E{e} W{w}{} {} {}", + l.names.get(gi).map(String::as_str).unwrap_or("-"), + wc[0], wc[1], wc[2], + g.bbox_max[0] - g.bbox_min[0], + g.bbox_max[1] - g.bbox_min[1], + g.bbox_max[2] - g.bbox_min[2], + if bound_groups.contains(&gid) { " stitch" } else { "" }, + nb.join(" "), + day_claim.get(&gid).map(|v| v.iter().take(3).cloned().collect::>().join(",")).unwrap_or_default(), + ); + } + 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, feed.iter().copied()); + 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. +fn placement() -> Option { + let spec = std::env::var("WOW_CENSUS_PLACE").ok()?; + let p: Vec<&str> = spec.split(',').collect(); + let (map, tx, ty, uid) = ( + p[0], + p[1].parse().ok()?, + p[2].parse().ok()?, + p[3].parse::().ok()?, + ); + let data = benilla_formats::wow_data()?; + let mut chain = open_chain(&data).ok()?; + let tile = benilla_formats::load_tile_mesh(&mut chain, map, tx, ty).ok()?; + let w = tile.wmos.iter().find(|w| w.unique_id == uid)?; + println!( + "placement uid {uid} pos {:?} rot {:?}", + w.position, w.rotation + ); + Some(bevy::math::Affine3A::from_scale_rotation_translation( + Vec3::ONE, + benilla_assets::coords::placement_rotation(w.rotation), + benilla_assets::coords::wow_to_bevy(w.position), + )) +} + +#[test] +#[ignore = "instrument: needs WOW_DATA; run by hand with --ignored --nocapture"] +fn city_daylight_census() { + match std::env::var("WOW_CENSUS_WMO") { + Ok(p) => census(&p), + Err(_) => { + census(r"World\wmo\Azeroth\Buildings\Stormwind\Stormwind.wmo"); + census(r"World\wmo\KhazModan\Cities\Ironforge\ironforge.wmo"); + } + } +} + +/// MONKEY (daylight: terrain torch casters): which lampposts have ground they cannot see — the +/// share of terrain points within 25 yd whose line to the lamp head (3.5 yd up) is blocked by the +/// terrain itself. Picks a vantage for the B1 capture. `WOW_LAMP_SCAN=map,x,y,radius,filter`. +#[test] +#[ignore = "instrument: needs WOW_DATA; run by hand with --ignored --nocapture"] +fn lamp_terrain_occlusion_scan() { + let spec = std::env::var("WOW_LAMP_SCAN") + .unwrap_or_else(|_| "Azeroth,-9300,150,1,lamppost".into()); + let p: Vec<&str> = spec.split(',').collect(); + let (map, cx, cy, r, filter) = ( + p[0], + p[1].parse::().unwrap(), + p[2].parse::().unwrap(), + p[3].parse::().unwrap(), + p[4].to_ascii_lowercase(), + ); + let data = benilla_formats::wow_data().expect("WOW_DATA"); + let mut chain = open_chain(&data).expect("chain"); + let (tx, ty) = benilla_formats::world_to_tile(cx, cy); + let mut chunks = Vec::new(); + let mut lamps = Vec::new(); + for x in tx as i32 - r..=tx as i32 + r { + for y in ty as i32 - r..=ty as i32 + r { + let Ok(t) = benilla_formats::load_tile_mesh(&mut chain, map, x as u32, y as u32) else { + continue; + }; + for d in &t.doodads { + if d.model.to_ascii_lowercase().contains(&filter) { + lamps.push(d.position); + } + } + chunks.push(t.chunks); + } + } + let h = |x: f32, y: f32| { + chunks + .iter() + .find_map(|c| benilla_formats::terrain_height_at(c, [x, y, 1.0e4])) + }; + let mut rows = Vec::new(); + for l in lamps { + let head = Vec3::new(l[0], l[1], l[2] + 3.5); + let (mut total, mut blocked) = (0u32, 0u32); + let mut best = (0.0f32, 0.0f32); + for ring in 1..=10 { + let d = ring as f32 * 2.5; + for k in 0..36 { + let a = k as f32 * std::f32::consts::TAU / 36.0; + let (x, y) = (l[0] + d * a.cos(), l[1] + d * a.sin()); + let Some(z) = h(x, y) else { continue }; + total += 1; + let target = Vec3::new(x, y, z + 0.3); + let steps = (d / 0.5) as i32; + let hit = (1..steps).any(|s| { + let q = head.lerp(target, s as f32 / steps as f32); + h(q.x, q.y).is_some_and(|g| g > q.z + 0.05) + }); + if hit { + blocked += 1; + best = (a.to_degrees(), d); + } + } + } + if total > 0 { + rows.push((blocked as f32 / total as f32, l, best)); + } + } + rows.sort_by(|a, b| b.0.total_cmp(&a.0)); + for (f, l, (a, d)) in rows.iter().take(12) { + println!( + "lamp ({:8.1},{:7.1},{:6.1}) terrain-blocked {:5.1}% e.g. bearing {a:5.0} deg at {d:4.1} yd", + l[0], l[1], l[2], f * 100.0 + ); + } +} diff --git a/crates/benilla-world/src/lighting/mod.rs b/crates/benilla-world/src/lighting/mod.rs index 5f0ab8c9..bb1da9a5 100644 --- a/crates/benilla-world/src/lighting/mod.rs +++ b/crates/benilla-world/src/lighting/mod.rs @@ -28,6 +28,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.rs b/crates/benilla-world/src/terrain_stream.rs index 32822b4a..c6d3e353 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -34,6 +34,9 @@ mod queries; mod spawn; mod weld; pub(crate) mod window; +// MONKEY (daylight: terrain torch casters): resident MCNK chunks as torch cube-map casters. +mod torch_terrain; +pub use torch_terrain::{append_terrain_torch_triangles, terrain_torch_generation}; use collider::{finish_colliders, impassable_wall_data, terrain_collider_data}; use furnish::furnish_tile_cells; 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..eeb37b04 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs @@ -330,6 +330,24 @@ 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, + }, + m.submeshes.iter().zip(m.submesh_group.iter()).map(|(s, g)| { + ( + *g, + matches!(s.wmo_batch, Some(benilla_formats::WmoBatchClass::Ext)), + &s.geometry.positions[..], + ) + }), + ); let instance = (has_portals || m.wmo_id != 0).then(|| { commands .spawn(WmoPortalInstance { @@ -405,6 +423,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, }, ) }) diff --git a/crates/benilla-world/src/terrain_stream/torch_terrain.rs b/crates/benilla-world/src/terrain_stream/torch_terrain.rs new file mode 100644 index 00000000..b58e2aa4 --- /dev/null +++ b/crates/benilla-world/src/terrain_stream/torch_terrain.rs @@ -0,0 +1,115 @@ +//! MONKEY (daylight: terrain torch casters): terrain as a CASTER in the torch cube maps. +//! +//! The torch lane's static caster mesh is gathered from the retained scene (`StaticGx`) and the +//! rigid model parts; the ground was never in it, so a hill or a bank between a campfire and the +//! slope behind it never blocked the fire. This module hands the gather the resident MCNK chunks +//! within a fixture's reach, as world-space triangles (hole-masked indices, draw space — the same +//! vertices `collider::terrain_collider_data` welds, so the depth map and the drawn ground agree). +//! +//! Budget: one chunk is 145 vertices / up to 256 triangles; a 48 yd sphere touches at most ~16 +//! chunks, so a slot gathers at most ~2.3k vertices and ~4k triangles once, when its static map +//! rebuilds. Chunks are culled by their own box against the sphere, then per triangle. + +use benilla_assets::coords::wow_to_bevy; +use benilla_assets::AdtTile; +use bevy::prelude::*; + +use super::TerrainStreamer; + +/// The terrain vertex bounds of one chunk in WoW axes. A chunk's positions are absolute world +/// yards, so the box is found without the tile index. +fn chunk_box(chunk: &benilla_formats::ChunkMesh) -> Option<(Vec3, Vec3)> { + let mut it = chunk.positions.iter(); + let first = Vec3::from(*it.next()?); + let (mut lo, mut hi) = (first, first); + for p in it { + let p = Vec3::from(*p); + lo = lo.min(p); + hi = hi.max(p); + } + Some((lo, hi)) +} + +fn box_sphere_d2(lo: Vec3, hi: Vec3, c: Vec3) -> f32 { + (c.clamp(lo, hi) - c).length_squared() +} + +/// Append every resident terrain triangle within `reach` of `center` (Bevy world space) to the +/// caster buffers, and return how many triangles were added. A triangle is kept when its own +/// box reaches the sphere — cheaper than an exact test and never drops a real occluder. +pub fn append_terrain_torch_triangles( + streamer: &TerrainStreamer, + adt_tiles: &Assets, + center: Vec3, + reach: f32, + positions: &mut Vec<[f32; 3]>, + indices: &mut Vec, +) -> u32 { + // Chunk boxes are in WoW axes, so test the sphere there (the conversion is a rigid swizzle). + let c = Vec3::from(benilla_assets::coords::bevy_to_wow(center)); + let r2 = reach * reach; + let mut added = 0u32; + for ts in streamer.tiles.values() { + let Some(adt) = adt_tiles.get(&ts.handle) else { + continue; + }; + for chunk in &adt.chunks { + let Some((lo, hi)) = chunk_box(chunk) else { + continue; + }; + if box_sphere_d2(lo, hi, c) > r2 { + continue; + } + let base = positions.len() as u32; + let mut used = false; + for tri in chunk.indices.as_chunks::<3>().0 { + let (Some(a), Some(b), Some(d)) = ( + chunk.positions.get(tri[0] as usize), + chunk.positions.get(tri[1] as usize), + chunk.positions.get(tri[2] as usize), + ) else { + continue; + }; + let (a, b, d) = (Vec3::from(*a), Vec3::from(*b), Vec3::from(*d)); + if box_sphere_d2(a.min(b).min(d), a.max(b).max(d), c) > r2 { + continue; + } + if !used { + positions.extend(chunk.positions.iter().map(|p| wow_to_bevy(*p).to_array())); + used = true; + } + indices.extend(tri.iter().map(|i| base + i)); + added += 1; + } + } + } + added +} + +/// A cheap stamp of the resident terrain set: which tiles are loaded (decoded) right now. Folded +/// into the torch lane's census and slot fingerprint so a tile streaming in or out re-gathers the +/// fixtures near it. Order-independent. +pub fn terrain_torch_generation(streamer: &TerrainStreamer, adt_tiles: &Assets) -> u64 { + use std::hash::{Hash, Hasher}; + let mut sum = 0u64; + for (key, ts) in &streamer.tiles { + let mut h = std::collections::hash_map::DefaultHasher::new(); + key.hash(&mut h); + ts.handle.id().hash(&mut h); + adt_tiles.contains(&ts.handle).hash(&mut h); + sum = sum.wrapping_add(h.finish()); + } + sum +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn box_sphere_distance_is_zero_inside_and_grows_outside() { + let (lo, hi) = (Vec3::ZERO, Vec3::ONE); + assert_eq!(box_sphere_d2(lo, hi, Vec3::splat(0.5)), 0.0); + assert!((box_sphere_d2(lo, hi, Vec3::new(3.0, 0.5, 0.5)) - 4.0).abs() < 1e-6); + } +}