From 9504310cac133bd3c209ff3847a96133dd2cf50e Mon Sep 17 00:00:00 2001 From: pkuzic Date: Thu, 17 Sep 2026 01:46:47 +0100 Subject: [PATCH] =?UTF-8?q?MONKEY:=20lighting=20round=205=20=E2=80=94=2012?= =?UTF-8?q?=20exterior=20light=20slots,=20slope-scaled=20torch=20bias,=20s?= =?UTF-8?q?pell-light=20room=20lane=20+=20spellLightGain,=20Stormwind=20da?= =?UTF-8?q?ylight=20quota?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/src/cvars.rs | 24 +- .../src/debug_panel/lighting_controls.rs | 4 + crates/benilla-app/src/dynamic_interior.rs | 15 +- .../benilla-app/src/entities/carried_light.rs | 174 ++++++- .../src/entities/spell_fx/lifecycle.rs | 11 +- crates/benilla-app/src/video.rs | 9 + .../src/shaders/shadow_hook.wgsl | 212 +++++++- .../benilla-assets/src/shaders/terrain.wgsl | 110 ++-- .../benilla-assets/src/shaders/wow_model.wgsl | 124 +++-- crates/benilla-world/src/lighting/daylight.rs | 477 ++++++++++++++++-- .../src/lighting/global_light.rs | 125 ++++- crates/benilla-world/src/lighting/mod.rs | 3 + .../benilla-world/src/shaders/static_gx.wgsl | 144 ++++-- 13 files changed, 1218 insertions(+), 214 deletions(-) diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 8b378d33..4b430309 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -792,6 +792,17 @@ pub(crate) const REGISTERED: &[Registered] = &[ "1", "benilla's own: brightness of lights synthesised from fire props' flame emitters (0 = off)", ), + // MONKEY (spellLightGain): the same dial for the SPELL lane — a kit's aura glow, a missile's + // core, an impact flash, a firework's burst. A separate knob from the one above it because the + // two are separate judgements: that one tunes SCENERY (how bright is the invented campfire), + // this one tunes COMBAT (how hard does a fight flash the room), and a spell light carries both + // markers, so one dial over both would mean dimming the world's hearths to calm a fireball. + // `0` is this lane's kill switch and reaches nothing else. + ours( + "spellLightGain", + "1", + "benilla's own: brightness of spell, missile and impact lights (0 = off)", + ), // MONKEY (flame flicker): how hard every FLAME breathes — candles fast and shallow, bonfires // slow and shallower still (`benilla_world::lighting::FlameKind`). Live like the gain beside // it, and `0` restores the steady constants every fire had before the feature, which is the @@ -1366,6 +1377,9 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool { "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 (spellLightGain): the spell lane's gain, same range and same edge clamp — and `0` + // is meaningful here (the lane off) exactly as it is for the fire gain above. + "spelllightgain" => knobs.video.spell_light_gain = v.clamp(0.0, 4.0), // MONKEY (flame flicker): 0..2 — the amplitudes are authored at 1, and 2 is the deliberate // over-drive for judging the shape. Clamped at the edge like every knob here. "fireflicker" => knobs.video.fire_flicker = v.clamp(0.0, 2.0), @@ -1671,7 +1685,7 @@ fn sync_cvars( .collect(), ); let flag = |b: bool| if b { "1" } else { "0" }.to_string(); - let session: [(&str, String); 72] = [ + let session: [(&str, String); 73] = [ ("MasterVolume", sound.master.to_string()), ("SoundVolume", sound.sfx.to_string()), ("MusicVolume", sound.music.to_string()), @@ -1750,6 +1764,7 @@ fn sync_cvars( ("interiorDaylight", video.interior_daylight.to_string()), ("interiorBakeFloor", video.interior_bake_floor.to_string()), ("fireLightGain", video.fire_light_gain.to_string()), + ("spellLightGain", video.spell_light_gain.to_string()), ("fireFlicker", video.fire_flicker.to_string()), // The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state. ( @@ -2191,6 +2206,13 @@ mod tests { // surfaces under +10 %) is only true at this number. assert_eq!(d["interiorBakeFloor"], shadows.interior_bake_floor); assert_eq!(d["interiorBakeFloor"], 0.12); + // MONKEY (fire GO lights) / MONKEY (spellLightGain): the two invented-light gains weld the + // same way. Both ship at 1 — the point of each is that it is a DIAL, not a default look — + // and the pin below is what keeps a tuning session from leaving one of them shipped at the + // value it was last dragged to in the debug panel. + assert_eq!(d["fireLightGain"], shadows.fire_light_gain); + assert_eq!(d["spellLightGain"], shadows.spell_light_gain); + assert_eq!(d["spellLightGain"], 1.0, "the spell lane ships neutral"); // 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 index deea78d6..cc6dc269 100644 --- a/crates/benilla-app/src/debug_panel/lighting_controls.rs +++ b/crates/benilla-app/src/debug_panel/lighting_controls.rs @@ -61,6 +61,10 @@ 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."), + // MONKEY (spellLightGain): in the "Fire" group because a spell light IS one of the invented + // sources this group tunes — it is just the one whose brightness is a combat setting rather + // than a scenery one, which is exactly why it needs its own dial and not the one above it. + knob!("spellLightGain", spell_light_gain, Slider(0.0..=4.0), "Brightness of spell, missile and impact lights."), knob!("fireFlicker", fire_flicker, Slider(0.0..=2.0), "0 steady, 1 default, 2 pronounced."), ]), ("Torch shadows", &[ diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs index b46bc80a..621924ef 100644 --- a/crates/benilla-app/src/dynamic_interior.rs +++ b/crates/benilla-app/src/dynamic_interior.rs @@ -13,7 +13,10 @@ //! [`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}; +//! +//! MONKEY (spellLightGain): and `spellLightGain` → [`benilla_world::lighting::SpellLightGain`], +//! the third of the same shape. +use benilla_world::lighting::{DynamicInteriors, FireLightGain, SpellLightGain}; use bevy::prelude::*; use crate::video::VideoConfig; @@ -36,10 +39,20 @@ fn bridge( video: Res, mut out: ResMut, mut fire: ResMut, + // MONKEY (spellLightGain): the spell lane's own gain rides the same bridge as the fire gain, + // for the same reasons — same shape (a live video cvar → a benilla-world resource the packer + // reads), same one-line guard, and a third plugin to carry a second `f32` is not worth its + // wiring. Kept a SEPARATE resource rather than a field on `DynamicInteriors` because it is not + // an interior knob at all: a fireball down a street takes it exactly as one down a corridor + // does, and `FireLightGain` beside it already stands as the precedent for that. + mut spell: ResMut, ) { if fire.0 != video.fire_light_gain { fire.0 = video.fire_light_gain; } + if spell.0 != video.spell_light_gain { + spell.0 = video.spell_light_gain; + } let want = DynamicInteriors { enabled: video.interior_light, ambient: video.interior_ambient, diff --git a/crates/benilla-app/src/entities/carried_light.rs b/crates/benilla-app/src/entities/carried_light.rs index 8988f46d..37d07d37 100644 --- a/crates/benilla-app/src/entities/carried_light.rs +++ b/crates/benilla-app/src/entities/carried_light.rs @@ -28,7 +28,7 @@ use bevy::prelude::*; use benilla_world::interior::{InteriorAnchor, WmoResidency}; use benilla_world::lighting::{ - LightLane, LightLitRooms, LightRooms, ShadowProxyLight, SyntheticFireLight, + LightLane, LightLitRooms, LightRooms, ShadowProxyLight, SpellFxLight, SyntheticFireLight, }; use benilla_world::static_gx::LightOwner; use benilla_world::terrain_stream::{carried_light_claims, point_light, CarriedClaimSet}; @@ -218,12 +218,20 @@ pub(super) fn spawn_spell_light( /// /// 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). +/// owns it and the `WOW_POINTS_DUMP` census counts it as one. +/// - [`SpellFxLight`] — MONKEY (spellLightGain): the world-side twin of [`SpellLight`], and the +/// one thing benilla-world is told about a spell light. It OVERRIDES the tag above in the +/// packer's gain fold: a spell row takes `spellLightGain`, never `fireLightGain` (they compose +/// nowhere — see [`benilla_world::lighting::SpellFxLight`] for why the marker is world-side +/// rather than `SpellLight` itself moving down). /// - [`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. +/// [`budget_spell_lights`]), the handle any future per-school gain targets, and (MONKEY (spell +/// light lane)) what [`claim_carried_light_rooms`] reads to let this light claim rooms while it +/// MOVES: a missile can never settle, and a light with no claims takes the exterior lane, which +/// the room shaders never read. /// /// 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 @@ -256,6 +264,7 @@ fn spawn_spell_light_child( Transform::from_translation(wow_to_bevy(local)), Visibility::default(), SyntheticFireLight, + SpellFxLight, HeldLight, SpellLight::new(base, fx.onset, mode), // The same owner exclusion every carried light takes: the flame sits INSIDE the thing @@ -528,6 +537,39 @@ fn claims_at( }) } +/// MONKEY (spell light lane): **may this light claim rooms this frame?** +/// +/// The settle rule ([`CarriedLightMotion::settled`]) exists to keep an O(groups) box scan per +/// resident placement off a WALKING bearer: a pet's torch would rebuild its set every frame for a +/// set that is about to be wrong again, so an unsettled carried light simply carries no claims and +/// falls back to the single-anchor-room behaviour it had before claims existed. +/// +/// A SPELL light inverts every term of that trade, so it is exempt: +/// +/// * **It never settles, by construction.** A missile flies from the caster's hand to the target +/// and is despawned on arrival; it can never hold still for [`STILL_HOLD`], so under the settle +/// rule it could never claim a room for a single frame of its life. The gap is not a tuning +/// question — an indoor fireball lit *nothing* on the way down a corridor, and only the impact's +/// own burst (which IS momentarily still, at a dest anchor) ever reached a wall. +/// * **The fallback it would take is empty.** An unsettled carried light still has its bearer's +/// anchor room to fall back on; a missile is a FREE world entity with no `InteriorAnchor` +/// anywhere above it ([`owner_lane`] walks to the top and answers `(None, 0.0)`), so "no claims" +/// means the EXTERIOR lane — and the exterior lane is the one `interior_room_light` never reads, +/// which is why the bolt lit no walls at all. +/// * **The cost it was protecting against is bounded.** Spell lights are capped at +/// [`SPELL_LIGHTS_MAX`](super::spell_fx::SPELL_LIGHTS_MAX) = 24 across every lane, and each lives +/// for about a second. The claim walk is still change-gated by [`CarriedClaims::fresh`] — a light +/// that has not drifted past [`CLAIM_DRIFT`] since it was measured keeps its set — so the worst +/// case is 24 lights × (resident placements) containment scans in a frame, each of which +/// early-outs on an AABB test per group and does no portal work at all unless some box holds the +/// light. That is the same walk ~150 of Orgrimmar's brazier GameObjects run on their first frame. +/// +/// Settling is still what the SHADOW lane reads, and this function does not touch it: a spell light +/// is [`HeldLight`] and unsettled, so it is refused a cube-shadow slot exactly as before. +fn may_claim(spell: bool, motion: Option<&CarriedLightMotion>) -> bool { + spell || motion.is_some_and(CarriedLightMotion::settled) +} + /// MONKEY (fire GO lights): give a CARRIED light the room its owner was classified into. /// /// A world-baked light gets its rooms at spawn from the building's own MODR/MOLR tables. An @@ -568,12 +610,20 @@ fn claims_at( /// gated when the set was built, or every claim in the world would rebuild on a slider drag. /// /// **Gated on four things**, because the walk is an O(groups) box scan per resident placement and -/// Orgrimmar has ~150 of these lights: the light must be SETTLED -/// ([`CarriedLightMotion::settled`] — a walking pet's light re-claims no more often than the settle -/// cadence, and while it is unsettled and off its build point it carries NO claims, i.e. exactly -/// the single-anchor-room behaviour it had before this change), and the set is rebuilt only when -/// the light drifts past [`CLAIM_DRIFT`], when [`WmoResidency`] ticks, or when the bearer's room -/// changes ([`CarriedClaims::fresh`]). +/// Orgrimmar has ~150 of these lights: the light must be allowed to claim ([`may_claim`] — SETTLED +/// for an ordinary carried light, so a walking pet's light re-claims no more often than the settle +/// cadence and while it is unsettled and off its build point it carries NO claims, i.e. exactly the +/// single-anchor-room behaviour it had before this change; a SPELL light is exempt and claims while +/// it flies), and the set is rebuilt only when the light drifts past [`CLAIM_DRIFT`], when +/// [`WmoResidency`] ticks, or when the bearer's room changes ([`CarriedClaims::fresh`]). +/// +/// MONKEY (spell light lane): and the LANE half is what makes a moving claim worth anything. A +/// missile has no [`InteriorAnchor`] above it at all, so `want` is `None` and the bearer half of +/// the lane verdict can never fire; it reaches the interior lane purely through [`lane_worthy`]'s +/// claim half, which is the path an anchorless GameObject in an exterior-class canyon group already +/// took. Without it the bolt packs on the EXTERIOR lane, whose entries `interior_room_light` skips +/// outright (`static_gx.wgsl` drops every row with colour-row `.w < 0.5`) — the room's walls never +/// see it, and only the impact's own burst, which lands still at a dest anchor, ever lit anything. /// /// Every component write below is change-gated on [`ClaimedRoom`]/[`CarriedClaims`]: a settled /// light is one hierarchy walk and three compares per frame, and nothing is written, so the light's @@ -589,12 +639,23 @@ pub(crate) fn claim_carried_light_rooms( Entity, // MONKEY (GO room claims): the light's own WORLD position (the claim rule's input) and // its intensity (which sizes the portal hop). `Update` reads last frame's propagation, - // which is this frame's answer for anything settled — and only settled lights claim. + // which is this frame's answer for anything settled. + // MONKEY (spell light lane): a MOVING spell light now claims too, so for that one case + // this position is one frame stale — at a missile's ~25 yd/s that is ~0.4 yd at 60 Hz. + // Accepted rather than re-scheduled: the answer it feeds is ROOM MEMBERSHIP, whose + // smallest feature is a doorway a yard wide, and the light's brightness at a wall + // changes by nothing measurable over that distance. The alternative — moving this + // system into `PostUpdate` between `Propagate` and the packer — would put a full + // O(placements) walk for every carried light in the world (Orgrimmar: ~150) into the + // frame's tightest stage to buy 0.4 yd on 24 of them. &GlobalTransform, &PointLight, Option<&CarriedLightMotion>, Option<&mut ClaimedRoom>, Option<&mut CarriedClaims>, + // MONKEY (spell light lane): is this a spell light? It is the one carried light that + // claims while MOVING — see [`may_claim`]. + Has, ), (With, Without), >, @@ -606,7 +667,7 @@ pub(crate) fn claim_carried_light_rooms( residency: Res, ) { let generation = residency.generation(); - for (light, gt, pl, motion, mut claimed, mut claims) in &mut lights { + for (light, gt, pl, motion, mut claimed, mut claims, spell) in &mut lights { let (room, lane) = owner_lane(light, &parents, &anchors); // The room to claim IF we are on the interior lane: the one the anchor names this frame, // else the last one it named (the ramp-down window described on `ClaimedRoom::seen`). @@ -621,7 +682,7 @@ pub(crate) fn claim_carried_light_rooms( .is_some_and(|c| c.fresh(at, generation, want)); let set = if fresh { None - } else if motion.is_some_and(CarriedLightMotion::settled) { + } else if may_claim(spell, motion) { // The prop lane's own sizing: the intensity bucket (`PointLight` premultiplied 4π at // spawn, so this inverts it exactly) through the DEFAULT atten scale. let reach = benilla_formats::room_claim::claim_reach( @@ -629,6 +690,11 @@ pub(crate) fn claim_carried_light_rooms( pl.intensity / (4.0 * std::f32::consts::PI), ), ); + // MONKEY (spell light lane): for a spell light the FIRST entry is usually `None` — + // a missile has no bearer anchor to name a placement — so the steady-state probe is + // the SECOND: the building it claimed last frame. A bolt flying down a corridor stays + // in the building it was cast in, so that probe hits on every frame after the first + // and the full placement scan runs once per spell light, at birth. let prefer = [ want.map(|r| r.instance), claims @@ -639,7 +705,9 @@ pub(crate) fn claim_carried_light_rooms( Some(claims_at(&wmos, &placements, prefer, at, reach)) } else { // Moved off its build point and not standing still: drop to the pre-claims behaviour - // rather than assert a set for a position it was not measured at. + // rather than assert a set for a position it was not measured at. A SPELL light never + // reaches here ([`may_claim`]) — for it the "position it was measured at" is simply + // re-measured, one frame late (see the `GlobalTransform` note on the query). Some(None) }; // The set that stands THIS frame: the one just decided, else the one already applied. Read @@ -996,4 +1064,84 @@ mod tests { "the bearer left the building" ); } + + /// GOLDEN — MONKEY (spell light lane). A SPELL light claims rooms **while it is moving**; every + /// other carried light must hold still first. + /// + /// This is the whole of the missile fix in one predicate. A projectile is despawned on arrival + /// and can never satisfy [`STILL_HOLD`], so under the settle rule it claimed nothing for its + /// entire life — and with no [`InteriorAnchor`] above it either, "no claims" left it on the + /// EXTERIOR lane, which `interior_room_light` never reads. An indoor fireball therefore lit no + /// wall at any point of its flight; only its impact, which lands still, ever did. + /// + /// The exemption is deliberately NOT "everything unsettled claims": the settle rule still keeps + /// a walking pet's torch from rebuilding an O(groups) scan per resident placement every frame. + /// It is bought for the spell lane alone, where the population is capped + /// ([`super::spell_fx::SPELL_LIGHTS_MAX`]) and each light lives about a second. + #[test] + fn a_spell_light_claims_while_it_flies_and_a_torch_still_must_settle() { + let moving = CarriedLightMotion { + last: Vec3::ZERO, + still_for: 0.0, + }; + let parked = CarriedLightMotion { + last: Vec3::ZERO, + still_for: STILL_HOLD, + }; + assert!( + may_claim(true, Some(&moving)), + "a missile's light claims mid-flight — the whole point" + ); + assert!( + may_claim(true, None), + "and on its very first frame, before the motion tracker has seen it at all" + ); + assert!( + !may_claim(false, Some(&moving)), + "a walking bearer's torch still waits: the scan it would run is about to be stale" + ); + assert!( + may_claim(false, Some(&parked)), + "a placed brazier settles and claims exactly as before" + ); + assert!( + !may_claim(false, None), + "born moving is not a licence to claim" + ); + } + + /// GOLDEN — MONKEY (spell light lane). The LANE half: a claim set with no bearer room behind it + /// still earns the INTERIOR lane, which is the only lane whose entries the room shaders read. + /// + /// A missile is a free world entity — [`owner_lane`] finds no [`InteriorAnchor`] above its + /// light and answers `(None, 0.0)` — so `want` is `None` and the bearer half of the lane + /// verdict can never fire for it. Everything therefore rests on [`lane_worthy`], and on both of + /// its conditions holding their meaning: a real room moves the light onto the interior lane, + /// and a district-scale exterior shell (Stormwind's districts, Orgrimmar's valley) does NOT — + /// a bolt flying over open cobbles must keep lighting them through `point_light_sum` rather + /// than be handed to a lane that would not reach them. + #[test] + fn an_anchorless_spell_light_takes_the_interior_lane_from_its_claims_alone() { + let set = |groups: &[u16], any_interior| CarriedClaimSet { + instance: Entity::PLACEHOLDER, + groups: groups.to_vec().into(), + fades: Vec::new().into(), + any_interior, + }; + assert!( + lane_worthy(&set(&[3, 4], true)), + "a corridor and the room past its doorway: the walls must see the bolt" + ); + assert!( + !lane_worthy(&set(&[3, 4], false)), + "an all-exterior set reaches no room lane, so moving onto it would only lose the pool" + ); + assert!( + !lane_worthy(&set( + &[3, 4 | benilla_world::lighting::LIT_ROOM_EXT_DENY], + true + )), + "one district-scale shell in the set and the light stays on the cobbles it lights" + ); + } } diff --git a/crates/benilla-app/src/entities/spell_fx/lifecycle.rs b/crates/benilla-app/src/entities/spell_fx/lifecycle.rs index 44520255..5ed3b18a 100644 --- a/crates/benilla-app/src/entities/spell_fx/lifecycle.rs +++ b/crates/benilla-app/src/entities/spell_fx/lifecycle.rs @@ -258,9 +258,14 @@ pub(crate) const SPELL_LIGHTS_MAX: usize = 24; /// 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. +/// An env var and STILL not a cvar, now that `spellLightGain` exists beside `fireLightGain` +/// (MONKEY (spellLightGain) — `cvars.rs`, folded at pack time in +/// `benilla_world::lighting::global_light::build_light_data`). The two are different tools and both +/// are wanted: the cvar is the live BRIGHTNESS dial, and its `0` darkens a spell light that is +/// still spawned, still parented, still aged and still counted against +/// [`SPELL_LIGHTS_MAX`]; this switch stops the lights being CREATED at all, which is what an A/B +/// against the pre-feature build needs (`WOW_SPELL_LIGHT=0` costs the frame nothing, and takes the +/// claim walk in `carried_light::claim_carried_light_rooms` with it). 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"))) diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index c90bd9f1..623f1696 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -378,6 +378,14 @@ pub(crate) struct VideoConfig { /// [`benilla_world::lighting::FireLightGain`] by `dynamic_interior`, and applied at PACK time /// so it is live. pub(crate) fire_light_gain: f32, + /// MONKEY (spellLightGain): gain on every light a SPELL EFFECT invented — a kit's aura glow, a + /// missile's core, an impact flash, a firework shell's burst (`spellLightGain`, 0..4; `0` = the + /// spell-light lane off). Bridged to benilla-world's + /// [`benilla_world::lighting::SpellLightGain`] by `dynamic_interior` and applied at PACK time, + /// so it is live. Deliberately NOT folded into `fireLightGain`: a spell light is tagged + /// synthetic too, and one dial over both would mean turning the world's hearths down darkened + /// every fireball in the game. + pub(crate) spell_light_gain: f32, /// MONKEY (flame flicker): how strongly every FLAME's brightness wobbles (`fireFlicker`, 0..2; /// `1` = the authored per-kind amplitudes, `0` = the pre-feature steady constants, `2` = /// doubled). Bridged to `DynamicInteriors::flicker` and applied at PACK time, so it is live — @@ -437,6 +445,7 @@ impl Default for VideoConfig { // under 10 % (see `lighting::DynamicInteriors::bake_floor`). interior_bake_floor: 0.12, fire_light_gain: 1.0, + spell_light_gain: 1.0, fire_flicker: 1.0, display: if windowed_env() { DisplayMode::Windowed diff --git a/crates/benilla-assets/src/shaders/shadow_hook.wgsl b/crates/benilla-assets/src/shaders/shadow_hook.wgsl index 09170b37..da85a643 100644 --- a/crates/benilla-assets/src/shaders/shadow_hook.wgsl +++ b/crates/benilla-assets/src/shaders/shadow_hook.wgsl @@ -108,6 +108,77 @@ 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 (slope bias): the per-tap RECEIVER-PLANE correction's ceiling, in multiples of the +// constant `bias`. A PCF/PCSS tap `n` texels off centre lands on a piece of the SAME surface whose +// stored depth differs by the receiver plane's own depth gradient times `n`; `torch_map_shadow` +// walks that tap's compare reference along the plane by exactly that much, so a tap on the +// receiver's own plane compares equal-to-equal and can never fail, at any grazing angle. What the +// correction must NOT do is walk so far that a tap on a real OCCLUDER also passes - that is +// peter-panning, a shadow lifting off its contact - so it is clamped at `TORCH_SLOPE_MAX * bias`. +// +// THE ARITHMETIC - and a CORRECTION to round 3's. The receivers' `TORCH_NORMAL_OFFSET` note prices +// all of this in "yards of RAY distance", which is not what `textureSampleCompareLevel` compares: +// the stored value is `ndc.z` on the cube FACE the fragment lands in, and under a perspective +// projection that is a function of the VIEW-SPACE Z - the component along the FACE AXIS - not of +// the ray length. Two things follow that the round-3 table cannot show: +// +// · a floor directly under a fixture lands in the DOWN face, where its view-space Z is the +// constant `h`, so its stored depth is CONSTANT across that whole face and there is no +// gradient to clear at all - acne is impossible there. It begins the moment `r > h` and the +// fragment crosses into a SIDE face, whose view-space Z is the HORIZONTAL distance and along +// which the floor runs away from the eye. On a side face the gradient works out to `2A/(f*h)` +// per unit uv (`A = near*far/(far-near)`, `f = 1/tan(fov/2)`) - i.e. it depends on the +// fixture's HEIGHT and not at all on `r`: the same at 3 yd out as at 30. +// · the note's normal-offset term `0.15*(h/t)` is INVERTED. Moving the sample point 0.15 yd up +// off a grazing floor moves the place where its own shadow ray meets that floor by +// `0.15*(t/h)` - amplified by the grazing angle, not reduced by it. So the offset is worth +// about `(t/h)^2` times what round 3 credited it with, which is why a knee-high fixture's pool +// was in fact clean to ~10 yd rather than to ~2.1. +// +// Re-measured exactly (scratchpad `slopebias_exact.py`: the real reverse-Z cube face, the real +// `torch_face` pick on the OFFSET point, the widened kernel's 8 taps at 3 and 6 texels, each +// SNAPPED to its texel centre, compared against the real floor plane). BREAK RADIUS = how far a +// fixture `h` yd above a flat floor lights before its own worst tap starts to fail: +// +// h (yd) before after +// 4.0 clean clean a chandelier, a lamppost head +// 3.0 clean clean a WALL TORCH - always was fine, and stays fine +// 2.0 25.8 yd clean a brazier +// 1.5 14.1 yd clean +// 1.0 10.1 yd clean the IMP's hand flame - the reported case +// 0.5 7.3 yd clean a candle standing on the floor +// 0.25 7.1 yd 11.7 yd CLAMPED - see below +// +// "clean" = no tap fails anywhere out to the projection's usable 46 yd, i.e. the acne is GONE and +// not merely pushed outward. Where a tap was already passing the margin simply widens, at h = 1 yd: +// +3.5e-4 -> +2.3e-3 at r = 9, +1.7e-3 -> +3.6e-3 at r = 5; and where it was failing, -5.1e-4 -> +// +1.5e-3 at r = 15. The h = 3 wall-torch rows move 1.0x-2.7x in the same direction and never +// change verdict, which is the "forge floor unchanged, still attached at contact" requirement. +// +// The BLOCKER SEARCH matters as much as the taps. At h = 1, r = 15 its own 3-texel taps were +// self-detecting (-5.1e-4). That darkens nothing, but it hands `TORCH_PCSS_K` a blocker ratio made +// entirely of bias error - a pool going soft with nothing in it. Corrected: +1.5e-3. +// +// WHY 4 AND NOT MORE. The correction the outermost tap WANTS is 2.8e-3 at h = 1 and 6.8e-3 at +// h = 0.5, against the `4*bias` = 4e-3 ceiling: free at h = 1, clamped at h = 0.5 - and clamped is +// still enough there (+3.5e-3 at r = 9). It only falls short under a QUARTER-yard fixture, a light +// effectively lying on the ground, where the surface is near-edge-on to it, `max(N.L, 0)` has +// already taken the term toward zero, and refusing to move further is the conservative answer. A +// TILTED receiver wants LESS, not more (2.7e-4 at 45 deg, 3.0e-4 at 75 deg: the face pick swings +// back to the DOWN face and the gradient collapses), so raising the ceiling would buy nothing +// anywhere except the one case where it is deliberately declining to act, while starting to lift +// genuine contact shadows - the single failure mode a shadow bias must never have. +const TORCH_SLOPE_MAX: f32 = 4.0; + +// MONKEY (slope bias): ONE tap's compare reference - the centre reference walked along the RECEIVER +// PLANE by `dot(grad, off)` and bounded at +/-`lim`. `grad` is `d(ndc.z)/d(uv)` restricted to that +// plane (built once per call in `torch_map_shadow`), `off` is the tap's uv displacement from the +// centre. Three ALU and a clamp, no texture read. It is a function rather than four inline +// expressions so a tap's OFFSET and its REFERENCE cannot drift apart - they are the same argument. +fn torch_plane_ref(ref_depth: f32, grad: vec2, off: vec2, lim: f32) -> f32 { + return ref_depth + clamp(dot(grad, off), -lim, lim); +} + // 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 @@ -129,8 +200,17 @@ fn torch_map_depth( // - `view_proj` the fixture's down-looking reverse-Z matrix. // - `layer` which array layer (fixture index) to sample. // - `world_pos` the receiving fragment's world position. +// - `normal` MONKEY (slope bias): the receiving surface's world-space normal, used ONLY to +// build the receiver plane whose depth gradient each tap's compare reference is +// walked along (see `TORCH_SLOPE_MAX`). It need not be unit and may be the zero +// vector - the M2 corpus authors those, and a zero normal simply switches the +// slope term off, leaving the constant bias exactly as it was. Callers pass the +// SAME normal they already offset `world_pos` along (`TORCH_NORMAL_OFFSET`), so +// the offset point and the plane through it agree by construction. // - `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). +// MONKEY (slope bias): still a CONSTANT floor under every tap - the slope term is +// added on top of it per tap, never in place of it. // - `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 @@ -173,10 +253,16 @@ fn torch_map_depth( // 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. +// MONKEY (slope bias) adds NO texture read at all: ~90 ALU once per call (two crosses, two +// `mat4x4 * vec4`, a 2x2 solve) to build the receiver-plane gradient, then a dot-clamp-add — about +// 5 ALU — on each of the 4 search taps and 4 or 8 PCF taps. The gradient cannot hoist out of the +// receivers' per-fixture loop (it depends on the fixture's own face matrix), but it is bounded by +// `EXT_SEL_SHADOWED` there exactly as the taps are. fn torch_map_shadow( view_proj: mat4x4, layer: i32, world_pos: vec3, + normal: vec3, depth_tex: texture_depth_2d_array, comp: sampler_comparison, bias: f32, @@ -198,27 +284,96 @@ fn torch_map_shadow( // 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`. 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. + // compare ref is `ndc.z + bias` against `GreaterEqual`. UNCHANGED bias semantics: this is the + // CENTRE reference, and the blocker test and every comparison tap are derived from this one + // expression, so a surface can no more blocker-detect itself than it could shadow itself. + // MONKEY (slope bias): "derived from" rather than "is" - each tap now adds its own + // receiver-plane term on top (`torch_plane_ref` below), which strengthens that property rather + // than weakening it: the taps that used to self-detect were exactly the off-centre ones. let ref_depth = ndc.z + bias; + // MONKEY (slope bias) — RECEIVER-PLANE DEPTH BIAS. `bias` is a constant and the thing it has to + // clear is not: a tap `n` texels off centre reads a piece of the SAME surface whose stored depth + // differs by the receiver plane's own gradient times `n`, and on a floor lit from a low angle + // that gradient is the whole problem (`TORCH_SLOPE_MAX` has the measured numbers — a knee-high + // flame's floor starts losing to it past 10 yd, a candle's past 7, and the taps that fail + // print as faint rings out at the rim of the pool where the term is still visible). So every + // tap gets its OWN reference, walked along the receiver's plane by what + // that plane does over that tap's displacement: a tap sitting on the plane then compares + // equal-to-equal and cannot fail at any grazing angle, while a tap on a real occluder is + // untouched — the occluder is not on this plane, which is what makes it an occluder. + // + // `grad` is `d(ndc.z)/d(uv)` restricted to the plane through `world_pos` with normal `normal`, + // computed ANALYTICALLY rather than from `dpdx`/`dpdy` of the ndc. Two reasons, both hard: + // · every call site is inside a receiver's per-fixture table scan, with data-dependent + // `continue`s and an early `return` (`torch_map_at`, `torch_terrain_shadow`, + // `torch_entity_shadow_at`) — NON-UNIFORM control flow, where WGSL's derivative-uniformity + // rule makes `dpdx` a diagnostic at best and a neighbouring-fixture read at worst; + // · screen-space derivatives are a QUAD difference, so they are wrong by construction on a + // two-pixel-wide sliver and along every silhouette — exactly the geometry (fence rails, + // chair legs, tent ropes) this lane spends its taps on. + // The analytic form needs no quad and no uniformity, and is EXACT for a plane. + // + // Any two independent vectors IN the plane give the same gradient, so the tangents are left + // un-normalised and `normal` need not be unit: only the plane's direction SPACE matters and the + // 2x2 solve is invariant to how it is spanned. `a` picks the axis the normal is least aligned + // with, so the first cross is never near-degenerate. A ZERO normal (the M2 corpus authors them + // — see `wow_normalize` in the receivers) leaves `grad` at zero, i.e. the old constant-bias + // behaviour, bit-for-bit. + var grad = vec2(0.0); + if (dot(normal, normal) > 1e-12) { + let a = select(vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, 1.0), + abs(normal.x) > abs(normal.z)); + let t1 = cross(normal, a); + let t2 = cross(normal, t1); + // d(clip) along each tangent, then through the perspective divide: + // `d(ndc) = (d(clip).xyz - ndc * d(clip).w) / clip.w`. + let c1 = view_proj * vec4(t1, 0.0); + let c2 = view_proj * vec4(t2, 0.0); + let inv_w = 1.0 / clip.w; + let g1 = (c1.xyz - ndc * c1.w) * inv_w; + let g2 = (c2.xyz - ndc * c2.w) * inv_w; + let u1 = g1.xy * vec2(0.5, -0.5); // the same ndc -> uv flip `uv` above uses + let u2 = g2.xy * vec2(0.5, -0.5); + // Solve `dot(grad, u_i) = g_i.z` for i = 1, 2. `det` collapses only as the plane goes + // EDGE-ON to the fixture, where the plane projects to a line in the map, `max(N·L, 0)` has + // already taken the lit term to zero, and a zero gradient is the right answer anyway. + let det = u1.x * u2.y - u1.y * u2.x; + if (abs(det) > 1e-9) { + let inv = 1.0 / det; + grad = vec2((g1.z * u2.y - u1.y * g2.z) * inv, + (u1.x * g2.z - g1.z * u2.x) * inv); + } + } + // …and the ceiling on what any one tap may be moved by. See `TORCH_SLOPE_MAX`. + let slope_lim = TORCH_SLOPE_MAX * bias; + // MONKEY (pcss) 1/3 — BLOCKER SEARCH. Four raw reads on the diagonals of a box of half-width // `soft * TORCH_PCSS_SEARCH` texels, averaging the depths that lie IN FRONT of the receiver. // Four taps at one radius rather than a ring at several: they only have to answer "is there a // caster near me, and roughly how far in front", and denser searching costs more than the extra // precision buys on a 512² face whose texels are ~10 cm at a candle's range. let search = base * TORCH_PCSS_SEARCH * inv_dims; - let b0 = torch_map_depth(depth_tex, layer, uv + vec2(-1.0, -1.0) * search, dims); - let b1 = torch_map_depth(depth_tex, layer, uv + vec2( 1.0, -1.0) * search, dims); - let b2 = torch_map_depth(depth_tex, layer, uv + vec2(-1.0, 1.0) * search, dims); - let b3 = torch_map_depth(depth_tex, layer, uv + vec2( 1.0, 1.0) * search, dims); + // MONKEY (slope bias): the offsets are NAMED so the search's reference and its sample are the + // same displacement, exactly as in the PCF kernel below. + let s0 = vec2(-1.0, -1.0) * search; + let s1 = vec2( 1.0, -1.0) * search; + let s2 = vec2(-1.0, 1.0) * search; + let s3 = vec2( 1.0, 1.0) * search; + let b0 = torch_map_depth(depth_tex, layer, uv + s0, dims); + let b1 = torch_map_depth(depth_tex, layer, uv + s1, dims); + let b2 = torch_map_depth(depth_tex, layer, uv + s2, dims); + let b3 = torch_map_depth(depth_tex, layer, uv + s3, 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 (slope bias): the SEARCH is plane-corrected too, and it has to be. Left on the flat + // reference, a grazing floor detects ITSELF as its own blocker, and the penumbra width below is + // then estimated from a depth difference that is pure bias error — a pool that goes soft with + // nothing in it. Same law, same `grad`, same clamp as the taps. + if (b0 > torch_plane_ref(ref_depth, grad, s0, slope_lim)) { blocker += b0; blockers += 1.0; } + if (b1 > torch_plane_ref(ref_depth, grad, s1, slope_lim)) { blocker += b1; blockers += 1.0; } + if (b2 > torch_plane_ref(ref_depth, grad, s2, slope_lim)) { blocker += b2; blockers += 1.0; } + if (b3 > torch_plane_ref(ref_depth, grad, s3, slope_lim)) { 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 @@ -241,17 +396,36 @@ fn torch_map_shadow( // 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; + // MONKEY (slope bias): every tap carries its OWN plane-corrected reference. With `grad` zero - + // a receiver square-on to the fixture, or a zero normal - `torch_plane_ref` returns `ref_depth` + // unchanged and this kernel is the shipped one bit-for-bit. + let p0 = vec2(-0.5, -0.5) * texel; + let p1 = vec2( 0.5, -0.5) * texel; + let p2 = vec2(-0.5, 0.5) * texel; + let p3 = vec2( 0.5, 0.5) * texel; var sum = 0.0; - sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2(-0.5, -0.5) * texel, layer, ref_depth); - sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2( 0.5, -0.5) * texel, layer, ref_depth); - sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2(-0.5, 0.5) * texel, layer, ref_depth); - sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2( 0.5, 0.5) * texel, layer, ref_depth); + sum += textureSampleCompareLevel(depth_tex, comp, uv + p0, layer, + torch_plane_ref(ref_depth, grad, p0, slope_lim)); + sum += textureSampleCompareLevel(depth_tex, comp, uv + p1, layer, + torch_plane_ref(ref_depth, grad, p1, slope_lim)); + sum += textureSampleCompareLevel(depth_tex, comp, uv + p2, layer, + torch_plane_ref(ref_depth, grad, p2, slope_lim)); + sum += textureSampleCompareLevel(depth_tex, comp, uv + p3, layer, + torch_plane_ref(ref_depth, grad, p3, slope_lim)); var pcf = sum * 0.25; if (radius > TORCH_PCSS_WIDE) { - sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2(-1.0, 0.0) * texel, layer, ref_depth); - sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2( 1.0, 0.0) * texel, layer, ref_depth); - sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2( 0.0, -1.0) * texel, layer, ref_depth); - sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2( 0.0, 1.0) * texel, layer, ref_depth); + let p4 = vec2(-1.0, 0.0) * texel; + let p5 = vec2( 1.0, 0.0) * texel; + let p6 = vec2( 0.0, -1.0) * texel; + let p7 = vec2( 0.0, 1.0) * texel; + sum += textureSampleCompareLevel(depth_tex, comp, uv + p4, layer, + torch_plane_ref(ref_depth, grad, p4, slope_lim)); + sum += textureSampleCompareLevel(depth_tex, comp, uv + p5, layer, + torch_plane_ref(ref_depth, grad, p5, slope_lim)); + sum += textureSampleCompareLevel(depth_tex, comp, uv + p6, layer, + torch_plane_ref(ref_depth, grad, p6, slope_lim)); + sum += textureSampleCompareLevel(depth_tex, comp, uv + p7, layer, + torch_plane_ref(ref_depth, grad, p7, slope_lim)); pcf = sum * 0.125; } // Phase 5: the six cube faces tile the whole sphere, so there is no cone edge to soften — a fade diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl index 7a4e36ed..cecef46e 100644 --- a/crates/benilla-assets/src/shaders/terrain.wgsl +++ b/crates/benilla-assets/src/shaders/terrain.wgsl @@ -195,9 +195,12 @@ fn torch_terrain_shadow(light_pos: vec3, P: vec3, N: vec3) -> f32 // "how far does this fire's shadow carry", a property of the fire. Chosen just inside the // cube projection's own 48 yd far plane so the frustum edge is never a hard cut. let fade = 1.0 - smoothstep(0.8 * TORCH_EXT_FADE_YD, TORCH_EXT_FADE_YD, distance(fixture, Ps)); + // MONKEY (slope bias): `N` is the ground normal the sample point was already + // offset along, so the plane the projector biases against is the one this fragment + // actually lies on. let s = shadow_hook::torch_map_shadow( - torch_table.view_projs[layer], i32(depth_layer), Ps, torch_depth, torch_samp, TORCH_BIAS, - torch_soft(), fade); + torch_table.view_projs[layer], i32(depth_layer), Ps, N, torch_depth, torch_samp, + TORCH_BIAS, torch_soft(), fade); // 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. @@ -225,9 +228,11 @@ fn torch_terrain_debug_factor(P: vec3, N: vec3) -> f32 { // MONKEY (live bank rank): debug samples the same compact bank as the lit path. let rank = countOneBits(torch_table.count.z & ((1u << i) - 1u)); let depth_layer = select(layer, 96u + 6u * rank + face, (torch_table.count.z & (1u << i)) != 0u); + // MONKEY (slope bias): the overlay biases against the SAME plane the lit path does, or + // `interiorDebug 2` would keep showing acne the render no longer has. let raw = shadow_hook::torch_map_shadow( - torch_table.view_projs[layer], i32(depth_layer), Ps, torch_depth, torch_samp, TORCH_BIAS, - torch_soft(), -1.0); + torch_table.view_projs[layer], i32(depth_layer), Ps, N, torch_depth, torch_samp, + TORCH_BIAS, torch_soft(), -1.0); // MONKEY (torch caster selection): the WEIGHTED factor, matching the real render. s = min(s, mix(1.0, raw, torch_table.positions[i].w)); } @@ -257,15 +262,16 @@ struct TerrainVsOut { // bias. This is forward-pass data only; the terrain shadow caster is a stock Bevy proxy. @location(7) world_normal: vec3, // MONKEY (outdoor torch shadows: terrain; 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 + // entries `primary`'s point term was summed from, packed 8 bits each across FOUR 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, + // 8-bit ranks and then to FOUR u32s of twelve - three extra interstage components in all (28 + // for this struct, against a 60-component limit) - see `EXT_SEL_K` for why three, and then + // eight, stopped being enough. + @location(8) @interpolate(flat) ext_sel: vec4, // 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 @@ -311,14 +317,14 @@ 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 <=`EXT_SEL_K`-nearest selection packed into TWO u32s, eight 8-bit indices (rank 0 in +// sync): the <=`EXT_SEL_K`-nearest selection packed into FOUR u32s, twelve 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 +// MONKEY (ext light k12) - **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 @@ -330,37 +336,63 @@ const TERRAIN_REACH: f32 = 33.570166; // 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. +// "scars"), and as a bench lit by a firework over ground that is not. More slots make neighbouring +// units AGREE, so a pool ends where the FALLOFF ends instead of where the cell does; the only open +// question is how many is enough, and the next paragraph answers it with the measurement. // // 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 +// MONKEY (ext light k12) - **AND WHY 8 -> 12, one round later.** Eight was MARGINAL the day it +// landed and the note here said so. The Darkmoon Faire's densest chunk holds THIRTEEN candidates +// and EIGHT of them stand strictly inside its own 33.33 yd cell, so a torch 2.5 yd outside the edge +// - lighting the grass right at the seam - cannot win a slot under any ranking, and that chunk goes +// on disagreeing with its neighbour about it. On that particular seam K=8 did not help, it made +// things WORSE (0.198 -> 0.356 of a falloff unit): raising K let the SPARSE neighbour take in +// lights the dense chunk still had no room for, so the two sets grew further apart, not closer. +// Twelve is the first K at which every chunk around the player keeps everything it can see bar one: +// +// worst ABSOLUTE seam jump, the four chunks meeting at the corner nearest WoW (-9557, 99) +// (`k12_table.py` in the scratchpad; falloff units, colour and N.L factored out, the byte-verified +// `1/(0.7d + 0.03d^2)`, 200 samples along each shared edge) +// seam cands K=3 K=8 K=12 K=16 +// (-288,2)|(-288,3) 6 | 2 0.0418 0.1117 0.1117 0.1117 +// (-288,2)|(-287,2) 6 | 13 0.6425 0.0882 0.0669 0.1281 +// (-288,3)|(-287,3) 2 | 7 0.2888 0.0862 0.0862 0.0862 +// (-287,2)|(-287,3) 13 | 7 0.1980 0.3556 0.1144 0.1406 +// WORST 0.6425 0.3556 0.1144 0.1406 +// +// Read the last two columns together. At K>=13 every chunk keeps ALL its candidates, so 0.1406 is +// the CANDIDACY BOX's own floor - the residual disagreement two chunks have because their gather +// boxes are centred 33 yd apart - and no K can touch it (widening the box is a separate change, and +// it is the byte-verified one). K=12 measures 0.1144, slightly UNDER that floor, because the dense +// chunk drops exactly one far candidate its neighbour also barely weights. So twelve ENDS this line +// of attack: 16 is not better, only wider. +// +// The own-origin case in the same table - a bench standing on the ground, ranking from its own +// position, against the chunk under it - goes 79 % / 71 % / 52 % apart at K=3, to 31 % / 27 % / 5 % +// at K=8, to 3 % / 1 % / 5 % at K=12. That is the "bench lit by a firework over dark grass" closing. +// +// RAISING K FURTHER: `ext_sel_get` and the pack tail index the selection vector dynamically // (`sel[s >> 2u]`) and the rank arrays are zero-constructed and filled, so K is not spelled out -// anywhere but here. K=16 = swap `vec2` for `vec4` at its five type sites plus the -// interstage field, set this to `16u`, and give `EXT_SEL_NONE` its two extra words. Worth knowing -// because the Darkmoon Faire measurement is MARGINAL at 8: one 33.33 yd chunk there holds exactly -// eight fixtures strictly inside its own cell, so a ninth standing 2.5 yd outside the edge cannot -// win a slot under any ranking, and that chunk still disagrees with its neighbour about it. Eight -// takes the worst measured seam jump there from 0.64 to 0.36 of a falloff unit (and the everyday -// Goldshire/Stormwind case to zero); twelve would take it to 0.11, against a hard floor of ~0.14 -// set by the candidacy box itself, which is a separate change. -const EXT_SEL_K: u32 = 8u; +// anywhere but here. The vector is `vec4` now and holds SIXTEEN rank slots, so K = 13..16 is +// this constant ALONE - no type site, no interstage field, no `EXT_SEL_NONE` edit. Past 16 the next +// step is a second `vec4` (four more interstage components) and `ext_sel_get` learning which +// vector to read, which is a real change rather than a constant. +const EXT_SEL_K: u32 = 12u; const EXT_SEL_EMPTY: u32 = 255u; -// MONKEY (ext light k8): how many of the `EXT_SEL_K` ranks pay for a CUBE-MAP OCCLUSION lookup in +// MONKEY (ext light k12): 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 +// dominates this fragment; ranks 3..11 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. +// what it was - three table scans and their taps - while the selection itself gets twelve deep. const EXT_SEL_SHADOWED: u32 = 3u; -// Unpack rank `s` (0..`EXT_SEL_K`-1) from the two-word selection. MIRRORED - keep in sync. -fn ext_sel_get(sel: vec2, s: u32) -> u32 { +// Unpack rank `s` (0..`EXT_SEL_K`-1) from the four-word selection. MIRRORED - keep in sync. +fn ext_sel_get(sel: vec4, s: u32) -> u32 { return (sel[s >> 2u] >> (8u * (s & 3u))) & 255u; } @@ -368,7 +400,7 @@ fn ext_sel_get(sel: vec2, s: u32) -> u32 { // 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 +// MONKEY (ext light k12): `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 @@ -383,7 +415,7 @@ fn ext_sel_get(sel: vec2, s: u32) -> u32 { // // CANDIDACY deliberately stays on the anchor POINT: it is the byte-verified gather box, and // widening it is a different question from how the survivors are ordered. -fn point_light_pick(anchor: vec3, box: f32) -> vec2 { +fn point_light_pick(anchor: vec3, box: f32) -> vec4 { let count = u32(wow_light.point_count.x); var sel = array(); var sd = array(); @@ -409,11 +441,11 @@ fn point_light_pick(anchor: vec3, box: f32) -> vec2 { if (max(abs(dv.x), abs(dv.z)) > TERRAIN_REACH) { continue; } - // MONKEY (ext light k8): distance to the draw unit's BOX (see the header note). With + // MONKEY (ext light k12): distance to the draw unit's BOX (see the header note). With // `box = 0` both `max`es are identities and this is exactly the old `dot(dv, dv)`. let e = max(abs(dv.xz) - vec2(box), vec2(0.0)); let d2 = dot(e, e) + dv.y * dv.y; - // MONKEY (ext light k8): an `EXT_SEL_K`-deep insertion in place of the hand-unrolled 3-deep + // MONKEY (ext light k12): 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). // @@ -444,7 +476,7 @@ fn point_light_pick(anchor: vec3, box: f32) -> vec2 { } // Pack low-byte-first, `EXT_SEL_EMPTY` for a rank nothing reached. No real index can collide // with the sentinel: the live table caps at 255 (`MAX_LIVE_POINT_LIGHTS`). - var packed = vec2(0u, 0u); + var packed = vec4(0u, 0u, 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)); @@ -455,10 +487,10 @@ fn point_light_pick(anchor: vec3, box: f32) -> vec2 { // 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 live table caps at 255). MONKEY (ext light k8): up to `EXT_SEL_K` terms now, +// collide with it: the live table caps at 255). MONKEY (ext light k12): up to `EXT_SEL_K` terms now, // still Gouraud (per vertex) and still linear in the falloff, so nothing about the day lane's FORM // changed - a chunk simply stops dropping the fixtures its neighbour kept. -fn point_light_eval(sel: vec2, P: vec3, N: vec3) -> vec3 { +fn point_light_eval(sel: vec4, P: vec3, N: vec3) -> vec3 { var sum = vec3(0.0); for (var s = 0u; s < EXT_SEL_K; s = s + 1u) { let idx = ext_sel_get(sel, s); @@ -480,17 +512,17 @@ fn point_light_eval(sel: vec2, P: vec3, N: vec3) -> vec3 { // untouched. This is what step (a) already did for WMO exteriors and models; without it a // character's shadow stopped dead at the grass. // -// MONKEY (ext light k8): the SUM runs to `EXT_SEL_K`, but only the first `EXT_SEL_SHADOWED` ranks +// MONKEY (ext light k12): 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 +// the selection itself got twelve deep. Ranks 3..11 are the distance-ordered tail: they are what // makes a torch's pool agree across a cell edge, and they arrive unshadowed, which at their // amplitude is not a look the eye can separate from a shadowed one. Reached only under // `torch_ext_on()` - so it is night-only and dead with `exteriorShadows 0`. `TORCH_SKIP_EPS` drops // the scan entirely wherever the unshadowed term is already invisible, which on terrain is most of // the frame (ground facing away from the fire, or far enough out that the falloff has taken the // term below a code). -fn point_light_eval_shadowed(sel: vec2, P: vec3, N: vec3) -> vec3 { +fn point_light_eval_shadowed(sel: vec4, P: vec3, N: vec3) -> vec3 { var sum = vec3(0.0); for (var s = 0u; s < EXT_SEL_K; s = s + 1u) { let idx = ext_sel_get(sel, s); @@ -539,7 +571,7 @@ fn mcnk_cell_anchor(P: vec3) -> vec3 { return vec3((ix + 0.5) * cell - half, P.y, (iz + 0.5) * cell - half); } -// MONKEY (ext light k8): the MCNK cell's HORIZONTAL half-extent (yd) - the `box` a cell-anchored +// MONKEY (ext light k12): 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; diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index 41277ad2..65e95694 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -334,9 +334,10 @@ fn torch_entity_shadow_at(light_pos: vec3, P: vec3, N: vec3, fade 1.0 - smoothstep(0.8 * fade_radius, fade_radius, distance(fixture, Ps)), fade_radius > 0.0, ); + // MONKEY (slope bias): the entity's own normal - the one `Ps` was offset along. let s = shadow_hook::torch_map_shadow( - torch_table.view_projs[layer], i32(depth_layer), Ps, torch_depth, torch_samp, TORCH_BIAS, - torch_soft(), fade); + torch_table.view_projs[layer], i32(depth_layer), Ps, N, torch_depth, torch_samp, + TORCH_BIAS, torch_soft(), fade); // MONKEY (torch caster selection, MIRRORED from static_gx.wgsl): `.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. An entity and the floor under it MUST use the same weight or the NPC's @@ -375,9 +376,10 @@ fn torch_entity_debug_factor(P: vec3, N: vec3) -> f32 { // MONKEY (live bank rank): debug samples the same compact bank as the lit path. let rank = countOneBits(torch_table.count.z & ((1u << i) - 1u)); let depth_layer = select(layer, 96u + 6u * rank + face, (torch_table.count.z & (1u << i)) != 0u); + // MONKEY (slope bias): same plane as the lit path, so `interiorDebug 2` stays honest. let raw = shadow_hook::torch_map_shadow( - torch_table.view_projs[layer], i32(depth_layer), Ps, torch_depth, torch_samp, TORCH_BIAS, - torch_soft(), -1.0); + torch_table.view_projs[layer], i32(depth_layer), Ps, N, torch_depth, torch_samp, + TORCH_BIAS, torch_soft(), -1.0); // MONKEY (torch caster selection): the WEIGHTED factor, matching the real render. s = min(s, mix(1.0, raw, torch_table.positions[i].w)); } @@ -423,13 +425,14 @@ struct WowVsOut { @location(10) @interpolate(flat) merged_slot: u32, #endif // 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` / + // `point_lit` was summed from, packed 8 bits each across FOUR 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, + // 8-bit ranks and then to FOUR u32s of twelve - three extra interstage components in all (25 + // for this struct, against a 60-component limit) - see `EXT_SEL_K`. + @location(11) @interpolate(flat) ext_sel: vec4, } // The dynamic point-light term at a world-space point (decisions 0016/0273/0278, selection 0285) — @@ -471,13 +474,13 @@ fn wow_normalize(v: vec3) -> vec3 { } // 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 +// ≤`EXT_SEL_K`-nearest EXTERIOR selection packed into FOUR u32s, twelve 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 +// MONKEY (ext light k12) - **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 @@ -489,46 +492,77 @@ fn wow_normalize(v: vec3) -> vec3 { // 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. +// "scars"), and as a bench lit by a firework over ground that is not. More slots make neighbouring +// units AGREE, so a pool ends where the FALLOFF ends instead of where the cell does; the only open +// question is how many is enough, and the next paragraph answers it with the measurement. // // 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 +// MONKEY (ext light k12) - **AND WHY 8 -> 12, one round later.** Eight was MARGINAL the day it +// landed and the note here said so. The Darkmoon Faire's densest chunk holds THIRTEEN candidates +// and EIGHT of them stand strictly inside its own 33.33 yd cell, so a torch 2.5 yd outside the edge +// - lighting the grass right at the seam - cannot win a slot under any ranking, and that chunk goes +// on disagreeing with its neighbour about it. On that particular seam K=8 did not help, it made +// things WORSE (0.198 -> 0.356 of a falloff unit): raising K let the SPARSE neighbour take in +// lights the dense chunk still had no room for, so the two sets grew further apart, not closer. +// Twelve is the first K at which every chunk around the player keeps everything it can see bar one: +// +// worst ABSOLUTE seam jump, the four chunks meeting at the corner nearest WoW (-9557, 99) +// (`k12_table.py` in the scratchpad; falloff units, colour and N.L factored out, the byte-verified +// `1/(0.7d + 0.03d^2)`, 200 samples along each shared edge) +// seam cands K=3 K=8 K=12 K=16 +// (-288,2)|(-288,3) 6 | 2 0.0418 0.1117 0.1117 0.1117 +// (-288,2)|(-287,2) 6 | 13 0.6425 0.0882 0.0669 0.1281 +// (-288,3)|(-287,3) 2 | 7 0.2888 0.0862 0.0862 0.0862 +// (-287,2)|(-287,3) 13 | 7 0.1980 0.3556 0.1144 0.1406 +// WORST 0.6425 0.3556 0.1144 0.1406 +// +// Read the last two columns together. At K>=13 every chunk keeps ALL its candidates, so 0.1406 is +// the CANDIDACY BOX's own floor - the residual disagreement two chunks have because their gather +// boxes are centred 33 yd apart - and no K can touch it (widening the box is a separate change, and +// it is the byte-verified one). K=12 measures 0.1144, slightly UNDER that floor, because the dense +// chunk drops exactly one far candidate its neighbour also barely weights. So twelve ENDS this line +// of attack: 16 is not better, only wider. +// +// The own-origin case in the same table - a bench standing on the ground, ranking from its own +// position, against the chunk under it - goes 79 % / 71 % / 52 % apart at K=3, to 31 % / 27 % / 5 % +// at K=8, to 3 % / 1 % / 5 % at K=12. That is the "bench lit by a firework over dark grass" closing. +// +// RAISING K FURTHER: `ext_sel_get` and the pack tail index the selection vector dynamically // (`sel[s >> 2u]`) and the rank arrays are zero-constructed and filled, so K is not spelled out -// anywhere but here. K=16 = swap `vec2` for `vec4` at its five type sites plus the -// interstage field, set this to `16u`, and give `EXT_SEL_NONE` its two extra words. Worth knowing -// because the Darkmoon Faire measurement is MARGINAL at 8: one 33.33 yd chunk there holds exactly -// eight fixtures strictly inside its own cell, so a ninth standing 2.5 yd outside the edge cannot -// win a slot under any ranking, and that chunk still disagrees with its neighbour about it. Eight -// takes the worst measured seam jump there from 0.64 to 0.36 of a falloff unit (and the everyday -// Goldshire/Stormwind case to zero); twelve would take it to 0.11, against a hard floor of ~0.14 -// set by the candidacy box itself, which is a separate change. -const EXT_SEL_K: u32 = 8u; +// anywhere but here. The vector is `vec4` now and holds SIXTEEN rank slots, so K = 13..16 is +// this constant ALONE - no type site, no interstage field, no `EXT_SEL_NONE` edit. Past 16 the next +// step is a second `vec4` (four more interstage components) and `ext_sel_get` learning which +// vector to read, which is a real change rather than a constant. +const EXT_SEL_K: u32 = 12u; const EXT_SEL_EMPTY: u32 = 255u; -// Eight empty ranks. MONKEY (ext light k8): two words now, so it can no longer be a scalar literal. -const EXT_SEL_NONE: vec2 = vec2(4294967295u, 4294967295u); +// Twelve empty ranks. MONKEY (ext light k12): FOUR words - the vector holds sixteen 8-bit rank +// slots and only the first `EXT_SEL_K` are ever read, so filling all four with the sentinel costs +// nothing and leaves no word that could be mistaken for a real index if K is raised again. (The +// PACK tail below builds its `vec4` from ZERO and writes only ranks 0..K-1, so its unused high +// word reads as index 0 rather than the sentinel - harmless, because no loop here ever looks past +// `EXT_SEL_K`.) +const EXT_SEL_NONE: vec4 = vec4(4294967295u, 4294967295u, 4294967295u, 4294967295u); -// MONKEY (ext light k8): how many of the `EXT_SEL_K` ranks pay for a CUBE-MAP OCCLUSION lookup in +// MONKEY (ext light k12): 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 +// dominates this fragment; ranks 3..11 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. +// what it was - three table scans and their taps - while the selection itself gets twelve deep. const EXT_SEL_SHADOWED: u32 = 3u; -// Unpack rank `s` (0..`EXT_SEL_K`-1) from the two-word selection. MIRRORED - keep in sync. -fn ext_sel_get(sel: vec2, s: u32) -> u32 { +// Unpack rank `s` (0..`EXT_SEL_K`-1) from the four-word selection. MIRRORED - keep in sync. +fn ext_sel_get(sel: vec4, 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 +// MONKEY (ext light k12): `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 @@ -543,7 +577,7 @@ fn ext_sel_get(sel: vec2, s: u32) -> u32 { // // CANDIDACY deliberately stays on the anchor POINT (`dc2` below): it is the byte-verified gather // test, and widening it is a different question from how the survivors are ordered. -fn point_light_pick(anchor: vec3, box: f32) -> vec2 { +fn point_light_pick(anchor: vec3, box: f32) -> vec4 { let count = u32(wow_light.point_count.x); var sel = array(); var sd = array(); @@ -565,11 +599,11 @@ fn point_light_pick(anchor: vec3, box: f32) -> vec2 { if (dc2 > pos_range.w * pos_range.w) { continue; } - // MONKEY (ext light k8): rank by the distance to the draw unit's BOX (see the header + // MONKEY (ext light k12): rank by the distance to the draw unit's BOX (see the header // note). With `box = 0` both `max`es are identities and this is exactly the old `dc2`. let e = max(abs(dv.xz) - vec2(box), vec2(0.0)); let d2 = dot(e, e) + dv.y * dv.y; - // MONKEY (ext light k8): an `EXT_SEL_K`-deep insertion in place of the hand-unrolled + // MONKEY (ext light k12): 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). // @@ -600,7 +634,7 @@ fn point_light_pick(anchor: vec3, box: f32) -> vec2 { } // Pack low-byte-first, `EXT_SEL_EMPTY` for a rank nothing reached. No real index can collide // with the sentinel: the live table caps at 255 (`MAX_LIVE_POINT_LIGHTS`). - var packed = vec2(0u, 0u); + var packed = vec4(0u, 0u, 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)); @@ -611,10 +645,10 @@ fn point_light_pick(anchor: vec3, box: f32) -> vec2 { // 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). -// MONKEY (ext light k8): up to `EXT_SEL_K` terms now, still Gouraud (per vertex) and still linear +// MONKEY (ext light k12): up to `EXT_SEL_K` terms now, still Gouraud (per vertex) and still linear // in the falloff, so nothing about the day lane's FORM changed — a unit simply stops dropping the // fixtures its neighbour kept. -fn point_light_eval(sel: vec2, P: vec3, N: vec3) -> vec3 { +fn point_light_eval(sel: vec4, P: vec3, N: vec3) -> vec3 { var sum = vec3(0.0); for (var s = 0u; s < EXT_SEL_K; s = s + 1u) { let idx = ext_sel_get(sel, s); @@ -636,13 +670,13 @@ fn point_light_eval(sel: vec2, P: vec3, N: vec3) -> vec3 { // 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 +// MONKEY (ext light k12): 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 +// was before the widening while the selection itself got twelve deep. Ranks 3..11 are the // distance-ordered tail — the terms that make a torch's pool agree across a draw-unit boundary — // and they arrive unshadowed, which at their amplitude is not a look the eye can separate from a // shadowed one. -fn point_light_eval_shadowed(sel: vec2, P: vec3, N: vec3) -> vec3 { +fn point_light_eval_shadowed(sel: vec4, P: vec3, N: vec3) -> vec3 { var sum = vec3(0.0); for (var s = 0u; s < EXT_SEL_K; s = s + 1u) { let idx = ext_sel_get(sel, s); @@ -879,7 +913,7 @@ fn mcnk_cell_anchor(P: vec3) -> vec3 { return vec3((ix + 0.5) * cell - half, P.y, (iz + 0.5) * cell - half); } -// MONKEY (ext light k8): the MCNK cell's HORIZONTAL half-extent (yd) - the `box` a cell-anchored +// MONKEY (ext light k12): 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; @@ -898,7 +932,7 @@ const MCNK_CELL_HALF: f32 = 533.33333 / 32.0; 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) -> vec2 { +fn wmo_exterior_pick(P: vec3) -> vec4 { let anchor = mcnk_cell_anchor(P); let count = u32(wow_light.point_count.x); var sel = array(); @@ -918,12 +952,12 @@ fn wmo_exterior_pick(P: vec3) -> vec2 { if (max(abs(dv.x), abs(dv.z)) > WMO_EXT_REACH) { continue; } - // MONKEY (ext light k8): rank by the distance to the MCNK cell's BOX, not to its centre - + // MONKEY (ext light k12): rank by the distance to the MCNK cell's BOX, not to its centre - // see `point_light_pick`. This is the term that makes a street and the road it runs into // agree about a torch standing on the seam between them. let e = max(abs(dv.xz) - vec2(MCNK_CELL_HALF), vec2(0.0)); let d2 = dot(e, e) + dv.y * dv.y; - // MONKEY (ext light k8): an `EXT_SEL_K`-deep insertion in place of the hand-unrolled + // MONKEY (ext light k12): 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). // @@ -954,7 +988,7 @@ fn wmo_exterior_pick(P: vec3) -> vec2 { } // Pack low-byte-first, `EXT_SEL_EMPTY` for a rank nothing reached. No real index can collide // with the sentinel: the live table caps at 255 (`MAX_LIVE_POINT_LIGHTS`). - var packed = vec2(0u, 0u); + var packed = vec4(0u, 0u, 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)); @@ -1246,7 +1280,7 @@ 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 + // MONKEY (ext light k12): 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; diff --git a/crates/benilla-world/src/lighting/daylight.rs b/crates/benilla-world/src/lighting/daylight.rs index b14f1cc6..65edbb12 100644 --- a/crates/benilla-world/src/lighting/daylight.rs +++ b/crates/benilla-world/src/lighting/daylight.rs @@ -68,8 +68,8 @@ use benilla_formats::{PortalGraph, WmoGroupInfo}; use bevy::prelude::*; use super::{ - DynamicInteriors, FireLightGain, LightLane, LightLitRooms, LightReach, SyntheticFireLight, - WowLighting, + DynamicInteriors, FireLightGain, LightLane, LightLitRooms, LightReach, SpellFxLight, + SpellLightGain, SyntheticFireLight, WowLighting, }; /// A `PointLight` that IS the daylight standing in one exterior-facing opening. Spawned with the @@ -137,10 +137,15 @@ pub struct DaylightSeed { pub pos: [f32; 3], /// The opening's bounding-box diagonal (yd) — the reach formula's input. pub diag: f32, - /// The opening's area (yd^2) — the BUDGET rank key (largest first). + /// The opening's area (yd^2) — the rank key WITHIN a quota round (largest first). pub area: f32, /// The seed's height above the opening's bottom edge (yd) — the calibration geometry. pub hz: f32, + /// MONKEY (daylight quota): which ROUND of [`quota_select`] bought this slot — 0 = "this room + /// had no fixture yet", 1 = "its second opening", and so on. Carried on the seed rather than + /// returned beside it because the spawner consumes `Vec` by value and the dump + /// wants the reason; 0 until the quota runs. + pub round: u8, } /// How far INTO the interior group an opening's fixture stands (yd). Far enough that the light is @@ -223,10 +228,74 @@ const SEAM_MIN_XY_EXTENT: f32 = 1.0; /// are 338 yd^2 and a stitched threshold is tens. So the guard costs the city nothing it was /// keeping, and buys back the hitch. const SEAM_VERT_BUDGET: usize = 300_000; -/// Openings per placement. The rank is by AREA, so what survives a cap is the building's real -/// doorways and its largest windows. Eight is a whole facade's worth and still far under the -/// 256-slot packed table, which a city's placements share. +/// Openings per placement — **a BUILDING's budget, and now the floor of [`daylight_budget`]**. The +/// rank is by AREA within each quota round, so what survives a cap is the building's real doorways +/// and its largest windows. Eight is a whole facade's worth and still far under the 256-slot packed +/// table, which a city's placements share. pub const MAX_DAYLIGHT_PER_PLACEMENT: usize = 8; +/// MONKEY (daylight quota): the interior-group count above which a placement stops being a BUILDING +/// and starts being a DISTRICT — past it [`daylight_budget`] scales with the rooms instead of +/// holding at [`MAX_DAYLIGHT_PER_PLACEMENT`]. +/// +/// Not a taste number: MEASURED over the whole shipped WMO corpus (`benilla-extract wmolights` run +/// on all 1 211 root `.wmo`s, 2026-09-13), the interior-group count is bimodal and the gap is wide. +/// **1 183 of 1 211 roots (97.7 %) author 32 interior groups or fewer** — 1 049 author five or +/// fewer, the Lion's Pride Inn authors 10, Northshire abbey 11 — and every one of those keeps the +/// eight-slot budget it was calibrated on, unchanged. The 28 roots above the line are the cities and +/// the raid interiors: Undercity 200, Stormwind 190, alphairon 146, Orgrimmar 129, Sunken Temple +/// 122, Ironforge 103, Stratholme 91, Diremaul 74. Nothing in the corpus sits between 74 and 32. +const DAYLIGHT_DISTRICT_ROOMS: usize = 32; +/// MONKEY (daylight quota): the hard ceiling on one placement's fixtures, however many rooms it +/// authors. The resource it protects is the 255-row packed point table (`global_light`'s +/// `MAX_LIVE_POINT_LIGHTS`), which a city's own MOLT fixtures already contest — MEASURED, Stormwind +/// puts up to 392 of its 606 MOLT lights inside `POINT_PACK_RADIUS` (300 yd) of one camera. +/// +/// What makes 160 affordable rather than reckless is that **spawned is not packed**. A daylight +/// fixture carries `LightRooms` naming its own group, so the packer admits it only when that room is +/// in this frame's portal PVS or the fixture is within `INTERIOR_NEAR_ADMIT` (90 yd) of the camera. +/// MEASURED over the whole selection, per placement (10 yd camera grid over each city): the worst +/// 90 yd neighbourhood anywhere in the corpus holds **31** daylight rows — Stormwind, of the 154 +/// fixtures it spawns — against the **133** MOLT rows that same neighbourhood already packs. Undercity +/// 19 of 74, Ironforge 15 of 71, Orgrimmar 10 of 48. So the per-frame cost of a whole city is ~20 % +/// on top of its own torches, and the truncation that guards the 255-row cap is nearest-camera-first: +/// what a full table drops is the FARTHEST fixture, whose ~20 yd pool was sub-pixel anyway. +/// +/// 160 rather than 154 leaves the measured worst case (Stormwind, the largest seed list in the +/// corpus) inside the ceiling with room to spare, so what decides a city's daylight is its ROOMS and +/// not this constant. Lower it and the rooms that fall off are the ones with the narrowest openings. +const DAYLIGHT_BUDGET_MAX: usize = 160; + +/// MONKEY (daylight quota): **how many openings THIS placement may seed** — the fix for a city +/// being handed a building's budget. +/// +/// MEASURED, and this is the whole argument (`benilla-extract wmolights` on +/// `World\wmo\Azeroth\Buildings\Stormwind\Stormwind.wmo`, 2026-09-13): the city is ONE placement of +/// 306 groups, **190 of them interior-class**, authoring 96 daylight seeds (71 portal + 25 aperture) +/// and 58 bleed seeds across **136 distinct rooms**. Against a flat eight-slot budget ranked by +/// area, the whole city's daylight is four 338 yd² canal-mouth portals (p233-p236, into g187-g190), +/// two ~115 yd² apertures (g241 Dwarf01, g65) and two cathedral portals (g286, g183) — **eight +/// fixtures in eight rooms, while 128 other rooms with an authored exterior doorway get nothing**. +/// Every tavern and shop interior in the round-3 list (g21 Old Town, g44 Taventrance13, g57 Trade +/// District, g58 BM02, g66 NEM02, g67/g69 The Canals, g68 NEH02) is in the losing 128: their +/// doorways are 8-16 yd², two orders under a district portal, so no AREA rank can ever reach them — +/// which is why the ranking changes too ([`quota_select`]) and not just the number. +/// +/// The budget scales with ROOMS because a room is what a fixture is spent on, and one per room is +/// the quota's own unit. Below [`DAYLIGHT_DISTRICT_ROOMS`] nothing moves at all; above it the +/// placement gets one slot per interior group, capped at [`DAYLIGHT_BUDGET_MAX`]. +/// +/// What it produces: Goldshire inn 8 (and its eight are byte-identical to the old merge's — MEASURED, +/// see [`quota_select`]), Northshire abbey 8, Ironforge 103, Stormwind 160. At Stormwind that budget +/// takes the WHOLE seed list — 154 fixtures, 145 of them round-0, covering all 136 seedable rooms — +/// so the ceiling binds nowhere in the corpus; Ironforge's whole list is 71 against a budget of 103. +pub fn daylight_budget(groups: &[WmoGroupInfo]) -> usize { + let rooms = groups.iter().filter(|g| g.interior).count(); + if rooms <= DAYLIGHT_DISTRICT_ROOMS { + MAX_DAYLIGHT_PER_PLACEMENT + } else { + rooms.min(DAYLIGHT_BUDGET_MAX) + } +} /// The EFFECTIVE reach (yd) of an opening `diag` yards across — the radius `R` the shader actually /// windows the pool with: `clamp(1.5*diag + 4, 6, 20)`. The linear term says a wide doorway throws @@ -447,8 +516,9 @@ fn nudge_inward(from: [f32; 3], g: &WmoGroupInfo, plane_n: Option<[f32; 3]>) -> } /// **THE SELECTION RULE** — every exterior-facing opening of one WMO root, in WMO model space, -/// ranked by area and capped at [`MAX_DAYLIGHT_PER_PLACEMENT`]. See the module doc for the two -/// seeds and the corpus measurement behind having both. +/// ranked and capped by the same quota the live path uses ([`quota_select`] over +/// [`daylight_budget`]) with the bleed lane empty. See the module doc for the two seeds and the +/// corpus measurement behind having both. /// /// `batches` is `(absolute group index, EXT-class?, the batch's model-space positions)` for every /// render batch of the root — the aperture seed's input, passed as an iterator so the caller can @@ -462,9 +532,10 @@ pub fn daylight_seeds<'a, I>( where I: IntoIterator, { - let mut out = daylight_seeds_ranked(groups, portals, batches); - out.truncate(MAX_DAYLIGHT_PER_PLACEMENT); - out + let ranked = daylight_seeds_ranked(groups, portals, batches); + // MONKEY (daylight quota): the quota, not a truncate — so this entry point and + // [`placement_openings`] can never answer the same root differently. + quota_select(&ranked, &[], daylight_budget(groups)).0 } /// MONKEY (portal bleed): the same rule, RANKED but not capped — the budget is now shared with the @@ -542,6 +613,7 @@ where area: benilla_formats::room_claim::portal_area(&portals, *portal) .unwrap_or_else(|| opening_area(lo, hi)), hz, + round: 0, }); } let portal_seeds = out.len(); @@ -582,6 +654,7 @@ where diag, area: opening_area(lo, hi), hz, + round: 0, }); } } @@ -622,12 +695,14 @@ where // tall as it is wide, so its run squared is the honest comparable. area: diag * diag, hz, + round: 0, }); } } - // Largest opening first: what a placement keeps once the shared budget cuts is its real - // doorways and its widest windows, and what it drops is the trim. + // Largest opening first. MONKEY (daylight quota): this is now the rank WITHIN one quota round + // (and the deterministic order [`quota_select`]'s index tie-break inherits) rather than the + // budget's own cut — what a room keeps is its widest opening, and what it drops is the trim. out.sort_by(|a, b| b.area.total_cmp(&a.area)); out } @@ -1171,8 +1246,12 @@ pub struct BleedSeed { /// fixture IS the opening. pub pos: [f32; 3], pub diag: f32, - /// The portal polygon's area — the shared budget's rank key. + /// The portal polygon's area — the rank key WITHIN a quota round. pub area: f32, + /// MONKEY (daylight quota): the round of [`quota_select`] that bought this slot. A bleed seed + /// serves BOTH its rooms, so its round is the LOWER of the two — a doorway into a room that has + /// nothing yet is a round-0 candidate even when its neighbour is already lit. + pub round: u8, } /// Every portal of one root whose BOTH sides are interior-class groups, ranked by area — the bleed @@ -1252,6 +1331,7 @@ pub fn bleed_seeds(groups: &[WmoGroupInfo], portals: PortalGraph<'_>) -> Vec) -> Vec( groups: &[WmoGroupInfo], portals: PortalGraph<'_>, @@ -1284,30 +1366,164 @@ where { let day = daylight_seeds_ranked(groups, portals, batches); let bleed = bleed_seeds(groups, portals); - // Two descending-by-area lists merged into one budget. A plain interleave rather than a - // concat-and-sort so each rule's own tie-break survives (both are already deterministic). - let (mut di, mut bi) = (0usize, 0usize); + quota_select(&day, &bleed, daylight_budget(groups)) +} + +/// MONKEY (daylight quota): **one fixture per ROOM before any room gets a second** — the ranking +/// half of the city fix, and the reason raising the budget alone would not have been enough. +/// +/// THE BUG THE OLD RANK HAD. Both lanes arrived sorted by opening AREA and were merged straight into +/// the budget, so the budget went to the placement's biggest HOLES rather than to its rooms. That is +/// the right question for a building — a facade's openings are all in a room or two, and area really +/// does say which one matters — and exactly the wrong one for a district, where the areas span two +/// orders of magnitude across rooms that are all equally dark inside their own door. MEASURED at +/// Stormwind (see [`daylight_budget`]): four canal-mouth portals at 338 yd^2 took HALF the city's +/// budget for four groups of one canal, while 68 interior groups holding an authored exterior portal +/// — every tavern, shop and vestibule — sat at 8-16 yd^2 and could never rank. Raising the cap alone +/// does not fix that: at a cap of 40 the next 32 winners are still 29-115 yd^2 apertures in the +/// cathedral and the wine shop, and the taverns are still last. +/// +/// THE RULE. Round-robin over rooms. A candidate's ROUND is how many fixtures its room already holds +/// ON ITS OWN LANE (for a bleed, the LOWER of its two rooms — a doorway into a room with no bleed yet +/// is a round-0 candidate even when its neighbour has one, because it is that room's first). Take the +/// lowest round; break ties by area descending, then daylight over bleed, then seed order. So every +/// room gets its best opening before any room gets a second, and WITHIN a round the old area rank is +/// untouched — which is why a placement whose openings all share one room gets a byte-identical +/// answer to the old merge's. +/// +/// TWO BOOKS, NOT ONE, and the Lion's Pride Inn is why. A daylight fixture and a bleed fixture serve +/// DIFFERENT HOURS: the sun in a doorway is gone after dark (`update_daylight_fixtures` strips its +/// `PointLight` outright), and a doorway carrying the next room's candles is worth nothing at noon +/// beside the sun. So a room holding one is not served against the other, and charging both to one +/// counter mis-ranks exactly the case the bleed feature was built for. MEASURED: with ONE counter +/// the inn loses `p1`, the vestibule doorway — its two rooms `g0` and `g1` are both already +/// "served", `g0` by the porch DAYLIGHT portal `p0` and `g1` by the hall bleed `p2` — and the +/// vestibule goes back to `interiorAmbient` at 20:50, the exact frame the owner photographed. With +/// separate books `p1` is still a round-0 bleed and **the inn's eight come out identical to the old +/// area merge's, fixture for fixture**. Northshire abbey moves by one swap (its `g7<->g2` doorway, +/// which is the SECOND bleed of both its rooms, for `g9<->g8`, which is `g8`'s first). +/// +/// COST. `O(budget x candidates)` with no allocation per step: 154 x 154 = 23 716 float compares for +/// Stormwind, once, on the frame its placement spawns — against the 162 ms the boundary pass costs on +/// the same root (and which [`SEAM_VERT_BUDGET`] already refuses). A linear rescan per slot rather +/// than a heap because the key CHANGES as rooms fill, which is the thing a heap cannot express. +pub fn quota_select( + day: &[DaylightSeed], + bleed: &[BleedSeed], + budget: usize, +) -> (Vec, Vec) { + // How many fixtures each room already holds, **keyed by `(group, daylight?)`** — the two books of + // the rule above. A `HashMap` rather than a `Vec` indexed by group: the caller's group ids are + // absolute indices into a root that may have 306 of them while eight rooms are ever touched — + // and nothing here ITERATES the map, so its non-deterministic order can never reach the output. + let mut taken: HashMap<(u16, bool), u8> = HashMap::new(); + let mut used_day = vec![false; day.len()]; + let mut used_bleed = vec![false; bleed.len()]; let (mut day_keep, mut bleed_keep) = (Vec::new(), Vec::new()); - while day_keep.len() + bleed_keep.len() < MAX_DAYLIGHT_PER_PLACEMENT { - match (day.get(di).map(|s| s.area), bleed.get(bi).map(|c| c.area)) { - (Some(d), Some(b)) if d < b => { - bleed_keep.push(bleed[bi]); - bi += 1; + while day_keep.len() + bleed_keep.len() < budget { + // The comparison key, smallest wins: `(round, -area, lane, index)`. `lane` is 0 for daylight + // and 1 for bleed — the tie the inn's p0/p1 pair lands on. `index` makes the order TOTAL, so + // two identical openings can never swap between launches. + let mut best: Option<(u8, f32, u8, usize, bool)> = None; + let consider = |round: u8, area: f32, lane: u8, i: usize, is_day: bool, best: &mut Option<(u8, f32, u8, usize, bool)>| { + let key = (round, -area, lane, i); + if best.is_none_or(|b| key < (b.0, b.1, b.2, b.3)) { + *best = Some((key.0, key.1, key.2, key.3, is_day)); } - (Some(_), _) => { - day_keep.push(day[di]); - di += 1; + }; + for (i, s) in day.iter().enumerate() { + if used_day[i] { + continue; } - (None, Some(_)) => { - bleed_keep.push(bleed[bi]); - bi += 1; + let round = taken.get(&(s.group, true)).copied().unwrap_or(0); + consider(round, s.area, 0, i, true, &mut best); + } + for (i, s) in bleed.iter().enumerate() { + if used_bleed[i] { + continue; } - (None, None) => break, + // The LOWER of the two rooms, on the BLEED book — see THE RULE above. + let round = s + .sides + .iter() + .map(|g| taken.get(&(*g, false)).copied().unwrap_or(0)) + .min() + .unwrap_or(0); + consider(round, s.area, 1, i, false, &mut best); + } + let Some((round, _, _, i, is_day)) = best else { + break; // every candidate spent + }; + // The round is stamped on the KEPT copy (the dump's `q` column) and the rooms it serves + // are charged for it, which is what moves the next pick on to the rooms still at zero. + if is_day { + used_day[i] = true; + let mut s = day[i]; + s.round = round; + *taken.entry((s.group, true)).or_default() += 1; + day_keep.push(s); + } else { + used_bleed[i] = true; + let mut s = bleed[i]; + s.round = round; + // BOTH rooms are charged: the doorway lights the pair, so neither of them is still + // waiting for its first one. + for g in s.sides { + *taken.entry((g, false)).or_default() += 1; + } + bleed_keep.push(s); } } + quota_dump(&day_keep, &bleed_keep, day.len() + bleed.len(), budget); (day_keep, bleed_keep) } +/// MONKEY (daylight quota): the selection's own `WOW_POINTS_DUMP` block — WHY each opening got a +/// slot, printed once when the placement spawns. +/// +/// It rides here rather than on the per-frame `DAYLIGHT` lines because the round is a SELECTION-time +/// fact and `DaylightFixture` is read (and struct-literal-built) by `global_light.rs`, another +/// agent's file this round — a new field there would be a merge conflict for a readout. The format +/// is the same family as [`update_daylight_fixtures`]'s: a header with the totals, then one indented +/// row per fixture. `q` is the reason: **`q0` = the first light this room has ever had** (the +/// rule's whole point, and what a city's shop interiors now win on), `q1+` = its room was already +/// served and this is its second opening. In a city an all-`q0` block IS the quota working; a block +/// full of `q1`/`q2` on one group is the old failure mode coming back. +fn quota_dump(day: &[DaylightSeed], bleed: &[BleedSeed], candidates: usize, budget: usize) { + if std::env::var_os("WOW_POINTS_DUMP").is_none() || day.is_empty() && bleed.is_empty() { + return; + } + let rooms: HashSet = day + .iter() + .map(|s| s.group) + .chain(bleed.iter().flat_map(|s| s.sides)) + .collect(); + eprintln!( + "[daylight] quota: {} of {candidates} opening(s) kept on a budget of {budget} — {} daylight + {} bleed over {} room(s)", + day.len() + bleed.len(), + day.len(), + bleed.len(), + rooms.len(), + ); + for s in day { + eprintln!( + " QUOTA q{} g{} {}{} area {:7.2} yd^2 diag {:5.2}", + s.round, + s.group, + s.how.tag(), + s.portal.map_or(String::new(), |p| format!(" p{p}")), + s.area, + s.diag, + ); + } + for s in bleed { + eprintln!( + " QUOTA q{} g{} <-> g{} bleed p{} area {:7.2} yd^2 diag {:5.2}", + s.round, s.sides[0], s.sides[1], s.portal, s.area, s.diag, + ); + } +} + /// `entry * (1 - smoothstep(0, radius, max(|P - center| - slack, 0)))` — the CPU mirror of /// `static_gx.wgsl`'s `interior_room_admits` fade arm. `radius == 0` is the hard claim. fn claim_fade_weight(radius: f32, slack: f32, entry: f32, center: Vec3, p: Vec3) -> f32 { @@ -1534,6 +1750,9 @@ pub fn update_bleed_fixtures( mut commands: Commands, knobs: Res, fire_gain: Res, + // MONKEY (spellLightGain): the spell lane's own dial, mirrored here for the same reason the fire + // one is — a doorway carries what its neighbour room is WORTH after the dials, not before. + spell_gain: Res, mut bleeds: Query<( Entity, &DaylightFixture, @@ -1552,6 +1771,10 @@ pub fn update_bleed_fixtures( Option<&super::LightRooms>, Has, Has, + // MONKEY (spellLightGain): last, and it OVERRIDES the synthetic bit below — every spell + // light also carries [`SyntheticFireLight`], so testing fire first would put a fireball + // on the hearth dial. + Has, ), Without, >, @@ -1573,7 +1796,7 @@ pub fn update_bleed_fixtures( let src: Vec> = if on && !live.is_empty() { sources .iter() - .filter_map(|(pl, gt, reach, lit_rooms, lane, rooms, synthetic, daylight)| { + .filter_map(|(pl, gt, reach, lit_rooms, lane, rooms, synthetic, daylight, spell)| { if lit_rooms.is_some_and(|c| { !c.rooms.groups.is_empty() && !live.contains(&c.rooms.instance) }) { @@ -1588,7 +1811,21 @@ pub fn update_bleed_fixtures( let base = pl.intensity / (4.0 * std::f32::consts::PI); // `fireLightGain` IS mirrored (a dial that darkens the hearth must darken what the // doorway carries of it); the FLICKER deliberately is not. - let s = base * if synthetic { fire_gain.0.max(0.0) } else { 1.0 }; + // + // MONKEY (spellLightGain): and the spell dial the same way, in the same ORDER the + // packer folds them (`global_light::build_light_data` ~:1328) — spell first, fire + // second, because a spell light carries BOTH markers and the two dials answer + // different questions (a hearth's brightness vs how hard combat may strobe a room). + // Without the mirror a player who turned `spellLightGain` to 0 would still see the + // fireball's light arrive through the doorway while its own row was dark. + let s = base + * if spell { + spell_gain.0.max(0.0) + } else if synthetic { + fire_gain.0.max(0.0) + } else { + 1.0 + }; // …and `interiorGain`, which the packer applies to every interior fixture EXCEPT a // `DaylightFixture`. Mirroring that exclusion here is what keeps the doorway from // being dimmed twice: a bleed fixture IS a `DaylightFixture`, so the packer will not @@ -1949,6 +2186,166 @@ mod tests { ); } + /// MONKEY (daylight quota): **a room's FIRST opening outranks any room's second** — the rule + /// that stops a district spending its whole budget on one canal. + /// + /// Twelve rooms, each with two windows, sized so the area rank INTERLEAVES them: room `i` has a + /// big window of `26 - 2i` yd^2 and a small one of `23 - 2i`, so by pure area the order runs + /// g0-big (26), g1-big (24), g0-small (23), g2-big (22), g1-small (21)… and the old rank's top + /// eight covered only FIVE rooms while three of them got two lights each. This is the shape of + /// Stormwind's failure in miniature (four canal portals, one canal, half the city's budget). + #[test] + fn the_quota_gives_every_room_one_before_any_room_two() { + // Room `i` is a 16 x 10 x 10 box centred at x = 10i, its windows on the y = -5 wall. + let groups: Vec = (0..12) + .map(|i| { + let x = i as f32 * 10.0; + group(true, [x - 8.0, -5.0, 0.0], [x + 8.0, 5.0, 10.0]) + }) + .collect(); + // `opening_area` is the two largest extents multiplied, so a `w x 0.1 x 2` slab on the wall + // is exactly `2w` yd^2 — the areas above, spelled as widths. + let window = |cx: f32, area: f32| { + let hw = 0.5 * (area / 2.0); + [ + [cx - hw, -5.0, 1.0], + [cx + hw, -5.0, 1.0], + [cx + hw, -4.9, 3.0], + [cx - hw, -4.9, 3.0], + ] + }; + let quads: Vec<(u16, [[f32; 3]; 4])> = (0..12u16) + .flat_map(|i| { + let x = i as f32 * 10.0; + let big = 26.0 - 2.0 * i as f32; + [(i, window(x - 4.0, big)), (i, window(x + 4.0, big - 3.0))] + }) + .collect(); + let batches = || quads.iter().map(|(g, q)| (*g, true, &q[..])); + + // THE CONTROL: the uncapped, area-ranked list really does interleave the rooms — its first + // eight cover five rooms, three of them twice. Without this the test below could pass on a + // corpus the old rule would have handled just as well. + let ranked = daylight_seeds_ranked(&groups, PortalGraph::default(), batches()); + assert_eq!(ranked.len(), 24, "every window seeds: {ranked:?}"); + let by_area: HashSet = ranked[..MAX_DAYLIGHT_PER_PLACEMENT] + .iter() + .map(|s| s.group) + .collect(); + assert_eq!(by_area.len(), 5, "pure area doubles up: {by_area:?}"); + + // THE RULE, through the live entry point. Twelve interior groups is under + // `DAYLIGHT_DISTRICT_ROOMS`, so the budget is still the building floor of eight — and those + // eight now land in eight DIFFERENT rooms, every one of them a room's first light. + assert_eq!(daylight_budget(&groups), MAX_DAYLIGHT_PER_PLACEMENT); + let kept = daylight_seeds(&groups, PortalGraph::default(), batches()); + assert_eq!(kept.len(), MAX_DAYLIGHT_PER_PLACEMENT); + let rooms: HashSet = kept.iter().map(|s| s.group).collect(); + assert_eq!(rooms.len(), MAX_DAYLIGHT_PER_PLACEMENT, "one each: {kept:?}"); + assert!(kept.iter().all(|s| s.round == 0), "all first lights: {kept:?}"); + // …and the eight rooms are the ones with the WIDEST first opening, in that order: within a + // round the old area rank is untouched. + for (n, s) in kept.iter().enumerate() { + assert_eq!(s.group, n as u16); + assert!((s.area - (26.0 - 2.0 * n as f32)).abs() < 1e-3, "{s:?}"); + } + + // THE SECOND ROUND, and the cap. With twenty slots every room takes its big window first + // (twelve round-0 picks), and only then do eight rooms take their small one — nothing + // reaches a third opening because no room has one. + let (day, _) = quota_select(&ranked, &[], 20); + assert_eq!(day.len(), 20); + assert_eq!(day.iter().filter(|s| s.round == 0).count(), 12); + assert_eq!(day.iter().filter(|s| s.round == 1).count(), 8); + assert!(day.iter().all(|s| s.round < 2)); + assert_eq!( + day.iter().map(|s| s.group).collect::>().len(), + 12, + "every room served in round 0" + ); + // The budget TRUNCATES, at any size, and a budget of zero spends nothing. + assert_eq!(quota_select(&ranked, &[], 5).0.len(), 5); + assert_eq!(quota_select(&ranked, &[], 0).0.len(), 0); + assert_eq!(quota_select(&ranked, &[], 999).0.len(), 24, "runs out of seeds, not slots"); + } + + /// MONKEY (daylight quota): the budget itself — a building holds at eight, a district scales + /// with its rooms, and nothing scales past the ceiling. + #[test] + fn the_budget_scales_only_past_a_district() { + let rooms = |n: usize, interior: bool| -> Vec { + (0..n) + .map(|_| group(interior, [0.0; 3], [10.0, 10.0, 10.0])) + .collect() + }; + // The whole small-building corpus (1 183 of 1 211 shipped roots) is on this arm, the inn + // (10 interior groups) and the abbey (11) included: nothing moves. + assert_eq!(daylight_budget(&rooms(0, true)), MAX_DAYLIGHT_PER_PLACEMENT); + assert_eq!(daylight_budget(&rooms(10, true)), MAX_DAYLIGHT_PER_PLACEMENT); + assert_eq!( + daylight_budget(&rooms(DAYLIGHT_DISTRICT_ROOMS, true)), + MAX_DAYLIGHT_PER_PLACEMENT + ); + // One room past the line it is a district and pays per room — Ironforge's 103 interior + // groups, Stormwind's 190 against the 128 ceiling. + assert_eq!(daylight_budget(&rooms(DAYLIGHT_DISTRICT_ROOMS + 1, true)), 33); + assert_eq!(daylight_budget(&rooms(103, true)), 103); + assert_eq!(daylight_budget(&rooms(190, true)), DAYLIGHT_BUDGET_MAX); + // EXTERIOR groups are not rooms: Stormwind's 116 exterior-class groups buy nothing, which is + // why the count is `g.interior` and not `groups.len()`. + assert_eq!(daylight_budget(&rooms(200, false)), MAX_DAYLIGHT_PER_PLACEMENT); + } + + /// MONKEY (daylight quota): a BLEED serves TWO rooms, so its round is the lower of them — a + /// doorway into a room with nothing yet beats a second opening of an already-lit room, even + /// when that second opening is five times its area. + #[test] + fn a_bleed_ranks_by_its_darker_room() { + let seed = |group: u16, area: f32| DaylightSeed { + group, + portal: None, + how: DaylightHow::Aperture, + pos: [0.0, 0.0, 0.0], + diag: 4.0, + area, + hz: 1.0, + round: 0, + }; + let day = [seed(0, 100.0), seed(0, 50.0)]; + let bleed = [BleedSeed { + sides: [0, 1], + portal: 7, + pos: [0.0, 0.0, 0.0], + diag: 4.0, + area: 10.0, + round: 0, + }]; + let (d, b) = quota_select(&day, &bleed, 2); + // Slot 1: g0's 100 yd^2 aperture (every candidate is round 0; area decides). + assert_eq!(d.len(), 1); + assert!((d[0].area - 100.0).abs() < 1e-3); + // Slot 2: g0's own 50 yd^2 aperture is now a ROUND-1 candidate, while the doorway is still + // round 0 — g1 has nothing. The doorway wins on round, not on area. + assert_eq!(b.len(), 1, "the doorway into the dark room took the slot"); + assert_eq!(b[0].round, 0); + // Slot 3 is what is left, and its round shows the TWO BOOKS: the doorway charged g0's BLEED + // book, not its daylight one, so g0's second aperture comes in at daylight-round 1 — not 2. + // One shared counter would say 2 here, and that off-by-one is exactly what costs the inn its + // vestibule doorway (see [`quota_select`]). + let (d3, b3) = quota_select(&day, &bleed, 3); + assert_eq!(d3.len() + b3.len(), 3); + assert_eq!(d3.last().unwrap().round, 1, "{d3:?}"); + + // …and the mirror of the same fact: a room served only by a DOORWAY still has a round-0 + // DAYLIGHT candidate, because the sun it is missing by day is not the candles it borrows by + // night. g1 has the doorway above and one small window; both come in at round 0. + let day2 = [seed(0, 100.0), seed(1, 5.0)]; + let (d4, b4) = quota_select(&day2, &bleed, 3); + assert_eq!(b4.len(), 1); + assert!(d4.iter().all(|s| s.round == 0), "{d4:?}"); + assert!(d4.iter().any(|s| s.group == 1), "g1's window is a first light: {d4:?}"); + } + /// MONKEY (daylight fixtures: boundary): the stitched-threshold seed — it finds the shared /// vertex run, refuses a vertical party-wall seam and a corner stitch, and steps into the room. #[test] diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index 3a4f9d8e..8350a3d6 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -534,6 +534,44 @@ impl Default for FireLightGain { } } +/// MONKEY (spellLightGain): marks a `PointLight` that a SPELL EFFECT invented — a kit's aura glow, +/// a missile's core, an impact flash, a firework shell's burst. The app's own +/// `entities::spell_fx::SpellLight` carries the envelope, the mode and the budget; this is the +/// two-word shadow of it the PACKER needs. +/// +/// A world-side marker rather than moving `SpellLight` down here, deliberately. `SpellLight` is a +/// LIFECYCLE — it reads `FxDecay` off the effect root, it knows `SpellLightMode::{Kit, Missile, +/// Burst}`, it is aged and evicted by the effect lane's own budget — and every one of those +/// concepts belongs to benilla-app's effect layer, which benilla-world knows nothing about and must +/// not learn. What the packer needs is one bit ("scale this row by `spellLightGain`, not by +/// `fireLightGain`"), so one bit is what crosses the crate boundary; the app inserts it at the one +/// spawn site that makes a spell light (`carried_light::spawn_spell_light_child`), beside the other +/// three markers it already stamps there. +/// +/// Every spell light also carries [`SyntheticFireLight`] (it IS an invented source, and the census +/// counts it as one). This marker OVERRIDES that one in the gain fold — see [`build_light_data`]. +#[derive(Component)] +pub struct SpellFxLight; + +/// MONKEY (spellLightGain): the live gain on every spell-effect light (`spellLightGain`, default +/// 1.0), bridged from benilla-app's cvars exactly as [`FireLightGain`] is, and applied at the same +/// place for the same reason (a dial that needed a respawn to retune is not a dial). +/// +/// Separate from `fireLightGain` because the two answer different questions. The fire gain tunes a +/// CONTENT HEURISTIC — "how bright should the light we invented for this campfire prop be" — and +/// its `0` is the kill switch for a lane that is not byte-verified against anything. This one tunes +/// a GAMEPLAY lane: spell lights are short, bright and numerous, they are the one light source that +/// can strobe a room during a fight, and a player who wants combat flashes turned down must not +/// have to put out every hearth in the world to get it. +#[derive(Resource, Clone, Copy, PartialEq, Debug)] +pub struct SpellLightGain(pub f32); + +impl Default for SpellLightGain { + fn default() -> Self { + Self(1.0) + } +} + /// MONKEY (torch shadows, Stage B): marks a `PointLight` that exists ONLY to render a cube shadow /// map (`benilla_app::torch_shadow`'s promoted fixtures). Such a proxy must NOT enter the /// `wow_light` light table — benilla's receivers would treat it as a real fixture and its nominal @@ -622,6 +660,8 @@ pub(super) fn register(app: &mut App) { .init_resource::() // MONKEY (fire GO lights): the live gain on synthesised fire lights. .init_resource::() + // MONKEY (spellLightGain): and the one on spell-effect lights, which overrides it. + .init_resource::() .init_resource::() .add_plugins(ExtractResourcePlugin::::default()) .add_plugins(ExtractResourcePlugin::::default()) @@ -1098,6 +1138,10 @@ fn build_light_data( // 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, + // MONKEY (spellLightGain): the spell-effect marker ([`SpellFxLight`]). Last in the + // tuple so every positional destructuring below keeps its index — and read for one + // thing only: WHICH live gain owns this row. + Has, ), Without, >, @@ -1116,6 +1160,8 @@ fn build_light_data( dynamic_interiors: Res, // MONKEY (fire GO lights): the live gain on synthesised fire lights (0 = the lane off). fire_gain: Res, + // MONKEY (spellLightGain): and the spell lane's own, which overrides it on a spell row. + spell_gain: Res, mut last_dump: Local, mut last_rows_dump: Local, ) { @@ -1245,7 +1291,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 @@ -1263,7 +1309,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, daylight)| { + .filter_map(|(pl, gt, rooms, synthetic, reach, lane_of, lit_rooms, flicker, daylight, spell)| { let p = gt.translation(); let d2 = p.distance_squared(cam_pos); (d2 < POINT_PACK_RADIUS * POINT_PACK_RADIUS).then(|| { @@ -1274,7 +1320,19 @@ fn build_light_data( // MONKEY (fire GO lights): the live `fireLightGain` folds in HERE, over the // recovered colour×intensity, and only for a synthesised source. At spawn it // would need a world respawn to retune; here the dial moves the frame it changes. - let s = base * if synthetic { fire_gain.0.max(0.0) } else { 1.0 }; + // MONKEY (spellLightGain): …and the spell lane's own gain INSTEAD of it on a + // spell row. Every spell light is also tagged synthetic (it is an invented source + // and the census counts it as one), so the arms must be ordered, not summed: a + // fireball scaled by both dials would darken when the player turned the hearths + // down, which is precisely the coupling the second cvar exists to cut. + let s = base + * if spell { + spell_gain.0.max(0.0) + } else if synthetic { + fire_gain.0.max(0.0) + } else { + 1.0 + }; // MONKEY (flame flicker): the fire wobble, folded in at the very last moment — over // the committed colour and NOWHERE else. Deliberately downstream of `base`, which // still feeds the reach/lane below unmodulated: a breathing REACH would move the @@ -1427,7 +1485,7 @@ 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}, night x{:.2}, interior 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}, spell 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, @@ -1438,6 +1496,10 @@ fn build_light_data( // the question this line exists to answer without a rebuild. dynamic_interiors.night_gain, dynamic_interiors.interior_gain, + // MONKEY (spellLightGain): beside them for the same reason — "is that fireball + // dark because the dial is 0 or because its model synthesised no light at all" is + // one number away, and the two causes look identical on screen. + spell_gain.0, ); for (d2, p, _, rgb, synthetic, lane, claim, lit_n) in pts.iter().take(8) { eprintln!( @@ -1782,6 +1844,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_systems(Update, build_light_data); app.world_mut() .spawn((crate::view::WorldCamera, GlobalTransform::IDENTITY)); @@ -2010,6 +2073,60 @@ mod tests { assert!((data.points[1][0] - 2.0).abs() < 1e-4, "authored unaffected"); } + /// GOLDEN — MONKEY (spellLightGain): a SPELL light takes `spellLightGain` **instead of** + /// `fireLightGain`, not as well as it. + /// + /// Every spell light is tagged [`SyntheticFireLight`] too — it IS an invented source, and the + /// census must keep counting it as one — so the two arms overlap on every row this dial exists + /// for. If they composed, turning the world's hearths down would darken every fireball in the + /// game, and `spellLightGain 0` would still leave combat flashes on wherever a player had + /// raised `fireLightGain`. Both halves are asserted: the spell row moves with ONE dial and is + /// deaf to the other, and the plain synthetic row beside it is unmoved by the new one. + #[test] + fn the_spell_gain_overrides_the_fire_gain_on_a_spell_light() { + let mut app = packer_app(); + let recipe = || crate::terrain_stream::point_light([1.0, 0.5, 0.25], 2.0); + // A campfire's invented light… + app.world_mut().spawn(( + recipe(), + GlobalTransform::from_translation(Vec3::X), + SyntheticFireLight, + )); + // …and a fireball's, which carries BOTH tags exactly as the spawn site stamps them. + app.world_mut().spawn(( + recipe(), + GlobalTransform::from_translation(Vec3::new(2.0, 0.0, 0.0)), + SyntheticFireLight, + SpellFxLight, + )); + app.world_mut().insert_resource(FireLightGain(0.5)); + app.world_mut().insert_resource(SpellLightGain(2.0)); + app.update(); + + let data = app.world().resource::().0; + assert_eq!(data.rows[20][0], 2.0, "both packed"); + let fire = data.points[1]; + let spell = data.points[3]; + assert!( + (fire[0] - 1.0).abs() < 1e-4, + "the campfire takes the fire gain alone: {fire:?}" + ); + assert!( + (spell[0] - 4.0).abs() < 1e-4, + "the spell light takes 2x, NOT 2x0.5: {spell:?}" + ); + + // Zero is this lane's own kill switch, and it reaches nothing else. + app.world_mut().insert_resource(SpellLightGain(0.0)); + app.update(); + let data = app.world().resource::().0; + assert_eq!(data.points[3], [0.0, 0.0, 0.0, 0.0], "spell gain 0 = spell lights off"); + assert!( + (data.points[1][0] - 1.0).abs() < 1e-4, + "the campfire still burns at its own gain" + ); + } + /// GOLDEN — MONKEY (flame flicker): the wobble reaches the packed COLOUR and nothing else. /// /// Three properties, and each one is a bug if it inverts. (1) A flame's committed colour MOVES diff --git a/crates/benilla-world/src/lighting/mod.rs b/crates/benilla-world/src/lighting/mod.rs index f00ac637..6de45c8d 100644 --- a/crates/benilla-world/src/lighting/mod.rs +++ b/crates/benilla-world/src/lighting/mod.rs @@ -41,6 +41,9 @@ pub use global_light::{ ShadowDistance, ShadowFilterGaussian, ShadowProxyLight, SyntheticFireLight, WorldShadowActive, }; +// MONKEY (spellLightGain): the spell lane's marker + its live gain — the two-word world-side +// shadow of benilla-app's own `SpellLight` lifecycle, and the dial the packer folds over it. +pub use global_light::{SpellFxLight, SpellLightGain}; pub use global_light::{ room_claim_bytes, CLAIM_EXT_OK, LIT_ROOM_EXT_DENY, ROOM_CLAIM_MAX, ROOM_CLAIM_STRIDE, }; diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index 117db55f..72e01ee6 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -332,6 +332,16 @@ const TORCH_BIAS: f32 = 0.001; // named rather than hidden: a knee-high fixture's OUTER pool can still acne, and the honest // cure for that is a slope-scaled bias or a grazing-angle clamp on the PCF radius, not a // bigger constant (which detaches every real shadow - see TORCH_BIAS). +// +// MONKEY (slope bias): that RESIDUAL is now fixed - and TWO numbers above are wrong, so do not +// re-derive from them. The table prices depth in yards along the RAY, but the compare reads +// `ndc.z` on a cube FACE, which is a function of the VIEW-SPACE Z (the component along the face +// AXIS). So (a) a floor is on the DOWN face while `r < h`, where its depth is the constant `h` and +// acne is impossible, and crosses to a SIDE face after that, where the gradient is `2A/(f*h)` per +// unit uv and does NOT grow with `r`; and (b) `0.15*(h/t)` is inverted - on a grazing floor this +// offset moves the ray's own floor hit by `0.15*(t/h)`. Re-measured exactly, the offset holds a +// h = 1 yd fixture clean to r = 10.1 yd, not 2.1. `shadow_hook::TORCH_SLOPE_MAX` carries the +// corrected table and the receiver-plane bias that closes the rest. const TORCH_NORMAL_OFFSET: f32 = 0.15; // MONKEY (torch caster selection): the live PCF tap-radius scale, unpacked from the table's // `count.y`'s LOW 16 bits (stored x100 because the row is `vec4`; the high half is the @@ -416,7 +426,10 @@ fn torch_face(d: vec3) -> u32 { // promoted it. `0` = the interior/legacy `ndc.z` fade, unchanged bit-for-bit; `> 0` = the exterior // world-distance fade `1 − smoothstep(0.8R, R, d)` measured from the FIXTURE (not from the camera: // this is "how far does this fire's shadow carry", a property of the fire). -fn torch_map_at(light_pos: vec3, P: vec3, fade_radius: f32) -> f32 { +// MONKEY (slope bias): `N` rides along purely so the projector can bias against the RECEIVER'S +// PLANE. `P` is the already-offset point, `N` the normal it was offset along, and this function +// makes no other use of it - the correlation, the face pick and the fades are unchanged. +fn torch_map_at(light_pos: vec3, P: vec3, N: vec3, fade_radius: f32) -> f32 { #ifdef TORCH_SHADOWS // MONKEY (torch lane perf): the whole table is empty far more often than not (no promoted // fixture in range, or the lane switched off while the shader-def is still compiled in). Say so @@ -444,8 +457,8 @@ fn torch_map_at(light_pos: vec3, P: vec3, fade_radius: f32) -> f32 { fade_radius > 0.0, ); let s = shadow_hook::torch_map_shadow( - torch_table.view_projs[layer], i32(depth_layer), P, torch_depth, torch_samp, TORCH_BIAS, - torch_soft(), fade); + torch_table.view_projs[layer], i32(depth_layer), P, N, torch_depth, torch_samp, + TORCH_BIAS, torch_soft(), fade); // MONKEY (torch caster selection): `.w` is the slot's FADE WEIGHT. A slot ramps 0 -> 1 // over ~1/3 s when it is promoted and 1 -> 0 before it is reused, and `mix` turns that // into the shadow appearing/dissolving instead of switching. Without it the selection @@ -468,7 +481,7 @@ fn torch_map_at(light_pos: vec3, P: vec3, fade_radius: f32) -> f32 { // BEFORE `torch_face`, so a fragment right on a cube-face boundary picks the same face its // neighbour does). See TORCH_NORMAL_OFFSET for the texel-vs-bias arithmetic this is the answer to. fn torch_surface_shadow(light_pos: vec3, P: vec3, N: vec3) -> f32 { - return torch_map_at(light_pos, P + N * TORCH_NORMAL_OFFSET, 0.0); + return torch_map_at(light_pos, P + N * TORCH_NORMAL_OFFSET, N, 0.0); } // MONKEY (outdoor torch shadows): the EXTERIOR lane's call — the campfire/brazier/lamppost pool on @@ -477,7 +490,7 @@ fn torch_surface_shadow(light_pos: vec3, P: vec3, N: vec3) -> f32 // terrain/entity receivers — an outdoor WMO floor under a low fire (a campfire on a porch) has the // same grazing-angle PCF acne as the imp's floor; see TORCH_NORMAL_OFFSET for the arithmetic. fn torch_exterior_shadow(light_pos: vec3, P: vec3, N: vec3) -> f32 { - return torch_map_at(light_pos, P + N * TORCH_NORMAL_OFFSET, TORCH_EXT_FADE_YD); + return torch_map_at(light_pos, P + N * TORCH_NORMAL_OFFSET, N, TORCH_EXT_FADE_YD); } // MONKEY (torch debug, interiorDebug 2): the MIN raw depth-map shadow factor over EVERY promoted map @@ -497,9 +510,10 @@ fn torch_debug_factor(P: vec3, N: vec3) -> f32 { // MONKEY (live bank rank): debug samples the same compact bank as the lit path. let rank = countOneBits(torch_table.count.z & ((1u << i) - 1u)); let depth_layer = select(layer, 96u + 6u * rank + face, (torch_table.count.z & (1u << i)) != 0u); + // MONKEY (slope bias): same plane as the lit path, so `interiorDebug 2` stays honest. let raw = shadow_hook::torch_map_shadow( - torch_table.view_projs[layer], i32(depth_layer), Ps, torch_depth, torch_samp, TORCH_BIAS, - torch_soft(), -1.0); + torch_table.view_projs[layer], i32(depth_layer), Ps, N, torch_depth, torch_samp, + TORCH_BIAS, torch_soft(), -1.0); // MONKEY (torch caster selection): the WEIGHTED factor, so the debug view shows what the // real render shows - a fading slot greys out here too, and a slot stuck at w = 0 (never // ramping in) is visible as a map that contributes nothing. @@ -518,8 +532,8 @@ fn wow_normalize(v: vec3) -> vec3 { return select(vec3(0.0), normalize(v), l2 > 1e-12); } -// MONKEY (outdoor torch shadows): the ≤`EXT_SEL_K`-nearest EXTERIOR selection, packed into TWO -// u32s as eight 8-bit indices (rank 0 in the low byte of `.x`), `EXT_SEL_EMPTY` for an unfilled +// MONKEY (outdoor torch shadows): the ≤`EXT_SEL_K`-nearest EXTERIOR selection, packed into FOUR +// u32s as twelve 8-bit indices (rank 0 in the low byte of `.x`), `EXT_SEL_EMPTY` for an unfilled // rank. // // WHY the selection is packed and TRAVELS from the vertex stage: the exterior point term is chosen @@ -530,7 +544,7 @@ fn wow_normalize(v: vec3) -> vec3 { // boundary, where the interpolated vertex term has none. So the vertex stage publishes its choice // and the fragment stage only re-EVALUATES it (and shadows it). // -// MONKEY (ext light k8) - **WHY K WENT 3 -> 8.** Three is the reference FFP's own commit limit (GL +// MONKEY (ext light k12) - **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 @@ -542,39 +556,70 @@ fn wow_normalize(v: vec3) -> vec3 { // 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. +// "scars"), and as a bench lit by a firework over ground that is not. More slots make neighbouring +// units AGREE, so a pool ends where the FALLOFF ends instead of where the cell does; the only open +// question is how many is enough, and the next paragraph answers it with the measurement. // // 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 +// MONKEY (ext light k12) - **AND WHY 8 -> 12, one round later.** Eight was MARGINAL the day it +// landed and the note here said so. The Darkmoon Faire's densest chunk holds THIRTEEN candidates +// and EIGHT of them stand strictly inside its own 33.33 yd cell, so a torch 2.5 yd outside the edge +// - lighting the grass right at the seam - cannot win a slot under any ranking, and that chunk goes +// on disagreeing with its neighbour about it. On that particular seam K=8 did not help, it made +// things WORSE (0.198 -> 0.356 of a falloff unit): raising K let the SPARSE neighbour take in +// lights the dense chunk still had no room for, so the two sets grew further apart, not closer. +// Twelve is the first K at which every chunk around the player keeps everything it can see bar one: +// +// worst ABSOLUTE seam jump, the four chunks meeting at the corner nearest WoW (-9557, 99) +// (`k12_table.py` in the scratchpad; falloff units, colour and N.L factored out, the byte-verified +// `1/(0.7d + 0.03d^2)`, 200 samples along each shared edge) +// seam cands K=3 K=8 K=12 K=16 +// (-288,2)|(-288,3) 6 | 2 0.0418 0.1117 0.1117 0.1117 +// (-288,2)|(-287,2) 6 | 13 0.6425 0.0882 0.0669 0.1281 +// (-288,3)|(-287,3) 2 | 7 0.2888 0.0862 0.0862 0.0862 +// (-287,2)|(-287,3) 13 | 7 0.1980 0.3556 0.1144 0.1406 +// WORST 0.6425 0.3556 0.1144 0.1406 +// +// Read the last two columns together. At K>=13 every chunk keeps ALL its candidates, so 0.1406 is +// the CANDIDACY BOX's own floor - the residual disagreement two chunks have because their gather +// boxes are centred 33 yd apart - and no K can touch it (widening the box is a separate change, and +// it is the byte-verified one). K=12 measures 0.1144, slightly UNDER that floor, because the dense +// chunk drops exactly one far candidate its neighbour also barely weights. So twelve ENDS this line +// of attack: 16 is not better, only wider. +// +// The own-origin case in the same table - a bench standing on the ground, ranking from its own +// position, against the chunk under it - goes 79 % / 71 % / 52 % apart at K=3, to 31 % / 27 % / 5 % +// at K=8, to 3 % / 1 % / 5 % at K=12. That is the "bench lit by a firework over dark grass" closing. +// +// RAISING K FURTHER: `ext_sel_get` and the pack tail index the selection vector dynamically // (`sel[s >> 2u]`) and the rank arrays are zero-constructed and filled, so K is not spelled out -// anywhere but here. K=16 = swap `vec2` for `vec4` at its five type sites plus the -// interstage field, set this to `16u`, and give `EXT_SEL_NONE` its two extra words. Worth knowing -// because the Darkmoon Faire measurement is MARGINAL at 8: one 33.33 yd chunk there holds exactly -// eight fixtures strictly inside its own cell, so a ninth standing 2.5 yd outside the edge cannot -// win a slot under any ranking, and that chunk still disagrees with its neighbour about it. Eight -// takes the worst measured seam jump there from 0.64 to 0.36 of a falloff unit (and the everyday -// Goldshire/Stormwind case to zero); twelve would take it to 0.11, against a hard floor of ~0.14 -// set by the candidacy box itself, which is a separate change. -const EXT_SEL_K: u32 = 8u; +// anywhere but here. The vector is `vec4` now and holds SIXTEEN rank slots, so K = 13..16 is +// this constant ALONE - no type site, no interstage field, no `EXT_SEL_NONE` edit. Past 16 the next +// step is a second `vec4` (four more interstage components) and `ext_sel_get` learning which +// vector to read, which is a real change rather than a constant. +const EXT_SEL_K: u32 = 12u; const EXT_SEL_EMPTY: u32 = 255u; -// Eight empty ranks. MONKEY (ext light k8): two words now, so it can no longer be a scalar literal. -const EXT_SEL_NONE: vec2 = vec2(4294967295u, 4294967295u); +// Twelve empty ranks. MONKEY (ext light k12): FOUR words - the vector holds sixteen 8-bit rank +// slots and only the first `EXT_SEL_K` are ever read, so filling all four with the sentinel costs +// nothing and leaves no word that could be mistaken for a real index if K is raised again. (The +// PACK tail below builds its `vec4` from ZERO and writes only ranks 0..K-1, so its unused high +// word reads as index 0 rather than the sentinel - harmless, because no loop here ever looks past +// `EXT_SEL_K`.) +const EXT_SEL_NONE: vec4 = vec4(4294967295u, 4294967295u, 4294967295u, 4294967295u); -// MONKEY (ext light k8): how many of the `EXT_SEL_K` ranks pay for a CUBE-MAP OCCLUSION lookup in +// MONKEY (ext light k12): 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 +// dominates this fragment; ranks 3..11 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. +// what it was - three table scans and their taps - while the selection itself gets twelve deep. const EXT_SEL_SHADOWED: u32 = 3u; -// Unpack rank `s` (0..`EXT_SEL_K`-1) from the two-word selection. MIRRORED - keep in sync. -fn ext_sel_get(sel: vec2, s: u32) -> u32 { +// Unpack rank `s` (0..`EXT_SEL_K`-1) from the four-word selection. MIRRORED - keep in sync. +fn ext_sel_get(sel: vec4, s: u32) -> u32 { return (sel[s >> 2u] >> (8u * (s & 3u))) & 255u; } @@ -583,7 +628,7 @@ fn ext_sel_get(sel: vec2, s: u32) -> u32 { // inserts, so an equal distance leaves the earlier table index at the better rank) so the sum can // be re-evaluated later against the identical choice. // -// MONKEY (ext light k8): `box` is the draw unit's HORIZONTAL half-extent in yards, and it changes +// MONKEY (ext light k12): `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 @@ -598,7 +643,7 @@ fn ext_sel_get(sel: vec2, s: u32) -> u32 { // // CANDIDACY deliberately stays on the anchor POINT (`dc2` below): it is the byte-verified gather // test, and widening it is a different question from how the survivors are ordered. -fn point_light_pick(anchor: vec3, box: f32) -> vec2 { +fn point_light_pick(anchor: vec3, box: f32) -> vec4 { let count = u32(wow_light.point_count.x); var sel = array(); var sd = array(); @@ -620,11 +665,11 @@ fn point_light_pick(anchor: vec3, box: f32) -> vec2 { if (dc2 > pos_range.w * pos_range.w) { continue; } - // MONKEY (ext light k8): rank by the distance to the draw unit's BOX (see the header + // MONKEY (ext light k12): rank by the distance to the draw unit's BOX (see the header // note). With `box = 0` both `max`es are identities and this is exactly the old `dc2`. let e = max(abs(dv.xz) - vec2(box), vec2(0.0)); let d2 = dot(e, e) + dv.y * dv.y; - // MONKEY (ext light k8): an `EXT_SEL_K`-deep insertion in place of the hand-unrolled + // MONKEY (ext light k12): 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). // @@ -655,7 +700,7 @@ fn point_light_pick(anchor: vec3, box: f32) -> vec2 { } // Pack low-byte-first, `EXT_SEL_EMPTY` for a rank nothing reached. No real index can collide // with the sentinel: the live table caps at 255 (`MAX_LIVE_POINT_LIGHTS`). - var packed = vec2(0u, 0u); + var packed = vec4(0u, 0u, 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)); @@ -666,10 +711,10 @@ fn point_light_pick(anchor: vec3, box: f32) -> vec2 { // 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). -// MONKEY (ext light k8): up to `EXT_SEL_K` terms now, still Gouraud (per vertex) and still linear +// MONKEY (ext light k12): up to `EXT_SEL_K` terms now, still Gouraud (per vertex) and still linear // in the falloff, so nothing about the day lane's FORM changed — a unit simply stops dropping the // fixtures its neighbour kept. -fn point_light_eval(sel: vec2, P: vec3, N: vec3) -> vec3 { +fn point_light_eval(sel: vec4, P: vec3, N: vec3) -> vec3 { var sum = vec3(0.0); for (var s = 0u; s < EXT_SEL_K; s = s + 1u) { let idx = ext_sel_get(sel, s); @@ -693,13 +738,13 @@ fn point_light_eval(sel: vec2, P: vec3, N: vec3) -> vec3 { // actually looks like). Reached only under `torch_ext_on()`, so nothing here executes 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 +// MONKEY (ext light k12): 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 +// was before the widening while the selection itself got twelve deep. Ranks 3..11 are the // distance-ordered tail — the terms that make a torch's pool agree across a draw-unit boundary — // and they arrive unshadowed, which at their amplitude is not a look the eye can separate from a // shadowed one. -fn point_light_eval_shadowed(sel: vec2, P: vec3, N: vec3) -> vec3 { +fn point_light_eval_shadowed(sel: vec4, P: vec3, N: vec3) -> vec3 { var sum = vec3(0.0); for (var s = 0u; s < EXT_SEL_K; s = s + 1u) { let idx = ext_sel_get(sel, s); @@ -766,13 +811,13 @@ fn mcnk_cell_anchor(P: vec3) -> vec3 { return vec3((ix + 0.5) * cell - half, P.y, (iz + 0.5) * cell - half); } -// MONKEY (ext light k8): the MCNK cell's HORIZONTAL half-extent (yd) - the `box` a cell-anchored +// MONKEY (ext light k12): 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 (outdoor torch shadows): the ranking half, split out for the same reason as // `point_light_pick` — the fragment stage must re-evaluate the VERTEX's choice, not make its own. -fn wmo_exterior_pick(P: vec3) -> vec2 { +fn wmo_exterior_pick(P: vec3) -> vec4 { let anchor = mcnk_cell_anchor(P); let count = u32(wow_light.point_count.x); var sel = array(); @@ -792,12 +837,12 @@ fn wmo_exterior_pick(P: vec3) -> vec2 { if (max(abs(dv.x), abs(dv.z)) > WMO_EXT_REACH) { continue; } - // MONKEY (ext light k8): rank by the distance to the MCNK cell's BOX, not to its centre - + // MONKEY (ext light k12): rank by the distance to the MCNK cell's BOX, not to its centre - // see `point_light_pick`. This is the term that makes a street and the road it runs into // agree about a torch standing on the seam between them. let e = max(abs(dv.xz) - vec2(MCNK_CELL_HALF), vec2(0.0)); let d2 = dot(e, e) + dv.y * dv.y; - // MONKEY (ext light k8): an `EXT_SEL_K`-deep insertion in place of the hand-unrolled + // MONKEY (ext light k12): 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). // @@ -828,7 +873,7 @@ fn wmo_exterior_pick(P: vec3) -> vec2 { } // Pack low-byte-first, `EXT_SEL_EMPTY` for a rank nothing reached. No real index can collide // with the sentinel: the live table caps at 255 (`MAX_LIVE_POINT_LIGHTS`). - var packed = vec2(0u, 0u); + var packed = vec4(0u, 0u, 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)); @@ -1236,14 +1281,15 @@ struct GxVsOut { @location(4) point_lit: vec3, @location(5) color: vec4, // 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` / + // `point_lit` was summed from, packed 8 bits each across FOUR u32s (see `EXT_SEL_NONE` / // `ext_sel_get`). FLAT, because it is a choice, 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, the collapsed exile vertex), // which makes the fragment 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 + // 10-bit ranks to TWO u32s of eight 8-bit ranks and then to FOUR u32s of twelve — three extra + // interstage components in all (21 for this struct, against a 60-component limit) — see // `EXT_SEL_K`. - @location(6) @interpolate(flat) ext_sel: vec2, + @location(6) @interpolate(flat) ext_sel: vec4, } @vertex @@ -1311,7 +1357,7 @@ fn vertex(v: GxVertex) -> GxVsOut { out.ext_sel = sel; out.point_lit = point_light_eval(sel, world, v.normal); } else { - // MONKEY (ext light k8): `box = 0` — an exterior doodad/MODD prop is its own draw unit and + // MONKEY (ext light k12): `box = 0` — an exterior doodad/MODD prop is its own draw unit and // ranks from its baked PLACEMENT origin, exactly as it did before the widening. let sel = point_light_pick(v.anchor, 0.0); out.ext_sel = sel;