From 082f3ebe501fbfa78a3f9234a39e2d2aaec9f6a5 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Wed, 9 Sep 2026 16:08:36 +0100 Subject: [PATCH] =?UTF-8?q?MONKEY:=20lighting=20round=20=E2=80=94=20fire/l?= =?UTF-8?q?amp=20lights,=20light=20lanes,=20room=20gating,=20static=20torc?= =?UTF-8?q?h=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point lights - Synthesised lights for fire and lamp models that author none (benilla-formats fire_light.rs: flame route from the particle emitter's over-life colour, name and emissive-glass routes for lampposts/lanterns/candelabra; NoOmni braziers included — they are Orgrimmar's street lighting). Spawned on every lane: ADT doodads, WMO props in interior and exterior groups (2.5 yd dedupe against authored MOLT fixtures), GameObjects and creatures (carried). Cvar fireLightGain (synthetic only). - Light LANE decided by a down-ray at the light's position: exterior consumers (terrain, models, WMO exterior batches — new per-vertex point term) read exterior lights; the interior room lane reads interior fixtures. Fixes interior fixtures leaking onto lawns and street torches lighting nothing. - Interior attenuation: the authored MOLT reach replaces the flat 48 yd, with a soft long-tailed profile (constant 1.75 yd core so energy no longer scales with reach), cvar interiorAttenScale; a 1.6x default. - ROOM GATE: a WMO surface takes an interior fixture only if the fixture claims its group — claims = containment ∪ MOLR ∪ portal-adjacent rooms (2 hops), shared rule in benilla-formats room_claim.rs, packed per light into a 32 KB storage buffer; portal claims are weighted (1 across the door frame, fading into the next room) so doorways carry no seam; in-building exterior-class groups take claimed fixtures. Carried lights (GameObjects, pets) claim rooms from their world position once settled. Cvar interiorRoomGate. Torch shadows - Casters ranked by contribution at the player with hysteresis and fade weights (no pop-in); eligibility 4R+3 and the shadowDistance setting; caster geometry gathered around the fixtures, not the camera. - Static cube maps cached per fixture (rendered once), entity casters rendered per frame only for the nearest interiorShadowDynamic fixtures via a depth copy; up to 16 resident maps (interiorShadowCasters), interiorShadowSoft PCF radius. - Carried lights: rest-pose flame position (no hand-bone jitter), lane from the bearer's cross-fade weight, caster only while settled — the imp no longer strobes. Entities - Portal lane cross-fade (tag bits 15..18) and MATTE_INDOOR bit so a unit keeps its room lighting across a Bake/Matte flicker; hover highlight folded into the room lane; the blob shadow returns whenever the realtime sun lane is inactive (night, beyond shadow distance) with a crossfade at dusk/dawn. Robustness - A placement or WMO prop whose model fails to load is retired instead of pinning the loading screen forever (Stormwind's alliancebrasscannon_flat.m2). - benilla-extract: wmolights (MOLT + claims + group classes), wmolamps (corpus audit), m2firescan (synthesis routes). Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/src/blob_shadow.rs | 114 +- crates/benilla-app/src/cvars.rs | 85 +- crates/benilla-app/src/dynamic_interior.rs | 27 +- crates/benilla-app/src/entities.rs | 25 + .../benilla-app/src/entities/carried_light.rs | 658 ++++++++- crates/benilla-app/src/entities/wmo_props.rs | 27 +- crates/benilla-app/src/portrait/glue_booth.rs | 1 + crates/benilla-app/src/torch_shadow.rs | 795 +++++++++-- crates/benilla-app/src/video.rs | 47 + crates/benilla-assets/src/m2.rs | 93 +- crates/benilla-assets/src/materials.rs | 10 +- .../src/shaders/shadow_hook.wgsl | 12 +- .../benilla-assets/src/shaders/terrain.wgsl | 14 +- .../benilla-assets/src/shaders/wow_model.wgsl | 283 +++- .../src/bin/benilla-extract/main.rs | 34 + .../src/bin/benilla-extract/scan/lighting.rs | 683 +++++++++- .../src/bin/benilla-extract/scan/mod.rs | 2 +- crates/benilla-formats/src/fire_light.rs | 1189 +++++++++++++++++ crates/benilla-formats/src/lib.rs | 12 + crates/benilla-formats/src/room_claim.rs | 676 ++++++++++ crates/benilla-world/src/interior.rs | 34 +- .../src/lighting/global_light.rs | 1165 +++++++++++++++- crates/benilla-world/src/lighting/mod.rs | 9 +- .../benilla-world/src/shaders/static_gx.wgsl | 492 ++++++- crates/benilla-world/src/static_gx/render.rs | 131 +- .../src/static_gx/torch_depth.rs | 472 +++++-- crates/benilla-world/src/terrain_stream.rs | 5 +- .../src/terrain_stream/spawn/fx.rs | 534 +++++++- .../src/terrain_stream/spawn/mod.rs | 110 +- crates/benilla-world/src/wmo_portal/mod.rs | 16 + 30 files changed, 7369 insertions(+), 386 deletions(-) create mode 100644 crates/benilla-formats/src/fire_light.rs create mode 100644 crates/benilla-formats/src/room_claim.rs diff --git a/crates/benilla-app/src/blob_shadow.rs b/crates/benilla-app/src/blob_shadow.rs index 5acf0792..bc280f5c 100644 --- a/crates/benilla-app/src/blob_shadow.rs +++ b/crates/benilla-app/src/blob_shadow.rs @@ -66,6 +66,7 @@ use crate::creature_anim::AnimData; use crate::net::{Embodied, NetEntity}; use crate::player::CameraControl; use benilla_world::decal::{DecalFrame, WorldDecal}; +use benilla_world::lighting::WowLighting; use benilla_world::model_fade::{fade_alpha, RenderFade}; use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex}; use benilla_world::schedule::WorldStage; @@ -81,6 +82,10 @@ const BOX_CLAMP: f32 = 5.0; /// Degenerate-box epsilon (the reference's `[0x8029d4]` = 2.384e-7): a zero horizontal extent is /// the no-op exit — no shadow. const DEGENERATE_EPS: f32 = 2.384e-7; +/// MONKEY (night blob): the realtime lane's edge band — mirrors `SHADOW_EDGE_BAND` in +/// `shadow_hook.wgsl`, where the realtime shadow lightens over the last yards of the cascade. +/// The oval fades back IN across the same band, so the handover is a crossfade at both ends. +const SHADOW_EDGE_BAND: f32 = 14.0; /// One unit's shadow record (a top-level entity — no render components; the cached projection /// rides the effect stream). Despawned when the owner goes. @@ -190,6 +195,12 @@ fn update_shadows( shadow_assets: Option>, images: Res>, decals: WorldDecal, + // MONKEY (night blob): the live celestial sun, for the realtime lane's day strength — the SAME + // `celestial_dir` height `global_light` packs the shader's night fade from, so the oval's + // crossfade and the realtime shadow's fade are two halves of one number. + lighting: Res, + // …and the camera, for the realtime lane's EDGE fade (its cascade ends at `shadowDistance`). + cam_pos: Query<&GlobalTransform, With>, // Spawn/despawn fades live on the model *part* entities; attribute each to its unit root so // the shadow can ride the same alpha the body renders with (O(#currently-fading parts) — zero // in the steady state). @@ -226,11 +237,26 @@ fn update_shadows( mut census_at: Local, ) { // `characterShadows 1`: the realtime shadow-map path owns OUTDOOR unit shadows — the per-unit - // gate in the loop below hides the oval for outdoor units so they don't wear both, while INDOOR - // units keep it (the realtime sun shadow is gated out of interiors, so an indoor unit would - // otherwise have no ground shadow at all). (This keys on the CHARACTER lane, not the world lane: - // the oval is a character's shadow, so `worldShadows` alone must leave it be.) With the cvar off - // — the shipped default — that gate is a single false branch and this is the reference path. + // gate in the loop below fades the oval out for outdoor units so they don't wear both, while + // INDOOR units keep it at full strength (the realtime sun shadow is gated out of interiors, so + // an indoor unit would otherwise have no ground shadow at all). (This keys on the CHARACTER + // lane, not the world lane: the oval is a character's shadow, so `worldShadows` alone must + // leave it be.) With the cvar off — the shipped default — that gate is a single false branch + // and this is the reference path. + // + // MONKEY (night blob): it is a FADE, not the hard hide it used to be. The realtime lane's own + // strength is `night · (1 − edge_fade)` (`shadow_hook.wgsl::realtime_shadow`), and `night` is + // `sun_shadow_strength(celestial sun height)` — exactly ZERO once the sun is under the horizon. + // So with the old hide, an outdoor unit between dusk and dawn had NO shadow of any kind: no + // realtime cast (faded to nothing) and no oval (hidden by the cvar). The reference client draws + // the oval all night, which is what the complement below restores. The oval is NOT replaced by + // a moon-lit realtime cast: benilla's rig aims only at the celestial SUN (`shadow_sun_travel`), + // the moon direction is `pub(super)` inside `benilla-world`'s day/night module and never + // reaches a shadow lane, and the packed `night` scalar would zero a moon cast anyway. + let sun_strength = sun_shadow_strength(lighting.celestial_dir().y); + // No camera yet → treat every unit as beyond the cascade, i.e. keep its oval. Failing toward + // "has a shadow" is the safe direction for a lane whose bug is a missing shadow. + let camera_pos = cam_pos.iter().next().map(GlobalTransform::translation); let now = time.elapsed_secs(); let census = now >= *census_at; if census { @@ -261,14 +287,24 @@ fn update_shadows( n_no_owner += 1; continue; }; - // `characterShadows`: the realtime cast owns OUTDOOR units, so hide the oval there (no - // double shadow). INDOOR units (`UnitWmoRoom::room()` = Some) keep it — the realtime sun - // shadow is gated out of interiors, so without this an indoor unit has no ground shadow at - // all. Absent room component / no-room claim = outdoors. + // `characterShadows`: the realtime cast owns OUTDOOR units, so the oval yields to it there + // (no double shadow). INDOOR units (`UnitWmoRoom::room()` = Some) keep it at full strength — + // the realtime sun shadow is gated out of interiors, so without this an indoor unit has no + // ground shadow at all. Absent room component / no-room claim = outdoors. + // + // MONKEY (night blob): "yields to it" is now proportional to how much the realtime lane + // actually casts for THIS unit — full oval at night and past the cascade edge, none in + // broad daylight up close, a crossfade between. Below a colour step of oval there is + // nothing left to draw, so that stays the early-out the hard hide used to be. let indoors = room.is_some_and(|r| r.room().is_some()); + let mut lane_w = 1.0; if video.character_shadows && !indoors { - hide(&mut key, &mut verts); - continue; + let cam_dist = camera_pos.map_or(f32::INFINITY, |c| unit.translation.distance(c)); + lane_w = blob_weight(sun_strength, cam_dist, video.shadow_distance); + if lane_w <= 1.0 / 255.0 { + hide(&mut key, &mut verts); + continue; + } } // An owner that is not drawn casts nothing. The director's report from inside Caverns of // Time (decision 1277): the exterior election had correctly stopped drawing the Tanaris @@ -319,6 +355,12 @@ fn update_shadows( // The self first-person fade rides the same model-fade slot in the reference. alpha *= rig.as_deref().map_or(1.0, CameraControl::self_fade); } + // MONKEY (night blob): the realtime-lane handover rides the SAME alpha the spawn/despawn + // fades do — the oval is a Multiply decal, so scaling its alpha scales its darkening, and + // `ShadowKey::alpha` already re-arms the projection when it moves by a colour step. `1.0` + // whenever the realtime lane is off, indoors, or contributing nothing, so the shipped + // default path is untouched. + alpha *= lane_w; if alpha <= 0.0 { hide(&mut key, &mut verts); continue; @@ -545,6 +587,30 @@ fn key_changed(a: &ShadowKey, b: &ShadowKey) -> bool { || a.surfaces != b.surfaces } +/// MONKEY (night blob): the realtime sun lane's DAY strength — 0 at or below the horizon, 1 by +/// ~12° of elevation, smoothstepped. A three-line mirror of `sun_shadow_strength` in +/// `benilla_world::lighting::global_light` (private there; that copy is what gets packed into the +/// shader's `night` scalar). The pair is pinned by `oval_is_the_complement_of_the_realtime_lane` +/// below — if the curve is ever tuned, tune both or the handover gains a seam. +fn sun_shadow_strength(sun_height: f32) -> f32 { + let t = (sun_height / 0.208).clamp(0.0, 1.0); // 0.208 ≈ sin(12°) + t * t * (3.0 - 2.0 * t) +} + +/// MONKEY (night blob): how much OVAL a unit still wears while `characterShadows` owns it. +/// +/// `shadow_hook.wgsl::realtime_shadow` returns `1 − (1 − shadow) · night · (1 − edge_fade)`, so +/// `night · (1 − edge_fade)` is precisely the fraction of a realtime cast that survives to the +/// screen. The oval takes the COMPLEMENT of that: full where the realtime lane lands nothing (all +/// night; past the cascade's last [`SHADOW_EDGE_BAND`] yards), zero where it lands everything +/// (daylight, close in), a continuous crossfade through dusk and dawn and across the edge band — +/// so neither shadow pops in or out. +fn blob_weight(sun_strength: f32, cam_dist: f32, shadow_range: f32) -> f32 { + let t = ((cam_dist - (shadow_range - SHADOW_EDGE_BAND)) / SHADOW_EDGE_BAND).clamp(0.0, 1.0); + let edge_fade = t * t * (3.0 - 2.0 * t); + (1.0 - sun_strength.clamp(0.0, 1.0) * (1.0 - edge_fade)).clamp(0.0, 1.0) +} + /// The reference's trapezoid alpha ramp (`0x6d81a0`/`0x6d82d0`, diffed bit-exact in wow-re: /// `x = 12·u` — rise `x<2 → x/2`, flat `2≤x<10 → 1`, fall `x≥10 → (12−x)/2`, clamped at 0). fn shadow_ramp(u: f32) -> f32 { @@ -578,6 +644,32 @@ mod tests { assert_eq!(shadow_ramp(2.0), 0.0); } + /// MONKEY (night blob): the oval is the exact complement of what the realtime lane casts — + /// gone in daylight up close (the old hide), FULL at night (the bug: the sun lane's `night` + /// scalar is 0 under the horizon, so nothing was casting and nothing was drawing either), and + /// full again past the cascade's edge band. Monotone in between, so no pop at dusk or dawn. + #[test] + fn oval_is_the_complement_of_the_realtime_lane() { + // Sun down (and the whole night): the realtime lane casts nothing → the full oval. + assert_eq!(sun_shadow_strength(-0.3), 0.0); + assert_eq!(blob_weight(sun_shadow_strength(-0.3), 10.0, 70.0), 1.0); + // High sun, well inside the cascade: the realtime cast owns it → no oval. + assert_eq!(sun_shadow_strength(0.9), 1.0); + assert_eq!(blob_weight(sun_shadow_strength(0.9), 10.0, 70.0), 0.0); + // Same high sun, but past the cascade edge: the realtime cast has faded → the oval is back. + assert_eq!(blob_weight(sun_shadow_strength(0.9), 80.0, 70.0), 1.0); + // Dusk sweeps the weight monotonically from 0 up to 1 with no jump. + let mut prev = 0.0; + for step in 0..=20 { + let height = 0.25 - 0.25 * step as f32 / 20.0; // ~14° down to the horizon + let w = blob_weight(sun_shadow_strength(height), 10.0, 70.0); + assert!(w >= prev - 1e-6, "dusk weight went backwards at step {step}"); + assert!(w - prev < 0.35, "dusk weight jumped at step {step}"); + prev = w; + } + assert!((prev - 1.0).abs() < 1e-6); + } + /// The box law: clamp INTO ±5 pre-scale (a cap, not a floor), then scale. #[test] fn box_clamp_caps_pre_scale() { diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index bf3d08e1..701cb66d 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -624,17 +624,76 @@ pub(crate) const REGISTERED: &[Registered] = &[ "2.5", "benilla's own: interior light-budget multiplier before the soft rolloff, 0.25..8", ), + // MONKEY (soft falloff): the live scale on every interior fixture's AUTHORED attenuation + // window (WMO MOLT `+0x28/+0x2c`; M2 sources bucket by intensity instead — their authored pair + // is a template default, not a reach). A fixture's EFFECTIVE RADIUS is `authored end × this`. + // The artists' own ends — Goldshire inn 6.97-9.53 yd over 10 fixtures, its blacksmith 6.0, + // NSabbey 4.17-5.56, Stormwind's 606 median 6.94 — are where FULL brightness ends, not where + // light stops, so `1` drew a hard-edged disc at exactly that radius with black beyond it (the + // abbey candelabra ring). **2.5** is the default: the pool now tails smoothly to 2.5× the + // authored end, reading ~⅓ of its 1 yd brightness AT the authored end and ~8 % at twice it. + // `>1.6` widens further, `<1.6` tightens, `0` switches the window off (the 48 yd lane), so the + // whole shape A/Bs from chat. + ours( + "interiorAttenScale", + "1.6", + "benilla's own: scale on interior fixtures' authored attenuation window = their effective \ + radius, 0..8 (0 = no window, the old flat lane)", + ), + // MONKEY (room gate): whether an interior fixture may light only the rooms it CLAIMS — its + // authored MOLR groups unioned with the interior groups whose MOGI bounding box it stands + // inside (`LightLitRooms` carries the corpus evidence for why MOLR alone is far too sparse: + // the Goldshire inn authors one on 2 of its 12 groups). Off restores the pre-gate behaviour: + // every interior fixture in range lights every interior surface in range, so an inn's + // ground-floor candles light its basement through the floor. Kept as a dial because a room the + // gate leaves on ambient alone looks the same as a bug, and this tells the two apart in one + // keystroke. + ours( + "interiorRoomGate", + "1", + "benilla's own: an interior fixture lights only the WMO groups it claims (0 = the old \ + leak-through-walls behaviour)", + ), ours( "interiorShadows", "1", "benilla's own: interior fixtures cast real shadows (Stage B, the nearest few); needs \ interiorLight", ), + // MONKEY (static torch cache): residency and per-frame work have separate live budgets. + ours( + "interiorShadowCasters", + "12", + "benilla's own: resident interior fixture shadow maps, 1..16", + ), + ours( + "interiorShadowDynamic", + "4", + "benilla's own: nearest promoted fixtures with moving entity shadows, 0..16", + ), + // MONKEY (torch caster selection): the PCF tap radius on the torch maps. Candle clusters read + // very hard-edged at 1 (a half-texel box on a 512² face); 2 is a visible softening for four + // 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", + ), ours( "interiorDebug", "0", "benilla's own: interior diagnostic overlay — 1 classification, 2 shadow, 3 caster count", ), + // 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 + // the whole invented-light lane, which matters because unlike everything beside it this one is + // a heuristic over content rather than a byte-verified mechanism. + ours( + "fireLightGain", + "1", + "benilla's own: brightness of lights synthesised from fire props' flame emitters (0 = off)", + ), // **Display mode** (decisions 1627, 1650) — 1.12's own `gxWindow`, worn since 1650 as modern // Classic's two-entry *Display Mode* dropdown rather than 1.12's *Windowed Mode* checkbox: the // two states 1627 settled on ARE that client's two (its own `Graphics.lua` builds the list from @@ -1126,8 +1185,23 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool { "interiorambient" => knobs.video.interior_ambient = v.clamp(0.0, 1.0), "interiorfill" => knobs.video.interior_fill = v.clamp(0.0, 2.0), "interiorexposure" => knobs.video.interior_exposure = v.clamp(0.25, 8.0), + // MONKEY (interior attenuation): the authored-window scale. `0` is a MEANINGFUL value here + // (the window off), so the range floors at 0 rather than at a small positive. + "interiorattenscale" => knobs.video.interior_atten_scale = v.clamp(0.0, 8.0), + "interiorroomgate" => knobs.video.interior_room_gate = v != 0.0, "interiorshadows" => knobs.video.interior_shadows = v != 0.0, + // MONKEY (torch caster selection): the working-set size and the PCF radius, clamped at the + // edge like every other numeric row. `casters` floors at 1, not 0 — `interiorShadows 0` is + // already the off switch, and a 0 here would be a second, confusing one. + "interiorshadowcasters" => { + knobs.video.interior_shadow_casters = (v.max(1.0) as u32).clamp(1, 16); + } + // MONKEY (static torch cache): zero is useful for static-only rooms. + "interiorshadowdynamic" => knobs.video.interior_shadow_dynamic = (v.max(0.0) as u32).min(16), + "interiorshadowsoft" => knobs.video.interior_shadow_soft = v.clamp(0.5, 3.0), "interiordebug" => knobs.video.interior_debug = (v.max(0.0) as u32).min(3), + // 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), // Display mode (1627) — a flag like every other checkbox here, and the reference's own // polarity: `1` is WINDOWED (the row is "Windowed Mode"). `video::apply_window_mode` // watches the value and pushes it to the window; nothing else reads it. @@ -1430,7 +1504,7 @@ fn sync_cvars( .collect(), ); let flag = |b: bool| if b { "1" } else { "0" }.to_string(); - let session: [(&str, String); 53] = [ + let session: [(&str, String); 59] = [ ("MasterVolume", sound.master.to_string()), ("SoundVolume", sound.sfx.to_string()), ("MusicVolume", sound.music.to_string()), @@ -1483,8 +1557,17 @@ fn sync_cvars( ("interiorAmbient", video.interior_ambient.to_string()), ("interiorFill", video.interior_fill.to_string()), ("interiorExposure", video.interior_exposure.to_string()), + ("interiorAttenScale", video.interior_atten_scale.to_string()), + ("interiorRoomGate", flag(video.interior_room_gate)), ("interiorShadows", flag(video.interior_shadows)), + ( + "interiorShadowCasters", + video.interior_shadow_casters.to_string(), + ), + ("interiorShadowDynamic", video.interior_shadow_dynamic.to_string()), + ("interiorShadowSoft", video.interior_shadow_soft.to_string()), ("interiorDebug", video.interior_debug.to_string()), + ("fireLightGain", video.fire_light_gain.to_string()), // The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state. ( "gxWindow", diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs index 8d25440e..d4ad633c 100644 --- a/crates/benilla-app/src/dynamic_interior.rs +++ b/crates/benilla-app/src/dynamic_interior.rs @@ -8,7 +8,12 @@ //! //! The knobs are LIVE — `/script SetCVar("interiorExposure", 2)` in chat retunes the room without //! a rebuild, which is how the defaults were found. -use benilla_world::lighting::DynamicInteriors; +//! +//! MONKEY (fire GO lights): the same bridge also carries `fireLightGain` → +//! [`benilla_world::lighting::FireLightGain`]. Same shape, same guard, same liveness requirement — +//! and `fireLightGain 0` is the kill switch for the synthesised-fire lane the way `interiorLight 0` +//! is for this one. +use benilla_world::lighting::{DynamicInteriors, FireLightGain}; use bevy::prelude::*; use crate::video::VideoConfig; @@ -23,12 +28,30 @@ impl Plugin for DynamicInteriorPlugin { /// Publish the cvars to benilla-world when they change. Guarded so the resource's change ticks /// stay quiet on idle frames. -fn bridge(video: Res, mut out: ResMut) { +/// +/// MONKEY (fire GO lights): `fireLightGain` rides along here rather than in a module of its own — +/// it is the same shape (a live video cvar → a benilla-world resource the light packer reads) and +/// the same one-line guard, and a second plugin to carry one `f32` is not worth its wiring. +fn bridge( + video: Res, + mut out: ResMut, + mut fire: ResMut, +) { + if fire.0 != video.fire_light_gain { + fire.0 = video.fire_light_gain; + } let want = DynamicInteriors { enabled: video.interior_light, ambient: video.interior_ambient, fill: video.interior_fill, exposure: video.interior_exposure, + // MONKEY (interior attenuation): the authored-window scale rides the same bridge — the + // light packer folds it into every interior entry's packed reach, so + // `SetCVar("interiorAttenScale", 2)` widens every pool on the very next frame. + atten_scale: video.interior_atten_scale, + // MONKEY (room gate): the packer turns this into a per-light claim head, so + // `SetCVar("interiorRoomGate", 0)` restores the ungated look on the very next frame. + room_gate: video.interior_room_gate, debug: video.interior_debug, }; if *out != want { diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 1ca6a018..833a0bd9 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -47,6 +47,9 @@ 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 (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; /// Equipment visuals (decisions 0072/0074): held items (weapon/shield/ranged) plus worn-armor and /// helm/shoulder resolution, all resolved from the unit descriptor + ItemDisplayInfo and spawned as @@ -855,6 +858,28 @@ impl Plugin for EntitiesPlugin { .after(apply_render_fade) .before(crate::player::apply_self_model_fade), ) + // MONKEY (fire GO lights): copy each carried light's owner room onto the light, so an + // entity's torch/brazier is portal-gated like a building's own fixture AND is eligible for + // a cube shadow. In `Update` **after the classifier** — that is the frame's only writer of + // `InteriorAnchor::room`, and the whole of `Update` precedes `PostUpdate`'s light pack + // (`lighting::build_light_data`), so a claim made here is always in this frame's table. + .add_systems( + Update, + carried_light::claim_carried_light_rooms + .after(benilla_world::interior::classify_entity_interior), + ) + // MONKEY (carried light stability): how long each carried light has been standing still, + // which is what lets `torch_shadow` refuse a MOVING one a cube-shadow slot (a walking + // bearer invalidated its cached depth map every frame, and a withdrawn map zeroes the + // slot's cross-fade weight with no ramp — the imp's strobing green pool). `PostUpdate` + // after transform propagation, because a child light's `GlobalTransform` is only this + // frame's once `Propagate` has run; `update_torch_shadows` reads it later the SAME frame, + // in `Last`. + .add_systems( + PostUpdate, + carried_light::track_carried_light_motion + .after(bevy::transform::TransformSystems::Propagate), + ) // 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 560d0086..401041a6 100644 --- a/crates/benilla-app/src/entities/carried_light.rs +++ b/crates/benilla-app/src/entities/carried_light.rs @@ -26,7 +26,12 @@ use benilla_assets::coords::wow_to_bevy; use benilla_assets::ModelLight; use bevy::prelude::*; -use benilla_world::terrain_stream::point_light; +use benilla_world::interior::{InteriorAnchor, WmoResidency}; +use benilla_world::lighting::{ + LightLane, LightLitRooms, LightRooms, ShadowProxyLight, SyntheticFireLight, +}; +use benilla_world::terrain_stream::{carried_light_claims, point_light, CarriedClaimSet}; +use benilla_world::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom}; /// Spawn a `PointLight` child for each **casting** (`type==1`, not visibility-gated dark) M2 light of /// an entity's model. @@ -49,7 +54,30 @@ pub(super) fn spawn_carried_lights( if !l.def.casts() { continue; // directionals feed an ambient term; a static `0` visibility key is dark } - let (parent, local) = match joint(l.def.bone) { + // MONKEY (carried light stability): a SYNTHESISED light (`fire_light` — the imp's hand + // flames, a brazier GameObject, a fire elemental) does NOT ride its host bone; an + // AUTHORED one still does. + // + // The distinction is what the position MEANS. An authored light block is a fixture an + // artist put on a bone, and `0x718960` re-registering it through the live bone matrix is + // the reference's own behaviour — the held torch that tracks the swinging hand, and that + // case takes the boneless arm below anyway (a held item spawns no skeleton). A synthesised + // one is a stand-in for a whole flame VOLUME whose position we GUESSED off the emitter + // record: it is already approximate to within the flame's own size, and riding a hand bone + // through an idle animation adds high-frequency motion that every consumer downstream + // reads as an EVENT rather than as a wobble. The per-vertex nearest-3 in `point_light_sum` + // re-ranks; and the cube-shadow cache tripped its 0.1 yd staleness test + // (`torch_shadow::map_publishable`) every single frame and withdrew its map, which + // `TorchTable::pack` answers by forcing that slot's cross-fade weight to 0 with no ramp — + // a full-strength shadow toggling at frame rate. That is the reported "the imp produces a + // DISCO with its light". + // + // The rest pose is identity about the pivot ([`ModelLight::bone_pivot`]), so hanging the + // light off `frame` at the raw model-space `def.position` puts it EXACTLY where the bone + // ride puts it in the rest pose — and `frame` is the model root, so the light still + // follows the unit's own motion with zero lag. Only the animation is dropped. + let host = if l.synthetic { None } else { joint(l.def.bone) }; + let (parent, local) = match host { Some(j) => ( j, [ @@ -60,21 +88,493 @@ pub(super) fn spawn_carried_lights( ), None => (frame, l.def.position), }; - let glow = commands - .spawn(( - point_light(l.def.diffuse_color, l.def.diffuse_intensity), - Transform::from_translation(wow_to_bevy(local)), - Visibility::default(), - )) - .id(); + let mut glow = commands.spawn(( + point_light(l.def.diffuse_color, l.def.diffuse_intensity), + Transform::from_translation(wow_to_bevy(local)), + Visibility::default(), + )); + // MONKEY (fire GO lights): a light we DERIVED from the model's flame emitter is tagged, so + // the packer can ride the live `fireLightGain` over it (and only over it). The entity lanes + // take synthetic lights: a placed brazier GameObject, a fire elemental, a flaming helm have + // no MOLT fixture standing beside them the way a WMO's own wall torches do. + if l.synthetic { + glow.insert(SyntheticFireLight); + } + let glow = glow.id(); commands.entity(parent).add_child(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 +/// malformed cycle can never hang the frame. +const ANCHOR_WALK_DEPTH: usize = 64; + +/// MONKEY (carried light stability): the bearer crossfade weight at which a carried light COMMITS +/// to its owner's room. [`InteriorAnchor::lane`] ramps at 2/s, so crossing this midpoint needs +/// ~0.25 s of a HELD verdict; one frame of a wrong law moves the weight by `dt * 2` and can never +/// cross it. That IS the hysteresis — no second timer, and because it is the very signal the +/// bearer's own meshes crossfade their lighting on, the light changes lane with the body. +const LANE_INTERIOR: f32 = 0.5; + +/// MONKEY (carried light stability): how far a carried light may drift and still count as standing +/// still. Deliberately HALF `torch_shadow`'s 0.1 yd cache-staleness radius: the settle test has to +/// be the stricter of the two, or a light could qualify as a shadow caster while already sitting +/// far enough from its baked map to have that map withdrawn again on the next step. +const STILL_DRIFT: f32 = 0.05; + +/// MONKEY (carried light stability): how long a carried light must hold still before it may cast. +/// Long enough that a pet fidgeting between waypoints never qualifies, short enough that a placed +/// brazier GameObject is eligible well inside the first second it exists. +const STILL_HOLD: f32 = 0.75; + +/// MONKEY (carried light stability): how long a carried light has been standing still, so the +/// torch-shadow lane can refuse a MOVING one. +/// +/// The cube-shadow cache is built for fixtures that do not move: a slot keeps its baked depth only +/// while its fixture stays within 0.1 yd of where the map was baked +/// (`torch_shadow::map_publishable`), and 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 walking bearer +/// crossed that threshold every frame, which is a full-strength shadow blinking at frame rate — +/// and worse, it burned the lane's global two-rebuilds-a-frame budget re-baking a mesh that was +/// stale again immediately, starving the STATIC candles around it so those flickered too. One +/// pet's shadow is not worth that. +/// +/// A carried light is not excluded outright, because the same `ChildOf` spawn path carries the +/// stationary cases the shadow lane was written for: a GM-placed brazier GameObject in the Lion's +/// Pride Inn, a campfire. Those settle within [`STILL_HOLD`] and cast normally; only things that +/// actually move are refused. Refusal is a *candidacy* verdict, so an incumbent that starts +/// walking is cross-faded out by the lane's own eviction path rather than cut. +#[derive(Component)] +pub(crate) struct CarriedLightMotion { + /// World position at the last frame the light was judged to have MOVED. Not re-anchored while + /// it is still, so a slow creep accumulates and eventually trips the test — which is right, + /// because the shadow cache's own staleness test is likewise absolute from the bake point. + last: Vec3, + /// Seconds held within [`STILL_DRIFT`] of `last`, capped at [`STILL_HOLD`]. The cap is what + /// keeps a settled light a pure READ: no field write, so no change tick, so nothing + /// downstream is woken by a light that is doing nothing. + still_for: f32, +} + +impl CarriedLightMotion { + /// Has this light held still long enough to be trusted with a cached cube shadow? + pub(crate) fn settled(&self) -> bool { + self.still_for >= STILL_HOLD + } +} + +/// MONKEY (carried light stability): maintain [`CarriedLightMotion`] for every carried light. +/// +/// `PostUpdate`, **after transform propagation**, for the same reason the packer is there: a +/// carried light is a child of a moving parent, so its `GlobalTransform` is only this frame's once +/// `Propagate` has run. `update_torch_shadows` reads the verdict in `Last`, i.e. later in the same +/// frame — never one stale. +pub(crate) fn track_carried_light_motion( + mut commands: Commands, + time: Res