Merge gfx/daylight into integration
# Conflicts: # LIGHTING.md # crates/benilla-app/src/cvars.rs
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user