From f8b05f25aefc372caad0b6134be8592a2fe0c58a Mon Sep 17 00:00:00 2001 From: pkuzic Date: Sun, 13 Sep 2026 14:38:53 +0100 Subject: [PATCH] =?UTF-8?q?MONKEY:=20lighting=20round=203=20=E2=80=94=20da?= =?UTF-8?q?ylight=20fixtures,=20spell=20lights,=20shadow=20quality,=208-li?= =?UTF-8?q?ght=20exterior=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interiors - Daylight fixtures (lighting/daylight.rs): PORTAL/APERTURE/BOUNDARY seeds calibrated so the room law at a door equals the sunlit reference; BLEED fixtures at interior↔interior portals carry 0.6× the brighter side; interiorDaylight default 0 (daylight only through doors/windows). - Darkness gains: nightGain 0.45, interiorGain 0.5 (Dim preset), interiorAmbient 0.015, fill 0.08. - Enclosed day floor + shell candle add + TRANS night floor (capped 1.5× room, sun-gated). - interiorBakeFloor 0.12: a per-fragment floor from the authored MOCV so fixture-starved rooms (the Lion's Pride vestibule, 2 % of tex before) are never black; packed in sh_c16.w's fraction. - TRANS blend continuity: smooth-max replaces the per-channel max whose crossing loci drew a hard diagonal across the vestibule floor by day. - Debug panel "Lighting & shadows" (Ctrl+Shift+D) with sliders for every lighting cvar + presets. Torch cube-map shadows - Owner exclusion (LightOwner placement/instance + flame-in-bounds) — the swinging lantern. - torchShadowStrength 0.7 shadow floor (packed with soft into TorchTable.count.y), PCSS-lite contact hardening (raw depth blocker search), interiorShadowSoft 1.5. - Moving fixtures (carried lights) re-render every frame (cap 2) instead of publishing a stale map. - Normal offset on the WMO surface lane (interior + exterior) — the imp's boxy/moiré pool. Spell and firework lights (benilla-formats fire_light.rs spell route) - Fire / Holy / Fel effects emit a short-lived point light (kits, missiles, impacts, firework GO shells); frost/nature/arcane/shadow vetoed by name; lifecycle envelope; cap 24; WOW_SPELL_LIGHT=0 kill switch. Exterior point lights - ≤3-nearest per draw unit → 8 (8-bit ranks, table cap 255), ranked by distance to the chunk's box; cube occlusion only for ranks 0..2. Removes the chunk-edge "scars" at the Darkmoon Faire (15 synthesised fixtures in one chunk's reach) and the lit-bench-over-dark-ground disagreement. Tooling: benilla-extract wmolights --verts ; missing-model placements retire instead of pinning the loading screen. Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/src/cvars.rs | 100 +- .../src/debug_panel/lighting_controls.rs | 187 ++ crates/benilla-app/src/debug_panel/mod.rs | 13 +- crates/benilla-app/src/dynamic_interior.rs | 17 + crates/benilla-app/src/entities.rs | 17 + .../benilla-app/src/entities/carried_light.rs | 127 + crates/benilla-app/src/entities/dest_fx.rs | 16 + crates/benilla-app/src/entities/missile.rs | 11 + .../src/entities/spell_fx/lifecycle.rs | 221 ++ .../benilla-app/src/entities/spell_fx/mod.rs | 14 + crates/benilla-app/src/portrait/glue_booth.rs | 1 + crates/benilla-app/src/torch_shadow.rs | 577 ++++- crates/benilla-app/src/video.rs | 70 +- crates/benilla-assets/src/lib.rs | 3 + crates/benilla-assets/src/m2.rs | 79 +- .../src/shaders/shadow_hook.wgsl | 142 +- .../benilla-assets/src/shaders/terrain.wgsl | 245 +- .../benilla-assets/src/shaders/wow_model.wgsl | 364 ++- .../src/bin/benilla-extract/main.rs | 11 +- .../src/bin/benilla-extract/scan/lighting.rs | 222 +- crates/benilla-formats/src/fire_light.rs | 664 +++++ crates/benilla-formats/src/lib.rs | 4 + crates/benilla-formats/src/room_claim.rs | 79 + crates/benilla-world/src/lighting/blob.rs | 30 +- crates/benilla-world/src/lighting/daylight.rs | 2232 +++++++++++++++++ .../src/lighting/global_light.rs | 520 +++- crates/benilla-world/src/lighting/mod.rs | 15 + .../benilla-world/src/shaders/static_gx.wgsl | 785 +++++- crates/benilla-world/src/static_gx/bake.rs | 3 + crates/benilla-world/src/static_gx/mod.rs | 13 + crates/benilla-world/src/static_gx/render.rs | 18 +- .../src/static_gx/torch_depth.rs | 407 ++- .../src/terrain_stream/spawn/assemble.rs | 9 + .../src/terrain_stream/spawn/fx.rs | 106 +- .../src/terrain_stream/spawn/mod.rs | 44 + 35 files changed, 6920 insertions(+), 446 deletions(-) create mode 100644 crates/benilla-app/src/debug_panel/lighting_controls.rs create mode 100644 crates/benilla-world/src/lighting/daylight.rs diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index d291827a..8b378d33 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -590,8 +590,8 @@ pub(crate) const REGISTERED: &[Registered] = &[ // shadow rig (one sun / one map). `worldShadows` = the static world (trees, buildings, foliage) // casts realtime shadows and baked MCSH terrain shadows switch off; `characterShadows` = // players/NPCs/creatures/mounts cast realtime silhouettes instead of the legacy oval blob. - same("worldShadows", "0"), - same("characterShadows", "0"), + same("worldShadows", "1"), + same("characterShadows", "1"), // Realtime-shadow render distance in yards (the shadow-map cascade range + caster reach). // benilla's own — the reference has no realtime shadow to size. Clamped to SHADOW_DISTANCE_RANGE. ours( @@ -640,12 +640,12 @@ pub(crate) const REGISTERED: &[Registered] = &[ ), ours( "interiorAmbient", - "0.15", + "0.015", "benilla's own: interior base ambient, 0..1", ), ours( "interiorFill", - "0.12", + "0.08", "benilla's own: interior per-fixture bounce gain, 0..2", ), ours( @@ -724,14 +724,64 @@ pub(crate) const REGISTERED: &[Registered] = &[ // extra texel-neighbourhood taps' worth of cache pressure, no extra samples. ours( "interiorShadowSoft", - "1", - "benilla's own: torch-shadow edge softness — the PCF tap radius scale, 0.5..3", + "1.5", + "benilla's own: torch-shadow edge softness — the PCF tap radius scale at a CONTACT, 0.5..3", + ), + // MONKEY (shadow floor): how BLACK a torch shadow is allowed to get. The lane's shadows were + // the only occlusion in the direct term and took all of it, which is what made them read as + // scars rather than as shadows; 0.7 leaves 30 % standing in place of the bounce light this + // renderer does not have. `1` is the shipped look, `0` is off. + ours( + "torchShadowStrength", + "0.7", + "benilla's own: torch-shadow darkness — how much of the direct term a shadow removes, 0..1", ), ours( "interiorDebug", "0", "benilla's own: interior diagnostic overlay — 1 classification, 2 shadow, 3 caster count, 4 WMO lane map", ), + // MONKEY (darkness gains): the two live dim dials. `nightGain` scales the EXTERIOR day/night + // law (the packed ambient/diffuse/specular rows) by `mix(1, gain, night_w)`, so it is exactly + // inert by day and full strength after dark; `interiorGain` scales the room lane's inputs (base + // ambient, per-fixture fill, and every interior fixture's colour). Both fold in at PACK time in + // `build_light_data`, so `SetCVar` moves the whole world on the very next frame — which is how + // "20 % / 30 % darker" gets judged at all, and `1` on either is the restore. + // + // Neither touches the fires: a point light keeps its brightness under both dials, because the + // ask is for a darker night AROUND the flame, not a dimmer flame. + ours( + "nightGain", + "0.45", + "benilla's own: exterior night brightness, 0.2..1.5 (1 = the reference's own night)", + ), + // MONKEY (lighting debug panel): weaker fresh interiors; persisted gains still win at boot. + ours( + "interiorGain", + "0.5", + "benilla's own: WMO interior brightness, 0.2..1.5 (1 = the pre-dial fixture-lit room)", + ), + // MONKEY (enclosed day floor): the daylight a room INSIDE A BUILDING gets by day, for the + // doorways this renderer cannot locate in the data (the Goldshire inn's entry group authors no + // portal, no EXT-class batch, no stitched vertex and no bake hot spot — there is nowhere to + // stand a fixture). An additive ambient in `interiorAmbient`'s own units, scaled by the sun's + // day envelope, so it is exactly 0 at night and the night look never moves. `0` is the restore. + ours( + "interiorDaylight", + "0.0", + "benilla's own: daylight floor for rooms inside a building, 0..1 (0 = none, the old look)", + ), + // MONKEY (bake floor): the share of an interior batch's own MOCV bake that survives the live- + // fixture lane. The lane throws the bake away and lets the fixtures decide, which leaves a room + // no fixture reaches (the Lion's Pride Inn's east vestibule: MOLR 0, no claims, one faded + // portal hop) rendering black between a sky-lit porch and a candle-lit hall — something the + // reference client cannot do, because it draws every interior batch at its bake regardless of + // lights. A fraction of the bake, inside the room law's rolloff, so a lit surface barely moves. + ours( + "interiorBakeFloor", + "0.12", + "benilla's own: share of an interior batch's baked light kept where no fixture reaches, 0..1 (0 = the old look)", + ), // MONKEY (fire GO lights): the gain on lights SYNTHESISED from a model's flame emitter for the // ~410 fire props the artists never gave a light block (campfires, wall torches, magic // braziers, forges, candles). Live, like the interior knobs — and `0` is the kill switch for @@ -1296,7 +1346,24 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool { knobs.video.interior_shadow_entity_rate = (v.max(0.0) as u32).min(240); } "interiorshadowsoft" => knobs.video.interior_shadow_soft = v.clamp(0.5, 3.0), + // MONKEY (shadow floor): 0 IS meaningful (shadows off), so this floors at 0, not at a + // minimum-useful value; 1 is the pre-feature pitch black. + "torchshadowstrength" => knobs.video.torch_shadow_strength = v.clamp(0.0, 1.0), "interiordebug" => knobs.video.interior_debug = (v.max(0.0) as u32).min(4), + // MONKEY (darkness gains): the two dim dials, clamped at the edge like every numeric row + // here. The floor is 0.2 rather than 0: a true 0 would be indistinguishable from a broken + // light pack (black world / black room), and the off switch people actually want is `1`. + "nightgain" => knobs.video.night_gain = v.clamp(0.2, 1.5), + "interiorgain" => knobs.video.interior_gain = v.clamp(0.2, 1.5), + // MONKEY (enclosed day floor): 0 IS meaningful here (it restores the pre-feature look + // exactly), unlike the two dim dials above whose 0 would be a broken-looking world. + "interiordaylight" => knobs.video.interior_daylight = v.clamp(0.0, 1.0), + // MONKEY (bake floor): 0 IS meaningful here too (it restores the pre-feature look exactly). + // The upper clamp matters more than usual: the packer multiplies this by `interiorGain` + // (up to 1.5) and rides the product in a lane fraction that must stay under 0.5 after + // scaling, so a value that escaped this clamp would reach the world-shadow flag it shares + // a lane with. `pack_bake_lane` clamps the product too — belt and braces, one at each end. + "interiorbakefloor" => knobs.video.interior_bake_floor = v.clamp(0.0, 1.0), // MONKEY (fire GO lights): the synthesised-fire gain, clamped at the edge like the rest. "firelightgain" => knobs.video.fire_light_gain = v.clamp(0.0, 4.0), // MONKEY (flame flicker): 0..2 — the amplitudes are authored at 1, and 2 is the deliberate @@ -1604,7 +1671,7 @@ fn sync_cvars( .collect(), ); let flag = |b: bool| if b { "1" } else { "0" }.to_string(); - let session: [(&str, String); 67] = [ + let session: [(&str, String); 72] = [ ("MasterVolume", sound.master.to_string()), ("SoundVolume", sound.sfx.to_string()), ("MusicVolume", sound.music.to_string()), @@ -1676,7 +1743,12 @@ fn sync_cvars( video.interior_shadow_entity_rate.to_string(), ), ("interiorShadowSoft", video.interior_shadow_soft.to_string()), + ("torchShadowStrength", video.torch_shadow_strength.to_string()), ("interiorDebug", video.interior_debug.to_string()), + ("nightGain", video.night_gain.to_string()), + ("interiorGain", video.interior_gain.to_string()), + ("interiorDaylight", video.interior_daylight.to_string()), + ("interiorBakeFloor", video.interior_bake_floor.to_string()), ("fireLightGain", video.fire_light_gain.to_string()), ("fireFlicker", video.fire_flicker.to_string()), // The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state. @@ -2105,6 +2177,20 @@ mod tests { assert_eq!(d["characterShadowRate"], shadows.character_shadow_rate as f32); assert_eq!(d["worldShadowRate"], shadows.world_shadow_rate as f32); assert_eq!(d["shadowCasterReach"], shadows.shadow_caster_reach); + // MONKEY (darkness gains): the two dim dials weld to the same knob for the same reason — + // the registered default IS what the light packer runs with until a config says otherwise, + // and a row that drifts is a setting that reads one way in the config and renders another. + assert_eq!(d["nightGain"], shadows.night_gain); + assert_eq!(d["interiorGain"], shadows.interior_gain); + // MONKEY (lighting debug panel): pin the requested dimmer baseline as well as the weld. + assert_eq!(d["interiorGain"], 0.5); + // MONKEY (enclosed day floor): same weld, same reason. + assert_eq!(d["interiorDaylight"], shadows.interior_daylight); + // MONKEY (bake floor): same weld, same reason — and pin the calibrated value, because the + // measurement the default stands on (the inn's door band at 0.108 x tex, candle-lit + // surfaces under +10 %) is only true at this number. + assert_eq!(d["interiorBakeFloor"], shadows.interior_bake_floor); + assert_eq!(d["interiorBakeFloor"], 0.12); // The pane half-rate (1444) welds to the portrait knob's shipped default. assert_eq!(d["boothHalfRate"] != 0.0, PaneRate::default().half); // Render scale (1639) welds to OFF. Not a taste default: the whole tree of visual diff --git a/crates/benilla-app/src/debug_panel/lighting_controls.rs b/crates/benilla-app/src/debug_panel/lighting_controls.rs new file mode 100644 index 00000000..deea78d6 --- /dev/null +++ b/crates/benilla-app/src/debug_panel/lighting_controls.rs @@ -0,0 +1,187 @@ +//! MONKEY (lighting debug panel): a native editor for the LIVE video CVars. Reads are typed +//! `VideoConfig` fields, never a second settings store; changed widgets alone call +//! `UiScript::set_cvar_engine`, whose queue is the same one Lua `SetCVar` feeds. The normal host +//! sync owns clamps, renderer bridges and persistence, so a slider and a chat command cannot +//! acquire separate truths. Registry lookup is reserved for an explicit reset, not idle frames. + +use std::ops::RangeInclusive; + +use benilla_ui::script::UiScript; +use bevy_egui::egui; + +use crate::cvars::REGISTERED; +use crate::shadow_core::{CASTER_REACH_RANGE, MAX_SHADOW_RATE, SHADOW_DISTANCE_RANGE, SHADOW_MAP_SIZES}; +use crate::video::VideoConfig; + +enum Widget { + Toggle, + Slider(RangeInclusive), + Integer(RangeInclusive), + Choice(&'static [u32]), +} + +struct Knob { + name: &'static str, + read: fn(&VideoConfig) -> f32, + widget: Widget, + help: &'static str, +} + +// MONKEY (lighting debug panel): keep names, typed reads and presentation together. The numeric +// bounds mirror `cvars::apply_to_knobs` (including its wider ambient/fill/attenuation ranges); +// shared shadow constants follow the setter directly. Casting the booleans through u32 keeps +// the descriptor read-only without inventing a parallel configuration type. +macro_rules! knob { + ($name:literal, $field:ident, $widget:expr, $help:literal) => { + Knob { name: $name, read: |v| v.$field as f32, widget: $widget, help: $help } + }; + (flag $name:literal, $field:ident, $help:literal) => { + Knob { name: $name, read: |v| v.$field as u32 as f32, widget: Widget::Toggle, help: $help } + }; +} + +use Widget::{Choice, Integer, Slider}; + +const INTERIOR: &[Knob] = &[ + knob!(flag "interiorLight", interior_light, "Fixture-lit interiors; off restores the baked path."), + knob!("interiorGain", interior_gain, Slider(0.2..=1.5), "Overall room input brightness."), + knob!("interiorAmbient", interior_ambient, Slider(0.0..=1.0), "Base room ambient."), + knob!("interiorFill", interior_fill, Slider(0.0..=2.0), "Per-fixture bounce gain."), + // MONKEY (bake floor): index 4, immediately after fill, because the three presets below take + // `INTERIOR[1..5]` as one group — this dial belongs with gain/ambient/fill, not with the + // exposure and reach knobs the presets deliberately leave alone. + knob!("interiorBakeFloor", interior_bake_floor, Slider(0.0..=1.0), "Share of a room's baked light kept where no fixture reaches; 0 = fixtures only."), + knob!("interiorExposure", interior_exposure, Slider(0.25..=8.0), "Light budget before soft rolloff."), + knob!("interiorAttenScale", interior_atten_scale, Slider(0.0..=8.0), "Pool radius scale; 0 disables the authored window."), + knob!("interiorDaylight", interior_daylight, Slider(0.0..=1.0), "Sun-driven day floor in enclosed rooms."), + knob!(flag "interiorRoomGate", interior_room_gate, "Restrict fixtures to the rooms they claim."), +]; + +const GROUPS: &[(&str, &[Knob])] = &[ + ("Interior light", INTERIOR), + ("Fire", &[ + knob!("fireLightGain", fire_light_gain, Slider(0.0..=4.0), "Brightness of lights synthesized from flame emitters."), + knob!("fireFlicker", fire_flicker, Slider(0.0..=2.0), "0 steady, 1 default, 2 pronounced."), + ]), + ("Torch shadows", &[ + knob!(flag "interiorShadows", interior_shadows, "Interior fixture shadows; requires interiorLight."), + knob!(flag "exteriorShadows", exterior_shadows, "Outdoor fire shadows at night."), + knob!("interiorShadowCasters", interior_shadow_casters, Integer(1.0..=16.0), "Resident fixture shadow maps."), + knob!("interiorShadowDynamic", interior_shadow_dynamic, Integer(1.0..=crate::torch_shadow::MAX_TORCH_DYNAMIC as f32), "Nearest fixtures with moving entity shadows."), + knob!("interiorShadowSoft", interior_shadow_soft, Slider(0.5..=3.0), "Shadow edge softness at a contact; the penumbra grows from it."), + knob!("torchShadowStrength", torch_shadow_strength, Slider(0.0..=1.0), "Shadow darkness: how much direct light a shadow removes. 1 = black."), + knob!("interiorShadowEntityRate", interior_shadow_entity_rate, Integer(0.0..=240.0), "Moving caster regather Hz; 0 = every frame."), + ]), + ("Sun shadows", &[ + knob!(flag "characterShadows", character_shadows, "Realtime character silhouettes instead of blobs."), + knob!(flag "worldShadows", world_shadows, "Realtime static-world shadows instead of baked terrain shadows."), + knob!("shadowDistance", shadow_distance, Slider(SHADOW_DISTANCE_RANGE), "Shadow distance in yards."), + knob!("shadowMapSize", shadow_map_size, Choice(&SHADOW_MAP_SIZES), "Map edge in texels; larger maps cost more GPU time and memory."), + knob!("shadowFilter", shadow_filter, Widget::Toggle, "0 hardware 2x2, 1 Gaussian PCF."), + knob!("characterShadowRate", character_shadow_rate, Integer(0.0..=MAX_SHADOW_RATE as f32), "Character shadow update Hz; 0 = every frame."), + knob!("worldShadowRate", world_shadow_rate, Integer(0.0..=MAX_SHADOW_RATE as f32), "World shadow update Hz; 0 = every frame."), + knob!("shadowCasterReach", shadow_caster_reach, Slider(CASTER_REACH_RANGE), "Caster collection reach multiplier."), + ]), + ("Night", &[ + knob!("nightGain", night_gain, Slider(0.2..=1.5), "Exterior night brightness; inert by day."), + ]), + ("Debug", &[ + knob!("interiorDebug", interior_debug, Choice(&[0, 1, 2, 3, 4]), "0 off, 1 classification, 2 shadow, 3 caster count, 4 WMO lane map."), + ]), +]; + +fn reset(script: &mut UiScript, knobs: &[Knob]) { + for knob in knobs { + let registered = REGISTERED.iter().find(|r| r.name == knob.name) + .expect("lighting panel knob must be registered"); + script.set_cvar_engine(registered.name, registered.default); + } +} + +fn row(ui: &mut egui::Ui, video: &VideoConfig, script: &mut UiScript, knob: &Knob) { + let mut value = (knob.read)(video); + // MONKEY (lighting debug panel): stack the label over numeric controls so even the longest + // CVar fits the existing 280 px panel. Sliders retain their editable current-value readout; + // integer knobs cannot queue fractional counts/rates, and flags explicitly show their 0/1. + let changed = ui.push_id(knob.name, |ui| match &knob.widget { + Widget::Toggle => { + let mut enabled = value != 0.0; + let response = ui.checkbox(&mut enabled, format!("{} {}", knob.name, value as u32)) + .on_hover_text(knob.help); + value = enabled as u32 as f32; + response.changed() + } + Slider(range) | Integer(range) => { + ui.label(knob.name).on_hover_text(knob.help); + let mut slider = egui::Slider::new(&mut value, range.clone()); + if matches!(knob.widget, Integer(_)) { + slider = slider.integer(); + } + ui.add(slider).on_hover_text(knob.help).changed() + } + Choice(options) => { + ui.label(knob.name).on_hover_text(knob.help); + let mut changed = false; + egui::ComboBox::from_id_salt("value") + .selected_text((value as u32).to_string()) + .show_ui(ui, |ui| { + for &option in *options { + changed |= ui.selectable_value(&mut value, option as f32, option.to_string()).changed(); + } + }); + changed + } + }).inner; + if changed { + script.set_cvar_engine(knob.name, &value.to_string()); + } +} + +pub(super) fn section(ui: &mut egui::Ui, video: &VideoConfig, script: Option<&mut UiScript>) { + let Some(script) = script else { + ui.weak("Lighting controls are available once the UI session is ready."); + return; + }; + ui.horizontal(|ui| { + ui.label("Presets"); + for (label, values) in [ + // MONKEY (bake floor): the fourth value is `interiorBakeFloor`, and it rides the + // presets because it is a room BRIGHTNESS INPUT exactly like the three beside it. + // Leaving it out would make the presets change the RATIO between a fixture-lit room + // and a fixture-starved one rather than the level of both: Bright would raise the + // candles and leave the vestibule at the Default floor. (The `interiorGain` the + // presets also set does scale it at pack time, so its own value composes with that + // — which is why Dim asks for 0.08 and not for 0.12 halved twice.) + ("Dim", Some(["0.5", "0.015", "0.08", "0.08"])), + ("Default", None), + ("Bright", Some(["1.0", "0.04", "0.16", "0.2"])), + ] { + if ui.small_button(label).on_hover_text("Only gain, ambient, fill and bake floor; other settings stay as they are.").clicked() { + if let Some(values) = values { + for (knob, value) in INTERIOR[1..5].iter().zip(values) { + script.set_cvar_engine(knob.name, value); + } + } else { + reset(script, &INTERIOR[1..5]); + } + } + } + }); + if ui.small_button("Reset all").on_hover_text("Restore registry defaults for every group in Lighting & shadows.").clicked() { + for (_, knobs) in GROUPS { + reset(script, knobs); + } + } + for (name, knobs) in GROUPS { + ui.push_id(name, |ui| { + ui.separator(); + ui.strong(*name); + for knob in *knobs { + row(ui, video, script, knob); + } + if ui.small_button("Reset group to defaults").clicked() { + reset(script, knobs); + } + }); + } +} diff --git a/crates/benilla-app/src/debug_panel/mod.rs b/crates/benilla-app/src/debug_panel/mod.rs index b56a927f..865f0fa4 100644 --- a/crates/benilla-app/src/debug_panel/mod.rs +++ b/crates/benilla-app/src/debug_panel/mod.rs @@ -49,6 +49,7 @@ use crate::ui_script::EguiPointerOver; mod inspect; mod journal; +mod lighting_controls; /// The dev state this panel edits — resource-only, faithful defaults (decision 0026: the /// always-present config layer; this module is only its editor). The engine owns and inits it, @@ -364,7 +365,11 @@ fn debug_panel_ui( ping: Res, dropped: Res, mut weather_state: Option>, - mut world: WorldReadout, + (mut world, video, mut script): ( + WorldReadout, + Res, + Option>, + ), ) -> Result { if !debug.open { return Ok(()); @@ -550,6 +555,12 @@ fn debug_panel_ui( ui.checkbox(&mut l.disable_sky_dome, "disable sky dome"); }); + egui::CollapsingHeader::new("Lighting & shadows") + .default_open(false) + .show(ui, |ui| { + lighting_controls::section(ui, &video, script.as_deref_mut()); + }); + egui::CollapsingHeader::new("Weather") .default_open(false) .show(ui, |ui| { diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs index cd3565d7..b46bc80a 100644 --- a/crates/benilla-app/src/dynamic_interior.rs +++ b/crates/benilla-app/src/dynamic_interior.rs @@ -57,6 +57,23 @@ fn bridge( // on the CPU in `build_light_data`, so `SetCVar("fireFlicker", 0)` freezes every flame on // the very next frame with no respawn and no shader change. flicker: video.fire_flicker, + // MONKEY (darkness gains): both dim dials ride this same bridge — they are consumed + // entirely on the CPU in `build_light_data` (folded into the packed rows / the packed + // colours), so `SetCVar("nightGain", 1)` restores the reference night on the very next + // frame with no respawn and no shader change. `night_gain` is an EXTERIOR knob living on an + // interior-named resource for the sake of one bridge rather than two; the packer reads it + // ungated by `enabled`. + night_gain: video.night_gain, + interior_gain: video.interior_gain, + // MONKEY (enclosed day floor): the daylight floor rides the same bridge, but is consumed in + // the SHADER (it rides the packed `wmo_fog_params.w` fraction) rather than folded on the + // CPU — the packer's only job is to put it in the lane. + daylight: video.interior_daylight, + // MONKEY (bake floor): the bake floor rides the same bridge. Half CPU, half shader: the + // packer folds `interiorGain` in and puts the product in the `sh_c16.w` fraction, and the + // two interior lanes read it from there — so `SetCVar("interiorBakeFloor", 0)` restores + // the pre-feature look on the very next frame with no respawn and no pipeline rebuild. + bake_floor: video.interior_bake_floor, }; if *out != want { *out = want; diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 3b1bb462..4d46259e 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -47,6 +47,10 @@ use attach::{attach_entity_visuals, build_dressup_preview, build_glue_pet, build /// decision 0016's law applied to the *entity* half of the scene, not just the placed half. mod carried_light; use carried_light::spawn_carried_lights; +// MONKEY (spell light): the same file's second spawner — the ONE light a luminous spell effect / +// firework throws, hung on the effect's own root so the effect's death reaps it. Used by the kit, +// missile and dest-anchored lanes. +use carried_light::spawn_spell_light; // MONKEY (carried light stability): `torch_shadow` needs the settle verdict to refuse a MOVING // carried light a cube-shadow slot. pub(crate) use carried_light::CarriedLightMotion; @@ -883,6 +887,19 @@ impl Plugin for EntitiesPlugin { carried_light::track_carried_light_motion .after(bevy::transform::TransformSystems::Propagate), ) + // MONKEY (spell light): the luminous-effect lights' envelope and their ceiling. `Update`, + // after the attach passes that spawn them (`EntityVisualsSet`) so a light born this frame + // is already on its own clock, and well before `PostUpdate`'s packer reads the intensity + // they write. The budget runs after the envelope so the age it orders on is this frame's. + .add_systems( + Update, + ( + spell_fx::advance_spell_lights, + spell_fx::budget_spell_lights, + ) + .chain() + .after(EntityVisualsSet), + ) // The aura CharProc layer (`crate::aura_visual`): the state kit's effect on the BODY. // The drain installs/removes this frame's nodes; the author then owns the render alpha // of every part under a translucent unit — after the three steady-state alpha authors diff --git a/crates/benilla-app/src/entities/carried_light.rs b/crates/benilla-app/src/entities/carried_light.rs index 02434592..8988f46d 100644 --- a/crates/benilla-app/src/entities/carried_light.rs +++ b/crates/benilla-app/src/entities/carried_light.rs @@ -30,9 +30,15 @@ use benilla_world::interior::{InteriorAnchor, WmoResidency}; use benilla_world::lighting::{ LightLane, LightLitRooms, LightRooms, ShadowProxyLight, SyntheticFireLight, }; +use benilla_world::static_gx::LightOwner; use benilla_world::terrain_stream::{carried_light_claims, point_light, CarriedClaimSet}; use benilla_world::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom}; +// MONKEY (spell light): the effect-side lifecycle this file's spawn helper stamps on — the +// envelope, the budget and the kill switch all live beside the rest of the effect lifecycle +// (`spell_fx::lifecycle`), because that is what a spell light's lifetime IS. +use super::spell_fx::{spell_lights_enabled, SpellLight, SpellLightMode, SPELL_BURST_SPAN}; + /// Spawn a `PointLight` child for each **casting** (`type==1`, not visibility-gated dark) M2 light of /// an entity's model. /// @@ -110,6 +116,26 @@ pub(super) fn spawn_carried_lights( ), None => (frame, l.def.position), }; + // MONKEY (spell light): a SPELL/FIREWORK-derived light is not a carried FIXTURE and does + // not take this lane's rules. It has an onset, a lifecycle envelope and a budget, and it + // never flickers — all of which live on [`SpellLight`]. The branch is here because the + // firework GameObject arrives HERE: its shell (`World\Goober\G_Firework0*`) is a GO model + // like any other, so the entity lane is the only place its light can be born. + if let Some(fx) = l.spell { + spawn_spell_light_child( + commands, + parent, + local, + l, + fx, + // A GameObject that carries a spell light IS a one-shot: a firework shell, a + // trigger's effect model. Nothing on this lane holds. + SpellLightMode::Burst { + span: SPELL_BURST_SPAN, + }, + ); + continue; + } let mut glow = commands.spawn(( point_light(l.def.diffuse_color, l.def.diffuse_intensity), Transform::from_translation(wow_to_bevy(local)), @@ -129,6 +155,14 @@ pub(super) fn spawn_carried_lights( if held { glow.insert(HeldLight); } + // MONKEY (torch owner exclusion): and the light's OWNER — the model frame it belongs to. + // Same bug as the placed lane's (`terrain_stream::spawn`'s `tag_light_owner`): the flame + // sits INSIDE the thing that burns it, so a placed brazier GameObject was the nearest + // occluder on all six of its own cube faces and stamped a black disc on the ground under + // itself. No `WorldObject` exists at this spawn site — a carried light's host is a net + // entity, not a placement — so the identity is the FRAME, and the caster gather asks + // "is this part a descendant of it?" (`LightOwner::Instance`). + glow.insert(LightOwner::Instance(frame)); // MONKEY (flame flicker): a carried flame breathes like any other — the imp's hand fire, a // held torch, a brazier GameObject. It is safe on THIS lane specifically because the // modulation touches neither position nor reach: the settle/claim logic @@ -151,6 +185,98 @@ pub(super) fn spawn_carried_lights( } } +/// MONKEY (spell light): spawn the ONE spell light a luminous effect model carries, as a child of +/// that effect's root. `false` when the model carries none — which is the great majority of the +/// corpus (frost, nature, arcane, shadow and everything unnamed synthesise nothing at all), and +/// when the env kill switch `WOW_SPELL_LIGHT=0` is set. +/// +/// `root` is the effect INSTANCE's root entity — the kit instance's attach node, the missile, the +/// dest-anchored plant, the firework's GameObject frame. That parentage is the whole lifetime +/// contract: every one of those lanes despawns its root when the effect ends (the reap, the +/// arrival, the expiry), and a child light goes with it. There is no separate reaping path to get +/// wrong, and no `on_owner_loss` case to answer — a light is not a particle pool and never drains. +/// +/// The light hangs at the emitter's own model-space position, exactly as a synthesised fire light +/// does, and does NOT ride a bone: a synthesised light stands for a whole flame VOLUME whose +/// position was inferred, and riding an animating joint adds motion that the shadow/claim lanes +/// read as an event (the same reasoning [`spawn_carried_lights`] gives for its `l.synthetic` arm). +pub(super) fn spawn_spell_light( + commands: &mut Commands, + lights: &[benilla_assets::ModelLight], + root: Entity, + mode: SpellLightMode, +) -> bool { + let Some((l, fx)) = lights.iter().find_map(|l| l.spell.map(|fx| (l, fx))) else { + return false; + }; + spawn_spell_light_child(commands, root, l.def.position, l, fx, mode).is_some() +} + +/// The shared body of the two spawn sites (the effect lanes' [`spawn_spell_light`] and the +/// entity lane's firework branch): one `PointLight` child at `local` (WoW model space, relative to +/// `parent`), carrying the tags every spell light must have. +/// +/// The tag set is three separate promises, and each one is load-bearing: +/// - [`SyntheticFireLight`] — this light was INVENTED, not authored, so the packer's synthetic gain +/// owns it (and a future `spellLightGain` would find it here). +/// - [`HeldLight`] — **never** an exterior torch-shadow caster. A spell light moves (a missile), is +/// born and dies inside a second, and would churn the cube-shadow cache at frame rate for a +/// shadow nobody could resolve in the time it exists. The marker is that lane's refusal. +/// - [`SpellLight`] — the envelope and the budget ([`advance_spell_lights`] / +/// [`budget_spell_lights`]), and the handle any future per-school gain or kill switch targets. +/// +/// No [`FlameFlicker`](benilla_world::lighting::FlameFlicker), deliberately: the flicker makes a +/// fire breathe, and a spell light already has a shape of its own. The two together read as the +/// effect stuttering rather than as fire. +/// +/// `None` when the kill switch is off — so a caller can tell "disabled" from "this model has no +/// light", though neither has any further consequence. +fn spawn_spell_light_child( + commands: &mut Commands, + parent: Entity, + local: [f32; 3], + l: &benilla_assets::ModelLight, + fx: benilla_assets::SpellLightInfo, + mode: SpellLightMode, +) -> Option { + if !spell_lights_enabled() { + return None; + } + let lit = point_light(l.def.diffuse_color, l.def.diffuse_intensity); + // Born DARK and raised by the envelope: the onset is real time (a firework's fuse), and a + // light that showed its full strength on its first frame and only then ramped would flash + // once before every effect it belongs to. + let base = lit.intensity; + let glow = commands + .spawn(( + PointLight { + intensity: 0.0, + ..lit + }, + Transform::from_translation(wow_to_bevy(local)), + Visibility::default(), + SyntheticFireLight, + HeldLight, + SpellLight::new(base, fx.onset, mode), + // The same owner exclusion every carried light takes: the flame sits INSIDE the thing + // that burns it, so the effect's own meshes must not occlude their own light. + LightOwner::Instance(parent), + )) + .id(); + commands.entity(parent).add_child(glow); + if benilla_assets::trace::enabled() { + benilla_assets::trace::line( + "fx", + &format!( + "spell light e={glow} school={} onset={:.2} base={base:.1}", + fx.kind.label(), + fx.onset + ), + ); + } + Some(glow) +} + /// MONKEY (fire GO lights): how far up the hierarchy the room walk looks before giving up. A /// carried light's anchor is at most `light → joint → … → net entity`, and a rig's joint chain is /// the deep part; 64 clears every shipped skeleton with room to spare, and the cap is there so a @@ -640,6 +766,7 @@ mod tests { bone_pivot: [1.0, 0.0, 0.5], synthetic: false, flame: false, + spell: None, } } diff --git a/crates/benilla-app/src/entities/dest_fx.rs b/crates/benilla-app/src/entities/dest_fx.rs index c4618067..be11f84a 100644 --- a/crates/benilla-app/src/entities/dest_fx.rs +++ b/crates/benilla-app/src/entities/dest_fx.rs @@ -308,6 +308,22 @@ pub(super) fn attach_ground_fx_models( continue; // model still building — attach on a later pass } inst.spawned = true; + // MONKEY (spell light): the IMPACT flash. A dest-anchored effect is the one lane whose + // light must not hold — a Fire Nova lights the ground it lands on and is gone. The + // burst span is the model's own first-sequence duration where it authors one (so the + // light fades with the effect rather than on a guess), the shared default otherwise; + // a LOOPING plant (a persistent ground aura) takes the default too, because "as long + // as the aura lasts" is exactly what a burst must not be. + let span = dm + .first_seq_span + .filter(|_| !inst.looping) + .unwrap_or(super::spell_fx::SPELL_BURST_SPAN); + super::spawn_spell_light( + &mut commands, + &dm.lights, + entity, + super::spell_fx::SpellLightMode::Burst { span }, + ); if !inst.looping { // One pass of the first sequence — the kit pipeline's completion-callback // stand-in (`spell_fx`'s span clock, same law). diff --git a/crates/benilla-app/src/entities/missile.rs b/crates/benilla-app/src/entities/missile.rs index 7698cb9a..fbb3e04e 100644 --- a/crates/benilla-app/src/entities/missile.rs +++ b/crates/benilla-app/src/entities/missile.rs @@ -647,6 +647,17 @@ pub(super) fn attach_missile_models( ) { continue; } + // MONKEY (spell light): a luminous projectile carries its light with it — the Fireball's + // core lights the road it flies over, and the flash at the end is the impact effect's own + // (`dest_fx` / the kit on the target), not this one's. `Missile` = constant: a projectile + // has no lifecycle to follow and no fade to run, because its ending is the arrival despawn + // that takes the light with it. + super::spawn_spell_light( + &mut commands, + &dm.lights, + entity, + super::spell_fx::SpellLightMode::Missile, + ); missile.parts_spawned = true; } } diff --git a/crates/benilla-app/src/entities/spell_fx/lifecycle.rs b/crates/benilla-app/src/entities/spell_fx/lifecycle.rs index 883c1415..44520255 100644 --- a/crates/benilla-app/src/entities/spell_fx/lifecycle.rs +++ b/crates/benilla-app/src/entities/spell_fx/lifecycle.rs @@ -217,6 +217,183 @@ pub(crate) fn decay_span(anims: Option<&ModelAnimations>) -> Option { anims?.find(ANIM_DECAY).map(|c| c.duration) } +// ------------------------------------------------------------------------------------------- +// MONKEY (spell light): the LIGHT a luminous effect throws, and the envelope it throws it on. +// ------------------------------------------------------------------------------------------- +// +// The synthesis — which effects are luminous at all (fire / holy / fel, never frost, nature, +// arcane or shadow), what colour they burn and how far they reach — is +// [`benilla_formats::fire_light`]'s spell route, decided once per MODEL at asset load. What lives +// here is everything that is per-INSTANCE: when the light comes up, how it goes out, and how many +// of them may exist at once. +// +// It sits in this file rather than beside the other carried lights because it is a LIFECYCLE, and +// this file is where an effect instance's lifecycle lives (`Stand` → `Hold` → `Decay`). The light +// is a child of the effect's own root, so the reap that despawns the root takes the light with it +// and there is no orphaning path to get wrong; the envelope below only shapes the brightness +// inside that life. + +/// Seconds a spell light takes to come up. Short enough to read as a flash rather than a fade-in — +/// the point is only that a light never appears at full strength on a single frame, which reads as +/// a rendering fault rather than as a spell. +pub(crate) const SPELL_LIGHT_RAMP: f32 = 0.1; + +/// The default burst fade ([`SpellLightMode::Burst`]) when the caller knows no better span: an +/// impact's flash. Long enough to see, short enough that a volley of them never overlaps into a +/// standing glow. +pub(crate) const SPELL_BURST_SPAN: f32 = 0.6; + +/// How fast a KIT light goes dark once its instance is reaped ([`FxDecay`]). Deliberately quicker +/// than the model's own `Decay` sequence: the aura is over the moment the server said so, and a +/// light that outlived the visual by a second would read as a stuck effect. +pub(crate) const SPELL_REAP_FADE: f32 = 0.25; + +/// How many spell lights may be alive at once, across every lane. The shared point-light table is +/// 512 rows packed from the 256 nearest sources every frame, and the whole feature is worth +/// nothing if a raid's worth of casts can evict a city's torches: 24 is a generous ceiling for +/// what one camera can see cast at once and still a small fraction of the pack. +pub(crate) const SPELL_LIGHTS_MAX: usize = 24; + +/// MONKEY (spell light): the env kill switch, `WOW_SPELL_LIGHT=0`. Read ONCE — the value is +/// latched on first call, so it cannot change under a running frame and costs one atomic load at +/// each spawn site thereafter. +/// +/// An env var and not a cvar on purpose: this pass adds no cvar (the lighting cvars are another +/// lane's file this session). The natural home for a live `spellLightGain` is the packer, beside +/// `fireLightGain` — see the report note. +pub(crate) fn spell_lights_enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| !matches!(std::env::var("WOW_SPELL_LIGHT").as_deref(), Ok("0"))) +} + +/// Which lifecycle a spell light follows — the one thing its spawn site knows that the model +/// cannot. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) enum SpellLightMode { + /// A kit/aura effect attached to a unit: up on the ramp, HELD for as long as the instance + /// stands, out on the reap ([`FxDecay`] → [`SPELL_REAP_FADE`]). Ice Barrier's shield holds, + /// Immolate burns for its duration, a precast glows while it is cast. + Kit, + /// A missile: constant while it flies. The light rides the projectile root, so it sweeps the + /// ground and the walls on the way — and the missile's own arrival despawn ends it, which is + /// the only ending a projectile has. + Missile, + /// An impact / destination effect / firework shell: full at onset, gone `span` seconds later. + /// The one shape whose *whole point* is that it does not persist. + Burst { span: f32 }, +} + +/// MONKEY (spell light): one live spell light. A child of the effect root it belongs to, so the +/// effect's own despawn reaps it; this component only shapes its brightness while it lives. +#[derive(Component)] +pub(crate) struct SpellLight { + /// The `PointLight::intensity` this light sits at when the envelope is at full — captured at + /// spawn from the synthesised colour/intensity, never recomputed. The envelope only ever + /// SCALES this, so nothing downstream can drift the calibration. + base: f32, + /// Seconds from spawn before the light comes up — the emitter's own rate-track onset + /// (`benilla_formats::fire_light::emit_onset`). A firework shell detonates half a second into + /// its model's clip and a rocket that lit the sky on the way UP would be exactly backwards. + onset: f32, + /// The lifecycle ([`SpellLightMode`]). + mode: SpellLightMode, + /// Seconds alive. Also the budget's age ordering — the OLDEST spell light is the one dropped + /// when the ceiling is hit, because it is the one whose moment has most passed. + age: f32, + /// Seconds this light's HOST instance has been reaped ([`FxDecay`]), i.e. how far into + /// [`SPELL_REAP_FADE`] it is. Only ticks in [`SpellLightMode::Kit`]. + reaped: f32, +} + +impl SpellLight { + /// A fresh light at `base` intensity, coming up `onset` seconds from now. + pub(crate) fn new(base: f32, onset: f32, mode: SpellLightMode) -> Self { + Self { + base, + onset, + mode, + age: 0.0, + reaped: 0.0, + } + } + + /// The envelope's current multiplier, `0..=1` — pure, so the shape is testable without a + /// world. Ramp in, then the mode's own decay, then the reap fade, multiplied. + fn envelope(&self) -> f32 { + let t = self.age - self.onset; + if t <= 0.0 { + return 0.0; // the flame this light stands for does not exist yet + } + let up = (t / SPELL_LIGHT_RAMP).min(1.0); + let down = match self.mode { + // The fade starts where the ramp ended, so a burst's peak is a real (if brief) plateau + // rather than a single-frame spike that a low frame rate could skip entirely. + SpellLightMode::Burst { span } => { + (1.0 - (t - SPELL_LIGHT_RAMP).max(0.0) / span.max(1e-3)).clamp(0.0, 1.0) + } + SpellLightMode::Kit | SpellLightMode::Missile => 1.0, + }; + let reap = (1.0 - self.reaped / SPELL_REAP_FADE).clamp(0.0, 1.0); + up * down * reap + } +} + +/// MONKEY (spell light): run every live spell light's envelope. +/// +/// `Update`, beside the rest of the effect lane, and it writes `PointLight::intensity` ONLY — the +/// packer reads that in `PostUpdate` (`lighting::build_light_data`), so a light's brightness is +/// always this frame's. Deliberately NOT a `FlameFlicker`: that modulation exists to make a fire +/// breathe, and a spell light already has a shape of its own; the two together read as the effect +/// stuttering. +/// +/// The KIT arm is the only one that has to look outside itself: a reaped instance +/// ([`FxDecay`] on the effect root, written by the reap) must take its light down with it, and the +/// light's parent IS that root. +pub(crate) fn advance_spell_lights( + time: Res