MONKEY: lighting round 3 — daylight fixtures, spell lights, shadow quality, 8-light exterior selection

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 <group>; missing-model placements retire instead of
pinning the loading screen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-13 14:38:53 +01:00
co-authored by Claude Fable 5.1
parent 1119e039d4
commit f8b05f25ae
35 changed files with 6920 additions and 446 deletions
+93 -7
View File
@@ -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
@@ -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<f32>),
Integer(RangeInclusive<f32>),
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);
}
});
}
}
+12 -1
View File
@@ -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<crate::net::PingShared>,
dropped: Res<crate::net::DroppedOpcodes>,
mut weather_state: Option<ResMut<benilla_world::weather::WeatherState>>,
mut world: WorldReadout,
(mut world, video, mut script): (
WorldReadout,
Res<crate::video::VideoConfig>,
Option<NonSendMut<benilla_ui::script::UiScript>>,
),
) -> 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| {
@@ -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;
+17
View File
@@ -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
@@ -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<Entity> {
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,
}
}
@@ -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).
@@ -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;
}
}
@@ -217,6 +217,183 @@ pub(crate) fn decay_span(anims: Option<&ModelAnimations>) -> Option<f32> {
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<bool> = 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<Time>,
mut lights: Query<(&mut SpellLight, &mut PointLight, Option<&ChildOf>)>,
reaped: Query<Has<FxDecay>>,
) {
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(),
@@ -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
@@ -2037,6 +2037,7 @@ mod tests {
bone_pivot: [0.0; 3],
synthetic: false,
flame: false,
spell: None,
}
}
+499 -78
View File
@@ -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<LightOwner>,
/// 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<LightOwner>);
/// 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<LightOwner>);
/// 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<Candidate>, 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<Vec3>, 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<usize>, Vec<usize>) {
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<WowModelMaterial>>,
// 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<BillboardCard>,
>,
/// 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<LightLitRooms>` rides the tuple to reproduce the other half of the
// retired `Or`, and `Has<HeldLight>` 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<LightRooms>, Has<LightLitRooms>, Has<SyntheticFireLight>, Has<ChildOf>,
Has<HeldLight>, Option<&crate::entities::CarriedLightMotion>),
Without<ShadowProxyLight>,
Has<HeldLight>, 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<ShadowProxyLight>, Without<DaylightFixture>),
>,
// 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<Entity> = 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<Candidate> = 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<usize> = 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<GatherFixture> = 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, 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<Candidate> = (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<Candidate> =
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<LightOwner>, 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");
+62 -8
View File
@@ -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() {
+3
View File
@@ -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};
+73 -6
View File
@@ -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<SpellLightInfo>,
}
/// 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,
});
}
}
@@ -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<f32>,
dims: vec2<f32>,
) -> f32 {
let c = clamp(vec2<i32>(floor(uv * dims)), vec2<i32>(0, 0), vec2<i32>(dims) - vec2<i32>(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<f32>,
layer: i32,
@@ -126,18 +192,68 @@ fn torch_map_shadow(
return 1.0;
}
let uv = ndc.xy * vec2<f32>(0.5, -0.5) + vec2<f32>(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<f32>(textureDimensions(depth_tex).xy)) * max(soft, 0.05);
let dims = vec2<f32>(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<f32>(-1.0, -1.0) * search, dims);
let b1 = torch_map_depth(depth_tex, layer, uv + vec2<f32>( 1.0, -1.0) * search, dims);
let b2 = torch_map_depth(depth_tex, layer, uv + vec2<f32>(-1.0, 1.0) * search, dims);
let b3 = torch_map_depth(depth_tex, layer, uv + vec2<f32>( 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<f32>(-0.5, -0.5) * texel, layer, ref_depth);
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2<f32>( 0.5, -0.5) * texel, layer, ref_depth);
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2<f32>(-0.5, 0.5) * texel, layer, ref_depth);
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2<f32>( 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<f32>(-1.0, 0.0) * texel, layer, ref_depth);
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2<f32>( 1.0, 0.0) * texel, layer, ref_depth);
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2<f32>( 0.0, -1.0) * texel, layer, ref_depth);
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2<f32>( 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
+186 -59
View File
@@ -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<u32>`).
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<f32>, P: vec3<f32>, N: vec3<f32>) -> 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<f32>,
// 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<u32>,
// 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<u32>` for `vec4<u32>` 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<f32>) -> 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<u32>, 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<f32>, box: f32) -> vec2<u32> {
let count = u32(wow_light.point_count.x);
var sel = array<u32, 3>(0u, 0u, 0u);
var sd = array<f32, 3>(1e30, 1e30, 1e30);
var sel = array<u32, EXT_SEL_K>();
var sd = array<f32, EXT_SEL_K>();
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<f32>(box), vec2<f32>(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<u32>(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<f32>, N: vec3<f32>) -> vec3<f32> {
// 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<u32>, P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
var sum = vec3<f32>(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<f32>, N: vec3<f32>) -> vec3<f32> {
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<f32>, N: vec3<f32>) -> vec3<f32> {
// 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<u32>, P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
var sum = vec3<f32>(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<f32>, N: vec3<f32>) -> vec3<f32>
// 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<f32>) -> vec3<f32> {
return vec3<f32>((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<f32>(0.0), vec3<f32>(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<f32> {
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<f32> {
// 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
+284 -80
View File
@@ -150,9 +150,12 @@ struct WowLight {
sh_c13_r: vec4<f32>,
sh_c13_g: vec4<f32>,
sh_c13_b: vec4<f32>,
// 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<f32>,
_water: array<vec4<f32>, 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<u32>`).
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<f32>, P: vec3<f32>, N: vec3<f32>, 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<u32>,
}
// 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<f32>) -> vec3<f32> {
return select(vec3<f32>(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<u32>` for `vec4<u32>` 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<u32> = vec2<u32>(4294967295u, 4294967295u);
// The ranking half of `point_light_sum`, split out verbatim (same tests, same order, same ties).
fn point_light_pick(anchor: vec3<f32>) -> 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<u32>, 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<f32>, box: f32) -> vec2<u32> {
let count = u32(wow_light.point_count.x);
var sel = array<u32, 3>(0u, 0u, 0u);
var sd = array<f32, 3>(1e30, 1e30, 1e30);
var sel = array<u32, EXT_SEL_K>();
var sd = array<f32, EXT_SEL_K>();
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<f32>(box), vec2<f32>(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<u32>(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<f32>, N: vec3<f32>) -> vec3<f32> {
// 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<u32>, P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
var sum = vec3<f32>(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<f32>, N: vec3<f32>) -> vec3<f32> {
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<f32>, N: vec3<f32>) -> vec3<f32> {
// 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<u32>, P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
var sum = vec3<f32>(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<f32>, N: vec3<f32>) -> vec3<f32>
// 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<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
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<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
// 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<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
// 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<f32>) -> vec3<f32> {
return vec3<f32>((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<f32>) -> vec3<f32> {
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<f32>) -> u32 {
fn wmo_exterior_pick(P: vec3<f32>) -> vec2<u32> {
let anchor = mcnk_cell_anchor(P);
let count = u32(wow_light.point_count.x);
var sel = array<u32, 3>(0u, 0u, 0u);
var sd = array<f32, 3>(1e30, 1e30, 1e30);
var sel = array<u32, EXT_SEL_K>();
var sd = array<f32, EXT_SEL_K>();
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<f32>) -> 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<f32>(MCNK_CELL_HALF), vec2<f32>(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<u32>(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<f32>, N: vec3<f32>) -> vec3<f32> {
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<f32>(1.0) - exp(-room * wow_light.point_count.w)) + vec3<f32>(highlight),
inst_tint * (vec3<f32>(1.0) - exp(-(room + vec3<f32>(bake)) * wow_light.point_count.w))
+ vec3<f32>(highlight),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
@@ -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<usize>,
},
/// 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())?
}
@@ -598,6 +598,163 @@ fn group_names(bytes: &[u8]) -> Vec<Option<String>> {
.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<String>],
infos: &[benilla_formats::WmoGroupInfo],
// MONKEY (trans day law): dump this group's per-VERTEX rows as well (see the CLI doc).
verts: Option<usize>,
) {
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<usize> = 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<usize> = 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::<Vec<_>>().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<usize>) -> 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<bool> = 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,
+664
View File
@@ -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<SpellLightKind> {
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<Item = &'a ParticleEmitterDef>,
) -> Option<SyntheticSpell> {
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"));
}
}
+4
View File
@@ -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.
+79
View File
@@ -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<WmoPortalInfo>) {
(
+22 -8
View File
@@ -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
File diff suppressed because it is too large Load Diff
+503 -17
View File
@@ -36,7 +36,9 @@ use crate::view::WorldCamera;
/// per-material uniforms fed by `apply_wow_lighting` — editing this layout does NOT reach them.)
/// 0 light_ambient (w=Mod2x 1.0) · 1 light_diffuse (w=clamp on) · 2 light_sun (w=dir/SH enable) ·
/// 3 light_spec (w=terrain shininess 20) · 4 fog_color (w=enable) · 5 fog_params (x=start y=end w=farclip) ·
/// 6-8 sh_c10_{r,g,b} · 9-11 sh_c13_{r,g,b} · 12 sh_c16 ·
/// 6-8 sh_c10_{r,g,b} · 9-11 sh_c13_{r,g,b} · 12 sh_c16 (.w = the world-shadow flag in the
/// integer part, MONKEY (bake floor) `interiorBakeFloor × interiorGain` in the fraction —
/// [`BAKE_LANE_SCALE`]) ·
/// 13-14 water river {shallow,deep} (w=alpha) · 15-16 water ocean {shallow,deep} (w=alpha) ·
/// 17 grade — `.x` = the SIDN night fraction (`WowLighting::sidn_night`: 1 overnight, 0 all day;
/// `wow_model.wgsl` scales every WMO SIDN material's authored emissive by it); `.yzw` = the
@@ -129,7 +131,9 @@ pub fn pack_model_core_rows(
rows[6][3] = ambient[0]; // the DC lanes carry ambient alone
rows[7][3] = ambient[1];
rows[8][3] = ambient[2];
rows[12][0] = sun[6].x; // 12 sh_c16 xyz — .w is a free lane (see the struct comment)
rows[12][0] = sun[6].x; // 12 sh_c16 xyz — .w is the shared world-shadow/bake-floor lane, written
// by `build_light_data` alone (the portrait booth leaves it 0, so a
// studio portrait takes neither — the frozen look is deliberate)
rows[12][1] = sun[6].y;
rows[12][2] = sun[6].z;
// 17 `.yzw` — the sun's SH DC redistribution at intensity 1 (`D·(4/17)(0.375+0.9375(uₓ²+u_y²))`
@@ -158,11 +162,32 @@ pub fn commit_raw(rgb: [f32; 3]) -> [f32; 3] {
rgb.map(|c| c.max(0.0))
}
/// Capacity of the packed point-light table (fixed-size in the WGSL mirror structs — keep in sync).
/// 256 lights × 2 rows × 16 B = 8 KB — generous for the densest streamed village/city interior set;
/// [`build_light_data`] packs the nearest-to-camera first when over capacity.
/// **Slot** capacity of the packed point-light table (fixed-size in the WGSL mirror structs — keep
/// in sync). 256 lights × 2 rows × 16 B = 8 KB — generous for the densest streamed village/city
/// interior set; [`build_light_data`] packs the nearest-to-camera first when over capacity.
///
/// This is the BUFFER shape (`LightStd430` and [`RoomClaimTable`] size against it) and it must not
/// move: the blob is 8528 B and is mirrored by three shaders plus the portrait booth. How many of
/// those slots may actually hold a light is [`MAX_LIVE_POINT_LIGHTS`], which is one less.
pub(super) const MAX_POINT_LIGHTS: usize = 256;
/// MONKEY (ext light k8): how many of [`MAX_POINT_LIGHTS`] slots may hold a LIVE entry — **255**,
/// one short of the buffer.
///
/// The three shaders publish each draw unit's chosen exterior lights from the vertex stage to the
/// fragment stage as a packed index list (`ext_sel`). Widening that list from three lights to eight
/// — the fix for the Darkmoon Faire "scars", where adjacent draw units kept different threes out of
/// 15+ synthesised torch lights and the disagreement drew a straight edge across the grass — cost
/// bits: eight ranks in two u32s is **8 bits each**, so an index runs 0..=254 and **255 is the
/// `EXT_SEL_EMPTY` sentinel** for "this rank is unfilled". A 256th live light would pack as 255 and
/// every shader would read it as "stop here", silently truncating that unit's whole list.
///
/// So the pack truncates one entry earlier. The lights are sorted nearest-camera-first before the
/// truncation, so the one this drops is the farthest of a 256-strong set — sub-pixel and usually
/// fogged at that density. The BUFFER keeps all 256 slots, so nothing about the layout, the 8528 B
/// blob or the room-claim table changes.
pub(super) const MAX_LIVE_POINT_LIGHTS: usize = MAX_POINT_LIGHTS - 1;
/// MONKEY (dynamic interiors): a fixture this close to the camera (yd) is packed regardless of the
/// portal flood — see the room term in [`build_light_data`]. Sized to a WHOLE BUILDING PLUS its
/// approach, not just the room: at 40 yd, walking around the outside of the inn kept crossing the
@@ -675,6 +700,80 @@ fn sun_shadow_strength(sun_height: f32) -> f32 {
t * t * (3.0 - 2.0 * t)
}
/// MONKEY (enclosed day floor): how [`DynamicInteriors::daylight`] rides to the shader — as the
/// FRACTIONAL part of the packed `wmo_fog_params.w` lane, `w = 1 + debug + daylight * this`.
///
/// **There was no free f32 left.** [`LightStd430`] is 8528 B, mirrored by three shaders plus the
/// portrait booth, and must not grow; every `.w` in rows 0..=20 is spoken for (the Mod2x/clamp/SH
/// enables, terrain shininess, fog enable, farclip, the ambient DC lanes, `sh_c13_*.w` which the
/// SH `dot(row, quad)` reads as a real band, the world-shadow flag, the shadow distance, the
/// exposure). The tail regions are declared only by `wow_model.wgsl`, so `static_gx` — the one
/// consumer that needs this number — cannot reach them at all.
///
/// So it rides a lane that has spare RANGE rather than a spare slot. `wmo_fog_params.w` is packed
/// as `0` (interior lane off) or `1 + interiorDebug` (0..=4), and every one of its four decodes
/// across the three shaders is insensitive to a fraction below 0.5:
/// * `w > 0.5` — the on/off test (`static_gx` x2, `wow_model`): adding to `1 + debug` cannot
/// reach it from below, and cannot leave it from above.
/// * `u32(max(w - 1, 0) + 0.5)` — the debug decode (`static_gx` x3, `wow_model`, `terrain`):
/// `debug + f + 0.5` truncates to `debug` for every `f` in `[0, 0.5)`.
/// **0.49** keeps a clear margin under that 0.5 cliff while spending the whole of the rest of the
/// range on the value. `1 + debug` is an exact integer in f32, so the shader's `fract(w)` returns
/// `daylight * 0.49` bit-for-bit up to the f32 ulp at `w <= 5.49` (~5e-7 — four orders of magnitude
/// below anything the eye can see in an ambient floor). **Keep in sync with `static_gx.wgsl`'s
/// `DAYLIGHT_LANE_SCALE`.**
///
/// **The default (0.12) is calibrated, not chosen.** At the owner's live cvars (`interiorExposure 4`,
/// `interiorAmbient 0.02`, `interiorGain 0.7`) the room law's floor is `0.02 x 0.7 = 0.014`, so an
/// unlit interior fragment renders `1 - exp(-0.014 x 4) = 0.055`. Elwynn at 11:28 renders its
/// sunlit threshold at luminance **0.831** (`ambient (103,129,154)/255 + diffuse (255,133,0)/255 x
/// N.L 0.5805`, clamped). With the floor: `1 - exp(-(0.014 + 0.12) x 4) = 0.415`, i.e. **50 % of
/// the threshold** — a soft step across a doorway instead of a black hole. (0.10 reads 44 %, 0.14
/// reads 55 %; the 0.35 the brief suggested reads **92 %**, which is a room with no walls.)
pub const DAYLIGHT_LANE_SCALE: f32 = 0.49;
/// MONKEY (bake floor): how [`DynamicInteriors::bake_floor`] rides to the shader — as the
/// FRACTIONAL part of the packed `sh_c16.w` lane, `w = world_shadow_flag + bake × this`.
///
/// **There is still no free f32.** [`LightStd430`] is 8528 B and mirrored by three shaders plus the
/// portrait booth (see [`DAYLIGHT_LANE_SCALE`] for the full accounting of why it must not grow).
/// `wmo_fog_params.w` — the lane the daylight floor rides — is now spent: its integer part is
/// `1 + interiorDebug` and its fraction is `interiorDaylight × 0.49`, and two fractions cannot
/// share one lane without a second quantisation. So this one takes the OTHER lane with spare range.
///
/// `sh_c16.w` is the world-shadow flag: packed as exactly `0.0` or `1.0`, and it has precisely ONE
/// decode in the whole shader set — `terrain.wgsl`'s `wow_light.sh_c16.w > 0.5` (the MCSH
/// suppression gate). `sh_c16.xyz` is a real SH band that `wow_model`/`static_gx` read; neither
/// read `.w` at all before this, and both now read only its FRACTION. A fraction strictly below
/// 0.5 therefore cannot move terrain’s decode in either direction: `0 + f` stays below the
/// threshold, `1 + f` stays above it.
///
/// **0.49** keeps the same clear margin under the 0.5 cliff that the daylight lane keeps, and the
/// packed value is CLAMPED to `[0, 1]` before scaling — `bake_floor` is 0..1 but `interior_gain`
/// reaches 1.5, so the product alone could reach 1.5 and push the fraction past the cliff (0.735),
/// which would switch MCSH terrain shadows on for anyone running `interiorBakeFloor 1` with the
/// Bright preset. The clamp costs nothing real: the slider's useful range is 0..0.3.
///
/// `world_shadow_flag` is an exact integer in f32, so the shader's `fract(w)` returns
/// `bake × 0.49` bit-for-bit up to the f32 ulp at `w <= 1.49` (~6e-8). **Keep in sync with
/// `static_gx.wgsl` / `wow_model.wgsl`'s `BAKE_LANE_SCALE`.**
pub const BAKE_LANE_SCALE: f32 = 0.49;
/// MONKEY (bake floor): the CPU half of the `sh_c16.w` pack — the lane word for a world-shadow
/// flag and a bake floor. Split out so the round-trip test below exercises the SAME arithmetic the
/// packer runs, not a transcription of it.
pub fn pack_bake_lane(world_shadow: bool, bake_floor: f32, interior_gain: f32) -> f32 {
let flag = if world_shadow { 1.0 } else { 0.0 };
flag + (bake_floor * interior_gain).clamp(0.0, 1.0) * BAKE_LANE_SCALE
}
/// MONKEY (bake floor): the SHADER's decode, transcribed — `fract(w) / BAKE_LANE_SCALE`. Only
/// exists for the round-trip test; the real decode lives in the two WGSL files.
#[cfg(test)]
fn unpack_bake_lane(w: f32) -> f32 {
(w - w.floor()) / BAKE_LANE_SCALE
}
/// MONKEY (dynamic interiors): the live knobs of the interior lane — WMO interior surfaces AND
/// interior props light from the room's live fixtures (`static_gx.wgsl` `interior_room_light`)
/// instead of the MOCV bake / the baked prop probe. Bridged every frame from the app-side cvars
@@ -727,14 +826,72 @@ pub struct DynamicInteriors {
/// [`FireLightGain`]: that one scales the SYNTHESISED lane only, while a flicker belongs to
/// authored wall torches just as much.
pub flicker: f32,
/// MONKEY (darkness gains): the live dim on the EXTERIOR day/night law (`nightGain`, 0.2..1.5,
/// default **0.8** = nights 20 % darker). Folded into the packed ambient/diffuse/specular rows
/// by `mix(1, gain, night_w)`, `night_w = 1 - sun_shadow_strength(celestial_dir.y)` — so it is
/// EXACTLY inert while the sun is up and full strength after dark.
///
/// It rides THIS resource despite being an exterior knob because the bridge is the same one
/// (a live video cvar → a resource the light packer folds at pack time), and a second resource
/// plus a second plugin to carry one `f32` buys nothing. `enabled` does NOT gate it: the night
/// law lights terrain and models whether or not the interior lane is on.
pub night_gain: f32,
/// MONKEY (darkness gains): the live dim on the whole INTERIOR room lane (`interiorGain`,
/// 0.2..1.5, default **0.7** = interiors 30 % darker). Scales the lane's three INPUTS — the
/// packed [`Self::ambient`], [`Self::fill`], and every interior fixture's committed colour —
/// and deliberately not [`Self::exposure`], which is the user's own live dial; a gain on the
/// inputs composes with whatever exposure they have settled on.
pub interior_gain: f32,
/// MONKEY (enclosed day floor): the DAYLIGHT a room in a building gets through the doorways
/// this renderer cannot locate (`interiorDaylight`, 0..1, default **0** — the director wants daylight ONLY at doorways/windows, i.e. from the daylight fixtures; the room-wide floor stays available as a dial).
///
/// It is an ADDITIVE ambient in exactly [`Self::ambient`]'s units — the room law's pre-exposure
/// illumination — scaled by the sun's own day envelope and tinted by the sky's ambient band, so
/// it is 0 all night and full at midday. Three seeds now find a building's authored openings
/// (`lighting::daylight`), but the shipped corpus also contains rooms whose doorway is in NO
/// table: the Goldshire inn's entry group `g3` has no portal, no EXT-class batch, no vertex
/// stitched to the shell that owns its threshold planks, and no localized spot in its own MOCV
/// bake. For those there is nothing to stand a fixture in, and the honest fallback is to say
/// what IS known — the room is inside a building, the sun is up, so it is not pitch dark.
///
/// Rides the free FRACTION of the packed `wmo_fog_params.w` lane (see
/// [`DAYLIGHT_LANE_SCALE`]); `0` restores the pre-feature look exactly.
pub daylight: f32,
/// MONKEY (bake floor): the share of a WMO interior batch's OWN MOCV bake that every
/// interior-lane fragment keeps, whether or not a fixture reaches it (`interiorBakeFloor`,
/// 0..1, default **0.12**).
///
/// The lane's premise — "the live fixtures decide, the bake's LEVEL is wrong" — has one hole
/// in it: a room the fixture table cannot reach renders at the bare [`Self::ambient`] floor,
/// i.e. black. The Lion's Pride Inn's east vestibule (group `g0`, box x 14.1..20.5) is the case
/// that forced it — MOLR 0, ZERO fixture claims (nearest L2 ≈ 16 yd against R 11.2, L3 ≈ 17.6
/// against R 14.7), one faded portal hop, so the whole budget collapses to `0.0075` and the
/// door band renders 0.019 × tex between a sky-lit porch and a candle-lit hall. The reference
/// client has no such hole: it draws every interior batch at its authored MOCV regardless of
/// fixtures, so a fixture-starved room is DIM there, never black.
///
/// So the honest floor is the bake ITSELF, at a fraction: `vc.rgb × this`, added to the room's
/// pre-exposure budget INSIDE the `1 − exp(−x·exposure)` rolloff, so it saturates with the
/// fixtures instead of stacking on top of them (a candle-lit surface barely moves — measured
/// +7 % at the inn's `g3` floor under `L9`, +9 % at a `g5` wall 3 yd from `L0`) while an unlit
/// one goes from black to the bake's own relative statement about the room. A FRACTION, not the
/// bake, precisely because the LEVEL is what this lane rejects: an UNCAPPED bake share on this
/// same band measured 15-18 × its neighbour and read as a flat grey slab (see the
/// `static_gx.wgsl` portal-bleed comment); 0.12 restores about an eighth of it.
///
/// Scaled by [`Self::interior_gain`] at PACK time (so the Dim preset dims it with everything
/// else and the shader carries no second knob), and NOT by `fireLightGain`, NOT flickered — it
/// is not a fire, it is the room's own authored light. `0` restores the pre-feature look
/// exactly. Rides the free FRACTION of the packed `sh_c16.w` lane — see [`BAKE_LANE_SCALE`].
pub bake_floor: f32,
}
impl Default for DynamicInteriors {
fn default() -> Self {
Self {
enabled: true,
ambient: 0.15,
fill: 0.12,
ambient: 0.015,
fill: 0.08,
exposure: 2.5,
// MONKEY (soft falloff): 1.6, matching the `interiorAttenScale` cvar default (2.5 read oversaturated — the soft core `0.26·R` widens with it too).
atten_scale: 1.6,
@@ -744,6 +901,17 @@ impl Default for DynamicInteriors {
debug: 0,
// MONKEY (flame flicker): on at the authored amplitudes.
flicker: 1.0,
// MONKEY (darkness gains): the director's call — nights 20 % darker, interiors 30 %.
night_gain: 0.45,
interior_gain: 0.5,
// MONKEY (enclosed day floor): calibrated, not chosen — see [`DAYLIGHT_LANE_SCALE`]
// for the arithmetic that lands the Goldshire inn's entry floor at ~half the sunlit
// threshold beside it instead of at the near-black `interiorAmbient`.
daylight: 0.0,
// MONKEY (bake floor): an eighth of the authored bake — see the field doc for the
// measurement this is calibrated against (the inn's `g0` door band, and the
// candle-lit surfaces it must NOT move).
bake_floor: 0.12,
}
}
}
@@ -927,6 +1095,9 @@ fn build_light_data(
// at spawn). Read-only, never written — the wobble is a pure function of it plus the
// clock, so it adds no `Changed` traffic and no archetype churn to the frame.
Option<&FlameFlicker>,
// MONKEY (darkness gains): the daylight-fixture marker — an interior-lane entry that
// is the SUN standing in a doorway, not a candle, so `interiorGain` must skip it.
Has<super::DaylightFixture>,
),
Without<ShadowProxyLight>,
>,
@@ -951,12 +1122,37 @@ fn build_light_data(
let l = &*light;
let fog_enable = if debug.lighting.disable_fog { 0.0 } else { 1.0 };
let farclip = view.farclip;
// MONKEY (darkness gains): `nightGain` — one live dim over everything the EXTERIOR law lights
// (terrain, models, WMO exteriors, and the ext-class night blend, all of which derive from the
// ambient/diffuse/specular rows below). Folded CPU-side into those packed rows rather than
// added as a shader uniform because `LightStd430` has no free lane left (8528 B, mirrored by
// three WGSL structs) — and a pack-time fold costs the GPU exactly nothing anyway.
//
// The ramp is the DUSK CLOCK every other night feature already fades on: `sun_w` is
// `sun_shadow_strength(celestial_dir.y)` — 1 in daylight, smoothstepping to 0 as the celestial
// sun reaches the horizon — so `night_w = 1 - sun_w` is 0 all day and 1 after dark. Written as
// `1 + (g - 1)·night_w` (== `mix(1, g, night_w)`) so daylight multiplies by an EXACT 1.0 and
// every daytime frame stays bit-identical to before the feature: a gain that perturbed the day
// in the last ulp would move the `WOW_LIGHT_DUMP` row hash, which is the instrument three
// rounds of shading forensics are denominated in.
//
// POINT lights are deliberately NOT scaled by it. Dimming the sky law alone is the whole point:
// a campfire should read BRIGHTER against a darker night, not equally dim.
let sun_w = sun_shadow_strength(l.celestial_dir.y);
let night_k = 1.0 + (dynamic_interiors.night_gain - 1.0) * (1.0 - sun_w);
let night_dim = |c: [f32; 3]| c.map(|v| v * night_k);
// Per-kind water swatches (shallow/deep rgb + alpha). River/lake use the non-ocean path.
let (rs, rd, rsa, rda) = l.water_colors(LiquidKind::Still);
let (os, od, osa, oda) = l.water_colors(LiquidKind::Ocean);
data.0.rows = [[0.0; 4]; LIGHT_HEADER_ROWS];
let rows = &mut data.0.rows;
rows[3] = [l.spec[0], l.spec[1], l.spec[2], 20.0]; // 3 light_spec (w=terrain shininess 20)
// MONKEY (darkness gains): the specular row takes the night dim because it IS the sun — the
// DBC sun-halo colour driving the terrain sheen. The FOG rows below do not (fog colour is the
// horizon backdrop the world is seen AGAINST, and dimming it would paint a dark world under a
// bright skyline), and neither does the sky dome (`resolve::apply_sky_backdrop`, which never
// reads this blob) or the water swatches.
let spec = night_dim(l.spec);
rows[3] = [spec[0], spec[1], spec[2], 20.0]; // 3 light_spec (w=terrain shininess 20)
rows[4] = [l.fog_color[0], l.fog_color[1], l.fog_color[2], fog_enable]; // 4 fog_color (w=enable)
rows[5] = [l.fog_start, l.fog_end, 0.0, farclip]; // 5 fog_params (z unused; w=farclip)
rows[13] = [rs[0], rs[1], rs[2], rsa]; // 13 water river shallow (w=alpha)
@@ -979,16 +1175,31 @@ fn build_light_data(
rows[19] = [l.wmo_fog_start, l.wmo_fog_end, 0.0, 0.0];
// Rows 0-2, the SH block 6-12.xyz, and the sun DC (17.yzw) — the shared model-light core
// (also the portrait booth's packer). Row 20 (point_count) is the point-table pack's below.
pack_model_core_rows(rows, l.ambient, l.diffuse, l.sun_dir);
// MONKEY (darkness gains): the night dim goes in HERE, on the (ambient, diffuse) triple the
// whole model core is derived from, rather than onto the packed rows afterwards — every row
// this packs (rows 0/1, the SH block, the sun's DC redistribution) is LINEAR in that triple, so
// one multiply at the input dims all of them consistently and no derived row can be missed.
pack_model_core_rows(rows, night_dim(l.ambient), night_dim(l.diffuse), l.sun_dir);
// MONKEY (world shadows): pack the world-shadow lane flag into the free `sh_c16.w` lane. The
// MCSH terrain-shadow suppression in `terrain.wgsl` keys on THIS — not on the mere presence of
// a shadow sun — so character-only shadows (sun present, world lane off) keep the baked MCSH.
rows[12][3] = if world_shadow.0 { 1.0 } else { 0.0 };
// MONKEY (bake floor): ...and `interiorBakeFloor` rides the free FRACTION of that same lane,
// with `interiorGain` already folded in (the shader must not carry a second knob, and the room
// lane's other two inputs — the base ambient and the per-fixture fill — take the gain at pack
// time in exactly this way, three rows down). See [`BAKE_LANE_SCALE`] for why a fraction is
// invisible to the one `> 0.5` decode this lane has, and why the product is clamped first.
rows[12][3] = pack_bake_lane(
world_shadow.0,
dynamic_interiors.bake_floor,
dynamic_interiors.interior_gain,
);
// MONKEY (night fade): realtime-shadow strength by the REAL celestial sun height, packed into
// the free `fog_params.z` lane. The receivers (terrain/model) lighten their shadow term by it,
// so shadows soften and vanish at night; the shadow basis is separately clamped to 18° so a low
// sun still casts the right DIRECTION.
rows[5][2] = sun_shadow_strength(l.celestial_dir.y);
// MONKEY (darkness gains): computed once above — `nightGain` rides this exact same curve, so
// the dim and the shadow fade can never drift onto two different dusk clocks.
rows[5][2] = sun_w;
// MONKEY (distance slider): the realtime-shadow render distance (yd), packed into the free
// `_wmo_fog[1].z` / `wmo_fog_params.z` lane (row 19). The receivers' edge fade reads it so the
// shadow fades at the cascade's actual `maximum_distance`, whatever the slider is set to.
@@ -997,8 +1208,13 @@ fn build_light_data(
// interior surfaces + props light from the room's live fixtures, 0 = the faithful baked path).
// Its three knobs ride `point_count.yzw`, packed with the table below.
// On/off in the integer part, the debug mode added on top: 0 = off, 1 = on, 1+n = on + debug n.
// MONKEY (enclosed day floor): …and `interiorDaylight` rides the same lane's FRACTION (see
// [`DAYLIGHT_LANE_SCALE`] for why there was nowhere else to put it and why every existing
// decode survives it). Zero when the lane is off, so the packed word is byte-identical to
// before the feature in that arm.
rows[19][3] = if dynamic_interiors.enabled {
1.0 + dynamic_interiors.debug as f32
+ dynamic_interiors.daylight.clamp(0.0, 1.0) * DAYLIGHT_LANE_SCALE
} else {
0.0
};
@@ -1029,7 +1245,7 @@ fn build_light_data(
// the interior lane, so `claims 4` beside `EXT` is the readout of exactly that trade.
let mut pts: Vec<(f32, Vec3, f32, [f32; 3], bool, f32, RoomClaim, usize)> = lights_q
.iter()
.filter(|(_, gt, rooms, _, _, _, _, _)| {
.filter(|(_, gt, rooms, _, _, _, _, _, _)| {
// The ROOM term (decision 0689's law, fourth lane — see [`LightRooms`]). Not a
// visibility test bolted onto a faithful gather: the reference's register walk has no
// such term either, it simply never has a culled room's torch to register. Ungated for
@@ -1047,7 +1263,7 @@ fn build_light_data(
&& gt.translation().distance_squared(cam_pos)
< INTERIOR_NEAR_ADMIT * INTERIOR_NEAR_ADMIT)
})
.filter_map(|(pl, gt, rooms, synthetic, reach, lane_of, lit_rooms, flicker)| {
.filter_map(|(pl, gt, rooms, synthetic, reach, lane_of, lit_rooms, flicker, daylight)| {
let p = gt.translation();
let d2 = p.distance_squared(cam_pos);
(d2 < POINT_PACK_RADIUS * POINT_PACK_RADIUS).then(|| {
@@ -1087,6 +1303,24 @@ fn build_light_data(
// is corrected outward, never the reverse — so the gap can never flash a pool onto
// an inn's lawn.
let interior = lane_of.map_or_else(|| rooms.is_some(), |l| l.interior);
// MONKEY (darkness gains): `interiorGain` dims the room lane's third input — every
// INTERIOR fixture's committed colour — downstream of the fire gain and the
// flicker, so those two keep their own meanings ("how bright is this invented
// source" / "how hard does it breathe") and this one reads purely as "how dark is
// the room". The EXTERIOR half of the table is untouched on purpose: an outdoor
// campfire belongs to the night law, which dims the sky around it and not it.
//
// And so is the DAYLIGHT FIXTURE ([`super::DaylightFixture`]) — an interior-lane
// entry that IS the sun standing in a doorway. A sunlit opening must not dim with
// the room's candles: this dial means "how dark is the CANDLELIGHT". Nor does
// `nightGain` claim it instead — that one dims the night, and a daylight fixture is
// already scaled to nothing by its own day envelope (`daylight_target`'s `sun_w`,
// the same curve) by the time the night dim is at full strength.
let rgb = if interior && !daylight {
rgb.map(|c| c * dynamic_interiors.interior_gain)
} else {
rgb
};
// A fixture's reach is its authored MOLT end where there is one and the M2 bucket
// otherwise, with a fail-open default for a MOLT record whose end is absent or
// degenerate (a few author 0 with `useAtten` clear).
@@ -1113,13 +1347,22 @@ fn build_light_data(
})
.collect();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
pts.truncate(MAX_POINT_LIGHTS);
// MONKEY (ext light k8): 255, not 256 — index 255 is the shaders' `EXT_SEL_EMPTY` sentinel now
// that a draw unit's selection packs eight 8-bit ranks. See [`MAX_LIVE_POINT_LIGHTS`]. The sort
// above is nearest-camera-first, so the entry this drops is the farthest of the set.
pts.truncate(MAX_LIVE_POINT_LIGHTS);
// `.x` = the live entry count; MONKEY (dynamic interiors): `.yzw` = the interior lane's live
// knobs (base ambient, per-fixture fill gain, exposure) — free lanes until now.
// MONKEY (darkness gains): `interiorGain` scales two of the room lane's three inputs here (the
// base ambient floor and the per-fixture fill gain; the fixtures' own colours took it above)
// and pointedly leaves EXPOSURE alone — that one is the user's live dial (their config sits at
// 4), and folding a dim into it would have the two knobs fight over the same number. A gain on
// the INPUTS composes with whatever exposure is set to. The strict exterior-batch `ext_room`
// term and the ext-class night blend read these same packed lanes, so they follow for free.
data.0.rows[20] = [
pts.len() as f32,
dynamic_interiors.ambient,
dynamic_interiors.fill,
dynamic_interiors.ambient * dynamic_interiors.interior_gain,
dynamic_interiors.fill * dynamic_interiors.interior_gain,
dynamic_interiors.exposure,
];
for (i, (_, p, range, rgb, _, lane, claim, _)) in pts.iter().enumerate() {
@@ -1184,12 +1427,17 @@ fn build_light_data(
// exterior lane should never have seen — a number, printed per row below as INT/EXT.
let interior = pts.iter().filter(|(.., lane, _, _)| *lane > 0.5).count();
eprintln!(
"[points] {} packed ({interior} INT / {} EXT, {synth} synthetic, {flames} flickering, gain {:.2}, atten x{:.2}, flicker x{:.2}), cam {cam_pos:.1?} — this chunk's candidates: {boxed} (was {sphere} at the 48 yd sphere), 3 slots",
"[points] {} packed ({interior} INT / {} EXT, {synth} synthetic, {flames} flickering, gain {:.2}, atten x{:.2}, flicker x{:.2}, night x{:.2}, interior x{:.2}), cam {cam_pos:.1?} — this chunk's candidates: {boxed} (was {sphere} at the 48 yd sphere), 3 slots",
pts.len(),
pts.len() - interior,
fire_gain.0,
dynamic_interiors.atten_scale,
dynamic_interiors.flicker,
// MONKEY (darkness gains): the two dim dials, beside the gains they compose with —
// "is this room dark because the gain is 0.2 or because no fixture claims it" is
// the question this line exists to answer without a rebuild.
dynamic_interiors.night_gain,
dynamic_interiors.interior_gain,
);
for (d2, p, _, rgb, synthetic, lane, claim, lit_n) in pts.iter().take(8) {
eprintln!(
@@ -2056,4 +2304,242 @@ mod tests {
);
}
}
/// MONKEY (ext light k8): the LIVE cap is one short of the SLOT cap, and the blob did not grow.
///
/// The two numbers are easy to confuse and the failure mode of confusing them is invisible: a
/// light seated at index 255 packs identically to `EXT_SEL_EMPTY` in all three shaders, so the
/// draw units that selected it would read "end of list" and quietly drop every rank after it —
/// a dark patch with nothing in any log. The second assertion is the other half of the deal
/// that bought those bits: widening the selection had to cost the buffer NOTHING, because
/// `LightStd430` is mirrored by three shaders plus the portrait booth's frozen studio blob.
#[test]
fn the_live_point_cap_leaves_the_sentinel_index_free() {
assert_eq!(MAX_LIVE_POINT_LIGHTS, 255, "255 is EXT_SEL_EMPTY in the three shaders");
assert_eq!(MAX_LIVE_POINT_LIGHTS, MAX_POINT_LIGHTS - 1);
// 21 header rows + 2 x 256 point rows, 16 B each.
assert_eq!(per_frame_blob_bytes(), 8528, "the mirrored blob must not change size");
}
/// MONKEY (enclosed day floor): `interiorDaylight` rides the FRACTION of the interior lane's
/// on/off word, and every existing decode of that word must be blind to it.
///
/// This is the test the feature stands on: there was no free `f32` left in an 8528-byte layout
/// three shaders mirror, so the value shares a lane with two other facts. If the fraction ever
/// grew past 0.5 — a wider `DAYLIGHT_LANE_SCALE`, a `daylight` that escaped its clamp — the
/// debug decode `u32(max(w - 1, 0) + 0.5)` would round UP and every building in the frame would
/// silently switch to a diagnostic overlay. Asserting the two decodes, not just the value, is
/// what makes that impossible to introduce quietly.
#[test]
fn the_daylight_lane_rides_the_fraction_without_disturbing_its_neighbours() {
// The shader's own two decodes of `wmo_fog_params.w`, transcribed.
let interiors_on = |w: f32| w > 0.5;
let idbg = |w: f32| (0.0f32.max(w - 1.0) + 0.5) as u32;
let daylight_of = |w: f32| (w - w.floor()) / DAYLIGHT_LANE_SCALE;
for debug in 0..=4u32 {
for daylight in [0.0f32, 0.01, 0.12, 0.5, 0.999, 1.0] {
let w = 1.0 + debug as f32 + daylight * DAYLIGHT_LANE_SCALE;
assert!(interiors_on(w), "lane off at debug {debug} daylight {daylight}");
assert_eq!(idbg(w), debug, "debug decode moved (w {w})");
assert!(
(daylight_of(w) - daylight).abs() < 1e-4,
"daylight {daylight} round-tripped as {} (w {w})",
daylight_of(w),
);
}
}
// The OFF arm is untouched — byte-identical to before the feature, whatever the cvar says.
assert!(!interiors_on(0.0));
assert_eq!(daylight_of(0.0), 0.0);
// …and the packer really writes it. `1 + debug + d*scale`, clamped at both ends.
let pack = |enabled: bool, debug: u32, d: f32| {
if enabled {
1.0 + debug as f32 + d.clamp(0.0, 1.0) * DAYLIGHT_LANE_SCALE
} else {
0.0
}
};
assert_eq!(pack(false, 3, 0.5), 0.0);
assert_eq!(idbg(pack(true, 3, 5.0)), 3, "an out-of-range cvar must still clamp under 0.5");
assert!((daylight_of(pack(true, 3, 5.0)) - 1.0).abs() < 1e-4);
assert!((daylight_of(pack(true, 0, -1.0))).abs() < 1e-4);
}
/// MONKEY (bake floor): `interiorBakeFloor` rides the FRACTION of the world-shadow lane
/// (`sh_c16.w`), and that lane's one existing decode must be blind to it.
///
/// Same shape as the daylight-lane test above and load-bearing for the same reason: the layout
/// has no free `f32`, so the value shares a row with a boolean. The failure this forbids is
/// quiet and remote — a fraction that reached 0.5 would make `terrain.wgsl` read
/// `sh_c16.w > 0.5` as TRUE with `worldShadows` OFF, i.e. every ADT in the world would drop its
/// baked MCSH shadows because someone moved an interior slider. Hence the clamp inside
/// [`pack_bake_lane`] (the product `bake_floor × interior_gain` reaches 1.5 at the knobs' own
/// limits, which × 0.49 is 0.735 — over the cliff) and hence asserting the DECODE, not the value.
#[test]
fn the_bake_floor_rides_the_fraction_without_disturbing_the_world_shadow_flag() {
// `terrain.wgsl`'s only decode of this lane, transcribed.
let world_shadow_lane = |w: f32| w > 0.5;
for &flag in &[false, true] {
for &bake in &[0.0f32, 0.01, 0.08, 0.12, 0.2, 0.5, 1.0] {
for &gain in &[0.2f32, 0.5, 1.0, 1.5] {
let w = pack_bake_lane(flag, bake, gain);
assert_eq!(
world_shadow_lane(w),
flag,
"world-shadow decode moved (w {w}, bake {bake}, gain {gain})",
);
let want = (bake * gain).clamp(0.0, 1.0);
assert!(
(unpack_bake_lane(w) - want).abs() < 1e-4,
"bake {bake} x gain {gain} round-tripped as {} (w {w})",
unpack_bake_lane(w),
);
}
}
}
// `0` restores the pre-feature look EXACTLY: the packed word is the bare flag, bit for bit.
assert_eq!(pack_bake_lane(false, 0.0, 0.5), 0.0);
assert_eq!(pack_bake_lane(true, 0.0, 0.5), 1.0);
// An out-of-range cvar still clamps under the cliff rather than flipping the flag.
assert!(!world_shadow_lane(pack_bake_lane(false, 9.0, 1.5)));
assert!((unpack_bake_lane(pack_bake_lane(false, 9.0, 1.5)) - 1.0).abs() < 1e-4);
// The gain really is the dimmer: the Dim preset's floor is under the Default's.
assert!(pack_bake_lane(false, 0.08, 0.5) < pack_bake_lane(false, 0.12, 0.5));
}
/// GOLDEN — MONKEY (darkness gains): `nightGain` is EXACTLY inert while the sun is up and
/// exactly the gain after dark, and it never touches a flame.
///
/// The daylight half is the load-bearing one. The dial folds into rows the entire exterior look
/// is derived from (ambient, diffuse, the SH block, the sun halo), so a ramp that missed 1.0 by
/// an ulp at noon would perturb every daytime frame of a renderer whose fidelity work is
/// measured against bit-exact row hashes — the bug would be invisible on screen and expensive
/// in the log. `1 + (g - 1)·night_w` is written the way it is to make that endpoint exact.
///
/// The point-table half is the FEATURE, not a detail: dimming the sky law while leaving the
/// fires alone is what makes a torch read brighter against a darker night. A gain that reached
/// the point entries would dim the flame by the same 20 % and net out to no change at all.
#[test]
fn the_night_gain_is_inert_by_day_and_exact_after_dark() {
let base = WowLighting {
ambient: [0.30, 0.32, 0.38],
diffuse: [0.85, 0.70, 0.45],
spec: [0.60, 0.55, 0.50],
sun_dir: Vec3::new(0.3, -0.8, 0.52).normalize(),
..default()
};
// `sun_shadow_strength` saturates at `sin(12°)`: y = 1 is broad daylight, y = 0 the horizon.
let pack = |celestial_y: f32| {
let mut app = packer_app();
app.world_mut().insert_resource(WowLighting {
celestial_dir: Vec3::new(0.0, celestial_y, 0.0),
..base
});
// An EXTERIOR fire, to prove the dial stops at the sky law.
app.world_mut().spawn((
crate::terrain_stream::point_light([1.0, 0.5, 0.25], 2.0),
GlobalTransform::from_translation(Vec3::X),
));
app.update();
app.world().resource::<WowLightData>().0
};
let day = pack(1.0);
assert_eq!(day.rows[5][2], 1.0, "the sun is up: night_w is 0");
assert_eq!([day.rows[0][0], day.rows[0][1], day.rows[0][2]], base.ambient);
assert_eq!([day.rows[1][0], day.rows[1][1], day.rows[1][2]], base.diffuse);
assert_eq!([day.rows[3][0], day.rows[3][1], day.rows[3][2]], base.spec);
// The SH block is derived from the same triple — its DC lanes carry ambient verbatim.
assert_eq!(day.rows[6][3], base.ambient[0], "the SH DC is undimmed by day too");
let night = pack(0.0);
assert_eq!(night.rows[5][2], 0.0, "below the horizon: night_w is 1");
let g = DynamicInteriors::default().night_gain;
assert_eq!(g, 0.45, "the shipped default is the director's pick (2026-09-11: 0.45)");
for c in 0..3 {
assert_eq!(night.rows[0][c], base.ambient[c] * g, "ambient takes the gain");
assert_eq!(night.rows[1][c], base.diffuse[c] * g, "diffuse takes the gain");
assert_eq!(night.rows[3][c], base.spec[c] * g, "the sun halo follows its sun");
}
assert_eq!(night.rows[6][3], base.ambient[0] * g, "the SH DC follows the triple");
// The fire is the same brightness on both frames — which is the point of the feature.
assert_eq!(day.points[1], night.points[1], "a point light never takes the night dim");
assert!((night.points[1][0] - 2.0).abs() < 1e-4, "…at its authored value");
}
/// GOLDEN — MONKEY (darkness gains): `interiorGain` scales all THREE of the room lane's inputs
/// and nothing on the exterior lane.
///
/// Three inputs make a room's brightness (`static_gx.wgsl`'s `interior_room_light`): the base
/// ambient floor, the per-fixture fill gain, and the fixtures' own colours. Scaling two of the
/// three would change the room's COLOUR BALANCE rather than dim it — a 30 % cut that left the
/// ambient floor standing reads as a washed-out room, not a darker one. `interiorExposure` is
/// pointedly NOT in the set: it is the dial the user tunes live, and this must compose with it.
///
/// The exterior assertion is the seam that matters. The two lanes share one packed table, so a
/// gain applied before the lane verdict would dim every campfire and street torch in the world
/// with the candles.
#[test]
fn the_interior_gain_scales_the_room_lane_and_never_the_exterior_one() {
let mut app = packer_app();
// One recipe, one lane component apart: nothing else can separate the two entries.
let recipe = || crate::terrain_stream::point_light([1.0, 0.5, 0.25], 2.0);
let lane = |interior| LightLane { interior, generation: LightLane::SETTLED };
app.world_mut()
.spawn((recipe(), GlobalTransform::from_translation(Vec3::X), lane(true)));
app.world_mut().spawn((
recipe(),
GlobalTransform::from_translation(Vec3::new(2.0, 0.0, 0.0)),
lane(false),
));
// MONKEY (darkness gains): a DAYLIGHT fixture — an interior-lane entry that is the sun in
// a doorway. It rides the room lane but belongs to the exterior law, so the candle dial
// must step over it; without the marker check it would dim a sunlit doorway by 30 %.
app.world_mut().spawn((
recipe(),
GlobalTransform::from_translation(Vec3::new(3.0, 0.0, 0.0)),
lane(true),
crate::lighting::DaylightFixture {
instance: Entity::PLACEHOLDER,
group: 0,
portal: None,
how: crate::lighting::DaylightHow::Portal,
reach: 8.0,
cal_d: 4.0,
cal_ndl: 0.5,
},
));
let g = 0.7;
app.world_mut().insert_resource(DynamicInteriors {
ambient: 0.2,
fill: 0.5,
exposure: 3.0,
interior_gain: g,
..default()
});
app.update();
let data = app.world().resource::<WowLightData>().0;
assert_eq!(data.rows[20][0], 3.0, "all three packed");
assert_eq!(data.rows[20][1], 0.2 * g, "the base ambient floor takes the gain");
assert_eq!(data.rows[20][2], 0.5 * g, "the per-fixture fill takes the gain");
assert_eq!(data.rows[20][3], 3.0, "exposure stays the user's own dial");
// Nearest-first: the interior fixture at x=1 is entry 0, the exterior one at x=2 entry 1.
let (int, ext) = (data.points[1], data.points[3]);
assert!(int[3] > 0.5 && ext[3] == 0.0, "the lanes packed as expected: {int:?} {ext:?}");
assert!(
(int[0] - 2.0 * g).abs() < 1e-4,
"the interior fixture's colour takes the gain: {int:?}"
);
assert!(
(ext[0] - 2.0).abs() < 1e-4,
"the exterior light is untouched by it: {ext:?}"
);
let sun = data.points[5];
assert!(sun[3] > 0.5, "the daylight fixture packed on the interior lane: {sun:?}");
assert!(
(sun[0] - 2.0).abs() < 1e-4,
"…and a sunlit doorway does not dim with the candles: {sun:?}"
);
}
}
+15
View File
@@ -11,6 +11,7 @@ use benilla_assets::AssetSet;
use benilla_formats::{LightCatalog, LiquidKind};
mod blob; // the off-world light-blob builder (booth studio, body pane, glue scene)
mod daylight; // MONKEY (daylight fixtures): the sun as an interior-lane light in a doorway
mod daynight; // the two sun directions + day/night interp + the dawn/dusk warp curve
mod flicker; // MONKEY (flame flicker): the per-light fire wobble folded in at pack time
mod global_light; // the one shared global-light storage buffer (replaces the per-material push)
@@ -18,6 +19,18 @@ mod prop_probes; // the per-instance interior-prop SH probe table (slot ↔ Mesh
mod resolve; // the per-frame time-of-day sample into WowLighting + the WMO interior-fog crossfade
mod sh; // the model SH light-probe coefficient math
pub use blob::LightBlob;
// MONKEY (daylight fixtures): the marker (torch_shadow excludes it), the selection rule and the
// spawn-side helpers the placement lane calls.
// MONKEY (portal bleed): `placement_openings` is the ONE selection entry point the placement lane
// calls now -- daylight seeds and interior<->interior doorway seeds share the per-placement budget,
// so neither can be ranked without the other. `BleedFixture`/`BleedSeed` are the doorway lane's own
// two types; the fixture itself still wears `DaylightFixture`.
pub use daylight::{
daylight_claims, daylight_intensity, daylight_lane, daylight_point_light, daylight_reach,
daylight_rooms, daylight_seeds, daylight_target, bleed_seeds, placement_openings, BleedFixture,
BleedSeed,
DaylightFixture, DaylightHow, DaylightSeed, BLEED_K, MAX_DAYLIGHT_PER_PLACEMENT,
};
// MONKEY (flame flicker): the component + the one route rule, so every spawn lane files a flame
// the same way and the packer has a single function to evaluate.
pub use flicker::{flame_kind_for, flicker_seed, FlameFlicker, FlameKind, FlickerMod};
@@ -339,5 +352,7 @@ impl Plugin for LightingPlugin {
// The shared global-light buffer (build_light_data after the resolve above; the extract +
// render-world upload). Materials read this instead of carrying their own light copy.
global_light::register(app);
// MONKEY (daylight fixtures): the per-frame re-aim, ordered before the packer's own set.
daylight::register(app);
}
}
File diff suppressed because it is too large Load Diff
@@ -297,6 +297,8 @@ fn bake_cell(items: &[GxItem], meshes: &mut Assets<Mesh>) -> render::GxCellDraw
sidn: item.wmo.as_ref().map_or([0; 3], |w| w.sidn),
slot: item.prop.as_ref().and_then(|p| p.slot).unwrap_or(0),
ext_night: item.wmo.as_ref().is_some_and(|w| w.ext_night),
// MONKEY (enclosed day floor): rides the bake exactly as `ext_night` does.
enclosed: item.wmo.as_ref().is_some_and(|w| w.enclosed),
});
}
// Recentre for clip-space precision (0974): the shader reconstructs world = v + origin.
@@ -536,6 +538,7 @@ mod tests {
window: true,
batch_order: group + 1,
ext_night: false,
enclosed: false,
};
// An INT batch of group 2, pushed FIRST — Matte shade must NOT refuse it…
let mut b = batch(&g, Vec3::new(1.0, 0.0, 1.0), None, ModelBlend::Opaque);
+13
View File
@@ -136,6 +136,10 @@ mod shadow; // MONKEY (world shadows): CPU triangle collection for the static-wo
mod torch_depth; // MONKEY (torch shadows Phase 1): the per-fixture depth-map render + its targets
pub use shadow::CutoutBucket; // MONKEY (world shadows): per-leaf-texture alpha-cutout caster group
pub use torch_depth::TorchShadowViews; // MONKEY (torch shadows Phase 1): the app→render publication
// MONKEY (torch owner exclusion): the light→caster ownership key, written at the light spawn
// sites (`terrain_stream::spawn`, `benilla_app::entities::carried_light`) and read by both
// torch caster gathers, so a fixture never casts its own body's shadow into its own map.
pub use torch_depth::{torch_flame_inside_bounds, LightOwner};
// MONKEY (torch shadows Phase 3A): the shared depth image + table buffer every model material binds,
// their startup constructor, the one-param main-world accessor, and the always-on wiring — used by
// the asset foundation (`crate::assets`) and every material-building site; NOT gated on `enabled()`.
@@ -405,6 +409,10 @@ struct GxItemWmo {
/// ([`benilla_formats::room_claim::ext_building_scale`]) — the record table's bit 27, which
/// makes `static_gx.wgsl` blend it onto the interior light law after dark.
ext_night: bool,
/// MONKEY (enclosed day floor): this batch's group is an INTERIOR room inside a building-scale
/// shell ([`benilla_formats::room_claim::enclosed_by_building_shell`]) — the record table's
/// bit 28, which gives the room law a sun-driven ambient floor by day.
enclosed: bool,
/// The batch-class lane exactly as `model_render` packs `tint.w`: 0 = EXT law, 1 = INT,
/// 2 = TRANS — non-zero only on an interior group's batches.
class_lane: u8,
@@ -576,6 +584,10 @@ pub struct GxWmoBatch {
/// MOGI box ([`benilla_formats::room_claim::ext_building_scale`]), because that is the last
/// place the model's group table is in hand; it rides to the shader as a record bit.
pub ext_night: bool,
/// MONKEY (enclosed day floor): the mirror question — this is an INTERIOR-class group whose
/// box centre sits inside such a shell, i.e. a ROOM IN A BUILDING rather than a cave. Resolved
/// at the same site off the same table; rides to the shader as bit 28.
pub enclosed: bool,
/// The MOBA batch class (INT/TRANS/EXT) — the lighting-lane selector on interior groups.
pub class: Option<WmoBatchClass>,
/// The MOMT SIDN night-glow colour.
@@ -699,6 +711,7 @@ impl StaticGx {
group: w.group,
interior: w.interior,
ext_night: w.ext_night,
enclosed: w.enclosed,
class_lane: match (w.interior, w.class) {
(true, Some(WmoBatchClass::Int)) => 1,
(true, Some(WmoBatchClass::Trans)) => 2,
+17 -1
View File
@@ -75,6 +75,10 @@ pub(crate) struct GxItemDraw {
/// record table's bit 27 (see [`RECORD_EXT_NIGHT_BIT`]). False on cells, props and interior
/// batches.
pub ext_night: bool,
/// MONKEY (enclosed day floor): this is an INTERIOR-class WMO batch whose group sits inside a
/// building-scale shell — the record table's bit 28 (see [`RECORD_ENCLOSED_BIT`]). False on
/// cells, props, exterior batches, and every group of a dungeon (which authors no shell).
pub enclosed: bool,
}
/// One baked cell (or WMO region), published by the main-world flush.
@@ -188,6 +192,13 @@ const RECORD_ROOM_MASK: u32 = 0xfff;
/// `RECORD_EXT_NIGHT`.**
const RECORD_EXT_NIGHT_BIT: u32 = 1 << 27;
/// MONKEY (enclosed day floor): record column `w`, bit 28 — this batch's group is an INTERIOR room
/// inside a building-scale exterior shell, so by day `static_gx.wgsl` gives its room law a
/// sun-driven ambient floor (the daylight a doorway lets in, for the doorways this renderer cannot
/// locate). Bit 27 is the night law, so 28 is the first free bit; bits 29..=31 remain free.
/// **Keep in sync with `static_gx.wgsl`'s `RECORD_ENCLOSED`.**
const RECORD_ENCLOSED_BIT: u32 = 1 << 28;
/// The room key for one baked item: `group + 1` for a WMO surface batch, and for a PROP batch whose
/// referrer set names exactly one group (a prop in one room — the common case); 0 for a terrain
/// cell, and for a multi-room prop, which stays ungated because a single key cannot express it.
@@ -759,7 +770,11 @@ fn assemble_region(
// could carry a room key. An ungated region's ext-class group still must not
// read as night sky; its `interior_room_light` simply falls open, exactly as an
// ungated interior group's already does.
| if item.ext_night { RECORD_EXT_NIGHT_BIT } else { 0 },
| if item.ext_night { RECORD_EXT_NIGHT_BIT } else { 0 }
// MONKEY (enclosed day floor): unconditional on `gated` for the same reason —
// "is this group a room in a building" is a fact about the authored group
// table, not about whether this region could carry a room key.
| if item.enclosed { RECORD_ENCLOSED_BIT } else { 0 },
]
})
.collect();
@@ -1280,6 +1295,7 @@ mod tests {
sidn: [0; 3],
slot: 0,
ext_night: false,
enclosed: false,
}
}
+368 -39
View File
@@ -50,6 +50,11 @@ use bevy::render::{Render, RenderApp, RenderStartup, RenderSystems};
use bytemuck::Zeroable;
use super::render::StaticGxView;
// MONKEY (torch owner exclusion): the placement identity both caster lanes carry, and the
// subsystem tag that keeps a building's own batches apart from the props standing in it.
use crate::interact::WorldObject;
use crate::model_render::ModelKind;
use bevy::math::Affine3A;
use benilla_assets::materials::TorchBinds;
/// MONKEY (live bank rank): each 512-square Depth32 face is 1 MiB. Sixteen static cubes plus
@@ -57,6 +62,14 @@ use benilla_assets::materials::TorchBinds;
const TORCH_MAP_EDGE: u32 = 512;
pub(crate) const MAX_TORCH_MAPS: usize = 16;
const MAX_TORCH_DYNAMIC: usize = 8;
/// MONKEY (moving fixture): how many slots may be FULLY DYNAMIC in one frame - re-rendered from
/// scratch (static geometry AND entities) because their fixture is physically moving. Two, because
/// each one costs a whole cached slot's work every frame: the CPU gather that the static cache
/// exists to amortise, plus the render-world depth rebuild. The app lane picks the two by score and
/// fast-fades the rest ([`benilla_app::torch_shadow`]); the same number bounds the rebuild budget
/// in [`torch_cache_plan`], so a moving fixture can never starve a resident room's static maps of
/// their own (separate) two.
pub(crate) const TORCH_MOVING_MAX: usize = 2;
pub(crate) const CUBE_FACES: usize = 6;
/// MONKEY (live bank rank): matrix count stays 96, independent of texture capacity. Static
/// layers 0..96 stay slot-addressed; live layers 96..144 follow ascending dynamic-mask rank.
@@ -70,6 +83,112 @@ pub(crate) const TORCH_TABLE_BYTES: u64 = 6416;
/// in sync with `TORCH_EXT_LANE` in BOTH `static_gx.wgsl` and `wow_model.wgsl`.
const TORCH_EXT_LANE: u32 = 1;
/// MONKEY (torch owner exclusion): WHO a light belongs to — the identity a caster gather uses to
/// drop a fixture's OWN body out of the fixture's OWN shadow map.
///
/// The bug this answers. A synthetic fire light is placed AT THE FLAME, and a flame is *inside* the
/// thing that burns it: inside a lantern's glass housing, inside a campfire's ring of logs, at the
/// head of a wall torch. Nothing in this lane knew the two belonged together — the light is an ECS
/// entity, the mesh is either a retained [`super::GxItem`] or a model-part entity, and the
/// placement that spawned both is not carried on either side — so the fixture was the nearest
/// occluder on all six of its cube faces and blacked out its own pool. That is the moving bright
/// WEDGE on the ground under the inn's swinging lantern (the housing shadowing every direction but
/// one gap), the BLACK SQUARE under the wall torch by the crate, and the dark blotches under the
/// faire torches.
///
/// The first attempt approximated ownership by PROXIMITY (the retired `TORCH_EXT_SELF_EXCLUDE`:
/// drop an item whose whole bounding sphere lies within 2.5 yd of the flame). That covers a
/// campfire — a prop that is a ball around its own flame — and essentially nothing else: a lantern
/// hanging off a 4-yd post, or a torch on a tall bracket, has bounds that run all the way to the
/// ground and is never "wholly within 2.5 yd" of anything. So ownership is carried EXPLICITLY here,
/// and proximity is demoted to a bounded fallback ([`torch_bound_contains`]).
///
/// Two shapes, because lights reach the world by two routes:
/// * [`Self::Placement`] — a placed ADT doodad's or WMO prop's own M2 light
/// (`terrain_stream::spawn`'s `spawn_lights_for`). The key is the placement's
/// [`WorldObject`] identity, because that is the one identity BOTH caster lanes already carry:
/// the retained `GxItem` holds it as an `Arc`, and the entity path's parts hold a CLONE of it.
/// Compared by CONTENT (kind + placement uniqueId + label hash) for exactly that reason — an
/// `Arc` pointer would match the retained half and never the entity half.
/// * [`Self::Instance`] — an entity-hosted light (`entities::carried_light`): a GameObject
/// brazier, a placed campfire, an NPC's torch. Those hang under the host model's FRAME entity,
/// which no `WorldObject` is available at, so the frame IS the identity and a caster part is
/// "mine" iff it is a descendant of it.
///
/// Deliberately NOT written onto a WMO's authored MOLT fixtures. A MOLT light has no model of its
/// own, and its placement identity is the BUILDING's — tagging it would exclude every wall, floor
/// and pillar of the building from its own torch's map, i.e. delete the interior shadow lane.
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
pub enum LightOwner {
Placement {
kind: ModelKind,
/// The placement uniqueId (`WorldObject::id`).
id: u32,
/// A hash of the model path, so two identical lanterns in one building are told apart from
/// its barrels and its chairs (WMO props all share their BUILDING's uniqueId).
label: u64,
},
Instance(Entity),
}
impl LightOwner {
/// The placement key of `object` — the value both caster lanes are compared against.
pub fn placement(object: &WorldObject) -> Self {
Self::Placement {
kind: object.kind,
id: object.id,
label: label_hash(&object.label),
}
}
/// Does this owner name `object`? The two SCALAR lanes are tested first and the label hash only
/// if they both match: this runs per candidate item inside a 48-yd gather, and hashing a model
/// path for every barrel in a city would be real cost for an answer that is almost always "no".
pub fn owns(&self, object: &WorldObject) -> bool {
match *self {
Self::Placement { kind, id, label } => {
id == object.id && kind == object.kind && label == label_hash(&object.label)
}
Self::Instance(_) => false,
}
}
/// The host model frame, for the entity lane's ancestry test — `None` for a placed light.
pub fn instance(&self) -> Option<Entity> {
match *self {
Self::Instance(e) => Some(e),
Self::Placement { .. } => None,
}
}
}
fn label_hash(label: &str) -> u64 {
let mut hash = std::collections::hash_map::DefaultHasher::new();
label.hash(&mut hash);
hash.finish()
}
/// MONKEY (torch owner exclusion): how far OUTSIDE its own bounds a flame may sit and still count
/// as being inside the fixture (yd). A synthesised light is positioned from the model's flame
/// EMITTER, which routinely sits a finger's width proud of the housing it burns in; 0.3 yd closes
/// that gap without reaching anything the fixture merely stands next to.
const TORCH_SELF_PAD: f32 = 0.3;
/// MONKEY (torch owner exclusion): the containment fallback applies only to FIXTURE-SIZED items —
/// this is the cap on the transformed bounding-sphere radius (yd).
///
/// Unbounded containment would be a disaster, and specifically on the lane that works today: a WMO
/// group's wall batch is a ROOM-sized box, it trivially "contains" every candle in the room, and
/// excluding it would delete that room's shadows and leak the candle straight through its walls.
/// 6 yd covers a tall lamp post, a gallows-arm lantern bracket and a bonfire whole, and is an order
/// of magnitude under any wall, floor, building or terrain batch.
const TORCH_SELF_MAX_EXTENT: f32 = 6.0;
/// MONKEY (torch owner exclusion): the last resort, for items whose bounds are DEGENERATE (a candle
/// flame card, a zero-extent helper) where containment cannot decide anything. An item whose WHOLE
/// bounding sphere lies inside this of the flame is the flame's own body by construction. This is
/// the surviving half of the retired `TORCH_EXT_SELF_EXCLUDE`, shrunk from 2.5 yd to 0.6 so that it
/// can no longer swallow the crate, fence post or barrel standing beside the fire.
const TORCH_SELF_TINY: f32 = 0.6;
/// **The app→render publication** (`benilla_app::torch_shadow` writes it each frame, extracted here):
/// the promoted fixtures' cube-face matrices, world positions and static mesh revisions.
/// `count` is the sparse slot high-water mark (0 when the lane is off).
@@ -84,6 +203,19 @@ pub struct TorchShadowViews {
/// 0.5..3). Carried here rather than in `DynamicInteriors` because it belongs to the shadow
/// table's own bytes — [`TorchTableUniform::pack`] puts it in the otherwise-padding `count.y`.
pub soft: f32,
/// MONKEY (shadow floor): the live torch SHADOW STRENGTH (`torchShadowStrength`, 0..1, default
/// 0.7) - how much of the direct term a fully shadowed fragment loses. It rides the same
/// `count.y` word as [`Self::soft`] (high half; see [`TorchTableUniform::pack`]) and the
/// receivers fold it into the slot's cross-fade weight, so a shadow darkens the direct arm to
/// 30 % instead of to nothing. The fill/ambient arms never saw this factor and are untouched.
pub strength: f32,
/// MONKEY (moving fixture): which slots are MOVING this frame - a carried/unit-hosted fixture
/// or one that has drifted off the position its cached map was baked at. They get their own
/// rebuild budget in [`torch_cache_plan`] ([`TORCH_MOVING_MAX`]) ahead of the resident slots,
/// because a moving fixture's map is WRONG (not merely stale) the moment it is deferred: the
/// projections republish from the live position every frame while the depth still holds the
/// old one, which is the smeared, lagging pool the imp dragged across the inn floor.
pub moving_mask: u32,
/// MONKEY (outdoor torch shadows): does the EXTERIOR receiver lane run this frame? The app lane
/// sets it when `exteriorShadows` is on, the sun is below the daylight threshold, AND at least
/// one promoted slot is an exterior fixture. It rides to the receivers in the table's
@@ -111,6 +243,9 @@ pub struct TorchShadowViews {
impl TorchShadowViews {
/// MONKEY (live bank rank): expose the allocation cap through the existing app resource.
pub const MAX_TORCH_DYNAMIC: usize = MAX_TORCH_DYNAMIC;
/// MONKEY (moving fixture): ONE definition of the moving budget, shared by the app lane that
/// picks the slots and the render plan that rebuilds them.
pub const MAX_TORCH_MOVING: usize = TORCH_MOVING_MAX;
/// MONKEY (live bank rank): WGSL countOneBits(mask & ((1u << slot) - 1u)). Use the
/// FINAL ready-filtered mask for rendering; app traces describe only requested ranks.
@@ -130,6 +265,10 @@ impl Default for TorchShadowViews {
dynamic_mask: 0,
ready_mask: 0,
soft: 1.0,
// MONKEY (shadow floor): 1.0 = the pre-feature pitch-black shadow, so a views value
// that never went through the cvar cannot silently lighten the world.
strength: 1.0,
moving_mask: 0,
exterior: false,
positions: [Vec4::ZERO; MAX_TORCH_MAPS],
view_projs: [Mat4::ZERO; MAX_TORCH_LAYERS],
@@ -235,7 +374,7 @@ pub fn new_torch_shared(
}
/// MONKEY (static torch cache): byte-identical std140/std430 table in BOTH receiver shaders.
/// count@0: live high-water mark, soft*100, dynamic mask, lane flags (16 bytes).
/// count@0: live high-water mark, (strength*100 << 16 | soft*100), dynamic mask, lane flags (16 B).
/// positions[16]@16: world xyz + fade weight (256 bytes).
/// view_projs[96]@272: six static-bank face matrices per physical slot (6144 bytes).
/// Total 6416. Unready/hole slots have zero weight; no stale owner's depth can be sampled.
@@ -246,7 +385,13 @@ pub(crate) struct TorchTableUniform {
/// MONKEY (torch caster selection): `soft x 100` — the PCF tap-radius scale, riding the first
/// of the three padding words the `vec4<u32>` alignment already forced us to carry. The
/// alternative was a whole new 16-byte row for one dial.
soft_x100: u32,
///
/// MONKEY (shadow floor): and now `strength x 100` in its HIGH 16 bits, for the same reason
/// one level down — the word was carrying one dial in a range (1..300) that needs nine bits,
/// and growing the table by a row costs 16 bytes in a struct whose SIZE is the contract with
/// three shaders (a mismatch hides every building). Low half `soft`, high half `strength`;
/// both receivers mask/shift rather than reading the word whole.
soft_strength: u32,
dynamic_mask: u32,
/// MONKEY (outdoor torch shadows): lane flags, the word that was reserved padding.
/// Bit 0 ([`TORCH_EXT_LANE`]) = the EXTERIOR receiver lane is live this frame. Both WGSL
@@ -273,7 +418,14 @@ impl TorchTableUniform {
// MONKEY (torch caster selection): 0 would mean a zero tap radius (four identical taps =
// a hard edge), so a table published without a scale reads as the neutral 1.0.
let soft = if views.soft > 0.01 { views.soft } else { 1.0 };
table.soft_x100 = (soft * 100.0).round().max(1.0) as u32;
// MONKEY (shadow floor): the cvar is clamped 0..1 on the way in, so the pack only has to
// keep the two halves from colliding - `soft` is capped at 3.0 (300, nine bits) by its own
// cvar clamp and belt-and-braces here, and `strength` cannot exceed 100. A strength of 0 is
// MEANINGFUL (shadows off) and must survive the pack, which is why it gets no `max(1)`.
let soft_pct = ((soft * 100.0).round() as i64).clamp(1, 0xffff) as u32;
let strength_pct = ((views.strength.clamp(0.0, 1.0) * 100.0).round() as i64)
.clamp(0, 100) as u32;
table.soft_strength = (strength_pct << 16) | soft_pct;
// MONKEY (outdoor torch shadows): the exterior receiver lane's live gate. Packed even when
// `count` is 0 costs nothing and is simpler to reason about than a conditional flag.
table.flags = if views.exterior { TORCH_EXT_LANE } else { 0 };
@@ -535,7 +687,8 @@ fn prepare_torch_depth(
return;
}
let cached = cache.0.lock().unwrap();
let (ready_mask, rebuild_mask, dynamic_mask) = torch_cache_plan(&*cached, &views.caster_meshes[..count], views.dynamic_mask,
let (ready_mask, rebuild_mask, dynamic_mask) = torch_cache_plan(
&*cached, &views.caster_meshes[..count], views.dynamic_mask, views.moving_mask,
|id| torch_mesh_ready(id, &meshes, &allocator));
views.ready_mask = ready_mask;
views.dynamic_mask = dynamic_mask;
@@ -632,8 +785,12 @@ impl ViewNode for TorchDepthNode {
if trace_due { *last_trace = Some(std::time::Instant::now()); }
for slot in 0..count / CUBE_FACES {
if views.ready_mask & (1 << slot) == 0 { continue; }
// MONKEY (moving fixture): same reasoning as `torch_cache_plan` — a live slot's mesh
// id is stable by design, so the id comparison would veto every rebuild the plan just
// granted it and freeze the map at the fixture's first position.
let moving = views.moving_mask & (1 << slot) != 0;
let rebuild = draw.rebuild_mask & (1 << slot) != 0
&& cached[slot] != views.caster_meshes[slot];
&& (moving || cached[slot] != views.caster_meshes[slot]);
let dynamic = views.dynamic_mask & (1 << slot) != 0;
let live_rank = dynamic.then(|| TorchShadowViews::live_rank(views.dynamic_mask, slot));
// MONKEY (static torch cache): render static once, then copy to the disjoint live
@@ -753,14 +910,34 @@ pub(super) fn build(app: &mut App) {
// MONKEY (static torch cache): planning does not commit residency. A missing upload cannot
// certify an empty map, and the two-rebuild budget also covers GPU texture recreation.
//
// MONKEY (moving fixture): `moving_mask`'s slots draw from a SEPARATE budget of
// [`TORCH_MOVING_MAX`], and they draw from it first. Two reasons it cannot be one shared budget:
// a moving slot asks for a rebuild EVERY frame (its mesh id changes every frame by construction),
// so sharing would let two moving fixtures own the budget forever and freeze every resident room's
// static map at whatever it last held; and a deferred moving rebuild is not a stale map but a
// wrong one — the table republishes its six projections from the live fixture position each frame
// while the depth still holds the old one, i.e. a shadow drawn from where the light no longer is.
fn torch_cache_plan<T: Copy + Eq>(cached: &[Option<T>], requested: &[Option<T>], dynamic_mask: u32,
mut mesh_ready: impl FnMut(T) -> bool) -> (u32, u32, u32) {
moving_mask: u32, mut mesh_ready: impl FnMut(T) -> bool) -> (u32, u32, u32) {
let (mut ready, mut rebuild) = (0u32, 0u32);
let mut budget = 2usize;
let mut moving_budget = TORCH_MOVING_MAX;
for (i, requested) in requested.iter().take(MAX_TORCH_MAPS).enumerate() {
let Some(id) = *requested else { continue };
if cached[i] == Some(id) {
let moving = moving_mask & (1 << i) != 0;
// MONKEY (moving fixture): a live slot MUTATES its caster mesh in place and keeps its asset
// id (an asset added this frame would not be certified resident until the next one, and the
// slot would lose its ready bit — a zero weight — on every frame its fixture moved). So the
// usual "same id ⇒ the cached depth is still good" shortcut is exactly wrong here: the id
// is the same and the CONTENTS are a frame old. A live slot always takes the rebuild path.
if !moving && cached[i] == Some(id) {
ready |= 1 << i;
} else if rebuild.count_ones() < 2 && mesh_ready(id) {
continue;
}
let lane = if moving { &mut moving_budget } else { &mut budget };
if *lane > 0 && mesh_ready(id) {
*lane -= 1;
rebuild |= 1 << i;
ready |= 1 << i;
}
@@ -805,12 +982,17 @@ impl super::StaticGx {
/// MONKEY (static torch cache): fixture-local static collection uses batch bounds, not the
/// building's placement origin. A distant Abbey room is still part of a WMO anchored more
/// than 48 yards away. The key and the mesh MUST use this identical admission predicate.
pub fn append_torch_triangles(&self, center: Vec3, reach: f32, self_exclude: f32,
positions: &mut Vec<[f32; 3]>, indices: &mut Vec<u32>) {
///
/// MONKEY (torch owner exclusion): returns how many in-range items were dropped as the
/// FIXTURE'S OWN BODY — the `WOW_TORCH_TRACE` counter, and the one number that says whether
/// this fix is doing anything for a given slot.
pub fn append_torch_triangles(&self, center: Vec3, reach: f32, owner: Option<LightOwner>,
positions: &mut Vec<[f32; 3]>, indices: &mut Vec<u32>) -> u32 {
let mut excluded = 0;
for cell in self.cells.values().chain(self.wmos.values()).chain(self.props.values()) {
for item in &cell.items {
if !torch_item_in_range(item, center, reach) { continue; }
if torch_item_is_emitter(item, center, self_exclude) { continue; }
if torch_item_is_emitter(item, center, owner) { excluded += 1; continue; }
let base = positions.len() as u32;
positions.extend(item.geometry.positions.iter().map(|p|
item.transform.transform_point(benilla_assets::coords::wow_to_bevy(*p)).to_array()));
@@ -820,6 +1002,7 @@ impl super::StaticGx {
}
}
}
excluded
}
/// MONKEY (torch lane perf): a CHEAP stamp of "has the retained scene changed at all" —
@@ -851,12 +1034,12 @@ impl super::StaticGx {
/// by append_torch_triangles. Order-independent per-item hashing ignores HashMap reordering
/// and camera/portal/fader bookkeeping. Arrival, unload, replacement or transform edits in
/// THIS fixture's sphere invalidate it even when neither camera nor fixture moves.
pub fn torch_geometry_key(&self, center: Vec3, reach: f32, self_exclude: f32) -> u64 {
pub fn torch_geometry_key(&self, center: Vec3, reach: f32, owner: Option<LightOwner>) -> u64 {
let mut sum = 0u64;
for cell in self.cells.values().chain(self.wmos.values()).chain(self.props.values()) {
for item in &cell.items {
if !torch_item_in_range(item, center, reach) { continue; }
if torch_item_is_emitter(item, center, self_exclude) { continue; }
if torch_item_is_emitter(item, center, owner) { continue; }
let mut hash = std::collections::hash_map::DefaultHasher::new();
// MONKEY (static torch cache): source residency stamp prevents allocator address
// reuse after unload/reload from impersonating the previous geometry Arc.
@@ -870,30 +1053,70 @@ impl super::StaticGx {
}
}
/// MONKEY (outdoor torch shadows): EMITTER-OWNER EXCLUSION — is this item the fixture's own body?
/// MONKEY (torch owner exclusion): EMITTER-OWNER EXCLUSION — is this item the fixture's own body?
///
/// A campfire's light sits AT the flame, inside the campfire prop's own opaque logs, and nothing in
/// this lane knows the two belong together (the light is an ECS entity, the logs are a retained
/// `GxItem`; the placement that spawned both is not carried on either side). Left in, the logs are
/// the nearest occluder in every one of the six cube faces and the fire blacks out its OWN pool —
/// which indoors never mattered, because an authored MOLT fixture stands clear of its bracket,
/// and outdoors is the whole effect.
/// Three tests, cheapest and most exact first. Only the first is IDENTITY; the other two are the
/// fallback for a light whose owner could not be plumbed (a server-placed GameObject fire, a light
/// whose placement tag has not landed yet) and for one whose own model is split across placements.
///
/// So identity is approximated by CONTAINMENT: an item whose entire bounding sphere lies inside
/// `radius` of the fixture is the fixture's own body (or is so close to it that it can cast
/// nothing but self-shadow). Containment, not centre distance, is what keeps a wall or a fence
/// that merely PASSES near the fire — a large sphere with a near centre — casting normally.
/// `radius <= 0` (the interior lane, and any caller that wants the old set) excludes nothing, so
/// the interior gather is byte-for-byte the set it always was. An item with no bounds is never
/// excluded: the failure direction of this test must be "keeps a caster", not "loses a wall".
fn torch_item_is_emitter(item: &super::GxItem, center: Vec3, radius: f32) -> bool {
item.local_aabb.is_some_and(|aabb| torch_bound_contained(
&item.transform, aabb.center.into(), aabb.half_extents.into(), center, radius))
/// 1. OWNERSHIP. `owner` names a placement and this item belongs to it — see [`LightOwner`]. This
/// is the case the lantern-on-a-post and the tall wall torch need: their bounds reach the
/// ground, so no proximity rule of any radius could have found them.
/// 2. CONTAINMENT of the FLAME. The item's own bounds hold the light position (padded by
/// [`TORCH_SELF_PAD`]) and the item is FIXTURE-SIZED ([`TORCH_SELF_MAX_EXTENT`]). A lamp
/// housing contains its own flame; a crate a yard and a half away does not. The size cap is
/// load-bearing, not tidiness — see the constant.
/// 3. The DEGENERATE case: the item's whole bounding sphere lies within [`TORCH_SELF_TINY`] of the
/// flame, i.e. it is a flame card or a helper sitting on top of the light and can cast nothing
/// but self-shadow. This is all that survives of the old 2.5-yd proximity rule.
///
/// An item with no bounds is never excluded by 2 or 3: the failure direction of a heuristic here
/// must be "keeps a caster", not "loses a wall".
fn torch_item_is_emitter(item: &super::GxItem, center: Vec3, owner: Option<LightOwner>) -> bool {
if owner.is_some_and(|o| o.owns(&item.object)) {
return true;
}
item.local_aabb.is_some_and(|aabb| {
let (c, h) = (Vec3::from(aabb.center), Vec3::from(aabb.half_extents));
torch_bound_contains(&item.transform, c, h, center)
|| torch_bound_contained(&item.transform, c, h, center, TORCH_SELF_TINY)
})
}
/// The containment half of [`torch_item_is_emitter`], split out exactly as `torch_bound_in_range`
/// MONKEY (torch owner exclusion): does this bound CONTAIN the flame? Tested in MODEL space (the
/// inverse transform), so a rotated lamp post is measured against its own box rather than against
/// the circumsphere a world-space test would have to use — the difference between "the lantern
/// housing" and "a 3-yd ball centred on the lantern" that would swallow the porch it hangs from.
/// The size cap is applied FIRST and on the cheap sphere radius, so the inverse is only ever paid
/// by the handful of fixture-sized items standing at a flame.
///
/// `pub` and taking an `Affine3A` because BOTH caster lanes need the identical rule and they hold
/// their pose in different shapes: the retained lane has a `Transform`, the entity lane a
/// `GlobalTransform`. Two copies of a heuristic that decides "does this cast" would drift.
pub fn torch_flame_inside_bounds(world_from_local: Affine3A, local_center: Vec3,
half_extents: Vec3, light: Vec3) -> bool {
let m = world_from_local.matrix3;
// The basis columns' lengths ARE the scale, whatever rotation is folded in with them.
let scale = Vec3::new(m.x_axis.length(), m.y_axis.length(), m.z_axis.length());
if (half_extents * scale).length() > TORCH_SELF_MAX_EXTENT {
return false;
}
let local = world_from_local.inverse().transform_point3(light);
// A world-space pad has to be expressed in model units, hence the divide; the floor keeps a
// degenerate (zero-scale) placement from producing an infinite pad that contains the world.
let pad = Vec3::splat(TORCH_SELF_PAD) / scale.max(Vec3::splat(1e-4));
(local - local_center).abs().cmple(half_extents + pad).all()
}
/// The retained lane's shape of [`torch_flame_inside_bounds`].
fn torch_bound_contains(transform: &Transform, local_center: Vec3, half_extents: Vec3,
light: Vec3) -> bool {
torch_flame_inside_bounds(transform.compute_affine(), local_center, half_extents, light)
}
/// The proximity half of [`torch_item_is_emitter`], split out exactly as `torch_bound_in_range`
/// is: the transformed bounding sphere lies WHOLLY inside `radius` of the fixture. `radius <= 0`
/// (the interior lane) contains nothing.
/// contains nothing.
fn torch_bound_contained(transform: &Transform, local_center: Vec3, half_extents: Vec3,
center: Vec3, radius: f32) -> bool {
if radius <= 0.0 {
@@ -927,12 +1150,46 @@ mod tests {
#[test]
fn cache_plan_reuses_and_limits_rebuilds() {
let cached = [Some(10), Some(11), None, Some(13)];
assert_eq!(torch_cache_plan(&cached, &cached, 0xf, |_| false), (0b1011, 0, 0b1011));
assert_eq!(torch_cache_plan(&cached, &cached, 0xf, 0, |_| false), (0b1011, 0, 0b1011));
let requested = [Some(20), Some(11), Some(12), Some(23)];
assert_eq!(torch_cache_plan(&cached, &requested, 0xf, |_| true), (0b0111, 0b0101, 0b0111));
assert_eq!(torch_cache_plan(&cached, &requested, 0xf, |id| id == 23), (0b1010, 0b1000, 0b1010));
assert_eq!(torch_cache_plan(&cached, &[None, Some(11)], 0xf, |_| true), (0b10, 0, 0b10));
assert_eq!(torch_cache_plan(&[None; 4], &requested, 0, |_| true), (0b11, 0b11, 0));
assert_eq!(torch_cache_plan(&cached, &requested, 0xf, 0, |_| true), (0b0111, 0b0101, 0b0111));
assert_eq!(torch_cache_plan(&cached, &requested, 0xf, 0, |id| id == 23), (0b1010, 0b1000, 0b1010));
assert_eq!(torch_cache_plan(&cached, &[None, Some(11)], 0xf, 0, |_| true), (0b10, 0, 0b10));
assert_eq!(torch_cache_plan(&[None; 4], &requested, 0, 0, |_| true), (0b11, 0b11, 0));
}
// MONKEY (moving fixture): a moving slot rebuilds out of its OWN budget, so two moving
// fixtures cannot consume the resident slots' two - the failure that would freeze a room's
// static shadows for as long as a pet walked around in it.
#[test]
fn moving_slots_rebuild_on_a_separate_budget() {
let cached = [None, None, None, None];
let requested = [Some(1), Some(2), Some(3), Some(4)];
// No moving slots: the historical two-rebuild ceiling, untouched.
assert_eq!(torch_cache_plan(&cached, &requested, 0, 0, |_| true).1, 0b0011);
// Slots 0 and 1 moving: they take the moving budget and slots 2/3 still get the static one.
assert_eq!(torch_cache_plan(&cached, &requested, 0, 0b0011, |_| true).1, 0b1111);
// Three moving: the third waits (it fast-fades app-side rather than showing a wrong map).
assert_eq!(torch_cache_plan(&cached, &requested, 0, 0b0111, |_| true).1, 0b1011);
}
// MONKEY (shadow floor): the two dials share one word; neither may corrupt the other, and a
// strength of 0 (shadows off) must survive the pack as a real 0 rather than a floored 1.
#[test]
fn count_y_packs_soft_low_and_strength_high() {
for (soft, strength, soft_pct, strength_pct) in [
(1.5f32, 0.7f32, 150u32, 70u32),
(3.0, 1.0, 300, 100),
(0.5, 0.0, 50, 0),
// Out-of-range inputs are clamped, never wrapped into the other half.
(0.0, 2.0, 100, 100),
(0.5, -1.0, 50, 0),
] {
let views = TorchShadowViews { count: 1, soft, strength, ..Default::default() };
let word = TorchTableUniform::pack(Some(&views)).soft_strength;
assert_eq!(word & 0xffff, soft_pct, "soft {soft}");
assert_eq!(word >> 16, strength_pct, "strength {strength}");
}
}
#[test]
@@ -950,11 +1207,11 @@ mod tests {
}
}
let cached = [Some(1); MAX_TORCH_MAPS];
let (_, _, capped) = torch_cache_plan(&cached, &cached, 0xffff, |_| false);
let (_, _, capped) = torch_cache_plan(&cached, &cached, 0xffff, 0, |_| false);
assert_eq!(capped, 0xff);
let mut requested = cached;
requested[0] = None;
let (ready, _, dynamic) = torch_cache_plan(&cached, &requested, 0x8001, |_| false);
let (ready, _, dynamic) = torch_cache_plan(&cached, &requested, 0x8001, 0, |_| false);
let views = TorchShadowViews { count: 16, ready_mask: ready, dynamic_mask: dynamic, ..Default::default() };
let table = TorchTableUniform::pack(Some(&views));
assert_eq!(dynamic, 0x8000);
@@ -1020,4 +1277,76 @@ mod tests {
let scaled = Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::splat(4.0));
assert!(!torch_bound_contained(&scaled, Vec3::ZERO, Vec3::splat(0.5), Vec3::ZERO, 2.5));
}
// MONKEY (torch owner exclusion): the IDENTITY key. Both caster lanes have to agree on it while
// holding the placement identity in two different shapes — the retained `GxItem` owns an `Arc`,
// the entity path's parts own a CLONE — so the comparison must be by CONTENT, never by pointer.
// And WMO props all share their building's uniqueId, which is why the model path is in the key:
// without it a candelabra's light would drop every barrel and chair in the building.
#[test]
fn a_light_owner_names_one_placement_by_content() {
let lantern = WorldObject {
kind: ModelKind::Doodad,
label: "world/generic/lantern01.m2".into(),
id: 4242,
detail: "emitters: 1".into(),
};
let owner = LightOwner::placement(&lantern);
// The entity lane's CLONE of the same identity still matches (a pointer key would not).
assert!(owner.owns(&lantern.clone()));
// Same building, different prop model — a WMO prop shares its building's uniqueId.
let barrel = WorldObject { label: "world/generic/barrel01.m2".into(), ..lantern.clone() };
assert!(!owner.owns(&barrel));
// Same model, different placement: the lantern down the street still casts.
let other = WorldObject { id: 4243, ..lantern.clone() };
assert!(!owner.owns(&other));
// The BUILDING itself is a different subsystem, so a prop light can never delete its shell.
let building = WorldObject { kind: ModelKind::Wmo, ..lantern.clone() };
assert!(!owner.owns(&building));
// An entity-hosted light names a frame, and never claims a placement by accident.
assert!(!LightOwner::Instance(Entity::from_raw_u32(7).unwrap()).owns(&lantern));
assert_eq!(owner.instance(), None);
}
// MONKEY (torch owner exclusion): the CONTAINMENT fallback, for a light whose owner could not
// be plumbed. The housing that holds the flame is excluded; the crate beside it is not; and —
// the rule that keeps the shipped interior lane intact — a room-sized batch is never excluded
// however deep inside it the candle sits.
#[test]
fn only_a_fixture_sized_bound_may_contain_its_own_flame() {
let at = |x: f32, y: f32| Transform::from_xyz(x, y, 0.0);
// A lantern housing 3 yd up a post: the placement's box runs from the ground to the lamp,
// so its CENTRE is 1.5 yd below the flame and no proximity rule of any radius finds it —
// the exact case `TORCH_EXT_SELF_EXCLUDE = 2.5` could not exclude.
let post = Vec3::new(0.0, 1.5, 0.0);
let post_half = Vec3::new(0.4, 1.6, 0.4);
let flame = Vec3::new(0.0, 3.0, 0.0);
assert!(torch_bound_contains(&at(0.0, 0.0), post, post_half, flame));
assert!(!torch_bound_contained(&at(0.0, 0.0), post, post_half, flame, TORCH_SELF_TINY),
"the retired proximity rule never saw it");
// The crate 1.5 yd away keeps casting — that shadow is the whole point of the feature.
assert!(!torch_bound_contains(&at(1.5, 0.3), Vec3::ZERO, Vec3::splat(0.5), flame));
// A room-sized wall batch holds the candle but is NOT fixture-sized: excluding it would
// delete the room's shadows and leak the candle through its own walls.
assert!(!torch_bound_contains(&at(0.0, 0.0), Vec3::ZERO, Vec3::splat(20.0), flame));
assert!(!torch_bound_contains(&at(0.0, 0.0), Vec3::ZERO,
Vec3::splat(TORCH_SELF_MAX_EXTENT), flame), "at the cap, still a building");
// The pad reaches a flame sitting just proud of its housing, and stops well short of the
// next prop along.
let head = Vec3::new(0.0, 0.0, 0.0);
let head_half = Vec3::splat(0.25);
assert!(torch_bound_contains(&at(0.0, 0.0), head, head_half, Vec3::new(0.0, 0.5, 0.0)));
assert!(!torch_bound_contains(&at(0.0, 0.0), head, head_half, Vec3::new(0.0, 0.9, 0.0)));
// Scale applies to the box AND to the pad (a world-space pad in model units).
let big = Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::splat(4.0));
assert!(torch_bound_contains(&big, Vec3::ZERO, Vec3::splat(0.3), Vec3::new(0.0, 1.2, 0.0)));
assert!(!torch_bound_contains(&big, Vec3::ZERO, Vec3::splat(2.0), Vec3::ZERO),
"scaled past the fixture cap");
// A rotated post is tested against its own box, not its circumsphere.
let spun = Transform::from_xyz(0.0, 0.0, 0.0)
.with_rotation(Quat::from_rotation_y(std::f32::consts::FRAC_PI_2));
assert!(torch_bound_contains(&spun, Vec3::new(0.0, 1.5, 2.0), post_half,
Vec3::new(2.0, 3.0, 0.0)));
assert!(!torch_bound_contains(&spun, Vec3::new(0.0, 1.5, 2.0), post_half, flame));
}
}
@@ -448,6 +448,15 @@ pub fn spawn_model_entities(
ext_night: bounds
.get(usize::from(g))
.is_some_and(benilla_formats::room_claim::ext_building_scale),
// MONKEY (enclosed day floor): this batch's group is a ROOM
// INSIDE A BUILDING -- an interior-class group whose centre
// sits in a building-scale exterior shell of the same root.
// Same place, same table, same argument as `ext_night` above:
// this is the last point at which the model's group table is
// in hand, and the answer rides to the shader as a record bit.
enclosed: benilla_formats::room_claim::enclosed_by_building_shell(
bounds, g,
),
class: sub.wmo_batch,
sidn: sub.sidn,
window: sub.window,
@@ -6,7 +6,7 @@
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_assets::{ModelEmitter, ModelLight, WmoModel};
use benilla_formats::room_claim::{self, PortalGraph};
use benilla_formats::{WmoGroupInfo, WmoLight};
use benilla_formats::{WmoBatchClass, WmoGroupInfo, WmoLight};
use bevy::prelude::*;
use crate::particles;
@@ -158,7 +158,15 @@ pub(super) fn spawn_lights_for(
claims: Option<&PropClaims<'_>>,
out: &mut Vec<Entity>,
) {
for l in lights.iter().filter(|l| synthetic || !l.synthetic) {
// MONKEY (spell light): `l.spell.is_none()` keeps the PLACED lane exactly as it was. The spell
// route (`fire_light::synthesize_spell_light`) lights effect models and fireworks, whose whole
// premise — a light that exists for the length of one cast, budgeted and enveloped — belongs to
// the entity lanes that reap it with its effect. A spell model placed as scenery must not enter
// a table built for fixtures that stand still for minutes.
for l in lights
.iter()
.filter(|l| (synthetic || !l.synthetic) && l.spell.is_none())
{
let def = &l.def;
if !def.casts() {
continue; // directional lights feed an ambient term; a static `0` visibility key is dark
@@ -543,6 +551,100 @@ pub(super) fn spawn_wmo_lights_for(
}
}
/// MONKEY (daylight fixtures): spawn one interior-lane point light per EXTERIOR-FACING OPENING of
/// this placement — the doorways and windows whose sunlit threshold the room law otherwise meets
/// with a hard line (see `crate::lighting`'s `daylight` module for the whole argument, the two
/// seeds and the corpus measurement behind them).
///
/// It is deliberately the SAME entity shape a MOLT fixture gets — [`crate::lighting::LightRooms`]
/// for the visibility gate, the `room_claims` claim set through [`lit_rooms`],
/// [`crate::lighting::LightReach`] for the packer's window, an interior `LightLane` — so the
/// packer, the claim table, the shader and the std430 layout all need no knowledge that this source
/// is the sun rather than a candle. What it does NOT get is a `FlameFlicker` (daylight is steady)
/// or `SyntheticFireLight` (`fireLightGain` must not be able to dim the sun).
///
/// The `PointLight` itself is NOT spawned here: `update_daylight_fixtures` inserts it on the first
/// frame it is day, with the sun-calibrated colour and intensity. A placeholder would pack one
/// uncalibrated frame — a flash of the wrong brightness in every doorway of a building that just
/// streamed in — and a placement streaming in at night would spawn a light only to have it removed.
///
/// `instance` absent (a placement with no portal instance) means there is nothing to key a room
/// claim to, and an unclaimed interior fixture is packed UNGATED, which fails OPEN — one doorway
/// lighting every room of the building through its walls. Such a placement gets no daylight at all,
/// exactly as the MOLT lane drops its own claims there.
pub(super) fn spawn_daylight_fixtures_for(
commands: &mut Commands,
model: &WmoModel,
portals: PortalGraph<'_>,
instance: Option<Entity>,
transform: Transform,
out: &mut Vec<Entity>,
) {
let Some(instance) = instance else {
return;
};
// The APERTURE seed's input: every render batch of the root as `(group, EXT-class?, points)`,
// borrowed straight off the loaded asset's two parallel arrays. The selection rule consumes it
// lazily, so a root whose groups are all exterior walks the batches once and allocates nothing.
let batches = model
.submeshes
.iter()
.zip(model.submesh_group.iter())
.map(|(s, g)| {
(
*g,
matches!(s.wmo_batch, Some(WmoBatchClass::Ext)),
&s.geometry.positions[..],
)
});
// MONKEY (portal bleed): one selection call for both lanes -- the exterior-facing openings that
// get the SUN and the interior<->interior doorways that get the ROOM NEXT DOOR -- ranked by area
// against each other, so the eight slots go to this building's biggest openings whichever kind
// they are.
let (day, bleed) = crate::lighting::placement_openings(&model.group_bounds, portals, batches);
for seed in day {
let fixture = seed.fixture(instance);
let claims = crate::lighting::daylight_claims(&model.group_bounds, portals, &seed);
let e = commands
.spawn((
Transform::from_translation(transform.transform_point(wow_to_bevy(seed.pos))),
fixture,
crate::lighting::daylight_rooms(instance, seed.group),
lit_rooms(instance, &claims, transform),
crate::lighting::LightReach(fixture.reach),
crate::lighting::daylight_lane(),
))
.id();
out.push(e);
}
// MONKEY (portal bleed): the doorway fixtures. Deliberately the SAME bundle as above plus one
// component -- a bleed IS a daylight fixture as far as the packer, the claim table, the shader
// and `torch_shadow` are concerned, and `BleedFixture` only names the two rooms it joins and the
// plane it is calibrated at (in WORLD space: the per-frame evaluation has world positions and no
// placement matrix). Its visibility gate takes BOTH rooms, not one. The `PointLight` is again NOT
// spawned here -- a doorway between two unlit rooms never gets one at all.
for seed in bleed {
let fixture = seed.fixture(instance);
let claims = seed.claims(&model.group_bounds, portals);
let world = transform.transform_point(wow_to_bevy(seed.pos));
let e = commands
.spawn((
Transform::from_translation(world),
fixture,
crate::lighting::BleedFixture {
sides: seed.sides,
probe: world,
},
seed.rooms(instance),
lit_rooms(instance, &claims, transform),
crate::lighting::LightReach(fixture.reach),
crate::lighting::daylight_lane(),
))
.id();
out.push(e);
}
}
/// The **one draw-set gate a placed model's emitters share** — the quad clouds and the ribbon
/// trails alike, built once per placement so the two families can never be handed different
/// answers about whether their model is in the frame's scene.
@@ -310,6 +310,9 @@ pub(super) fn spawn_loaded_placements(
// there is no MOLT fixture beside it and nothing else would light it.
// Empty dedupe list: an ADT map doodad belongs to no WMO, so there is no MOLT
// table its flame could be duplicating.
// MONKEY (torch owner exclusion): where this placement's own lights start in
// `ents`, so they — and ONLY they — can be tagged with its identity below.
let lights_from = ents.len();
spawn_lights_for(
&mut commands,
&m.lights,
@@ -323,6 +326,7 @@ pub(super) fn spawn_loaded_placements(
None,
&mut ents,
);
tag_light_owner(&mut commands, &ents[lights_from..], &object);
tag_world_object(&mut commands, &ents, &object);
if let Some((target, r)) = fade_near_target() {
let pos = p.transform.translation;
@@ -670,6 +674,22 @@ pub(super) fn spawn_loaded_placements(
p.transform,
&mut ents,
);
// MONKEY (daylight fixtures): …and one interior-lane light per exterior-facing
// opening, so the room law has a sun to find at the threshold instead of
// meeting the sky-lit doorway with a hard line (`lighting::daylight`).
fx::spawn_daylight_fixtures_for(
&mut commands,
m,
benilla_formats::PortalGraph {
vertices: &m.portal_vertices,
infos: &m.portal_infos,
refs: &m.portal_refs,
slices: &slices,
},
p.portal_instance,
p.transform,
&mut ents,
);
tag_world_object(&mut commands, &ents, &object);
// Resolve the interior props for this instance's doodad set (set 0 + the selected
// set), each composed onto the WMO's world transform; their M2s load async.
@@ -961,6 +981,8 @@ pub(super) fn spawn_loaded_placements(
ents.first().copied(),
&fade,
);
// MONKEY (torch owner exclusion): as at the ADT site — the prop's OWN lights only.
let lights_from = ents.len();
spawn_lights_for(
&mut commands,
&m.lights,
@@ -986,6 +1008,7 @@ pub(super) fn spawn_loaded_placements(
claims.as_ref(),
&mut ents,
);
tag_light_owner(&mut commands, &ents[lights_from..], &object);
tag_world_object(&mut commands, &ents, &object);
p.entities.extend(ents);
d.spawned = true;
@@ -1148,6 +1171,27 @@ fn tag_world_object(commands: &mut Commands, ents: &[Entity], object: &Arc<World
}
}
/// MONKEY (torch owner exclusion): stamp a placement's OWN M2 lights with its identity, so the
/// torch-shadow caster gathers can drop the fixture's own body out of its own shadow map
/// (`static_gx::LightOwner` carries the whole argument).
///
/// Narrow on purpose — this takes the slice of `ents` that [`fx::spawn_lights_for`] just appended,
/// never the placement's whole entity list, and it is NOT folded into [`tag_world_object`]. The
/// WMO lane runs `tag_world_object` over its authored MOLT fixtures too, and a MOLT fixture's
/// placement identity is the BUILDING's: tagged, an inn's hearth would name every wall, floor and
/// pillar of the inn as its own body and the interior shadow lane would go dark. A MOLT light has
/// no model of its own, so it needs no exclusion at all — only a light SYNTHESISED from (or
/// authored inside) a model does, and those are exactly the ones `spawn_lights_for` makes.
fn tag_light_owner(commands: &mut Commands, lights: &[Entity], object: &Arc<WorldObject>) {
if lights.is_empty() {
return;
}
let owner = crate::static_gx::LightOwner::placement(object);
for &e in lights {
commands.entity(e).insert(owner);
}
}
/// The **animated cull bound** for an M2 placement: the model's authored header bounding box
/// (`M2Header.bounding_box_min/max`), in Bevy model-local space. `None` when the model carries no
/// authored bounds.