From f8b05f25aefc372caad0b6134be8592a2fe0c58a Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sun, 13 Sep 2026 14:38:53 +0100
Subject: [PATCH] =?UTF-8?q?MONKEY:=20lighting=20round=203=20=E2=80=94=20da?=
=?UTF-8?q?ylight=20fixtures,=20spell=20lights,=20shadow=20quality,=208-li?=
=?UTF-8?q?ght=20exterior=20selection?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Interiors
- Daylight fixtures (lighting/daylight.rs): PORTAL/APERTURE/BOUNDARY seeds calibrated so the
room law at a door equals the sunlit reference; BLEED fixtures at interior↔interior portals
carry 0.6× the brighter side; interiorDaylight default 0 (daylight only through doors/windows).
- Darkness gains: nightGain 0.45, interiorGain 0.5 (Dim preset), interiorAmbient 0.015, fill 0.08.
- Enclosed day floor + shell candle add + TRANS night floor (capped 1.5× room, sun-gated).
- interiorBakeFloor 0.12: a per-fragment floor from the authored MOCV so fixture-starved rooms
(the Lion's Pride vestibule, 2 % of tex before) are never black; packed in sh_c16.w's fraction.
- TRANS blend continuity: smooth-max replaces the per-channel max whose crossing loci drew a
hard diagonal across the vestibule floor by day.
- Debug panel "Lighting & shadows" (Ctrl+Shift+D) with sliders for every lighting cvar + presets.
Torch cube-map shadows
- Owner exclusion (LightOwner placement/instance + flame-in-bounds) — the swinging lantern.
- torchShadowStrength 0.7 shadow floor (packed with soft into TorchTable.count.y), PCSS-lite
contact hardening (raw depth blocker search), interiorShadowSoft 1.5.
- Moving fixtures (carried lights) re-render every frame (cap 2) instead of publishing a stale map.
- Normal offset on the WMO surface lane (interior + exterior) — the imp's boxy/moiré pool.
Spell and firework lights (benilla-formats fire_light.rs spell route)
- Fire / Holy / Fel effects emit a short-lived point light (kits, missiles, impacts, firework
GO shells); frost/nature/arcane/shadow vetoed by name; lifecycle envelope; cap 24;
WOW_SPELL_LIGHT=0 kill switch.
Exterior point lights
- ≤3-nearest per draw unit → 8 (8-bit ranks, table cap 255), ranked by distance to the chunk's
box; cube occlusion only for ranks 0..2. Removes the chunk-edge "scars" at the Darkmoon Faire
(15 synthesised fixtures in one chunk's reach) and the lit-bench-over-dark-ground disagreement.
Tooling: benilla-extract wmolights --verts ; missing-model placements retire instead of
pinning the loading screen.
Co-Authored-By: Claude Fable 5.1
---
crates/benilla-app/src/cvars.rs | 100 +-
.../src/debug_panel/lighting_controls.rs | 187 ++
crates/benilla-app/src/debug_panel/mod.rs | 13 +-
crates/benilla-app/src/dynamic_interior.rs | 17 +
crates/benilla-app/src/entities.rs | 17 +
.../benilla-app/src/entities/carried_light.rs | 127 +
crates/benilla-app/src/entities/dest_fx.rs | 16 +
crates/benilla-app/src/entities/missile.rs | 11 +
.../src/entities/spell_fx/lifecycle.rs | 221 ++
.../benilla-app/src/entities/spell_fx/mod.rs | 14 +
crates/benilla-app/src/portrait/glue_booth.rs | 1 +
crates/benilla-app/src/torch_shadow.rs | 577 ++++-
crates/benilla-app/src/video.rs | 70 +-
crates/benilla-assets/src/lib.rs | 3 +
crates/benilla-assets/src/m2.rs | 79 +-
.../src/shaders/shadow_hook.wgsl | 142 +-
.../benilla-assets/src/shaders/terrain.wgsl | 245 +-
.../benilla-assets/src/shaders/wow_model.wgsl | 364 ++-
.../src/bin/benilla-extract/main.rs | 11 +-
.../src/bin/benilla-extract/scan/lighting.rs | 222 +-
crates/benilla-formats/src/fire_light.rs | 664 +++++
crates/benilla-formats/src/lib.rs | 4 +
crates/benilla-formats/src/room_claim.rs | 79 +
crates/benilla-world/src/lighting/blob.rs | 30 +-
crates/benilla-world/src/lighting/daylight.rs | 2232 +++++++++++++++++
.../src/lighting/global_light.rs | 520 +++-
crates/benilla-world/src/lighting/mod.rs | 15 +
.../benilla-world/src/shaders/static_gx.wgsl | 785 +++++-
crates/benilla-world/src/static_gx/bake.rs | 3 +
crates/benilla-world/src/static_gx/mod.rs | 13 +
crates/benilla-world/src/static_gx/render.rs | 18 +-
.../src/static_gx/torch_depth.rs | 407 ++-
.../src/terrain_stream/spawn/assemble.rs | 9 +
.../src/terrain_stream/spawn/fx.rs | 106 +-
.../src/terrain_stream/spawn/mod.rs | 44 +
35 files changed, 6920 insertions(+), 446 deletions(-)
create mode 100644 crates/benilla-app/src/debug_panel/lighting_controls.rs
create mode 100644 crates/benilla-world/src/lighting/daylight.rs
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index d291827a..8b378d33 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -590,8 +590,8 @@ pub(crate) const REGISTERED: &[Registered] = &[
// shadow rig (one sun / one map). `worldShadows` = the static world (trees, buildings, foliage)
// casts realtime shadows and baked MCSH terrain shadows switch off; `characterShadows` =
// players/NPCs/creatures/mounts cast realtime silhouettes instead of the legacy oval blob.
- same("worldShadows", "0"),
- same("characterShadows", "0"),
+ same("worldShadows", "1"),
+ same("characterShadows", "1"),
// Realtime-shadow render distance in yards (the shadow-map cascade range + caster reach).
// benilla's own — the reference has no realtime shadow to size. Clamped to SHADOW_DISTANCE_RANGE.
ours(
@@ -640,12 +640,12 @@ pub(crate) const REGISTERED: &[Registered] = &[
),
ours(
"interiorAmbient",
- "0.15",
+ "0.015",
"benilla's own: interior base ambient, 0..1",
),
ours(
"interiorFill",
- "0.12",
+ "0.08",
"benilla's own: interior per-fixture bounce gain, 0..2",
),
ours(
@@ -724,14 +724,64 @@ pub(crate) const REGISTERED: &[Registered] = &[
// extra texel-neighbourhood taps' worth of cache pressure, no extra samples.
ours(
"interiorShadowSoft",
- "1",
- "benilla's own: torch-shadow edge softness — the PCF tap radius scale, 0.5..3",
+ "1.5",
+ "benilla's own: torch-shadow edge softness — the PCF tap radius scale at a CONTACT, 0.5..3",
+ ),
+ // MONKEY (shadow floor): how BLACK a torch shadow is allowed to get. The lane's shadows were
+ // the only occlusion in the direct term and took all of it, which is what made them read as
+ // scars rather than as shadows; 0.7 leaves 30 % standing in place of the bounce light this
+ // renderer does not have. `1` is the shipped look, `0` is off.
+ ours(
+ "torchShadowStrength",
+ "0.7",
+ "benilla's own: torch-shadow darkness — how much of the direct term a shadow removes, 0..1",
),
ours(
"interiorDebug",
"0",
"benilla's own: interior diagnostic overlay — 1 classification, 2 shadow, 3 caster count, 4 WMO lane map",
),
+ // MONKEY (darkness gains): the two live dim dials. `nightGain` scales the EXTERIOR day/night
+ // law (the packed ambient/diffuse/specular rows) by `mix(1, gain, night_w)`, so it is exactly
+ // inert by day and full strength after dark; `interiorGain` scales the room lane's inputs (base
+ // ambient, per-fixture fill, and every interior fixture's colour). Both fold in at PACK time in
+ // `build_light_data`, so `SetCVar` moves the whole world on the very next frame — which is how
+ // "20 % / 30 % darker" gets judged at all, and `1` on either is the restore.
+ //
+ // Neither touches the fires: a point light keeps its brightness under both dials, because the
+ // ask is for a darker night AROUND the flame, not a dimmer flame.
+ ours(
+ "nightGain",
+ "0.45",
+ "benilla's own: exterior night brightness, 0.2..1.5 (1 = the reference's own night)",
+ ),
+ // MONKEY (lighting debug panel): weaker fresh interiors; persisted gains still win at boot.
+ ours(
+ "interiorGain",
+ "0.5",
+ "benilla's own: WMO interior brightness, 0.2..1.5 (1 = the pre-dial fixture-lit room)",
+ ),
+ // MONKEY (enclosed day floor): the daylight a room INSIDE A BUILDING gets by day, for the
+ // doorways this renderer cannot locate in the data (the Goldshire inn's entry group authors no
+ // portal, no EXT-class batch, no stitched vertex and no bake hot spot — there is nowhere to
+ // stand a fixture). An additive ambient in `interiorAmbient`'s own units, scaled by the sun's
+ // day envelope, so it is exactly 0 at night and the night look never moves. `0` is the restore.
+ ours(
+ "interiorDaylight",
+ "0.0",
+ "benilla's own: daylight floor for rooms inside a building, 0..1 (0 = none, the old look)",
+ ),
+ // MONKEY (bake floor): the share of an interior batch's own MOCV bake that survives the live-
+ // fixture lane. The lane throws the bake away and lets the fixtures decide, which leaves a room
+ // no fixture reaches (the Lion's Pride Inn's east vestibule: MOLR 0, no claims, one faded
+ // portal hop) rendering black between a sky-lit porch and a candle-lit hall — something the
+ // reference client cannot do, because it draws every interior batch at its bake regardless of
+ // lights. A fraction of the bake, inside the room law's rolloff, so a lit surface barely moves.
+ ours(
+ "interiorBakeFloor",
+ "0.12",
+ "benilla's own: share of an interior batch's baked light kept where no fixture reaches, 0..1 (0 = the old look)",
+ ),
// MONKEY (fire GO lights): the gain on lights SYNTHESISED from a model's flame emitter for the
// ~410 fire props the artists never gave a light block (campfires, wall torches, magic
// braziers, forges, candles). Live, like the interior knobs — and `0` is the kill switch for
@@ -1296,7 +1346,24 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
knobs.video.interior_shadow_entity_rate = (v.max(0.0) as u32).min(240);
}
"interiorshadowsoft" => knobs.video.interior_shadow_soft = v.clamp(0.5, 3.0),
+ // MONKEY (shadow floor): 0 IS meaningful (shadows off), so this floors at 0, not at a
+ // minimum-useful value; 1 is the pre-feature pitch black.
+ "torchshadowstrength" => knobs.video.torch_shadow_strength = v.clamp(0.0, 1.0),
"interiordebug" => knobs.video.interior_debug = (v.max(0.0) as u32).min(4),
+ // MONKEY (darkness gains): the two dim dials, clamped at the edge like every numeric row
+ // here. The floor is 0.2 rather than 0: a true 0 would be indistinguishable from a broken
+ // light pack (black world / black room), and the off switch people actually want is `1`.
+ "nightgain" => knobs.video.night_gain = v.clamp(0.2, 1.5),
+ "interiorgain" => knobs.video.interior_gain = v.clamp(0.2, 1.5),
+ // MONKEY (enclosed day floor): 0 IS meaningful here (it restores the pre-feature look
+ // exactly), unlike the two dim dials above whose 0 would be a broken-looking world.
+ "interiordaylight" => knobs.video.interior_daylight = v.clamp(0.0, 1.0),
+ // MONKEY (bake floor): 0 IS meaningful here too (it restores the pre-feature look exactly).
+ // The upper clamp matters more than usual: the packer multiplies this by `interiorGain`
+ // (up to 1.5) and rides the product in a lane fraction that must stay under 0.5 after
+ // scaling, so a value that escaped this clamp would reach the world-shadow flag it shares
+ // a lane with. `pack_bake_lane` clamps the product too — belt and braces, one at each end.
+ "interiorbakefloor" => knobs.video.interior_bake_floor = v.clamp(0.0, 1.0),
// MONKEY (fire GO lights): the synthesised-fire gain, clamped at the edge like the rest.
"firelightgain" => knobs.video.fire_light_gain = v.clamp(0.0, 4.0),
// MONKEY (flame flicker): 0..2 — the amplitudes are authored at 1, and 2 is the deliberate
@@ -1604,7 +1671,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
- let session: [(&str, String); 67] = [
+ let session: [(&str, String); 72] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1676,7 +1743,12 @@ fn sync_cvars(
video.interior_shadow_entity_rate.to_string(),
),
("interiorShadowSoft", video.interior_shadow_soft.to_string()),
+ ("torchShadowStrength", video.torch_shadow_strength.to_string()),
("interiorDebug", video.interior_debug.to_string()),
+ ("nightGain", video.night_gain.to_string()),
+ ("interiorGain", video.interior_gain.to_string()),
+ ("interiorDaylight", video.interior_daylight.to_string()),
+ ("interiorBakeFloor", video.interior_bake_floor.to_string()),
("fireLightGain", video.fire_light_gain.to_string()),
("fireFlicker", video.fire_flicker.to_string()),
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
@@ -2105,6 +2177,20 @@ mod tests {
assert_eq!(d["characterShadowRate"], shadows.character_shadow_rate as f32);
assert_eq!(d["worldShadowRate"], shadows.world_shadow_rate as f32);
assert_eq!(d["shadowCasterReach"], shadows.shadow_caster_reach);
+ // MONKEY (darkness gains): the two dim dials weld to the same knob for the same reason —
+ // the registered default IS what the light packer runs with until a config says otherwise,
+ // and a row that drifts is a setting that reads one way in the config and renders another.
+ assert_eq!(d["nightGain"], shadows.night_gain);
+ assert_eq!(d["interiorGain"], shadows.interior_gain);
+ // MONKEY (lighting debug panel): pin the requested dimmer baseline as well as the weld.
+ assert_eq!(d["interiorGain"], 0.5);
+ // MONKEY (enclosed day floor): same weld, same reason.
+ assert_eq!(d["interiorDaylight"], shadows.interior_daylight);
+ // MONKEY (bake floor): same weld, same reason — and pin the calibrated value, because the
+ // measurement the default stands on (the inn's door band at 0.108 x tex, candle-lit
+ // surfaces under +10 %) is only true at this number.
+ assert_eq!(d["interiorBakeFloor"], shadows.interior_bake_floor);
+ assert_eq!(d["interiorBakeFloor"], 0.12);
// The pane half-rate (1444) welds to the portrait knob's shipped default.
assert_eq!(d["boothHalfRate"] != 0.0, PaneRate::default().half);
// Render scale (1639) welds to OFF. Not a taste default: the whole tree of visual
diff --git a/crates/benilla-app/src/debug_panel/lighting_controls.rs b/crates/benilla-app/src/debug_panel/lighting_controls.rs
new file mode 100644
index 00000000..deea78d6
--- /dev/null
+++ b/crates/benilla-app/src/debug_panel/lighting_controls.rs
@@ -0,0 +1,187 @@
+//! MONKEY (lighting debug panel): a native editor for the LIVE video CVars. Reads are typed
+//! `VideoConfig` fields, never a second settings store; changed widgets alone call
+//! `UiScript::set_cvar_engine`, whose queue is the same one Lua `SetCVar` feeds. The normal host
+//! sync owns clamps, renderer bridges and persistence, so a slider and a chat command cannot
+//! acquire separate truths. Registry lookup is reserved for an explicit reset, not idle frames.
+
+use std::ops::RangeInclusive;
+
+use benilla_ui::script::UiScript;
+use bevy_egui::egui;
+
+use crate::cvars::REGISTERED;
+use crate::shadow_core::{CASTER_REACH_RANGE, MAX_SHADOW_RATE, SHADOW_DISTANCE_RANGE, SHADOW_MAP_SIZES};
+use crate::video::VideoConfig;
+
+enum Widget {
+ Toggle,
+ Slider(RangeInclusive),
+ Integer(RangeInclusive),
+ Choice(&'static [u32]),
+}
+
+struct Knob {
+ name: &'static str,
+ read: fn(&VideoConfig) -> f32,
+ widget: Widget,
+ help: &'static str,
+}
+
+// MONKEY (lighting debug panel): keep names, typed reads and presentation together. The numeric
+// bounds mirror `cvars::apply_to_knobs` (including its wider ambient/fill/attenuation ranges);
+// shared shadow constants follow the setter directly. Casting the booleans through u32 keeps
+// the descriptor read-only without inventing a parallel configuration type.
+macro_rules! knob {
+ ($name:literal, $field:ident, $widget:expr, $help:literal) => {
+ Knob { name: $name, read: |v| v.$field as f32, widget: $widget, help: $help }
+ };
+ (flag $name:literal, $field:ident, $help:literal) => {
+ Knob { name: $name, read: |v| v.$field as u32 as f32, widget: Widget::Toggle, help: $help }
+ };
+}
+
+use Widget::{Choice, Integer, Slider};
+
+const INTERIOR: &[Knob] = &[
+ knob!(flag "interiorLight", interior_light, "Fixture-lit interiors; off restores the baked path."),
+ knob!("interiorGain", interior_gain, Slider(0.2..=1.5), "Overall room input brightness."),
+ knob!("interiorAmbient", interior_ambient, Slider(0.0..=1.0), "Base room ambient."),
+ knob!("interiorFill", interior_fill, Slider(0.0..=2.0), "Per-fixture bounce gain."),
+ // MONKEY (bake floor): index 4, immediately after fill, because the three presets below take
+ // `INTERIOR[1..5]` as one group — this dial belongs with gain/ambient/fill, not with the
+ // exposure and reach knobs the presets deliberately leave alone.
+ knob!("interiorBakeFloor", interior_bake_floor, Slider(0.0..=1.0), "Share of a room's baked light kept where no fixture reaches; 0 = fixtures only."),
+ knob!("interiorExposure", interior_exposure, Slider(0.25..=8.0), "Light budget before soft rolloff."),
+ knob!("interiorAttenScale", interior_atten_scale, Slider(0.0..=8.0), "Pool radius scale; 0 disables the authored window."),
+ knob!("interiorDaylight", interior_daylight, Slider(0.0..=1.0), "Sun-driven day floor in enclosed rooms."),
+ knob!(flag "interiorRoomGate", interior_room_gate, "Restrict fixtures to the rooms they claim."),
+];
+
+const GROUPS: &[(&str, &[Knob])] = &[
+ ("Interior light", INTERIOR),
+ ("Fire", &[
+ knob!("fireLightGain", fire_light_gain, Slider(0.0..=4.0), "Brightness of lights synthesized from flame emitters."),
+ knob!("fireFlicker", fire_flicker, Slider(0.0..=2.0), "0 steady, 1 default, 2 pronounced."),
+ ]),
+ ("Torch shadows", &[
+ knob!(flag "interiorShadows", interior_shadows, "Interior fixture shadows; requires interiorLight."),
+ knob!(flag "exteriorShadows", exterior_shadows, "Outdoor fire shadows at night."),
+ knob!("interiorShadowCasters", interior_shadow_casters, Integer(1.0..=16.0), "Resident fixture shadow maps."),
+ knob!("interiorShadowDynamic", interior_shadow_dynamic, Integer(1.0..=crate::torch_shadow::MAX_TORCH_DYNAMIC as f32), "Nearest fixtures with moving entity shadows."),
+ knob!("interiorShadowSoft", interior_shadow_soft, Slider(0.5..=3.0), "Shadow edge softness at a contact; the penumbra grows from it."),
+ knob!("torchShadowStrength", torch_shadow_strength, Slider(0.0..=1.0), "Shadow darkness: how much direct light a shadow removes. 1 = black."),
+ knob!("interiorShadowEntityRate", interior_shadow_entity_rate, Integer(0.0..=240.0), "Moving caster regather Hz; 0 = every frame."),
+ ]),
+ ("Sun shadows", &[
+ knob!(flag "characterShadows", character_shadows, "Realtime character silhouettes instead of blobs."),
+ knob!(flag "worldShadows", world_shadows, "Realtime static-world shadows instead of baked terrain shadows."),
+ knob!("shadowDistance", shadow_distance, Slider(SHADOW_DISTANCE_RANGE), "Shadow distance in yards."),
+ knob!("shadowMapSize", shadow_map_size, Choice(&SHADOW_MAP_SIZES), "Map edge in texels; larger maps cost more GPU time and memory."),
+ knob!("shadowFilter", shadow_filter, Widget::Toggle, "0 hardware 2x2, 1 Gaussian PCF."),
+ knob!("characterShadowRate", character_shadow_rate, Integer(0.0..=MAX_SHADOW_RATE as f32), "Character shadow update Hz; 0 = every frame."),
+ knob!("worldShadowRate", world_shadow_rate, Integer(0.0..=MAX_SHADOW_RATE as f32), "World shadow update Hz; 0 = every frame."),
+ knob!("shadowCasterReach", shadow_caster_reach, Slider(CASTER_REACH_RANGE), "Caster collection reach multiplier."),
+ ]),
+ ("Night", &[
+ knob!("nightGain", night_gain, Slider(0.2..=1.5), "Exterior night brightness; inert by day."),
+ ]),
+ ("Debug", &[
+ knob!("interiorDebug", interior_debug, Choice(&[0, 1, 2, 3, 4]), "0 off, 1 classification, 2 shadow, 3 caster count, 4 WMO lane map."),
+ ]),
+];
+
+fn reset(script: &mut UiScript, knobs: &[Knob]) {
+ for knob in knobs {
+ let registered = REGISTERED.iter().find(|r| r.name == knob.name)
+ .expect("lighting panel knob must be registered");
+ script.set_cvar_engine(registered.name, registered.default);
+ }
+}
+
+fn row(ui: &mut egui::Ui, video: &VideoConfig, script: &mut UiScript, knob: &Knob) {
+ let mut value = (knob.read)(video);
+ // MONKEY (lighting debug panel): stack the label over numeric controls so even the longest
+ // CVar fits the existing 280 px panel. Sliders retain their editable current-value readout;
+ // integer knobs cannot queue fractional counts/rates, and flags explicitly show their 0/1.
+ let changed = ui.push_id(knob.name, |ui| match &knob.widget {
+ Widget::Toggle => {
+ let mut enabled = value != 0.0;
+ let response = ui.checkbox(&mut enabled, format!("{} {}", knob.name, value as u32))
+ .on_hover_text(knob.help);
+ value = enabled as u32 as f32;
+ response.changed()
+ }
+ Slider(range) | Integer(range) => {
+ ui.label(knob.name).on_hover_text(knob.help);
+ let mut slider = egui::Slider::new(&mut value, range.clone());
+ if matches!(knob.widget, Integer(_)) {
+ slider = slider.integer();
+ }
+ ui.add(slider).on_hover_text(knob.help).changed()
+ }
+ Choice(options) => {
+ ui.label(knob.name).on_hover_text(knob.help);
+ let mut changed = false;
+ egui::ComboBox::from_id_salt("value")
+ .selected_text((value as u32).to_string())
+ .show_ui(ui, |ui| {
+ for &option in *options {
+ changed |= ui.selectable_value(&mut value, option as f32, option.to_string()).changed();
+ }
+ });
+ changed
+ }
+ }).inner;
+ if changed {
+ script.set_cvar_engine(knob.name, &value.to_string());
+ }
+}
+
+pub(super) fn section(ui: &mut egui::Ui, video: &VideoConfig, script: Option<&mut UiScript>) {
+ let Some(script) = script else {
+ ui.weak("Lighting controls are available once the UI session is ready.");
+ return;
+ };
+ ui.horizontal(|ui| {
+ ui.label("Presets");
+ for (label, values) in [
+ // MONKEY (bake floor): the fourth value is `interiorBakeFloor`, and it rides the
+ // presets because it is a room BRIGHTNESS INPUT exactly like the three beside it.
+ // Leaving it out would make the presets change the RATIO between a fixture-lit room
+ // and a fixture-starved one rather than the level of both: Bright would raise the
+ // candles and leave the vestibule at the Default floor. (The `interiorGain` the
+ // presets also set does scale it at pack time, so its own value composes with that
+ // — which is why Dim asks for 0.08 and not for 0.12 halved twice.)
+ ("Dim", Some(["0.5", "0.015", "0.08", "0.08"])),
+ ("Default", None),
+ ("Bright", Some(["1.0", "0.04", "0.16", "0.2"])),
+ ] {
+ if ui.small_button(label).on_hover_text("Only gain, ambient, fill and bake floor; other settings stay as they are.").clicked() {
+ if let Some(values) = values {
+ for (knob, value) in INTERIOR[1..5].iter().zip(values) {
+ script.set_cvar_engine(knob.name, value);
+ }
+ } else {
+ reset(script, &INTERIOR[1..5]);
+ }
+ }
+ }
+ });
+ if ui.small_button("Reset all").on_hover_text("Restore registry defaults for every group in Lighting & shadows.").clicked() {
+ for (_, knobs) in GROUPS {
+ reset(script, knobs);
+ }
+ }
+ for (name, knobs) in GROUPS {
+ ui.push_id(name, |ui| {
+ ui.separator();
+ ui.strong(*name);
+ for knob in *knobs {
+ row(ui, video, script, knob);
+ }
+ if ui.small_button("Reset group to defaults").clicked() {
+ reset(script, knobs);
+ }
+ });
+ }
+}
diff --git a/crates/benilla-app/src/debug_panel/mod.rs b/crates/benilla-app/src/debug_panel/mod.rs
index b56a927f..865f0fa4 100644
--- a/crates/benilla-app/src/debug_panel/mod.rs
+++ b/crates/benilla-app/src/debug_panel/mod.rs
@@ -49,6 +49,7 @@ use crate::ui_script::EguiPointerOver;
mod inspect;
mod journal;
+mod lighting_controls;
/// The dev state this panel edits — resource-only, faithful defaults (decision 0026: the
/// always-present config layer; this module is only its editor). The engine owns and inits it,
@@ -364,7 +365,11 @@ fn debug_panel_ui(
ping: Res,
dropped: Res,
mut weather_state: Option>,
- mut world: WorldReadout,
+ (mut world, video, mut script): (
+ WorldReadout,
+ Res,
+ Option>,
+ ),
) -> Result {
if !debug.open {
return Ok(());
@@ -550,6 +555,12 @@ fn debug_panel_ui(
ui.checkbox(&mut l.disable_sky_dome, "disable sky dome");
});
+ egui::CollapsingHeader::new("Lighting & shadows")
+ .default_open(false)
+ .show(ui, |ui| {
+ lighting_controls::section(ui, &video, script.as_deref_mut());
+ });
+
egui::CollapsingHeader::new("Weather")
.default_open(false)
.show(ui, |ui| {
diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs
index cd3565d7..b46bc80a 100644
--- a/crates/benilla-app/src/dynamic_interior.rs
+++ b/crates/benilla-app/src/dynamic_interior.rs
@@ -57,6 +57,23 @@ fn bridge(
// on the CPU in `build_light_data`, so `SetCVar("fireFlicker", 0)` freezes every flame on
// the very next frame with no respawn and no shader change.
flicker: video.fire_flicker,
+ // MONKEY (darkness gains): both dim dials ride this same bridge — they are consumed
+ // entirely on the CPU in `build_light_data` (folded into the packed rows / the packed
+ // colours), so `SetCVar("nightGain", 1)` restores the reference night on the very next
+ // frame with no respawn and no shader change. `night_gain` is an EXTERIOR knob living on an
+ // interior-named resource for the sake of one bridge rather than two; the packer reads it
+ // ungated by `enabled`.
+ night_gain: video.night_gain,
+ interior_gain: video.interior_gain,
+ // 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 (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
+ // the pre-feature look on the very next frame with no respawn and no pipeline rebuild.
+ bake_floor: video.interior_bake_floor,
};
if *out != want {
*out = want;
diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs
index 3b1bb462..4d46259e 100644
--- a/crates/benilla-app/src/entities.rs
+++ b/crates/benilla-app/src/entities.rs
@@ -47,6 +47,10 @@ use attach::{attach_entity_visuals, build_dressup_preview, build_glue_pet, build
/// decision 0016's law applied to the *entity* half of the scene, not just the placed half.
mod carried_light;
use carried_light::spawn_carried_lights;
+// MONKEY (spell light): the same file's second spawner — the ONE light a luminous spell effect /
+// firework throws, hung on the effect's own root so the effect's death reaps it. Used by the kit,
+// missile and dest-anchored lanes.
+use carried_light::spawn_spell_light;
// MONKEY (carried light stability): `torch_shadow` needs the settle verdict to refuse a MOVING
// carried light a cube-shadow slot.
pub(crate) use carried_light::CarriedLightMotion;
@@ -883,6 +887,19 @@ impl Plugin for EntitiesPlugin {
carried_light::track_carried_light_motion
.after(bevy::transform::TransformSystems::Propagate),
)
+ // MONKEY (spell light): the luminous-effect lights' envelope and their ceiling. `Update`,
+ // after the attach passes that spawn them (`EntityVisualsSet`) so a light born this frame
+ // is already on its own clock, and well before `PostUpdate`'s packer reads the intensity
+ // they write. The budget runs after the envelope so the age it orders on is this frame's.
+ .add_systems(
+ Update,
+ (
+ spell_fx::advance_spell_lights,
+ spell_fx::budget_spell_lights,
+ )
+ .chain()
+ .after(EntityVisualsSet),
+ )
// The aura CharProc layer (`crate::aura_visual`): the state kit's effect on the BODY.
// The drain installs/removes this frame's nodes; the author then owns the render alpha
// of every part under a translucent unit — after the three steady-state alpha authors
diff --git a/crates/benilla-app/src/entities/carried_light.rs b/crates/benilla-app/src/entities/carried_light.rs
index 02434592..8988f46d 100644
--- a/crates/benilla-app/src/entities/carried_light.rs
+++ b/crates/benilla-app/src/entities/carried_light.rs
@@ -30,9 +30,15 @@ use benilla_world::interior::{InteriorAnchor, WmoResidency};
use benilla_world::lighting::{
LightLane, LightLitRooms, LightRooms, ShadowProxyLight, SyntheticFireLight,
};
+use benilla_world::static_gx::LightOwner;
use benilla_world::terrain_stream::{carried_light_claims, point_light, CarriedClaimSet};
use benilla_world::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom};
+// MONKEY (spell light): the effect-side lifecycle this file's spawn helper stamps on — the
+// envelope, the budget and the kill switch all live beside the rest of the effect lifecycle
+// (`spell_fx::lifecycle`), because that is what a spell light's lifetime IS.
+use super::spell_fx::{spell_lights_enabled, SpellLight, SpellLightMode, SPELL_BURST_SPAN};
+
/// Spawn a `PointLight` child for each **casting** (`type==1`, not visibility-gated dark) M2 light of
/// an entity's model.
///
@@ -110,6 +116,26 @@ pub(super) fn spawn_carried_lights(
),
None => (frame, l.def.position),
};
+ // MONKEY (spell light): a SPELL/FIREWORK-derived light is not a carried FIXTURE and does
+ // not take this lane's rules. It has an onset, a lifecycle envelope and a budget, and it
+ // never flickers — all of which live on [`SpellLight`]. The branch is here because the
+ // firework GameObject arrives HERE: its shell (`World\Goober\G_Firework0*`) is a GO model
+ // like any other, so the entity lane is the only place its light can be born.
+ if let Some(fx) = l.spell {
+ spawn_spell_light_child(
+ commands,
+ parent,
+ local,
+ l,
+ fx,
+ // A GameObject that carries a spell light IS a one-shot: a firework shell, a
+ // trigger's effect model. Nothing on this lane holds.
+ SpellLightMode::Burst {
+ span: SPELL_BURST_SPAN,
+ },
+ );
+ continue;
+ }
let mut glow = commands.spawn((
point_light(l.def.diffuse_color, l.def.diffuse_intensity),
Transform::from_translation(wow_to_bevy(local)),
@@ -129,6 +155,14 @@ pub(super) fn spawn_carried_lights(
if held {
glow.insert(HeldLight);
}
+ // MONKEY (torch owner exclusion): and the light's OWNER — the model frame it belongs to.
+ // Same bug as the placed lane's (`terrain_stream::spawn`'s `tag_light_owner`): the flame
+ // sits INSIDE the thing that burns it, so a placed brazier GameObject was the nearest
+ // occluder on all six of its own cube faces and stamped a black disc on the ground under
+ // itself. No `WorldObject` exists at this spawn site — a carried light's host is a net
+ // entity, not a placement — so the identity is the FRAME, and the caster gather asks
+ // "is this part a descendant of it?" (`LightOwner::Instance`).
+ glow.insert(LightOwner::Instance(frame));
// MONKEY (flame flicker): a carried flame breathes like any other — the imp's hand fire, a
// held torch, a brazier GameObject. It is safe on THIS lane specifically because the
// modulation touches neither position nor reach: the settle/claim logic
@@ -151,6 +185,98 @@ pub(super) fn spawn_carried_lights(
}
}
+/// MONKEY (spell light): spawn the ONE spell light a luminous effect model carries, as a child of
+/// that effect's root. `false` when the model carries none — which is the great majority of the
+/// corpus (frost, nature, arcane, shadow and everything unnamed synthesise nothing at all), and
+/// when the env kill switch `WOW_SPELL_LIGHT=0` is set.
+///
+/// `root` is the effect INSTANCE's root entity — the kit instance's attach node, the missile, the
+/// dest-anchored plant, the firework's GameObject frame. That parentage is the whole lifetime
+/// contract: every one of those lanes despawns its root when the effect ends (the reap, the
+/// arrival, the expiry), and a child light goes with it. There is no separate reaping path to get
+/// wrong, and no `on_owner_loss` case to answer — a light is not a particle pool and never drains.
+///
+/// The light hangs at the emitter's own model-space position, exactly as a synthesised fire light
+/// does, and does NOT ride a bone: a synthesised light stands for a whole flame VOLUME whose
+/// position was inferred, and riding an animating joint adds motion that the shadow/claim lanes
+/// read as an event (the same reasoning [`spawn_carried_lights`] gives for its `l.synthetic` arm).
+pub(super) fn spawn_spell_light(
+ commands: &mut Commands,
+ lights: &[benilla_assets::ModelLight],
+ root: Entity,
+ mode: SpellLightMode,
+) -> bool {
+ let Some((l, fx)) = lights.iter().find_map(|l| l.spell.map(|fx| (l, fx))) else {
+ return false;
+ };
+ spawn_spell_light_child(commands, root, l.def.position, l, fx, mode).is_some()
+}
+
+/// The shared body of the two spawn sites (the effect lanes' [`spawn_spell_light`] and the
+/// entity lane's firework branch): one `PointLight` child at `local` (WoW model space, relative to
+/// `parent`), carrying the tags every spell light must have.
+///
+/// The tag set is three separate promises, and each one is load-bearing:
+/// - [`SyntheticFireLight`] — this light was INVENTED, not authored, so the packer's synthetic gain
+/// owns it (and a future `spellLightGain` would find it here).
+/// - [`HeldLight`] — **never** an exterior torch-shadow caster. A spell light moves (a missile), is
+/// born and dies inside a second, and would churn the cube-shadow cache at frame rate for a
+/// shadow nobody could resolve in the time it exists. The marker is that lane's refusal.
+/// - [`SpellLight`] — the envelope and the budget ([`advance_spell_lights`] /
+/// [`budget_spell_lights`]), and the handle any future per-school gain or kill switch targets.
+///
+/// No [`FlameFlicker`](benilla_world::lighting::FlameFlicker), deliberately: the flicker makes a
+/// fire breathe, and a spell light already has a shape of its own. The two together read as the
+/// effect stuttering rather than as fire.
+///
+/// `None` when the kill switch is off — so a caller can tell "disabled" from "this model has no
+/// light", though neither has any further consequence.
+fn spawn_spell_light_child(
+ commands: &mut Commands,
+ parent: Entity,
+ local: [f32; 3],
+ l: &benilla_assets::ModelLight,
+ fx: benilla_assets::SpellLightInfo,
+ mode: SpellLightMode,
+) -> Option {
+ if !spell_lights_enabled() {
+ return None;
+ }
+ let lit = point_light(l.def.diffuse_color, l.def.diffuse_intensity);
+ // Born DARK and raised by the envelope: the onset is real time (a firework's fuse), and a
+ // light that showed its full strength on its first frame and only then ramped would flash
+ // once before every effect it belongs to.
+ let base = lit.intensity;
+ let glow = commands
+ .spawn((
+ PointLight {
+ intensity: 0.0,
+ ..lit
+ },
+ Transform::from_translation(wow_to_bevy(local)),
+ Visibility::default(),
+ SyntheticFireLight,
+ HeldLight,
+ SpellLight::new(base, fx.onset, mode),
+ // The same owner exclusion every carried light takes: the flame sits INSIDE the thing
+ // that burns it, so the effect's own meshes must not occlude their own light.
+ LightOwner::Instance(parent),
+ ))
+ .id();
+ commands.entity(parent).add_child(glow);
+ if benilla_assets::trace::enabled() {
+ benilla_assets::trace::line(
+ "fx",
+ &format!(
+ "spell light e={glow} school={} onset={:.2} base={base:.1}",
+ fx.kind.label(),
+ fx.onset
+ ),
+ );
+ }
+ Some(glow)
+}
+
/// MONKEY (fire GO lights): how far up the hierarchy the room walk looks before giving up. A
/// carried light's anchor is at most `light → joint → … → net entity`, and a rig's joint chain is
/// the deep part; 64 clears every shipped skeleton with room to spare, and the cap is there so a
@@ -640,6 +766,7 @@ mod tests {
bone_pivot: [1.0, 0.0, 0.5],
synthetic: false,
flame: false,
+ spell: None,
}
}
diff --git a/crates/benilla-app/src/entities/dest_fx.rs b/crates/benilla-app/src/entities/dest_fx.rs
index c4618067..be11f84a 100644
--- a/crates/benilla-app/src/entities/dest_fx.rs
+++ b/crates/benilla-app/src/entities/dest_fx.rs
@@ -308,6 +308,22 @@ pub(super) fn attach_ground_fx_models(
continue; // model still building — attach on a later pass
}
inst.spawned = true;
+ // MONKEY (spell light): the IMPACT flash. A dest-anchored effect is the one lane whose
+ // light must not hold — a Fire Nova lights the ground it lands on and is gone. The
+ // burst span is the model's own first-sequence duration where it authors one (so the
+ // light fades with the effect rather than on a guess), the shared default otherwise;
+ // a LOOPING plant (a persistent ground aura) takes the default too, because "as long
+ // as the aura lasts" is exactly what a burst must not be.
+ let span = dm
+ .first_seq_span
+ .filter(|_| !inst.looping)
+ .unwrap_or(super::spell_fx::SPELL_BURST_SPAN);
+ super::spawn_spell_light(
+ &mut commands,
+ &dm.lights,
+ entity,
+ super::spell_fx::SpellLightMode::Burst { span },
+ );
if !inst.looping {
// One pass of the first sequence — the kit pipeline's completion-callback
// stand-in (`spell_fx`'s span clock, same law).
diff --git a/crates/benilla-app/src/entities/missile.rs b/crates/benilla-app/src/entities/missile.rs
index 7698cb9a..fbb3e04e 100644
--- a/crates/benilla-app/src/entities/missile.rs
+++ b/crates/benilla-app/src/entities/missile.rs
@@ -647,6 +647,17 @@ pub(super) fn attach_missile_models(
) {
continue;
}
+ // MONKEY (spell light): a luminous projectile carries its light with it — the Fireball's
+ // core lights the road it flies over, and the flash at the end is the impact effect's own
+ // (`dest_fx` / the kit on the target), not this one's. `Missile` = constant: a projectile
+ // has no lifecycle to follow and no fade to run, because its ending is the arrival despawn
+ // that takes the light with it.
+ super::spawn_spell_light(
+ &mut commands,
+ &dm.lights,
+ entity,
+ super::spell_fx::SpellLightMode::Missile,
+ );
missile.parts_spawned = true;
}
}
diff --git a/crates/benilla-app/src/entities/spell_fx/lifecycle.rs b/crates/benilla-app/src/entities/spell_fx/lifecycle.rs
index 883c1415..44520255 100644
--- a/crates/benilla-app/src/entities/spell_fx/lifecycle.rs
+++ b/crates/benilla-app/src/entities/spell_fx/lifecycle.rs
@@ -217,6 +217,183 @@ pub(crate) fn decay_span(anims: Option<&ModelAnimations>) -> Option {
anims?.find(ANIM_DECAY).map(|c| c.duration)
}
+// -------------------------------------------------------------------------------------------
+// MONKEY (spell light): the LIGHT a luminous effect throws, and the envelope it throws it on.
+// -------------------------------------------------------------------------------------------
+//
+// The synthesis — which effects are luminous at all (fire / holy / fel, never frost, nature,
+// arcane or shadow), what colour they burn and how far they reach — is
+// [`benilla_formats::fire_light`]'s spell route, decided once per MODEL at asset load. What lives
+// here is everything that is per-INSTANCE: when the light comes up, how it goes out, and how many
+// of them may exist at once.
+//
+// It sits in this file rather than beside the other carried lights because it is a LIFECYCLE, and
+// this file is where an effect instance's lifecycle lives (`Stand` → `Hold` → `Decay`). The light
+// is a child of the effect's own root, so the reap that despawns the root takes the light with it
+// and there is no orphaning path to get wrong; the envelope below only shapes the brightness
+// inside that life.
+
+/// Seconds a spell light takes to come up. Short enough to read as a flash rather than a fade-in —
+/// the point is only that a light never appears at full strength on a single frame, which reads as
+/// a rendering fault rather than as a spell.
+pub(crate) const SPELL_LIGHT_RAMP: f32 = 0.1;
+
+/// The default burst fade ([`SpellLightMode::Burst`]) when the caller knows no better span: an
+/// impact's flash. Long enough to see, short enough that a volley of them never overlaps into a
+/// standing glow.
+pub(crate) const SPELL_BURST_SPAN: f32 = 0.6;
+
+/// How fast a KIT light goes dark once its instance is reaped ([`FxDecay`]). Deliberately quicker
+/// than the model's own `Decay` sequence: the aura is over the moment the server said so, and a
+/// light that outlived the visual by a second would read as a stuck effect.
+pub(crate) const SPELL_REAP_FADE: f32 = 0.25;
+
+/// How many spell lights may be alive at once, across every lane. The shared point-light table is
+/// 512 rows packed from the 256 nearest sources every frame, and the whole feature is worth
+/// nothing if a raid's worth of casts can evict a city's torches: 24 is a generous ceiling for
+/// what one camera can see cast at once and still a small fraction of the pack.
+pub(crate) const SPELL_LIGHTS_MAX: usize = 24;
+
+/// MONKEY (spell light): the env kill switch, `WOW_SPELL_LIGHT=0`. Read ONCE — the value is
+/// latched on first call, so it cannot change under a running frame and costs one atomic load at
+/// each spawn site thereafter.
+///
+/// An env var and not a cvar on purpose: this pass adds no cvar (the lighting cvars are another
+/// lane's file this session). The natural home for a live `spellLightGain` is the packer, beside
+/// `fireLightGain` — see the report note.
+pub(crate) fn spell_lights_enabled() -> bool {
+ static ON: std::sync::OnceLock = std::sync::OnceLock::new();
+ *ON.get_or_init(|| !matches!(std::env::var("WOW_SPELL_LIGHT").as_deref(), Ok("0")))
+}
+
+/// Which lifecycle a spell light follows — the one thing its spawn site knows that the model
+/// cannot.
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub(crate) enum SpellLightMode {
+ /// A kit/aura effect attached to a unit: up on the ramp, HELD for as long as the instance
+ /// stands, out on the reap ([`FxDecay`] → [`SPELL_REAP_FADE`]). Ice Barrier's shield holds,
+ /// Immolate burns for its duration, a precast glows while it is cast.
+ Kit,
+ /// A missile: constant while it flies. The light rides the projectile root, so it sweeps the
+ /// ground and the walls on the way — and the missile's own arrival despawn ends it, which is
+ /// the only ending a projectile has.
+ Missile,
+ /// An impact / destination effect / firework shell: full at onset, gone `span` seconds later.
+ /// The one shape whose *whole point* is that it does not persist.
+ Burst { span: f32 },
+}
+
+/// MONKEY (spell light): one live spell light. A child of the effect root it belongs to, so the
+/// effect's own despawn reaps it; this component only shapes its brightness while it lives.
+#[derive(Component)]
+pub(crate) struct SpellLight {
+ /// The `PointLight::intensity` this light sits at when the envelope is at full — captured at
+ /// spawn from the synthesised colour/intensity, never recomputed. The envelope only ever
+ /// SCALES this, so nothing downstream can drift the calibration.
+ base: f32,
+ /// Seconds from spawn before the light comes up — the emitter's own rate-track onset
+ /// (`benilla_formats::fire_light::emit_onset`). A firework shell detonates half a second into
+ /// its model's clip and a rocket that lit the sky on the way UP would be exactly backwards.
+ onset: f32,
+ /// The lifecycle ([`SpellLightMode`]).
+ mode: SpellLightMode,
+ /// Seconds alive. Also the budget's age ordering — the OLDEST spell light is the one dropped
+ /// when the ceiling is hit, because it is the one whose moment has most passed.
+ age: f32,
+ /// Seconds this light's HOST instance has been reaped ([`FxDecay`]), i.e. how far into
+ /// [`SPELL_REAP_FADE`] it is. Only ticks in [`SpellLightMode::Kit`].
+ reaped: f32,
+}
+
+impl SpellLight {
+ /// A fresh light at `base` intensity, coming up `onset` seconds from now.
+ pub(crate) fn new(base: f32, onset: f32, mode: SpellLightMode) -> Self {
+ Self {
+ base,
+ onset,
+ mode,
+ age: 0.0,
+ reaped: 0.0,
+ }
+ }
+
+ /// The envelope's current multiplier, `0..=1` — pure, so the shape is testable without a
+ /// world. Ramp in, then the mode's own decay, then the reap fade, multiplied.
+ fn envelope(&self) -> f32 {
+ let t = self.age - self.onset;
+ if t <= 0.0 {
+ return 0.0; // the flame this light stands for does not exist yet
+ }
+ let up = (t / SPELL_LIGHT_RAMP).min(1.0);
+ let down = match self.mode {
+ // The fade starts where the ramp ended, so a burst's peak is a real (if brief) plateau
+ // rather than a single-frame spike that a low frame rate could skip entirely.
+ SpellLightMode::Burst { span } => {
+ (1.0 - (t - SPELL_LIGHT_RAMP).max(0.0) / span.max(1e-3)).clamp(0.0, 1.0)
+ }
+ SpellLightMode::Kit | SpellLightMode::Missile => 1.0,
+ };
+ let reap = (1.0 - self.reaped / SPELL_REAP_FADE).clamp(0.0, 1.0);
+ up * down * reap
+ }
+}
+
+/// MONKEY (spell light): run every live spell light's envelope.
+///
+/// `Update`, beside the rest of the effect lane, and it writes `PointLight::intensity` ONLY — the
+/// packer reads that in `PostUpdate` (`lighting::build_light_data`), so a light's brightness is
+/// always this frame's. Deliberately NOT a `FlameFlicker`: that modulation exists to make a fire
+/// breathe, and a spell light already has a shape of its own; the two together read as the effect
+/// stuttering.
+///
+/// The KIT arm is the only one that has to look outside itself: a reaped instance
+/// ([`FxDecay`] on the effect root, written by the reap) must take its light down with it, and the
+/// light's parent IS that root.
+pub(crate) fn advance_spell_lights(
+ time: Res,
+ mut lights: Query<(&mut SpellLight, &mut PointLight, Option<&ChildOf>)>,
+ reaped: Query>,
+) {
+ let dt = time.delta_secs();
+ for (mut light, mut point, parent) in &mut lights {
+ light.age += dt;
+ if light.mode == SpellLightMode::Kit
+ && parent.is_some_and(|c| reaped.get(c.parent()).unwrap_or(false))
+ {
+ light.reaped += dt;
+ }
+ let want = light.base * light.envelope();
+ // Write only on a real change: a held kit light sits at its plateau for the whole aura,
+ // and a per-frame write there would wake every change-detection consumer of `PointLight`
+ // for nothing.
+ if (point.intensity - want).abs() > 1e-3 {
+ point.intensity = want;
+ }
+ }
+}
+
+/// MONKEY (spell light): the ceiling ([`SPELL_LIGHTS_MAX`]) — drop the OLDEST beyond it.
+///
+/// Oldest rather than dimmest or furthest, for one reason: the lane exists to light what is
+/// HAPPENING, and the newest cast is the one the player is looking at. A dimness rule would evict
+/// the burst that is mid-fade (i.e. exactly the flash being watched) and a distance rule would
+/// fight the packer, which already sorts by distance and would then be given a hole it had no say
+/// in. Age is also the only ordering that is stable frame to frame, so the set does not churn.
+///
+/// Despawning the light alone never disturbs its effect: the light is a leaf child of the effect
+/// root and nothing reads back from it.
+pub(crate) fn budget_spell_lights(mut commands: Commands, lights: Query<(Entity, &SpellLight)>) {
+ let live = lights.iter().count();
+ if live <= SPELL_LIGHTS_MAX {
+ return;
+ }
+ let mut by_age: Vec<(Entity, f32)> = lights.iter().map(|(e, s)| (e, s.age)).collect();
+ by_age.sort_by(|a, b| b.1.total_cmp(&a.1)); // oldest first
+ for (light, _) in by_age.into_iter().take(live - SPELL_LIGHTS_MAX) {
+ commands.entity(light).try_despawn();
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -306,6 +483,50 @@ mod tests {
assert_eq!(decay_span(None), None);
}
+ /// MONKEY (spell light) — GOLDEN: the envelope's four legs, on the pure function so the shape
+ /// is pinned without a world. The ONSET leg is the one with a real bug behind it: a firework
+ /// shell detonates half a second into its clip, and a light lit at spawn flashes the rocket's
+ /// flight instead of its burst.
+ #[test]
+ fn the_spell_envelope_ramps_holds_and_bursts() {
+ let at = |mut l: SpellLight, age: f32| {
+ l.age = age;
+ l.envelope()
+ };
+
+ // A kit light: dark before its onset, ramped over SPELL_LIGHT_RAMP, then HELD.
+ let kit = || SpellLight::new(100.0, 0.0, SpellLightMode::Kit);
+ assert_eq!(at(kit(), 0.0), 0.0, "nothing on the spawn frame");
+ assert!((at(kit(), SPELL_LIGHT_RAMP * 0.5) - 0.5).abs() < 1e-3);
+ assert_eq!(at(kit(), SPELL_LIGHT_RAMP), 1.0);
+ assert_eq!(at(kit(), 30.0), 1.0, "an aura holds for as long as it stands");
+
+ // A missile is the same minus any ending of its own — the arrival despawn is the ending.
+ assert_eq!(at(SpellLight::new(1.0, 0.0, SpellLightMode::Missile), 5.0), 1.0);
+
+ // A burst peaks at the ramp's end and is gone `span` later; the plateau is real, not a
+ // one-frame spike a low frame rate could step over.
+ let burst = || SpellLight::new(1.0, 0.0, SpellLightMode::Burst { span: 0.6 });
+ assert_eq!(at(burst(), SPELL_LIGHT_RAMP), 1.0);
+ assert!((at(burst(), SPELL_LIGHT_RAMP + 0.3) - 0.5).abs() < 1e-3);
+ assert_eq!(at(burst(), SPELL_LIGHT_RAMP + 0.6), 0.0);
+ assert_eq!(at(burst(), 10.0), 0.0, "and stays gone");
+
+ // The ONSET: a firework's fuse. Dark for the whole flight, up at the detonation.
+ let fuse = || SpellLight::new(1.0, 1.5, SpellLightMode::Burst { span: 0.6 });
+ assert_eq!(at(fuse(), 1.4), 0.0);
+ assert_eq!(at(fuse(), 1.5 + SPELL_LIGHT_RAMP), 1.0);
+
+ // The reap fade: a kit light goes dark on its instance's `FxDecay`, quicker than the
+ // model's own Decay sequence — the aura is over the moment the server said so.
+ let mut reaped = kit();
+ reaped.age = 5.0;
+ reaped.reaped = SPELL_REAP_FADE * 0.5;
+ assert!((reaped.envelope() - 0.5).abs() < 1e-3);
+ reaped.reaped = SPELL_REAP_FADE;
+ assert_eq!(reaped.envelope(), 0.0);
+ }
+
fn test_anims(clips: &[AnimClip]) -> ModelAnimations {
ModelAnimations {
graph: Handle::default(),
diff --git a/crates/benilla-app/src/entities/spell_fx/mod.rs b/crates/benilla-app/src/entities/spell_fx/mod.rs
index a24aca9a..764b18c1 100644
--- a/crates/benilla-app/src/entities/spell_fx/mod.rs
+++ b/crates/benilla-app/src/entities/spell_fx/mod.rs
@@ -62,6 +62,14 @@ use benilla_world::vis_chain::VisChainOnly;
use super::{BoneAttach, DisplayModel, EntityPart, ModelHandle};
pub(super) use lifecycle::advance_fx_anim;
+// MONKEY (spell light): the luminous-effect light's lifecycle — its envelope, its budget, its kill
+// switch and the mode each lane spawns it in. Exported at `pub(crate)` because the spawners live
+// outside this module: `entities::carried_light` holds the one spawn helper (and the firework
+// GameObject's own branch), and `entities.rs` registers the two systems.
+pub(crate) use lifecycle::{
+ advance_spell_lights, budget_spell_lights, spell_lights_enabled, SpellLight, SpellLightMode,
+ SPELL_BURST_SPAN,
+};
use lifecycle::{decay_span, FxAnimLife, FxDecay};
/// The client's attach fallback cascade when a model lacks the requested point (wow-re
@@ -940,6 +948,12 @@ pub(super) fn attach_spell_fx(
&mut palettes,
None, // an attach-point kit effect opens on its model's own `Stand`
);
+ // MONKEY (spell light): and the light the effect throws, if its school is a luminous
+ // one (fire / holy / fel — never frost, nature, arcane or shadow). A child of the
+ // instance root, so the reap that despawns the root takes it. `Kit` because that is
+ // this lane's lifecycle exactly: up on the ramp, held for the aura's life, out when
+ // `FxDecay` lands.
+ super::spawn_spell_light(&mut commands, &dm.lights, root, SpellLightMode::Kit);
inst.root = Some(root);
if !inst.persistent {
// The client's completion-callback moment: one full pass of the sequence the
diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs
index c1f4571f..b64a2da5 100644
--- a/crates/benilla-app/src/portrait/glue_booth.rs
+++ b/crates/benilla-app/src/portrait/glue_booth.rs
@@ -2037,6 +2037,7 @@ mod tests {
bone_pivot: [0.0; 3],
synthetic: false,
flame: false,
+ spell: None,
}
}
diff --git a/crates/benilla-app/src/torch_shadow.rs b/crates/benilla-app/src/torch_shadow.rs
index f053866c..51fe5c14 100644
--- a/crates/benilla-app/src/torch_shadow.rs
+++ b/crates/benilla-app/src/torch_shadow.rs
@@ -56,17 +56,23 @@ use benilla_assets::materials::WowModelMaterial;
use benilla_assets::coords::wow_to_bevy;
use benilla_formats::ModelBlend;
use benilla_world::billboard::BillboardCard;
-use benilla_world::interact::PickMesh;
+// MONKEY (torch owner exclusion): the placement identity an entity-lane caster part carries, and
+// the model-local bound the containment fallback is decided on.
+use benilla_world::interact::{PickMesh, WorldObject};
+use bevy::camera::primitives::Aabb;
use benilla_world::lighting::{
- interior_reach, m2_light_reach, DynamicInteriors, FireLightGain, LightLane, LightLitRooms,
- LightReach, LightRooms, ShadowDistance, ShadowProxyLight, SyntheticFireLight, WowLighting,
+ interior_reach, m2_light_reach, DaylightFixture, DynamicInteriors, FireLightGain, LightLane,
+ LightLitRooms, LightReach, LightRooms, ShadowDistance, ShadowProxyLight, SyntheticFireLight,
+ WowLighting,
};
// MONKEY (carried light stability): the settle verdict a carried light earns by standing still.
// MONKEY (outdoor torch shadows): …and the marker that says a light is CARRIED BY A BODY.
use crate::entities::{CarriedLightMotion, HeldLight};
use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
-use benilla_world::static_gx::{StaticGx, TorchShadowViews};
+use benilla_world::static_gx::{
+ torch_flame_inside_bounds, LightOwner, StaticGx, TorchShadowViews,
+};
use benilla_world::view::WorldCamera;
use crate::char_select::ClientState;
@@ -158,16 +164,35 @@ const TORCH_STALE_DRIFT_SQ: f32 = 0.01;
/// range: past [`TORCH_RANGE`] a fixture's whole shadow volume is outside the projection anyway,
/// and the world fade in the shaders has already ended by then.
const TORCH_EXT_ELIGIBLE_YD: f32 = TORCH_RANGE;
-/// MONKEY (outdoor torch shadows): EMITTER-OWNER EXCLUSION radius for an exterior slot (yd) — any
-/// retained item whose whole bounding sphere sits inside this of the flame is the fire's OWN body
-/// and is dropped from its caster gather (`StaticGx::append_torch_triangles`). Indoors this is 0
-/// (the interior gather is untouched): an authored MOLT fixture stands clear of its bracket, so
-/// nothing was ever wrong there. Outdoors the light sits INSIDE the campfire prop's opaque logs, and
-/// left in they are the nearest occluder on all six faces — the fire blacks out its own pool.
-/// 2.5 yd covers a campfire/brazier prop whole; a crate or fence post two yards away has its centre
-/// inside that but not its sphere, so it still casts (containment, not centre distance — see
-/// `torch_item_is_emitter`).
-const TORCH_EXT_SELF_EXCLUDE: f32 = 2.5;
+/// MONKEY (moving fixture): how many slots may be FULLY DYNAMIC in one frame — rebuilt from
+/// scratch (static geometry AND entities, into their live layers) because their fixture is
+/// physically moving. Shared with the render plan, which keeps a matching separate rebuild budget.
+const TORCH_MOVING_MAX: usize = TorchShadowViews::MAX_TORCH_MOVING;
+/// MONKEY (moving fixture): how long a fixture must hold still before it is treated as SETTLED and
+/// handed back to the cached path. Long enough to cover a walk cycle's pauses and a pet's
+/// stop-turn-start, short enough that a brazier dropped on the ground is cached within a second.
+const TORCH_MOVING_SETTLE: f64 = 0.75;
+/// MONKEY (moving fixture): how far (yd²) a fixture must shift between two frames to count as
+/// having MOVED at all. A hand-held flame on an idling NPC jitters by millimetres with the breathe
+/// animation; at 60 fps this threshold is ~1.2 yd/s, so an idle never registers and a walk always
+/// does. It is deliberately NOT [`TORCH_STALE_DRIFT_SQ`]: that one asks "is the cached map still
+/// valid" (a question about the map), this one asks "is the fixture in motion" (a question about
+/// the fixture), and a fixture creeping 0.09 yd a frame would answer the first "yes, forever".
+const TORCH_MOVING_EPS_SQ: f32 = 0.0004;
+/// MONKEY (moving fixture): the fade rate (weight per second) for a moving slot that could NOT get
+/// the moving budget — 0.15 s to nothing, five times the normal [`TORCH_FADE_RATE`]. The normal
+/// rate exists to keep a *correct* shadow on screen while it dissolves; this one is for a shadow
+/// that is already wrong (its map is frozen at a position the light has left), so the only good
+/// answer is to get rid of it faster than the eye tracks it, and the fade is there purely so it
+/// dissolves instead of blinking.
+const TORCH_MOVING_FADE_RATE: f32 = 1.0 / 0.15;
+/// MONKEY (torch owner exclusion): how far up the `ChildOf` chain a caster part is followed while
+/// asking "is this the host model of the light that is lighting me?" — the entity-lane half of the
+/// owner test ([`benilla_world::static_gx::LightOwner::Instance`]). A part's chain to its model
+/// frame is `part → (joint → …) → frame`, and a rig's joint chain is the deep part; 64 clears every
+/// shipped skeleton (it is `carried_light`'s own `ANCHOR_WALK_DEPTH`, for the same hierarchy) and
+/// the cap is there so a malformed cycle can never hang the frame.
+const OWNER_WALK_DEPTH: usize = 64;
/// Each cube face's FOV: 90° plus a hair, so a direction exactly on a face border still lands
/// inside the face the shader picks for it (the projector returns "lit" outside its frustum).
const TORCH_FACE_FOV: f32 = std::f32::consts::FRAC_PI_2 + 0.02;
@@ -223,6 +248,29 @@ struct TorchSlot {
/// exterior by where it physically stands ([`LightLane`]), and if that verdict ever flipped the
/// fixture would leave `cands` and be cross-faded out like any other loss.
exterior: bool,
+ /// MONKEY (torch owner exclusion): WHO this fixture is part of — the identity its caster
+ /// gathers drop out of its own map, so the lamp never shadows itself. `None` for a fixture
+ /// that carries no owner tag (an authored WMO MOLT fixture, which has no model at all, and any
+ /// light source predating the tag); those fall back to the containment rule alone.
+ owner: Option,
+ /// MONKEY (torch owner exclusion): how many items the LAST rebuild of this slot dropped as its
+ /// own body — `WOW_TORCH_TRACE` only. Held on the slot rather than in a per-frame counter
+ /// because the gather is cached: the answer is "at the last rebuild", not "this frame".
+ trace_excluded: (u32, u32),
+ /// MONKEY (moving fixture): where the fixture stood LAST frame, and the last time it was seen
+ /// to move further than [`TORCH_MOVING_EPS_SQ`]. `still_since` starts "long ago" (0.0) so a
+ /// freshly promoted slot takes the ordinary cached path on its first frame and only becomes a
+ /// moving slot once it has actually been observed moving — a placed brazier or a standing NPC's
+ /// torch must never spend its first three quarters of a second on the moving budget.
+ last_pos: Vec3,
+ still_since: f64,
+ /// MONKEY (moving fixture): this slot holds one of the [`TORCH_MOVING_MAX`] live rebuild
+ /// budgets this frame — its caster mesh is regathered and its six faces re-rendered EVERY
+ /// frame, from the live fixture position, with the moving entities overlaid.
+ moving_live: bool,
+ /// MONKEY (moving fixture): …and this one is moving but did NOT get a budget, so it is being
+ /// dropped at [`TORCH_MOVING_FADE_RATE`] rather than kept as a stale map.
+ fast_fade: bool,
/// The cross-fade weight, published as `positions[i].w` and applied as `mix(1, shadow, w)` by
/// both receivers.
w: f32,
@@ -232,6 +280,32 @@ struct TorchSlot {
evicting: bool,
}
+impl TorchSlot {
+ /// MONKEY (moving fixture): has the fixture left the position its cached map was baked from?
+ /// This is [`map_publishable`]'s complement with one extra condition — a slot that has NEVER
+ /// built is not "drifted", it is simply new, and belongs to the promotion path rather than to
+ /// the moving budget.
+ fn drifted(&self) -> bool {
+ self.built_at
+ .is_some_and(|p| p.distance_squared(self.pos) > TORCH_STALE_DRIFT_SQ)
+ }
+
+ /// MONKEY (moving fixture): is this fixture MOVING right now, i.e. does it need its whole cube
+ /// re-rendered this frame instead of its cached one republished?
+ ///
+ /// Two halves, and both are needed. WHO it is: an entity-hosted light ([`LightOwner::Instance`]
+ /// — `entities::carried_light`'s pet flames, NPC torches, GameObject braziers) is the only kind
+ /// that CAN move, and a fixture that has already drifted off its baked map is proof of motion
+ /// whoever owns it. WHETHER it is moving NOW: a placed campfire is `Instance`-owned and never
+ /// moves, so the owner alone would put every brazier in a village on the moving budget forever.
+ /// `still_since` is stamped by the per-frame motion test and the fixture is handed back to the
+ /// cache [`TORCH_MOVING_SETTLE`] after it stops.
+ fn moving(&self, now: f64) -> bool {
+ (matches!(self.owner, Some(LightOwner::Instance(_))) || self.drifted())
+ && now - self.still_since < TORCH_MOVING_SETTLE
+ }
+}
+
/// MONKEY (static torch cache): stable physical slots and per-fixture source meshes.
/// Camera drift never rebuilds static geometry; only a source-set/fixture change does.
#[derive(Resource, Default)]
@@ -288,9 +362,17 @@ struct PartCensus {
/// with distance while its RANK by distance changes in steps.
///
/// MONKEY (torch caster selection): one scored candidate — `(fixture, position, score, exterior,
-/// reach)`. Named because MONKEY (torch lane perf) added the trailing REACH lane, and a `(.., x)`
-/// pattern over a bare tuple would silently have re-bound to it.
-type Candidate = (Entity, Vec3, f32, bool, f32);
+/// reach, owner)`. Named because MONKEY (torch lane perf) added the trailing REACH lane, and a
+/// `(.., x)` pattern over a bare tuple would silently have re-bound to it. MONKEY (torch owner
+/// exclusion) added the OWNER lane for the same reason it rides the slot.
+type Candidate = (Entity, Vec3, f32, bool, f32, Option);
+
+/// MONKEY (torch owner exclusion): one fixture in a caster gather — `(slot, position, admission
+/// radius, owner)`. The owner rides the gather because the exclusion is per FIXTURE, not per lane:
+/// the static gather runs for one slot at a time and is therefore exact, and the shared moving
+/// gather (one mesh for the whole dynamic set) excludes a part owned by ANY of its fixtures, which
+/// is the only thing one mesh can express.
+type GatherFixture = (usize, Vec3, f32, Option);
/// MONKEY (torch caster reach): retain the intensity and soft inverse-square core at the player's
/// distance, but stretch the selection window to 4R (including eligibility slack). This is a
@@ -375,7 +457,7 @@ fn exterior_budget(want: usize) -> usize {
/// fires, which is the same ranking rule the whole lane runs on.
fn cap_exterior_candidates(cands: &mut Vec, cap: usize) {
let mut seen = 0usize;
- cands.retain(|(_, _, _, exterior, _)| {
+ cands.retain(|(_, _, _, exterior, ..)| {
if !*exterior {
return true;
}
@@ -412,6 +494,21 @@ fn map_publishable(built_at: Option, pos: Vec3) -> bool {
built_at.is_some_and(|p| p.distance_squared(pos) <= TORCH_STALE_DRIFT_SQ)
}
+/// MONKEY (moving fixture): split this frame's scored movers into the [`TORCH_MOVING_MAX`] that get
+/// a live rebuild and the rest, which are dropped (fast-faded) rather than kept as frozen maps.
+///
+/// Ranked by the SAME contribution score the whole lane ranks by, so the budget goes to the mover
+/// whose shadow the player can actually see, and tied scores fall back to slot order so the answer
+/// is deterministic frame to frame — a budget that alternated between two equally-scored pets would
+/// fast-fade each of them in turn, which is the churn the whole selection machinery exists to avoid.
+fn moving_budget(mut movers: Vec<(usize, f32)>) -> (Vec, Vec) {
+ movers.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
+ (
+ movers.iter().take(TORCH_MOVING_MAX).map(|(i, _)| *i).collect(),
+ movers.iter().skip(TORCH_MOVING_MAX).map(|(i, _)| *i).collect(),
+ )
+}
+
/// MONKEY (static torch cache): the nearest promoted fixtures to the player receive entities.
/// Stable physical-slot tie breaking keeps coincident/equidistant candles deterministic; this
/// subset is independent of contribution ranking and never moves the resident static maps.
@@ -437,8 +534,67 @@ fn entity_gather_radius(reach: f32) -> f32 {
/// MONKEY (torch lane perf): is `origin` inside ANY of the gather's fixtures' own radii — the
/// UNION that replaced "48 yd of every dynamic fixture". Split out as a pure function because it is
/// the whole admission rule of the gather and the one part of it worth a test.
-fn within_any_reach(fixtures: &[(usize, Vec3, f32)], origin: Vec3) -> bool {
- fixtures.iter().any(|(_, p, r)| p.distance_squared(origin) <= r * r)
+fn within_any_reach(fixtures: &[GatherFixture], origin: Vec3) -> bool {
+ fixtures.iter().any(|(_, p, r, _)| p.distance_squared(origin) <= r * r)
+}
+
+/// MONKEY (torch owner exclusion): is this entity-lane caster part the fixture's OWN body?
+///
+/// The entity half of `static_gx`'s `torch_item_is_emitter`, and deliberately the same two rules in
+/// the same order — a part must not cast or not cast depending on which lane happens to be drawing
+/// its placement that frame:
+///
+/// 1. OWNERSHIP. A placed light names its placement by content ([`LightOwner::Placement`]), and
+/// the entity path's parts carry a CLONE of that identity, so a doodad still in its feather
+/// band (or one the retained lane never took) is excluded exactly as its retained twin is. A
+/// carried light names its host model FRAME instead ([`LightOwner::Instance`] — a GameObject
+/// brazier, an NPC's torch have no placement identity at all), and a part is its body iff it
+/// hangs under that frame.
+/// 2. CONTAINMENT of the flame in the part's own bound, shared verbatim with the retained lane
+/// ([`torch_flame_inside_bounds`], including the fixture-size cap that stops a room-sized batch
+/// from claiming every candle in it).
+///
+/// Excluded if ANY fixture of the gather owns it: the moving gather feeds ONE mesh to up to eight
+/// fixtures, so per-fixture exclusion is not representable there. The cost is that a self-lit
+/// creature stops casting for the other fixtures of the same dynamic set — a shadow that is at
+/// worst missing, against a self-shadow artefact that is always visible.
+///
+/// **Rule 2 never applies to a CREATURE.** A body standing against a wall torch has that torch's
+/// flame inside its own bounding box perfectly often, and a player who walks up to a brazier and
+/// loses their shadow is a worse bug than the one this function exists to fix. A creature that
+/// really does carry its own light (an imp's hand fire, a fire elemental, a torch-bearing guard)
+/// is covered by rule 1 instead, which is exact — `entities::carried_light` tags every one of
+/// those lights with the frame it hangs under.
+fn part_is_own_body(
+ ents: &EntityCasters, fixtures: &[GatherFixture], entity: Entity, kind: ModelKind,
+ object: Option<&WorldObject>, global: Option<&GlobalTransform>, aabb: Option<&Aabb>,
+) -> bool {
+ let bound = (kind != ModelKind::Creature).then_some(()).and(global.zip(aabb));
+ fixtures.iter().any(|(_, pos, _, owner)| {
+ let owned = match owner.map(|o| (o, o.instance())) {
+ Some((_, Some(frame))) => hangs_under(ents, entity, frame),
+ Some((o, None)) => object.is_some_and(|obj| o.owns(obj)),
+ None => false,
+ };
+ owned
+ || bound.is_some_and(|(g, b)| {
+ torch_flame_inside_bounds(g.affine(), b.center.into(), b.half_extents.into(), *pos)
+ })
+ })
+}
+
+/// MONKEY (torch owner exclusion): is `entity` `frame`, or a descendant of it? See
+/// [`OWNER_WALK_DEPTH`] for the cap.
+fn hangs_under(ents: &EntityCasters, entity: Entity, frame: Entity) -> bool {
+ let mut at = entity;
+ for _ in 0..OWNER_WALK_DEPTH {
+ if at == frame {
+ return true;
+ }
+ let Ok(parent) = ents.parents.get(at) else { return false };
+ at = parent.parent();
+ }
+ false
}
/// MONKEY (torch lane perf): is a REGATHER of the moving-caster mesh due this frame?
@@ -470,9 +626,18 @@ struct EntityCasters<'w, 's> {
Option<&'static RigPart>,
&'static ShadowOccluder,
Option<&'static MeshMaterial3d>,
+ // MONKEY (torch owner exclusion): the placement identity the entity lane carries (a
+ // CLONE of the retained lane's `Arc`, which is why the owner key compares by content),
+ // and the model-local bound the containment fallback needs. Both `Option`: a part that
+ // has neither is simply never excluded, which is the safe failure direction.
+ Option<&'static WorldObject>,
+ Option<&'static Aabb>,
),
Without,
>,
+ /// MONKEY (torch owner exclusion): the hierarchy walk for a CARRIED light's owner — a brazier
+ /// GameObject's light names its model frame, and its own meshes are that frame's descendants.
+ parents: Query<'w, 's, &'static ChildOf>,
rigs: Query<'w, 's, &'static RigSkin>,
palettes: Res<'w, RigPalettes>,
distance: Res<'w, ShadowDistance>,
@@ -571,11 +736,20 @@ fn update_torch_shadows(
// per-candidate on the INTERIOR arm below, so the interior candidate set is unchanged
// member-for-member. `Has` rides the tuple to reproduce the other half of the
// retired `Or`, and `Has` to refuse a body-carried torch an EXTERIOR slot.
+ // MONKEY (torch owner exclusion): `Option<&LightOwner>` — who this fixture is part of, written
+ // at the two light spawn sites (`terrain_stream::spawn`'s `tag_light_owner`,
+ // `entities::carried_light`). It rides the candidate and then the slot, because the caster
+ // gathers need it and they run far downstream of this query.
torches: Query<
(Entity, &GlobalTransform, &PointLight, Option<&LightReach>, Option<&LightLane>,
Has, Has, Has, Has,
- Has, Option<&crate::entities::CarriedLightMotion>),
- Without,
+ Has, Option<&crate::entities::CarriedLightMotion>, Option<&LightOwner>),
+ // MONKEY (daylight fixtures): a doorway's daylight source is NOT a caster candidate. It is
+ // an AREA source the width of the opening, standing in a hole in a wall — a point-cube
+ // shadow of it would be wrong in kind (hard radial wedges from a soft sky) — and being
+ // bright and close to the player the moment they walk in, it would outrank the room's real
+ // fixtures for the twelve cube slots and take their shadows away.
+ (Without, Without),
>,
// MONKEY (torch caster selection): `atten_scale` — a fixture's EFFECTIVE radius is its authored
// end times this live cvar, and the score's window is a fraction of that radius. Reading the
@@ -631,6 +805,15 @@ fn update_torch_shadows(
let want = (video.interior_shadow_casters as usize).clamp(1, MAX_TORCH_CASTERS);
let dt = time.delta_secs();
let now = time.elapsed_secs_f64();
+ // MONKEY (moving fixture): who held a live rebuild budget LAST frame. Read before the candidate
+ // closure because the closure needs it and `lane` is borrowed mutably from (1b) on; last
+ // frame's answer is the right one anyway — the point of the test is incumbency.
+ let live_held: Vec = lane
+ .slots
+ .iter()
+ .filter(|s| s.moving_live)
+ .map(|s| s.fixture)
+ .collect();
// (1a) MONKEY (torch caster reach): each pool is eligible from 4R + slack away. Rank with the
// same soft core/intensity so nearby contributors still win over pools seen across the room.
@@ -638,25 +821,29 @@ fn update_torch_shadows(
let mut cands: Vec = torches
.iter()
.filter_map(|(e, gt, pl, reach, light_lane, has_rooms, has_lit, synthetic, carried,
- held, motion)| {
+ held, motion, owner)| {
// MONKEY (carried light stability): a CARRIED light (`ChildOf` — a pet's hand flame,
- // an NPC's torch, a transport's deck brazier) casts only while it is STANDING STILL.
+ // an NPC's torch, a transport's deck brazier) used to cast ONLY while standing still,
+ // because a cached cube is valid only while its fixture stays within
+ // `TORCH_STALE_DRIFT_SQ` of where it was baked ([`map_publishable`]) and a moving one
+ // toggled a full-strength shadow at frame rate.
//
- // This whole lane caches a depth cube per fixture and keeps it only while the fixture
- // stays within `TORCH_STALE_DRIFT_SQ` of where it was baked ([`map_publishable`]); a
- // slot whose map is withdrawn has its cross-fade weight forced to 0 with NO ramp
- // (`static_gx::torch_depth`'s `TorchTable::pack`). A moving fixture therefore toggled
- // a full-strength shadow at frame rate — the reported epileptic pool around a
- // summoned imp — and consumed the lane's global two-rebuilds-a-frame budget re-baking
- // a mesh that was stale again on arrival, so the STATIC candles beside it flickered too.
+ // MONKEY (moving fixture): that ban is lifted, because the lane can now RE-RENDER a
+ // moving fixture's whole cube every frame instead of republishing a frozen one (see
+ // [`TorchSlot::moving`]). What survives of the old rule is its arithmetic: only
+ // [`TORCH_MOVING_MAX`] fixtures can be afforded that way, so a moving carried light is
+ // admitted as a candidate only if it already HOLDS one of those budgets or one is free.
+ // Doing it here, at candidacy, rather than by promoting it and evicting it a moment
+ // later, is what stops a third walking pet from churning the slot machinery: it never
+ // enters `cands`, so nothing downstream has to fade it out and refill after it.
//
- // Gated on MOTION rather than excluded outright, because the stationary members of the
- // same `ChildOf` family are exactly what the entity half of this lane was built for: a
- // placed brazier GameObject, a campfire. They settle in well under a second and cast
- // like a MOLT fixture. And because this is a CANDIDACY verdict, an incumbent that
- // starts walking drops out of `cands` and is cross-faded out by (1b)/(1c) rather than
- // cut — "fade, not switch" holds for the eviction as much as for a swap.
- if carried && !motion.is_some_and(CarriedLightMotion::settled) {
+ // The stationary members of the same `ChildOf` family are untouched — a placed brazier
+ // GameObject or a campfire is `settled` and takes the ordinary cached path.
+ if carried
+ && !motion.is_some_and(CarriedLightMotion::settled)
+ && !live_held.contains(&e)
+ && live_held.len() >= TORCH_MOVING_MAX
+ {
return None;
}
let p = gt.translation();
@@ -702,7 +889,7 @@ fn update_torch_shadows(
candidate_score(lum, d, r, light_lane, has_rooms, synthetic, fire_gain.0);
// MONKEY (torch lane perf): `r` rides along — the moving-caster gather sizes its
// radius off the light that will be sampled, not off the cube's 48 yd far plane.
- return (score > 0.0).then_some((e, p, score, false, r));
+ return (score > 0.0).then_some((e, p, score, false, r, owner.copied()));
}
// The EXTERIOR arm — a campfire, a brazier, a lamppost, a bonfire.
if !exterior_on || held {
@@ -718,7 +905,7 @@ fn update_torch_shadows(
let score = exterior_fixture_score(lum * gain, d);
// MONKEY (torch lane perf): an exterior entry packs no reach and its receivers apply an
// un-windowed falloff, so the cube's own range is the only honest gather radius here.
- (score > 0.0).then_some((e, p, score, true, TORCH_RANGE))
+ (score > 0.0).then_some((e, p, score, true, TORCH_RANGE, owner.copied()))
})
.collect();
cands.sort_by(|a, b| b.2.total_cmp(&a.2));
@@ -731,10 +918,14 @@ fn update_torch_shadows(
// fades its extra shadows out instead of cutting them.
for (i, slot) in lane.slots.iter_mut().enumerate() {
match cands.iter().find(|(e, ..)| *e == slot.fixture) {
- Some((_, p, sc, _, r)) => {
+ Some((_, p, sc, _, r, owner)) => {
slot.pos = *p;
slot.score = *sc;
slot.reach = *r;
+ // The owner tag can only ever LAND (it is written at spawn and never removed), so
+ // refreshing it here is what lets a fixture promoted on its very first frame pick
+ // it up rather than casting its own body's shadow until it is next re-promoted.
+ slot.owner = *owner;
}
None => {
slot.score = 0.0;
@@ -746,9 +937,49 @@ fn update_torch_shadows(
}
}
+ // (1b2) MONKEY (moving fixture): the motion test and the live-rebuild budget.
+ //
+ // The bug this answers: a carried light's slot kept publishing the cube it baked at `built_at`
+ // while the light pool followed the pet, so the imp walked around the Darkshire inn dragging a
+ // dark smear that belonged to where it had been. The eviction rule that produced it ("a drifted
+ // slot on its way out publishes from `built_at` and fades") is correct for a fixture that is
+ // LEAVING; it is simply the wrong rule for one that is merely walking, and the only right
+ // answer for that one is to re-render it.
+ //
+ // Ordering matters: this sits between the incumbent refresh (which wrote this frame's `pos` and
+ // `score`) and the weight ramp below (which needs to know who fades fast), so every slot's
+ // verdict is made from fresh positions and acted on in the same frame it is made.
+ for slot in lane.slots.iter_mut() {
+ if slot.pos.distance_squared(slot.last_pos) > TORCH_MOVING_EPS_SQ {
+ slot.still_since = now;
+ }
+ slot.last_pos = slot.pos;
+ }
+ let (live, dropped) = moving_budget(
+ lane.slots
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| s.moving(now))
+ .map(|(i, s)| (i, s.score))
+ .collect(),
+ );
+ for (i, slot) in lane.slots.iter_mut().enumerate() {
+ slot.moving_live = live.contains(&i);
+ // A mover past the cap is withdrawn rather than kept: a frozen map on a fixture that is
+ // still walking is a shadow in the wrong place, which is worse than no shadow. It still
+ // FADES (over 0.15 s) rather than blinking, and it still publishes from `built_at` while it
+ // does, which is what the `evicting` flag buys it in the publish step below.
+ slot.fast_fade = dropped.contains(&i);
+ if slot.fast_fade {
+ slot.evicting = true;
+ }
+ }
+
// (1c) Ramp the weights, then release the slots that finished fading out.
let step = TORCH_FADE_RATE * dt;
for slot in lane.slots.iter_mut() {
+ // MONKEY (moving fixture): a dropped mover leaves five times faster — see the const.
+ let step = if slot.fast_fade { TORCH_MOVING_FADE_RATE * dt } else { step };
slot.w = if slot.evicting {
(slot.w - step).max(0.0)
} else {
@@ -769,7 +1000,7 @@ fn update_torch_shadows(
// MONKEY (static torch cache): at most two new residents per frame, including startup.
let mut promotions = 0;
while lane.slots.len() < want && promotions < 2 {
- let Some(&(e, p, sc, exterior, reach)) = cands
+ let Some(&(e, p, sc, exterior, reach, owner)) = cands
.iter()
.find(|(e, ..)| !lane.slots.iter().any(|s| s.fixture == *e))
else {
@@ -789,6 +1020,15 @@ fn update_torch_shadows(
reach,
checked: None,
exterior,
+ owner,
+ trace_excluded: (0, 0),
+ // MONKEY (moving fixture): born settled — `still_since` at 0 is "moved long ago", so
+ // the first frame takes the ordinary (queue-jumping) cached build and only an observed
+ // move promotes the slot onto the live budget.
+ last_pos: p,
+ still_since: 0.0,
+ moving_live: false,
+ fast_fade: false,
w: 0.0,
evicting: false,
});
@@ -834,8 +1074,21 @@ fn update_torch_shadows(
// removals and replacements even with a stationary camera. Entity-resident rigid furniture
// joins the static mesh; creatures and skinned/animated parts belong to the dynamic overlay.
let fixture_positions: Vec<_> = promoted.iter().map(|(i, _, p, ..)| (*i, *p)).collect();
- let dynamic = dynamic_set(&fixture_positions, anchor, video.interior_shadow_dynamic as usize);
+ // MONKEY (moving fixture): a live slot is dynamic BY DEFINITION — the moving entities are half
+ // of what makes its map wrong when it is frozen (the pet's own body is usually excluded as its
+ // own owner, but everyone it walks past is not), and the overlay is where they are drawn. OR'd
+ // in rather than folded into `dynamic_set`'s nearest-N so the cvar keeps meaning exactly what
+ // it says; `torch_cache_plan` still clamps the union to the eight-cube live bank.
+ let moving_mask = lane
+ .slots
+ .iter()
+ .filter(|s| s.moving_live)
+ .fold(0u32, |mask, s| mask | (1 << s.cache_slot));
+ let dynamic = dynamic_set(&fixture_positions, anchor, video.interior_shadow_dynamic as usize)
+ | moving_mask;
let mut rebuilt = 0;
+ // MONKEY (moving fixture): the live slots' own rebuild allowance, kept apart from `rebuilt`.
+ let mut moving_rebuilt = 0;
let mut static_rebuilt = 0u32;
// MONKEY (torch lane perf): how many fingerprint walks the gate actually let through this
// frame, folded into the lane's per-second trace below (`lane` is mutably borrowed inside the
@@ -844,7 +1097,11 @@ fn update_torch_shadows(
let mut published = TorchShadowViews {
count: promoted.iter().map(|(i, ..)| *i as u32 + 1).max().unwrap_or(0),
soft: video.interior_shadow_soft,
+ // MONKEY (shadow floor): the direct-term floor, packed beside `soft` in `count.y`.
+ strength: video.torch_shadow_strength,
dynamic_mask: dynamic,
+ // MONKEY (moving fixture): the render plan's own budget follows the same set.
+ moving_mask,
// MONKEY (outdoor torch shadows): the receivers' one-bit gate — the cvar, the sun AND an
// actual exterior slot to sample. Keyed on the SLOTS rather than on `exterior_on` alone so
// that a night with the cvar on but nothing promoted (no fires in range) costs the
@@ -882,10 +1139,39 @@ fn update_torch_shadows(
.position(|s| s.checked.is_none())
.unwrap_or_else(|| if slot_count == 0 { 0 } else { lane.rebuild_cursor % slot_count });
let scan = slot_count.min(TORCH_SCAN_PER_FRAME);
+ // MONKEY (moving fixture): a live slot is scanned EVERY frame, in addition to (and ahead of)
+ // the cursor's window. It cannot wait its turn in a rotation that takes a dozen frames to come
+ // round: its fingerprint changes every frame by construction (`slot.pos` is hashed into it), so
+ // "wait for the cursor" and "publish a map baked somewhere else for twelve frames" are the same
+ // sentence. The two budgets stay separate below, so this never costs the resident slots their
+ // own rebuild.
+ let mut scanning: Vec = lane
+ .slots
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| s.moving_live)
+ .map(|(i, _)| i)
+ .collect();
for offset in 0..scan {
let index = (start + offset) % slot_count;
+ if !scanning.contains(&index) {
+ scanning.push(index);
+ }
+ }
+ for index in scanning {
let slot = &mut lane.slots[index];
let i = slot.cache_slot;
+ // MONKEY (moving fixture): this slot's verdict, read once — it picks the gather radius,
+ // the rebuild budget and whether the mesh asset is reused in place.
+ let moving = slot.moving_live;
+ // MONKEY (moving fixture): a moving fixture gathers static geometry inside its OWN reach
+ // rather than the cube's flat 48 yd. The receivers' direct term is exactly zero past
+ // `reach` (`interior_window`), so everything between the two radii could only ever shadow
+ // fragments this fixture does not light — for a cached slot that waste is paid once and
+ // does not matter, but this slot pays it EVERY frame, and an imp's flame reaches ~10 yd
+ // where the cube reaches 48 (a hundredth of the volume). Settled slots keep the full range
+ // verbatim, so no cached key or mesh changes meaning.
+ let range = if moving { entity_gather_radius(slot.reach) } else { TORCH_RANGE };
// MONKEY (torch lane perf): the skip. The fixture's own position joins the census because
// it is hashed INTO the fingerprint (a carried torch moves without the world changing),
// and a slot is only ever stamped once its key is actually committed below — a rebuild
@@ -897,18 +1183,25 @@ fn update_torch_shadows(
let mut hash = std::collections::hash_map::DefaultHasher::new();
slot.fixture.hash(&mut hash);
slot.pos.to_array().map(f32::to_bits).hash(&mut hash);
- // MONKEY (outdoor torch shadows): the exclusion radius is part of the ADMISSION, so it
- // must be part of the FINGERPRINT — a key computed over a different source set than the
- // mesh would certify the wrong geometry as current.
- let self_exclude = if slot.exterior { TORCH_EXT_SELF_EXCLUDE } else { 0.0 };
+ // MONKEY (torch owner exclusion): the owner is part of the ADMISSION, so it is part of the
+ // FINGERPRINT by construction — a key computed over a different source set than the mesh
+ // would certify the wrong geometry as current. (It needs no separate hash lane: the owner
+ // is a function of `slot.fixture`, which is hashed above, and it never changes for a slot.)
+ let owner = slot.owner;
+ let gather = [(i, slot.pos, range, owner)];
gx.as_ref()
- .map(|gx| gx.torch_geometry_key(slot.pos, TORCH_RANGE, self_exclude))
+ .map(|gx| gx.torch_geometry_key(slot.pos, range, owner))
.hash(&mut hash);
let mut entity_key = 0u64;
- for (entity, pick, part, global, rig, occluder, _) in &ents.parts {
+ for (entity, pick, part, global, rig, occluder, _, object, aabb) in &ents.parts {
if !static_part(part, rig.is_some()) || !occluder.0 { continue; }
let Some(global) = global else { continue };
- if global.translation().distance_squared(slot.pos) > TORCH_RANGE * TORCH_RANGE { continue; }
+ if global.translation().distance_squared(slot.pos) > range * range { continue; }
+ // The same exclusion the gather below applies, or the key would certify a mesh that
+ // was never built from this set.
+ if part_is_own_body(&ents, &gather, entity, part.kind, object, Some(global), aabb) {
+ continue;
+ }
// Arc identity catches geometry replacement, transform bits catch moved furniture.
let mut part_hash = std::collections::hash_map::DefaultHasher::new();
entity.hash(&mut part_hash);
@@ -922,22 +1215,47 @@ fn update_torch_shadows(
if !dirty {
slot.checked = Some((census.gx, census.parts, slot.pos));
}
- if dirty && rebuilt < 2 {
- let mut positions = Vec::new();
- let mut indices = Vec::new();
- if let Some(gx) = &gx {
+ // MONKEY (moving fixture): two budgets, not one. A moving slot asks to rebuild every
+ // frame, so sharing the resident slots' two would let one walking pet freeze every static
+ // map in the room for as long as it walked.
+ let afford = if moving { moving_rebuilt < TORCH_MOVING_MAX } else { rebuilt < 2 };
+ if dirty && afford {
+ // MONKEY (moving fixture): recycle THIS slot's own buffers when it is live, so a
+ // fixture that rebuilds sixty times a second does not allocate two Vecs a frame.
+ let (mut positions, mut indices) = slot
+ .mesh
+ .as_ref()
+ .filter(|_| moving)
+ .and_then(|h| meshes.get_mut(h))
+ .map(take_mesh_buffers)
+ .unwrap_or_default();
+ let gx_excluded = gx.as_ref().map_or(0, |gx| {
gx.append_torch_triangles(
- slot.pos, TORCH_RANGE, self_exclude, &mut positions, &mut indices);
+ slot.pos, range, owner, &mut positions, &mut indices)
+ });
+ let (_, part_excluded) =
+ collect_torch_entities(&ents, true, &gather, &mut positions, &mut indices);
+ 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
+ // frame churns a vertex+index GPU allocation per frame per mover. And, worse, a brand
+ // new asset is not certified resident on the frame it is added — `torch_cache_plan`
+ // answers an unready mesh by clearing the slot's ready bit, which `TorchTable::pack`
+ // turns into a zero cross-fade weight: the shadow would blink off on exactly the frames
+ // the fixture is moving. With the id stable the mesh is always ready, and the render
+ // plan rebuilds a `moving_mask` slot unconditionally instead of on an id change.
+ match slot.mesh.as_ref().filter(|_| moving).and_then(|h| meshes.get_mut(h)) {
+ Some(mesh) => restore_mesh_buffers(mesh, positions, indices),
+ None => {
+ let mut mesh = empty_shadow_mesh();
+ restore_mesh_buffers(&mut mesh, positions, indices);
+ if let Some(old) = slot.mesh.replace(meshes.add(mesh)) { meshes.remove(old.id()); }
+ }
}
- collect_torch_entities(
- &ents, true, &[(i, slot.pos, TORCH_RANGE)], &mut positions, &mut indices);
- let mut mesh = empty_shadow_mesh();
- restore_mesh_buffers(&mut mesh, positions, indices);
- if let Some(old) = slot.mesh.replace(meshes.add(mesh)) { meshes.remove(old.id()); }
slot.geometry_key = Some(key);
slot.built_at = Some(slot.pos);
slot.checked = Some((census.gx, census.parts, slot.pos));
- rebuilt += 1;
+ if moving { moving_rebuilt += 1 } else { rebuilt += 1 }
static_rebuilt |= 1 << i;
}
}
@@ -984,12 +1302,12 @@ fn update_torch_shadows(
// (`interior_window`), so a caster admitted beyond it could only ever shadow fragments this
// fixture does not light — it was pure cost. A candle's ~12 yd pool is a twentieth of the
// volume the 48 yd radius swept, and this gather CPU-SKINS every part it admits.
- let dynamic_positions: Vec<(usize, Vec3, f32)> = lane
+ let dynamic_positions: Vec = lane
.slots
.iter()
.take(MAX_TORCH_CASTERS)
.filter(|s| dynamic & (1 << s.cache_slot) != 0)
- .map(|s| (s.cache_slot, s.pos, entity_gather_radius(s.reach)))
+ .map(|s| (s.cache_slot, s.pos, entity_gather_radius(s.reach), s.owner))
.collect();
// MONKEY (torch lane perf): and only at `interiorShadowEntityRate` Hz. The gather + the
// `Mesh` mutation it feeds (a full vertex/index re-extraction, a GPU re-upload and an
@@ -1001,7 +1319,15 @@ fn update_torch_shadows(
if dynamic != 0 {
let fresh = lane.entity_mesh.is_none();
if fresh { lane.entity_mesh = Some(meshes.add(empty_shadow_mesh())); }
+ // MONKEY (moving fixture): a live slot overrides the cadence. `interiorShadowEntityRate`
+ // ages the POSE of the moving casters, which is invisible on a fixture that is standing
+ // still (the shadow is in the right place either way, a frame's stride out of date at
+ // worst). On a fixture that is itself walking it is not: the overlay is re-rendered from a
+ // NEW position every frame, so a mesh gathered two frames ago puts the room's other bodies
+ // in the wrong place relative to the light, which is the smear this change is about. The
+ // rate is untouched for the settled case, which is every other slot in the room.
let due = fresh
+ || moving_mask != 0
|| entity_gather_due(
video.interior_shadow_entity_rate,
now - lane.entity_at,
@@ -1011,7 +1337,7 @@ fn update_torch_shadows(
if due {
if let Some(mesh) = lane.entity_mesh.as_ref().and_then(|h| meshes.get_mut(h)) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
- lane.trace_parts = collect_torch_entities(
+ (lane.trace_parts, _) = collect_torch_entities(
&ents, false, &dynamic_positions, &mut positions, &mut indices);
restore_mesh_buffers(mesh, positions, indices);
}
@@ -1022,7 +1348,7 @@ fn update_torch_shadows(
published.entity_mesh = lane.entity_mesh.as_ref().map(Handle::id);
}
lane.trace_scans += lane_scans;
- lane.trace_reach = dynamic_positions.iter().map(|(.., r)| *r).fold(0.0, f32::max);
+ lane.trace_reach = dynamic_positions.iter().map(|(_, _, r, _)| *r).fold(0.0, f32::max);
*views = published;
// `WOW_TORCH_TRACE=1` — once a second, what the lane published: fixture distances + whether a
@@ -1075,8 +1401,21 @@ fn update_torch_shadows(
// MONKEY (torch caster reach): scientific scores expose the small distant tail;
// distance is still from the player, and eligibility now extends to 4R + 3 yd.
for (i, e, p, sc, w, ext) in &promoted {
+ // MONKEY (torch owner exclusion): `owner` + `own body` are the fix's own readout —
+ // "who this fixture belongs to" and "how many retained items / entity parts its
+ // LAST rebuild dropped as that owner's body". A fixture standing in its own black
+ // square with `own body 0/0` is a missing owner tag; one with a plausible count and
+ // a shadow still under it is a caster arriving by some third route.
+ let slot = lane.slots.iter().find(|s| s.cache_slot == *i);
+ let (gx_excl, part_excl) = slot.map_or((0, 0), |s| s.trace_excluded);
+ // MONKEY (moving fixture): `moving` is this feature's readout — `live` is a slot
+ // being re-rendered from scratch every frame, `dropped` one that is moving but past
+ // TORCH_MOVING_MAX and fast-fading out, `settled` the ordinary cached path. An imp
+ // whose shadow lags is a slot reading `settled` while it walks (then look at
+ // `still_since` / the owner tag); a room whose static shadows freeze is two slots
+ // stuck on `live`.
info!(
- " slot {i}: lane {} {e} at [{:.1},{:.1},{:.1}] d(player) {:.1} score {:.4e} w {:.2} (camera <= shadowDistance AND player <= 4R+3) static {} dynamic {} requested_live_rank {:?}",
+ " slot {i}: lane {} {e} at [{:.1},{:.1},{:.1}] d(player) {:.1} score {:.4e} w {:.2} owner {:?} moving {} own body {gx_excl} gx + {part_excl} parts (camera <= shadowDistance AND player <= 4R+3) static {} dynamic {} requested_live_rank {:?}",
if *ext { "EXT" } else { "INT" },
p.x,
p.y,
@@ -1084,6 +1423,12 @@ fn update_torch_shadows(
p.distance(anchor),
sc,
w,
+ slot.and_then(|s| s.owner),
+ match slot {
+ Some(s) if s.moving_live => "live",
+ Some(s) if s.fast_fade => "dropped",
+ _ => "settled",
+ },
if static_rebuilt & (1 << i) != 0 { "rebuilt (GPU pending)" } else { "cached/requested" },
if dynamic & (1 << i) != 0 { "yes" } else { "no" },
(dynamic & (1 << i) != 0).then(|| TorchShadowViews::live_rank(dynamic, *i)),
@@ -1099,16 +1444,20 @@ fn static_part(part: &ModelPart, rigged: bool) -> bool {
part.kind != ModelKind::Creature && !rigged && part.blend == ModelBlend::Opaque
}
-/// MONKEY (torch lane perf): `fixtures` is `(slot, position, RADIUS)` — each fixture carries the
-/// radius it admits casters inside, instead of the whole gather sharing [`TORCH_RANGE`]. The static
-/// (cached) caller passes `TORCH_RANGE` and is therefore byte-for-byte the gather it always was;
+/// MONKEY (torch lane perf): `fixtures` is `(slot, position, RADIUS, owner)` — each fixture carries
+/// the radius it admits casters inside, instead of the whole gather sharing [`TORCH_RANGE`]. The
+/// static (cached) caller passes `TORCH_RANGE`, so its admission WINDOW is the one it always was;
/// only the per-frame MOVING gather narrows, to `entity_gather_radius(reach)`.
+///
+/// MONKEY (torch owner exclusion): …and the owner, which drops the fixture's own body
+/// ([`part_is_own_body`]). Returns `(admitted, excluded-as-own-body)` — the second is trace only.
fn collect_torch_entities(
- ents: &EntityCasters, want_static: bool, fixtures: &[(usize, Vec3, f32)],
+ ents: &EntityCasters, want_static: bool, fixtures: &[GatherFixture],
positions: &mut Vec<[f32; 3]>, indices: &mut Vec,
-) -> u32 {
+) -> (u32, u32) {
let mut admitted = 0;
- for (_, pick, part, global, rig_part, occluder, _) in &ents.parts {
+ let mut excluded = 0;
+ for (entity, pick, part, global, rig_part, occluder, _, object, aabb) in &ents.parts {
if !occluder.0 || static_part(part, rig_part.is_some()) != want_static { continue; }
let solid = part.blend == ModelBlend::Opaque
|| (part.kind == ModelKind::Creature && part.blend == ModelBlend::AlphaTest);
@@ -1118,6 +1467,12 @@ fn collect_torch_entities(
.or_else(|| rig.and_then(|r| ents.palettes.slot_origin(r.slot)));
let Some(origin) = origin else { continue };
if !within_any_reach(fixtures, origin) { continue; }
+ // After the cheap window, before the expensive CPU skin: this is the lamp's own housing,
+ // the brazier's own bowl, the lantern post the light hangs off.
+ if part_is_own_body(ents, fixtures, entity, part.kind, object, global, aabb) {
+ excluded += 1;
+ continue;
+ }
let base = positions.len() as u32;
if let Some(rig) = rig {
let Some(palette) = ents.palettes.world_palette(rig.slot, rig.bones() as usize) else { continue };
@@ -1149,7 +1504,7 @@ fn collect_torch_entities(
}
admitted += 1;
}
- admitted
+ (admitted, excluded)
}
/// MONKEY (torch lane perf): the entity half of [`PartCensus`] — one branch-free mix per RIGID
@@ -1164,7 +1519,7 @@ fn collect_torch_entities(
/// the column sampled — has no bearing on a shadow's silhouette to the precision this lane renders.
fn static_part_census(ents: &EntityCasters) -> u64 {
let mut census = 0u64;
- for (entity, pick, part, global, rig, occluder, _) in &ents.parts {
+ for (entity, pick, part, global, rig, occluder, ..) in &ents.parts {
if !occluder.0 || !static_part(part, rig.is_some()) { continue; }
let Some(global) = global else { continue };
let a = global.affine();
@@ -1322,14 +1677,15 @@ mod tests {
assert_eq!(exterior_budget(1), 1, "the smallest budget still gets one outdoor shadow");
let mut cands: Vec = (0..8)
.map(|i| {
- (Entity::from_raw_u32(i).unwrap(), Vec3::ZERO, 8.0 - i as f32, i % 2 == 0, 10.0)
+ (Entity::from_raw_u32(i).unwrap(), Vec3::ZERO, 8.0 - i as f32, i % 2 == 0, 10.0,
+ None)
})
.collect();
cap_exterior_candidates(&mut cands, 2);
// Identified by score, which is also their rank: 8,7,6,5,4,3,2,1 with the even ranks
// exterior. Capped at two, ranks 8 and 6 (the best two fires) survive; 4 and 2 do not.
let kept: Vec<(u32, bool)> =
- cands.iter().map(|(_, _, sc, x, _)| (*sc as u32, *x)).collect();
+ cands.iter().map(|(_, _, sc, x, ..)| (*sc as u32, *x)).collect();
assert_eq!(
kept,
vec![(8, true), (7, false), (6, true), (5, false), (3, false), (1, false)],
@@ -1337,7 +1693,7 @@ mod tests {
);
// A cap of zero is a lane switched off, and it takes nothing else with it.
let mut all_ext: Vec =
- vec![(Entity::from_raw_u32(0).unwrap(), Vec3::ZERO, 1.0, true, 10.0)];
+ vec![(Entity::from_raw_u32(0).unwrap(), Vec3::ZERO, 1.0, true, 10.0, None)];
cap_exterior_candidates(&mut all_ext, 0);
assert!(all_ext.is_empty());
}
@@ -1365,7 +1721,7 @@ mod tests {
fn the_gather_admits_the_union_of_the_fixture_reaches() {
let candle = entity_gather_radius(8.0);
let hearth = entity_gather_radius(30.0);
- let set = [(0usize, Vec3::ZERO, candle), (1usize, Vec3::X * 60.0, hearth)];
+ let set = [(0usize, Vec3::ZERO, candle, None), (1usize, Vec3::X * 60.0, hearth, None)];
assert!(within_any_reach(&set, Vec3::X * 5.0), "inside the candle");
assert!(within_any_reach(&set, Vec3::X * 40.0), "inside the hearth, not the candle");
assert!(!within_any_reach(&set, Vec3::X * 20.0), "between the two, lit by neither");
@@ -1378,6 +1734,71 @@ mod tests {
// changed dynamic set always overrides the clock (the mesh is only valid for the fixtures it
// was gathered around), and the tolerance stops a 30 Hz cadence from beating against a 60 fps
// frame into an alternating 1/3-frame stutter.
+ // MONKEY (moving fixture): a slot for the motion tests — everything but the four fields the
+ // verdict reads is inert.
+ fn moving_slot(owner: Option, built_at: Vec3, pos: Vec3, still_since: f64) -> TorchSlot {
+ TorchSlot {
+ cache_slot: 0,
+ mesh: None,
+ geometry_key: None,
+ built_at: Some(built_at),
+ fixture: Entity::PLACEHOLDER,
+ pos,
+ score: 1.0,
+ reach: 10.0,
+ checked: None,
+ exterior: false,
+ owner,
+ trace_excluded: (0, 0),
+ last_pos: pos,
+ still_since,
+ moving_live: false,
+ fast_fade: false,
+ w: 1.0,
+ evicting: false,
+ }
+ }
+
+ // MONKEY (moving fixture): WHO may be re-rendered every frame, and for HOW LONG after it stops.
+ #[test]
+ fn a_fixture_is_moving_only_while_it_is_actually_moving() {
+ let pet = Some(LightOwner::Instance(Entity::PLACEHOLDER));
+ let wall = Some(LightOwner::Placement { kind: ModelKind::Wmo, id: 7, label: 0 });
+ let now = 100.0;
+ // A pet's flame that moved this frame: live.
+ assert!(moving_slot(pet, Vec3::ZERO, Vec3::ZERO, now).moving(now));
+ // …still live a moment later (a walk cycle pauses; the map must not thrash back and forth).
+ assert!(moving_slot(pet, Vec3::ZERO, Vec3::ZERO, now - 0.5).moving(now));
+ // …and settled once it has held still past the threshold — back on the cached path.
+ assert!(!moving_slot(pet, Vec3::ZERO, Vec3::ZERO, now - 1.0).moving(now));
+ // A placed brazier that has never moved is never live, however recently it was promoted.
+ assert!(!moving_slot(wall, Vec3::ZERO, Vec3::ZERO, now).moving(now));
+ // …unless it has physically drifted off its own baked map, which IS proof of motion:
+ // exactly TORCH_STALE_DRIFT_SQ (0.1 yd) worth, the same threshold `map_publishable` uses.
+ assert!(!moving_slot(wall, Vec3::ZERO, Vec3::X * 0.09, now).moving(now));
+ assert!(moving_slot(wall, Vec3::ZERO, Vec3::X * 0.2, now).moving(now));
+ // A slot with no map yet is NEW, not drifted — it belongs to the promotion path.
+ let mut fresh = moving_slot(wall, Vec3::ZERO, Vec3::splat(50.0), now);
+ fresh.built_at = None;
+ assert!(!fresh.drifted());
+ assert!(!fresh.moving(now));
+ }
+
+ // MONKEY (moving fixture): the budget is by contribution, capped, and deterministic.
+ #[test]
+ fn the_moving_budget_keeps_the_best_two_and_drops_the_rest() {
+ assert_eq!(moving_budget(vec![]), (vec![], vec![]));
+ assert_eq!(moving_budget(vec![(3, 0.5)]), (vec![3], vec![]));
+ let (live, dropped) = moving_budget(vec![(0, 0.1), (1, 9.0), (2, 4.0), (3, 0.2)]);
+ assert_eq!(live, vec![1, 2]);
+ assert_eq!(dropped, vec![3, 0]);
+ // Equal scores resolve by slot order, both ways round, so the set cannot alternate.
+ let tied = vec![(5, 1.0), (2, 1.0), (9, 1.0)];
+ assert_eq!(moving_budget(tied.clone()).0, vec![2, 5]);
+ assert_eq!(moving_budget(tied.into_iter().rev().collect()).0, vec![2, 5]);
+ assert_eq!(TORCH_MOVING_MAX, 2, "the cap the render plan budgets for");
+ }
+
#[test]
fn the_entity_gather_cadence_is_a_rate_not_a_gate() {
assert!(entity_gather_due(0, 0.0, 1, 1), "rate 0 = every frame");
diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs
index 888bf7d6..c90bd9f1 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -316,11 +316,27 @@ pub(crate) struct VideoConfig {
/// three", and a walking NPC's shadow lags its body by at most one frame's stride.
pub(crate) interior_shadow_entity_rate: u32,
/// MONKEY (torch caster selection): the PCF tap-radius scale for the torch maps
- /// (`interiorShadowSoft`, 0.5..3, default 1). A candle cluster casts many hard-edged
+ /// (`interiorShadowSoft`, 0.5..3, default **1.5**). A candle cluster casts many hard-edged
/// overlapping shadows; widening the 4-tap kernel is the cheap softening. Rides the torch
- /// table's `count.y` (as `x100`) rather than a `DynamicInteriors` field, because it belongs
- /// to the shadow table's own bytes.
+ /// table's `count.y` (as `x100`, LOW half) rather than a `DynamicInteriors` field, because it
+ /// belongs to the shadow table's own bytes.
+ ///
+ /// MONKEY (pcss): it is now the CONTACT radius, not the radius everywhere — the projector's
+ /// blocker search grows the kernel with the receiver's distance from its caster and clamps at
+ /// 4x this. So this dial sets how sharp the sharpest edge in the scene is, and 1 (the old
+ /// default) now reads sharper at a contact than it used to read anywhere; 1.5 restores the
+ /// shipped softness at a contact and lets the penumbra open up from there.
pub(crate) interior_shadow_soft: f32,
+ /// MONKEY (shadow floor): how much of the DIRECT term a torch shadow removes
+ /// (`torchShadowStrength`, 0..1, default **0.7**). A torch map is the only occlusion the
+ /// direct arm has, so a blocked fragment used to lose all of it — the pitch-black razor-edged
+ /// "scars" the Darkmoon tents printed on the grass and the Darkshire chairs printed on the inn
+ /// floor. Nothing in this renderer bounces light, so the 30 % left standing at the default IS
+ /// the bounce. Rides the torch table's `count.y` HIGH half beside `interior_shadow_soft`, and
+ /// the receivers fold it into the slot's cross-fade weight (one multiply, no extra tap), so it
+ /// touches the direct arm only — fill and ambient never saw this factor. `1` restores the
+ /// shipped look exactly; `0` turns torch shadows off without disturbing the lane behind them.
+ pub(crate) torch_shadow_strength: f32,
/// MONKEY (room gate): whether an interior fixture may only light the ROOMS IT CLAIMS
/// (`interiorRoomGate`, default on). Off = the pre-gate behaviour, where every interior fixture
/// in range lights every interior surface in range and the only occlusion is the handful of
@@ -332,6 +348,31 @@ pub(crate) struct VideoConfig {
/// MONKEY (interior debug): the interior-lane diagnostic overlay (`interiorDebug`, 0..4). See
/// [`benilla_world::lighting::DynamicInteriors::debug`].
pub(crate) interior_debug: u32,
+ /// MONKEY (darkness gains): the exterior night dim (`nightGain`, 0.2..1.5, default **0.8** =
+ /// nights 20 % darker). Bridged to `DynamicInteriors::night_gain`, which the light packer folds
+ /// into the packed ambient/diffuse/specular rows on a `mix(1, gain, night_w)` ramp — so it is
+ /// exactly inert while the sun is up and live the frame it changes after dark.
+ pub(crate) night_gain: f32,
+ /// MONKEY (lighting debug panel): the interior dim (`interiorGain`, 0.2..1.5, default **0.5** =
+ /// room inputs 50 % weaker). Bridged to `DynamicInteriors::interior_gain`, which scales the room
+ /// lane's INPUTS (base ambient, per-fixture fill, every interior fixture's colour) and not
+ /// `interiorExposure` — that stays the user's own dial, and this composes with it.
+ pub(crate) interior_gain: f32,
+ /// MONKEY (enclosed day floor): the DAYLIGHT floor a room inside a building gets by day
+ /// (`interiorDaylight`, 0..1, default **0.12**). Bridged to
+ /// [`benilla_world::lighting::DynamicInteriors::daylight`], packed into the free fraction of
+ /// the interior lane's on/off word, and added to the room law's ambient budget for batches the
+ /// record table flags as enclosed. `0` restores the pre-feature look exactly; the night look is
+ /// unaffected at any value (the term is scaled by the sun's own day envelope).
+ pub(crate) interior_daylight: f32,
+ /// MONKEY (bake floor): the share of a WMO interior batch's OWN MOCV bake every interior-lane
+ /// fragment keeps whether or not a fixture reaches it (`interiorBakeFloor`, 0..1, default
+ /// **0.12**). Bridged to [`benilla_world::lighting::DynamicInteriors::bake_floor`], packed
+ /// (times `interiorGain`) into the free fraction of the world-shadow lane, and added to the
+ /// room law's budget inside its rolloff. It is what stops a room the fixture table cannot
+ /// reach — the Lion's Pride Inn's east vestibule — rendering black; `0` restores the
+ /// pre-feature look exactly.
+ pub(crate) interior_bake_floor: f32,
/// MONKEY (fire GO lights): gain on every light SYNTHESISED from a fire prop's flame emitter
/// (`fireLightGain`, 0..4; `0` = the invented-light lane off). Bridged to benilla-world's
/// [`benilla_world::lighting::FireLightGain`] by `dynamic_interior`, and applied at PACK time
@@ -352,8 +393,8 @@ impl Default for VideoConfig {
fn default() -> Self {
Self {
vsync: !novsync_env(),
- world_shadows: false,
- character_shadows: false,
+ world_shadows: true,
+ character_shadows: true,
shadow_distance: crate::shadow_core::DEFAULT_SHADOW_DISTANCE,
// MONKEY (sun shadow perf): 2048, not the rig's old 4096 literal — see the field docs.
shadow_map_size: crate::shadow_core::DEFAULT_SHADOW_MAP_SIZE,
@@ -363,8 +404,8 @@ impl Default for VideoConfig {
shadow_caster_reach: 1.0,
// The cvar defaults are the source of truth at load; these only stand in until then.
interior_light: true,
- interior_ambient: 0.15,
- interior_fill: 0.12,
+ interior_ambient: 0.015,
+ interior_fill: 0.08,
interior_exposure: 2.5,
// MONKEY (soft falloff): 2.5, not 1 — see the field doc.
interior_atten_scale: 1.6,
@@ -377,11 +418,24 @@ impl Default for VideoConfig {
interior_shadow_dynamic: 4,
// MONKEY (torch lane perf): 30 Hz - see the field doc.
interior_shadow_entity_rate: 30,
- interior_shadow_soft: 1.0,
+ // MONKEY (pcss): 1.5 — see the field doc; `soft` is now the CONTACT radius.
+ interior_shadow_soft: 1.5,
+ // MONKEY (shadow floor): 0.7 — a shadow takes 70 % of the direct term, not all of it.
+ torch_shadow_strength: 0.7,
// MONKEY (room gate): on — without it a building's fixtures light through its own
// floors and walls.
interior_room_gate: true,
interior_debug: 0,
+ // MONKEY (lighting debug panel): nights 20 % darker, interior inputs 50 % weaker.
+ night_gain: 0.45,
+ interior_gain: 0.5,
+ // MONKEY (enclosed day floor): calibrated so the Goldshire inn's entry floor reads
+ // ~50 % of the sunlit threshold beside it — see `lighting::DAYLIGHT_LANE_SCALE`.
+ interior_daylight: 0.0,
+ // MONKEY (bake floor): an eighth of the authored bake — measured to lift the inn's
+ // black door band from 0.019 to 0.108 x tex while moving candle-lit surfaces by
+ // under 10 % (see `lighting::DynamicInteriors::bake_floor`).
+ interior_bake_floor: 0.12,
fire_light_gain: 1.0,
fire_flicker: 1.0,
display: if windowed_env() {
diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs
index 6fe74995..7cdf7a23 100644
--- a/crates/benilla-assets/src/lib.rs
+++ b/crates/benilla-assets/src/lib.rs
@@ -53,6 +53,9 @@ pub use tex_filter::{publish_tex_filter, tex_filter, TexFilterSetting, ANISO_RAN
mod m2;
pub use m2::{
EmitterBillboard, M2Model, M2ModelLoader, ModelEmitter, ModelLight, ModelRibbon, PortraitCamera,
+ // MONKEY (spell light): what a spell/firework-derived light carries beyond the light itself
+ // (its school and its onset) — see [`ModelLight::spell`].
+ SpellLightInfo,
};
mod wmo;
pub use benilla_formats::{WmoPortalInfo, WmoPortalRef};
diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs
index a79fd563..ad44dcaa 100644
--- a/crates/benilla-assets/src/m2.rs
+++ b/crates/benilla-assets/src/m2.rs
@@ -313,6 +313,35 @@ pub struct ModelLight {
/// than as fire. Keying on the ROUTE rather than on the hue is what gets both right — see
/// `benilla_world::lighting::flame_kind_for`.
pub flame: bool,
+ /// MONKEY (spell light): `true` when this light came from the **SPELL** route
+ /// ([`benilla_formats::synthesize_spell_light`]) — a spell effect's or a firework's emitter.
+ /// Always `false` on an authored block and on the two world routes.
+ ///
+ /// It exists so the PLACED lanes can keep refusing spell content exactly as they did before
+ /// this feature (`benilla_world::terrain_stream::spawn`'s `spawn_lights_for`): the world light
+ /// table is built for fixtures that stand still for minutes, and an effect model that happens
+ /// to be placed as scenery must not enter it. Only the ENTITY lanes — a spell-visual instance,
+ /// a missile, a firework GameObject — take it, and there it is budgeted, enveloped and reaped
+ /// with its effect (`entities::carried_light`'s spell-light helper).
+ ///
+ /// It also carries the light's **onset**: `benilla_formats::fire_light::emit_onset`, the delay
+ /// before the emitter this light stands for actually fires. A firework's shell detonates half
+ /// a second into its model's clip, and a light lit at spawn would flash the rocket's flight
+ /// instead of its burst.
+ pub spell: Option,
+}
+
+/// MONKEY (spell light): what a spell/firework-derived [`ModelLight`] carries beyond the light
+/// itself — see [`ModelLight::spell`].
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SpellLightInfo {
+ /// The school judged at synthesis ([`benilla_formats::SpellLightKind`]) — for the trace and
+ /// for any future per-school gain. Never `None` (that case yields no light at all).
+ pub kind: benilla_formats::SpellLightKind,
+ /// Seconds from the instance's birth before the light comes up (the emitter's rate-track
+ /// onset). `0.0` for everything with a constant emission rate, which is every missile and
+ /// every kit glow.
+ pub onset: f32,
}
/// Bevy [`AssetLoader`] decoding `*.m2` → [`M2Model`].
@@ -502,6 +531,7 @@ impl AssetLoader for M2ModelLoader {
def,
synthetic: false,
flame: false,
+ spell: None,
})
.collect();
// MONKEY (fire GO lights) / MONKEY (lamp lights): a prop that authors NO casting light gets
@@ -524,13 +554,49 @@ impl AssetLoader for M2ModelLoader {
// yields the same four things: model-space position, host bone, colour, intensity —
// plus, MONKEY (flame flicker), WHICH route won, because that is what decides whether
// the light burns (flickers) or merely shines (see [`ModelLight::flame`]).
- let synth = benilla_formats::synthesize_fire_light(
+ //
+ // MONKEY (spell light): and a THIRD route ahead of both — SPELL effects and FIREWORKS,
+ // which the two world rules veto by path (`fire_light::is_spell_path`). They are put
+ // first because the veto is theirs: a model that IS spell content can never be a
+ // placed prop, so there is nothing for the other two to say about it. What comes back
+ // is flagged ([`ModelLight::spell`]) so the PLACED lanes can keep refusing it exactly
+ // as they do today — only the entity lanes, which reap a light with its effect, take
+ // one.
+ let synth = benilla_formats::synthesize_spell_light(
&path,
emitters.iter().map(|e| &e.def),
)
- .map(|fire| {
- let src = &emitters[fire.emitter].def;
- (src.position, src.bone, fire.color, fire.intensity, true)
+ .map(|fx| {
+ let src = &emitters[fx.emitter].def;
+ (
+ src.position,
+ src.bone,
+ fx.color,
+ fx.intensity,
+ // Never a FLAME for the flicker's purposes: a spell light runs its own
+ // lifecycle envelope (ramp in / hold / decay) and a fire wobble on top of it
+ // would read as the effect stuttering, not as fire breathing.
+ false,
+ Some(SpellLightInfo {
+ kind: fx.kind,
+ onset: fx.onset,
+ }),
+ )
+ })
+ .or_else(|| {
+ benilla_formats::synthesize_fire_light(&path, emitters.iter().map(|e| &e.def)).map(
+ |fire| {
+ let src = &emitters[fire.emitter].def;
+ (
+ src.position,
+ src.bone,
+ fire.color,
+ fire.intensity,
+ true,
+ None,
+ )
+ },
+ )
})
.or_else(|| {
// MONKEY (lamp lights): a lamppost/lantern/chandelier authors NO particle emitter
@@ -545,9 +611,9 @@ impl AssetLoader for M2ModelLoader {
.collect();
let bbox = bounds.as_ref().map(|b| (b.bbox_min, b.bbox_max));
benilla_formats::synthesize_lamp_light(&path, &batches, bbox)
- .map(|l| (l.position, l.bone, l.color, l.intensity, false))
+ .map(|l| (l.position, l.bone, l.color, l.intensity, false, None))
});
- if let Some((position, src_bone, color, intensity, flame)) = synth {
+ if let Some((position, src_bone, color, intensity, flame, spell)) = synth {
// The light sits at the FLAME (or the lamp glass), not the model origin: a brazier's
// origin is under its bowl, and a light there back-lights the bowl into every
// surface it should be lighting (and self-shadows through the prop's own caster
@@ -582,6 +648,7 @@ impl AssetLoader for M2ModelLoader {
.map_or([0.0; 3], |b| b.pivot),
synthetic: true,
flame,
+ spell,
});
}
}
diff --git a/crates/benilla-assets/src/shaders/shadow_hook.wgsl b/crates/benilla-assets/src/shaders/shadow_hook.wgsl
index 49a8084a..09170b37 100644
--- a/crates/benilla-assets/src/shaders/shadow_hook.wgsl
+++ b/crates/benilla-assets/src/shaders/shadow_hook.wgsl
@@ -78,6 +78,51 @@ fn torch_caster_count() -> u32 {
return 0u;
}
+// MONKEY (pcss): the contact-hardening constants.
+//
+// `TORCH_DEPTH_B` is the reverse-Z projection's depth OFFSET, mirrored from
+// `benilla_app::torch_shadow::{reverse_z_perspective, cube_view_projs}` (near 0.1, far TORCH_RANGE
+// 48 — every matrix this function is ever handed is one of those six cube faces). Under reverse-Z
+// the stored depth is an AFFINE function of the RECIPROCAL distance along the face axis:
+// `z = A/t + B`, `A = near*far/(far-near)`, `B = -near/(far-near)`. So `z - B` is proportional to
+// `1/t`, and the one quantity a penumbra needs — the ratio of two distances — is
+// `(z_blocker - B) / (z_receiver - B)` with `A` cancelling: no linearisation, no extra uniform, one
+// subtraction per term. A constant rather than something decoded out of `view_proj` because the
+// w-row of a look-at product carries the eye translation, so `B` cannot be read off a single
+// element of that matrix without dividing two of them — and a wrong `B` would silently mis-size
+// every penumbra instead of failing.
+const TORCH_DEPTH_B: f32 = -0.1 / 47.9;
+// How far out (in `soft` texels) the blocker search looks. Two: wide enough to find the caster of
+// an edge a texel or two away, narrow enough that a fragment well inside a lit area still reads
+// four empty texels and takes the cheap path.
+const TORCH_PCSS_SEARCH: f32 = 2.0;
+// Penumbra gain — extra `soft` radii per unit of `(d_receiver - d_blocker)/d_blocker`. A receiver
+// twice as far from the fixture as its blocker (ratio 1) is filtered three times as wide; the
+// Darkmoon tent (post ~2 yd from the torch, grass ~10) saturates the clamp below, and a chair leg
+// resting ON the floor (ratio ~0.05) stays within 10 % of the hard kernel — the whole point.
+const TORCH_PCSS_K: f32 = 2.0;
+// …clamped at four radii. Past that the 8 taps are too sparse for the area they cover and the
+// penumbra starts to band, and the shadow there is faint enough that widening it further is
+// invisible anyway.
+const TORCH_PCSS_MAX: f32 = 4.0;
+// Above this filter radius (texels) the 4-tap box is too sparse and earns the second ring.
+const TORCH_PCSS_WIDE: f32 = 1.5;
+
+// MONKEY (pcss): one RAW depth texel of a torch map — the blocker search's read. `textureLoad`
+// takes no sampler and imposes no uniformity requirement, which is what makes the search legal
+// inside the receivers' per-fixture loop. The clamp is the border rule: a search that walks off the
+// face re-reads its edge texel, the same answer the comparison sampler's ClampToEdge address mode
+// gives the PCF taps beside it.
+fn torch_map_depth(
+ depth_tex: texture_depth_2d_array,
+ layer: i32,
+ uv: vec2,
+ dims: vec2,
+) -> f32 {
+ let c = clamp(vec2(floor(uv * dims)), vec2(0, 0), vec2(dims) - vec2(1, 1));
+ return textureLoad(depth_tex, c, layer, 0);
+}
+
// MONKEY (torch shadows Phase 1): the depth-map projector. Because `shadow_hook` is imported by
// receivers WITHOUT the group-3 torch bindings (wow_model, terrain), the texture + comparison
// sampler come in AS PARAMETERS — the caller supplies them only where they are bound (static_gx).
@@ -86,12 +131,13 @@ fn torch_caster_count() -> u32 {
// - `world_pos` the receiving fragment's world position.
// - `depth_tex`/`comp` the group-3 depth array + `GreaterEqual` comparison sampler.
// - `bias` reverse-Z receiver bias (nudges the compare ref up to kill self-shadow acne).
-// - `soft` MONKEY (torch caster selection): the PCF tap-radius scale (`interiorShadowSoft`,
-// 0.5..3, carried in the table's `count.y` as `soft x 100`). The four taps sit at
-// ±0.5 texel × this; 1 is the historical half-texel box. A candle cluster casts
-// many overlapping penumbra-less edges, and widening the kernel is the cheapest
-// softening available here (a real penumbra would need the blocker distance and a
-// variable kernel — not worth a second sampling pass on a 512² face).
+// - `soft` MONKEY (torch caster selection): the BASE PCF tap-radius scale
+// (`interiorShadowSoft`, 0.5..3, carried in the table's `count.y` LOW half as
+// `soft x 100`). The four taps sit at ±0.5 texel × this; 1 is the historical
+// half-texel box. MONKEY (pcss): it is now the radius at a CONTACT (a receiver
+// touching its blocker) — the contact-hardening search below grows it with the
+// blocker distance, so `soft` sets how sharp the sharpest edge in the scene is
+// and every penumbra scales off it.
// - `fade` MONKEY (outdoor torch shadows): WHICH far fade ends the shadow.
// **Negative = the interior/legacy behaviour, bit-for-bit** — the reverse-Z
// far-plane fade computed from `ndc.z` below. Zero-or-positive = the CALLER's own
@@ -106,7 +152,27 @@ fn torch_caster_count() -> u32 {
// in here — it wants to be a function of WORLD distance from the fixture, and
// this function is deliberately a pure projector that never sees the fixture — so
// the exterior lane computes `1 − smoothstep(0.8R, R, d)` at its call site.
-// Returns 1.0 outside the frustum / behind the light; a 4-tap PCF factor otherwise (0 = shadowed).
+// Returns 1.0 outside the frustum / behind the light; a PCF factor otherwise (0 = shadowed).
+//
+// MONKEY (pcss): CONTACT-HARDENING. The old kernel was one fixed ±0.5·soft texel box everywhere,
+// which is why a torch beside a Darkmoon tent post threw the canvas onto the grass fifteen yards
+// away as a razor-edged cut-out: a real penumbra grows with the receiver's distance FROM ITS
+// BLOCKER, and a constant kernel has no way to know that distance. The three steps below recover
+// it from the map itself — search for blockers, estimate how far in front of the receiver they
+// are, widen the filter by that much — so a chair leg stays crisp where it meets the floor while
+// the tent's edge goes soft where the canvas is metres above the grass.
+//
+// The blocker search reads RAW depth with `textureLoad`, not through the comparison sampler: the
+// array is bound as `texture_depth_2d_array` (wgpu sample type Depth), and a Depth binding serves
+// BOTH `textureSampleCompare*` (through `comp`) and an unfiltered `textureLoad` that takes no
+// sampler at all — so no binding, layout or pipeline changed. That is worth insisting on, because
+// the alternative approximation (infer the blocker from the FRACTION of blocked compares at two or
+// three radii) only ever answers "how deep inside the shadow am I", never "how far in front of me
+// is the caster", and those two differ by exactly the quantity a penumbra is made of.
+//
+// Cost: 4 unfiltered loads, then 4 comparison taps (the shipped kernel, unchanged) whenever the
+// search finds no blocker — which is every fully-lit fragment, i.e. most of a pool — or 8 when the
+// widened radius earns them. Worst case 8 compares + 4 loads per fixture per fragment.
fn torch_map_shadow(
view_proj: mat4x4,
layer: i32,
@@ -126,18 +192,68 @@ fn torch_map_shadow(
return 1.0;
}
let uv = ndc.xy * vec2(0.5, -0.5) + vec2(0.5);
- // MONKEY (torch caster selection): the tap radius is `soft` texels, clamped off zero so a
- // table that never got a scale still samples a real (hard) 4-tap box rather than four copies
- // of one texel.
- let texel = (1.0 / vec2(textureDimensions(depth_tex).xy)) * max(soft, 0.05);
+ let dims = vec2(textureDimensions(depth_tex).xy);
+ let inv_dims = 1.0 / dims;
+ // MONKEY (torch caster selection): the base radius, clamped off zero so a table that never got
+ // a scale still samples a real (hard) box rather than four copies of one texel.
+ let base = max(soft, 0.05);
// Reverse-Z: the fragment is lit iff its own depth is at least the stored nearest depth, so the
- // compare ref is `ndc.z + bias` against `GreaterEqual`.
+ // compare ref is `ndc.z + bias` against `GreaterEqual`. UNCHANGED bias semantics: the same
+ // biased reference decides both the blocker test and every comparison tap, so a surface can no
+ // more blocker-detect itself than it could shadow itself.
let ref_depth = ndc.z + bias;
+
+ // MONKEY (pcss) 1/3 — BLOCKER SEARCH. Four raw reads on the diagonals of a box of half-width
+ // `soft * TORCH_PCSS_SEARCH` texels, averaging the depths that lie IN FRONT of the receiver.
+ // Four taps at one radius rather than a ring at several: they only have to answer "is there a
+ // caster near me, and roughly how far in front", and denser searching costs more than the extra
+ // precision buys on a 512² face whose texels are ~10 cm at a candle's range.
+ let search = base * TORCH_PCSS_SEARCH * inv_dims;
+ let b0 = torch_map_depth(depth_tex, layer, uv + vec2(-1.0, -1.0) * search, dims);
+ let b1 = torch_map_depth(depth_tex, layer, uv + vec2( 1.0, -1.0) * search, dims);
+ let b2 = torch_map_depth(depth_tex, layer, uv + vec2(-1.0, 1.0) * search, dims);
+ let b3 = torch_map_depth(depth_tex, layer, uv + vec2( 1.0, 1.0) * search, dims);
+ var blocker = 0.0;
+ var blockers = 0.0;
+ if (b0 > ref_depth) { blocker += b0; blockers += 1.0; }
+ if (b1 > ref_depth) { blocker += b1; blockers += 1.0; }
+ if (b2 > ref_depth) { blocker += b2; blockers += 1.0; }
+ if (b3 > ref_depth) { blocker += b3; blockers += 1.0; }
+
+ // MONKEY (pcss) 2/3 — PENUMBRA WIDTH. `radius` stays at `base` when the search found nothing: a
+ // caster thinner than the search box (a chair leg, a tent rope, a candlestick) must NOT be
+ // widened away to nothing, and such a fragment is then filtered by exactly the kernel this
+ // function has always used.
+ var radius = base;
+ if (blockers > 0.0) {
+ // `(d_receiver - d_blocker) / d_blocker`, exact — see TORCH_DEPTH_B: `z - B` is
+ // proportional to `1/distance`, so the quotient of the two `z - B` terms IS the quotient of
+ // the two distances, and the projection's scale factor cancels. The receiver term uses the
+ // UNBIASED `ndc.z` (the bias exists to keep a surface off its own stored depth, not to move
+ // the surface), and `max` only guards f32 noise: inside the frustum a detected blocker is
+ // always the nearer of the two.
+ let ratio = max((blocker / blockers - TORCH_DEPTH_B) / (ndc.z - TORCH_DEPTH_B) - 1.0, 0.0);
+ radius = min(base * (1.0 + TORCH_PCSS_K * ratio), base * TORCH_PCSS_MAX);
+ }
+
+ // MONKEY (pcss) 3/3 — PCF at that radius. The 4-tap box below `TORCH_PCSS_WIDE` texels is the
+ // shipped kernel bit-for-bit (`radius == base` reproduces the old `texel` expression exactly);
+ // past it that box would alias into four separate soft bands, so a second ring of four cardinal
+ // taps at the full radius fills the area in.
+ let texel = inv_dims * radius;
var sum = 0.0;
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2(-0.5, -0.5) * texel, layer, ref_depth);
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2( 0.5, -0.5) * texel, layer, ref_depth);
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2(-0.5, 0.5) * texel, layer, ref_depth);
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2( 0.5, 0.5) * texel, layer, ref_depth);
+ var pcf = sum * 0.25;
+ if (radius > TORCH_PCSS_WIDE) {
+ sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2(-1.0, 0.0) * texel, layer, ref_depth);
+ sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2( 1.0, 0.0) * texel, layer, ref_depth);
+ sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2( 0.0, -1.0) * texel, layer, ref_depth);
+ sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2( 0.0, 1.0) * texel, layer, ref_depth);
+ pcf = sum * 0.125;
+ }
// Phase 5: the six cube faces tile the whole sphere, so there is no cone edge to soften — a fade
// there would punch a lit seam along every face border. Only the far plane fades (reverse-Z:
// ndc.z → 0 at far), so a shadow at the fixture's range limit ends softly.
@@ -145,7 +261,7 @@ fn torch_map_shadow(
// rather than a branch so both arms cost the same and the interior arm is the identical
// expression it always was.
let ndc_fade = smoothstep(0.0, 0.06, ndc.z);
- return mix(1.0, sum * 0.25, select(fade, ndc_fade, fade < 0.0));
+ return mix(1.0, pcf, select(fade, ndc_fade, fade < 0.0));
}
// MONKEY (interior debug): the interior lane's diagnostic overlay. `mode` is decoded from the
diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl
index 23131737..7a4e36ed 100644
--- a/crates/benilla-assets/src/shaders/terrain.wgsl
+++ b/crates/benilla-assets/src/shaders/terrain.wgsl
@@ -109,10 +109,27 @@ const TORCH_BIAS: f32 = 0.001;
// collapses on precisely the surfaces that need it most. The same 0.15 the entity lane uses, so a
// character and the ground under it are offset alike and their shadows meet without a gap.
const TORCH_NORMAL_OFFSET: f32 = 0.15;
-// MONKEY (torch caster selection, MIRRORED from static_gx.wgsl): the live PCF tap-radius scale,
+// MONKEY (torch caster selection, MIRRORED from static_gx.wgsl): the live PCF tap-radius scale
+// (count.y LOW half) and MONKEY (shadow floor)'s strength (HIGH half),
// unpacked from the table's `count.y` (stored x100 - the row is `vec4`).
fn torch_soft() -> f32 {
- return max(f32(torch_table.count.y) * 0.01, 0.05);
+ return max(f32(torch_table.count.y & 0xffffu) * 0.01, 0.05);
+}
+// MONKEY (shadow floor): the live SHADOW STRENGTH (`torchShadowStrength`, 0..1, default 0.7),
+// unpacked from `count.y`'s HIGH half (`(strength x 100) << 16 | soft x 100` - see
+// `TorchTableUniform::pack`; the low half is `torch_soft` above and `count.w`'s flags are
+// untouched). It is applied as `mix(1, s, w * strength)` where the cross-fade weight already
+// multiplies the shadow factor, which is algebraically the same thing as flooring the factor
+// itself (`1 - w*strength*(1-s)` either way) for one extra multiply and no extra tap.
+//
+// WHY a floor at all: a torch map is the ONLY occlusion in the direct term, so a blocked fragment
+// used to drop that term to exactly zero - a pitch-black, razor-edged scar of tent canvas across
+// Darkmoon's grass, table legs printed on the Darkshire inn floor. Nothing in this renderer
+// bounces, so the 30 % that survives at the default IS the bounce: the fill/ambient arms are
+// untouched (they never saw this factor), only the DIRECT arm is floored. 1 restores the shipped
+// pitch-black look exactly, 0 disables torch shadows without disturbing the lane behind them.
+fn torch_strength() -> f32 {
+ return clamp(f32(torch_table.count.y >> 16u) * 0.01, 0.0, 1.0);
}
// MONKEY (outdoor torch shadows, MIRRORED from static_gx.wgsl - keep in sync): the EXTERIOR lane's
// world-distance fade radius (yd) and the CPU's one-bit lane gate in `count.w`. See the static_gx
@@ -184,7 +201,10 @@ fn torch_terrain_shadow(light_pos: vec3, P: vec3, N: vec3) -> f32
// MONKEY (torch caster selection): `.w` is the slot's FADE WEIGHT, so a promoted fixture's
// shadow ramps in over ~1/3 s and a demoted one ramps out. The ground and the NPC standing
// on it MUST use the same weight or one shadow would pop while the other faded.
- return mix(1.0, s, torch_table.positions[i].w);
+ // MONKEY (shadow floor): and `torch_strength()` is the DIRECT-term floor folded
+ // into that same weight (see the function). `w * strength` rather than a second `mix`
+ // because the two are the same expression.
+ return mix(1.0, s, torch_table.positions[i].w * torch_strength());
}
}
return 1.0;
@@ -236,13 +256,16 @@ struct TerrainVsOut {
// The receiver normal is carried so the real cascaded shadow lookup can apply a slope-aware
// bias. This is forward-pass data only; the terrain shadow caster is a stock Bevy proxy.
@location(7) world_normal: vec3,
- // MONKEY (outdoor torch shadows: terrain): WHICH <=3 exterior table entries `primary`'s point
- // term was summed from, packed 10 bits each (see `EXT_SEL_EMPTY`). FLAT, because it is a CHOICE
- // and not a quantity - interpolating three packed indices across a triangle would produce a
- // fourth, meaningless one. It lets the fragment stage re-shadow the VERTEX's own selection
- // instead of re-ranking, which is what keeps the selection popping at chunk granularity (the
- // authored behaviour) rather than drawing a hard line mid-cell.
- @location(8) @interpolate(flat) ext_sel: u32,
+ // MONKEY (outdoor torch shadows: terrain; ext light k8): WHICH <=`EXT_SEL_K` exterior table
+ // entries `primary`'s point term was summed from, packed 8 bits each across TWO u32s (see
+ // `EXT_SEL_EMPTY` / `ext_sel_get`). FLAT, because it is a CHOICE and not a quantity -
+ // interpolating packed indices across a triangle would produce a different, meaningless one. It
+ // lets the fragment stage re-shadow the VERTEX's own selection instead of re-ranking, which is
+ // what keeps the selection popping at chunk granularity (the authored behaviour) rather than
+ // drawing a hard line mid-cell. Widened from ONE u32 of three 10-bit ranks to TWO u32s of eight
+ // 8-bit ranks - one extra interstage component - see `EXT_SEL_K` for why three stopped being
+ // enough.
+ @location(8) @interpolate(flat) ext_sel: vec2,
// MONKEY (outdoor torch shadows: terrain): `primary` WITHOUT the point term and WITHOUT the
// clamp - just `ambient + sun*max(N.L,0)`. `primary` is clamped per VERTEX (the GL T&L locus,
// see its note above) and that clamp is lossy: once a hot torch has saturated a vertex there is
@@ -288,64 +311,157 @@ const TERRAIN_REACH: f32 = 33.570166;
// box, a *different* byte-pinned number), a change that would move the approved interior look, so it
// is the director's to weigh. The two lanes diverge on purpose; do not "fix" them back into one.
// MONKEY (outdoor torch shadows: terrain, MIRRORED from wow_model.wgsl / static_gx.wgsl - keep in
-// sync): the <=3-nearest selection packed into ONE u32, three 10-bit indices (rank 0 in the low
-// bits) with `EXT_SEL_EMPTY` for an unfilled rank. The point table is capped at 256 entries, so ten
-// bits leaves two bits of headroom. It exists so the per-FRAGMENT shadowed term below can
-// re-evaluate the VERTEX stage's choice rather than making its own - re-ranking per fragment would
-// draw a hard line wherever the ranking flips, and on terrain that line would run straight through
-// the middle of an MCNK cell, which is exactly the chunk-shaped popping the 0285 anchor removed.
-const EXT_SEL_EMPTY: u32 = 1023u;
+// sync): the <=`EXT_SEL_K`-nearest selection packed into TWO u32s, eight 8-bit indices (rank 0 in
+// the low byte of `.x`) with `EXT_SEL_EMPTY` for an unfilled rank. It exists so the per-FRAGMENT
+// shadowed term below can re-evaluate the VERTEX stage's choice rather than making its own -
+// re-ranking per fragment would draw a hard line wherever the ranking flips, and on terrain that
+// line would run straight through the middle of an MCNK cell, which is exactly the chunk-shaped
+// popping the 0285 anchor removed.
+//
+// MONKEY (ext light k8) - **WHY K WENT 3 -> 8.** Three is the reference FFP's own commit limit (GL
+// slots 1-3; wow-re `wmo-surface-dynamic-light` sections 4/6) and it was the right number for the
+// reference's SPARSE, hand-authored light set: with two or three authored lights in a village the
+// nearest three IS all of them, and no two draw units can disagree. benilla does not have that set
+// - `fire_light.rs` SYNTHESISES a point light for every torch, lantern, brazier and campfire
+// GameObject/doodad in range, so a lamp-lit set piece now puts 15+ exterior fixtures inside ONE
+// chunk's candidacy box (measured at the Darkmoon Faire: 12 `Free Standing Torch 01` + 3
+// `General Lantern 01` within reach of the player's chunk, plus a stall lamp and the fireworks'
+// spell lights). Once the candidates outnumber the slots, adjacent draw units keep DIFFERENT
+// threes; and because the boundary between draw units is a straight line - an MCNK cell edge, or
+// the jump from a cell-anchored chunk to the origin-anchored bench standing on it - the
+// disagreement reads as a HARD STRAIGHT EDGE across a torch's pool of light (the director's
+// "scars"), and as a bench lit by a firework over ground that is not. Eight slots cover every
+// fixture that can meaningfully reach a unit in these set pieces, so neighbouring units agree and a
+// pool ends where the FALLOFF ends instead of where the cell does.
+//
+// 8 bits per rank caps the LIVE table at 255 real entries, `EXT_SEL_EMPTY` = 255 being the
+// sentinel; `global_light::MAX_LIVE_POINT_LIGHTS` enforces that CPU-side. The buffer still carries
+// 256 slots, so `LightStd430` keeps its 8528 B and no mirror struct moves.
+//
+// RAISING K: `ext_sel_get` and the pack tail index the selection vector dynamically
+// (`sel[s >> 2u]`) and the rank arrays are zero-constructed and filled, so K is not spelled out
+// anywhere but here. K=16 = swap `vec2` for `vec4` at its five type sites plus the
+// interstage field, set this to `16u`, and give `EXT_SEL_NONE` its two extra words. Worth knowing
+// because the Darkmoon Faire measurement is MARGINAL at 8: one 33.33 yd chunk there holds exactly
+// eight fixtures strictly inside its own cell, so a ninth standing 2.5 yd outside the edge cannot
+// win a slot under any ranking, and that chunk still disagrees with its neighbour about it. Eight
+// takes the worst measured seam jump there from 0.64 to 0.36 of a falloff unit (and the everyday
+// Goldshire/Stormwind case to zero); twelve would take it to 0.11, against a hard floor of ~0.14
+// set by the candidacy box itself, which is a separate change.
+const EXT_SEL_K: u32 = 8u;
+const EXT_SEL_EMPTY: u32 = 255u;
-// The RANKING half of the old `point_light_sum`, split out verbatim: the same interior-lane skip,
-// the same Chebyshev candidacy box, the same tie order, so the set a chunk selects is bit-for-bit
-// what it was and the day render cannot move.
-fn point_light_pick(anchor: vec3) -> u32 {
+// MONKEY (ext light k8): how many of the `EXT_SEL_K` ranks pay for a CUBE-MAP OCCLUSION lookup in
+// the night lane. The ranking is by distance, so ranks 0..2 are the three fixtures whose term
+// dominates this fragment; ranks 3..7 are the long tail that fixes the SELECTION (the scars) and
+// contribute a soft, low-amplitude wash where a hard-edged shadow would not be legible anyway.
+// Holding the shadowed count at the OLD K keeps the per-fragment cost of the night lane exactly
+// what it was - three table scans and their taps - while the selection itself gets eight deep.
+const EXT_SEL_SHADOWED: u32 = 3u;
+
+// Unpack rank `s` (0..`EXT_SEL_K`-1) from the two-word selection. MIRRORED - keep in sync.
+fn ext_sel_get(sel: vec2, s: u32) -> u32 {
+ return (sel[s >> 2u] >> (8u * (s & 3u))) & 255u;
+}
+
+// The RANKING half of the old `point_light_sum`: the same interior-lane skip, the same Chebyshev
+// candidacy box, the same tie order (strictly-less inserts, so an equal distance leaves the earlier
+// table index at the better rank).
+//
+// MONKEY (ext light k8): `box` is the draw unit's HORIZONTAL half-extent in yards, and it changes
+// what "nearest" MEANS - ranking is now by the distance from the light to the unit's AABB, not to
+// the unit's anchor POINT. Terrain's unit is the 33.33 yd MCNK cell, so a torch standing 2 yd
+// outside a cell's edge is 2 yd from the nearest ground that cell draws, yet ~18 yd from its
+// CENTRE, which is how it used to lose its slot to three torches clustered near the middle while
+// lighting nothing of the grass right under it. Clamping the light into the cell box
+// (`max(|d| - box, 0)` per horizontal axis) ranks it the way the ground actually sees it, and - the
+// point of the exercise - makes two ADJACENT cells rank a light on their shared edge almost
+// identically, so their sets agree there. The VERTICAL stays unbounded, exactly as the reference's
+// hash sweep is and as `mcnk_cell_anchor` (which keeps the vertex's own y) already implies.
+// `box = 0` reproduces the old point-anchored ranking BIT-FOR-BIT - that is what the own-origin
+// units (props, entities) pass.
+//
+// CANDIDACY deliberately stays on the anchor POINT: it is the byte-verified gather box, and
+// widening it is a different question from how the survivors are ordered.
+fn point_light_pick(anchor: vec3, box: f32) -> vec2 {
let count = u32(wow_light.point_count.x);
- var sel = array(0u, 0u, 0u);
- var sd = array(1e30, 1e30, 1e30);
+ var sel = array();
+ var sd = array();
+ for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
+ sd[s] = 1e30;
+ }
for (var i = 0u; i < count; i = i + 1u) {
// MONKEY (light lanes): skip INTERIOR fixtures. The colour row's `.w` is `0` on an exterior
// source and the fixture's reach in yards (always ≥ 1) on one that claims a room, so
// `> 0.5` is the lane test. Terrain is exterior by definition: an inn's candles reaching
// the grass at the base of its wall — a warm pool on the lawn at night — is the whole bug,
// and it is here rather than in the pack because the fixtures MUST stay packed for the
- // interior lane to light the room they belong to. Skipped BEFORE the ≤3 ranking, so an
- // interior fixture cannot even occupy a slot an outdoor fire should have had.
+ // interior lane to light the room they belong to. Skipped BEFORE the ≤`EXT_SEL_K`
+ // ranking, so an interior fixture cannot even occupy a slot an outdoor fire should
+ // have had.
if (wow_light.points[2u * i + 1u].w > 0.5) {
continue;
}
let pos_range = wow_light.points[2u * i];
let dv = pos_range.xyz - anchor;
- let d2 = dot(dv, dv);
// The hash sweep is horizontal (the grid keys on WoW x/y = Bevy z/x) and has no vertical
- // bound; ranking below is the full 3-D distance, as `0x71bf90` does.
+ // bound; the ranking below is the full 3-D distance, as `0x71bf90` does.
if (max(abs(dv.x), abs(dv.z)) > TERRAIN_REACH) {
continue;
}
- if (d2 < sd[0]) {
- sd[2] = sd[1]; sel[2] = sel[1];
- sd[1] = sd[0]; sel[1] = sel[0];
- sd[0] = d2; sel[0] = i;
- } else if (d2 < sd[1]) {
- sd[2] = sd[1]; sel[2] = sel[1];
- sd[1] = d2; sel[1] = i;
- } else if (d2 < sd[2]) {
- sd[2] = d2; sel[2] = i;
+ // MONKEY (ext light k8): distance to the draw unit's BOX (see the header note). With
+ // `box = 0` both `max`es are identities and this is exactly the old `dot(dv, dv)`.
+ let e = max(abs(dv.xz) - vec2(box), vec2(0.0));
+ let d2 = dot(e, e) + dv.y * dv.y;
+ // MONKEY (ext light k8): an `EXT_SEL_K`-deep insertion in place of the hand-unrolled 3-deep
+ // cascade. Both loops are bounded by a module const, so the compiler unrolls them; the
+ // comparison is strictly-less, which keeps the old tie order (first-found wins).
+ //
+ // The guard on the WORST kept rank first: `sd` is sorted, so a candidate that cannot beat
+ // `sd[K-1]` cannot beat anything, and the scan below would walk all eight slots only to
+ // decide that. It makes the REJECT path - which is what nearly every table entry takes once
+ // the set is full - ONE comparison, i.e. cheaper than the three the old cascade spent, so
+ // widening K did not make the common case more expensive. Exactly equivalent to letting the
+ // scan run: `d2 >= sd[K-1]` is precisely the condition under which it returns `r = K`.
+ if (d2 >= sd[EXT_SEL_K - 1u]) {
+ continue;
+ }
+ var r = EXT_SEL_K;
+ for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
+ if (d2 < sd[s]) {
+ r = s;
+ break;
+ }
+ }
+ if (r < EXT_SEL_K) {
+ for (var s = EXT_SEL_K - 1u; s > r; s = s - 1u) {
+ sd[s] = sd[s - 1u];
+ sel[s] = sel[s - 1u];
+ }
+ sd[r] = d2;
+ sel[r] = i;
}
}
- return select(EXT_SEL_EMPTY, sel[0], sd[0] <= 9.9e29)
- | (select(EXT_SEL_EMPTY, sel[1], sd[1] <= 9.9e29) << 10u)
- | (select(EXT_SEL_EMPTY, sel[2], sd[2] <= 9.9e29) << 20u);
+ // Pack low-byte-first, `EXT_SEL_EMPTY` for a rank nothing reached. No real index can collide
+ // with the sentinel: the live table caps at 255 (`MAX_LIVE_POINT_LIGHTS`).
+ var packed = vec2(0u, 0u);
+ for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
+ let idx = select(EXT_SEL_EMPTY, sel[s], sd[s] <= 9.9e29);
+ packed[s >> 2u] |= idx << (8u * (s & 3u));
+ }
+ return packed;
}
// The EVALUATION half - the byte-verified falloff `1/(0.7d + 0.03d^2)` x `max(N.L, 0)` x the
// committed colour, in rank order, stopping at the first empty rank exactly as the old
// `sd[s] > 9.9e29` break did (an unfilled rank packs as `EXT_SEL_EMPTY`, and no real index can
-// collide with it: the table caps at 256).
-fn point_light_eval(sel: u32, P: vec3, N: vec3) -> vec3 {
+// collide with it: the live table caps at 255). MONKEY (ext light k8): up to `EXT_SEL_K` terms now,
+// still Gouraud (per vertex) and still linear in the falloff, so nothing about the day lane's FORM
+// changed - a chunk simply stops dropping the fixtures its neighbour kept.
+fn point_light_eval(sel: vec2, P: vec3, N: vec3) -> vec3 {
var sum = vec3(0.0);
- for (var s = 0u; s < 3u; s = s + 1u) {
- let idx = (sel >> (10u * s)) & 1023u;
+ for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
+ let idx = ext_sel_get(sel, s);
if (idx == EXT_SEL_EMPTY) {
break;
}
@@ -358,21 +474,26 @@ fn point_light_eval(sel: u32, P: vec3, N: vec3) -> vec3 {
return sum;
}
-// MONKEY (outdoor torch shadows: terrain): the SHADOWED evaluation - the SAME three entries the
+// MONKEY (outdoor torch shadows: terrain): the SHADOWED evaluation - the SAME entries the
// vertex picked, each multiplied by its OWN fixture's cube-map occlusion, so a crate between the
// campfire and this patch of grass darkens the campfire's term while the brazier across the road is
// untouched. This is what step (a) already did for WMO exteriors and models; without it a
// character's shadow stopped dead at the grass.
//
-// Bounded at THREE iterations (the FFP commit limit), each costing a <=16-slot table scan and four
-// comparison taps, and reached only under `torch_ext_on()` - so it is night-only and dead with
-// `exteriorShadows 0`. `TORCH_SKIP_EPS` drops the scan entirely wherever the unshadowed term is
-// already invisible, which on terrain is most of the frame (ground facing away from the fire, or
-// far enough out that the falloff has taken the term below a code).
-fn point_light_eval_shadowed(sel: u32, P: vec3, N: vec3) -> vec3 {
+// MONKEY (ext light k8): the SUM runs to `EXT_SEL_K`, but only the first `EXT_SEL_SHADOWED` ranks
+// pay for an occlusion lookup - each of those costs a <=16-slot table scan and four comparison taps,
+// so the per-fragment cost of this lane is pinned at exactly what it was before the widening while
+// the selection itself got eight deep. Ranks 3..7 are the distance-ordered tail: they are what
+// makes a torch's pool agree across a cell edge, and they arrive unshadowed, which at their
+// amplitude is not a look the eye can separate from a shadowed one. Reached only under
+// `torch_ext_on()` - so it is night-only and dead with `exteriorShadows 0`. `TORCH_SKIP_EPS` drops
+// the scan entirely wherever the unshadowed term is already invisible, which on terrain is most of
+// the frame (ground facing away from the fire, or far enough out that the falloff has taken the
+// term below a code).
+fn point_light_eval_shadowed(sel: vec2, P: vec3, N: vec3) -> vec3 {
var sum = vec3(0.0);
- for (var s = 0u; s < 3u; s = s + 1u) {
- let idx = (sel >> (10u * s)) & 1023u;
+ for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
+ let idx = ext_sel_get(sel, s);
if (idx == EXT_SEL_EMPTY) {
break;
}
@@ -385,7 +506,7 @@ fn point_light_eval_shadowed(sel: u32, P: vec3, N: vec3) -> vec3
// ground facing away from the fire.
let ext_w = atten * nl;
var occ = 1.0;
- if (ext_w > TORCH_SKIP_EPS) {
+ if (s < EXT_SEL_SHADOWED && ext_w > TORCH_SKIP_EPS) {
occ = torch_terrain_shadow(fixture, P, N);
}
sum += wow_light.points[2u * idx + 1u].rgb * (ext_w * occ);
@@ -418,6 +539,11 @@ fn mcnk_cell_anchor(P: vec3) -> vec3 {
return vec3((ix + 0.5) * cell - half, P.y, (iz + 0.5) * cell - half);
}
+// MONKEY (ext light k8): the MCNK cell's HORIZONTAL half-extent (yd) - the `box` a cell-anchored
+// draw unit ranks by. The same grid constant as above, halved: 533.33333/16/2 = 16.666666. An
+// own-origin unit passes 0 instead. MIRRORED in wow_model.wgsl / static_gx.wgsl - keep in sync.
+const MCNK_CELL_HALF: f32 = 533.33333 / 32.0;
+
@vertex
fn vertex(in: Vertex) -> TerrainVsOut {
var out: TerrainVsOut;
@@ -442,14 +568,15 @@ fn vertex(in: Vertex) -> TerrainVsOut {
out.specular = clamp(wow_light.light_spec.rgb * pow(ndoth, wow_light.light_spec.w), vec3(0.0), vec3(1.0));
// STEP 3 at the faithful locus — the WHOLE diffuse sum, clamped HERE per vertex (see the
- // struct note). Sun: `ambient + diffuse·max(N·L,0)` on the MCNR normal; points: the ≤3-nearest
- // committed lights of this vertex's MCNK chunk cell — the terrain draw unit (0285) — at the
+ // struct note). Sun: `ambient + diffuse·max(N·L,0)` on the MCNR normal; points: the
+ // ≤`EXT_SEL_K`-nearest committed lights of this vertex's MCNK chunk cell — the terrain draw
+ // unit (0285), ranked from the CELL BOX (`MCNK_CELL_HALF`, see `point_light_pick`) — at the
// byte-verified `1/(0.7d + 0.03d²)`, raw over-gamut colours in.
let ndotl = max(dot(n, l), 0.0);
// MONKEY (outdoor torch shadows: terrain): the selection is PUBLISHED so the fragment stage can
- // shadow these same three entries at night without re-ranking. One table walk still, not two -
+ // shadow this same selection at night without re-ranking. One table walk still, not two -
// the sum was always `eval(pick(..))`, it is just no longer inlined.
- let sel = point_light_pick(mcnk_cell_anchor(out.world_position.xyz));
+ let sel = point_light_pick(mcnk_cell_anchor(out.world_position.xyz), MCNK_CELL_HALF);
out.ext_sel = sel;
let points = point_light_eval(sel, out.world_position.xyz, n);
let sun_lighting = wow_light.light_diffuse.rgb * ndotl;
@@ -517,7 +644,7 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
specmask = mix(specmask, s3.a, a.b);
// STEP 3 (diffuse) arrives from the vertex stage, ALREADY summed (ambient + sun N·L + the
- // chunk's ≤3 committed point lights) and ALREADY clamped — Gouraud of the clamped vertex
+ // chunk's committed point lights) and ALREADY clamped — Gouraud of the clamped vertex
// colour, byte-faithful to GL T&L (see the `primary` struct note; the MCNR normal and the
// DayNight sun share a space per the Phase-0 validation). The MCSH ×(0.3·s + 0.7) factor below
// still scales the whole modulate, as the traced combine does.
@@ -549,7 +676,7 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
// MONKEY (outdoor torch shadows: terrain): the exterior point term, CAST-SHADOWED at night.
//
// `in.primary` is the Gouraud (per-vertex, per-vertex-CLAMPED) diffuse, and it stays the only
- // thing this lane reads by day. After dark the same three entries the vertex picked
+ // thing this lane reads by day. After dark the same entries the vertex picked
// (`in.ext_sel`) are re-evaluated PER FRAGMENT with each fixture's own cube-map occlusion folded
// in, re-summed onto the sun/ambient half (`in.base_lit`) and re-clamped here - the clamp has to
// move to the fragment because the shadow is a per-fragment quantity, and clamping the sum is
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 3f0bb7ab..41277ad2 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -150,9 +150,12 @@ struct WowLight {
sh_c13_r: vec4,
sh_c13_g: vec4,
sh_c13_b: vec4,
- // xyz = the true c16 quad band (x²−y², per channel), live with the block above. w = FREE — it
- // carried the 0273 point gain, then the 0750/0751 sun dial, then 0799's response A/B, and every
- // one of those is retired. Nothing writes it and nothing reads it; claim it if you need a lane.
+ // xyz = the true c16 quad band (x²−y², per channel), live with the block above. `.w` is the
+ // SHARED world-shadow / bake-floor lane, and it is no longer free: its INTEGER part is the
+ // `worldShadows` flag (`terrain.wgsl`'s MCSH gate reads `> 0.5`) and MONKEY (bake floor) its
+ // FRACTION is `interiorBakeFloor × interiorGain × BAKE_LANE_SCALE`, which the interior entity
+ // lane below decodes. (It had carried the 0273 point gain, the 0750/0751 sun dial and 0799's
+ // response A/B before those were retired — hence the long-standing "free" note here.)
sh_c16: vec4,
_water: array, 4>, // rows 13-16: the liquid swatches — unread by models.
// x = SIDN night fraction (1 overnight, 0 by day — scales m.sidn.rgb).
@@ -238,10 +241,27 @@ const TORCH_BIAS: f32 = 0.001;
// interiorDebug 2, flickering shadows in the real render). Pushing the sample point out of the skin
// makes self-occlusion impossible while a pillar or another character still casts onto it.
const TORCH_NORMAL_OFFSET: f32 = 0.15;
-// MONKEY (torch caster selection, MIRRORED from static_gx.wgsl): the live PCF tap-radius scale,
+// MONKEY (torch caster selection, MIRRORED from static_gx.wgsl): the live PCF tap-radius scale
+// (count.y LOW half) and MONKEY (shadow floor)'s strength (HIGH half),
// unpacked from the table's `count.y` (stored x100 — the row is `vec4`).
fn torch_soft() -> f32 {
- return max(f32(torch_table.count.y) * 0.01, 0.05);
+ return max(f32(torch_table.count.y & 0xffffu) * 0.01, 0.05);
+}
+// MONKEY (shadow floor): the live SHADOW STRENGTH (`torchShadowStrength`, 0..1, default 0.7),
+// unpacked from `count.y`'s HIGH half (`(strength x 100) << 16 | soft x 100` - see
+// `TorchTableUniform::pack`; the low half is `torch_soft` above and `count.w`'s flags are
+// untouched). It is applied as `mix(1, s, w * strength)` where the cross-fade weight already
+// multiplies the shadow factor, which is algebraically the same thing as flooring the factor
+// itself (`1 - w*strength*(1-s)` either way) for one extra multiply and no extra tap.
+//
+// WHY a floor at all: a torch map is the ONLY occlusion in the direct term, so a blocked fragment
+// used to drop that term to exactly zero - a pitch-black, razor-edged scar of tent canvas across
+// Darkmoon's grass, table legs printed on the Darkshire inn floor. Nothing in this renderer
+// bounces, so the 30 % that survives at the default IS the bounce: the fill/ambient arms are
+// untouched (they never saw this factor), only the DIRECT arm is floored. 1 restores the shipped
+// pitch-black look exactly, 0 disables torch shadows without disturbing the lane behind them.
+fn torch_strength() -> f32 {
+ return clamp(f32(torch_table.count.y >> 16u) * 0.01, 0.0, 1.0);
}
// MONKEY (outdoor torch shadows, MIRRORED from static_gx.wgsl — keep in sync): the EXTERIOR lane's
// world-distance fade radius (yd) and the CPU's one-bit lane gate in `count.w`. See the static_gx
@@ -321,7 +341,10 @@ fn torch_entity_shadow_at(light_pos: vec3, P: vec3, N: vec3, fade
// FADE WEIGHT, so a promoted fixture's shadow ramps in over ~1/3 s and a demoted one
// ramps out. An entity and the floor under it MUST use the same weight or the NPC's
// shadow would pop while the floor's faded.
- return mix(1.0, s, torch_table.positions[i].w);
+ // MONKEY (shadow floor): and `torch_strength()` is the DIRECT-term floor folded
+ // into that same weight (see the function). `w * strength` rather than a second `mix`
+ // because the two are the same expression.
+ return mix(1.0, s, torch_table.positions[i].w * torch_strength());
}
}
return 1.0;
@@ -399,12 +422,14 @@ struct WowVsOut {
#ifdef WOW_MERGED_SLOT
@location(10) @interpolate(flat) merged_slot: u32,
#endif
- // MONKEY (outdoor torch shadows): WHICH <=3 exterior table entries `point_lit` was summed from,
- // packed 10 bits each (see `EXT_SEL_NONE`). FLAT, because it is a choice and not a quantity -
- // interpolating three packed indices would produce a fourth, meaningless one. `EXT_SEL_NONE` on
- // every lane that takes no exterior point term (interior WMO surfaces, interior props), which
- // makes the night lane a no-op there by construction.
- @location(11) @interpolate(flat) ext_sel: u32,
+ // MONKEY (outdoor torch shadows; ext light k8): WHICH <=`EXT_SEL_K` exterior table entries
+ // `point_lit` was summed from, packed 8 bits each across TWO u32s (see `EXT_SEL_NONE` /
+ // `ext_sel_get`). FLAT, because it is a choice and not a quantity - interpolating packed
+ // indices would produce a different, meaningless one. `EXT_SEL_NONE` on every lane that takes
+ // no exterior point term (interior WMO surfaces, interior props), which makes the night lane a
+ // no-op there by construction. Widened from ONE u32 of three 10-bit ranks to TWO u32s of eight
+ // 8-bit ranks - one extra interstage component - see `EXT_SEL_K`.
+ @location(11) @interpolate(flat) ext_sel: vec2,
}
// The dynamic point-light term at a world-space point (decisions 0016/0273/0278, selection 0285) —
@@ -415,7 +440,8 @@ struct WowVsOut {
// table instead lit every candelabra pole in the abbey from a dozen sideways fixtures the real
// client never commits for it — the director's "stands must not light up with the point gain".
//
-// So: pass 1 picks the ≤3 nearest table entries to `anchor` (the unit position — a light's packed
+// So: pass 1 picks the ≤`EXT_SEL_K` nearest table entries to `anchor` (the unit position, or its
+// AABB where it has one — a light's packed
// range bounds candidacy); pass 2 evaluates ONLY those at the vertex — the byte-verified falloff
// `1/(0.7·d + 0.03·d²)`, diffuse-only (committed ambient/specular are zero), on the SUBMITTED normal
// (the FFP never enables GL_LIGHT_MODEL_TWO_SIDE — no per-face flip). A selected light reaches every
@@ -444,57 +470,154 @@ fn wow_normalize(v: vec3) -> vec3 {
return select(vec3(0.0), normalize(v), l2 > 1e-12);
}
-// MONKEY (outdoor torch shadows, MIRRORED from static_gx.wgsl — keep in sync): the ≤3-nearest
-// EXTERIOR selection packed into ONE u32, three 10-bit indices (rank 0 in the low bits) with
-// `EXT_SEL_EMPTY` for an unfilled rank. The point table is capped at 256 entries, so 10 bits leaves
-// two bits of headroom. It exists so the per-FRAGMENT shadowed term below can re-evaluate the
-// VERTEX stage's choice rather than making its own — re-ranking per fragment would draw a hard
-// line wherever the ranking flips, which is precisely what the Gouraud term never does.
-const EXT_SEL_EMPTY: u32 = 1023u;
-const EXT_SEL_NONE: u32 = 1073741823u; // three empty ranks: 1023 | 1023<<10 | 1023<<20
+// MONKEY (outdoor torch shadows, MIRRORED from static_gx.wgsl — keep in sync): the
+// ≤`EXT_SEL_K`-nearest EXTERIOR selection packed into TWO u32s, eight 8-bit indices (rank 0 in the
+// low byte of `.x`) with `EXT_SEL_EMPTY` for an unfilled rank. It exists so the per-FRAGMENT
+// shadowed term below can re-evaluate the VERTEX stage's choice rather than making its own —
+// re-ranking per fragment would draw a hard line wherever the ranking flips, which is precisely
+// what the Gouraud term never does.
+//
+// MONKEY (ext light k8) - **WHY K WENT 3 -> 8.** Three is the reference FFP's own commit limit (GL
+// slots 1-3; wow-re `wmo-surface-dynamic-light` sections 4/6) and it was the right number for the
+// reference's SPARSE, hand-authored light set: with two or three authored lights in a village the
+// nearest three IS all of them, and no two draw units can disagree. benilla does not have that set
+// - `fire_light.rs` SYNTHESISES a point light for every torch, lantern, brazier and campfire
+// GameObject/doodad in range, so a lamp-lit set piece now puts 15+ exterior fixtures inside ONE
+// chunk's candidacy box (measured at the Darkmoon Faire: 12 `Free Standing Torch 01` + 3
+// `General Lantern 01` within reach of the player's chunk, plus a stall lamp and the fireworks'
+// spell lights). Once the candidates outnumber the slots, adjacent draw units keep DIFFERENT
+// threes; and because the boundary between draw units is a straight line - an MCNK cell edge, or
+// the jump from a cell-anchored chunk to the origin-anchored bench standing on it - the
+// disagreement reads as a HARD STRAIGHT EDGE across a torch's pool of light (the director's
+// "scars"), and as a bench lit by a firework over ground that is not. Eight slots cover every
+// fixture that can meaningfully reach a unit in these set pieces, so neighbouring units agree and a
+// pool ends where the FALLOFF ends instead of where the cell does.
+//
+// 8 bits per rank caps the LIVE table at 255 real entries, `EXT_SEL_EMPTY` = 255 being the
+// sentinel; `global_light::MAX_LIVE_POINT_LIGHTS` enforces that CPU-side. The buffer still carries
+// 256 slots, so `LightStd430` keeps its 8528 B and no mirror struct moves.
+//
+// RAISING K: `ext_sel_get` and the pack tail index the selection vector dynamically
+// (`sel[s >> 2u]`) and the rank arrays are zero-constructed and filled, so K is not spelled out
+// anywhere but here. K=16 = swap `vec2` for `vec4` at its five type sites plus the
+// interstage field, set this to `16u`, and give `EXT_SEL_NONE` its two extra words. Worth knowing
+// because the Darkmoon Faire measurement is MARGINAL at 8: one 33.33 yd chunk there holds exactly
+// eight fixtures strictly inside its own cell, so a ninth standing 2.5 yd outside the edge cannot
+// win a slot under any ranking, and that chunk still disagrees with its neighbour about it. Eight
+// takes the worst measured seam jump there from 0.64 to 0.36 of a falloff unit (and the everyday
+// Goldshire/Stormwind case to zero); twelve would take it to 0.11, against a hard floor of ~0.14
+// set by the candidacy box itself, which is a separate change.
+const EXT_SEL_K: u32 = 8u;
+const EXT_SEL_EMPTY: u32 = 255u;
+// Eight empty ranks. MONKEY (ext light k8): two words now, so it can no longer be a scalar literal.
+const EXT_SEL_NONE: vec2 = vec2(4294967295u, 4294967295u);
-// The ranking half of `point_light_sum`, split out verbatim (same tests, same order, same ties).
-fn point_light_pick(anchor: vec3) -> u32 {
+// MONKEY (ext light k8): how many of the `EXT_SEL_K` ranks pay for a CUBE-MAP OCCLUSION lookup in
+// the night lane. The ranking is by distance, so ranks 0..2 are the three fixtures whose term
+// dominates this fragment; ranks 3..7 are the long tail that fixes the SELECTION (the scars) and
+// contribute a soft, low-amplitude wash where a hard-edged shadow would not be legible anyway.
+// Holding the shadowed count at the OLD K pins the per-fragment cost of the night lane at exactly
+// what it was - three table scans and their taps - while the selection itself gets eight deep.
+const EXT_SEL_SHADOWED: u32 = 3u;
+
+// Unpack rank `s` (0..`EXT_SEL_K`-1) from the two-word selection. MIRRORED - keep in sync.
+fn ext_sel_get(sel: vec2, s: u32) -> u32 {
+ return (sel[s >> 2u] >> (8u * (s & 3u))) & 255u;
+}
+
+// The ranking half of `point_light_sum` (same tests, same order, same ties — strictly-less
+// inserts, so an equal distance leaves the earlier table index at the better rank).
+//
+// MONKEY (ext light k8): `box` is the draw unit's HORIZONTAL half-extent in yards, and it changes
+// what "nearest" MEANS - ranking is by the distance from the light to the unit's AABB, not to the
+// unit's anchor POINT. A cell-anchored unit (terrain's 33.33 yd MCNK chunk, clutter, an
+// exterior-class WMO street) passes `MCNK_CELL_HALF`: a torch standing 2 yd outside a cell's edge
+// is 2 yd from the nearest surface that cell draws, yet ~18 yd from its CENTRE, which is how it
+// used to lose its slot to three torches clustered near the middle while lighting nothing of the
+// ground right under it. Clamping the light into the box (`max(|d| - box, 0)` per horizontal axis)
+// ranks it the way the surface actually sees it, and - the point of the exercise - makes two
+// ADJACENT cells rank a light on their shared edge almost identically, so their sets agree there.
+// The VERTICAL stays unbounded, exactly as the reference's hash sweep is. `box = 0` reproduces the
+// old point-anchored ranking BIT-FOR-BIT, and that is what the own-origin units (props, entities)
+// pass, so nothing on those lanes moves.
+//
+// CANDIDACY deliberately stays on the anchor POINT (`dc2` below): it is the byte-verified gather
+// test, and widening it is a different question from how the survivors are ordered.
+fn point_light_pick(anchor: vec3, box: f32) -> vec2 {
let count = u32(wow_light.point_count.x);
- var sel = array(0u, 0u, 0u);
- var sd = array(1e30, 1e30, 1e30);
+ var sel = array();
+ var sd = array();
+ for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
+ sd[s] = 1e30;
+ }
for (var i = 0u; i < count; i = i + 1u) {
// MONKEY (light lanes): skip INTERIOR fixtures (colour row `.w > 0.5`) — they light the
// room that claims them and nothing else. Before the split, a proximity-admitted inn
// fixture reached every exterior receiver near the building through its own walls. Skipped
- // BEFORE the ≤3 ranking, so it cannot take a slot an outdoor fire should have had.
+ // BEFORE the ≤`EXT_SEL_K` ranking, so it cannot take a slot an outdoor fire should have
+ // had.
if (wow_light.points[2u * i + 1u].w > 0.5) {
continue;
}
let pos_range = wow_light.points[2u * i];
let dv = pos_range.xyz - anchor;
- let d2 = dot(dv, dv);
- if (d2 > pos_range.w * pos_range.w) {
+ let dc2 = dot(dv, dv);
+ if (dc2 > pos_range.w * pos_range.w) {
continue;
}
- if (d2 < sd[0]) {
- sd[2] = sd[1]; sel[2] = sel[1];
- sd[1] = sd[0]; sel[1] = sel[0];
- sd[0] = d2; sel[0] = i;
- } else if (d2 < sd[1]) {
- sd[2] = sd[1]; sel[2] = sel[1];
- sd[1] = d2; sel[1] = i;
- } else if (d2 < sd[2]) {
- sd[2] = d2; sel[2] = i;
+ // MONKEY (ext light k8): rank by the distance to the draw unit's BOX (see the header
+ // note). With `box = 0` both `max`es are identities and this is exactly the old `dc2`.
+ let e = max(abs(dv.xz) - vec2(box), vec2(0.0));
+ let d2 = dot(e, e) + dv.y * dv.y;
+ // MONKEY (ext light k8): an `EXT_SEL_K`-deep insertion in place of the hand-unrolled
+ // 3-deep cascade. Both loops are bounded by a module const, so the compiler unrolls them;
+ // the comparison is strictly-less, which keeps the old tie order (first-found wins).
+ //
+ // The guard on the WORST kept rank first: `sd` is sorted, so a candidate that cannot beat
+ // `sd[K-1]` cannot beat anything, and the scan below would walk all eight slots only to
+ // decide that. It makes the REJECT path - which is what nearly every table entry takes once
+ // the set is full - ONE comparison, i.e. cheaper than the three the old cascade spent, so
+ // widening K did not make the common case more expensive. Exactly equivalent to letting the
+ // scan run: `d2 >= sd[K-1]` is precisely the condition under which it returns `r = K`.
+ if (d2 >= sd[EXT_SEL_K - 1u]) {
+ continue;
+ }
+ var r = EXT_SEL_K;
+ for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
+ if (d2 < sd[s]) {
+ r = s;
+ break;
+ }
+ }
+ if (r < EXT_SEL_K) {
+ for (var s = EXT_SEL_K - 1u; s > r; s = s - 1u) {
+ sd[s] = sd[s - 1u];
+ sel[s] = sel[s - 1u];
+ }
+ sd[r] = d2;
+ sel[r] = i;
}
}
- return select(EXT_SEL_EMPTY, sel[0], sd[0] <= 9.9e29)
- | (select(EXT_SEL_EMPTY, sel[1], sd[1] <= 9.9e29) << 10u)
- | (select(EXT_SEL_EMPTY, sel[2], sd[2] <= 9.9e29) << 20u);
+ // Pack low-byte-first, `EXT_SEL_EMPTY` for a rank nothing reached. No real index can collide
+ // with the sentinel: the live table caps at 255 (`MAX_LIVE_POINT_LIGHTS`).
+ var packed = vec2(0u, 0u);
+ for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
+ let idx = select(EXT_SEL_EMPTY, sel[s], sd[s] <= 9.9e29);
+ packed[s >> 2u] |= idx << (8u * (s & 3u));
+ }
+ return packed;
}
// The evaluation half — the byte-verified falloff `1/(0.7d + 0.03d²)` × `max(N·L, 0)` × the
// committed colour, in rank order, stopping at the first empty rank exactly as the old
// `sd[s] > 9.9e29` break did. Shared by both pickers (their sum loops were already identical).
-fn point_light_eval(sel: u32, P: vec3, N: vec3) -> vec3 {
+// MONKEY (ext light k8): up to `EXT_SEL_K` terms now, still Gouraud (per vertex) and still linear
+// in the falloff, so nothing about the day lane's FORM changed — a unit simply stops dropping the
+// fixtures its neighbour kept.
+fn point_light_eval(sel: vec2, P: vec3, N: vec3) -> vec3 {
var sum = vec3(0.0);
- for (var s = 0u; s < 3u; s = s + 1u) {
- let idx = (sel >> (10u * s)) & 1023u;
+ for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
+ let idx = ext_sel_get(sel, s);
if (idx == EXT_SEL_EMPTY) {
break;
}
@@ -507,15 +630,22 @@ fn point_light_eval(sel: u32, P: vec3, N: vec3) -> vec3 {
return sum;
}
-// MONKEY (outdoor torch shadows): the SHADOWED evaluation — the same three entries, each
-// multiplied by its OWN fixture's cube-map occlusion (normal-offset, like every entity sample on
-// this lane), so a fence between the player and campfire A darkens A's term while lamppost B's is
-// untouched. Bounded at three iterations, and reached only under `torch_ext_on()`, so nothing here
-// runs in daylight or with `exteriorShadows 0`.
-fn point_light_eval_shadowed(sel: u32, P: vec3, N: vec3) -> vec3 {
+// MONKEY (outdoor torch shadows): the SHADOWED evaluation — the same entries the vertex picked,
+// each multiplied by its OWN fixture's cube-map occlusion (normal-offset, like every entity sample
+// on this lane), so a fence between the player and campfire A darkens A's term while lamppost B's
+// is untouched. Reached only under `torch_ext_on()`, so nothing here runs in daylight or with
+// `exteriorShadows 0`.
+//
+// MONKEY (ext light k8): the SUM runs to `EXT_SEL_K`, but only the first `EXT_SEL_SHADOWED` ranks
+// pay for an occlusion lookup, so the per-fragment cost of this lane is pinned at exactly what it
+// was before the widening while the selection itself got eight deep. Ranks 3..7 are the
+// distance-ordered tail — the terms that make a torch's pool agree across a draw-unit boundary —
+// and they arrive unshadowed, which at their amplitude is not a look the eye can separate from a
+// shadowed one.
+fn point_light_eval_shadowed(sel: vec2, P: vec3, N: vec3) -> vec3 {
var sum = vec3(0.0);
- for (var s = 0u; s < 3u; s = s + 1u) {
- let idx = (sel >> (10u * s)) & 1023u;
+ for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
+ let idx = ext_sel_get(sel, s);
if (idx == EXT_SEL_EMPTY) {
break;
}
@@ -527,17 +657,17 @@ fn point_light_eval_shadowed(sel: u32, P: vec3, N: vec3) -> vec3
// MONKEY (torch lane perf, MIRRORED from static_gx.wgsl): the occlusion is a factor on a
// term that is already zero on any surface facing away from the fire.
let ext_w = atten * nl;
- var s = 1.0;
- if (ext_w > TORCH_SKIP_EPS) {
- s = torch_entity_exterior_shadow(fixture, P, N);
+ var occ = 1.0;
+ if (s < EXT_SEL_SHADOWED && ext_w > TORCH_SKIP_EPS) {
+ occ = torch_entity_exterior_shadow(fixture, P, N);
}
- sum += wow_light.points[2u * idx + 1u].rgb * (ext_w * s);
+ sum += wow_light.points[2u * idx + 1u].rgb * (ext_w * occ);
}
return sum;
}
fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 {
- return point_light_eval(point_light_pick(anchor), P, N);
+ return point_light_eval(point_light_pick(anchor, 0.0), P, N);
}
// MONKEY (dynamic interiors): the per-FRAGMENT room light — MIRRORED from `static_gx.wgsl`
@@ -549,7 +679,7 @@ fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 {
// widest, flattest pool the lane can make, the nearest thing left to the pre-window flat lane (no
// longer the byte-exact restore it was, because the window's SHAPE moved with it).
// Every in-range light of the room-gated table, no
-// nearest-3 selection: DIRECT (falloff × wrapped Lambert, so a floor-level hearth still lights
+// nearest-K selection at all: DIRECT (falloff × wrapped Lambert, so a floor-level hearth still lights
// the floor) + FILL (normal-free, half-desaturated bounce) + the base ambient. The knobs are the
// live cvars packed into `point_count.yzw` (`.y` ambient, `.z` fill gain, `.w` exposure — the
// caller's multiplier before the rolloff). Here it lights INDOOR units and GameObjects (gated on the
@@ -557,6 +687,33 @@ fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 {
// day/night CGLight, and takes the SAME per-fixture torch shadows (Phase 3A: the shared depth
// array through this material's own group-2 bindings) as the surfaces.
const INTERIOR_WRAP: f32 = 0.5;
+
+// MONKEY (bake floor): the ENTITY half of `static_gx.wgsl`'s interior bake floor. Keep
+// `BAKE_LANE_SCALE` byte-identical with that file and with
+// `benilla_world::lighting::BAKE_LANE_SCALE`: the value rides the FRACTION of `sh_c16.w` (whose
+// integer part is the world-shadow flag), with `interiorGain` already folded in by the packer.
+const BAKE_LANE_SCALE: f32 = 0.49;
+// The surfaces get `vc.rgb * k` — each batch's OWN authored MOCV. An entity has no MOCV: it is an
+// M2 standing in the room, not a piece of the room's mesh, and nothing on this lane knows which
+// WMO group it is in (the entity call passes `room_inst`/`room_group` as 0 — the ungated stub —
+// and the claim binding is `static_gx`-only, group 3). Computing a per-group mean MOCV luminance
+// at spawn and threading it onto the entity's record would be the better answer, but there is no
+// entity->group record on this path to put it in; inventing one is a spawn-side plumbing change of
+// its own. So the entity lane uses a FLAT MEAN — deliberately, and this is the note that says so.
+//
+// 0.45 is MEASURED, not guessed: over the Lion's Pride Inn's 70 INT+TRANS batches
+// (`benilla-extract wmolights`), the per-batch mean MOCV luminances run 0.183 (the cellar `g10`)
+// to 0.692 (the hearth floor under L9), median 0.433, quartiles 0.310/0.522 — and the
+// TRIANGLE-WEIGHTED mean, i.e. what an entity is actually likely to be standing among, is
+// **0.451**. So an entity is lifted by about what the floor under it is, which is the whole
+// requirement: a chair in the inn’s fixture-starved vestibule must not be a black cut-out against
+// the wall behind it. At the owner’s cvars and `interiorBakeFloor 0.12` that chair renders
+// 0.083 x its texture where the wall beside it renders 0.108 — near enough that it reads as part
+// of the room, which is all a flat mean can promise.
+//
+// ZERO on the exterior lane by construction — this term is only reachable inside the
+// `is_interior || matte_indoor` branch below, which is also gated on `wmo_fog_params.w > 0.5`.
+const INTERIOR_BAKE_ENTITY_MEAN: f32 = 0.45;
// MONKEY (soft falloff): the interior pool's PROFILE — **keep this whole block byte-identical
// between wow_model.wgsl and static_gx.wgsl.**
//
@@ -722,6 +879,11 @@ fn mcnk_cell_anchor(P: vec3) -> vec3 {
return vec3((ix + 0.5) * cell - half, P.y, (iz + 0.5) * cell - half);
}
+// MONKEY (ext light k8): the MCNK cell's HORIZONTAL half-extent (yd) - the `box` a cell-anchored
+// draw unit ranks by. The same grid constant as above, halved: 533.33333/16/2 = 16.666666. An
+// own-origin unit passes 0 instead. MIRRORED in terrain.wgsl - keep in sync.
+const MCNK_CELL_HALF: f32 = 533.33333 / 32.0;
+
// MONKEY (wmo exterior points): the EXTERIOR-lane point term for a WMO's OUTDOOR-class surfaces —
// MIRRORED VERBATIM from `static_gx.wgsl` (`wmo_exterior_point_sum`; that file owns the full
// rationale — keep the two bodies identical). This copy exists because the retained WMO collector
@@ -736,11 +898,14 @@ fn mcnk_cell_anchor(P: vec3) -> vec3 {
const WMO_EXT_REACH: f32 = 33.570166;
// MONKEY (outdoor torch shadows): the ranking half, split out for the same reason as
// `point_light_pick` — the fragment stage re-evaluates the VERTEX's choice, never its own.
-fn wmo_exterior_pick(P: vec3) -> u32 {
+fn wmo_exterior_pick(P: vec3) -> vec2 {
let anchor = mcnk_cell_anchor(P);
let count = u32(wow_light.point_count.x);
- var sel = array(0u, 0u, 0u);
- var sd = array(1e30, 1e30, 1e30);
+ var sel = array();
+ var sd = array();
+ for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
+ sd[s] = 1e30;
+ }
for (var i = 0u; i < count; i = i + 1u) {
// Exterior lane only: an interior fixture's `.w` is its reach (≥ 1), an exterior source's
// is 0. A building's own candles must not pool on the street outside its wall.
@@ -753,21 +918,48 @@ fn wmo_exterior_pick(P: vec3) -> u32 {
if (max(abs(dv.x), abs(dv.z)) > WMO_EXT_REACH) {
continue;
}
- let d2 = dot(dv, dv);
- if (d2 < sd[0]) {
- sd[2] = sd[1]; sel[2] = sel[1];
- sd[1] = sd[0]; sel[1] = sel[0];
- sd[0] = d2; sel[0] = i;
- } else if (d2 < sd[1]) {
- sd[2] = sd[1]; sel[2] = sel[1];
- sd[1] = d2; sel[1] = i;
- } else if (d2 < sd[2]) {
- sd[2] = d2; sel[2] = i;
+ // MONKEY (ext light k8): rank by the distance to the MCNK cell's BOX, not to its centre -
+ // see `point_light_pick`. This is the term that makes a street and the road it runs into
+ // agree about a torch standing on the seam between them.
+ let e = max(abs(dv.xz) - vec2(MCNK_CELL_HALF), vec2(0.0));
+ let d2 = dot(e, e) + dv.y * dv.y;
+ // MONKEY (ext light k8): an `EXT_SEL_K`-deep insertion in place of the hand-unrolled
+ // 3-deep cascade. Both loops are bounded by a module const, so the compiler unrolls them;
+ // the comparison is strictly-less, which keeps the old tie order (first-found wins).
+ //
+ // The guard on the WORST kept rank first: `sd` is sorted, so a candidate that cannot beat
+ // `sd[K-1]` cannot beat anything, and the scan below would walk all eight slots only to
+ // decide that. It makes the REJECT path - which is what nearly every table entry takes once
+ // the set is full - ONE comparison, i.e. cheaper than the three the old cascade spent, so
+ // widening K did not make the common case more expensive. Exactly equivalent to letting the
+ // scan run: `d2 >= sd[K-1]` is precisely the condition under which it returns `r = K`.
+ if (d2 >= sd[EXT_SEL_K - 1u]) {
+ continue;
+ }
+ var r = EXT_SEL_K;
+ for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
+ if (d2 < sd[s]) {
+ r = s;
+ break;
+ }
+ }
+ if (r < EXT_SEL_K) {
+ for (var s = EXT_SEL_K - 1u; s > r; s = s - 1u) {
+ sd[s] = sd[s - 1u];
+ sel[s] = sel[s - 1u];
+ }
+ sd[r] = d2;
+ sel[r] = i;
}
}
- return select(EXT_SEL_EMPTY, sel[0], sd[0] <= 9.9e29)
- | (select(EXT_SEL_EMPTY, sel[1], sd[1] <= 9.9e29) << 10u)
- | (select(EXT_SEL_EMPTY, sel[2], sd[2] <= 9.9e29) << 20u);
+ // Pack low-byte-first, `EXT_SEL_EMPTY` for a rank nothing reached. No real index can collide
+ // with the sentinel: the live table caps at 255 (`MAX_LIVE_POINT_LIGHTS`).
+ var packed = vec2(0u, 0u);
+ for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
+ let idx = select(EXT_SEL_EMPTY, sel[s], sd[s] <= 9.9e29);
+ packed[s >> 2u] |= idx << (8u * (s & 3u));
+ }
+ return packed;
}
fn wmo_exterior_point_sum(P: vec3, N: vec3) -> vec3 {
return point_light_eval(wmo_exterior_pick(P), P, N);
@@ -1031,7 +1223,7 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
// the earlier "point-lit abbey wall" was a mis-identified unit draw; they light per FRAGMENT
// instead, `interior_room_light`), and interior M2 props take none HERE (their group-MOLR
// point lobes are folded into the per-instance SH probe). Everything else —
- // exterior doodads, entities, clutter — keeps the FFP ≤3-nearest selection: clutter (merged in
+ // exterior doodads, entities, clutter — keeps the nearest-`EXT_SEL_K` selection: clutter (merged in
// world space) anchors at its MCNK chunk cell (the terrain draw unit it belongs to), every M2
// at its INSTANCE origin (wow-re wmo-surface-dynamic-light §6 — the receiving unit's own
// position, deliberately not the skinned per-vertex matrix).
@@ -1044,7 +1236,7 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
// the NPC beside them. Mirrors `static_gx.wgsl`'s branch (which draws the vast majority of
// these batches) — keep the two in step.
// MONKEY (outdoor torch shadows): the pick is PUBLISHED so the fragment stage can shadow
- // these same three entries at night without re-ranking. One table walk still, not two -
+ // this same selection at night without re-ranking. One table walk still, not two -
// the sum was always `eval(pick(..))`, it is just no longer inlined.
let sel = wmo_exterior_pick(out.world_position.xyz);
out.ext_sel = sel;
@@ -1054,10 +1246,15 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
out.ext_sel = EXT_SEL_NONE;
} else {
var anchor = mesh_world_from_local[3].xyz;
+ // MONKEY (ext light k8): CLUTTER is merged in world space and anchors at its MCNK cell, so
+ // it ranks by that cell's BOX like the terrain under it; a standalone M2 keeps its own
+ // origin, i.e. `box = 0` — the old point ranking, unchanged.
+ var box = 0.0;
if (m.clutter_fade.w > 0.5) {
anchor = mcnk_cell_anchor(out.world_position.xyz);
+ box = MCNK_CELL_HALF;
}
- let sel = point_light_pick(anchor);
+ let sel = point_light_pick(anchor, box);
out.ext_sel = sel;
out.point_lit = point_light_eval(sel, out.world_position.xyz, out.world_normal);
}
@@ -1519,8 +1716,8 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
}
let sidn_e = m.sidn.rgb * (wow_light.grade.x * sidn_w);
// WoW dynamic point lights (decisions 0016/0273/0278, selection 0285) — exterior doodads,
- // entities, clutter, and terrain receive their unit's committed lights: the ≤3 NEAREST to the
- // receiving unit's own position, never the whole scene (`point_light_sum`). INTERIOR WMO
+ // entities, clutter, and terrain receive their unit's committed lights: the ≤`EXT_SEL_K` NEAREST to the
+ // receiving unit's own position or AABB, never the whole scene (`point_light_sum`). INTERIOR WMO
// surfaces take ZERO (observed on every WMO surface batch in the abbey capture — an interior
// capture, hence the MONKEY split above) and interior props fold their group-MOLR lobes into
// the SH probe instead — both zeroed in the VERTEX stage; an EXTERIOR-class WMO group takes
@@ -1530,7 +1727,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// smoothing is the authored look. Diffuse-only (committed ambient/specular are zero).
// MONKEY (outdoor torch shadows): ...and after dark that Gouraud term is CAST-SHADOWED.
//
- // The same three entries the vertex stage picked (`in.ext_sel`) are re-evaluated PER FRAGMENT
+ // The same entries the vertex stage picked (`in.ext_sel`) are re-evaluated PER FRAGMENT
// with each fixture's own cube-map occlusion folded in, and the two results are blended by
// `night_w = 1 - sun_shadow_strength`. Written as a blend rather than a swap for two reasons:
// - `fog_params.z` is EXACTLY 1.0 whenever the sun is above the daylight threshold
@@ -1657,8 +1854,15 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// ≤1), so the un-hovered indoor look is bit-identical to before.
// Placed BEFORE the debug branch on purpose: modes 1/2/3 all discard `room_rgb` and return
// their own diagnostic colour, so `interiorDebug` is untouched by this.
+ // MONKEY (bake floor): the room's own bake floor, so an entity in a fixture-starved room is
+ // not a black silhouette against a dimly lit wall. A flat room mean rather than the
+ // surfaces' per-batch `vc.rgb` — see `INTERIOR_BAKE_ENTITY_MEAN` for why the per-group
+ // number is not reachable on this lane. Inside the same rolloff as the surfaces, so a
+ // torch-lit NPC is moved by single-digit percent and an unlit one is carried.
+ let bake = INTERIOR_BAKE_ENTITY_MEAN * (fract(wow_light.sh_c16.w) / BAKE_LANE_SCALE);
var room_rgb = albedo * clamp(
- inst_tint * (vec3(1.0) - exp(-room * wow_light.point_count.w)) + vec3(highlight),
+ inst_tint * (vec3(1.0) - exp(-(room + vec3(bake)) * wow_light.point_count.w))
+ + vec3(highlight),
vec3(0.0),
vec3(1.0),
);
diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs
index da484352..021e4698 100644
--- a/crates/benilla-formats/src/bin/benilla-extract/main.rs
+++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs
@@ -644,6 +644,12 @@ enum Command {
Wmolights {
/// Internal path to the WMO **root** (forward or back slashes accepted).
internal_path: String,
+ /// MONKEY (trans day law): also dump ONE group's per-VERTEX rows - position + the MOCV
+ /// alpha the shader interpolates as `trans_a` - plus every portal's vertex bounds. The
+ /// batch table above gives a batch's alpha RANGE and mean; a per-vertex blend law can only
+ /// be evaluated against the actual vertices, which is what this prints.
+ #[arg(long)]
+ verts: Option,
},
/// MONKEY (interior prop lights): sweep every WMO **root** and audit whether its rooms have
/// any light at all — MOLT omni fixtures, MODD props that would SYNTHESISE one (the
@@ -940,7 +946,10 @@ fn main() -> Result<()> {
internal_path,
filter,
} => scan::wmodoodads(&mut chain, &internal_path, filter.as_deref())?,
- Command::Wmolights { internal_path } => scan::wmolights(&mut chain, &internal_path)?,
+ Command::Wmolights {
+ internal_path,
+ verts,
+ } => scan::wmolights(&mut chain, &internal_path, verts)?,
Command::Wmolamps { prefix, detail } => {
scan::wmolamps(&mut chain, prefix.as_deref(), detail.as_deref())?
}
diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs
index e17b81d2..1b3ff638 100644
--- a/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs
+++ b/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs
@@ -598,6 +598,163 @@ fn group_names(bytes: &[u8]) -> Vec> {
.collect()
}
+/// MONKEY (trans night floor): the group file's MOGP payload — `benilla_formats`'s own chunk walker
+/// is `pub(crate)` and this instrument lives in a separate bin target, so the four lines are
+/// repeated rather than the visibility widened for an audit.
+fn mogp_payload(bytes: &[u8]) -> Option<&[u8]> {
+ let mut o = 0usize;
+ while o + 8 <= bytes.len() {
+ let tag = [bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]];
+ let n = u32::from_le_bytes(bytes[o + 4..o + 8].try_into().ok()?) as usize;
+ if &tag == b"PGOM" {
+ return bytes.get(o + 8..(o + 8 + n).min(bytes.len()));
+ }
+ o += 8 + n;
+ }
+ None
+}
+
+/// MONKEY (trans night floor): the per-group **MOBA batch-class** table — the instrument the
+/// interior seam actually asks for.
+///
+/// `wmolights`'s group table above answers "which LIGHTING LANE is this group on" (INT / ext* /
+/// ext). It cannot answer the next question down, which is the one a seam *inside* one interior
+/// room reduces to: an interior group's batches are split TRANS / INT / EXT by the MOGP batch
+/// section counts (`+0x28` / `+0x2a`), and the shader gives each class a DIFFERENT day weight
+/// (`static_gx.wgsl`'s `day_w` — TRANS takes the MOCV alpha, EXT takes 1, INT takes 0). So a hard
+/// line across a doorway inside one room is a BATCH-class line, invisible to every table we had.
+///
+/// The printed numbers are exactly the shader's inputs for that weight: the class, the batch's
+/// triangle count, its world-space (model-space) bbox so it can be matched to a screenshot, and the
+/// **MOCV alpha** range/mean — `trans_a` in the shader, straight off `wmo_group_fixed_colors` (the
+/// buffer as the renderer uploads it, doorway fade included), i.e. the number `day_w = trans_a *
+/// sun_w` is built from. `a=255` on a TRANS batch means the batch takes the exterior sky law at
+/// FULL weight whenever the sun is above the horizon.
+fn batch_class_table(
+ chain: &mut Chain,
+ root: &benilla_formats::WmoRoot,
+ root_path: &str,
+ names: &[Option],
+ infos: &[benilla_formats::WmoGroupInfo],
+ // MONKEY (trans day law): dump this group's per-VERTEX rows as well (see the CLI doc).
+ verts: Option,
+) {
+ println!("=== MOBA batch classes (per group; a = MOCV alpha = the shader's `trans_a`) ===");
+ let stem = root_path
+ .to_ascii_lowercase()
+ .strip_suffix(".wmo")
+ .unwrap_or(root_path)
+ .to_string();
+ for gi in 0..root.group_count() as usize {
+ let Ok(gbytes) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else {
+ continue;
+ };
+ let Ok(benilla_wmo::ParsedWmo::Group(group)) =
+ benilla_wmo::parse_wmo(&mut std::io::Cursor::new(gbytes.as_slice()))
+ else {
+ continue;
+ };
+ // The MOGP batch-section counts — the SAME two reads `build_wmo_group_submeshes` makes.
+ let (trans_n, int_n) = mogp_payload(&gbytes)
+ .filter(|m| m.len() >= 0x2c)
+ .map_or((0usize, 0usize), |m| {
+ (
+ u16::from_le_bytes([m[0x28], m[0x29]]) as usize,
+ u16::from_le_bytes([m[0x2a], m[0x2b]]) as usize,
+ )
+ });
+ // The colours AS UPLOADED (doorway fade applied), so the alpha printed is the alpha the
+ // shader interpolates. BGRA — index 3 is alpha.
+ let colors = benilla_formats::wmo_group_fixed_colors(&gbytes, root);
+ let interior = (group.flags & 0x48) == 0;
+ println!(
+ " g{gi:<3} {:<12} {:<4} batches {:<3} (trans {trans_n}, int {int_n}, ext {})",
+ names.get(gi).and_then(|n| n.as_deref()).unwrap_or("-"),
+ if interior { "INT" } else { "ext" },
+ group.render_batches.len(),
+ group.render_batches.len().saturating_sub(trans_n + int_n),
+ );
+ for (bi, batch) in group.render_batches.iter().enumerate() {
+ let class = if bi < trans_n {
+ "TRANS"
+ } else if bi < trans_n + int_n {
+ "INT"
+ } else {
+ "EXT"
+ };
+ let start = batch.start_index as usize;
+ let idx: Vec = group
+ .vertex_indices
+ .get(start..start + batch.count as usize)
+ .unwrap_or(&[])
+ .iter()
+ .map(|&i| i as usize)
+ .filter(|&g| g < group.vertex_positions.len())
+ .collect();
+ if idx.is_empty() {
+ continue;
+ }
+ let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
+ let (mut a_lo, mut a_hi, mut a_sum, mut rgb_sum, mut n) =
+ (255u16, 0u16, 0u32, [0u32; 3], 0u32);
+ for &g in &idx {
+ let p = &group.vertex_positions[g];
+ for (k, v) in [p.x, p.y, p.z].into_iter().enumerate() {
+ lo[k] = lo[k].min(v);
+ hi[k] = hi[k].max(v);
+ }
+ if let Some(c) = colors.as_ref().and_then(|c| c.get(g)) {
+ let a = u16::from(c[3]);
+ a_lo = a_lo.min(a);
+ a_hi = a_hi.max(a);
+ a_sum += u32::from(c[3]);
+ // BGRA on disk — print it as RGB.
+ rgb_sum[0] += u32::from(c[2]);
+ rgb_sum[1] += u32::from(c[1]);
+ rgb_sum[2] += u32::from(c[0]);
+ n += 1;
+ }
+ }
+ let mean = |s: u32| if n == 0 { 0 } else { s / n };
+ println!(
+ " b{bi:<3} {class:<5} tris {:>5} a[{:>3}..{:>3}] mean {:>3} mocv rgb ({:>3},{:>3},{:>3}) box ({:>7.2},{:>7.2},{:>6.2})..({:>7.2},{:>7.2},{:>6.2})",
+ idx.len() / 3,
+ if n == 0 { 255 } else { a_lo },
+ if n == 0 { 255 } else { a_hi },
+ if n == 0 { 255 } else { mean(a_sum) as u16 },
+ mean(rgb_sum[0]), mean(rgb_sum[1]), mean(rgb_sum[2]),
+ lo[0], lo[1], lo[2], hi[0], hi[1], hi[2],
+ );
+ // MONKEY (trans day law): the per-VERTEX rows. `trans_a` is interpolated across the
+ // triangle from these alphas, so the BEFORE curve of the TRANS day blend is a function
+ // of exactly this list; the batch mean above cannot produce it.
+ if verts == Some(gi) {
+ let mut seen: Vec = Vec::new();
+ for &g in &idx {
+ if seen.contains(&g) {
+ continue;
+ }
+ seen.push(g);
+ let p = &group.vertex_positions[g];
+ let c = colors.as_ref().and_then(|c| c.get(g)).copied().unwrap_or([255; 4]);
+ let a = c[3];
+ println!(
+ " v{g:<5} ({:>7.2},{:>7.2},{:>6.2}) a {a:>3} ({:.3}) rgb ({:>3},{:>3},{:>3})",
+ p.x, p.y, p.z, f32::from(a) / 255.0, c[2], c[1], c[0],
+ );
+ }
+ print!(" tris");
+ for t in idx.chunks(3) {
+ print!(" [{}]", t.iter().map(|v| v.to_string()).collect::>().join(","));
+ }
+ println!();
+ }
+ }
+ }
+ let _ = infos;
+ println!();
+}
+
fn root_rooms(
chain: &mut Chain,
root: &benilla_formats::WmoRoot,
@@ -685,7 +842,7 @@ fn claim_cell(claims: &[benilla_formats::Claim]) -> String {
.join(" ")
}
-pub fn wmolights(chain: &mut Chain, raw_path: &str) -> Result<()> {
+pub fn wmolights(chain: &mut Chain, raw_path: &str, verts: Option) -> Result<()> {
let root_path = raw_path.replace('/', "\\").to_ascii_lowercase();
let bytes = chain
.read_file(&root_path)
@@ -749,13 +906,74 @@ pub fn wmolights(chain: &mut Chain, raw_path: &str) -> Result<()> {
.filter(|r| usize::from(r.portal) == pi)
.map(|r| r.group)
.collect();
+ // MONKEY (portal bleed): ...and the POLYGON's own box - centre + diagonal. The bleed seed
+ // reaches for both (`daylight_reach(diag)` is its radius, the centre its position), and the
+ // `BLEED` tag marks the population it seeds from: a portal whose two sides are BOTH
+ // interior-class groups, i.e. a doorway between two rooms rather than one to the outside.
+ // `DAY` is the daylight-fixture portal seed's own population, printed beside it so one
+ // table answers "which rule, if any, stands a light in this opening".
+ let pbox = rr
+ .portals
+ .infos
+ .get(pi)
+ .and_then(|i| {
+ let s = usize::from(i.start_vertex);
+ rr.portals.vertices.get(s..s + usize::from(i.count))
+ })
+ .map(|v| {
+ let mut lo = [f32::MAX; 3];
+ let mut hi = [f32::MIN; 3];
+ for p in v {
+ for a in 0..3 {
+ lo[a] = lo[a].min(p[a]);
+ hi[a] = hi[a].max(p[a]);
+ }
+ }
+ (lo, hi)
+ });
+ let (diag, ctr) = pbox.map_or((0.0, [0.0; 3]), |(lo, hi)| {
+ (
+ ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt(),
+ [
+ 0.5 * (lo[0] + hi[0]),
+ 0.5 * (lo[1] + hi[1]),
+ 0.5 * (lo[2] + hi[2]),
+ ],
+ )
+ });
+ let cls: Vec = joins
+ .iter()
+ .filter_map(|g| rr.infos.get(usize::from(*g)).map(|g| g.interior))
+ .collect();
+ let kind = if cls.len() == 2 && cls.iter().all(|i| *i) {
+ " BLEED"
+ } else if cls.iter().any(|i| *i) {
+ " DAY"
+ } else {
+ ""
+ };
println!(
- " p{pi:<3} area {area:>7.2} yd^2 |n.z| {:.2} joins {joins:?}{}",
+ " p{pi:<3} area {area:>7.2} yd^2 diag {diag:>6.2} ctr ({:>7.2},{:>7.2},{:>6.2}) |n.z| {:.2} joins {joins:?}{}{kind}",
+ ctr[0], ctr[1], ctr[2],
plane[2].abs(),
if area >= benilla_formats::room_claim::SPLIT_PORTAL_MIN_AREA { " SPLIT" } else { "" },
);
+ // MONKEY (trans day law): the portal's own vertex BOX under `--verts`. The daylight seed's
+ // calibration geometry is read off it and nothing else prints it: `seed_point` stands the
+ // fixture at the box centre but no higher than `SEED_MAX_HZ` above the box BOTTOM, and
+ // `hz = z - lo.z` is the calibration height. Without `lo[2]` neither number is recoverable
+ // from the centre+diagonal line above.
+ if verts.is_some() {
+ if let Some((lo, hi)) = pbox {
+ println!(
+ " box ({:>7.2},{:>7.2},{:>6.2})..({:>7.2},{:>7.2},{:>6.2})",
+ lo[0], lo[1], lo[2], hi[0], hi[1], hi[2],
+ );
+ }
+ }
}
println!();
+ batch_class_table(chain, &root, &root_path, &names, &infos, verts);
for (i, l) in lights.iter().enumerate() {
let prod = [
l.color[0] * l.intensity,
diff --git a/crates/benilla-formats/src/fire_light.rs b/crates/benilla-formats/src/fire_light.rs
index 7341165a..b1f2cfb5 100644
--- a/crates/benilla-formats/src/fire_light.rs
+++ b/crates/benilla-formats/src/fire_light.rs
@@ -727,6 +727,391 @@ pub fn synthesize_lamp_light(
})
}
+// ---------------------------------------------------------------------------------------------
+// MONKEY (spell light): the THIRD route — a SPELL EFFECT's emitter, which the two above veto.
+// ---------------------------------------------------------------------------------------------
+//
+// `is_spell_path` turns every `SPELLS\` model away from both routes above, and the reason it gives
+// is sound for the WORLD lane it was written for: a spell model burns for a second and vanishes,
+// and a placed-scenery light table that churned at cast rate would blow its budget on nothing. But
+// the *content* is exactly the same shape — an additive emitter with a colour ramp — and a
+// Fireball that flies over a night road lighting nothing is the reference's own hole, not a
+// decision. So the spell corpus gets its own route, with the constraints the world route lacks:
+// the light is ENTITY-scoped (it lives and dies with the effect instance, never enters the placed
+// lane — see `ModelLight::spell`), it is BUDGETED (the app's `SPELL_LIGHTS_MAX`), and it is
+// CLASSIFIED — because only some schools of magic are LUMINOUS.
+//
+// That last part is the user's rule and it is a physical one: fire burns, holy light shines, fel
+// fire burns green. Frost, nature, arcane and shadow do not emit — a Frostbolt is cold, Entangling
+// Roots is a plant, a Shadow Bolt is the ABSENCE of light and lighting the road with it would be
+// absurd. [`SpellLightKind`] is that judgement, and it is made from the model's NAME first (the
+// artists named the whole corpus by school) and from the winning emitter's HUE second (the
+// unnamed remainder — `Spells\Clouds8x8.blp` is the whole game's smoke and flame both).
+//
+// The emitter gate is deliberately WIDER than the world route's [`fire_emitter`]: a spell's flame
+// art is not the `FLAMELICK`/`TORCH` vocabulary at all (a Fireball's core is `Clouds8x8`, a
+// firework is `STAR5A`, Holy Light is `Clouds8x8Fade` — none of them a [`FIRE_TEXTURE_KEYS`] hit).
+// The texture therefore cannot be the gate here; ADDITIVE + a real particle size is, and the
+// school decides the rest.
+
+/// The flat gain on every synthesised SPELL light, folded into the intensity at synthesis. `1.0`
+/// = "a spell light sits on the same rungs a torch does", which is the calibration the reach
+/// buckets downstream ([`crate::room_claim::m2_light_reach`]) are built on. It is a `const` and
+/// not a cvar on purpose (this pass adds none); a live `spellLightGain` belongs beside
+/// `fireLightGain` in the packer, not here.
+pub const SPELL_LIGHT_GAIN: f32 = 1.0;
+
+/// The HOLY fallback hue: a warm white with a gold cast. Used when a holy effect's ramp yields no
+/// usable key, which is the common case rather than the exception — Holy Light's strongest emitter
+/// ramps `warm-gold (a 0) -> white (a 0.13) -> acid yellow (a 0)`, so the only key anyone ever SEES
+/// is the white one and the only SATURATED one is invisible. Taking the ramp literally there gives
+/// a yellow-green flash for the paladin's signature heal; this is the colour the effect reads as.
+pub const DEFAULT_HOLY: [f32; 3] = [1.0, 0.95, 0.78];
+
+/// The FEL fallback hue: warlock green. Same role as [`DEFAULT_HOLY`] for the demonic family,
+/// whose art is green fire — the imp's Firebolt, Fel Fire Nova, a Felhunter's sacrifice.
+pub const DEFAULT_FEL: [f32; 3] = [0.45, 1.0, 0.30];
+
+/// Which luminous school a spell effect belongs to — the whole yes/no of this route, and the
+/// user's stated rule: **fire, holy light and fel burn; frost, nature, arcane and shadow do not.**
+///
+/// The kind decides only WHETHER a light is derived and what its FALLBACK hue is; the light's
+/// actual colour still comes from the artist's own over-life ramp wherever the ramp has one
+/// ([`spell_color`]), so a blue firework lights blue even though its kind is `Fire`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SpellLightKind {
+ /// Burning: fireball, immolate, fire nova, a firework's powder, a rocket's exhaust.
+ Fire,
+ /// Radiant: Holy Light, Smite, a blessing, a resurrection — white-gold.
+ Holy,
+ /// Demonic green fire: the imp's flame, Fel Fire Nova, Hellfire's ring.
+ Fel,
+ /// Emits nothing. Frost, nature, arcane, shadow — and everything unrecognised, because the
+ /// safe answer for an effect we cannot name is the reference's own behaviour (no light).
+ None,
+}
+
+impl SpellLightKind {
+ /// Does this school light the world at all?
+ pub fn lights(self) -> bool {
+ !matches!(self, SpellLightKind::None)
+ }
+
+ /// The hue to use when the emitter's ramp yields none ([`spell_color`] returned `None`).
+ pub fn default_color(self) -> [f32; 3] {
+ match self {
+ SpellLightKind::Holy => DEFAULT_HOLY,
+ SpellLightKind::Fel => DEFAULT_FEL,
+ _ => DEFAULT_WARM,
+ }
+ }
+
+ /// Short label for the offline readout / trace.
+ pub fn label(self) -> &'static str {
+ match self {
+ SpellLightKind::Fire => "fire",
+ SpellLightKind::Holy => "holy",
+ SpellLightKind::Fel => "fel",
+ SpellLightKind::None => "none",
+ }
+ }
+}
+
+/// The NAME rules, **in evaluation order — first match wins, and the order is the whole design**.
+///
+/// The dark schools come FIRST because their names collide with the luminous ones and the collision
+/// is always resolved the same way: `LightningBoltIvus` contains `LIGHT` (holy) and must not be
+/// one — `LIGHTNING` ahead of it takes it; `Starfire` and `FaerieFire` contain `FIRE` and are
+/// druid/nature effects whose art is a green-white glow, not a flame; `ShadowFlame` contains
+/// `FLAME` and is shadow. Putting the vetoes first means a new luminous key can never accidentally
+/// re-admit one of them.
+///
+/// After the vetoes the luminous keys run. Note `FIRE` catches `FIREWORK`, `FIRECRACKER`,
+/// `SOULFIRE`, `HELLFIRE`, `RAINOFFIRE`, `FIREBALL`, `FIREBOLT` and `FIREBLAST` in one entry,
+/// which is why the list is this short: the artists named the corpus by school already.
+const SPELL_NAME_RULES: [(&str, SpellLightKind); 32] = [
+ // --- the dark schools, and the name collisions they must win ---
+ ("LIGHTNING", SpellLightKind::None),
+ ("STARFIRE", SpellLightKind::None),
+ ("FAERIE", SpellLightKind::None),
+ ("SHADOW", SpellLightKind::None),
+ ("FROST", SpellLightKind::None),
+ ("ICE", SpellLightKind::None),
+ ("FREEZE", SpellLightKind::None),
+ ("CHILL", SpellLightKind::None),
+ ("BLIZZ", SpellLightKind::None),
+ ("SNOW", SpellLightKind::None),
+ ("WINTER", SpellLightKind::None),
+ ("NATURE", SpellLightKind::None),
+ ("THUNDER", SpellLightKind::None),
+ ("WRATH", SpellLightKind::None),
+ ("ENTANGL", SpellLightKind::None),
+ ("POISON", SpellLightKind::None),
+ ("ARCANE", SpellLightKind::None),
+ ("POLYMORPH", SpellLightKind::None),
+ ("CURSE", SpellLightKind::None),
+ ("FEAR", SpellLightKind::None),
+ ("DRAIN", SpellLightKind::None),
+ ("DEATHCOIL", SpellLightKind::None),
+ // --- the luminous schools ---
+ ("FIRE", SpellLightKind::Fire),
+ ("FLAME", SpellLightKind::Fire),
+ ("IMMOLAT", SpellLightKind::Fire),
+ ("PYRO", SpellLightKind::Fire),
+ ("INCINER", SpellLightKind::Fire),
+ ("LAVA", SpellLightKind::Fire),
+ ("HOLY", SpellLightKind::Holy),
+ ("DIVINE", SpellLightKind::Holy),
+ ("SMITE", SpellLightKind::Holy),
+ ("FEL", SpellLightKind::Fel),
+];
+
+/// The school named by the model's **full path**, uppercased — `None` when no rule matches (the
+/// hue then decides, [`spell_hue_kind`]).
+///
+/// Matched on the whole path and not just the basename, because the corpus nests by family
+/// (`SPELLS\Enchantments\Shaman_Fire.m2`) and a directory naming the school is exactly as
+/// authoritative as a file naming it.
+pub fn spell_name_kind(path: &str) -> Option {
+ let p = path.replace('/', "\\").to_ascii_uppercase();
+ SPELL_NAME_RULES
+ .iter()
+ .find(|(k, _)| p.contains(k))
+ .map(|&(_, kind)| kind)
+}
+
+/// The school of an **unnamed** effect, from the colour its winning emitter burns.
+///
+/// Deliberately conservative — an unrecognised effect defaults to dark, so every arm here has to
+/// EARN its light:
+/// - **Fel** — green-dominant and saturated. The imp's flame is green fire.
+/// - **Holy** — bright and near-white with at most a warm cast (`r` close to `g`, both high, blue
+/// not above green). A white-gold flash is holy; a white-BLUE one is frost's burnout, refused by
+/// keeping blue under green.
+/// - **Fire** — warm and descending (`r >= g >= b`) with a real red-to-blue spread. That is a
+/// flame ramp and nothing else here: frost is blue-dominant, nature green-dominant, shadow
+/// desaturated-dark, arcane violet (`r` close to `b` above `g`, which the `b <= g` clause
+/// rejects).
+pub fn spell_hue_kind(c: [f32; 3]) -> SpellLightKind {
+ let (r, g, b) = (c[0], c[1], c[2]);
+ if g > r && g > b && g - r.max(b) > 0.25 {
+ return SpellLightKind::Fel; // green fire
+ }
+ if r >= 0.75 && g >= 0.75 && b <= g && (r - g).abs() <= 0.25 {
+ return SpellLightKind::Holy; // white / white-gold
+ }
+ if r >= 0.5 && g <= r && b <= g && r - b >= 0.35 {
+ return SpellLightKind::Fire; // orange-red
+ }
+ SpellLightKind::None
+}
+
+/// The spell route's emitter gate: ADDITIVE, a real particle size, and a rate that ever rises.
+///
+/// No texture test — see the module note: the spell corpus's flame art is `Clouds8x8`, `STAR5A`,
+/// `FLARE` and `lensflare1`, none of which is in the world route's flame vocabulary, and a
+/// texture gate here would light nothing at all. [`MIN_FLAME_SIZE`] is shared with the world route
+/// and does the same job it does there: it keeps SPARK emitters (0.02–0.03 yd) from being mistaken
+/// for the flame they trail.
+pub fn spell_emitter(def: &ParticleEmitterDef) -> bool {
+ def.blend == ParticleBlend::Add
+ && peak_size(def) >= MIN_FLAME_SIZE
+ && def.timing.peak_rate() > 0.0
+}
+
+/// An over-life ramp's peak additive weight — the `A` channel's largest key. A spell emitter whose
+/// every key is transparent is drawn as nothing and must not decide the light.
+pub fn peak_alpha(def: &ParticleEmitterDef) -> f32 {
+ def.over_life.color.iter().map(|k| k[3]).fold(0.0, f32::max)
+}
+
+/// Which emitter of a spell model IS the effect: `peak size × sqrt(rate) × peak alpha`.
+///
+/// Three factors, each answering a way a naive pick took the WRONG emitter on a real model:
+/// - **size** — the honest one, as in [`fire_strength`].
+/// - **sqrt(rate)** — a burst of 300 is a bigger event than a trickle of 10, but under a square
+/// root so a dense spark shower can't outrank the flame. (The world route's FOURTH root is tuned
+/// for steady props; a spell's whole vocabulary is bursts, and the fourth root flattens them.)
+/// - **peak alpha** — the one this route adds, and the one that gets fireworks right. Every
+/// firework model authors a pale muzzle FLASH at `a 0.39` beside its coloured BURST at `a 1.0`;
+/// size and rate alone crown the flash, and `Fireworks_Blue` then lights the sky pale yellow.
+pub fn spell_strength(def: &ParticleEmitterDef) -> f32 {
+ peak_size(def) * def.timing.peak_rate().max(0.0).sqrt() * peak_alpha(def)
+}
+
+/// The effect's colour: the ramp key with the largest **saturation × additive weight**,
+/// peak-normalised. `None` when no key is both saturated and visible.
+///
+/// The `× alpha` is the difference from [`flame_color`], and it is not a refinement — it is the
+/// rule. A spell ramp is authored `invisible hue -> VISIBLE colour -> invisible burnout`, i.e. its
+/// most saturated key is routinely one drawn at `a 0`: Fireball's tail ramps to pure red at
+/// `a 0.16` while it BURNS gold at `a 1.0`, and Holy Light's acid-yellow burnout is authored at
+/// `a 0` throughout. Weighting by the additive weight asks "what colour is actually on screen",
+/// which is the only question a light derived from it can be answering.
+///
+/// The floor is a QUARTER of [`MIN_SATURATION`], not the whole of it: the world route's floor is
+/// applied to a ramp key at full opacity, and applying the same number to a product that already
+/// carries the alpha would reject every faint-but-coloured spell key (Holy Light's tinted cloud
+/// sits at `a 0.09`).
+pub fn spell_color(over_life: &OverLife) -> Option<[f32; 3]> {
+ let score = |k: &[f32; 4]| {
+ let (lo, hi) = k[..3]
+ .iter()
+ .fold((f32::MAX, f32::MIN), |(lo, hi), &v| (lo.min(v), hi.max(v)));
+ (hi - lo) * k[3]
+ };
+ let best = over_life
+ .color
+ .iter()
+ .filter(|k| score(k) >= MIN_SATURATION * 0.25)
+ .max_by(|a, b| score(a).total_cmp(&score(b)))?;
+ let peak = best[0].max(best[1]).max(best[2]);
+ (peak > 1e-4).then(|| [best[0] / peak, best[1] / peak, best[2] / peak])
+}
+
+/// Bucket a spell light onto the shared intensity rungs (`m2_light_reach`: 0.6 -> 6 yd, 1.5 -> 12,
+/// 2.0 -> 16) from the winning emitter's **particle size**, promoted one rung by a BURST.
+///
+/// Size and not [`spell_strength`], because the strength score exists to RANK emitters within one
+/// model and its rate term saturates across models: every spell emitter runs 50–500/s, so a
+/// strength bucket would file Holy Light, Fireball and a firework on the same top rung. Size
+/// separates them as authored — a missile's core is 0.4–0.8 yd, a nova's cloud 1.1, a spark 0.05.
+///
+/// The burst promotion is what gets a firework to the brazier rung its 16 yd shell deserves: a
+/// `>= 100/s` rate is a detonation, not a flame, and its light should reach further than its
+/// (small) particles suggest. Nothing here reaches the 3.0 bonfire rung — a spell must not
+/// out-blaze a forge.
+pub fn spell_intensity(size: f32, rate: f32) -> (f32, &'static str) {
+ let (intensity, bucket) = match size {
+ s if s < 0.25 => (0.6, "candle"),
+ s if s < 0.75 => (1.5, "torch"),
+ _ => (2.0, "brazier"),
+ };
+ match (rate >= 100.0, bucket) {
+ (true, "candle") => (1.5, "torch"),
+ (true, "torch") => (2.0, "brazier"),
+ _ => (intensity, bucket),
+ }
+}
+
+/// When the emitter's rate track first reaches half its peak, in seconds from the sequence start —
+/// the light's **onset**, i.e. how long after the effect spawns the flame it stands for exists.
+///
+/// A firework is the whole reason this is read. `G_Firework03Red` is a ~0.7 s model whose shell
+/// only detonates at `t = 0.567` (`rate 0 -> 200` there); `SPELLS\Fireworks_Blue` bursts at 1.5 s.
+/// A light lit at spawn flashes the rocket's flight, which is exactly backwards. Everything with a
+/// constant rate — every missile, every kit glow — answers `0.0` and is unaffected.
+pub fn emit_onset(def: &ParticleEmitterDef) -> f32 {
+ let peak = def.timing.peak_rate();
+ if peak <= 0.0 {
+ return 0.0;
+ }
+ def.timing
+ .slot_views()
+ .first()
+ .and_then(|(_, rate, _)| *rate)
+ .and_then(|keys| {
+ keys.iter()
+ .find(|&&(_, v)| v >= peak * 0.5)
+ .map(|&(t, _)| t.max(0.0))
+ })
+ .unwrap_or(0.0)
+}
+
+/// One synthesised spell light — the same shape as [`SyntheticFire`], plus the school it was
+/// judged to be and the onset its emitter's rate track names.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct SyntheticSpell {
+ /// Index into the emitter slice handed in — the winning ([`spell_strength`]) emitter. The
+ /// caller takes that emitter's model-space position and bone, exactly as the flame route does.
+ pub emitter: usize,
+ /// The school ([`SpellLightKind`]) — never [`SpellLightKind::None`] here (that is a `None`).
+ pub kind: SpellLightKind,
+ /// Peak-normalised linear RGB ([`spell_color`], else the kind's default).
+ pub color: [f32; 3],
+ /// The light's `diffuse_intensity` ([`spell_intensity`] × [`SPELL_LIGHT_GAIN`]).
+ pub intensity: f32,
+ /// The intensity bucket's name — readout only, comparable with the other two routes'.
+ pub bucket: &'static str,
+ /// Seconds from the instance's birth before the light comes up ([`emit_onset`]).
+ pub onset: f32,
+}
+
+/// Is this model a FIREWORK — one of the `World\Goober\G_Firework0*` GameObject shells, or the
+/// `SPELLS\Firework*` / `Firecracker` family?
+///
+/// They are the one luminous population that is NOT under `SPELLS\` (the GameObject shells live in
+/// `World\Goober\`), so without this the user's "fireworks" case would be exactly half done: the
+/// spell-cast rockets would light and the Darkmoon show would not.
+///
+/// The LAUNCHER is excluded deliberately. `G_FireworkLauncher01` is permanent scenery that plays
+/// its muzzle flash on trigger; every lane here keys its light to the INSTANCE's birth, so a
+/// launcher would flash once when it streamed in and then stand dark forever — a light that is
+/// wrong in both directions. Its rockets do the lighting.
+pub fn is_firework_path(path: &str) -> bool {
+ let p = path.replace('/', "\\").to_ascii_uppercase();
+ !p.contains("LAUNCHER")
+ && (p.contains("FIREWORK") || p.contains("FIRECRACKER") || p.contains("ROMANCANDLE"))
+}
+
+/// Should this model take the SPELL route rather than the two world ones? `SPELLS\` content and
+/// the firework family, and nothing else — see [`is_spell_path`] / [`is_firework_path`].
+pub fn is_spell_light_path(path: &str) -> bool {
+ is_spell_path(path) || is_firework_path(path)
+}
+
+/// Derive **at most one** light for a spell effect / firework model: pick the strongest emitter
+/// ([`spell_strength`]), judge its school (NAME first, then HUE — a name rule always wins, so a
+/// frost effect with one red spark stays dark), and take its colour, bucket and onset.
+///
+/// `None` when the model is not spell content, authors no additive emitter, or belongs to a school
+/// that does not emit light — which is the great majority of the corpus, and is the point.
+///
+/// One light per model, for the reason the flame route gives: a Fireball authors four emitters and
+/// every one of them that passed would stack a full light on the same spot.
+///
+/// (No bounding box is read, unlike [`synthesize_lamp_light`]: an effect model has no "where is
+/// the lamp head" problem — its emitter IS the flame, and its position is authored.)
+pub fn synthesize_spell_light<'a>(
+ path: &str,
+ emitters: impl IntoIterator- ,
+) -> Option
{
+ if !is_spell_light_path(path) {
+ return None;
+ }
+ let (emitter, def, _) = emitters
+ .into_iter()
+ .enumerate()
+ .filter(|(_, d)| spell_emitter(d))
+ .map(|(i, d)| (i, d, spell_strength(d)))
+ // First-wins on a tie, as in [`synthesize_fire_light`] — mirrored emitter pairs are real
+ // here too (a firework shell authors three identical bursts on three bones).
+ .fold(None, |best: Option<(usize, &ParticleEmitterDef, f32)>, cur| {
+ match best {
+ Some(b) if b.2 >= cur.2 => Some(b),
+ _ => Some(cur),
+ }
+ })?;
+ let ramp = spell_color(&def.over_life);
+ // The NAME wins over the HUE wherever both speak: an artist naming a model `Frostbolt` has
+ // said more about it than one warm key in its ramp has. The hue decides for the unnamed
+ // remainder only, and it defaults to dark.
+ let kind =
+ spell_name_kind(path).unwrap_or_else(|| ramp.map_or(SpellLightKind::None, spell_hue_kind));
+ if !kind.lights() {
+ return None;
+ }
+ let (intensity, bucket) = spell_intensity(peak_size(def), def.timing.peak_rate());
+ Some(SyntheticSpell {
+ emitter,
+ kind,
+ color: ramp.unwrap_or_else(|| kind.default_color()),
+ intensity: intensity * SPELL_LIGHT_GAIN,
+ bucket,
+ onset: emit_onset(def),
+ })
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1186,4 +1571,283 @@ mod tests {
// No lamp word at all ⇒ the open-flame default, shared with the particle route.
assert_eq!(lamp_color("x\\Scholme_Wax03.m2"), DEFAULT_WARM);
}
+
+ // -----------------------------------------------------------------------------------------
+ // MONKEY (spell light): the THIRD route's tests. Every ramp below is a REAL one, dumped with
+ // `benilla-extract m2part` off the shipped model named in the comment — the rules were
+ // calibrated against these and nothing else.
+ // -----------------------------------------------------------------------------------------
+
+ /// GOLDEN — the classification table, school by school, on the models the user named. The
+ /// LUMINOUS half must light and the DARK half must not; nothing else about the feature matters
+ /// if this table is wrong.
+ #[test]
+ fn spell_school_decides_who_lights() {
+ // Fireball's winning emitter (the shockwave tail): gold -> red -> red, all additive.
+ let fireball = def(
+ Some("Creature\\Spells\\Shockwave4white.blp"),
+ ParticleBlend::Add,
+ [
+ [1.0, 0.847, 0.0, 1.0],
+ [0.98, 0.02, 0.0, 1.0],
+ [1.0, 0.0, 0.0, 0.161],
+ ],
+ 0.833,
+ 36.0,
+ );
+ let fire = synthesize_spell_light("Spells\\Fireball_Missile_High.m2", [&fireball])
+ .expect("a fireball burns");
+ assert_eq!(fire.kind, SpellLightKind::Fire);
+ // The colour is the key that is actually ON SCREEN (gold at full alpha), not the pure red
+ // burnout the world route's most-saturated rule would have taken.
+ assert!(close(fire.color, [1.0, 0.847, 0.0]));
+ assert_eq!(fire.bucket, "brazier"); // a 0.83 yd core
+
+ // Frostbolt — blue/cyan, and NAMED frost: dark twice over.
+ let frostbolt = def(
+ Some("Spells\\Clouds8x8.blp"),
+ ParticleBlend::Add,
+ [
+ [0.384, 0.106, 0.957, 0.671],
+ [0.106, 0.435, 0.929, 0.502],
+ [0.71, 0.871, 0.984, 0.0],
+ ],
+ 0.3167,
+ 60.0,
+ );
+ assert_eq!(synthesize_spell_light("Spells\\Frostbolt.m2", [&frostbolt]), None);
+
+ // Holy Light's strongest emitter: every saturated key is authored INVISIBLE, so the ramp
+ // yields nothing and the holy default (warm white) stands in. A most-saturated-key rule
+ // would flash the paladin acid yellow.
+ let holy = def(
+ Some("Spells\\Clouds8x8Fade.blp"),
+ ParticleBlend::Add,
+ [
+ [1.0, 0.729, 0.302, 0.0],
+ [1.0, 1.0, 1.0, 0.133],
+ [0.965, 1.0, 0.0, 0.0],
+ ],
+ 1.111,
+ 10.0,
+ );
+ let light = synthesize_spell_light("Spells\\HolyLight_Impact_Head.m2", [&holy])
+ .expect("holy light shines");
+ assert_eq!(light.kind, SpellLightKind::Holy);
+ assert_eq!(light.color, DEFAULT_HOLY);
+
+ // Shadow Bolt — shadow is the ABSENCE of light, whatever its ramp says. This ramp is the
+ // violet one; the NAME is what settles it either way.
+ let shadow = def(
+ Some("Spells\\Clouds8x8.blp"),
+ ParticleBlend::Add,
+ [[0.4, 0.0, 0.6, 1.0], [0.2, 0.0, 0.4, 1.0], [0.1, 0.0, 0.2, 0.0]],
+ 0.5,
+ 60.0,
+ );
+ assert_eq!(
+ synthesize_spell_light("Spells\\ShadowBolt_Missile.m2", [&shadow]),
+ None
+ );
+
+ // Entangling Roots / Wrath — nature. Named, so no hue can rescue them.
+ let green = def(
+ Some("Spells\\Clouds8x8.blp"),
+ ParticleBlend::Add,
+ [[0.2, 1.0, 0.2, 1.0]; 3],
+ 0.5,
+ 40.0,
+ );
+ assert_eq!(
+ synthesize_spell_light("Spells\\EntanglingRoots_State.m2", [&green]),
+ None
+ );
+ assert_eq!(synthesize_spell_light("Spells\\Wrath_Missile.m2", [&green]), None);
+
+ // The imp's Firebolt: NAMED fire (the `FIRE` key), and its green ramp still supplies the
+ // colour — a green flame is a flame with odd chemistry, exactly as the world route holds.
+ let imp = synthesize_spell_light("Spells\\FireBolt_Missile_Low.m2", [&green])
+ .expect("the imp's flame burns");
+ assert_eq!(imp.kind, SpellLightKind::Fire);
+ assert!(close(imp.color, [0.2, 1.0, 0.2]));
+
+ // …and the demonic family reached by NAME rather than by flame: `Fel_FireNova`.
+ assert_eq!(
+ synthesize_spell_light("Spells\\Fel_FireNova_Area.m2", [&green])
+ .expect("fel fire burns")
+ .kind,
+ // `FIRE` precedes `FEL` in the table and both light — the kind is a label here, and
+ // the light is identical either way.
+ SpellLightKind::Fire
+ );
+ }
+
+ /// GOLDEN — FIREWORKS, both families, and the emitter pick that gets their colour right.
+ ///
+ /// `Fireworks_Blue_01` authors a pale yellow muzzle FLASH beside its blue SHELL. The flash is
+ /// smaller but faster; only the alpha term in [`spell_strength`] puts the shell on top, and
+ /// without it the user's "coloured burst" is a yellow one.
+ #[test]
+ fn a_firework_lights_in_its_own_colour() {
+ // SPELLS\Fireworks_Blue_01 #1 — the blue shell (0.667 yd, 50/s, opaque).
+ let shell = def(
+ Some("ITEM\\OBJECTCOMPONENTS\\WEAPON\\FLARE.BLP"),
+ ParticleBlend::Add,
+ [
+ [0.753, 0.949, 1.0, 1.0],
+ [0.078, 0.337, 1.0, 1.0],
+ [0.157, 0.631, 1.0, 0.0],
+ ],
+ 0.667,
+ 50.0,
+ );
+ // …and #0, the pale flash: smaller, twice the rate.
+ let flash = def(
+ Some("ITEM\\OBJECTCOMPONENTS\\WEAPON\\FLARE.BLP"),
+ ParticleBlend::Add,
+ [
+ [0.941, 0.98, 0.678, 0.392],
+ [0.953, 0.918, 0.525, 0.392],
+ [0.973, 0.965, 0.796, 0.0],
+ ],
+ 0.444,
+ 100.0,
+ );
+ let fw = synthesize_spell_light("SPELLS\\Fireworks_Blue_01.m2", [&flash, &shell])
+ .expect("a firework burns");
+ assert_eq!(fw.kind, SpellLightKind::Fire);
+ assert_eq!(fw.emitter, 1, "the SHELL wins, not the flash");
+ assert!(close(fw.color, [0.078, 0.337, 1.0]));
+
+ // The GameObject family is NOT under `SPELLS\` and reaches the route by name alone.
+ let red = def(
+ Some("SPELLS\\STAR5A.BLP"),
+ ParticleBlend::Add,
+ [
+ [0.698, 0.0, 0.0, 1.0],
+ [0.922, 0.0, 0.0, 1.0],
+ [0.875, 0.176, 0.176, 0.0],
+ ],
+ 0.611,
+ 200.0,
+ );
+ let go = synthesize_spell_light("World\\Goober\\G_Firework03Red.m2", [&red])
+ .expect("the Darkmoon show burns");
+ assert!(close(go.color, [1.0, 0.0, 0.0]));
+ // 0.61 yd is the torch rung; the 200/s detonation promotes it to the brazier's 16 yd.
+ assert_eq!(go.bucket, "brazier");
+
+ // The LAUNCHER is scenery, not a firework: it must stay dark (it would otherwise flash
+ // once on stream-in and never again).
+ assert!(!is_firework_path("World\\Goober\\G_FireworkLauncher01.m2"));
+ assert!(is_firework_path("World/Goober/G_Firework01Blue.m2"));
+ }
+
+ /// GOLDEN — the NAME rules beat the HUE rules, which is the asymmetry the whole table rests
+ /// on: an artist's filename is evidence about the SCHOOL, a ramp key is evidence about one
+ /// particle. The three collisions below are all real shipped names.
+ #[test]
+ fn a_name_beats_a_hue() {
+ assert_eq!(
+ spell_name_kind("SPELLS\\LightningBoltIvus_Missile.m2"),
+ Some(SpellLightKind::None),
+ "LIGHTNING must not read as LIGHT"
+ );
+ assert_eq!(
+ spell_name_kind("SPELLS\\Starfire_Area.m2"),
+ Some(SpellLightKind::None),
+ "a druid's Starfire is not a fire"
+ );
+ assert_eq!(
+ spell_name_kind("Spells\\FaerieFire_Impact.m2"),
+ Some(SpellLightKind::None)
+ );
+ assert_eq!(
+ spell_name_kind("Spells\\HolyLight_Impact_Head.m2"),
+ Some(SpellLightKind::Holy)
+ );
+ assert_eq!(spell_name_kind("Spells\\Immolate_State.m2"), Some(SpellLightKind::Fire));
+ assert_eq!(spell_name_kind("Spells\\ArcaneExplosion_Base.m2"), Some(SpellLightKind::None));
+ assert_eq!(spell_name_kind("Spells\\Doomguard_Summon.m2"), None); // hue decides
+
+ // A FROST effect carrying one warm spark stays dark — the name arm is unconditional.
+ let spark = def(
+ Some("Spells\\Clouds8x8.blp"),
+ ParticleBlend::Add,
+ [[1.0, 0.4, 0.0, 1.0]; 3],
+ 0.5,
+ 60.0,
+ );
+ assert_eq!(synthesize_spell_light("Spells\\FrostNova_Base.m2", [&spark]), None);
+ // …the same emitter under an unnamed model lights, by hue alone.
+ assert_eq!(
+ synthesize_spell_light("Spells\\Doomguard_Summon.m2", [&spark])
+ .expect("a warm unnamed effect burns")
+ .kind,
+ SpellLightKind::Fire
+ );
+ }
+
+ /// The hue classifier's own table — the arms that must NOT fire are the point of it.
+ #[test]
+ fn the_hue_classifier_defaults_to_dark() {
+ assert_eq!(spell_hue_kind([1.0, 0.55, 0.2]), SpellLightKind::Fire);
+ assert_eq!(spell_hue_kind([1.0, 0.95, 0.78]), SpellLightKind::Holy);
+ assert_eq!(spell_hue_kind([0.2, 1.0, 0.2]), SpellLightKind::Fel);
+ assert_eq!(spell_hue_kind([0.1, 0.44, 0.93]), SpellLightKind::None, "frost");
+ assert_eq!(spell_hue_kind([0.71, 0.87, 0.98]), SpellLightKind::None, "ice white-blue");
+ assert_eq!(spell_hue_kind([0.7, 0.4, 1.0]), SpellLightKind::None, "arcane violet");
+ assert_eq!(spell_hue_kind([0.3, 0.3, 0.3]), SpellLightKind::None, "grey");
+ }
+
+ /// The gates: a non-spell path never reaches the route (the world routes own it), a
+ /// non-additive or spark-sized emitter never wins it, and an emitter-less model yields
+ /// nothing.
+ #[test]
+ fn the_spell_gate_refuses_everything_else() {
+ let flame = def(
+ Some("Spells\\Clouds8x8.blp"),
+ ParticleBlend::Add,
+ [[1.0, 0.4, 0.0, 1.0]; 3],
+ 0.5,
+ 60.0,
+ );
+ assert_eq!(
+ synthesize_spell_light("World\\Goober\\ElwynnCampfire.m2", [&flame]),
+ None,
+ "a placed campfire is the FLAME route's, not this one's"
+ );
+ let smoke = def(
+ Some("Spells\\Clouds8x8.blp"),
+ ParticleBlend::Alpha,
+ [[1.0, 0.4, 0.0, 1.0]; 3],
+ 0.5,
+ 60.0,
+ );
+ assert_eq!(synthesize_spell_light("Spells\\Fire_Nova.m2", [&smoke]), None);
+ let sparks = def(
+ Some("Spells\\Clouds8x8.blp"),
+ ParticleBlend::Add,
+ [[1.0, 0.4, 0.0, 1.0]; 3],
+ 0.02,
+ 60.0,
+ );
+ assert_eq!(synthesize_spell_light("Spells\\Fire_Nova.m2", [&sparks]), None);
+ assert_eq!(synthesize_spell_light("Spells\\Fire_Nova.m2", []), None);
+ // A constant-rate emitter has no fuse: the light is up at t=0.
+ assert_eq!(emit_onset(&flame), 0.0);
+ }
+
+ /// The reach ladder, and the burst promotion that puts a firework shell on the brazier rung.
+ #[test]
+ fn spell_reach_buckets_on_size_and_burst() {
+ assert_eq!(spell_intensity(0.08, 20.0), (0.6, "candle"));
+ assert_eq!(spell_intensity(0.5, 50.0), (1.5, "torch"));
+ assert_eq!(spell_intensity(1.1, 10.0), (2.0, "brazier"));
+ // A detonation is promoted exactly one rung, and the top rung stays the brazier's:
+ // nothing a spell does may out-blaze a forge.
+ assert_eq!(spell_intensity(0.08, 300.0), (1.5, "torch"));
+ assert_eq!(spell_intensity(0.5, 200.0), (2.0, "brazier"));
+ assert_eq!(spell_intensity(1.1, 500.0), (2.0, "brazier"));
+ }
}
diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs
index bd92f5e1..63236d56 100644
--- a/crates/benilla-formats/src/lib.rs
+++ b/crates/benilla-formats/src/lib.rs
@@ -225,6 +225,10 @@ pub use fire_light::{synthesize_fire_light, SyntheticFire};
// MONKEY (lamp lights): the SECOND route in that same module — an emissive lamp/lantern
// GEOSET rather than a flame emitter (a lamppost authors no particles at all).
pub use fire_light::{synthesize_lamp_light, EmissiveBatch, LightRoute, SyntheticLamp};
+// MONKEY (spell light): the THIRD route in that same module — a SPELL EFFECT's / FIREWORK's
+// emitter, which the two above veto outright. Fire, holy and fel burn; frost, nature, arcane and
+// shadow emit nothing at all.
+pub use fire_light::{is_spell_light_path, synthesize_spell_light, SpellLightKind, SyntheticSpell};
// MONKEY (portal claims): which ROOMS one fixture may light -- containment, MOLR, and one portal
// hop. Shared by the runtime spawner and the `wmolights`/`wmolamps` audits so the gate the shader
// enforces and the gate the instrument prints can never be two different rules.
diff --git a/crates/benilla-formats/src/room_claim.rs b/crates/benilla-formats/src/room_claim.rs
index 89bb3c9d..e2a2d825 100644
--- a/crates/benilla-formats/src/room_claim.rs
+++ b/crates/benilla-formats/src/room_claim.rs
@@ -349,6 +349,49 @@ pub fn ext_building_scale(g: &WmoGroupInfo) -> bool {
!g.interior && claimable_by_box(g)
}
+/// MONKEY (enclosed day floor): the slack (yd) an exterior shell's authored MOGI box is grown by
+/// before it is asked whether it CONTAINS a room. An authored box is not always tight around its
+/// own geometry (NSabbey g3's bottom floats 1.5 yd above its own floor), and a room whose centre
+/// sits a hand's breadth outside its own building's shell is a measurement artefact, not a cave.
+pub const SHELL_CONTAIN_SLACK: f32 = 0.5;
+
+/// MONKEY (enclosed day floor): is this INTERIOR-class group a room INSIDE A BUILDING — i.e. is its
+/// box centre contained in some EXTERIOR-class group of the same root at building scale
+/// ([`ext_building_scale`])?
+///
+/// The question the daylight floor turns on, and the one thing that separates "a room whose door
+/// this renderer cannot find" from "a cave that has no door". A building authors a shell: the
+/// Goldshire inn's `g4 upstairs` (57.7 x 32.2 yd, exterior-class) swallows all ten of its rooms,
+/// and Northshire abbey's `g5 mainlobby2` its nave. A dungeon authors none — `az_deadmines_*`,
+/// Stratholme and the rest are interior groups end to end — so nothing there is ever flagged and
+/// the sunless look of a cave is preserved by the DATA rather than by a special case.
+///
+/// A city's district shell is deliberately refused by [`ext_building_scale`]'s
+/// [`CLAIM_EXT_SHELL_YD`] cut, for the same reason the claim lane refuses it: a 660 yd box is not a
+/// building, and flagging every interior group in Stormwind because one shell spans the district
+/// would daylight its sealed cellars.
+pub fn enclosed_by_building_shell(groups: &[WmoGroupInfo], group: u16) -> bool {
+ let Some(g) = groups.get(usize::from(group)) else {
+ return false;
+ };
+ if !g.interior {
+ return false; // an exterior-class group is lit by the sky already
+ }
+ 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]),
+ ];
+ groups.iter().enumerate().any(|(i, e)| {
+ i != usize::from(group)
+ && ext_building_scale(e)
+ && (0..3).all(|a| {
+ c[a] >= e.bbox_min[a] - SHELL_CONTAIN_SLACK
+ && c[a] <= e.bbox_max[a] + SHELL_CONTAIN_SLACK
+ })
+ })
+}
+
/// `pos` inside a group's authored MOGI box.
fn contains(g: &WmoGroupInfo, pos: [f32; 3]) -> bool {
(0..3).all(|a| pos[a] >= g.bbox_min[a] && pos[a] <= g.bbox_max[a])
@@ -610,6 +653,42 @@ mod tests {
}
}
+ /// MONKEY (enclosed day floor): a room inside a building's shell is flagged; a cave, a room
+ /// under a CITY-scale shell, and the shell itself are not.
+ #[test]
+ fn enclosed_is_a_room_in_a_building_and_nothing_else() {
+ // 0 the room, 1 the inn's own shell (57 x 32 yd, building scale), 2 a second room just
+ // OUTSIDE that shell, 3 a district-scale shell (200 yd) containing everything.
+ let groups = [
+ g(true, [0.0, 0.0, 0.0], [10.0, 10.0, 5.0]),
+ g(false, [-5.0, -5.0, -1.0], [52.0, 27.0, 10.0]),
+ g(true, [90.0, 90.0, 0.0], [95.0, 95.0, 5.0]),
+ g(false, [-100.0, -100.0, -10.0], [100.0, 100.0, 40.0]),
+ ];
+ assert!(enclosed_by_building_shell(&groups, 0));
+ assert!(!enclosed_by_building_shell(&groups, 1), "a shell is not a room");
+ assert!(
+ !enclosed_by_building_shell(&groups, 2),
+ "only the DISTRICT shell holds it, and that is not a building"
+ );
+ assert!(!enclosed_by_building_shell(&groups, 3));
+ assert!(!enclosed_by_building_shell(&groups, 9), "out of range");
+ // A dungeon: interior groups end to end, no shell anywhere -> nothing is ever flagged.
+ let cave = [
+ g(true, [0.0, 0.0, 0.0], [10.0, 10.0, 5.0]),
+ g(true, [10.0, 0.0, 0.0], [20.0, 10.0, 5.0]),
+ ];
+ assert!(!enclosed_by_building_shell(&cave, 0));
+ assert!(!enclosed_by_building_shell(&cave, 1));
+ // The slack really is applied: a room whose centre sits a hand's breadth outside its own
+ // shell is still in the building.
+ let tight = [
+ g(true, [0.0, 0.0, 0.0], [10.0, 10.0, 5.0]),
+ g(false, [-5.0, -5.0, -1.0], [52.0, 27.0, 2.3]),
+ ];
+ assert!(enclosed_by_building_shell(&tight, 0), "centre z 2.5 vs shell top 2.3 + 0.5");
+ }
+
/// A quad portal in the x = 5 plane, spanning y 0..2, z 0..3.
fn one_portal() -> (Vec<[f32; 3]>, Vec) {
(
diff --git a/crates/benilla-world/src/lighting/blob.rs b/crates/benilla-world/src/lighting/blob.rs
index 63eedb67..ea54f89e 100644
--- a/crates/benilla-world/src/lighting/blob.rs
+++ b/crates/benilla-world/src/lighting/blob.rs
@@ -20,7 +20,7 @@ use bevy::render::render_resource::{Buffer, BufferDescriptor, BufferUsages};
use bevy::render::renderer::{RenderDevice, RenderQueue};
use super::global_light::{
- commit_raw, light_blob_bytes, pack_model_core_rows, LIGHT_HEADER_ROWS, MAX_POINT_LIGHTS,
+ commit_raw, light_blob_bytes, pack_model_core_rows, LIGHT_HEADER_ROWS, MAX_LIVE_POINT_LIGHTS,
};
use super::prop_probes::prop_probe_region_offset;
use super::sh::prop_probe_coeffs;
@@ -83,11 +83,14 @@ impl LightBlob {
/// Add a point light: position, range, and its diffuse colour **before** the raw commit (the
/// over-gamut commit law is applied here — see [`commit_raw`]).
///
- /// Silently capped at the table's capacity; past it a light is dropped rather than written
- /// over the probe region that follows.
+ /// Silently capped at the table's LIVE capacity; past it a light is dropped rather than written
+ /// over the probe region that follows. MONKEY (ext light k8): that cap is
+ /// [`MAX_LIVE_POINT_LIGHTS`] (255), not the 256-slot buffer size — index 255 is the shaders'
+ /// `EXT_SEL_EMPTY` sentinel, so a light seated there would end every draw unit's selection list
+ /// early. Mirrors the truncation `build_light_data` applies to the scene table.
pub fn point(mut self, pos: Vec3, range: f32, color: [f32; 3]) -> Self {
- if self.points.len() / 2 >= MAX_POINT_LIGHTS {
- warn!("light blob: over {MAX_POINT_LIGHTS} point lights — dropping the rest");
+ if self.points.len() / 2 >= MAX_LIVE_POINT_LIGHTS {
+ warn!("light blob: over {MAX_LIVE_POINT_LIGHTS} point lights — dropping the rest");
return self;
}
let c = commit_raw(color);
@@ -156,6 +159,9 @@ impl LightBlob {
#[cfg(test)]
mod tests {
use super::*;
+ // MONKEY (ext light k8): only the cap test needs the SLOT count now — the builder caps on the
+ // LIVE one.
+ use super::super::global_light::MAX_POINT_LIGHTS;
/// The off-world defaults every producer used to hand-write: spec at the terrain convention,
/// fog off with an inert farclip wall, an empty point table.
@@ -193,16 +199,24 @@ mod tests {
);
}
- /// Past the table's capacity a light is dropped, never written over the probe region that
+ /// Past the table's LIVE capacity a light is dropped, never written over the probe region that
/// follows it in the buffer.
+ ///
+ /// MONKEY (ext light k8): that capacity is 255, ONE SHORT of the 256-slot buffer, because the
+ /// shaders' packed per-draw-unit selection now uses 8 bits per rank and reserves index 255 as
+ /// `EXT_SEL_EMPTY`. The last two assertions pin that relationship: if the cap is ever "tidied"
+ /// back up to the slot count, a 256th light packs as the end-of-list sentinel and every draw
+ /// unit that selected it silently drops the rest of its lights — a bug with no log line.
#[test]
fn the_point_table_stops_at_capacity() {
let mut b = LightBlob::model([0.0; 3], [0.0; 3], Vec3::NEG_Y);
for _ in 0..MAX_POINT_LIGHTS + 32 {
b = b.point(Vec3::ZERO, 1.0, [1.0; 3]);
}
- assert_eq!(b.point_count(), MAX_POINT_LIGHTS);
- assert_eq!(b.points.len(), 2 * MAX_POINT_LIGHTS);
+ assert_eq!(b.point_count(), MAX_LIVE_POINT_LIGHTS);
+ assert_eq!(b.points.len(), 2 * MAX_LIVE_POINT_LIGHTS);
+ assert_eq!(MAX_LIVE_POINT_LIGHTS, MAX_POINT_LIGHTS - 1);
+ assert!(MAX_LIVE_POINT_LIGHTS <= 255, "EXT_SEL_EMPTY is index 255");
}
/// Fog states colour and far independently of whether the shader applies it — the glue
diff --git a/crates/benilla-world/src/lighting/daylight.rs b/crates/benilla-world/src/lighting/daylight.rs
new file mode 100644
index 00000000..b14f1cc6
--- /dev/null
+++ b/crates/benilla-world/src/lighting/daylight.rs
@@ -0,0 +1,2232 @@
+//! MONKEY (daylight fixtures): **the sun, as an interior-lane light, standing in the doorway.**
+//!
+//! THE SEAM. With `interiorLight` on, a WMO's interior groups light from the room law —
+//! `interiorAmbient` + the fixtures that claim the room, and no sun (`static_gx.wgsl`'s
+//! `interior_room_light`, folded through the `1 - exp(-x*interiorExposure)` rolloff). Everything
+//! around a doorway does NOT: an EXTERIOR-class group (the Goldshire inn's shell), and an
+//! EXT/TRANS-class BATCH of an interior group, both keep the reference sky law by day
+//! (`static_gx.wgsl` ~:1242 — `day_w = {TRANS: MOCV alpha, EXT: 1} x sun_w`, blended toward the
+//! reference result). So at the threshold one plank is `ambient + diffuse*N.L` and the next is
+//! `interiorAmbient` — a hard line, exactly where the artists' MOCV bake used to carry the sun
+//! bleed inward and the dynamic lane threw it away. At NIGHT the seam is already gone: `sun_w` is
+//! 0, `day_w` collapses, and both sides are on the room law.
+//!
+//! THE FIX. Give the room law a sun to find: an ordinary interior-lane point light standing in the
+//! doorway, coloured like the sky and calibrated so the room law's result AT THE DOOR equals the
+//! reference sunlit result the batch a plank away is rendering. The seam then closes by
+//! construction on both sides, and the light falls off inward the way daylight does. It rides the
+//! existing machinery whole — `LightRooms` / `LightLitRooms` claims through
+//! [`benilla_formats::room_claim`], `LightLane` interior, `LightReach`, the packer's soft window —
+//! so nothing in the shader, the claim table or the std430 layout changes.
+//!
+//! WHERE A DOORWAY IS. Two seeds, and they are the client's own two populations:
+//! 1. **PORTAL** — a portal whose two sides are one INTERIOR-class group and one EXTERIOR-class
+//! group (or which only one group names at all: a portal straight to the outside). This is
+//! byte-for-byte the set `FixColorVertexAlpha` (`0x6c43d0`, `benilla_formats`'s
+//! `fix_color_vertex_alpha`) whitens a group's MOCV across — "the fade is specific to
+//! interior<->exterior openings". The reference lights its dark transition corridors with
+//! exactly this relation, so it is the authored answer to "which opening lets daylight in",
+//! not a heuristic of ours.
+//! 2. **APERTURE** — an **EXT-class MOBA batch of an INTERIOR group**. That is the other half of
+//! the same fact: the batch class IS the artist saying "this surface takes the full exterior
+//! day/night law", and a batch that says so inside a room is a window or an open side. It is
+//! also the population that draws the seam from the inside (the room's INT-class floor meeting
+//! its own EXT-class window wall), which no portal describes.
+//! 3. **BOUNDARY** (MONKEY (daylight fixtures: boundary)) — the GROUP-LAW seam itself, taken off
+//! the meshes: the vertices an INTERIOR-class group shares (to within [`SEAM_EPS`]) with an
+//! EXTERIOR-class group of the same root. Where the artist split one continuous floor between a
+//! room and the building's shell, the two meshes are stitched along the threshold line with
+//! coincident vertices, and THAT line is the doorway — the one the inn's front door actually
+//! is, and the only one of the three seeds that can see it (there is no portal there and no
+//! EXT-class batch in the entry group; see MEASURED below). It is the exact locus of the seam
+//! rather than an approximation of it: the hard line the owner photographed IS the polygon edge
+//! where the sky law hands over to the room law.
+//!
+//! MEASURED, because the two seeds are not interchangeable (`benilla-extract wmolights` + a
+//! batch-class dump over the same roots, 2026-09-11):
+//! * **Goldshire inn** (Lion's Pride) — 12 groups, 10 portals, and exactly ONE of them is
+//! interior<->exterior (p0, `g0 <-> g11 room04`). Its **front door is not a portal at all**: the
+//! entry group `g3` is 100 % INT-class batches with two portals, both to interior neighbours,
+//! and the shell `g4 upstairs` that holds the threshold planks authors NO portals whatsoever.
+//! The aperture seed reaches its upstairs (`g2/g6/g7/g8/g9` each carry one EXT-class batch —
+//! the windows, diag 5.4-14.9 yd), and the BOUNDARY seed is what reaches the front door: `g3
+//! entry` shares its threshold vertices with the shell `g4`, and that stitch line is the door.
+//! * **Northshire abbey** — 14 groups, 14 portals, two of them interior<->exterior (p10
+//! `g3 Main Hall <-> g5 mainlobby2`, 40.3 yd^2; p13 `g10 Stairs2 <-> g5`, 14.3 yd^2), i.e. the
+//! portal seed's own best case.
+//!
+//! EXCLUSIONS. A daylight fixture is NOT a fire: no [`super::FlameFlicker`] (daylight does not
+//! wobble), no [`super::SyntheticFireLight`] (so `fireLightGain` cannot dim or kill it), and
+//! `benilla_app::torch_shadow` skips it as a caster candidate (`Without`) — it is
+//! a 20 yd-wide area source standing in a hole in a wall, so a point-cube shadow of it would be
+//! wrong in kind, and it would outrank real fixtures for the twelve cube slots.
+
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
+
+use benilla_formats::{PortalGraph, WmoGroupInfo};
+use bevy::prelude::*;
+
+use super::{
+ DynamicInteriors, FireLightGain, LightLane, LightLitRooms, LightReach, SyntheticFireLight,
+ WowLighting,
+};
+
+/// A `PointLight` that IS the daylight standing in one exterior-facing opening. Spawned with the
+/// placement (`terrain_stream::spawn::fx`), re-aimed every frame by [`update_daylight_fixtures`],
+/// and stripped of its `PointLight` outright after dark (see that system for why not an epsilon).
+#[derive(Component, Clone, Copy, Debug)]
+pub struct DaylightFixture {
+ /// The `WmoPortalInstance` this opening belongs to — the placement, for the claim table's head
+ /// word and for the dump.
+ pub instance: Entity,
+ /// The INTERIOR-class group the fixture stands in (it is nudged into it, see [`NUDGE_YD`]).
+ pub group: u16,
+ /// The MOPT portal index for a [`DaylightHow::Portal`] seed; `None` for an aperture seed, which
+ /// is a batch and has no portal id.
+ pub portal: Option,
+ /// Which rule found this opening — the dump's readout, and the reason the two are separable.
+ pub how: DaylightHow,
+ /// The AUTHORED reach (yd), read exactly like a MOLT fixture's `attenuation_end`: the packer
+ /// multiplies it by the live `interiorAttenScale` to get the effective radius `R`.
+ pub reach: f32,
+ /// The CALIBRATION distance (yd) — fixture to the floor fragment directly under the opening.
+ pub cal_d: f32,
+ /// `N.L` at that fragment for a floor normal (the fixture stands `hz` above it).
+ pub cal_ndl: f32,
+}
+
+/// Which of the two rules found an opening — see the module doc's MEASURED note for why both are
+/// needed and what each one reaches.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum DaylightHow {
+ /// An interior<->exterior MOPT portal (the client's own `FixColorVertexAlpha` relation).
+ Portal,
+ /// An EXT-class MOBA batch of an INTERIOR group — a window or an open side.
+ Aperture,
+ /// MONKEY (daylight fixtures: boundary): a run of vertices an interior group SHARES with an
+ /// exterior-class group of the same root — the stitched threshold of a doorway the artist cut
+ /// between two groups without authoring a portal.
+ Boundary,
+ /// MONKEY (portal bleed): an INTERIOR<->INTERIOR portal -- a doorway between two ROOMS, whose
+ /// fixture is not the sun at all but the LIT room's own light carried across the threshold into
+ /// the dark one. See the PORTAL BLEED section near the bottom of this file.
+ Bleed,
+}
+
+impl DaylightHow {
+ /// The dump's one-word tag.
+ pub fn tag(self) -> &'static str {
+ match self {
+ DaylightHow::Portal => "portal",
+ DaylightHow::Aperture => "aperture",
+ DaylightHow::Boundary => "boundary",
+ DaylightHow::Bleed => "bleed",
+ }
+ }
+}
+
+/// One opening, resolved in WMO MODEL space (WoW axes) — everything the spawner needs and nothing
+/// about the world, so the whole selection rule is testable without a placement.
+#[derive(Clone, Copy, Debug)]
+pub struct DaylightSeed {
+ pub group: u16,
+ pub portal: Option,
+ pub how: DaylightHow,
+ /// Already nudged [`NUDGE_YD`] INTO `group` — the spawn position.
+ pub pos: [f32; 3],
+ /// The opening's bounding-box diagonal (yd) — the reach formula's input.
+ pub diag: f32,
+ /// The opening's area (yd^2) — the BUDGET rank key (largest first).
+ pub area: f32,
+ /// The seed's height above the opening's bottom edge (yd) — the calibration geometry.
+ pub hz: f32,
+}
+
+/// How far INTO the interior group an opening's fixture stands (yd). Far enough that the light is
+/// unambiguously on the room's side of the threshold plane (so the claim rule's containment test
+/// puts it in the room rather than in whatever the doorway itself belongs to), and near enough that
+/// the calibration below is measuring the doorway and not the middle of the room.
+const NUDGE_YD: f32 = 0.5;
+/// The widest opening the APERTURE seed will accept (bbox diagonal, yd). An EXT-class batch this
+/// large is not a window: it is a whole open side of a group (the Goldshire inn's kitchen authors
+/// one at 41.5 yd), and a single point light standing in the middle of it would be a sun in the
+/// room rather than daylight at an edge. The portal seed has no such cap — a portal IS an opening
+/// however big, and the reach clamp below bounds it anyway.
+const APERTURE_MAX_DIAG: f32 = 24.0;
+/// …and the narrowest. Below this an EXT-class batch is a trim strip or a sliver of frame, not an
+/// aperture, and seeding one would spend a budget slot on a light nobody can see through.
+const APERTURE_MIN_DIAG: f32 = 1.0;
+/// An APERTURE seed this close to a PORTAL seed (yd) is the same opening described twice — the
+/// window-frame batch beside the doorway it belongs to. The portal wins (it is the authored
+/// relation); the aperture is dropped. Sized like `MODD_SYNTH_DEDUPE`'s argument: comfortably wider
+/// than one doorway's own geometry, far narrower than the gap between two real openings.
+const SEED_DEDUPE_YD: f32 = 4.0;
+/// How high above an opening's SILL the fixture may stand (yd). An opening's centre is the obvious
+/// place for it, and for a doorway (sill on the floor, ~1-2 yd to the middle) that is exactly right
+/// — but a tall arch or a two-storey window puts its centre 4-5 yd up, and then two things go wrong
+/// at once: the pool lands on the wall rather than on the floor where the seam is, and the
+/// calibration cannot be met at all (the inverse-square core has already spent most of its value by
+/// the time the light reaches the ground, so the solve saturates at `I = 1` and the room still
+/// comes out short — MEASURED at 0.68 against a 0.83 target on the inn's `g6` and the abbey's `g9`).
+/// Two yards is chest height on a threshold: low enough to light the floor, high enough that the
+/// down-angle is still steep.
+const SEED_MAX_HZ: f32 = 2.0;
+/// MONKEY (daylight fixtures: boundary): how close two vertices of DIFFERENT groups have to be (yd)
+/// to count as the same stitched point. A WMO's groups are cut out of one authored mesh, so a
+/// shared edge is duplicated with bit-identical `MOVT` floats; 1/20 yd is two orders of magnitude
+/// above float noise and still far below any gap an artist would leave between separate surfaces.
+const SEAM_EPS: f32 = 0.05;
+/// …and how far apart two stitched points may be (yd) and still belong to the SAME opening. A
+/// threshold's vertex run is dense (a plank edge every few inches); a yard is comfortably wider
+/// than that and comfortably narrower than the gap between a building's separate doors.
+const SEAM_CLUSTER_YD: f32 = 1.0;
+/// …overridable once per process by `WOW_DAYLIGHT_SEAM_EPS`, because the right value is a fact
+/// about the CONTENT and the corpus disagrees with itself. The Goldshire inn's shell stitches its
+/// upstairs rooms at the default and its ground floor not at all (`g4 <-> g5`: 0 coincident cells at
+/// 0.05, 59 at 0.25), so a building whose seam the default misses can be re-measured live rather
+/// than by another build. Read once; a bad value clamps back into `0.01..1.0`.
+fn seam_eps() -> f32 {
+ static EPS: std::sync::OnceLock = std::sync::OnceLock::new();
+ *EPS.get_or_init(|| {
+ std::env::var("WOW_DAYLIGHT_SEAM_EPS")
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ .map_or(SEAM_EPS, |v| v.clamp(0.01, 1.0))
+ })
+}
+/// The slack (yd) the exterior group's authored MOGI box is grown by before the pair is considered
+/// at all. The boxes of two stitched groups touch rather than overlap, and an authored box is not
+/// always tight around its own faces (NSabbey g3's bottom floats 1.5 yd above its floor), so a
+/// strict overlap test would drop real pairs.
+const SEAM_BOX_SLACK: f32 = 0.5;
+/// MONKEY (daylight fixtures: boundary): a cluster taller than this (yd) is not a doorway sill. It
+/// is the whole vertical seam where a room's wall is stitched to the shell's — the population that
+/// would otherwise put a light against every party wall in the building. A door frame's own floor
+/// line is centimetres tall and even a stair mouth stays well under three yards.
+const SEAM_MAX_Z_EXTENT: f32 = 3.0;
+/// …and its horizontal run must be at least this wide (yd), or it is a corner stitch rather than an
+/// opening: three or four coincident vertices where two walls and a floor meet.
+const SEAM_MIN_XY_EXTENT: f32 = 1.0;
+/// MONKEY (daylight fixtures: boundary): the vertex budget above which the stitch pass is SKIPPED
+/// (batch vertices summed over the whole root).
+///
+/// The pass is `27 hash probes per unique interior vertex per touching exterior neighbour`, and a
+/// building pays nothing for it -- MEASURED at 6.6 ms for the Goldshire inn (20 470 batch verts)
+/// and 7.4 ms for Northshire abbey (25 206), both including the other two seeds. A CITY does:
+/// `Stormwind.wmo` is 306 groups / 3 088 batches / **761 733** batch verts and the whole selection
+/// costs **162 ms**, synchronously, on the frame its placement spawns.
+///
+/// What makes the guard free rather than a trade is that the city gets nothing from the pass
+/// anyway: Stormwind's eight surviving seeds are four portals and two apertures and two more
+/// portals -- not one boundary cluster ranks into the budget, because a district's authored portals
+/// are 338 yd^2 and a stitched threshold is tens. So the guard costs the city nothing it was
+/// keeping, and buys back the hitch.
+const SEAM_VERT_BUDGET: usize = 300_000;
+/// Openings per placement. The rank is by AREA, so what survives a cap is the building's real
+/// doorways and its largest windows. Eight is a whole facade's worth and still far under the
+/// 256-slot packed table, which a city's placements share.
+pub const MAX_DAYLIGHT_PER_PLACEMENT: usize = 8;
+
+/// 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
+/// its own frame; the clamp keeps a 250 yd^2 cathedral opening from claiming a reach no interior
+/// could absorb.
+pub fn daylight_reach(diag: f32) -> f32 {
+ (1.5 * diag + 4.0).clamp(6.0, 20.0)
+}
+
+/// …and what a daylight fixture actually CARRIES, which is one step removed: the packer windows
+/// every interior fixture at `authored end x interiorAttenScale`, so an opening whose effective
+/// reach is to be [`daylight_reach`] must author `daylight_reach / atten_scale`.
+///
+/// The division is by the cvar's DEFAULT, not its live value, for the same reason
+/// [`benilla_formats::room_claim::CLAIM_ATTEN_SCALE`] exists: the number is baked once at spawn and
+/// the cvar is live, so dividing by the live one would make the knob a no-op on exactly these
+/// fixtures. At the default the effective reach is the formula's own answer; move the cvar and
+/// daylight widens or tightens with every other fixture in the building, which is the behaviour a
+/// global knob should have.
+///
+/// It lands where the corpus does, which is the corroboration: the Goldshire inn's own MOLT
+/// fixtures author 6.97-9.53 yd, and a 5.9 yd doorway of its authors 8.1 here.
+pub fn daylight_authored_reach(diag: f32) -> f32 {
+ daylight_reach(diag) / benilla_formats::room_claim::CLAIM_ATTEN_SCALE
+}
+
+// MONKEY (daylight fixtures): the interior pool's PROFILE, MIRRORED from the block
+// `static_gx.wgsl` and `wow_model.wgsl` carry ("MONKEY (soft falloff)") — the same mirror
+// `benilla_app::torch_shadow` keeps for its caster ranking, and for the same reason: this module
+// has to predict the shader's own answer at the door, and predicting it with a different curve
+// would calibrate against a pool that is not the one being drawn. **Keep the four in sync.**
+const INTERIOR_WRAP: f32 = 0.5;
+const INTERIOR_CORE_YD: f32 = 1.75;
+const INTERIOR_CORE_GAIN: f32 = 1.5;
+const INTERIOR_DIRECT_POW: f32 = 10.0;
+const INTERIOR_FILL_SPAN: f32 = 1.5;
+const INTERIOR_FILL_POW: f32 = 1.0;
+
+/// `(clamp01(1 - (d/r)^p))^2` — `static_gx.wgsl`'s `interior_window`.
+fn interior_window(d: f32, r: f32, p: f32) -> f32 {
+ let w = (1.0 - (d / r.max(1e-4)).clamp(0.0, 1.0).powf(p)).clamp(0.0, 1.0);
+ w * w
+}
+
+/// The shader's DIRECT term at `d` yards on a surface whose normal sees the fixture at `ndl`, for a
+/// fixture of effective radius `r_eff` and committed colour 1 — i.e. the factor the packed colour
+/// is multiplied by. `atten * nl * window`, the three lines of `interior_room_light`.
+fn direct_profile(d: f32, ndl: f32, r_eff: f32) -> f32 {
+ let atten = INTERIOR_CORE_GAIN / (1.0 + (d / INTERIOR_CORE_YD).powi(2));
+ let nl = ((ndl + INTERIOR_WRAP) / (1.0 + INTERIOR_WRAP)).max(0.0);
+ atten * nl * interior_window(d, r_eff, INTERIOR_DIRECT_POW)
+}
+
+/// MONKEY (night fade): a three-line mirror of `global_light`'s private `sun_shadow_strength` — the
+/// same one `blob_shadow` keeps, and for the same reason (the function is three lines and the
+/// alternative is widening a private packer detail into the crate API). 0 at or below the horizon,
+/// 1 by ~12 degrees of elevation, smoothstepped.
+fn sun_shadow_strength(sun_height: f32) -> f32 {
+ let t = (sun_height / 0.208).clamp(0.0, 1.0);
+ t * t * (3.0 - 2.0 * t)
+}
+
+/// Rec.709 luminance — the ONE scalar the calibration matches on. Matching per channel would be
+/// wrong as well as impossible: the room law's hue comes from the fixture's normalised colour and
+/// the reference's from `ambient + diffuse*N.L`, and the two agree in luminance at the door by
+/// construction but not channel for channel (the ambient half is bluer than the diffuse half).
+fn luminance(c: [f32; 3]) -> f32 {
+ 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
+}
+
+/// **THE CALIBRATION.** The packed intensity `I` in `[0, 1]` that makes the room law's result at the
+/// door equal the reference sunlit result there.
+///
+/// The room law is `illum = 1 - exp(-(direct + fill + interiorAmbient) * interiorExposure)` and the
+/// reference is `lit_int_base = clamp(ambient + diffuse*N.L)`, so with `T` the reference's luminance
+/// the required illumination is `X = -ln(1 - T) / exposure`. Both the direct and the FILL term ride
+/// the same committed colour (`c_norm`, and `c_fill` is a half-desaturated mix of it, which
+/// preserves luminance), so both are linear in `I`:
+///
+/// ```text
+/// X = hue_lum * I * (A + interiorFill * W_fill) + interiorAmbient
+/// ```
+///
+/// `I` is clamped to 1 because the shader NORMALISES the committed colour
+/// (`c_norm = c / max(1, max channel)`): past a peak of 1 the extra intensity is divided straight
+/// back out, so an uncalibratable opening saturates rather than silently reading as if it carried
+/// more light than it does. `I = 0` when the room already exceeds the target from its ambient floor
+/// alone.
+pub fn daylight_intensity(
+ target_lum: f32,
+ hue_lum: f32,
+ a_direct: f32,
+ w_fill: f32,
+ knobs: &DynamicInteriors,
+) -> f32 {
+ // `1 - exp(-x)` never reaches 1, so a reference luminance of exactly 1 has no finite solution;
+ // 0.999 is a thousandth of a byte-step short and keeps the log finite.
+ let t = target_lum.clamp(0.0, 0.999);
+ let x = -(1.0 - t).ln() / knobs.exposure.max(1e-3);
+ let denom = hue_lum.max(1e-3) * (a_direct + knobs.fill.max(0.0) * w_fill);
+ if denom <= 1e-6 {
+ return 0.0;
+ }
+ ((x - knobs.ambient) / denom).clamp(0.0, 1.0)
+}
+
+/// The day's own colour and brightness AT A FLOOR, as the reference law renders it — the target the
+/// calibration above matches, and the hue the fixture is given.
+///
+/// Returns `(hue, target_lum, sun_w)`: `hue` is the reference colour renormalised to peak 1 (what
+/// the packed colour carries, so `c_norm` in the shader comes back as `hue * I`), `target_lum` its
+/// luminance, and `sun_w` the day envelope every daylight fixture is scaled by — the SAME
+/// `sun_shadow_strength(celestial_dir.y)` the shader's `day_w` is scaled by, so the two halves of
+/// the threshold fade in and out of the sky law together instead of crossing.
+///
+/// The normal is UP because the seam the feature exists to close is a FLOOR seam — threshold plank
+/// to floorboard. A wall at the same door reads a different `N.L` under both laws, and no single
+/// point source can match a directional one on every normal at once; the floor is the surface the
+/// eye compares across the threshold.
+pub fn daylight_target(light: &WowLighting) -> ([f32; 3], f32, f32) {
+ // `static_gx.wgsl`: `L = -normalize(light_sun.xyz)`, `ndotl = max(dot(N, L), 0)`.
+ let l = -light.sun_dir.normalize_or_zero();
+ let ndotl = l.y.max(0.0);
+ let mut target = [0.0f32; 3];
+ let mut peak = 0.0f32;
+ for i in 0..3 {
+ target[i] = (light.ambient[i] + light.diffuse[i] * ndotl).clamp(0.0, 1.0);
+ peak = peak.max(target[i]);
+ }
+ let hue = if peak > 1e-4 {
+ [target[0] / peak, target[1] / peak, target[2] / peak]
+ } else {
+ [0.0; 3]
+ };
+ (
+ hue,
+ luminance(target),
+ sun_shadow_strength(light.celestial_dir().y),
+ )
+}
+
+/// The AABB of a point cloud in WMO model space — the one measurement both seeds take of their
+/// opening (centre, diagonal, and the bottom edge the calibration height is measured from).
+fn bounds(points: impl Iterator- ) -> Option<([f32; 3], [f32; 3])> {
+ let mut lo = [f32::MAX; 3];
+ let mut hi = [f32::MIN; 3];
+ let mut any = false;
+ for p in points {
+ any = true;
+ for a in 0..3 {
+ lo[a] = lo[a].min(p[a]);
+ hi[a] = hi[a].max(p[a]);
+ }
+ }
+ any.then_some((lo, hi))
+}
+
+/// The two largest extents of a box multiplied — the opening's AREA as the budget ranks it. An
+/// aperture is a slab (a window is thin in its wall normal), so dropping the smallest extent is
+/// exactly "how big is the hole", and it is the same number a portal's own polygon area approaches.
+fn opening_area(lo: [f32; 3], hi: [f32; 3]) -> f32 {
+ let mut e = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
+ e.sort_by(f32::total_cmp);
+ (e[1] * e[2]).max(0.0)
+}
+
+fn diagonal(lo: [f32; 3], hi: [f32; 3]) -> f32 {
+ ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt()
+}
+
+/// Where in an opening the fixture stands, and how far that is above the opening's SILL —
+/// the box centre in plan, but no more than [`SEED_MAX_HZ`] up. See that constant.
+fn seed_point(lo: [f32; 3], hi: [f32; 3]) -> ([f32; 3], f32) {
+ let c = center(lo, hi);
+ let z = c[2].min(lo[2] + SEED_MAX_HZ);
+ ([c[0], c[1], z], (z - lo[2]).max(0.0))
+}
+
+/// MONKEY (daylight fixtures: boundary): where a STITCHED cluster's fixture stands — the cluster's
+/// centre in plan, but a full [`SEED_MAX_HZ`] ABOVE its lowest point rather than at its own height.
+///
+/// The difference from [`seed_point`] is the whole geometry of the case. A portal or a window IS the
+/// opening, so its own centre is where the light comes through. A boundary cluster is the THRESHOLD
+/// — a line of vertices lying on the floor — and the opening is the doorway standing on top of it.
+/// Seeding at the cluster's own height would put a point light in the floorboards: calibrated
+/// correctly at the threshold (the solve does not care) but with a hot ring on the planks and a much
+/// steeper fall inward than a doorway has.
+fn seed_point_above(lo: [f32; 3], hi: [f32; 3]) -> ([f32; 3], f32) {
+ let c = center(lo, hi);
+ ([c[0], c[1], lo[2] + SEED_MAX_HZ], SEED_MAX_HZ)
+}
+
+fn center(lo: [f32; 3], hi: [f32; 3]) -> [f32; 3] {
+ [
+ 0.5 * (lo[0] + hi[0]),
+ 0.5 * (lo[1] + hi[1]),
+ 0.5 * (lo[2] + hi[2]),
+ ]
+}
+
+/// Nudge `from` [`NUDGE_YD`] toward the inside of `g`, preferring the direction `plane_n` when the
+/// opening has an authored plane (a portal): stepping along the doorway's own normal crosses the
+/// threshold squarely, where stepping toward the room's box centre can slide ALONG a wall in an
+/// L-shaped room and leave the fixture in the frame. The sign is taken from the room centre either
+/// way — that is the only thing that says which side is "in".
+fn nudge_inward(from: [f32; 3], g: &WmoGroupInfo, plane_n: Option<[f32; 3]>) -> [f32; 3] {
+ let gc = center(g.bbox_min, g.bbox_max);
+ let to_room = Vec3::new(gc[0] - from[0], gc[1] - from[1], gc[2] - from[2]);
+ let dir = match plane_n.map(|n| Vec3::from(n).normalize_or_zero()) {
+ Some(n) if n.length_squared() > 0.5 && to_room.dot(n).abs() > 1e-3 => {
+ n * to_room.dot(n).signum()
+ }
+ _ => to_room.normalize_or_zero(),
+ };
+ let p = Vec3::new(from[0], from[1], from[2]) + dir * NUDGE_YD;
+ [p.x, p.y, p.z]
+}
+
+/// **THE SELECTION RULE** — every exterior-facing opening of one WMO root, in WMO model space,
+/// ranked by area and capped at [`MAX_DAYLIGHT_PER_PLACEMENT`]. See the module doc for the two
+/// seeds and the corpus measurement behind having both.
+///
+/// `batches` is `(absolute group index, EXT-class?, the batch's model-space positions)` for every
+/// render batch of the root — the aperture seed's input, passed as an iterator so the caller can
+/// hand it the loaded asset's parallel `submeshes` / `submesh_group` arrays without building a
+/// second copy, and a test can hand it three points.
+pub fn daylight_seeds<'a, I>(
+ groups: &[WmoGroupInfo],
+ portals: PortalGraph<'_>,
+ batches: I,
+) -> Vec
+where
+ I: IntoIterator- ,
+{
+ let mut out = daylight_seeds_ranked(groups, portals, batches);
+ out.truncate(MAX_DAYLIGHT_PER_PLACEMENT);
+ out
+}
+
+/// MONKEY (portal bleed): the same rule, RANKED but not capped — the budget is now shared with the
+/// bleed seeds ([`placement_openings`]), and a cap applied here would spend the eight slots on
+/// daylight before a doorway between two rooms was ever compared against them.
+pub fn daylight_seeds_ranked<'a, I>(
+ groups: &[WmoGroupInfo],
+ portals: PortalGraph<'_>,
+ batches: I,
+) -> Vec
+where
+ I: IntoIterator- ,
+{
+ let (want_portals, want_apertures, want_boundaries) = daylight_modes();
+ let mut out: Vec
= Vec::new();
+ let interior_of = |g: u16| groups.get(usize::from(g)).map(|g| g.interior);
+ // Collected because TWO seeds read it: the aperture rule walks it for its EXT-class batches,
+ // and the boundary rule needs every batch of every group, grouped. It is one small Vec of
+ // `(u16, bool, &[_])` per placement per spawn wave -- borrowed slices, no vertex copy.
+ let batches: Vec<(u16, bool, &[[f32; 3]])> = batches.into_iter().collect();
+
+ // --- 1. PORTAL seeds. Both endpoints of a MOPR edge count as "a side": a portal is authored
+ // once per group that owns it, and a portal only ONE group names is a portal to the outside,
+ // whose missing side is exterior by definition (there is no group out there).
+ let mut sides: Vec<(u16, Vec)> = Vec::new();
+ if want_portals {
+ let note = |portal: u16, group: u16, sides: &mut Vec<(u16, Vec)>| {
+ match sides.iter_mut().find(|(p, _)| *p == portal) {
+ Some((_, gs)) => {
+ if !gs.contains(&group) {
+ gs.push(group);
+ }
+ }
+ None => sides.push((portal, vec![group])),
+ }
+ };
+ 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(&[]) {
+ note(r.portal, gi as u16, &mut sides);
+ note(r.portal, r.group, &mut sides);
+ }
+ }
+ }
+ for (portal, gs) in &sides {
+ let mut ins = gs.iter().copied().filter(|g| interior_of(*g) == Some(true));
+ let (Some(inside), None) = (ins.next(), ins.next()) else {
+ continue; // zero interior sides (an exterior-to-exterior seam) or two (a room to a room)
+ };
+ // One interior side is not yet a doorway: the other side must be exterior-CLASS, or absent
+ // entirely (a portal straight to the outside, which only one group names).
+ if gs.len() > 1 && !gs.iter().any(|g| interior_of(*g) == Some(false)) {
+ continue;
+ }
+ let Some(g) = groups.get(usize::from(inside)) else {
+ continue;
+ };
+ let info = portals.infos.get(usize::from(*portal));
+ let verts = info.and_then(|i| {
+ let s = usize::from(i.start_vertex);
+ portals.vertices.get(s..s + usize::from(i.count))
+ });
+ let Some((lo, hi)) = verts.and_then(|v| bounds(v.iter().copied())) else {
+ continue;
+ };
+ let (c, hz) = seed_point(lo, hi);
+ let n = info.map(|i| [i.plane[0], i.plane[1], i.plane[2]]);
+ out.push(DaylightSeed {
+ group: inside,
+ portal: Some(*portal),
+ how: DaylightHow::Portal,
+ pos: nudge_inward(c, g, n),
+ diag: diagonal(lo, hi),
+ // The real polygon area where the record gives one — a doorway is rarely its own box.
+ area: benilla_formats::room_claim::portal_area(&portals, *portal)
+ .unwrap_or_else(|| opening_area(lo, hi)),
+ hz,
+ });
+ }
+ let portal_seeds = out.len();
+
+ // --- 2. APERTURE seeds: an EXT-class batch of an INTERIOR group (see the module doc).
+ if want_apertures {
+ for &(gi, ext_class, positions) in &batches {
+ if !ext_class || interior_of(gi) != Some(true) {
+ continue;
+ }
+ 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;
+ }
+ 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,
+ });
+ }
+ }
+
+ // --- 3. BOUNDARY seeds: the stitched interior<->exterior group seam (see the module doc).
+ // Skipped outright on a city-scale root -- see [`SEAM_VERT_BUDGET`] for the measurement.
+ let root_verts: usize = batches.iter().map(|(_, _, p)| p.len()).sum();
+ if want_boundaries && root_verts <= SEAM_VERT_BUDGET {
+ let authored = out.len();
+ for (group, lo, hi, inward) in boundary_clusters(groups, &batches) {
+ let (c, hz) = seed_point_above(lo, hi);
+ let pos = [
+ c[0] + inward[0] * NUDGE_YD,
+ c[1] + inward[1] * NUDGE_YD,
+ c[2],
+ ];
+ // The authored seeds win a shared opening: a real doorway often has BOTH a portal (or
+ // an EXT batch) and a stitch line, and two lights in one door would double the pool.
+ let dup = out[..authored].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;
+ }
+ let diag = diagonal(lo, hi);
+ out.push(DaylightSeed {
+ group,
+ portal: None,
+ how: DaylightHow::Boundary,
+ pos,
+ diag,
+ // A threshold is a LINE, not a slab: `opening_area`'s two-largest-extents measure
+ // would call a 4 yd doorway sill `4 x 0.3 = 1.2` yd^2 and the budget would drop it
+ // under every window in the building. The opening the line REPRESENTS is about as
+ // tall as it is wide, so its run squared is the honest comparable.
+ area: diag * diag,
+ hz,
+ });
+ }
+ }
+
+ // Largest opening first: what a placement keeps once the shared budget cuts is its real
+ // doorways and its widest windows, and what it drops is the trim.
+ out.sort_by(|a, b| b.area.total_cmp(&a.area));
+ out
+}
+
+/// 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.
+///
+/// **The rule.** A WMO's groups are cut out of one authored mesh, so where a room's floor runs into
+/// the building shell's the two group files carry the SAME vertices -- the threshold is literally
+/// duplicated geometry. For each exterior-class group `E` we hash its vertices onto a [`SEAM_EPS`]
+/// grid; then for each interior-class group `G` whose authored box reaches `E`'s (grown by
+/// [`SEAM_BOX_SLACK`]) we keep the `G` vertices landing within `SEAM_EPS` of one of `E`'s. The
+/// survivors are clustered, and each cluster is one opening.
+///
+/// **The loop order is E-outer on purpose.** One hash per exterior group, reused across every
+/// interior group it touches, so peak memory is the LARGEST exterior group rather than the sum --
+/// Stormwind carries ~8 district shells of six figures of vertices each, and a per-pair hash would
+/// have rebuilt the big one eight times over.
+///
+/// **Clustering is by CELL, not by point.** Single-link over points is `O(k^2)` in the worst case
+/// and a dense threshold is exactly that worst case (a vertex every few inches). Instead the hits
+/// are dropped onto a [`SEAM_CLUSTER_YD`] grid and the occupied CELLS are union-found over their
+/// 27-neighbourhood, which links any two points closer than the cell size and at most its diagonal.
+/// The difference from exact single-link is a yard of slack at the joins, which the extent filters
+/// absorb.
+///
+/// **The filters** are the difference between "the doorway" and "every wall this room shares with
+/// the shell": a cluster must run at least [`SEAM_MIN_XY_EXTENT`] horizontally (or it is a corner
+/// stitch -- three vertices where two walls meet a floor), must stand no more than
+/// [`SEAM_MAX_Z_EXTENT`] tall (or it is the vertical seam of a whole party wall), and must fall in
+/// the same 1..24 yd size band the aperture seed uses.
+fn boundary_clusters(
+ groups: &[WmoGroupInfo],
+ batches: &[(u16, bool, &[[f32; 3]])],
+) -> Vec<(u16, [f32; 3], [f32; 3], [f32; 3])> {
+ // Per-group batch slices + each group's own vertex CENTROID, which is what "inward" points at.
+ // The centroid rather than the authored box centre: a box centre can sit outside an L-shaped
+ // room entirely, and a threshold's inward direction is the one thing this seed cannot get wrong
+ // without putting the light inside the wall.
+ let mut per_group: Vec> = vec![Vec::new(); groups.len()];
+ let mut sum: Vec<([f64; 3], u64)> = vec![([0.0; 3], 0); groups.len()];
+ for &(gi, _, positions) in batches {
+ let Some(slot) = per_group.get_mut(usize::from(gi)) else {
+ continue;
+ };
+ slot.push(positions);
+ let acc = &mut sum[usize::from(gi)];
+ for p in positions {
+ for a in 0..3 {
+ acc.0[a] += f64::from(p[a]);
+ }
+ acc.1 += 1;
+ }
+ }
+ let eps = seam_eps();
+ let key = |p: [f32; 3]| {
+ [
+ (p[0] / eps).round() as i32,
+ (p[1] / eps).round() as i32,
+ (p[2] / eps).round() as i32,
+ ]
+ };
+ // Each INTERIOR group's unique grid keys, built ONCE. A group repeats every vertex across the
+ // batches that use it (the Goldshire inn authors 92 batches over 12 groups), and the walk below
+ // is `27 hash probes per key per exterior neighbour` -- so deduplicating here, rather than
+ // inside that walk, is the difference between paying for vertices and paying for vertex USES.
+ let mut g_keys: Vec> = vec![Vec::new(); groups.len()];
+ for (gi, g) in groups.iter().enumerate() {
+ if !g.interior || per_group[gi].is_empty() {
+ continue;
+ }
+ let mut seen: HashSet<[i32; 3]> = HashSet::new();
+ for slice in &per_group[gi] {
+ for p in *slice {
+ seen.insert(key(*p));
+ }
+ }
+ g_keys[gi] = seen.into_iter().collect();
+ }
+ // Stitched hits per interior group, deduped by grid key (a group repeats each vertex across
+ // every batch that uses it, and a threshold vertex is shared by several).
+ let mut hits: HashMap> = HashMap::new();
+ for (ei, e) in groups.iter().enumerate() {
+ if e.interior || per_group[ei].is_empty() {
+ continue;
+ }
+ let elo = [
+ e.bbox_min[0] - SEAM_BOX_SLACK,
+ e.bbox_min[1] - SEAM_BOX_SLACK,
+ e.bbox_min[2] - SEAM_BOX_SLACK,
+ ];
+ let ehi = [
+ e.bbox_max[0] + SEAM_BOX_SLACK,
+ e.bbox_max[1] + SEAM_BOX_SLACK,
+ e.bbox_max[2] + SEAM_BOX_SLACK,
+ ];
+ let mut cells: HashSet<[i32; 3]> = HashSet::new();
+ for slice in &per_group[ei] {
+ for p in *slice {
+ cells.insert(key(*p));
+ }
+ }
+ for (gi, g) in groups.iter().enumerate() {
+ if !g.interior || per_group[gi].is_empty() {
+ continue;
+ }
+ if (0..3).any(|a| g.bbox_min[a] > ehi[a] || g.bbox_max[a] < elo[a]) {
+ continue; // the two groups are nowhere near each other
+ }
+ let seen = hits.entry(gi as u16).or_default();
+ for k in &g_keys[gi] {
+ // Cheap reject first: a vertex outside the exterior group's grown box cannot be
+ // within `eps` of one of its vertices, and that is most of them.
+ let p = [k[0] as f32 * eps, k[1] as f32 * eps, k[2] as f32 * eps];
+ if (0..3).any(|a| p[a] < elo[a] || p[a] > ehi[a]) {
+ continue;
+ }
+ if seen.contains(k) {
+ continue; // already stitched by another exterior neighbour
+ }
+ let hit = (-1..=1).any(|dx| {
+ (-1..=1).any(|dy| {
+ (-1..=1).any(|dz| cells.contains(&[k[0] + dx, k[1] + dy, k[2] + dz]))
+ })
+ });
+ if hit {
+ seen.insert(*k);
+ }
+ }
+ }
+ }
+
+ let mut out = Vec::new();
+ for (gi, keys) in hits {
+ let (acc, n) = sum[usize::from(gi)];
+ if keys.is_empty() || n == 0 {
+ continue;
+ }
+ let centroid = [
+ (acc[0] / n as f64) as f32,
+ (acc[1] / n as f64) as f32,
+ (acc[2] / n as f64) as f32,
+ ];
+ let points: Vec<[f32; 3]> = keys
+ .iter()
+ .map(|k| [k[0] as f32 * eps, k[1] as f32 * eps, k[2] as f32 * eps])
+ .collect();
+ for (lo, hi) in cluster_boxes(&points) {
+ let d = diagonal(lo, hi);
+ if !(APERTURE_MIN_DIAG..=APERTURE_MAX_DIAG).contains(&d) {
+ continue;
+ }
+ if hi[2] - lo[2] > SEAM_MAX_Z_EXTENT {
+ continue;
+ }
+ let xy = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2)).sqrt();
+ if xy < SEAM_MIN_XY_EXTENT {
+ continue;
+ }
+ let c = center(lo, hi);
+ // Horizontal only: the room's centroid is usually a storey up or down from a threshold,
+ // and a light nudged along that slope would leave the floor it is meant to be lighting.
+ let mut inward = Vec3::new(centroid[0] - c[0], centroid[1] - c[1], 0.0);
+ if inward.length_squared() < 1e-6 {
+ inward = Vec3::X; // degenerate (the centroid is over the door) -- any push will do
+ }
+ let inward = inward.normalize();
+ out.push((gi, lo, hi, [inward.x, inward.y, 0.0]));
+ }
+ }
+ // Deterministic across runs: a `HashMap` walk is not, and the budget TRUNCATES, so an unstable
+ // order would give the same building different doorways on different launches.
+ out.sort_by(|a, b| {
+ a.0.cmp(&b.0)
+ .then(a.1[0].total_cmp(&b.1[0]))
+ .then(a.1[1].total_cmp(&b.1[1]))
+ .then(a.1[2].total_cmp(&b.1[2]))
+ });
+ out
+}
+
+/// Single-link clustering of `points` at [`SEAM_CLUSTER_YD`], by grid cell (see
+/// [`boundary_clusters`] for why by cell): one `(lo, hi)` box per cluster.
+fn cluster_boxes(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] / SEAM_CLUSTER_YD).floor() as i32,
+ (p[1] / SEAM_CLUSTER_YD).floor() as i32,
+ (p[2] / SEAM_CLUSTER_YD).floor() as i32,
+ ]
+ };
+ let mut index: HashMap<[i32; 3], usize> = HashMap::new();
+ let mut cells: Vec<[i32; 3]> = Vec::new();
+ let mut of_point: Vec = Vec::with_capacity(points.len());
+ for p in points {
+ let c = cell(*p);
+ let i = *index.entry(c).or_insert_with(|| {
+ cells.push(c);
+ cells.len() - 1
+ });
+ of_point.push(i);
+ }
+ let mut parent: Vec = (0..cells.len()).collect();
+ for (i, c) in cells.iter().enumerate() {
+ for dx in -1..=1 {
+ for dy in -1..=1 {
+ for dz in -1..=1 {
+ if let Some(&j) = index.get(&[c[0] + dx, c[1] + dy, c[2] + dz]) {
+ let (a, b) = (find(&mut parent, i), find(&mut parent, j));
+ if a != b {
+ parent[a] = b;
+ }
+ }
+ }
+ }
+ }
+ }
+ let mut boxes: HashMap = HashMap::new();
+ for (p, ci) in points.iter().zip(of_point.iter()) {
+ let root = find(&mut parent, *ci);
+ let e = boxes.entry(root).or_insert((*p, *p));
+ for a in 0..3 {
+ e.0[a] = e.0[a].min(p[a]);
+ e.1[a] = e.1[a].max(p[a]);
+ }
+ }
+ let mut out: Vec<([f32; 3], [f32; 3])> = boxes.into_values().collect();
+ // Same determinism argument as `boundary_clusters`.
+ 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
+}
+
+impl DaylightSeed {
+ /// The component this seed becomes — the calibration geometry resolved once, at spawn.
+ ///
+ /// `cal_d` is the distance from the fixture to the floor fragment directly under the opening:
+ /// [`NUDGE_YD`] horizontally (the fixture stands that far inside the threshold) and `hz`
+ /// vertically (the opening's centre sits that far above its own sill). `cal_ndl` is that
+ /// fragment's `N.L` for a floor normal, which is `hz / cal_d` — so a low sill (a doorway,
+ /// `hz ~ 1.1`) reads nearly straight down and a high window reads more grazing, exactly as the
+ /// geometry says.
+ pub fn fixture(&self, instance: Entity) -> DaylightFixture {
+ let hz = self.hz.max(0.25);
+ let cal_d = (NUDGE_YD * NUDGE_YD + hz * hz).sqrt().max(1e-3);
+ DaylightFixture {
+ instance,
+ group: self.group,
+ portal: self.portal,
+ how: self.how,
+ reach: daylight_authored_reach(self.diag),
+ cal_d,
+ cal_ndl: (hz / cal_d).clamp(0.0, 1.0),
+ }
+ }
+}
+
+/// `WOW_DAYLIGHT` — the feature's own kill switch, read once. `0`/`off` disables it entirely;
+/// `portals`, `apertures` and `boundaries` each keep one seed alone. A plain env var rather than a
+/// cvar because the cvar table is another agent's file this round; the A/B it buys ("is THIS seam a
+/// portal opening, a window, or a stitched threshold?") is worth more than the tidier home.
+pub fn daylight_modes() -> (bool, bool, bool) {
+ static MODES: std::sync::OnceLock<(bool, bool, bool)> = std::sync::OnceLock::new();
+ *MODES.get_or_init(|| match std::env::var("WOW_DAYLIGHT").ok().as_deref() {
+ Some("0" | "off" | "false") => (false, false, false),
+ Some("portals") => (true, false, false),
+ Some("apertures") => (false, true, false),
+ Some("boundaries") => (false, false, true),
+ _ => (true, true, true),
+ })
+}
+
+/// Re-aim every daylight fixture at this frame's sun: the colour, the calibrated intensity, and the
+/// day/night existence switch.
+///
+/// Scheduled in `PostUpdate` **before** `global_light::classify_light_lanes`, which is itself
+/// chained before the packer `build_light_data` — so a fixture is packed on THIS frame's sun rather
+/// than last frame's. It needs no transform-propagation ordering of its own (unlike the packer): a
+/// daylight fixture never moves, and nothing here reads a `GlobalTransform`.
+///
+/// **After dark the `PointLight` is REMOVED, not dimmed.** The packer has no zero-intensity skip —
+/// it packs any `PointLight` within 300 yd — so an epsilon fixture would still hold one of the 256
+/// table slots and still be walked by every interior fragment in its fill radius, all night, to
+/// contribute nothing. Removing the component takes it out of the packer's query outright and costs
+/// two archetype moves per game day. Everything else about the entity (its claims, its lane, its
+/// reach) is untouched, so dawn re-inserts and the claim set is exactly the one built at spawn.
+pub fn update_daylight_fixtures(
+ mut commands: Commands,
+ light: Res,
+ knobs: Res,
+ // MONKEY (portal bleed): `Without` -- a bleed fixture wears `DaylightFixture` as
+ // its marker (for the packer's `interiorGain` skip and `torch_shadow`'s caster refusal) but is
+ // aimed by `update_bleed_fixtures` instead, from the room next door rather than from the sun.
+ // The exclusion is also what makes the two systems' `&mut PointLight` access disjoint.
+ mut fixtures: Query<
+ (
+ Entity,
+ &DaylightFixture,
+ Option<&LightReach>,
+ Option<&mut PointLight>,
+ ),
+ Without,
+ >,
+ time: Res,
+ mut last_dump: Local,
+) {
+ let (hue, target_lum, sun_w) = daylight_target(&light);
+ // The room lane being off is the feature being off: with `interiorLight 0` every interior
+ // surface is back on its MOCV bake, nothing reads the interior half of the point table, and a
+ // daylight fixture would be a slot spent on a lane nobody is listening to.
+ let day = knobs.enabled && sun_w > 1e-3 && target_lum > 1e-3;
+ let hue_lum = luminance(hue);
+ let mut lit: Vec<(Entity, DaylightFixture, f32)> = Vec::new();
+ for (e, fx, reach, pl) in &mut fixtures {
+ let intensity = if day {
+ // The EFFECTIVE radius the shader will window this fixture with — the same
+ // `interior_reach(authored, interiorAttenScale)` the packer folds into the colour row's
+ // `.w`, so the profile predicted here is the profile drawn.
+ let r_eff = super::interior_reach(reach.map_or(fx.reach, |r| r.0), knobs.atten_scale);
+ let a = direct_profile(fx.cal_d, fx.cal_ndl, r_eff);
+ let w_fill = interior_window(fx.cal_d, INTERIOR_FILL_SPAN * r_eff, INTERIOR_FILL_POW);
+ daylight_intensity(target_lum, hue_lum, a, w_fill, &knobs) * sun_w
+ } else {
+ 0.0
+ };
+ match pl {
+ Some(mut pl) if intensity > 1e-4 => {
+ pl.color = Color::linear_rgb(hue[0], hue[1], hue[2]);
+ pl.intensity = 4.0 * std::f32::consts::PI * intensity;
+ }
+ Some(_) => {
+ commands.entity(e).remove::();
+ }
+ None if intensity > 1e-4 => {
+ commands
+ .entity(e)
+ .insert(daylight_point_light(hue, intensity));
+ }
+ None => {}
+ }
+ if intensity > 1e-4 {
+ lit.push((e, *fx, intensity));
+ }
+ }
+ // `WOW_POINTS_DUMP`: the DAYLIGHT rows. They ride their own block rather than the packer's
+ // because the packer is another agent's file this round — the numbers are the same ones,
+ // printed one stage earlier, and a DAY-only block is itself the readout that the night switch
+ // above is working.
+ if let Some(mode) = std::env::var_os("WOW_POINTS_DUMP") {
+ let every = if mode == *"frame" { 0.0 } else { 1.0 };
+ let now = time.elapsed_secs_f64();
+ if now - *last_dump >= every {
+ *last_dump = now;
+ if !lit.is_empty() {
+ eprintln!(
+ "[daylight] {} DAYLIGHT fixture(s) lit — sun_w {sun_w:.3}, target lum {target_lum:.3}, hue [{:.3},{:.3},{:.3}]",
+ lit.len(),
+ hue[0],
+ hue[1],
+ hue[2],
+ );
+ for (e, fx, i) in lit.iter().take(8) {
+ eprintln!(
+ " DAYLIGHT {e} inst {} g{} {}{} reach {:5.2} cal d {:4.2} N.L {:4.2} I {i:.4}",
+ fx.instance,
+ fx.group,
+ fx.how.tag(),
+ fx.portal.map_or(String::new(), |p| format!(" p{p}")),
+ fx.reach,
+ fx.cal_d,
+ fx.cal_ndl,
+ );
+ }
+ }
+ }
+ }
+}
+
+/// The `PointLight` a daylight fixture carries. The same 4*PI convention every other authored
+/// source uses (`terrain_stream::spawn::fx::point_light`): the packer recovers `intensity/(4*PI)`
+/// and multiplies the linear colour by it, so the committed colour is exactly `hue * I` — which is
+/// what the calibration above solved for, and what the shader's `c_norm` reads back unchanged while
+/// `I <= 1`.
+pub fn daylight_point_light(hue: [f32; 3], intensity: f32) -> PointLight {
+ PointLight {
+ color: Color::linear_rgb(hue[0], hue[1], hue[2]),
+ intensity: 4.0 * std::f32::consts::PI * intensity.max(0.0),
+ // The 48 yd CANDIDACY constant every source packs — not a reach (that rides `LightReach`).
+ range: 48.0,
+ shadows_enabled: false,
+ ..default()
+ }
+}
+
+/// The claim set of a daylight fixture — the SAME rule every MOLT fixture's claims are built by
+/// ([`benilla_formats::room_claim`]), seeded with the interior group it stands in as its MOLR
+/// referrer. Spelled here rather than at the spawn site so the reach the claim's portal hop is
+/// measured against can never drift from the reach the packer windows the fixture with.
+pub fn daylight_claims(
+ groups: &[WmoGroupInfo],
+ portals: PortalGraph<'_>,
+ seed: &DaylightSeed,
+) -> Vec {
+ benilla_formats::room_claim::room_claims(
+ groups,
+ portals,
+ seed.pos,
+ benilla_formats::room_claim::claim_reach(daylight_authored_reach(seed.diag)),
+ &[seed.group],
+ )
+}
+
+/// The one-room visibility claim a daylight fixture carries — it exists while its own room is
+/// drawn, exactly like the MOLT fixture standing in that room (`LightRooms`, decision 0689).
+pub fn daylight_rooms(instance: Entity, group: u16) -> super::LightRooms {
+ super::LightRooms::new(crate::wmo_portal::WmoGroupVis {
+ instance,
+ groups: Arc::from([group].as_slice()),
+ })
+}
+
+/// A daylight fixture's lane is SETTLED interior, stamped at spawn rather than rayed.
+///
+/// `classify_light_lanes`'s down-ray would usually agree — the fixture stands a half-yard inside an
+/// interior group — but "usually" is the problem: it stands in a DOORWAY, the one place in a
+/// building where the surface under a light can belong to the exterior shell (the threshold plank),
+/// and a fixture flipped to the exterior lane would stop lighting the room it was built for and
+/// start pooling on the street instead. The seed already knows the answer from the authored group
+/// class, so it states it and the classifier skips the entity ([`super::LightLane::SETTLED`]).
+pub fn daylight_lane() -> super::LightLane {
+ super::LightLane {
+ interior: true,
+ generation: super::LightLane::SETTLED,
+ }
+}
+
+// ===========================================================================================
+// MONKEY (portal bleed): **the LIT room next door, glowing in the doorway.**
+//
+// THE BUG. Three seeds above stand the SUN in an opening, and every one of them needs the opening
+// to face OUTSIDE (a portal to an exterior group, an EXT-class batch, a stitch to the shell). A
+// room whose only doorways lead to other ROOMS gets nothing from them — and with `interiorDaylight`
+// defaulted to 0 (the director's call: by day light comes in at doorways and windows, not through
+// the walls) it gets nothing from the enclosed day floor either. What is left is `interiorAmbient`.
+//
+// MEASURED, the Lion's Pride Inn's front vestibule (`benilla-extract wmolights`, 2026-09-12). It is
+// group `g0`, the WMO's only TRANS-batch group, and it hangs off the end of a CHAIN of doorways:
+// porch `g11` --p0--> `g0` --p1--> `g1` --p2--> the hall `g5`, where the ten MOLT fixtures live.
+// No fixture CONTAINS-claims g0 or g1; both rooms are lit, if at all, by portal hops. And the hops
+// have run out by the time they reach p1: the nearest fixture claiming g1 (L2, 9.0 yd from p2)
+// carries 2.6 yd of unspent reach past p2's own 3.2 yd of doorway slack, and p1's centre is 5.8 yd
+// from p2's — so every claim weight at p1 is EXACTLY 0. The vestibule's budget there is the bare
+// `interiorAmbient x interiorGain` = 0.0075, i.e. `1 - exp(-0.0075 x 2.5)` = **0.019 x tex**, while
+// the `trans_night_floor` bake share renders the same planks at **0.324 x tex**: a flat grey band
+// seventeen times its own neighbour, which is what the owner photographed at 20:50 and 23:59.
+//
+// THE FIX, and it is the owner's own principle stated as a light: a doorway between two rooms
+// TRANSMITS. Stand an ordinary interior-lane point light IN such a portal, claiming both rooms, and
+// give it every frame [`BLEED_K`] times the irradiance the BRIGHTER side has at the portal plane.
+// Each room then reads a fraction of its neighbour AT the threshold (so nothing steps across the
+// doorway) and falls off inward on the room lane's own profile (so it is a doorway's spill, not a
+// second candle).
+//
+// **IN the doorway, not on the dark side of it, and that is the whole direction rule.** The obvious
+// design stands the fixture inside the darker room and points it at the lit one — but which room is
+// darker is not a fact about the building, it is a fact about the HOUR. At midnight the inn's
+// vestibule takes light from the hall through p1; at noon the porch's own DAYLIGHT fixture lights
+// the vestibule through p0 and the flow reverses, g0 into g1, which is exactly the owner's "by day
+// only doorways admit light". A fixture standing on one side would have to be moved, and a light
+// that moves through a wall when a cloud passes is worse than the seam it fixes. An APERTURE has no
+// side: it sits in the hole, it is claimed by both rooms, and only its INTENSITY and COLOUR move —
+// it carries whichever neighbour is brighter this frame, in that neighbour's own hue, and the
+// direction reverses without anything moving. The cost is that the brighter room also gains a
+// doorway glow (bounded at `BLEED_K` of what it already has there, falling off within a yard or
+// two), which is what a lit doorway looks like anyway.
+//
+// IT IS THE SAME ENTITY SHAPE as a daylight fixture, and it carries [`DaylightFixture`] as its
+// marker for exactly that reason — three exclusions ride on that component and none of them wants
+// re-deciding here: `benilla_app::torch_shadow` refuses it as a cube-shadow caster
+// (`Without` — a 6 yd-wide doorway is not a point source), the packer skips
+// `interiorGain` on it (`Has`, and this module folds that gain into the TARGET
+// already, so packing it again would dim the doorway twice), and it is neither a
+// [`super::SyntheticFireLight`] (no `fireLightGain` over it) nor a [`super::FlameFlicker`] (a
+// doorway does not wobble). [`BleedFixture`] beside it carries what is bleed-specific — the two
+// rooms and the portal plane — and doubles as the query filter that keeps the two per-frame
+// systems' `PointLight` access disjoint.
+//
+// NO FEEDBACK, ONE LEVEL. A bleed must not read another bleed as if it were a candle, or a run of
+// doorways would ring up without bound. But refusing outright breaks the very case this exists for:
+// the inn's vestibule is TWO doorways from the hall, so a p1 bleed that cannot see the p2 bleed sees
+// nothing at all. So [`update_bleed_fixtures`] runs the evaluation TWICE in one system over its own
+// local arrays: pass A lights every bleed from the REAL fixtures only, pass B re-lights each one
+// from the real fixtures plus the PASS-A value of any OTHER bleed standing in the room being
+// sampled. That is exactly one level of chaining, it resolves inside a single frame (no cross-frame
+// accumulation), and the order the bleeds happen to be queried in cannot change the answer.
+// ===========================================================================================
+
+/// The fraction of a neighbour's irradiance a doorway transmits, measured at the portal plane. A
+/// real opening passes most of the light that reaches it (it is a hole, not a filter) and loses the
+/// rest to the frame, the door leaf and the solid angle the wall subtends — so this is deliberately
+/// near, but under, 1: at the threshold the dark room reads a little dimmer than the lit one, which
+/// is the direction a threshold reads in life.
+pub const BLEED_K: f32 = 0.6;
+
+/// MONKEY (portal bleed): the bleed-specific half of the fixture — everything [`DaylightFixture`]
+/// has no field for, plus the marker that keeps the two per-frame systems' `PointLight` access
+/// disjoint (`update_daylight_fixtures` filters `Without`).
+#[derive(Component, Clone, Copy, Debug)]
+pub struct BleedFixture {
+ /// The two interior groups this doorway joins. Both are sampled every frame and the BRIGHTER one
+ /// is what the fixture carries — see the aperture argument in the block above.
+ pub sides: [u16; 2],
+ /// The portal's centre in BEVY WORLD space: the plane both sides are measured at, and the point
+ /// the calibration matches. World rather than model space for [`super::ClaimFade::center`]'s own
+ /// reason — the per-frame evaluation has world-space fixture positions and nothing else.
+ pub probe: Vec3,
+}
+
+/// One interior<->interior doorway, resolved in WMO MODEL space — the bleed twin of
+/// [`DaylightSeed`], and deliberately a separate type: a daylight seed's whole payload is the
+/// CALIBRATION geometry (`hz`, the floor fragment under the opening), and a bleed seed's is the two
+/// ROOMS and the plane between them.
+#[derive(Clone, Copy, Debug)]
+pub struct BleedSeed {
+ /// The two interior groups the portal joins.
+ pub sides: [u16; 2],
+ pub portal: u16,
+ /// The portal's centre, z-clamped exactly like [`seed_point`] so a two-storey arch stands near
+ /// its sill rather than at its apex. This is both the spawn position and the probe point: the
+ /// fixture IS the opening.
+ pub pos: [f32; 3],
+ pub diag: f32,
+ /// The portal polygon's area — the shared budget's rank key.
+ pub area: f32,
+}
+
+/// Every portal of one root whose BOTH sides are interior-class groups, ranked by area — the bleed
+/// seed's whole rule, and it needs nothing but the group table and the portal graph.
+///
+/// A portal only one group names is excluded by construction (its missing side is the outside, which
+/// is the DAYLIGHT portal seed's population, not this one), and so is any portal touching an
+/// exterior-class group: the porch outside the inn's front door is dark at night and lit by the sky
+/// by day, and neither is a room whose candles want carrying inward. The one size filter is
+/// [`APERTURE_MIN_DIAG`] — below it a "portal" is a sliver an artist left between two halves of one
+/// room, and a light in it would be a light inside a wall.
+pub fn bleed_seeds(groups: &[WmoGroupInfo], portals: PortalGraph<'_>) -> Vec {
+ if !bleed_enabled() {
+ return Vec::new();
+ }
+ let mut sides: Vec<(u16, Vec)> = Vec::new();
+ let note = |portal: u16, group: u16, sides: &mut Vec<(u16, Vec)>| {
+ match sides.iter_mut().find(|(p, _)| *p == portal) {
+ Some((_, gs)) => {
+ if !gs.contains(&group) {
+ gs.push(group);
+ }
+ }
+ None => sides.push((portal, vec![group])),
+ }
+ };
+ 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(&[]) {
+ note(r.portal, gi as u16, &mut sides);
+ note(r.portal, r.group, &mut sides);
+ }
+ }
+ let mut out = Vec::new();
+ for (portal, gs) in &sides {
+ if gs.len() != 2 {
+ continue;
+ }
+ if !gs
+ .iter()
+ .all(|g| groups.get(usize::from(*g)).is_some_and(|g| g.interior))
+ {
+ continue;
+ }
+ let info = portals.infos.get(usize::from(*portal));
+ let verts = info.and_then(|i| {
+ let s = usize::from(i.start_vertex);
+ portals.vertices.get(s..s + usize::from(i.count))
+ });
+ let Some((lo, hi)) = verts.and_then(|v| bounds(v.iter().copied())) else {
+ continue;
+ };
+ let diag = diagonal(lo, hi);
+ if diag < APERTURE_MIN_DIAG {
+ continue;
+ }
+ let area = benilla_formats::room_claim::portal_area(&portals, *portal)
+ .unwrap_or_else(|| opening_area(lo, hi));
+ // …and a portal at or above [`benilla_formats::room_claim::SPLIT_PORTAL_MIN_AREA`] is NOT a
+ // doorway. It is a room the artist CUT IN HALF for rendering, and the claim rule already
+ // says so: `ClaimHow::Split` gives a fixture on either side a HARD claim on the other, so
+ // both halves are lit by the same candles at full weight and there is nothing to transmit.
+ // A bleed there would stand a light in the middle of one room and add `BLEED_K` of that
+ // room's own light back to it. MEASURED, this is the whole difference between "the Goldshire
+ // inn's doorways" and "Northshire abbey changed character": the abbey's four biggest portals
+ // are 252.6 / 50.9 / 50.7 / 47.1 yd^2 splits of its nave and would have taken half the
+ // building's budget, while its real doorways are the 14-16 yd^2 ones underneath them. The
+ // inn's p8 (95.9) and p3 (35.1) go the same way, which is what lets BOTH halves of its
+ // entrance chain (p2 at 20.5 and p1 at 17.3) into the eight.
+ if area >= benilla_formats::room_claim::SPLIT_PORTAL_MIN_AREA {
+ continue;
+ }
+ let (pos, _) = seed_point(lo, hi);
+ out.push(BleedSeed {
+ sides: [gs[0], gs[1]],
+ portal: *portal,
+ pos,
+ diag,
+ area,
+ });
+ }
+ // Deterministic, largest first — the shared budget TRUNCATES, so an unstable order would give
+ // the same building different doorways on different launches.
+ out.sort_by(|a, b| b.area.total_cmp(&a.area).then(a.portal.cmp(&b.portal)));
+ out
+}
+
+/// **THE ONE SELECTION ENTRY POINT** a placement calls: every opening of one WMO root that gets a
+/// fixture, daylight and bleed together, sharing the [`MAX_DAYLIGHT_PER_PLACEMENT`] budget and
+/// ranked against each other by AREA.
+///
+/// One budget rather than two because the resource being spent is the same one — slots in the packed
+/// point table, and fragments walked by every interior pixel of the building — and because the
+/// ranking question is the same question: which of this building's openings matter. A city's district
+/// portals (338 yd^2) still take every slot; the Lion's Pride Inn's eight begin with the hall doorway
+/// p2 (20.5, bleed), its porch door p0 (17.3, daylight) and the vestibule doorway p1 (17.3, bleed) —
+/// i.e. both halves of its entrance chain fit, because the two portals that used to outrank them
+/// (95.9 and 35.1 yd^2) are SPLIT cuts the bleed seed now refuses.
+///
+/// Ties go to DAYLIGHT (`d < b` is what promotes a bleed): the sun standing in an opening is the
+/// bigger visual fact, and the inn's p0/p1 pair is exactly a tie at 17.34 yd^2.
+pub fn placement_openings<'a, I>(
+ groups: &[WmoGroupInfo],
+ portals: PortalGraph<'_>,
+ batches: I,
+) -> (Vec, Vec)
+where
+ I: IntoIterator- ,
+{
+ let day = daylight_seeds_ranked(groups, portals, batches);
+ let bleed = bleed_seeds(groups, portals);
+ // Two descending-by-area lists merged into one budget. A plain interleave rather than a
+ // concat-and-sort so each rule's own tie-break survives (both are already deterministic).
+ let (mut di, mut bi) = (0usize, 0usize);
+ let (mut day_keep, mut bleed_keep) = (Vec::new(), Vec::new());
+ while day_keep.len() + bleed_keep.len() < MAX_DAYLIGHT_PER_PLACEMENT {
+ match (day.get(di).map(|s| s.area), bleed.get(bi).map(|c| c.area)) {
+ (Some(d), Some(b)) if d < b => {
+ bleed_keep.push(bleed[bi]);
+ bi += 1;
+ }
+ (Some(_), _) => {
+ day_keep.push(day[di]);
+ di += 1;
+ }
+ (None, Some(_)) => {
+ bleed_keep.push(bleed[bi]);
+ bi += 1;
+ }
+ (None, None) => break,
+ }
+ }
+ (day_keep, bleed_keep)
+}
+
+/// `entry * (1 - smoothstep(0, radius, max(|P - center| - slack, 0)))` — the CPU mirror of
+/// `static_gx.wgsl`'s `interior_room_admits` fade arm. `radius == 0` is the hard claim.
+fn claim_fade_weight(radius: f32, slack: f32, entry: f32, center: Vec3, p: Vec3) -> f32 {
+ if radius <= 0.0 {
+ return 1.0;
+ }
+ let t = (((p - center).length() - slack).max(0.0) / radius.max(1e-4)).clamp(0.0, 1.0);
+ entry * (1.0 - t * t * (3.0 - 2.0 * t))
+}
+
+/// `INTERIOR_CORE_GAIN / (1 + (d/INTERIOR_CORE_YD)^2)` — the shader's soft-core inverse square, split
+/// out of [`direct_profile`] because the bleed lane measures IRRADIANCE (no surface, so no `N.L`)
+/// while the daylight calibration measures a floor fragment.
+fn core_atten(d: f32) -> f32 {
+ INTERIOR_CORE_GAIN / (1.0 + (d / INTERIOR_CORE_YD).powi(2))
+}
+
+/// `c / max(1, max channel)` — `static_gx.wgsl`'s `c_norm`. The table commits raw over-gamut colour
+/// x intensity and the shader normalises it per fixture, so anything predicting the shader's answer
+/// has to normalise it too.
+fn commit_norm(c: [f32; 3]) -> [f32; 3] {
+ let peak = c[0].max(c[1]).max(c[2]).max(1.0);
+ [c[0] / peak, c[1] / peak, c[2] / peak]
+}
+
+/// `WOW_BLEED=0` — the feature's own kill switch, read once, for the same A/B reason
+/// [`daylight_modes`] has one. Anything else (absent included) leaves it on.
+fn bleed_enabled() -> bool {
+ static ON: std::sync::OnceLock
= std::sync::OnceLock::new();
+ *ON.get_or_init(|| {
+ !matches!(
+ std::env::var("WOW_BLEED").ok().as_deref(),
+ Some("0" | "off" | "false")
+ )
+ })
+}
+
+impl BleedSeed {
+ /// The shared component this seed becomes. `cal_d`/`cal_ndl` state the BLEED lane's calibration
+ /// rather than the daylight lane's: the match is made at the PORTAL PLANE, [`NUDGE_YD`] from the
+ /// fixture, as irradiance (`N.L = 1`) — a doorway has no one surface normal, and the wrapped
+ /// Lambert spans only 1/3..1 anyway, so choosing a normal would add a factor to both sides of the
+ /// ratio and information to neither. `group` is the first side, for the dump and the claim head;
+ /// the pair that matters lives on [`BleedFixture::sides`].
+ pub fn fixture(&self, instance: Entity) -> DaylightFixture {
+ DaylightFixture {
+ instance,
+ group: self.sides[0],
+ portal: Some(self.portal),
+ how: DaylightHow::Bleed,
+ reach: daylight_authored_reach(self.diag),
+ cal_d: NUDGE_YD,
+ cal_ndl: 1.0,
+ }
+ }
+
+ /// The claim set — the SAME rule every other fixture's is built by, seeded with BOTH rooms as
+ /// its MOLR referrers, which is the aperture argument spelled in claim terms: the doorway is not
+ /// in one room, it is in the wall between two, and it lights both. Groups one hop beyond either
+ /// come in soft, exactly as they do for a candle standing in the room.
+ pub fn claims(
+ &self,
+ groups: &[WmoGroupInfo],
+ portals: PortalGraph<'_>,
+ ) -> Vec