Merge gfx/daylight into integration

# Conflicts:
#	LIGHTING.md
#	crates/benilla-app/src/cvars.rs
This commit is contained in:
pkuzic
2026-09-25 11:46:41 +01:00
16 changed files with 943 additions and 37 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` |
@@ -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 <Data> wmolights <wmo> [--verts <group>]`, `wmolamps`, `m2firescan`
print the inputs the system works from (groups, batch classes, portals, claims, flame emitters).
+36 -2
View File
@@ -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")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowTorchSoft" inherits="OptionsSliderRowTemplate">
<!-- MONKEY (daylight: terrain torch casters) -->
<Frame name="$parentRowTorchTerrainShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowExteriorShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowExteriorShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "torchTerrainShadows",
BENILLA_ADVGFX.torchTerrainShadows,
"BENILLA_TOOLTIP_TORCH_TERRAIN_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowTorchSoft" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowTorchTerrainShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowTorchTerrainShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- 0.5..3, the CVar's own clamp verbatim: it is a
-- SCALE on the PCF tap radius at a contact, so the
@@ -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 {
+10 -2
View File
@@ -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),
+18 -1
View File
@@ -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<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::*;
+48 -1
View File
@@ -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<Res<'w, benilla_world::terrain_stream::TerrainStreamer>>,
adt_tiles: Option<Res<'w, Assets<benilla_assets::AdtTile>>>,
}
/// 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<Option<bool>> = 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
@@ -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
+8
View File
@@ -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.
+278 -28
View File
@@ -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<bool>
where
I: IntoIterator<Item = (u16, bool, &'a [[f32; 3]])>,
{
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)),
_ => {}
}
}
// …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<usize> = 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<usize> = (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<usize, ([f32; 3], [f32; 3])> = 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<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 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() {
@@ -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=<Data> cargo test -p benilla-world --lib lighting::daylight::census -- --ignored --nocapture`
//! `WOW_CENSUS_WMO=<internal path>` 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<WmoGroupInfo>,
names: Vec<String>,
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<String> {
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<u16, Vec<String>> = 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<u16> = HashSet::new();
for s in &bleed {
for c in s.claims(&l.groups, graph) {
bleed_claim.insert(c.group);
}
}
let bound_groups: HashSet<u16> = 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::<Vec<_>>().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<bevy::math::Affine3A> {
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::<u32>().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::<f32>().unwrap(),
p[2].parse::<f32>().unwrap(),
p[3].parse::<i32>().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
);
}
}
+2
View File
@@ -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,
};
@@ -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
@@ -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;
@@ -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,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,
},
)
})
@@ -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<AdtTile>,
center: Vec3,
reach: f32,
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) -> 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<AdtTile>) -> 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);
}
}