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