MONKEY: lighting round — fire/lamp lights, light lanes, room gating, static torch cache

Point lights
- Synthesised lights for fire and lamp models that author none (benilla-formats
  fire_light.rs: flame route from the particle emitter's over-life colour, name and
  emissive-glass routes for lampposts/lanterns/candelabra; NoOmni braziers included —
  they are Orgrimmar's street lighting). Spawned on every lane: ADT doodads, WMO props
  in interior and exterior groups (2.5 yd dedupe against authored MOLT fixtures),
  GameObjects and creatures (carried). Cvar fireLightGain (synthetic only).
- Light LANE decided by a down-ray at the light's position: exterior consumers
  (terrain, models, WMO exterior batches — new per-vertex point term) read exterior
  lights; the interior room lane reads interior fixtures. Fixes interior fixtures
  leaking onto lawns and street torches lighting nothing.
- Interior attenuation: the authored MOLT reach replaces the flat 48 yd, with a soft
  long-tailed profile (constant 1.75 yd core so energy no longer scales with reach),
  cvar interiorAttenScale; a 1.6x default.
- ROOM GATE: a WMO surface takes an interior fixture only if the fixture claims its
  group — claims = containment ∪ MOLR ∪ portal-adjacent rooms (2 hops), shared rule in
  benilla-formats room_claim.rs, packed per light into a 32 KB storage buffer; portal
  claims are weighted (1 across the door frame, fading into the next room) so doorways
  carry no seam; in-building exterior-class groups take claimed fixtures. Carried
  lights (GameObjects, pets) claim rooms from their world position once settled.
  Cvar interiorRoomGate.

Torch shadows
- Casters ranked by contribution at the player with hysteresis and fade weights
  (no pop-in); eligibility 4R+3 and the shadowDistance setting; caster geometry
  gathered around the fixtures, not the camera.
- Static cube maps cached per fixture (rendered once), entity casters rendered per
  frame only for the nearest interiorShadowDynamic fixtures via a depth copy; up to
  16 resident maps (interiorShadowCasters), interiorShadowSoft PCF radius.
- Carried lights: rest-pose flame position (no hand-bone jitter), lane from the
  bearer's cross-fade weight, caster only while settled — the imp no longer strobes.

Entities
- Portal lane cross-fade (tag bits 15..18) and MATTE_INDOOR bit so a unit keeps its
  room lighting across a Bake/Matte flicker; hover highlight folded into the room lane;
  the blob shadow returns whenever the realtime sun lane is inactive (night, beyond
  shadow distance) with a crossfade at dusk/dawn.

Robustness
- A placement or WMO prop whose model fails to load is retired instead of pinning
  the loading screen forever (Stormwind's alliancebrasscannon_flat.m2).
- benilla-extract: wmolights (MOLT + claims + group classes), wmolamps (corpus audit),
  m2firescan (synthesis routes).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-09 16:08:36 +01:00
co-authored by Claude Fable 5.1
parent 7309297f1c
commit 082f3ebe50
30 changed files with 7369 additions and 386 deletions
+103 -11
View File
@@ -66,6 +66,7 @@ use crate::creature_anim::AnimData;
use crate::net::{Embodied, NetEntity};
use crate::player::CameraControl;
use benilla_world::decal::{DecalFrame, WorldDecal};
use benilla_world::lighting::WowLighting;
use benilla_world::model_fade::{fade_alpha, RenderFade};
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
use benilla_world::schedule::WorldStage;
@@ -81,6 +82,10 @@ const BOX_CLAMP: f32 = 5.0;
/// Degenerate-box epsilon (the reference's `[0x8029d4]` = 2.384e-7): a zero horizontal extent is
/// the no-op exit — no shadow.
const DEGENERATE_EPS: f32 = 2.384e-7;
/// MONKEY (night blob): the realtime lane's edge band — mirrors `SHADOW_EDGE_BAND` in
/// `shadow_hook.wgsl`, where the realtime shadow lightens over the last yards of the cascade.
/// The oval fades back IN across the same band, so the handover is a crossfade at both ends.
const SHADOW_EDGE_BAND: f32 = 14.0;
/// One unit's shadow record (a top-level entity — no render components; the cached projection
/// rides the effect stream). Despawned when the owner goes.
@@ -190,6 +195,12 @@ fn update_shadows(
shadow_assets: Option<Res<ShadowAssets>>,
images: Res<Assets<Image>>,
decals: WorldDecal,
// MONKEY (night blob): the live celestial sun, for the realtime lane's day strength — the SAME
// `celestial_dir` height `global_light` packs the shader's night fade from, so the oval's
// crossfade and the realtime shadow's fade are two halves of one number.
lighting: Res<WowLighting>,
// …and the camera, for the realtime lane's EDGE fade (its cascade ends at `shadowDistance`).
cam_pos: Query<&GlobalTransform, With<WorldCamera>>,
// Spawn/despawn fades live on the model *part* entities; attribute each to its unit root so
// the shadow can ride the same alpha the body renders with (O(#currently-fading parts) — zero
// in the steady state).
@@ -226,11 +237,26 @@ fn update_shadows(
mut census_at: Local<f32>,
) {
// `characterShadows 1`: the realtime shadow-map path owns OUTDOOR unit shadows — the per-unit
// gate in the loop below hides the oval for outdoor units so they don't wear both, while INDOOR
// units keep it (the realtime sun shadow is gated out of interiors, so an indoor unit would
// otherwise have no ground shadow at all). (This keys on the CHARACTER lane, not the world lane:
// the oval is a character's shadow, so `worldShadows` alone must leave it be.) With the cvar off
// — the shipped default — that gate is a single false branch and this is the reference path.
// gate in the loop below fades the oval out for outdoor units so they don't wear both, while
// INDOOR units keep it at full strength (the realtime sun shadow is gated out of interiors, so
// an indoor unit would otherwise have no ground shadow at all). (This keys on the CHARACTER
// lane, not the world lane: the oval is a character's shadow, so `worldShadows` alone must
// leave it be.) With the cvar off — the shipped default — that gate is a single false branch
// and this is the reference path.
//
// MONKEY (night blob): it is a FADE, not the hard hide it used to be. The realtime lane's own
// strength is `night · (1 − edge_fade)` (`shadow_hook.wgsl::realtime_shadow`), and `night` is
// `sun_shadow_strength(celestial sun height)` — exactly ZERO once the sun is under the horizon.
// So with the old hide, an outdoor unit between dusk and dawn had NO shadow of any kind: no
// realtime cast (faded to nothing) and no oval (hidden by the cvar). The reference client draws
// the oval all night, which is what the complement below restores. The oval is NOT replaced by
// a moon-lit realtime cast: benilla's rig aims only at the celestial SUN (`shadow_sun_travel`),
// the moon direction is `pub(super)` inside `benilla-world`'s day/night module and never
// reaches a shadow lane, and the packed `night` scalar would zero a moon cast anyway.
let sun_strength = sun_shadow_strength(lighting.celestial_dir().y);
// No camera yet → treat every unit as beyond the cascade, i.e. keep its oval. Failing toward
// "has a shadow" is the safe direction for a lane whose bug is a missing shadow.
let camera_pos = cam_pos.iter().next().map(GlobalTransform::translation);
let now = time.elapsed_secs();
let census = now >= *census_at;
if census {
@@ -261,14 +287,24 @@ fn update_shadows(
n_no_owner += 1;
continue;
};
// `characterShadows`: the realtime cast owns OUTDOOR units, so hide the oval there (no
// double shadow). INDOOR units (`UnitWmoRoom::room()` = Some) keep it — the realtime sun
// shadow is gated out of interiors, so without this an indoor unit has no ground shadow at
// all. Absent room component / no-room claim = outdoors.
// `characterShadows`: the realtime cast owns OUTDOOR units, so the oval yields to it there
// (no double shadow). INDOOR units (`UnitWmoRoom::room()` = Some) keep it at full strength —
// the realtime sun shadow is gated out of interiors, so without this an indoor unit has no
// ground shadow at all. Absent room component / no-room claim = outdoors.
//
// MONKEY (night blob): "yields to it" is now proportional to how much the realtime lane
// actually casts for THIS unit — full oval at night and past the cascade edge, none in
// broad daylight up close, a crossfade between. Below a colour step of oval there is
// nothing left to draw, so that stays the early-out the hard hide used to be.
let indoors = room.is_some_and(|r| r.room().is_some());
let mut lane_w = 1.0;
if video.character_shadows && !indoors {
hide(&mut key, &mut verts);
continue;
let cam_dist = camera_pos.map_or(f32::INFINITY, |c| unit.translation.distance(c));
lane_w = blob_weight(sun_strength, cam_dist, video.shadow_distance);
if lane_w <= 1.0 / 255.0 {
hide(&mut key, &mut verts);
continue;
}
}
// An owner that is not drawn casts nothing. The director's report from inside Caverns of
// Time (decision 1277): the exterior election had correctly stopped drawing the Tanaris
@@ -319,6 +355,12 @@ fn update_shadows(
// The self first-person fade rides the same model-fade slot in the reference.
alpha *= rig.as_deref().map_or(1.0, CameraControl::self_fade);
}
// MONKEY (night blob): the realtime-lane handover rides the SAME alpha the spawn/despawn
// fades do — the oval is a Multiply decal, so scaling its alpha scales its darkening, and
// `ShadowKey::alpha` already re-arms the projection when it moves by a colour step. `1.0`
// whenever the realtime lane is off, indoors, or contributing nothing, so the shipped
// default path is untouched.
alpha *= lane_w;
if alpha <= 0.0 {
hide(&mut key, &mut verts);
continue;
@@ -545,6 +587,30 @@ fn key_changed(a: &ShadowKey, b: &ShadowKey) -> bool {
|| a.surfaces != b.surfaces
}
/// MONKEY (night blob): the realtime sun lane's DAY strength — 0 at or below the horizon, 1 by
/// ~12° of elevation, smoothstepped. A three-line mirror of `sun_shadow_strength` in
/// `benilla_world::lighting::global_light` (private there; that copy is what gets packed into the
/// shader's `night` scalar). The pair is pinned by `oval_is_the_complement_of_the_realtime_lane`
/// below — if the curve is ever tuned, tune both or the handover gains a seam.
fn sun_shadow_strength(sun_height: f32) -> f32 {
let t = (sun_height / 0.208).clamp(0.0, 1.0); // 0.208 ≈ sin(12°)
t * t * (3.0 - 2.0 * t)
}
/// MONKEY (night blob): how much OVAL a unit still wears while `characterShadows` owns it.
///
/// `shadow_hook.wgsl::realtime_shadow` returns `1 − (1 − shadow) · night · (1 − edge_fade)`, so
/// `night · (1 − edge_fade)` is precisely the fraction of a realtime cast that survives to the
/// screen. The oval takes the COMPLEMENT of that: full where the realtime lane lands nothing (all
/// night; past the cascade's last [`SHADOW_EDGE_BAND`] yards), zero where it lands everything
/// (daylight, close in), a continuous crossfade through dusk and dawn and across the edge band —
/// so neither shadow pops in or out.
fn blob_weight(sun_strength: f32, cam_dist: f32, shadow_range: f32) -> f32 {
let t = ((cam_dist - (shadow_range - SHADOW_EDGE_BAND)) / SHADOW_EDGE_BAND).clamp(0.0, 1.0);
let edge_fade = t * t * (3.0 - 2.0 * t);
(1.0 - sun_strength.clamp(0.0, 1.0) * (1.0 - edge_fade)).clamp(0.0, 1.0)
}
/// The reference's trapezoid alpha ramp (`0x6d81a0`/`0x6d82d0`, diffed bit-exact in wow-re:
/// `x = 12·u` — rise `x<2 → x/2`, flat `2≤x<10 → 1`, fall `x≥10 → (12−x)/2`, clamped at 0).
fn shadow_ramp(u: f32) -> f32 {
@@ -578,6 +644,32 @@ mod tests {
assert_eq!(shadow_ramp(2.0), 0.0);
}
/// MONKEY (night blob): the oval is the exact complement of what the realtime lane casts —
/// gone in daylight up close (the old hide), FULL at night (the bug: the sun lane's `night`
/// scalar is 0 under the horizon, so nothing was casting and nothing was drawing either), and
/// full again past the cascade's edge band. Monotone in between, so no pop at dusk or dawn.
#[test]
fn oval_is_the_complement_of_the_realtime_lane() {
// Sun down (and the whole night): the realtime lane casts nothing → the full oval.
assert_eq!(sun_shadow_strength(-0.3), 0.0);
assert_eq!(blob_weight(sun_shadow_strength(-0.3), 10.0, 70.0), 1.0);
// High sun, well inside the cascade: the realtime cast owns it → no oval.
assert_eq!(sun_shadow_strength(0.9), 1.0);
assert_eq!(blob_weight(sun_shadow_strength(0.9), 10.0, 70.0), 0.0);
// Same high sun, but past the cascade edge: the realtime cast has faded → the oval is back.
assert_eq!(blob_weight(sun_shadow_strength(0.9), 80.0, 70.0), 1.0);
// Dusk sweeps the weight monotonically from 0 up to 1 with no jump.
let mut prev = 0.0;
for step in 0..=20 {
let height = 0.25 - 0.25 * step as f32 / 20.0; // ~14° down to the horizon
let w = blob_weight(sun_shadow_strength(height), 10.0, 70.0);
assert!(w >= prev - 1e-6, "dusk weight went backwards at step {step}");
assert!(w - prev < 0.35, "dusk weight jumped at step {step}");
prev = w;
}
assert!((prev - 1.0).abs() < 1e-6);
}
/// The box law: clamp INTO ±5 pre-scale (a cap, not a floor), then scale.
#[test]
fn box_clamp_caps_pre_scale() {
+84 -1
View File
@@ -624,17 +624,76 @@ pub(crate) const REGISTERED: &[Registered] = &[
"2.5",
"benilla's own: interior light-budget multiplier before the soft rolloff, 0.25..8",
),
// MONKEY (soft falloff): the live scale on every interior fixture's AUTHORED attenuation
// window (WMO MOLT `+0x28/+0x2c`; M2 sources bucket by intensity instead — their authored pair
// is a template default, not a reach). A fixture's EFFECTIVE RADIUS is `authored end × this`.
// The artists' own ends — Goldshire inn 6.97-9.53 yd over 10 fixtures, its blacksmith 6.0,
// NSabbey 4.17-5.56, Stormwind's 606 median 6.94 — are where FULL brightness ends, not where
// light stops, so `1` drew a hard-edged disc at exactly that radius with black beyond it (the
// abbey candelabra ring). **2.5** is the default: the pool now tails smoothly to 2.5× the
// authored end, reading ~⅓ of its 1 yd brightness AT the authored end and ~8 % at twice it.
// `>1.6` widens further, `<1.6` tightens, `0` switches the window off (the 48 yd lane), so the
// whole shape A/Bs from chat.
ours(
"interiorAttenScale",
"1.6",
"benilla's own: scale on interior fixtures' authored attenuation window = their effective \
radius, 0..8 (0 = no window, the old flat lane)",
),
// MONKEY (room gate): whether an interior fixture may light only the rooms it CLAIMS — its
// authored MOLR groups unioned with the interior groups whose MOGI bounding box it stands
// inside (`LightLitRooms` carries the corpus evidence for why MOLR alone is far too sparse:
// the Goldshire inn authors one on 2 of its 12 groups). Off restores the pre-gate behaviour:
// every interior fixture in range lights every interior surface in range, so an inn's
// ground-floor candles light its basement through the floor. Kept as a dial because a room the
// gate leaves on ambient alone looks the same as a bug, and this tells the two apart in one
// keystroke.
ours(
"interiorRoomGate",
"1",
"benilla's own: an interior fixture lights only the WMO groups it claims (0 = the old \
leak-through-walls behaviour)",
),
ours(
"interiorShadows",
"1",
"benilla's own: interior fixtures cast real shadows (Stage B, the nearest few); needs \
interiorLight",
),
// MONKEY (static torch cache): residency and per-frame work have separate live budgets.
ours(
"interiorShadowCasters",
"12",
"benilla's own: resident interior fixture shadow maps, 1..16",
),
ours(
"interiorShadowDynamic",
"4",
"benilla's own: nearest promoted fixtures with moving entity shadows, 0..16",
),
// MONKEY (torch caster selection): the PCF tap radius on the torch maps. Candle clusters read
// very hard-edged at 1 (a half-texel box on a 512² face); 2 is a visible softening for four
// extra texel-neighbourhood taps' worth of cache pressure, no extra samples.
ours(
"interiorShadowSoft",
"1",
"benilla's own: torch-shadow edge softness — the PCF tap radius scale, 0.5..3",
),
ours(
"interiorDebug",
"0",
"benilla's own: interior diagnostic overlay — 1 classification, 2 shadow, 3 caster count",
),
// MONKEY (fire GO lights): the gain on lights SYNTHESISED from a model's flame emitter for the
// ~410 fire props the artists never gave a light block (campfires, wall torches, magic
// braziers, forges, candles). Live, like the interior knobs — and `0` is the kill switch for
// the whole invented-light lane, which matters because unlike everything beside it this one is
// a heuristic over content rather than a byte-verified mechanism.
ours(
"fireLightGain",
"1",
"benilla's own: brightness of lights synthesised from fire props' flame emitters (0 = off)",
),
// **Display mode** (decisions 1627, 1650) — 1.12's own `gxWindow`, worn since 1650 as modern
// Classic's two-entry *Display Mode* dropdown rather than 1.12's *Windowed Mode* checkbox: the
// two states 1627 settled on ARE that client's two (its own `Graphics.lua` builds the list from
@@ -1126,8 +1185,23 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"interiorambient" => knobs.video.interior_ambient = v.clamp(0.0, 1.0),
"interiorfill" => knobs.video.interior_fill = v.clamp(0.0, 2.0),
"interiorexposure" => knobs.video.interior_exposure = v.clamp(0.25, 8.0),
// MONKEY (interior attenuation): the authored-window scale. `0` is a MEANINGFUL value here
// (the window off), so the range floors at 0 rather than at a small positive.
"interiorattenscale" => knobs.video.interior_atten_scale = v.clamp(0.0, 8.0),
"interiorroomgate" => knobs.video.interior_room_gate = v != 0.0,
"interiorshadows" => knobs.video.interior_shadows = v != 0.0,
// MONKEY (torch caster selection): the working-set size and the PCF radius, clamped at the
// edge like every other numeric row. `casters` floors at 1, not 0 — `interiorShadows 0` is
// already the off switch, and a 0 here would be a second, confusing one.
"interiorshadowcasters" => {
knobs.video.interior_shadow_casters = (v.max(1.0) as u32).clamp(1, 16);
}
// MONKEY (static torch cache): zero is useful for static-only rooms.
"interiorshadowdynamic" => knobs.video.interior_shadow_dynamic = (v.max(0.0) as u32).min(16),
"interiorshadowsoft" => knobs.video.interior_shadow_soft = v.clamp(0.5, 3.0),
"interiordebug" => knobs.video.interior_debug = (v.max(0.0) as u32).min(3),
// MONKEY (fire GO lights): the synthesised-fire gain, clamped at the edge like the rest.
"firelightgain" => knobs.video.fire_light_gain = v.clamp(0.0, 4.0),
// Display mode (1627) — a flag like every other checkbox here, and the reference's own
// polarity: `1` is WINDOWED (the row is "Windowed Mode"). `video::apply_window_mode`
// watches the value and pushes it to the window; nothing else reads it.
@@ -1430,7 +1504,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 53] = [
let session: [(&str, String); 59] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1483,8 +1557,17 @@ fn sync_cvars(
("interiorAmbient", video.interior_ambient.to_string()),
("interiorFill", video.interior_fill.to_string()),
("interiorExposure", video.interior_exposure.to_string()),
("interiorAttenScale", video.interior_atten_scale.to_string()),
("interiorRoomGate", flag(video.interior_room_gate)),
("interiorShadows", flag(video.interior_shadows)),
(
"interiorShadowCasters",
video.interior_shadow_casters.to_string(),
),
("interiorShadowDynamic", video.interior_shadow_dynamic.to_string()),
("interiorShadowSoft", video.interior_shadow_soft.to_string()),
("interiorDebug", video.interior_debug.to_string()),
("fireLightGain", video.fire_light_gain.to_string()),
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
(
"gxWindow",
+25 -2
View File
@@ -8,7 +8,12 @@
//!
//! The knobs are LIVE — `/script SetCVar("interiorExposure", 2)` in chat retunes the room without
//! a rebuild, which is how the defaults were found.
use benilla_world::lighting::DynamicInteriors;
//!
//! MONKEY (fire GO lights): the same bridge also carries `fireLightGain` →
//! [`benilla_world::lighting::FireLightGain`]. Same shape, same guard, same liveness requirement —
//! and `fireLightGain 0` is the kill switch for the synthesised-fire lane the way `interiorLight 0`
//! is for this one.
use benilla_world::lighting::{DynamicInteriors, FireLightGain};
use bevy::prelude::*;
use crate::video::VideoConfig;
@@ -23,12 +28,30 @@ impl Plugin for DynamicInteriorPlugin {
/// Publish the cvars to benilla-world when they change. Guarded so the resource's change ticks
/// stay quiet on idle frames.
fn bridge(video: Res<VideoConfig>, mut out: ResMut<DynamicInteriors>) {
///
/// MONKEY (fire GO lights): `fireLightGain` rides along here rather than in a module of its own —
/// it is the same shape (a live video cvar → a benilla-world resource the light packer reads) and
/// the same one-line guard, and a second plugin to carry one `f32` is not worth its wiring.
fn bridge(
video: Res<VideoConfig>,
mut out: ResMut<DynamicInteriors>,
mut fire: ResMut<FireLightGain>,
) {
if fire.0 != video.fire_light_gain {
fire.0 = video.fire_light_gain;
}
let want = DynamicInteriors {
enabled: video.interior_light,
ambient: video.interior_ambient,
fill: video.interior_fill,
exposure: video.interior_exposure,
// MONKEY (interior attenuation): the authored-window scale rides the same bridge — the
// light packer folds it into every interior entry's packed reach, so
// `SetCVar("interiorAttenScale", 2)` widens every pool on the very next frame.
atten_scale: video.interior_atten_scale,
// MONKEY (room gate): the packer turns this into a per-light claim head, so
// `SetCVar("interiorRoomGate", 0)` restores the ungated look on the very next frame.
room_gate: video.interior_room_gate,
debug: video.interior_debug,
};
if *out != want {
+25
View File
@@ -47,6 +47,9 @@ use attach::{attach_entity_visuals, build_dressup_preview, build_glue_pet, build
/// decision 0016's law applied to the *entity* half of the scene, not just the placed half.
mod carried_light;
use carried_light::spawn_carried_lights;
// MONKEY (carried light stability): `torch_shadow` needs the settle verdict to refuse a MOVING
// carried light a cube-shadow slot.
pub(crate) use carried_light::CarriedLightMotion;
/// Equipment visuals (decisions 0072/0074): held items (weapon/shield/ranged) plus worn-armor and
/// helm/shoulder resolution, all resolved from the unit descriptor + ItemDisplayInfo and spawned as
@@ -855,6 +858,28 @@ impl Plugin for EntitiesPlugin {
.after(apply_render_fade)
.before(crate::player::apply_self_model_fade),
)
// MONKEY (fire GO lights): copy each carried light's owner room onto the light, so an
// entity's torch/brazier is portal-gated like a building's own fixture AND is eligible for
// a cube shadow. In `Update` **after the classifier** — that is the frame's only writer of
// `InteriorAnchor::room`, and the whole of `Update` precedes `PostUpdate`'s light pack
// (`lighting::build_light_data`), so a claim made here is always in this frame's table.
.add_systems(
Update,
carried_light::claim_carried_light_rooms
.after(benilla_world::interior::classify_entity_interior),
)
// MONKEY (carried light stability): how long each carried light has been standing still,
// which is what lets `torch_shadow` refuse a MOVING one a cube-shadow slot (a walking
// bearer invalidated its cached depth map every frame, and a withdrawn map zeroes the
// slot's cross-fade weight with no ramp — the imp's strobing green pool). `PostUpdate`
// after transform propagation, because a child light's `GlobalTransform` is only this
// frame's once `Propagate` has run; `update_torch_shadows` reads it later the SAME frame,
// in `Last`.
.add_systems(
PostUpdate,
carried_light::track_carried_light_motion
.after(bevy::transform::TransformSystems::Propagate),
)
// The aura CharProc layer (`crate::aura_visual`): the state kit's effect on the BODY.
// The drain installs/removes this frame's nodes; the author then owns the render alpha
// of every part under a translucent unit — after the three steady-state alpha authors
@@ -26,7 +26,12 @@ use benilla_assets::coords::wow_to_bevy;
use benilla_assets::ModelLight;
use bevy::prelude::*;
use benilla_world::terrain_stream::point_light;
use benilla_world::interior::{InteriorAnchor, WmoResidency};
use benilla_world::lighting::{
LightLane, LightLitRooms, LightRooms, ShadowProxyLight, SyntheticFireLight,
};
use benilla_world::terrain_stream::{carried_light_claims, point_light, CarriedClaimSet};
use benilla_world::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom};
/// Spawn a `PointLight` child for each **casting** (`type==1`, not visibility-gated dark) M2 light of
/// an entity's model.
@@ -49,7 +54,30 @@ pub(super) fn spawn_carried_lights(
if !l.def.casts() {
continue; // directionals feed an ambient term; a static `0` visibility key is dark
}
let (parent, local) = match joint(l.def.bone) {
// MONKEY (carried light stability): a SYNTHESISED light (`fire_light` — the imp's hand
// flames, a brazier GameObject, a fire elemental) does NOT ride its host bone; an
// AUTHORED one still does.
//
// The distinction is what the position MEANS. An authored light block is a fixture an
// artist put on a bone, and `0x718960` re-registering it through the live bone matrix is
// the reference's own behaviour — the held torch that tracks the swinging hand, and that
// case takes the boneless arm below anyway (a held item spawns no skeleton). A synthesised
// one is a stand-in for a whole flame VOLUME whose position we GUESSED off the emitter
// record: it is already approximate to within the flame's own size, and riding a hand bone
// through an idle animation adds high-frequency motion that every consumer downstream
// reads as an EVENT rather than as a wobble. The per-vertex nearest-3 in `point_light_sum`
// re-ranks; and the cube-shadow cache tripped its 0.1 yd staleness test
// (`torch_shadow::map_publishable`) every single frame and withdrew its map, which
// `TorchTable::pack` answers by forcing that slot's cross-fade weight to 0 with no ramp —
// a full-strength shadow toggling at frame rate. That is the reported "the imp produces a
// DISCO with its light".
//
// The rest pose is identity about the pivot ([`ModelLight::bone_pivot`]), so hanging the
// light off `frame` at the raw model-space `def.position` puts it EXACTLY where the bone
// ride puts it in the rest pose — and `frame` is the model root, so the light still
// follows the unit's own motion with zero lag. Only the animation is dropped.
let host = if l.synthetic { None } else { joint(l.def.bone) };
let (parent, local) = match host {
Some(j) => (
j,
[
@@ -60,21 +88,493 @@ pub(super) fn spawn_carried_lights(
),
None => (frame, l.def.position),
};
let glow = commands
.spawn((
point_light(l.def.diffuse_color, l.def.diffuse_intensity),
Transform::from_translation(wow_to_bevy(local)),
Visibility::default(),
))
.id();
let mut glow = commands.spawn((
point_light(l.def.diffuse_color, l.def.diffuse_intensity),
Transform::from_translation(wow_to_bevy(local)),
Visibility::default(),
));
// MONKEY (fire GO lights): a light we DERIVED from the model's flame emitter is tagged, so
// the packer can ride the live `fireLightGain` over it (and only over it). The entity lanes
// take synthetic lights: a placed brazier GameObject, a fire elemental, a flaming helm have
// no MOLT fixture standing beside them the way a WMO's own wall torches do.
if l.synthetic {
glow.insert(SyntheticFireLight);
}
let glow = glow.id();
commands.entity(parent).add_child(glow);
}
}
/// MONKEY (fire GO lights): how far up the hierarchy the room walk looks before giving up. A
/// carried light's anchor is at most `light → joint → … → net entity`, and a rig's joint chain is
/// the deep part; 64 clears every shipped skeleton with room to spare, and the cap is there so a
/// malformed cycle can never hang the frame.
const ANCHOR_WALK_DEPTH: usize = 64;
/// MONKEY (carried light stability): the bearer crossfade weight at which a carried light COMMITS
/// to its owner's room. [`InteriorAnchor::lane`] ramps at 2/s, so crossing this midpoint needs
/// ~0.25 s of a HELD verdict; one frame of a wrong law moves the weight by `dt * 2` and can never
/// cross it. That IS the hysteresis — no second timer, and because it is the very signal the
/// bearer's own meshes crossfade their lighting on, the light changes lane with the body.
const LANE_INTERIOR: f32 = 0.5;
/// MONKEY (carried light stability): how far a carried light may drift and still count as standing
/// still. Deliberately HALF `torch_shadow`'s 0.1 yd cache-staleness radius: the settle test has to
/// be the stricter of the two, or a light could qualify as a shadow caster while already sitting
/// far enough from its baked map to have that map withdrawn again on the next step.
const STILL_DRIFT: f32 = 0.05;
/// MONKEY (carried light stability): how long a carried light must hold still before it may cast.
/// Long enough that a pet fidgeting between waypoints never qualifies, short enough that a placed
/// brazier GameObject is eligible well inside the first second it exists.
const STILL_HOLD: f32 = 0.75;
/// MONKEY (carried light stability): how long a carried light has been standing still, so the
/// torch-shadow lane can refuse a MOVING one.
///
/// The cube-shadow cache is built for fixtures that do not move: a slot keeps its baked depth only
/// while its fixture stays within 0.1 yd of where the map was baked
/// (`torch_shadow::map_publishable`), and a slot whose map is withdrawn has its cross-fade weight
/// forced to 0 with no ramp (`static_gx::torch_depth`'s `TorchTable::pack`). A walking bearer
/// crossed that threshold every frame, which is a full-strength shadow blinking at frame rate —
/// and worse, it burned the lane's global two-rebuilds-a-frame budget re-baking a mesh that was
/// stale again immediately, starving the STATIC candles around it so those flickered too. One
/// pet's shadow is not worth that.
///
/// A carried light is not excluded outright, because the same `ChildOf` spawn path carries the
/// stationary cases the shadow lane was written for: a GM-placed brazier GameObject in the Lion's
/// Pride Inn, a campfire. Those settle within [`STILL_HOLD`] and cast normally; only things that
/// actually move are refused. Refusal is a *candidacy* verdict, so an incumbent that starts
/// walking is cross-faded out by the lane's own eviction path rather than cut.
#[derive(Component)]
pub(crate) struct CarriedLightMotion {
/// World position at the last frame the light was judged to have MOVED. Not re-anchored while
/// it is still, so a slow creep accumulates and eventually trips the test — which is right,
/// because the shadow cache's own staleness test is likewise absolute from the bake point.
last: Vec3,
/// Seconds held within [`STILL_DRIFT`] of `last`, capped at [`STILL_HOLD`]. The cap is what
/// keeps a settled light a pure READ: no field write, so no change tick, so nothing
/// downstream is woken by a light that is doing nothing.
still_for: f32,
}
impl CarriedLightMotion {
/// Has this light held still long enough to be trusted with a cached cube shadow?
pub(crate) fn settled(&self) -> bool {
self.still_for >= STILL_HOLD
}
}
/// MONKEY (carried light stability): maintain [`CarriedLightMotion`] for every carried light.
///
/// `PostUpdate`, **after transform propagation**, for the same reason the packer is there: a
/// carried light is a child of a moving parent, so its `GlobalTransform` is only this frame's once
/// `Propagate` has run. `update_torch_shadows` reads the verdict in `Last`, i.e. later in the same
/// frame — never one stale.
pub(crate) fn track_carried_light_motion(
mut commands: Commands,
time: Res<Time>,
mut lights: Query<
(Entity, &GlobalTransform, Option<&mut CarriedLightMotion>),
(With<PointLight>, With<ChildOf>, Without<ShadowProxyLight>),
>,
) {
let dt = time.delta_secs();
for (light, gt, motion) in &mut lights {
let p = gt.translation();
match motion {
Some(mut m) => {
if p.distance_squared(m.last) > STILL_DRIFT * STILL_DRIFT {
m.last = p;
m.still_for = 0.0;
} else if m.still_for < STILL_HOLD {
m.still_for += dt;
}
}
// Born moving: a light that spawns mid-stride must not be eligible on its first frame
// merely because it has no history yet.
None => {
commands.entity(light).try_insert(CarriedLightMotion {
last: p,
still_for: 0.0,
});
}
}
}
}
/// The bearer state a carried light's claim is derived from — the room its [`InteriorAnchor`]
/// names and that anchor's crossfade WEIGHT — walked up the hierarchy from the light itself,
/// because the light hangs off a BONE JOINT (or the model frame) and the anchor is the model's
/// net-entity root several links above it (`interior::part_interior_lit`: "`anchor` is the model's
/// NET ENTITY root for every caller").
fn owner_lane(
light: Entity,
parents: &Query<&ChildOf>,
anchors: &Query<&InteriorAnchor>,
) -> (Option<WmoRoom>, f32) {
let mut e = light;
for _ in 0..ANCHOR_WALK_DEPTH {
if let Ok(anchor) = anchors.get(e) {
return (anchor.room(), anchor.lane());
}
match parents.get(e) {
Ok(c) => e = c.parent(),
Err(_) => break,
}
}
// No anchor above us at all (a bearer with neither a lit part nor a lit emitter): exterior,
// and settled there — the same answer the old walk's `None` produced.
(None, 0.0)
}
/// The room a light's [`LightRooms`] claim currently stands for — our own record of it, because
/// `LightRooms` wraps a `WmoGroupVis` whose fields are benilla-world-private and cannot be read
/// back for a comparison. Absent = never claimed.
#[derive(Component, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ClaimedRoom {
/// What the light's `LightRooms` / `LightLane` currently stand for. `None` = exterior lane.
applied: Option<WmoRoom>,
/// MONKEY (carried light stability): the last room the bearer's anchor actually NAMED. Held
/// across the exterior ramp: the frame a bearer's law flips to `Exterior` its `room` goes
/// `None` at once, while the lane weight the claim is judged on takes ~0.25 s to fall past
/// [`LANE_INTERIOR`]. Without this there would be no room left to keep claiming during that
/// window, and the light would drop to the exterior lane on the raw flip — exactly the
/// one-frame verdict the weight exists to smooth.
seen: Option<WmoRoom>,
/// MONKEY (GO room claims): the [`LightLane`] currently on the light. The lane used to be a
/// pure function of `applied` (`is_some()`), so it needed no record; it is now also a function
/// of the CLAIM SET (a light standing in an exterior-class group that claims a real room moves
/// onto the interior lane — see [`CarriedClaims`]), and those two move independently. Recorded
/// so the `LightLane` insert stays change-gated: an insert is an archetype write, and the
/// render extraction behind it wakes on every one.
lane: bool,
}
/// MONKEY (GO room claims): how far a carried light may drift (yd) before its claim set is
/// rebuilt. Ten times [`STILL_DRIFT`] on purpose — the settle test is about whether a CACHE of the
/// light's surroundings is still valid frame to frame, this one is about whether a set of ROOMS is
/// still the right set, and a room is yards across. Half a yard is well inside the smallest shipped
/// doorway, so a light cannot cross a portal without tripping it.
const CLAIM_DRIFT: f32 = 0.5;
/// MONKEY (GO room claims): a carried light's [`LightLitRooms`] claim set, plus the three inputs
/// the rebuild is gated on. Our own record, for the same reason [`ClaimedRoom`] is one:
/// `LightLitRooms` wraps a crate-private `WmoGroupVis` and cannot be read back for a comparison.
#[derive(Component)]
pub(crate) struct CarriedClaims {
/// The set currently applied — `None` when the light carries no claims at all (outdoors, or
/// moving, or standing in no building's authored box).
set: Option<CarriedClaimSet>,
/// The world position the set was decided at. Absolute, not re-anchored: a light that creeps
/// out of the room it claimed must eventually rebuild, and the drift budget is the room's.
at: Vec3,
/// The [`WmoResidency`] generation it was decided under — a light that claimed nothing because
/// its building had not streamed in yet has to be re-asked once it has, and the reverse (the
/// placement entity a claim is keyed to is gone) is a stale instance id in the GPU claim head.
generation: u32,
/// The bearer room it was decided under: the light walked through a door, so the claim seed
/// (and the preferred placement) moved even if the light itself barely did.
room: Option<WmoRoom>,
}
impl CarriedClaims {
/// MONKEY (GO room claims): **the recompute gate.** `true` = the applied set still stands for
/// this position, this residency and this bearer room, so the whole claim walk is skipped.
///
/// Split out as a pure function so it can be tested without a world: the walk it guards is the
/// expensive half of this system (an O(groups) box scan per resident placement), and a gate
/// that silently stopped firing would show up not as a wrong picture but as a stale one.
fn fresh(&self, at: Vec3, generation: u32, room: Option<WmoRoom>) -> bool {
self.generation == generation
&& self.room == room
&& self.at.distance_squared(at) <= CLAIM_DRIFT * CLAIM_DRIFT
}
}
/// MONKEY (GO room claims): does this claim set justify moving the light onto the INTERIOR lane?
///
/// Two conditions, and the second is the safety half.
///
/// 1. **It must name an interior-class group.** Claims are read by exactly one function,
/// `static_gx.wgsl`'s `interior_room_light`, and that function skips every table entry whose
/// colour-row `.w < 0.5` — i.e. every EXTERIOR-lane light. So on the exterior lane a claim set
/// is inert: it is packed as the UNGATED head (`build_light_data` only calls `RoomClaim::build`
/// on the interior arm) and no shader ever looks at it. A light that claims a real room and
/// stays exterior has therefore gained nothing, which is the whole reason the lane moves.
/// 2. **Every claim must be exterior-lane eligible** (no `LIT_ROOM_EXT_DENY` bit). Moving onto the
/// interior lane is a TRADE: the light leaves `point_light_sum` (terrain, doodads, NPCs) and
/// `wmo_exterior_point_sum` (a WMO's exterior-class walls and cobbles) and joins
/// `interior_room_light`, which reaches an exterior-class surface only through the strict
/// exterior-batch room term — and that term admits a claim only when it carries `CLAIM_EXT_OK`.
/// A district-scale shell claim (Orgrimmar's whole valley, Stormwind's districts) carries the
/// deny bit, so a light standing in one would be taken off the very cobbles it lights today and
/// given nothing back that reached them. Those keep the exterior lane and behave exactly as
/// before; a BUILDING-scale exterior group (the Goldshire inn's shell, the canyon nook a shop
/// door opens onto) is eligible, so its floor keeps the light through the strict term while the
/// interior rooms one portal hop away gain it.
///
/// The trade the rule still accepts, stated plainly: a brazier that moves onto the interior lane
/// stops feeding the nearest-3 exterior term, so ADT TERRAIN under it and the NPCs standing beside
/// it lose that light and gain the room lane instead. That is the correct side for a fixture
/// genuinely inside a building's claimable volume — it is what a MOLT fixture in the same spot
/// already does — and condition 2 is what keeps it from happening out on a district-scale street.
fn lane_worthy(set: &CarriedClaimSet) -> bool {
set.any_interior && set.all_ext_ok()
}
/// MONKEY (GO room claims): the claim set of a light at `at`, from the first resident WMO placement
/// whose authored MOGI boxes hold it.
///
/// `prefer` is tried in order before the scan — the bearer's own anchor placement and the one the
/// light claimed last time. In the steady state one of those two always wins on the first probe,
/// which is what keeps a residency-generation tick (a building streaming in three streets away
/// re-gates every carried light in the world) from turning into a full scan per light. The scan is
/// the cold path: a GameObject standing in an EXTERIOR-class group has no anchor room at all, so
/// its very first claim has nowhere else to come from.
fn claims_at(
wmos: &Assets<benilla_assets::WmoModel>,
placements: &Query<(Entity, &WmoPortalInstance)>,
prefer: [Option<Entity>; 2],
at: Vec3,
reach: f32,
) -> Option<CarriedClaimSet> {
let one = |e: Entity| {
let (e, inst) = placements.get(e).ok()?;
carried_light_claims(wmos.get(&inst.handle)?, e, inst.world_from_local, at, reach)
};
for e in prefer.into_iter().flatten() {
if let Some(set) = one(e) {
return Some(set);
}
}
placements.iter().find_map(|(e, inst)| {
if prefer.contains(&Some(e)) {
return None; // already probed above
}
carried_light_claims(wmos.get(&inst.handle)?, e, inst.world_from_local, at, reach)
})
}
/// 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
/// entity's light cannot: a GameObject walks (or is placed) into a building the tables know nothing
/// about, and its room is only ever known from the interior classifier's down-ray. So this system
/// copies that verdict onto the light, and two things follow from it:
///
/// - **The faithful gate.** A torch in a portal-culled room stops lighting the hillside outside it
/// ([`LightRooms`], decision 0689) — until now a placed brazier GameObject lit through walls
/// because it claimed no room at all and `room_admits` admits `None` unconditionally.
/// - **Shadow eligibility.** `torch_shadow`'s caster query is `With<PointLight>, With<LightRooms>`,
/// so a roomless light can never be promoted to a cube-map caster however deep indoors it stands.
/// A GM-placed brazier in the Lion's Pride Inn now throws real shadows like a MOLT fixture.
///
/// Applies to synthetic and authored carried lights alike — the room is a fact about the OWNER,
/// not about where the light's colour came from.
///
/// MONKEY (carried light stability): the verdict is taken from the anchor's crossfade WEIGHT, not
/// from its raw per-frame law. The law alternates: `WOW_INTERIOR_LOG` from the 2026-09-09 run has
/// anchor `320v2` resolving `matte`↔`bake` on ten consecutive frames (t 73.75 … t 74.14), and
/// the same threshold noise puts an `Exterior` between two interior verdicts at a doorway. A light
/// that re-decided on that raw answer would hand itself back and forth between two DIFFERENT
/// consumer families every frame — the exterior `point_light_sum` and the interior room lane —
/// which is an on/off strobe of the pool, not a subtle difference. Reading `lane >= LANE_INTERIOR`
/// costs nothing and cannot be crossed by a blip (see [`LANE_INTERIOR`]).
///
/// MONKEY (GO room claims): **and it now builds the light's [`LightLitRooms`] too** — the rooms it
/// may LIGHT, as opposed to the one it must be VISIBLE from. The bearer's anchor answers "which
/// group is this standing in", singular, and one group was the whole gate: a brazier inside the
/// Lion's Pride Inn lit its own room and stopped dead at the doorway, and a brazier in one of
/// Orgrimmar's 123 canyon placements named no group at all (`MOGP & 0x48` ⇒ the anchor is `None`)
/// so it could never reach the shops the canyon opens onto. The fix is not a second classifier: it
/// is the SAME claim rule the WMO's own MOLT fixtures and MODD props run
/// (`benilla_world::terrain_stream::carried_light_claims` → `benilla_formats::room_claims`:
/// containment tightest-first, then one portal hop within reach), asked at the light's world
/// position. Reach is the M2 intensity bucket scaled by the DEFAULT `interiorAttenScale`, exactly
/// as the prop lane sizes its own hop — the live cvar must not be able to open a doorway that was
/// 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`]).
///
/// 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
/// archetype (and the render extraction behind it) stays quiet while an NPC stands still in a room.
#[allow(clippy::type_complexity)]
pub(crate) fn claim_carried_light_rooms(
mut commands: Commands,
// Carried lights only — `With<ChildOf>` IS the distinction: a world-baked doodad/MOLT light
// spawns as a free root with its rooms already correct, and re-deciding them from a hierarchy
// it isn't in would clear them.
mut lights: Query<
(
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.
&GlobalTransform,
&PointLight,
Option<&CarriedLightMotion>,
Option<&mut ClaimedRoom>,
Option<&mut CarriedClaims>,
),
(With<ChildOf>, Without<ShadowProxyLight>),
>,
parents: Query<&ChildOf>,
anchors: Query<&InteriorAnchor>,
// MONKEY (GO room claims): the resident placements + their models — the claim rule's tables.
placements: Query<(Entity, &WmoPortalInstance)>,
wmos: Res<Assets<benilla_assets::WmoModel>>,
residency: Res<WmoResidency>,
) {
let generation = residency.generation();
for (light, gt, pl, motion, mut claimed, mut claims) 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`).
let seen = room.or_else(|| claimed.as_deref().and_then(|c| c.seen));
let want = seen.filter(|_| lane >= LANE_INTERIOR);
// MONKEY (GO room claims): the LIT-room claim set, rebuilt only on the gates above.
// `None` below means "keep what is already applied"; `Some(x)` means "apply x".
let at = gt.translation();
let fresh = claims
.as_deref()
.is_some_and(|c| c.fresh(at, generation, want));
let set = if fresh {
None
} else if motion.is_some_and(CarriedLightMotion::settled) {
// 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(
benilla_formats::room_claim::m2_light_reach(
pl.intensity / (4.0 * std::f32::consts::PI),
),
);
let prefer = [
want.map(|r| r.instance),
claims
.as_deref()
.and_then(|c| c.set.as_ref())
.map(|s| s.instance),
];
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.
Some(None)
};
// The set that stands THIS frame: the one just decided, else the one already applied. Read
// from the local rather than back off the component, because on a light's first frame the
// component is still a queued command and re-reading it would see `None` and remove the
// very `LightLitRooms` this pass is inserting.
let had = claims.as_deref().and_then(|c| c.set.as_ref());
let next_set: Option<&CarriedClaimSet> = match &set {
Some(next) => next.as_ref(),
None => had,
};
let set_changed = match (next_set, had) {
(None, None) => false,
// MONKEY (soft portal claims): the FADES are part of the set. A light that drifts
// across a room keeps claiming the same groups while the reach it has left past each
// doorway shrinks — comparing ids alone would leave it faded by the distances it had
// at its first build point, for as long as it never changed rooms.
(Some(next), Some(had)) => {
next.instance != had.instance
|| next.groups != had.groups
|| next.fades != had.fades
}
_ => true,
};
// MONKEY (GO room claims): the lane is the OR of the two halves — the bearer's own room,
// and (new) a claim set that earns the interior lane on its own (see [`lane_worthy`]).
let want_lane = want.is_some() || next_set.is_some_and(lane_worthy);
// What the light already carries, so each insert below is earned.
let (had_room, had_lane) = claimed
.as_deref()
.map_or((None, None), |c| (Some(c.applied), Some(c.lane)));
let mut e = commands.entity(light);
// MONKEY (light lane by position): the carried lane's own lane verdict. A bearer's room
// came from the interior classifier's ray at the bearer's own feet, so "my owner is in a
// room" IS "I am physically inside one" — no second ray, and the static classifier
// (`classify_light_lanes`) deliberately skips `With<ChildOf>` so the two never fight.
// MONKEY (GO room claims): plus the claim half — a light standing in an EXTERIOR-class
// group (so: no anchor room) that nonetheless claims a real interior room through a portal
// has to be on the interior lane for those claims to be read at all (see [`lane_worthy`]).
if had_lane != Some(want_lane) {
e.insert(LightLane::carried(want_lane));
}
if had_room != Some(want) {
match want {
Some(room) => {
e.insert(LightRooms::new(WmoGroupVis::single(room.instance, room.group)));
}
// Back outdoors (or the anchor went away): drop the claim rather than leave a
// stale room, which would gate the light on a building it has walked out of.
None => {
e.remove::<LightRooms>();
}
}
}
if set_changed {
match next_set {
Some(next) => {
e.insert(next.component());
}
None => {
e.remove::<LightLitRooms>();
}
}
}
// Bookkeeping LAST — every verdict above borrows it. `seen` is a record, not a component:
// updating it must not touch the light's COMPONENTS, or a bearer walking a corridor would
// churn the archetype for nothing.
match claimed.as_mut() {
Some(c) => {
let (applied, lane) = (c.applied, c.lane);
c.set_if_neq(ClaimedRoom { applied, seen, lane });
c.applied = want;
c.lane = want_lane;
}
None => {
e.try_insert(ClaimedRoom {
applied: want,
seen,
lane: want_lane,
});
}
}
if let Some(next) = set {
let next = CarriedClaims {
set: next,
at,
generation,
room: want,
};
match claims.as_mut() {
Some(c) => **c = next,
None => {
e.try_insert(next);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_formats::M2Light;
use std::time::Duration;
fn light(light_type: u16, bone: i16, position: [f32; 3], visibility_off: bool) -> ModelLight {
ModelLight {
@@ -92,6 +592,7 @@ mod tests {
visibility_off,
},
bone_pivot: [1.0, 0.0, 0.5],
synthetic: false,
}
}
@@ -154,4 +655,143 @@ mod tests {
"colour × intensity survives the packing"
);
}
/// GOLDEN — MONKEY (carried light stability). A SYNTHESISED light does NOT ride its host
/// bone even when the instance carries that joint: it hangs off the model FRAME at the raw
/// model-space position, i.e. exactly where the bone ride would put it in the REST pose. The
/// frame is the model root, so the light still follows the unit with zero lag; what is dropped
/// is the per-frame animation swing that made the imp's green pool strobe (it re-ranked the
/// shaders' per-vertex nearest-3 and invalidated the cube-shadow cache every frame).
#[test]
fn a_synthetic_light_stays_on_the_rest_pose_pivot() {
let mut app = App::new();
let frame = app.world_mut().spawn(Transform::IDENTITY).id();
let joint = app.world_mut().spawn(Transform::IDENTITY).id();
let mut synth = light(1, 3, [2.0, 0.0, 1.5], false);
synth.synthetic = true;
let lights = [synth];
app.world_mut().commands().queue(move |world: &mut World| {
let mut q = world.commands();
spawn_carried_lights(&mut q, &lights, frame, move |bone| {
(bone == 3).then_some(joint)
});
});
app.world_mut().flush();
let spawned: Vec<(Vec3, Entity)> = app
.world_mut()
.query::<(&PointLight, &Transform, &ChildOf)>()
.iter(app.world())
.map(|(_, t, c)| (t.translation, c.parent()))
.collect();
assert_eq!(spawned.len(), 1);
assert_eq!(spawned[0].1, frame, "the frame, never the joint");
assert_eq!(
spawned[0].0,
wow_to_bevy([2.0, 0.0, 1.5]),
"raw model-space position, NOT rebased by the bone pivot"
);
// The tag still rides it: the packer must keep folding `fireLightGain` over this one.
assert!(app
.world_mut()
.query::<&SyntheticFireLight>()
.iter(app.world())
.next()
.is_some());
}
/// GOLDEN — MONKEY (carried light stability). A carried light earns its cube-shadow
/// eligibility by HOLDING STILL for [`STILL_HOLD`], and loses it the instant it moves further
/// than [`STILL_DRIFT`]. This is what keeps a walking bearer out of `torch_shadow`'s candidate
/// set: a moving fixture invalidates its cached depth map every frame, and a withdrawn map
/// zeroes that slot's cross-fade weight with no ramp — a shadow blinking at frame rate.
#[test]
fn a_carried_light_casts_only_after_it_holds_still() {
let mut app = App::new();
app.insert_resource(Time::<()>::default());
app.add_systems(Update, track_carried_light_motion);
let bearer = app.world_mut().spawn(Transform::IDENTITY).id();
let light = app
.world_mut()
.spawn((
PointLight::default(),
Transform::IDENTITY,
GlobalTransform::IDENTITY,
ChildOf(bearer),
))
.id();
// First sight of it: history exists, but nothing has been HELD yet.
app.update();
let settled = |app: &App, e: Entity| {
app.world()
.entity(e)
.get::<CarriedLightMotion>()
.is_some_and(CarriedLightMotion::settled)
};
assert!(!settled(&app, light), "born moving is not settled");
// A second of standing still clears the hold.
app.world_mut()
.resource_mut::<Time>()
.advance_by(Duration::from_secs_f32(1.0));
app.update();
assert!(settled(&app, light), "held still past STILL_HOLD");
// One stride and it is disqualified again, with no credit for the second it banked.
*app.world_mut().entity_mut(light).get_mut::<GlobalTransform>().unwrap() =
GlobalTransform::from_translation(Vec3::new(1.0, 0.0, 0.0));
app.world_mut()
.resource_mut::<Time>()
.advance_by(Duration::from_secs_f32(1.0 / 60.0));
app.update();
assert!(!settled(&app, light), "moved further than STILL_DRIFT");
}
/// GOLDEN — MONKEY (GO room claims). The claim-set RECOMPUTE GATE. Building a carried light's
/// lit-room set is an O(groups) authored-box scan per resident WMO placement, and Orgrimmar
/// alone places ~150 of these lights, so the walk must run on a change and never on a frame.
/// All three inputs invalidate, and none of them alone is enough to be left out:
/// residency (a building streamed in under a light that had claimed nothing), the bearer's room
/// (it walked through a door, so the claim seed moved even though the light barely did), and
/// drift past [`CLAIM_DRIFT`] (it is no longer where the set was measured).
#[test]
fn a_claim_set_is_rebuilt_only_on_residency_room_or_drift() {
let room = |group| {
Some(WmoRoom {
instance: Entity::PLACEHOLDER,
group,
})
};
let held = CarriedClaims {
set: None,
at: Vec3::new(10.0, 2.0, -4.0),
generation: 7,
room: room(3),
};
assert!(
held.fresh(held.at, 7, room(3)),
"nothing moved: no walk this frame"
);
assert!(
held.fresh(held.at + Vec3::X * (CLAIM_DRIFT * 0.9), 7, room(3)),
"a wobble inside the drift budget still stands"
);
assert!(
!held.fresh(held.at + Vec3::X * (CLAIM_DRIFT * 1.1), 7, room(3)),
"past CLAIM_DRIFT the set was measured somewhere else"
);
assert!(
!held.fresh(held.at, 8, room(3)),
"a placement streamed in or out: the claim's instance key may be stale"
);
assert!(
!held.fresh(held.at, 7, room(4)),
"the bearer changed room"
);
assert!(
!held.fresh(held.at, 7, None),
"the bearer left the building"
);
}
}
+16 -11
View File
@@ -419,19 +419,24 @@ pub(super) fn spawn_wmo_gameobject_props(
}
// M2 point lights (the lantern's glow source): a CHILD at the prop-local
// position — propagation carries the source with the hull.
for l in m.lights.iter().map(|l| &l.def) {
if !l.casts() {
for l in m.lights.iter() {
if !l.def.casts() {
continue; // directional lights feed an ambient term; a static `0` visibility key is dark
}
let glow = commands
.spawn((
point_light(l.diffuse_color, l.diffuse_intensity),
Transform::from_translation(
prop.local.transform_point(wow_to_bevy(l.position)),
),
Visibility::default(),
))
.id();
let mut glow = commands.spawn((
point_light(l.def.diffuse_color, l.def.diffuse_intensity),
Transform::from_translation(
prop.local.transform_point(wow_to_bevy(l.def.position)),
),
Visibility::default(),
));
// MONKEY (fire GO lights): a transport prop takes SYNTHESISED lights like the
// other entity lanes (the deck braziers on a zeppelin/boat author none), tagged
// so the live `fireLightGain` reaches them.
if l.synthetic {
glow.insert(benilla_world::lighting::SyntheticFireLight);
}
let glow = glow.id();
commands.entity(entity).add_child(glow);
lights += 1;
}
@@ -2035,6 +2035,7 @@ mod tests {
visibility_off: false,
},
bone_pivot: [0.0; 3],
synthetic: false,
}
}
+678 -117
View File
@@ -5,15 +5,34 @@
//! `bevy::light::cluster::ClusterConfig::None`, which disables Bevy's point/spot clustering AND
//! starves the point-light shadow-map prep — so `fetch_point_shadow` / `clusterable_objects` are
//! DEAD here. The old design (promote the nearest fixtures to shadow-casting `PointLight` proxies and
//! sample their cube maps) could never fire, so it is gone. In its place: this lane picks the nearest
//! ≤4 interior fixtures, computes a down-looking reverse-Z `view_proj` for each, builds the retained
//! interior caster geometry, and publishes it all as [`TorchShadowViews`] (an [`ExtractResource`]).
//! The render world then renders a depth map per fixture (`benilla_world::static_gx::torch_depth`)
//! and `static_gx.wgsl`'s interior surface lane samples it so a pillar throws a radial shadow.
//! sample their cube maps) could never fire, so it is gone. In its place: this lane picks the
//! interior fixtures that actually LIGHT the player, computes a down-looking reverse-Z `view_proj` for each, builds the
//! retained interior caster geometry, and publishes it all as [`TorchShadowViews`] (an
//! [`ExtractResource`]). The render world then renders a depth map per fixture
//! (`benilla_world::static_gx::torch_depth`) and `static_gx.wgsl`'s interior surface lane samples
//! it so a pillar throws a radial shadow.
//!
//! **MONKEY (torch caster selection).** Which fixtures get maps used to be "the four nearest the
//! camera", recomputed and hard-switched every frame. That is the shadow POP: in Northshire Abbey
//! (42 candelabra, ~5 yd apart) the nearest-four set churns every few steps, and every fixture
//! outside it lit with no shadow at all, so a fixture's shadow appeared as you walked up to it and
//! vanished as you moved on. The selection is now (a) ranked by the fixture's own DIRECT
//! CONTRIBUTION at the player using the shaders' profile ([`fixture_score`]), (b) hysteretic
//! (a challenger must beat an incumbent by [`TORCH_SWAP_RATIO`], one swap per
//! [`TORCH_SWAP_COOLDOWN`]), and (c) CROSS-FADED — every slot carries a weight the receivers
//! `mix` with, so a promotion ramps in and a demotion ramps out before the slot is reused. The
//! budget is [`MAX_TORCH_CASTERS`] slots, of which `interiorShadowCasters` (default 12) are filled.
//!
//! Gated on `interiorLight` + `interiorShadows` ([`VideoConfig`]); declares demand so the shared rig
//! stays up. Remove this module + its plugin + the `interiorShadows` cvar and the other lanes are
//! untouched. (Phase 1 receiver = static_gx WMO surfaces only; wow_model/entities/terrain are later.)
//! untouched. MONKEY (static torch cache): static_gx and wow_model share the 6416-byte table.
//! Camera distance must be <= ShadowDistance, AND player distance <= 4R+3. Contribution ranking,
//! 1.5x hysteresis and fades choose up to twelve of sixteen resident slots; this is still a finite
//! budget, not a promise that a room with forty eligible candles can give all forty cube maps.
//! Rigid furniture joins the cached static geometry. Only the nearest interiorShadowDynamic
//! promoted fixtures (default four, measured from the player) overlay creatures/animated parts.
use std::hash::{Hash, Hasher};
use bevy::math::{DMat4, DVec3, DVec4};
use bevy::prelude::*;
@@ -22,33 +41,82 @@ use bevy::render::extract_resource::ExtractResourcePlugin;
use bevy::pbr::MeshMaterial3d;
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::coords::wow_to_bevy;
use benilla_formats::ModelBlend;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::PickMesh;
use benilla_world::lighting::LightRooms;
use benilla_world::model_render::{ModelPart, ShadowOccluder};
use benilla_world::lighting::{
interior_reach, m2_light_reach, DynamicInteriors, FireLightGain, LightLane, LightLitRooms,
LightReach, LightRooms, ShadowDistance, SyntheticFireLight,
};
// MONKEY (carried light stability): the settle verdict a carried light earns by standing still.
use crate::entities::CarriedLightMotion;
use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
use benilla_world::static_gx::{StaticGx, TorchShadowViews};
use benilla_world::view::WorldCamera;
use crate::char_select::ClientState;
use crate::net::SelfPlayer;
use crate::shadow_core::{
collect_entity_geometry, empty_shadow_mesh, restore_mesh_buffers, take_mesh_buffers,
ShadowDemand, ShadowFrame, ShadowSet, STATIC_REBUILD_STEP,
empty_shadow_mesh, restore_mesh_buffers, take_mesh_buffers,
ShadowDemand, ShadowFrame, ShadowSet,
};
use crate::video::VideoConfig;
/// How many interior fixtures cast a depth map at once. The nearest N to the camera win, so the room
/// you are in casts. Matches `torch_depth::MAX_TORCH_MAPS`.
const MAX_TORCH_CASTERS: usize = 4;
/// How far from the camera to look for an interior fixture to promote (yd).
const TORCH_SEARCH_RADIUS: f32 = 55.0;
/// The map's shadow-cast range / perspective far plane (yd) — matches the WMO fixture range.
/// How many interior fixtures can hold a depth map at once — the ALLOCATION. Matches
/// `torch_depth::MAX_TORCH_MAPS`; the number actually filled is the live `interiorShadowCasters`
/// cvar (default 12), so raising the cap does not by itself cost a frame.
const MAX_TORCH_CASTERS: usize = 16;
/// MONKEY (torch caster reach): promote out to FOUR times the fixture's effective reach. Its
/// pool still needs shadows when viewed across the room, long before it lights the player's body.
const TORCH_ELIGIBLE_REACH_MULT: f32 = 4.0;
/// MONKEY (static torch cache): minimum coarse CAMERA search; the live search is
/// max(TORCH_SEARCH_RADIUS, ShadowDistance), so it never truncates the shadow-distance setting.
const TORCH_SEARCH_RADIUS: f32 = TORCH_ELIGIBLE_REACH_MULT * TORCH_RANGE + TORCH_ELIGIBLE_SLACK;
/// MONKEY (torch caster reach): extra eligibility slack beyond the expanded 4R window (yd).
const TORCH_ELIGIBLE_SLACK: f32 = 3.0;
/// MONKEY (torch caster selection): a challenger must beat the weakest incumbent by this FACTOR
/// before it may take its slot. Pure "best N" thrashes: two candles a step apart trade the lead
/// every few frames as you walk between them, and each trade used to be an instant on/off. 1.5 is
/// wide enough that a real change of room wins immediately and a step sideways never does.
const TORCH_SWAP_RATIO: f32 = 1.5;
/// MONKEY (torch caster selection): and at most ONE contested swap this often (s). The ratio alone
/// still permits a cascade — six slots could each lose their contest in the same frame, which is
/// six shadows changing at once no matter how smooth each fade is.
const TORCH_SWAP_COOLDOWN: f64 = 0.25;
/// MONKEY (torch caster selection): the cross-fade rate (weight per second) — ~1/3 s to ramp a
/// shadow fully in or out. Slow enough to read as "the light reaches you now", fast enough that
/// the geometry is not visibly shadow-less while you stand in front of it.
const TORCH_FADE_RATE: f32 = 3.0;
/// MONKEY (torch caster selection): the interior direct-light profile, MIRRORED from the
/// `interior_room_light` block that both `static_gx.wgsl` and `wow_model.wgsl` carry ("MONKEY (soft
/// falloff)" — keep the three in sync). Ranking by the shader's OWN contribution is the whole point
/// of this change: distance ranked a bright forge behind you below a dead candle at your feet.
const INTERIOR_CORE_FRAC: f32 = 0.26;
const INTERIOR_CORE_GAIN: f32 = 1.5;
const INTERIOR_DIRECT_POW: f32 = 10.0;
/// MONKEY (torch caster reach): unchanged fixture-centred far plane. `interior_reach` caps R at
/// 48 yd; seeing that pool from 4R away needs earlier promotion, not a longer projection.
const TORCH_RANGE: f32 = 48.0;
/// MONKEY (Phase 3B): how far from the camera (yd) an ENTITY — furniture, NPC, the player — is
/// gathered into the per-frame torch caster. Covers the farthest promoted fixture's range.
const ENTITY_REACH: f32 = 60.0;
/// MONKEY (Phase 5): six cube faces per fixture. Keep in sync with `torch_depth::CUBE_FACES`.
const CUBE_FACES: usize = 6;
/// MONKEY (static torch cache): how many resident slots have their source FINGERPRINT recomputed
/// per frame. Hashing one fixture's 48 yd source set walks every resident `GxItem` and every model
/// part; doing that for all twelve residents every frame hands the CPU back the cost the GPU cache
/// just saved (~12 full scene scans per frame, and it is a per-frame cost even in a room where
/// nothing ever changes). Four slots, cursor-rotated, still notices any streaming change within
/// three frames — under a tenth of a second, and the map it then rebuilds is the same one.
const TORCH_SCAN_PER_FRAME: usize = 4;
/// MONKEY (static torch cache): how far (yd²) a fixture may drift from the position its cached map
/// was rendered at before that map is WITHDRAWN. A cached map that is merely STALE (the room
/// streamed a chair in since it was baked) keeps being published — the shadow is a few frames out
/// of date, which is invisible. A map whose fixture MOVED is not stale but WRONG: every projection
/// in it aims from somewhere else, and the published `view_projs` (rebuilt from the live position
/// each frame) no longer address it. Withdrawing a merely-stale map instead was a hard on/off
/// blink: an unready slot's `positions[i].w` is forced to 0 with no fade — precisely the pop the
/// whole cross-fade exists to prevent — for however many frames the rebuild budget takes to reach it.
const TORCH_STALE_DRIFT_SQ: f32 = 0.01;
/// Each cube face's FOV: 90° plus a hair, so a direction exactly on a face border still lands
/// inside the face the shader picks for it (the projector returns "lit" outside its frustum).
const TORCH_FACE_FOV: f32 = std::f32::consts::FRAC_PI_2 + 0.02;
@@ -65,15 +133,137 @@ impl Plugin for TorchShadowPlugin {
}
}
/// The torch lane's retained state: the interior caster mesh (rebuilt on camera drift) and the
/// camera position it was last built at.
/// MONKEY (torch caster selection): one OCCUPIED caster slot — a fixture that holds (or is
/// releasing) six depth-map layers. Slots are retained across frames, which is what makes
/// hysteresis and cross-fading possible at all: the old lane recomputed "the nearest four" from
/// scratch every frame and so had no notion of an incumbent to keep.
struct TorchSlot {
// MONKEY (static torch cache): physical residency survives Vec compaction and rank changes.
cache_slot: usize,
mesh: Option<Handle<Mesh>>,
geometry_key: Option<u64>,
/// MONKEY (static torch cache): the fixture position `mesh` was actually gathered AT, which is
/// not necessarily `pos` — the cursor scans only [`TORCH_SCAN_PER_FRAME`] slots a frame, so a
/// slot can be published several frames after it last rebuilt. `None` until the first build.
/// See [`TORCH_STALE_DRIFT_SQ`] for why staleness publishes and drift does not.
built_at: Option<Vec3>,
/// The fixture entity — the identity the slot is held BY. Matching on entity rather than on
/// position is what lets a CARRIED torch (which moves every frame) keep its slot.
fixture: Entity,
/// Its world position this frame (absolute Bevy), the shader's correlation key.
pos: Vec3,
/// Its contribution score at the player this frame (see [`fixture_score`]).
score: f32,
/// The cross-fade weight, published as `positions[i].w` and applied as `mix(1, shadow, w)` by
/// both receivers.
w: f32,
/// Set once the slot has lost its contest (or its fixture went out of reach, or the budget
/// shrank). It keeps rendering while `w` ramps down, and the slot is freed for a challenger
/// only when `w` reaches 0 — "fade, not switch".
evicting: bool,
}
/// MONKEY (static torch cache): stable physical slots and per-fixture source meshes.
/// Camera drift never rebuilds static geometry; only a source-set/fixture change does.
#[derive(Resource, Default)]
struct TorchLane {
caster_mesh: Option<Handle<Mesh>>,
caster_at: Option<Vec3>,
/// MONKEY (Phase 3B): the per-frame ENTITY caster (furniture, NPCs, the player) — rebuilt every
/// frame because entities move; the static `caster_mesh` only rebuilds on camera drift.
// MONKEY (static torch cache): only moving casters are rebuilt every frame. Each slot owns
// its own static geometry handle; a replacement gets a NEW asset id, so the render world
// cannot certify an old GPU mesh as the newly streamed geometry while uploads catch up.
entity_mesh: Option<Handle<Mesh>>,
rebuild_cursor: usize,
/// MONKEY (torch caster selection): the occupied slots, at most `interiorShadowCasters` of them
/// (plus any still fading out after the budget shrank). Published in this order.
slots: Vec<TorchSlot>,
/// When the last CONTESTED swap was started (seconds of `Time::elapsed`), for the cooldown.
last_swap: f64,
}
/// MONKEY (torch caster selection): the interior direct term this fixture delivers at distance `d`
/// — the shaders' own profile (core inverse-square with a soft core, times the C1 window), times
/// the fixture's luminous intensity. This is the ranking key, and it is the answer to the bug: at
/// Northshire's candelabra spacing "nearest 4" and "the 4 that light you" are different sets, and
/// only the second one is stable as you walk, because a fixture's contribution changes smoothly
/// with distance while its RANK by distance changes in steps.
///
/// MONKEY (torch caster reach): retain the intensity and soft inverse-square core at the player's
/// distance, but stretch the selection window to 4R (including eligibility slack). This is a
/// ranking extension, not a lighting change: at 3R the score is small, positive and still falling.
/// No window floor flattens distant candidates; the window reaches zero at the eligibility edge.
fn fixture_score(intensity: f32, d: f32, r: f32) -> f32 {
let r0 = (INTERIOR_CORE_FRAC * r).max(1e-3);
let atten = INTERIOR_CORE_GAIN / (1.0 + (d / r0) * (d / r0));
let extent = TORCH_ELIGIBLE_REACH_MULT * r + TORCH_ELIGIBLE_SLACK;
let w = (1.0 - (d / extent.max(1e-4)).clamp(0.0, 1.0).powf(INTERIOR_DIRECT_POW)).clamp(0.0, 1.0);
intensity * atten * w * w
}
/// MONKEY (review fixes): rank only light the interior receivers actually sample. Match the
/// packer's unresolved-lane fallback AND its synthetic-only gain; a disabled flame or a resolved
/// exterior fixture must score zero before it can evict a useful caster. Reach still uses the
/// authored intensity, so dimming a flame changes its contribution without moving its pool.
fn candidate_score(
intensity: f32, d: f32, r: f32, lane: Option<&LightLane>, has_rooms: bool,
synthetic: bool, fire_gain: f32,
) -> f32 {
if !lane.map_or(has_rooms, |l| l.interior) {
return 0.0;
}
fixture_score(intensity, d, r) * if synthetic { fire_gain.max(0.0) } else { 1.0 }
}
/// MONKEY (static torch cache): camera distance uses the world-shadow resource's Bevy-yard
/// yardstick. The player-centred 4R+3 window still limits/ranks useful pools; BOTH tests apply.
/// The coarse camera search grows with shadowDistance so it can never silently shorten that dial.
fn fixture_eligible(player_d: f32, camera_d: f32, r: f32, distance: f32) -> bool {
camera_d <= TORCH_SEARCH_RADIUS.max(distance)
&& camera_d <= distance
&& player_d <= TORCH_ELIGIBLE_REACH_MULT * r + TORCH_ELIGIBLE_SLACK
}
/// MONKEY (static torch cache): may a slot publish its cached map this frame? Yes while the
/// fixture still stands where the map was baked from (see [`TORCH_STALE_DRIFT_SQ`]) — staleness in
/// the SURROUNDINGS is invisible for the few frames a rebuild takes, whereas withdrawing the map
/// zeroes the slot's weight with no fade. No when the fixture has moved: the six projections are
/// republished from the live position every frame and would no longer address that depth.
fn map_publishable(built_at: Option<Vec3>, pos: Vec3) -> bool {
built_at.is_some_and(|p| p.distance_squared(pos) <= TORCH_STALE_DRIFT_SQ)
}
/// MONKEY (static torch cache): the nearest promoted fixtures to the player receive entities.
/// Stable physical-slot tie breaking keeps coincident/equidistant candles deterministic; this
/// subset is independent of contribution ranking and never moves the resident static maps.
fn dynamic_set(fixtures: &[(usize, Vec3)], anchor: Vec3, budget: usize) -> u32 {
let mut nearest = fixtures.to_vec();
nearest.sort_by(|a, b| a.1.distance_squared(anchor).total_cmp(&b.1.distance_squared(anchor))
.then(a.0.cmp(&b.0)));
nearest.iter().take(budget.min(MAX_TORCH_CASTERS)).fold(0, |mask, (i, _)| mask | (1 << i))
}
/// MONKEY (torch caster selection): the entity-caster query trio, bundled as ONE `SystemParam`.
/// Not tidiness — [`update_torch_shadows`] sat at 15 of Bevy's 16 system params before this change
/// added the player anchor and the interior knobs, and three separate members would not compile.
#[derive(bevy::ecs::system::SystemParam)]
struct EntityCasters<'w, 's> {
/// The SAME query the world lane passes to `collect_entity_geometry` (written inline by that
/// contract so a lane can pass its own).
parts: Query<
'w,
's,
(
Entity,
&'static PickMesh,
&'static ModelPart,
Option<&'static GlobalTransform>,
Option<&'static RigPart>,
&'static ShadowOccluder,
Option<&'static MeshMaterial3d<WowModelMaterial>>,
),
Without<BillboardCard>,
>,
rigs: Query<'w, 's, &'static RigSkin>,
palettes: Res<'w, RigPalettes>,
distance: Res<'w, ShadowDistance>,
}
/// The lane is active when the dynamic-interior lane is on AND its shadow toggle is on.
@@ -128,24 +318,33 @@ fn update_torch_shadows(
mut meshes: ResMut<Assets<Mesh>>,
mut views: ResMut<TorchShadowViews>,
cameras: Query<&GlobalTransform, With<WorldCamera>>,
// Interior WMO fixtures: a `PointLight` carrying a room claim.
torches: Query<&GlobalTransform, (With<PointLight>, With<LightRooms>)>,
gx: Option<Res<StaticGx>>,
// MONKEY (Phase 3B): the entity caster inputs — the SAME query the world lane passes to
// `collect_entity_geometry` (written inline by that contract so a lane can pass its own).
parts: Query<
(
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
Option<&RigPart>,
&ShadowOccluder,
Option<&MeshMaterial3d<WowModelMaterial>>,
),
Without<BillboardCard>,
// MONKEY (torch caster selection): the RANKING ANCHOR. Shadows are for the player's own
// surroundings — a free-look camera swung across the room must not re-pick the casters — so
// scoring is done at the body, and only falls back to the camera when there is none (the glue
// scene, a capture fixture, the frame before the player streams in).
player: Query<&GlobalTransform, With<SelfPlayer>>,
// Interior WMO fixtures: a `PointLight` carrying a room claim. MONKEY (torch caster selection):
// the intensity/colour and the authored reach ride along now — the score is the light's own
// contribution, so the lane must read exactly what the packer reads.
// MONKEY (review fixes): containment is membership evidence even with no MOLR; keep the
// visibility membership separate so the missing-lane fallback stays identical to the packer.
// MONKEY (carried light stability): `Has<ChildOf>` + `Option<&CarriedLightMotion>` ride the
// tuple so the filter below can refuse a MOVING carried light a slot. Both are cheap table
// reads on a query that already walks these entities.
torches: Query<
(Entity, &GlobalTransform, &PointLight, Option<&LightReach>, Option<&LightLane>,
Has<LightRooms>, Has<SyntheticFireLight>, Has<ChildOf>,
Option<&crate::entities::CarriedLightMotion>),
Or<(With<LightRooms>, With<LightLitRooms>)>,
>,
rigs: Query<&RigSkin>,
palettes: Res<RigPalettes>,
// MONKEY (torch caster selection): `atten_scale` — a fixture's EFFECTIVE radius is its authored
// end times this live cvar, and the score's window is a fraction of that radius. Reading the
// same resource the packer folds into the shader's `.w` keeps ranking and rendering agreed.
interiors: Res<DynamicInteriors>,
fire_gain: Res<FireLightGain>,
gx: Option<Res<StaticGx>>,
// MONKEY (Phase 3B): the entity caster inputs, bundled (see [`EntityCasters`]).
ents: EntityCasters,
time: Res<Time>,
mut last_trace: Local<f64>,
) {
@@ -161,113 +360,475 @@ fn update_torch_shadows(
return;
};
// (1) The N nearest interior fixtures to the camera, nearest first.
let mut scored: Vec<(f32, Vec3)> = torches
// (1) MONKEY (torch caster selection): who casts. Three rules, in this order.
//
// RANK BY CONTRIBUTION, NOT DISTANCE. The old lane took the four fixtures nearest the camera
// and hard-switched the set every frame. In a candle-dense room that set churns every few
// steps — Northshire Abbey authors 42 candelabra about 5 yd apart — and every fixture NOT
// in it lit with no shadow at all, so shadows visibly popped in as you approached a candle
// and out again as you passed it. Scoring by the light's own direct term at the PLAYER makes
// the ranking a smooth function of position instead of a step function, and makes it right:
// a bright forge across the room outranks a guttering candle at your feet, which is what the
// eye expects of a shadow.
//
// HYSTERESIS. A smooth score still crosses. An incumbent keeps its slot unless a challenger
// beats it by TORCH_SWAP_RATIO, and at most one contested swap starts per
// TORCH_SWAP_COOLDOWN — so walking the candle corridor does not trade slots every step.
//
// FADE, NOT SWITCH. A promoted slot ramps its weight 0 to 1; a losing one ramps 1 to 0 and is
// only REPLACED at 0 (it keeps rendering while it fades). The receivers apply the weight as
// `mix(1, shadow, w)`, so a shadow dissolves rather than blinking.
let anchor = player
.iter()
.filter_map(|t| {
let p = t.translation();
let d2 = p.distance_squared(cam);
(d2 <= TORCH_SEARCH_RADIUS * TORCH_SEARCH_RADIUS).then_some((d2, p))
.next()
.map(GlobalTransform::translation)
.unwrap_or(cam);
let want = (video.interior_shadow_casters as usize).clamp(1, MAX_TORCH_CASTERS);
let dt = time.delta_secs();
let now = time.elapsed_secs_f64();
// (1a) MONKEY (torch caster reach): each pool is eligible from 4R + slack away. Rank with the
// same soft core/intensity so nearby contributors still win over pools seen across the room.
let mut cands: Vec<(Entity, Vec3, f32)> = torches
.iter()
.filter_map(|(e, gt, pl, reach, light_lane, has_rooms, synthetic, carried, motion)| {
// MONKEY (carried light stability): a CARRIED light (`ChildOf` — a pet's hand flame,
// an NPC's torch, a transport's deck brazier) casts only while it is STANDING STILL.
//
// This whole lane caches a depth cube per fixture and keeps it only while the fixture
// stays within `TORCH_STALE_DRIFT_SQ` of where it was baked ([`map_publishable`]); a
// slot whose map is withdrawn has its cross-fade weight forced to 0 with NO ramp
// (`static_gx::torch_depth`'s `TorchTable::pack`). A moving fixture therefore toggled
// a full-strength shadow at frame rate — the reported epileptic pool around a
// summoned imp — and consumed the lane's global two-rebuilds-a-frame budget re-baking
// a mesh that was stale again on arrival, so the STATIC candles beside it flickered too.
//
// Gated on MOTION rather than excluded outright, because the stationary members of the
// same `ChildOf` family are exactly what the entity half of this lane was built for: a
// placed brazier GameObject, a campfire. They settle in well under a second and cast
// like a MOLT fixture. And because this is a CANDIDACY verdict, an incumbent that
// starts walking drops out of `cands` and is cross-faded out by (1b)/(1c) rather than
// cut — "fade, not switch" holds for the eviction as much as for a swap.
if carried && !motion.is_some_and(CarriedLightMotion::settled) {
return None;
}
let p = gt.translation();
let d = p.distance(anchor);
if p.distance(cam) > TORCH_SEARCH_RADIUS.max(ents.distance.0) {
return None;
}
// The packer's own recovery of authored colour x intensity (`spawn_point_light`
// premultiplied 4*pi), and the packer's own reach: the authored MOLT end where there is
// one, the intensity bucket otherwise, times the live `interiorAttenScale`.
let base = pl.intensity / (4.0 * std::f32::consts::PI);
let authored = reach
.map(|r| r.0)
.filter(|r| *r > 0.5)
.unwrap_or_else(|| m2_light_reach(base));
let r = interior_reach(authored, interiors.atten_scale);
if !fixture_eligible(d, p.distance(cam), r, ents.distance.0) {
return None;
}
let c = pl.color.to_linear();
// Rec.709 luminance of the committed colour: ONE scalar for "how much light is this",
// so a dim blue magic brazier cannot outrank a bright hearth on channel count.
let lum = (0.2126 * c.red + 0.7152 * c.green + 0.0722 * c.blue).max(0.0) * base.max(0.0);
let score = candidate_score(lum, d, r, light_lane, has_rooms, synthetic, fire_gain.0);
(score > 0.0).then_some((e, p, score))
})
.collect();
scored.sort_by(|a, b| a.0.total_cmp(&b.0));
scored.truncate(MAX_TORCH_CASTERS);
let nearest: Vec<Vec3> = scored.into_iter().map(|(_, p)| p).collect();
cands.sort_by(|a, b| b.2.total_cmp(&a.2));
// (2) Build/refresh the interior caster mesh (ALWAYS when the lane is on — the depth node needs
// it regardless of `worldShadows`). Rebuilt from the same retained `StaticGx` geometry the world
// lane uses, on camera drift.
if let Some(gx) = gx.as_ref() {
if lane.caster_mesh.is_none() {
lane.caster_mesh = Some(meshes.add(empty_shadow_mesh()));
lane.caster_at = None;
// (1b) Refresh the incumbents against this frame's candidates. A fixture that dropped out of
// the set (it moved away, despawned, its room streamed out) starts fading rather than
// vanishing. A slot past the live budget does the same, so lowering `interiorShadowCasters`
// fades its extra shadows out instead of cutting them.
for (i, slot) in lane.slots.iter_mut().enumerate() {
match cands.iter().find(|(e, _, _)| *e == slot.fixture) {
Some((_, p, sc)) => {
slot.pos = *p;
slot.score = *sc;
}
None => {
slot.score = 0.0;
slot.evicting = true;
}
}
let due = lane
.caster_at
.is_none_or(|at| at.distance(cam) > STATIC_REBUILD_STEP);
if due {
if let Some(handle) = lane.caster_mesh.clone() {
if let Some(mesh) = meshes.get_mut(&handle) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
gx.append_shadow_triangles(cam, TORCH_RANGE, &mut positions, &mut indices);
restore_mesh_buffers(mesh, positions, indices);
lane.caster_at = Some(cam);
}
if i >= want {
slot.evicting = true;
}
}
// (1c) Ramp the weights, then release the slots that finished fading out.
let step = TORCH_FADE_RATE * dt;
for slot in lane.slots.iter_mut() {
slot.w = if slot.evicting {
(slot.w - step).max(0.0)
} else {
(slot.w + step).min(1.0)
};
}
lane.slots.retain(|s| {
let release = s.evicting && s.w <= 0.0;
if release {
if let Some(mesh) = &s.mesh { meshes.remove(mesh.id()); }
}
!release
});
// (1d) Fill free slots from the best unassigned candidates. A newly filled slot starts at
// w = 0 and fades IN. Note this can only run once a fading slot has fully drained, which is
// what stops a churn from being visible even when the SELECTION churns.
// MONKEY (static torch cache): at most two new residents per frame, including startup.
let mut promotions = 0;
while lane.slots.len() < want && promotions < 2 {
let Some(&(e, p, sc)) = cands
.iter()
.find(|(e, _, _)| !lane.slots.iter().any(|s| s.fixture == *e))
else {
break;
};
let cache_slot = (0..MAX_TORCH_CASTERS)
.find(|i| !lane.slots.iter().any(|s| s.cache_slot == *i)).unwrap();
promotions += 1;
lane.slots.push(TorchSlot {
cache_slot,
mesh: None,
geometry_key: None,
built_at: None,
fixture: e,
pos: p,
score: sc,
w: 0.0,
evicting: false,
});
}
// (1e) The contested swap: the best unassigned candidate takes the weakest incumbent's slot
// only if it beats it by the ratio AND the cooldown has elapsed. Marking `evicting` rather
// than replacing is the whole of "fade, not switch" — (1c) drains it and (1d) refills it a
// fade later, by which time the challenger has been re-scored at the player's new position.
if now - lane.last_swap >= TORCH_SWAP_COOLDOWN {
let weakest = lane
.slots
.iter()
.enumerate()
.filter(|(_, s)| !s.evicting)
.min_by(|a, b| a.1.score.total_cmp(&b.1.score))
.map(|(i, s)| (i, s.score));
let challenger = cands
.iter()
.find(|(e, _, _)| !lane.slots.iter().any(|s| s.fixture == *e))
.map(|(_, _, sc)| *sc);
if let (Some((idx, weak)), Some(best)) = (weakest, challenger) {
if best > weak * TORCH_SWAP_RATIO {
lane.slots[idx].evicting = true;
lane.last_swap = now;
}
}
}
// (2b) MONKEY (Phase 3B): the per-frame ENTITY caster — every frame (entities move), the same
// collector the world lane feeds the directional map: creatures + gameobjects AND environment
// (furniture, props) within ENTITY_REACH of the camera, in absolute Bevy world space like the
// static caster, so the same torch `view_proj` projects both. A table or a character now throws
// a torch shadow onto the floor the static_gx lane already receives.
let mut ent_admitted = 0u32;
if lane.entity_mesh.is_none() {
lane.entity_mesh = Some(meshes.add(empty_shadow_mesh()));
}
if let Some(handle) = lane.entity_mesh.clone() {
if let Some(mesh) = meshes.get_mut(&handle) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
let (admitted, _rejected) = collect_entity_geometry(
&parts,
&rigs,
&palettes,
true, // creatures + gameobjects (NPCs, the player, placed chairs/tables)
true, // environment (doodads / WMO props)
cam,
ENTITY_REACH,
ENTITY_REACH,
&mut positions,
&mut indices,
);
ent_admitted = admitted;
restore_mesh_buffers(mesh, positions, indices);
}
}
// The published set, in slot order: fixture, position, score (trace only) and fade weight,
// capped at the table's allocation.
let promoted: Vec<(usize, Entity, Vec3, f32, f32)> = lane
.slots
.iter()
.take(MAX_TORCH_CASTERS)
.map(|s| (s.cache_slot, s.fixture, s.pos, s.score, s.w))
.collect();
// (3) Publish the fixtures + their down-looking matrices + both caster mesh ids for the render world.
// (2) MONKEY (static torch cache): fingerprint resident SOURCE geometry per fixture, not
// StaticGx's change tick (frame bookkeeping changes it continuously). This catches arrivals,
// removals and replacements even with a stationary camera. Entity-resident rigid furniture
// joins the static mesh; creatures and skinned/animated parts belong to the dynamic overlay.
let fixture_positions: Vec<_> = promoted.iter().map(|(i, _, p, _, _)| (*i, *p)).collect();
let dynamic = dynamic_set(&fixture_positions, anchor, video.interior_shadow_dynamic as usize);
let mut rebuilt = 0;
let mut static_rebuilt = 0u32;
let mut published = TorchShadowViews {
count: nearest.len() as u32,
caster_mesh: lane.caster_mesh.as_ref().map(Handle::id),
entity_mesh: lane.entity_mesh.as_ref().map(Handle::id),
count: promoted.iter().map(|(i, ..)| *i as u32 + 1).max().unwrap_or(0),
soft: video.interior_shadow_soft,
dynamic_mask: dynamic,
..Default::default()
};
// Six cube faces per fixture, independent of camera and player: layer = fixture * 6 + face.
for (i, p) in nearest.iter().enumerate() {
published.positions[i] = p.extend(TORCH_RANGE);
for (f, vp) in cube_view_projs(*p).into_iter().enumerate() {
// MONKEY (static torch cache): rotate service order so two moving/carried fixtures cannot
// consume the rebuild budget forever and starve a newly resident room's static maps. The
// rotation bounds the SCAN as well as the rebuild (see TORCH_SCAN_PER_FRAME): fingerprinting a
// fixture's surroundings is the one part of this lane that is O(resident scene), so doing it
// for every slot every frame would be the new cost ceiling — and at most two of those slots
// could act on the answer anyway.
let slot_count = lane.slots.len();
let start = lane.rebuild_cursor;
let scan = slot_count.min(TORCH_SCAN_PER_FRAME);
for offset in 0..scan {
let index = (start + offset) % slot_count;
let slot = &mut lane.slots[index];
let i = slot.cache_slot;
let mut hash = std::collections::hash_map::DefaultHasher::new();
slot.fixture.hash(&mut hash);
slot.pos.to_array().map(f32::to_bits).hash(&mut hash);
gx.as_ref().map(|gx| gx.torch_geometry_key(slot.pos, TORCH_RANGE)).hash(&mut hash);
let mut entity_key = 0u64;
for (entity, pick, part, global, rig, occluder, _) in &ents.parts {
if !static_part(part, rig.is_some()) || !occluder.0 { continue; }
let Some(global) = global else { continue };
if global.translation().distance_squared(slot.pos) > TORCH_RANGE * TORCH_RANGE { continue; }
// Arc identity catches geometry replacement, transform bits catch moved furniture.
let mut part_hash = std::collections::hash_map::DefaultHasher::new();
entity.hash(&mut part_hash);
(std::sync::Arc::as_ptr(&pick.0) as usize).hash(&mut part_hash);
global.to_matrix().to_cols_array().map(f32::to_bits).hash(&mut part_hash);
entity_key = entity_key.wrapping_add(part_hash.finish());
}
entity_key.hash(&mut hash);
let key = hash.finish();
let dirty = slot.geometry_key != Some(key);
if dirty && rebuilt < 2 {
let mut positions = Vec::new();
let mut indices = Vec::new();
if let Some(gx) = &gx {
gx.append_torch_triangles(slot.pos, TORCH_RANGE, &mut positions, &mut indices);
}
collect_torch_entities(&ents, true, &[(i, slot.pos)], &mut positions, &mut indices);
let mut mesh = empty_shadow_mesh();
restore_mesh_buffers(&mut mesh, positions, indices);
if let Some(old) = slot.mesh.replace(meshes.add(mesh)) { meshes.remove(old.id()); }
slot.geometry_key = Some(key);
slot.built_at = Some(slot.pos);
rebuilt += 1;
static_rebuilt |= 1 << i;
}
}
// Advance past everything scanned, not just what was rebuilt, so the window sweeps the whole
// resident set at a steady rate instead of re-examining the head of it.
lane.rebuild_cursor = (start + scan) % slot_count.max(1);
// MONKEY (static torch cache): publish EVERY slot every frame — position, fade weight and the
// six projections all move with the fixture and the cross-fade, and only the caster MESH is
// cached. A slot whose map is stale (dirty, awaiting its rebuild turn) still publishes it; a
// slot whose fixture has drifted away from where its map was baked publishes none, which forces
// its weight to zero rather than showing shadows cast from a position the light has left.
for slot in &lane.slots {
let i = slot.cache_slot;
// MONKEY (carried light stability): where this slot casts FROM this frame. Normally the
// live fixture position. A slot that has drifted off its baked map AND is on its way out
// publishes from `built_at` instead of withdrawing: withdrawing clears the slot's
// `ready_mask` bit, and `TorchTable::pack` answers that by forcing the cross-fade weight
// to 0 with no ramp — a hard shadow-off in one frame, which is the very pop the "fade,
// not switch" law of this lane exists to remove. Frozen, the shadow dissolves over
// `TORCH_FADE_RATE` from the last place it was correct; a third of a second later the
// fixture is a stride away and the shadow is already gone, which reads as a shadow being
// left behind rather than as a blink. A slot that is NOT evicting still withdraws — a
// resident fixture mid-rebuild must never show shadows cast from a position it has left.
let origin = if map_publishable(slot.built_at, slot.pos) {
Some(slot.pos)
} else {
slot.built_at.filter(|_| slot.evicting)
};
if origin.is_some() {
published.caster_meshes[i] = slot.mesh.as_ref().map(Handle::id);
}
let at = origin.unwrap_or(slot.pos);
published.positions[i] = at.extend(slot.w);
for (f, vp) in cube_view_projs(at).into_iter().enumerate() {
published.view_projs[i * CUBE_FACES + f] = vp;
}
}
// MONKEY (static torch cache): gather only around the dynamic subset, not every promoted
// fixture. A single mesh serves those N maps and contains no rigid furniture already cached.
let dynamic_positions: Vec<_> = fixture_positions.iter().copied()
.filter(|(i, _)| dynamic & (1 << i) != 0).collect();
let mut ent_admitted = 0;
if dynamic != 0 {
if lane.entity_mesh.is_none() { lane.entity_mesh = Some(meshes.add(empty_shadow_mesh())); }
if let Some(mesh) = lane.entity_mesh.as_ref().and_then(|h| meshes.get_mut(h)) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
ent_admitted = collect_torch_entities(&ents, false, &dynamic_positions, &mut positions, &mut indices);
restore_mesh_buffers(mesh, positions, indices);
}
published.entity_mesh = lane.entity_mesh.as_ref().map(Handle::id);
}
*views = published;
// `WOW_TORCH_TRACE=1` — once a second, what the lane published: fixture distances + whether a
// caster mesh exists. The app-side half of the debug (the shader group-3 sample is the GPU half).
if std::env::var_os("WOW_TORCH_TRACE").is_some() {
let now = time.elapsed_secs_f64();
if now - *last_trace >= 1.0 {
*last_trace = now;
let dists: Vec<f32> = nearest.iter().map(|p| p.distance(cam)).collect();
info!(
"torch-trace: {} maps (caster {}, {} entity casters) — fixture dists {:?}",
nearest.len(),
if lane.caster_mesh.is_some() { "built" } else { "none" },
"torch-trace: {}/{} slots of {} ({} candidates, caster {}, {} entity casters)",
promoted.len(),
want,
MAX_TORCH_CASTERS,
cands.len(),
if lane.slots.iter().any(|s| s.mesh.is_some()) {
"built"
} else {
"none"
},
ent_admitted,
dists,
);
// MONKEY (torch caster selection): per SLOT, the three numbers the selection is made
// of — which fixture, what it scores at the player, and how far its cross-fade has
// ramped. A shadow that pops is a `w` that jumped; a shadow that should be there and
// is not is a fixture missing from this list (then read `cands`, above).
// MONKEY (torch caster reach): scientific scores expose the small distant tail;
// distance is still from the player, and eligibility now extends to 4R + 3 yd.
for (i, e, p, sc, w) in &promoted {
info!(
" slot {i}: {e} at [{:.1},{:.1},{:.1}] d(player) {:.1} score {:.4e} w {:.2} (camera <= shadowDistance AND player <= 4R+3) static {} dynamic {}",
p.x,
p.y,
p.z,
p.distance(anchor),
sc,
w,
if static_rebuilt & (1 << i) != 0 { "rebuilt (GPU pending)" } else { "cached/requested" },
if dynamic & (1 << i) != 0 { "yes" } else { "no" },
);
}
}
}
}
// MONKEY (static torch cache): a rig can deform even while its root stands still. Cache only
// rigid non-creature parts; animated furniture follows the same nearest-N policy as characters.
fn static_part(part: &ModelPart, rigged: bool) -> bool {
part.kind != ModelKind::Creature && !rigged && part.blend == ModelBlend::Opaque
}
fn collect_torch_entities(
ents: &EntityCasters, want_static: bool, fixtures: &[(usize, Vec3)],
positions: &mut Vec<[f32; 3]>, indices: &mut Vec<u32>,
) -> u32 {
let mut admitted = 0;
for (_, pick, part, global, rig_part, occluder, _) in &ents.parts {
if !occluder.0 || static_part(part, rig_part.is_some()) != want_static { continue; }
let solid = part.blend == ModelBlend::Opaque
|| (part.kind == ModelKind::Creature && part.blend == ModelBlend::AlphaTest);
if !solid { continue; }
let rig = rig_part.and_then(|r| ents.rigs.get(r.0).ok());
let origin = global.map(GlobalTransform::translation)
.or_else(|| rig.and_then(|r| ents.palettes.slot_origin(r.slot)));
let Some(origin) = origin else { continue };
if !fixtures.iter().any(|(_, p)| p.distance_squared(origin) <= TORCH_RANGE * TORCH_RANGE) { continue; }
let base = positions.len() as u32;
if let Some(rig) = rig {
let Some(palette) = ents.palettes.world_palette(rig.slot, rig.bones() as usize) else { continue };
for (v, p) in pick.0.positions.iter().enumerate() {
let p = wow_to_bevy(*p);
let (Some(joints), Some(weights)) = (pick.0.joints.get(v), pick.0.weights.get(v)) else {
positions.push(palette.first().map(|m| m.transform_point3(p)).unwrap_or(origin).to_array());
continue;
};
let mut skinned = Vec3::ZERO;
for lane in 0..4 {
if weights[lane] > 0.0 {
if let Some(m) = palette.get(joints[lane] as usize) {
skinned += m.transform_point3(p) * weights[lane];
}
}
}
positions.push(skinned.to_array());
}
} else if rig_part.is_none() {
if let Some(global) = global {
positions.extend(pick.0.positions.iter().map(|p| global.transform_point(wow_to_bevy(*p)).to_array()));
}
}
let added = positions.len() as u32 - base;
if added == 0 { continue; }
for tri in pick.0.indices.chunks_exact(3) {
if tri.iter().all(|i| *i < added) { indices.extend(tri.iter().map(|i| base + *i)); }
}
admitted += 1;
}
admitted
}
/// Tear down the caster mesh and clear the published views (the lane went off, or we left the world).
fn teardown(lane: &mut TorchLane, meshes: &mut Assets<Mesh>, views: &mut TorchShadowViews) {
if let Some(handle) = lane.caster_mesh.take() {
meshes.remove(handle.id());
for slot in &mut lane.slots {
if let Some(handle) = slot.mesh.take() { meshes.remove(handle.id()); }
}
if let Some(handle) = lane.entity_mesh.take() {
meshes.remove(handle.id());
}
lane.caster_at = None;
if let Some(handle) = lane.entity_mesh.take() { meshes.remove(handle.id()); }
// MONKEY (torch caster selection): the slot table goes with them — an incumbent kept across a
// teardown would come back at full weight in a room it is no longer in.
lane.slots.clear();
*views = TorchShadowViews::default();
}
// MONKEY (torch caster reach): regression boundaries and the unfloored distant ranking tail.
#[cfg(test)]
mod tests {
use super::*;
// MONKEY (review fixes): useful interior casters win regardless of an exterior fixture's
// brightness; the fire dial scales only invented sources, including its live kill switch.
#[test]
fn candidates_follow_the_packed_lane_and_fire_gain() {
let interior = LightLane::carried(true);
let exterior = LightLane::carried(false);
let authored = candidate_score(1.0, 10.0, 10.0, Some(&interior), true, false, 0.0);
assert!(authored > 0.0);
assert_eq!(candidate_score(100.0, 1.0, 10.0, Some(&exterior), true, false, 1.0), 0.0);
for gain in [0.0, -1.0] {
assert_eq!(candidate_score(100.0, 1.0, 10.0, Some(&interior), true, true, gain), 0.0);
}
assert_eq!(candidate_score(1.0, 10.0, 10.0, Some(&interior), true, true, 0.5), authored * 0.5);
// No MOLR is needed once a containment-only fixture resolves interior; before resolution
// the old LightRooms fallback is shared with build_light_data, in both directions.
assert_eq!(candidate_score(1.0, 10.0, 10.0, Some(&interior), false, false, 0.0), authored);
assert_eq!(candidate_score(1.0, 10.0, 10.0, None, true, false, 0.0), authored);
assert_eq!(candidate_score(1.0, 10.0, 10.0, None, false, false, 0.0), 0.0);
}
#[test]
fn eligibility_covers_four_reaches_with_slack() {
for r in [7.0, 15.0, 48.0] {
assert!(fixture_eligible(3.5 * r, 80.0, r, 80.0));
assert!(fixture_eligible(4.0 * r + 3.0, 80.0, r, 80.0));
assert!(!fixture_eligible(4.2 * r + 3.0, 80.0, r, 80.0));
}
assert!(!fixture_eligible(1.0, 80.01, 48.0, 80.0));
assert!(fixture_eligible(1.0, 250.0, 48.0, 300.0));
assert!(!fixture_eligible(32.0, 1.0, 7.0, 80.0));
assert!(!fixture_eligible(1.0, f32::NAN, 48.0, 80.0));
}
// MONKEY (static torch cache): sparse physical slots, tie stability and live shrink/off.
#[test]
fn dynamic_subset_is_nearest_and_deterministic() {
let fixtures = [(15, Vec3::X), (8, -Vec3::X), (2, Vec3::X * 20.0), (4, Vec3::ZERO)];
assert_eq!(dynamic_set(&fixtures, Vec3::ZERO, 2), (1 << 4) | (1 << 8));
assert_eq!(dynamic_set(&fixtures, Vec3::X * 20.0, 1), 1 << 2);
assert_eq!(dynamic_set(&fixtures, Vec3::ZERO, 0), 0);
assert_eq!(dynamic_set(&fixtures, Vec3::ZERO, 16).count_ones(), 4);
}
// MONKEY (static torch cache): a never-built slot publishes nothing; a merely STALE map keeps
// being published (its shadow must not blink off while the rebuild cursor reaches it); a map
// whose fixture has MOVED is withdrawn, because the projections no longer address it.
#[test]
fn a_cached_map_survives_staleness_but_not_fixture_drift() {
assert!(!map_publishable(None, Vec3::ZERO));
assert!(map_publishable(Some(Vec3::ZERO), Vec3::ZERO));
assert!(map_publishable(Some(Vec3::ZERO), Vec3::X * 0.09));
assert!(!map_publishable(Some(Vec3::ZERO), Vec3::X * 0.2));
assert!(!map_publishable(Some(Vec3::ZERO), Vec3::splat(10.0)));
}
#[test]
fn distant_scores_stay_positive_and_ordered() {
let r = 10.0;
let near = fixture_score(1.0, r, r);
let far = fixture_score(1.0, 3.0 * r, r);
let farther = fixture_score(1.0, 3.5 * r, r);
let slack = fixture_score(1.0, 4.0 * r + 2.0, r);
assert!(near > far && far > farther && farther > slack && slack > 0.0);
assert!(far < near * 0.2);
assert_eq!(fixture_score(0.0, 3.0 * r, r), 0.0);
assert_eq!(fixture_score(1.0, 4.0 * r + 3.0, r), 0.0);
assert!(fixture_score(2.0, 3.0 * r, r) > far);
}
}
+47
View File
@@ -234,12 +234,47 @@ pub(crate) struct VideoConfig {
pub(crate) interior_ambient: f32,
pub(crate) interior_fill: f32,
pub(crate) interior_exposure: f32,
/// MONKEY (soft falloff): live scale on every interior fixture's AUTHORED attenuation window
/// (`interiorAttenScale`, 0..8) — the fixture's EFFECTIVE RADIUS is `authored end × this`. A
/// WMO MOLT record's `+0x2c` (an M2 source buckets by intensity, its authored pair being a
/// template default rather than a reach) is a "full brightness ends here" number, not a
/// "nothing past here" one, so `1` gave a hard-edged disc at exactly the authored end. The
/// default is **2.5**: a 5 yd candle now tails smoothly out to 12.5, reading ~⅓ of its 1 yd
/// brightness at the authored 5 and ~8 % at 10. `0` still means "no window" (the 48 yd lane).
/// Bridged to benilla-world's `DynamicInteriors::atten_scale`, which the light packer folds
/// into each interior entry's packed radius, so the dial moves the frame it changes.
pub(crate) interior_atten_scale: f32,
/// MONKEY (torch shadows, Stage B): whether interior fixtures cast real shadows (the nearest few
/// promoted to cube-map casters — `torch_shadow`). Only meaningful with `interior_light` on.
pub(crate) interior_shadows: bool,
/// MONKEY (static torch cache): resident fixture budget (1..16, default 12). Static
/// geometry renders only on promotion/residency changes; lowering this fades extra slots out.
pub(crate) interior_shadow_casters: u32,
/// MONKEY (static torch cache): nearest promoted fixtures with per-frame entity overlays
/// (0..16, default 4). Zero keeps all static shadows and disables only the moving casters.
pub(crate) interior_shadow_dynamic: u32,
/// MONKEY (torch caster selection): the PCF tap-radius scale for the torch maps
/// (`interiorShadowSoft`, 0.5..3, default 1). A candle cluster casts many hard-edged
/// overlapping shadows; widening the 4-tap kernel is the cheap softening. Rides the torch
/// table's `count.y` (as `x100`) rather than a `DynamicInteriors` field, because it belongs
/// to the shadow table's own bytes.
pub(crate) interior_shadow_soft: f32,
/// MONKEY (room gate): whether an interior fixture may only light the ROOMS IT CLAIMS
/// (`interiorRoomGate`, default on). Off = the pre-gate behaviour, where every interior fixture
/// in range lights every interior surface in range and the only occlusion is the handful of
/// promoted cube-shadow casters — the live A/B for "did the gate darken this room, or was it
/// always unlit?". Bridged to benilla-world's `DynamicInteriors::room_gate`, which the light
/// packer applies at PACK time (an ungated pack is one `count = 0` head per light), so it moves
/// the frame it changes and costs the shader nothing.
pub(crate) interior_room_gate: bool,
/// MONKEY (interior debug): the interior-lane diagnostic overlay (`interiorDebug`, 0..3). See
/// [`benilla_world::lighting::DynamicInteriors::debug`].
pub(crate) interior_debug: u32,
/// MONKEY (fire GO lights): gain on every light SYNTHESISED from a fire prop's flame emitter
/// (`fireLightGain`, 0..4; `0` = the invented-light lane off). Bridged to benilla-world's
/// [`benilla_world::lighting::FireLightGain`] by `dynamic_interior`, and applied at PACK time
/// so it is live.
pub(crate) fire_light_gain: f32,
pub(crate) display: DisplayMode,
/// The windowed size, `gxResolution`. Kept while fullscreen so leaving it can restore it.
pub(crate) windowed: UVec2,
@@ -257,8 +292,18 @@ impl Default for VideoConfig {
interior_ambient: 0.15,
interior_fill: 0.12,
interior_exposure: 2.5,
// MONKEY (soft falloff): 2.5, not 1 — see the field doc.
interior_atten_scale: 1.6,
interior_shadows: true,
// MONKEY (static torch cache): 12 resident maps, four moving-caster overlays.
interior_shadow_casters: 12,
interior_shadow_dynamic: 4,
interior_shadow_soft: 1.0,
// MONKEY (room gate): on — without it a building's fixtures light through its own
// floors and walls.
interior_room_gate: true,
interior_debug: 0,
fire_light_gain: 1.0,
display: if windowed_env() {
DisplayMode::Windowed
} else {
@@ -574,6 +619,8 @@ mod tests {
shadow_distance: 80.0,
display: DisplayMode::Fullscreen,
windowed: UVec2::new(1024, 768),
// MONKEY (review fixes): this window test inherits unrelated lighting defaults.
..Default::default()
})
.add_systems(Update, apply_window_mode);
let win = app
+92 -1
View File
@@ -289,6 +289,18 @@ pub struct ModelRibbon {
pub struct ModelLight {
pub def: M2Light,
pub bone_pivot: [f32; 3],
/// MONKEY (fire GO lights): `true` when this light was **synthesised** from the model's flame
/// particle emitter rather than read off an authored light block
/// ([`benilla_formats::fire_light`]). Only ~17 of the ~430 fire-ish props in the chain author
/// one, so without this every wall torch, magic brazier, forge and candle in the game lights
/// nothing.
///
/// The flag exists so a lane can REFUSE it, and one does: the WMO MODD prop lane. A building's
/// own MOLT fixtures already sit at its wall torches' flames (the artists place a companion
/// MOLT per fixture doodad — NSabbey ships one per candelabra), so a synthetic light on the
/// prop would double-light exactly the interiors that are already correct. Every other lane —
/// ADT map doodads, GameObjects/creatures, transport props — takes it like an authored one.
pub synthetic: bool,
}
/// Bevy [`AssetLoader`] decoding `*.m2` → [`M2Model`].
@@ -468,7 +480,7 @@ impl AssetLoader for M2ModelLoader {
// M2 lights: the same host-bone pivot bake, so a light on an animating model (the torch in
// an NPC's hand) can ride its bone's joint instead of freezing at the rest-pose spot.
let lights = light_defs
let mut lights: Vec<ModelLight> = light_defs
.into_iter()
.map(|def| ModelLight {
bone_pivot: skeleton_raw
@@ -476,8 +488,87 @@ impl AssetLoader for M2ModelLoader {
.get(def.bone as usize)
.map_or([0.0; 3], |b| b.pivot),
def,
synthetic: false,
})
.collect();
// MONKEY (fire GO lights) / MONKEY (lamp lights): a prop that authors NO casting light gets
// ONE derived from what it DOES author — its flame emitter, or (lamps, lanterns, sconces,
// chandeliers, which author no particles whatsoever) its unlit glass geoset. Both rules,
// their calibration and the `benilla-extract m2firescan` audit live in
// [`benilla_formats::fire_light`]. This is the single site the synthesis happens, so every
// lane that already consumes `M2Model::lights` inherits it for free and nothing downstream
// has to know an emitter or a render batch was ever read.
//
// Gated on `casts()` and not on emptiness: an authored block ALWAYS wins, including the
// directional-only and visibility-off shapes — a model whose one light block is authored
// dark was authored dark on purpose.
if !lights.iter().any(|l| l.def.casts()) {
let path = ctx.path().path().to_string_lossy().to_string();
// The FLAME route first, then the LAMP route — never both. The flame wins because it
// carries a REAL colour (read off the artist's own over-life ramp) where the lamp route
// can only pick a plausible one from a name; a model with both a flame and lamp glass
// (`OrcBrazierStreetLamp`) should take the measured hue, not the guessed one. Each
// yields the same four things: model-space position, host bone, colour, intensity.
let synth = benilla_formats::synthesize_fire_light(
&path,
emitters.iter().map(|e| &e.def),
)
.map(|fire| {
let src = &emitters[fire.emitter].def;
(src.position, src.bone, fire.color, fire.intensity)
})
.or_else(|| {
// MONKEY (lamp lights): a lamppost/lantern/chandelier authors NO particle emitter
// at all — its glow is an UNLIT (render-flag 0x01) glass geoset. The rule reads the
// already-built render batches, so nothing extra is parsed; the position it returns
// is that geoset's CENTROID (the lamp head, 4-5 yd up a lamppost), never the model
// origin at the base of the pole. `bounds` is the fallback for a name-route hit
// whose glass we can't find — see `fire_light::lamp_position`.
let batches: Vec<benilla_formats::EmissiveBatch<'_>> = submeshes
.iter()
.map(|s| benilla_formats::EmissiveBatch::from(&*s.geometry))
.collect();
let bbox = bounds.as_ref().map(|b| (b.bbox_min, b.bbox_max));
benilla_formats::synthesize_lamp_light(&path, &batches, bbox)
.map(|l| (l.position, l.bone, l.color, l.intensity))
});
if let Some((position, src_bone, color, intensity)) = synth {
// The light sits at the FLAME (or the lamp glass), not the model origin: a brazier's
// origin is under its bowl, and a light there back-lights the bowl into every
// surface it should be lighting (and self-shadows through the prop's own caster
// mesh, which the torch lane includes). Same bone convention as an authored light —
// a bone the skeleton doesn't carry reads as `-1` (model origin), which is what a
// boneless prop is.
let bone = i16::try_from(src_bone)
.ok()
.filter(|_| (src_bone as usize) < skeleton_raw.bones.len())
.unwrap_or(-1);
lights.push(ModelLight {
def: M2Light {
light_type: 1, // point — the hot-spot caster; the whole reason we're here
bone,
position,
// Diffuse only, like every world light the reference commits (its ambient
// and specular are zero on world props — decision 0273).
ambient_color: [0.0; 3],
ambient_intensity: 0.0,
diffuse_color: color,
diffuse_intensity: intensity,
// The GL curve is fixed (`1/(0.7d+0.03d²)`) and ignores these; they are a
// cull hint no consumer reads, so a synthesised light authors none.
attenuation_start: 0.0,
attenuation_end: 0.0,
bone_z: [0.0, 0.0, 1.0], // point lights have no direction basis
visibility_off: false,
},
bone_pivot: skeleton_raw
.bones
.get(src_bone as usize)
.map_or([0.0; 3], |b| b.pivot),
synthetic: true,
});
}
}
let (skeleton, inverse_bindposes) = build_skeleton(&skeleton_raw);
let inverse_bindposes = ctx.add_labeled_asset(
"inverse_bindposes".to_string(),
+5 -5
View File
@@ -234,14 +234,14 @@ pub struct WowModelExt {
#[texture(91, dimension = "2d_array", sample_type = "depth", visibility(fragment))]
#[sampler(92, sampler_type = "comparison", visibility(fragment))]
pub torch_depth: Handle<Image>,
/// MONKEY (torch shadows Phase 3A): the ≤4-fixture torch TABLE — the same 1616-byte
/// `TorchTableUniform` bytes static_gx's group-3 uniform carries (count / positions[4] /
/// view_projs[24]), as a SEPARATE shared raw buffer (`SharedTorchBuffer`) rewritten in place
/// MONKEY (static torch cache): the ≤16-fixture torch TABLE — the same 6416-byte
/// `TorchTableUniform` bytes static_gx's group-3 uniform carries (count / positions[16] /
/// view_projs[96]), as a SEPARATE shared raw buffer (`SharedTorchBuffer`) rewritten in place
/// every frame from `TorchShadowViews`. A raw `Buffer` like `light_buf`, deliberately NOT a
/// `#[uniform]` field (a per-frame-mutated uniform re-prepares every material every frame)
/// and NOT a region of the shared light blob (whose `LightStd430` mirror + the booth packer
/// would all have to grow in lock-step). `wow_model.wgsl` reads it as
/// `var<storage, read> torch_table` (std430 of this struct == the std140 1616 bytes: every
/// `var<storage, read> torch_table` (std430 of this struct == the std140 6416 bytes: every
/// member is 16-aligned). Set once, never mutated.
#[storage(93, read_only, buffer, visibility(fragment))]
pub torch_buf: Buffer,
@@ -257,7 +257,7 @@ pub struct WowModelExt {
pub struct TorchBinds {
/// The shared torch depth array image (→ `WowModelExt::torch_depth`).
pub depth: Handle<Image>,
/// The shared 1616-byte torch table buffer (→ `WowModelExt::torch_buf`).
/// The shared 6416-byte torch table buffer (→ `WowModelExt::torch_buf`).
pub table: Buffer,
}
@@ -86,6 +86,12 @@ fn torch_caster_count() -> u32 {
// - `world_pos` the receiving fragment's world position.
// - `depth_tex`/`comp` the group-3 depth array + `GreaterEqual` comparison sampler.
// - `bias` reverse-Z receiver bias (nudges the compare ref up to kill self-shadow acne).
// - `soft` MONKEY (torch caster selection): the PCF tap-radius scale (`interiorShadowSoft`,
// 0.5..3, carried in the table's `count.y` as `soft x 100`). The four taps sit at
// ±0.5 texel × this; 1 is the historical half-texel box. A candle cluster casts
// many overlapping penumbra-less edges, and widening the kernel is the cheapest
// softening available here (a real penumbra would need the blocker distance and a
// variable kernel — not worth a second sampling pass on a 512² face).
// Returns 1.0 outside the frustum / behind the light; a 4-tap PCF factor otherwise (0 = shadowed).
fn torch_map_shadow(
view_proj: mat4x4<f32>,
@@ -94,6 +100,7 @@ fn torch_map_shadow(
depth_tex: texture_depth_2d_array,
comp: sampler_comparison,
bias: f32,
soft: f32,
) -> f32 {
let clip = view_proj * vec4<f32>(world_pos, 1.0);
if (clip.w <= 0.0) {
@@ -104,7 +111,10 @@ fn torch_map_shadow(
return 1.0;
}
let uv = ndc.xy * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5);
let texel = 1.0 / vec2<f32>(textureDimensions(depth_tex).xy);
// MONKEY (torch caster selection): the tap radius is `soft` texels, clamped off zero so a
// table that never got a scale still samples a real (hard) 4-tap box rather than four copies
// of one texel.
let texel = (1.0 / vec2<f32>(textureDimensions(depth_tex).xy)) * max(soft, 0.05);
// 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`.
let ref_depth = ndc.z + bias;
+13 -1
View File
@@ -70,7 +70,9 @@ struct WowLight {
grade: vec4<f32>, // reserved (the 0282 interior A/B retired; 0163). Layout only.
_wmo_fog: array<vec4<f32>, 2>, // rows 18-19: the interior fog triple — unread by terrain.
// The dynamic point-light table (decision 0278), packed by `global_light::build_light_data`:
// row 20 `.x` = live entry count; then TWO rows per light — `[pos.xyz, range]`, `[rgb, 0]`.
// row 20 `.x` = live entry count; then TWO rows per light — `[pos.xyz, range]`, `[rgb, lane]`.
// MONKEY (light lanes): `lane` = 0 for an EXTERIOR light, > 0.5 (and equal to the fixture's
// reach in yards) for an INTERIOR one. Terrain consumes only the exterior half.
point_count: vec4<f32>,
points: array<vec4<f32>, 512>,
};
@@ -138,6 +140,16 @@ fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
var sel = array<u32, 3>(0u, 0u, 0u);
var sd = array<f32, 3>(1e30, 1e30, 1e30);
for (var i = 0u; i < count; i = i + 1u) {
// MONKEY (light lanes): skip INTERIOR fixtures. The colour row's `.w` is `0` on an exterior
// source and the fixture's reach in yards (always ≥ 1) on one that claims a room, so
// `> 0.5` is the lane test. Terrain is exterior by definition: an inn's candles reaching
// the grass at the base of its wall — a warm pool on the lawn at night — is the whole bug,
// and it is here rather than in the pack because the fixtures MUST stay packed for the
// interior lane to light the room they belong to. Skipped BEFORE the ≤3 ranking, so an
// interior fixture cannot even occupy a slot an outdoor fire should have had.
if (wow_light.points[2u * i + 1u].w > 0.5) {
continue;
}
let pos_range = wow_light.points[2u * i];
let dv = pos_range.xyz - anchor;
let d2 = dot(dv, dv);
+257 -26
View File
@@ -164,9 +164,16 @@ struct WowLight {
wmo_fog_color: vec4<f32>, // rgb interior fog (gamma); w = enable (mirrors fog_color.w)
wmo_fog_params: vec4<f32>, // x = start yd; y = end yd; zw = free lanes (retired A/B dials)
// The dynamic point-light table (decision 0278), packed by `global_light::build_light_data`:
// row 20 `.x` = live entry count; then TWO rows per light — `[pos.xyz, range]`, `[rgb, 0]`.
// row 20 `.x` = live entry count; then TWO rows per light — `[pos.xyz, range]`, `[rgb, lane]`.
// Rides this buffer (not bevy's clusterables) because the view layout exposes those to the
// fragment stage only, and the Gouraud term is evaluated in the VERTEX stage.
//
// MONKEY (light lanes): the colour row's `.w` splits the table into two DISJOINT halves.
// `0` = an EXTERIOR light (nothing claims a room) — read only by `point_light_sum`. Anything
// `> 0.5` = an INTERIOR fixture — read only by `interior_room_light`, and the value itself is
// that fixture's REACH IN YARDS (its authored MOLT attenuation end, or the M2 intensity
// bucket, already scaled by the live `interiorAttenScale`). One float carries both because a
// packed reach is always ≥ 1 yd and can never be mistaken for the exterior 0.
point_count: vec4<f32>,
points: array<vec4<f32>, 512>,
// The interior-prop SH probe table (lighting::prop_probes — 7 rows per slot, 8192 slots; keep in
@@ -205,14 +212,17 @@ struct WowLight {
// MONKEY (torch shadows Phase 3A): the ENTITY receiver's torch bindings — the SAME depth array
// static_gx's group 3 samples (one shared `Image`, rendered by `static_gx::torch_depth`) and the
// SAME 1616-byte table, riding the material's own group (`WowModelExt` bindings 91/92/93) because
// SAME 6416-byte table, riding the material's own group (`WowModelExt` bindings 91/92/93) because
// a Bevy material draw sets groups 0/1/2 only. ALWAYS bound (the image and buffer exist from
// startup), so no shader-def guards this block; only the fragment stage reads it. The struct is
// std430 here and std140 in static_gx — identical bytes, every member is 16-aligned.
struct TorchTable {
count: vec4<u32>, // .x = live fixture count
positions: array<vec4<f32>, 4>, // xyz = fixture world pos, w = range
view_projs: array<mat4x4<f32>, 24>, // 6 cube faces per fixture: [fixture*6 + face]
// MONKEY (static torch cache): byte-identical in BOTH shaders and TorchTableUniform.
// count@0 (16): x high-water slot count, y soft*100, z dynamic/live-bank mask, w reserved.
// positions@16 (256), view_projs@272 (6144): total 6416 bytes. A mismatch hides buildings.
count: vec4<u32>,
positions: array<vec4<f32>, 16>,
view_projs: array<mat4x4<f32>, 96>,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(91) var torch_depth: texture_depth_2d_array;
@group(#{MATERIAL_BIND_GROUP}) @binding(92) var torch_samp: sampler_comparison;
@@ -227,6 +237,11 @@ const TORCH_BIAS: f32 = 0.001;
// interiorDebug 2, flickering shadows in the real render). Pushing the sample point out of the skin
// makes self-occlusion impossible while a pillar or another character still casts onto it.
const TORCH_NORMAL_OFFSET: f32 = 0.15;
// MONKEY (torch caster selection, MIRRORED from static_gx.wgsl): the live PCF tap-radius scale,
// unpacked from the table's `count.y` (stored x100 — the row is `vec4<u32>`).
fn torch_soft() -> f32 {
return max(f32(torch_table.count.y) * 0.01, 0.05);
}
// MONKEY (Phase 5, MIRRORED from static_gx.wgsl — keep in sync): the cube face that contains
// direction `d` (fixture → fragment) — the major axis, signed. Face order is the contract with
@@ -251,11 +266,19 @@ fn torch_entity_shadow(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>) -> f32
// Normal-offset the sample point out of the body (see TORCH_NORMAL_OFFSET).
let Ps = P + N * TORCH_NORMAL_OFFSET;
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
// MONKEY (static torch cache): holes and pending uploads never sample stale layers.
if (torch_table.positions[i].w <= 0.0) { continue; }
let fixture = torch_table.positions[i].xyz;
if (distance(fixture, light_pos) < 1.0) {
let layer = i * 6u + torch_face(Ps - fixture);
return shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(layer), Ps, torch_depth, torch_samp, TORCH_BIAS);
let s = shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(layer + select(0u, 96u, (torch_table.count.z & (1u << i)) != 0u)), Ps, torch_depth, torch_samp, TORCH_BIAS,
torch_soft());
// 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
// shadow would pop while the floor's faded.
return mix(1.0, s, torch_table.positions[i].w);
}
}
return 1.0;
@@ -269,9 +292,13 @@ fn torch_entity_debug_factor(P: vec3<f32>, N: vec3<f32>) -> f32 {
let Ps = P + N * TORCH_NORMAL_OFFSET;
var s = 1.0;
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
if (torch_table.positions[i].w <= 0.0) { continue; }
let layer = i * 6u + torch_face(Ps - torch_table.positions[i].xyz);
s = min(s, shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(layer), Ps, torch_depth, torch_samp, TORCH_BIAS));
let raw = shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(layer + select(0u, 96u, (torch_table.count.z & (1u << i)) != 0u)), Ps, torch_depth, torch_samp, TORCH_BIAS,
torch_soft());
// MONKEY (torch caster selection): the WEIGHTED factor, matching the real render.
s = min(s, mix(1.0, raw, torch_table.positions[i].w));
}
return s;
}
@@ -358,6 +385,13 @@ fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
var sel = array<u32, 3>(0u, 0u, 0u);
var sd = array<f32, 3>(1e30, 1e30, 1e30);
for (var i = 0u; i < count; i = i + 1u) {
// MONKEY (light lanes): skip INTERIOR fixtures (colour row `.w > 0.5`) — they light the
// room that claims them and nothing else. Before the split, a proximity-admitted inn
// fixture reached every exterior receiver near the building through its own walls. Skipped
// BEFORE the ≤3 ranking, so it cannot take a slot an outdoor fire should have had.
if (wow_light.points[2u * i + 1u].w > 0.5) {
continue;
}
let pos_range = wow_light.points[2u * i];
let dv = pos_range.xyz - anchor;
let d2 = dot(dv, dv);
@@ -391,7 +425,14 @@ fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
}
// MONKEY (dynamic interiors): the per-FRAGMENT room light — MIRRORED from `static_gx.wgsl`
// (`interior_room_light`; keep in sync). Every in-range light of the room-gated table, no
// (`interior_room_light`; keep in sync). MONKEY (light lanes / interior attenuation): the loop is
// over the INTERIOR half of the table only (colour row `.w > 0.5`), and that same `.w` is each
// fixture's REACH in yards — its authored MOLT attenuation end (M2 sources bucket by intensity),
// scaled live by `interiorAttenScale`; it bounds the loop, shapes the fill and drives the direct
// window. `interiorAttenScale 0` packs the legacy 48 yd — under the soft profile below that is the
// widest, flattest pool the lane can make, the nearest thing left to the pre-window flat lane (no
// longer the byte-exact restore it was, because the window's SHAPE moved with it).
// Every in-range light of the room-gated table, no
// nearest-3 selection: DIRECT (falloff × wrapped Lambert, so a floor-level hearth still lights
// the floor) + FILL (normal-free, half-desaturated bounce) + the base ambient. The knobs are the
// live cvars packed into `point_count.yzw` (`.y` ambient, `.z` fill gain, `.w` exposure — the
@@ -400,24 +441,125 @@ fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
// day/night CGLight, and takes the SAME per-fixture torch shadows (Phase 3A: the shared depth
// array through this material's own group-2 bindings) as the surfaces.
const INTERIOR_WRAP: f32 = 0.5;
fn interior_room_light(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
// MONKEY (soft falloff): the interior pool's PROFILE — **keep this whole block byte-identical
// between wow_model.wgsl and static_gx.wgsl.**
//
// The colour row's `.w` packs each fixture's EFFECTIVE RADIUS `R`: its authored MOLT
// `attenuation_end` (an M2 source buckets by intensity instead) already multiplied by the live
// `interiorAttenScale` (default 1.6). MONKEY (pool energy): the EXTENT terms — the direct window
// and the fill span — are still written as fractions of `R`, so the cvar stays a pure zoom on how
// far every pool REACHES; the core is not, so it is no longer also a brightness dial.
//
// The RETIRED profile was the reference's `1/(0.7d + 0.03d²)` windowed by
// `1 − smoothstep(0.65R, R, d)` at `R` = the authored reach. That window closed a still-BRIGHT
// curve — 0.28 of its 1 yd value at 0.65R — over the last 35 % of the radius, i.e. a bright disc
// with a visible rim and black beyond it. That is the abbey candelabra ring. Three changes replace
// it, and the ring cannot come back because the curve is already dim wherever the window bites:
// · CORE — `1/(1 + (d/r0)²)`: inverse square with a soft core. MONKEY (pool energy): `r0` is a
// CONSTANT `INTERIOR_CORE_YD` **yards**, not a fraction of `R` any more. It was `0.26·R`, and
// that tied a fixture's BRIGHTNESS to its REACH — the pool's total ENERGY grew as `R²`. The
// Goldshire inn's `R ≈ 11-15` fixtures therefore ran a 2.1× (at 3 yd) to 2.9× (at 5 yd) hotter
// curve than Northshire abbey's `R ≈ 7`; ten of them overlap in one room, the
// `1 − exp(−x·exposure)` rolloff saturated, and the inn read as one flat yellow while the
// abbey — whose pools the owner signed off — sat exactly where it should. Reach must BOUND a
// pool, not fuel it. So the energy is now one curve for every fixture and only the WINDOW and
// the fill span still scale with `R`: two fixtures of different reach read IDENTICALLY until
// the shorter one's window closes. `1.75 yd` is what the abbey already had (`0.26 × 6.7` — its
// median authored end 4.2 at the default `interiorAttenScale` 1.6), so the under-candle level
// the exposure was tuned against is preserved to within ±10 % across the abbey's own reach
// spread (−5 % at its median fixture) and nothing about the approved look moves.
// · GAIN — `INTERIOR_CORE_GAIN` normalises the core against the retired hyperbolic's 1 yd value,
// `1/(0.7 + 0.03) = 1.3699`. With a CONSTANT `r0` that normalisation is the same for every
// fixture instead of holding only at the median reach, which is the whole point; it lands at
// `1.5/(1 + (1/1.75)²) = 1.1308`, i.e. 0.83 × the hyperbolic — the abbey's own current level.
// (Gain 1.817 would restore 1.3699 exactly; that is a 21 % brighter room than the approved
// one, so it is deliberately NOT taken. Change the GAIN, never the core radius, to re-level.)
// · WINDOW — `(clamp01(1 − (d/R)^p))²`, the UE4-style windowing: value AND derivative both vanish
// at `d = R` (C1, so no rim), ≈1 over most of the radius, and it only ever removes energy the
// curve has already lost.
// MONKEY (pool energy): the direct term (before the wrapped Lambert) at d = 1 / 3 / 5 / 8 / 12 yd,
// abbey-class `R = 7` beside inn-class `R = 13` —
// was R=7 1.152 / 0.403 / 0.164 / 0 / 0 R=13 1.379 / 0.839 / 0.470 / 0.224 / 0.033
// now R=7 1.131 / 0.381 / 0.153 / 0 / 0 R=13 1.131 / 0.381 / 0.164 / 0.067 / 0.009
// The two "now" rows are the SAME curve until R=7's window bites (where it is already down to
// 0.15); R=13 only keeps a longer, dimmer TAIL, because its window is wider. That is the fix.
//
// FILL keeps its EXACT form (`(1 − d/r)²`, which is `interior_window(d, r, 1.0)`) and only widens:
// its radius is `INTERIOR_FILL_SPAN·R`, so the floor BETWEEN two pools takes a gentle wash instead
// of the bare ambient floor. Its value at the fixture is still 1, so `interiorFill` keeps the
// meaning it was tuned with.
const INTERIOR_CORE_YD: f32 = 1.75;
const INTERIOR_CORE_GAIN: f32 = 1.5;
const INTERIOR_DIRECT_POW: f32 = 10.0;
const INTERIOR_FILL_SPAN: f32 = 1.5;
const INTERIOR_FILL_POW: f32 = 1.0;
// `(clamp01(1 − (d/r)^p))²`. The inner clamp keeps `pow` off a negative base; `max(r, …)` keeps it
// off a zero divisor (the packer floors R at 1.0, but a shader must not lean on a producer's
// invariant). `clamp(…, 0, 1)` rather than `saturate` — nothing else in this shader set uses
// `saturate`, and the two are the same instruction.
fn interior_window(d: f32, r: f32, p: f32) -> f32 {
let w = clamp(1.0 - pow(clamp(d / max(r, 1e-4), 0.0, 1.0), p), 0.0, 1.0);
return w * w;
}
// MONKEY (room gate): the ENTITY/prop copy has no claim-table binding and no per-fragment room
// key (`static_gx.wgsl` owns both — the claim table hangs off its own group 2), so every fixture is
// admitted here, exactly as before this change. Follow-up: an entity already knows the room it
// STANDS in (the interior classifier's down-ray, `carried_light`'s `WmoGroupVis::single`), so the
// same gate could ride a per-instance key pushed through the model material.
fn interior_room_admits(i: u32, room_inst: u32, room_group: u32) -> bool {
return true;
}
fn interior_room_light(P: vec3<f32>, N: vec3<f32>, room_inst: u32, room_group: u32) -> vec3<f32> {
let count = u32(wow_light.point_count.x);
let k_fill = wow_light.point_count.z;
var direct = vec3<f32>(0.0);
var fill = vec3<f32>(0.0);
for (var i = 0u; i < count; i = i + 1u) {
let color_lane = wow_light.points[2u * i + 1u];
// MONKEY (light lanes): only a fixture that CLAIMS a room lights this room (keep in sync
// with static_gx.wgsl). `.w` is 0 on every exterior source, so a campfire burning outside
// the door stops reaching the floor inside it.
if (color_lane.w < 0.5) {
continue;
}
// MONKEY (room gate): and only a fixture that claims THIS ROOM lights this room's surfaces.
// Before this, the whole INT half of the table lit every interior fragment of every
// building in range and the only occlusion was the <=6 promoted cube-shadow casters — so an
// inn's ground-floor candles lit its basement THROUGH the floor, and an upstairs corridor
// wall glowed from the fixture in the room behind it. Tested here, before the distance
// test, because it is the term that throws away the most: a room claims one or two of the
// table's fixtures, not all of them.
if (!interior_room_admits(i, room_inst, room_group)) {
continue;
}
// MONKEY (soft falloff): `.w` is ALSO this fixture's EFFECTIVE RADIUS `R` in yards — the
// authored MOLT `attenuation_end` (or the M2 intensity bucket) already multiplied by the
// live `interiorAttenScale` at pack time. It replaces the flat 48 yd candidacy radius in
// `pos_range.w` on this lane, which is why a 10-candle inn read as one uniform wash.
let reach_yd = color_lane.w;
// Candidacy is the FILL radius, not R: the wash reaches further than the direct pool (see
// the profile block), and rejecting at R would cut it off exactly at the pool's own edge —
// putting the rim back one term down.
let fill_yd = INTERIOR_FILL_SPAN * reach_yd;
let pos_range = wow_light.points[2u * i];
let to_light = pos_range.xyz - P;
let d2 = dot(to_light, to_light);
if (d2 > pos_range.w * pos_range.w) {
if (d2 > fill_yd * fill_yd) {
continue;
}
let d = sqrt(d2);
let c = wow_light.points[2u * i + 1u].rgb;
let c = color_lane.rgb;
// Normalised for BOTH terms (keep in sync with static_gx.wgsl): the table commits RAW
// over-gamut colour × intensity; a hot forge would wash the direct term to white.
let c_norm = c / max(1.0, max(c.r, max(c.g, c.b)));
let atten = 1.0 / (0.7 * d + 0.03 * d2);
// MONKEY (soft falloff): inverse square with the authored-start soft core, normalised so
// the 1 yd value is the retired hyperbolic's (profile block above).
// MONKEY (pool energy): a CONSTANT core radius in yards (profile block above) — the reach
// no longer scales the pool's brightness, only its window. `max` keeps the divide honest
// if the constant is ever tuned toward 0.
let r0 = max(INTERIOR_CORE_YD, 1e-3);
let atten = INTERIOR_CORE_GAIN / (1.0 + (d / r0) * (d / r0));
let window = interior_window(d, reach_yd, INTERIOR_DIRECT_POW);
let nl = max(
(dot(N, to_light / max(d, 1e-4)) + INTERIOR_WRAP) / (1.0 + INTERIOR_WRAP),
0.0,
@@ -427,12 +569,16 @@ fn interior_room_light(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
// material's group-2 torch bindings — a pillar between an NPC and torch A darkens A's
// term without touching torch B's, exactly as on the surfaces around it.
let s = torch_entity_shadow(pos_range.xyz, P, N);
direct += c_norm * (atten * nl) * s;
let reach = 1.0 - d / max(pos_range.w, 1e-4);
direct += c_norm * (atten * nl * window) * s;
// MONKEY (soft falloff): the fill's profile is UNCHANGED in FORM — `(1 − d/r)²`, which is
// exactly `interior_window(d, r, 1.0)` — and only its radius moved, from R to
// `INTERIOR_FILL_SPAN·R`. That is the whole "gentle wash between the pools": at the fixture
// it is still 1 (so `interiorFill` keeps its tuned meaning) and it decays to 0 at 2R with a
// vanishing derivative, so the floor half-way between two candles is dim, not black.
let c_fill = mix(c_norm, vec3<f32>(dot(c_norm, vec3<f32>(0.299, 0.587, 0.114))), 0.5);
// DOMINANT fixture, not the sum (keep in sync with static_gx.wgsl): fill must not scale
// with candle count or a dense room saturates flat. Fill is indirect, so not shadowed.
fill = max(fill, c_fill * (k_fill * reach * reach));
fill = max(fill, c_fill * (k_fill * interior_window(d, fill_yd, INTERIOR_FILL_POW)));
}
return direct + fill + vec3<f32>(wow_light.point_count.y);
}
@@ -451,6 +597,61 @@ fn mcnk_cell_anchor(P: vec3<f32>) -> vec3<f32> {
return vec3<f32>((ix + 0.5) * cell - half, P.y, (iz + 0.5) * cell - half);
}
// MONKEY (wmo exterior points): the EXTERIOR-lane point term for a WMO's OUTDOOR-class surfaces —
// MIRRORED VERBATIM from `static_gx.wgsl` (`wmo_exterior_point_sum`; that file owns the full
// rationale — keep the two bodies identical). This copy exists because the retained WMO collector
// declines a small minority of batches (env-mapped, depth-flag oddities) and they fall through to
// this entity pipeline: a Trade District street lit on one pipeline and black on the other is
// worse than either. It reuses this file's OWN `mcnk_cell_anchor` (identical constants to
// static_gx's private copy) — a WMO batch's anchor is its PLACEMENT origin, one point for the whole
// of Stormwind, so ranking from it would commit the same three lights to every street in the city;
// the 33.33 yd MCNK cell is the unit terrain ranks by, so a street and the road it runs into rank
// the SAME candidates and agree at the seam. Candidacy is terrain's Chebyshev box, not this file's
// 48 yd sphere, for that same agreement reason.
const WMO_EXT_REACH: f32 = 33.570166;
fn wmo_exterior_point_sum(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
let anchor = mcnk_cell_anchor(P);
let count = u32(wow_light.point_count.x);
var sel = array<u32, 3>(0u, 0u, 0u);
var sd = array<f32, 3>(1e30, 1e30, 1e30);
for (var i = 0u; i < count; i = i + 1u) {
// Exterior lane only: an interior fixture's `.w` is its reach (≥ 1), an exterior source's
// is 0. A building's own candles must not pool on the street outside its wall.
if (wow_light.points[2u * i + 1u].w > 0.5) {
continue;
}
let dv = wow_light.points[2u * i].xyz - anchor;
// Terrain's hash sweep is horizontal and unbounded vertically; the ranking below is the
// full 3-D distance, as `0x71bf90` does.
if (max(abs(dv.x), abs(dv.z)) > WMO_EXT_REACH) {
continue;
}
let d2 = dot(dv, dv);
if (d2 < sd[0]) {
sd[2] = sd[1]; sel[2] = sel[1];
sd[1] = sd[0]; sel[1] = sel[0];
sd[0] = d2; sel[0] = i;
} else if (d2 < sd[1]) {
sd[2] = sd[1]; sel[2] = sel[1];
sd[1] = d2; sel[1] = i;
} else if (d2 < sd[2]) {
sd[2] = d2; sel[2] = i;
}
}
var sum = vec3<f32>(0.0);
for (var s = 0u; s < 3u; s = s + 1u) {
if (sd[s] > 9.9e29) {
break;
}
let to_light = wow_light.points[2u * sel[s]].xyz - P;
let d = length(to_light);
let atten = 1.0 / (0.7 * d + 0.03 * d * d);
let nl = max(dot(N, to_light / max(d, 1e-4)), 0.0);
sum += wow_light.points[2u * sel[s] + 1u].rgb * (atten * nl);
}
return sum;
}
// The vertex input — bevy 0.18's `forward_io::Vertex` fields at bevy's shader locations (the
// VERTEX_* defs come from the base mesh-pipeline specialize, driven by what the mesh authors;
// tangents / morphs never — no model mesh has them), PLUS the owned-palette joint attributes at
@@ -705,14 +906,24 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
#endif
// Dynamic point lights, by receiver class (wow-re trace-forensics-abbey-interior-d3d §2/§4):
// WMO surfaces take NONE (zero point lights on every observed WMO surface batch — the earlier
// "point-lit abbey wall" was a mis-identified unit draw), and interior M2 props take none HERE
// (their group-MOLR point lobes are folded into the per-instance SH probe). Everything else —
// INTERIOR WMO surfaces take NONE (zero point lights on every observed WMO surface batch —
// the earlier "point-lit abbey wall" was a mis-identified unit draw; they light per FRAGMENT
// instead, `interior_room_light`), and interior M2 props take none HERE (their group-MOLR
// point lobes are folded into the per-instance SH probe). Everything else —
// exterior doodads, entities, clutter — keeps the FFP ≤3-nearest selection: clutter (merged in
// world space) anchors at its MCNK chunk cell (the terrain draw unit it belongs to), every M2
// at its INSTANCE origin (wow-re wmo-surface-dynamic-light §6 — the receiving unit's own
// position, deliberately not the skinned per-vertex matrix).
if (m.model_flags.x > 0.5 || m.model_flags.z > 0.5) {
if (m.model_flags.x > 0.5 && m.model_flags.z < 0.5) {
// MONKEY (wmo exterior points): an EXTERIOR-class WMO group (`model_flags.z` clear = MOGP
// `& 0x48` set) is a street, a courtyard, a porch — drawn by the exterior law, the same law
// the terrain beside it is drawn by, so it takes the exterior point term terrain takes. The
// §2 "zero on every WMO surface" finding was measured on abbey INTERIOR rooms and reading
// it as a blanket zero is what left Stormwind's Trade District torches lighting nothing but
// the NPC beside them. Mirrors `static_gx.wgsl`'s branch (which draws the vast majority of
// these batches) — keep the two in step.
out.point_lit = wmo_exterior_point_sum(out.world_position.xyz, out.world_normal);
} else if (m.model_flags.x > 0.5 || m.model_flags.z > 0.5) {
out.point_lit = vec3<f32>(0.0);
} else {
var anchor = mesh_world_from_local[3].xyz;
@@ -1180,9 +1391,12 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
let sidn_e = m.sidn.rgb * (wow_light.grade.x * sidn_w);
// WoW dynamic point lights (decisions 0016/0273/0278, selection 0285) — exterior doodads,
// entities, clutter, and terrain receive their unit's committed lights: the ≤3 NEAREST to the
// receiving unit's own position, never the whole scene (`point_light_sum`). WMO surfaces take
// ZERO (observed on every WMO surface batch in the abbey capture) and interior props fold their
// group-MOLR lobes into the SH probe instead — both zeroed in the VERTEX stage. The term
// receiving unit's own position, never the whole scene (`point_light_sum`). INTERIOR WMO
// surfaces take ZERO (observed on every WMO surface batch in the abbey capture — an interior
// capture, hence the MONKEY split above) and interior props fold their group-MOLR lobes into
// the SH probe instead — both zeroed in the VERTEX stage; an EXTERIOR-class WMO group takes
// the exterior-lane term (`wmo_exterior_point_sum`) there instead, ranked from its MCNK cell
// like the terrain it adjoins, which is what puts a street torch on the cobbles. The term
// arrives GOURAUD-INTERPOLATED — per-vertex like the reference FFP, whose tessellation-scale
// smoothing is the authored look. Diffuse-only (committed ambient/specular are zero).
let point_diffuse = in.point_lit;
@@ -1277,8 +1491,25 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// in exterior material mode: in interior mode those bits are the probe slot.
let matte_indoor = !is_interior && (fade_tag & 0x4000u) != 0u;
if ((is_interior || matte_indoor) && !is_wmo && !is_rig && wow_light.wmo_fog_params.w > 0.5) {
let room = interior_room_light(in.world_position.xyz, n_lit);
var room_rgb = albedo * inst_tint * (vec3<f32>(1.0) - exp(-room * wow_light.point_count.w));
let room = interior_room_light(in.world_position.xyz, n_lit, 0u, 0u);
// MONKEY (indoor highlight): the hover/target emissive (`highlight`, tag bit 31) rides THIS
// lane's light sum too. It is the same GL_EMISSION placement as the exterior branches above
// — added to the material's ambient+diffuse product INSIDE the [0,1] saturate, with the
// texture (`albedo`) modulating the clamped result — so an indoor chair lifts by exactly the
// +64/255 an outdoor one does. It has to be folded HERE, not left in `lit_rgb`, because this
// lane REPLACES the exterior result a few lines down (`mix(lit_rgb, room_rgb, lane_w)`):
// with `lane_w` at 1 (a settled indoor unit) the exterior sum that carried the lift was
// discarded wholesale, which is why hovering a chair inside a Stormwind house brightened
// nothing at all while the same chair on the street did.
// The clamp is a no-op when nothing is hovered (`highlight` 0, and both factors are already
// ≤1), so the un-hovered indoor look is bit-identical to before.
// Placed BEFORE the debug branch on purpose: modes 1/2/3 all discard `room_rgb` and return
// their own diagnostic colour, so `interiorDebug` is untouched by this.
var room_rgb = albedo * clamp(
inst_tint * (vec3<f32>(1.0) - exp(-room * wow_light.point_count.w)) + vec3<f32>(highlight),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
let idbg = u32(max(wow_light.wmo_fog_params.w - 1.0, 0.0) + 0.5);
if (idbg == 2u) {
// The entity's OWN cube-map sampling as greyscale (the shared stub would show white).
@@ -522,6 +522,15 @@ enum Command {
/// models if omitted.
prefix: Option<String>,
},
/// MONKEY (fire GO lights): the twin of `m2lightscan` for the models that author NO light —
/// which ones would take a **synthesised** one from their flame particle emitter, and what
/// colour/intensity the heuristic derives (`benilla_formats::fire_light`). The offline audit
/// of a rule that is applied to ~430 fire props at load, none of which the reference lights.
M2firescan {
/// Internal-path prefix filter (e.g. `world\generic`), case-insensitive; all models if
/// omitted.
prefix: Option<String>,
},
/// Sweep every WMO ROOT and cross-tab the two halves of the skybox mechanism: the root's
/// **MOSB** skybox model against its groups' `0x40000` flag. `0x40000` is undocumented, so this
/// is what *identifies* it — across all 815 roots the bit never appears without a MOSB. Note
@@ -627,6 +636,26 @@ enum Command {
/// Case-insensitive substring of the prop's model path (e.g. `lightray`); all if omitted.
filter: Option<String>,
},
/// MONKEY (interior attenuation): dump one WMO root's **MOLT fixture table** — type,
/// `useAtten`, colour × intensity, the authored attenuation start/end (yd), model-space
/// position, and the groups whose MOLR names each fixture (its rooms). Closes with what our
/// fixed falloff `1/(0.7d + 0.03d²)` is still worth at each fixture's own authored end: the
/// number behind "why does a 20-candle room read uniform".
Wmolights {
/// Internal path to the WMO **root** (forward or back slashes accepted).
internal_path: String,
},
/// MONKEY (interior prop lights): sweep every WMO **root** and audit whether its rooms have
/// any light at all — MOLT omni fixtures, MODD props that would SYNTHESISE one (the
/// `fire_light` flame/lamp routes), how many of those the 2.5 yd MOLT dedupe drops, and how
/// many INTERIOR groups no source claims, before and after the prop lane is admitted indoors.
Wmolamps {
/// Only roots whose internal path starts with this (e.g. `world\wmo\azeroth`).
prefix: Option<String>,
/// Also name the still-unlit interior groups of every root whose path contains this.
#[arg(long)]
detail: Option<String>,
},
/// Sweep every WMO **root** (optionally under a path prefix) and list the placed MODD props
/// the INTERIOR lighting lane commits as **literal black**. That lane's entire base light is
/// the MODD entry's own baked colour (ambient `cap96`, diffuse `floor112` — `0x694e90` →
@@ -870,6 +899,7 @@ fn main() -> Result<()> {
scan::partscan(&mut chain, mask, prefix.as_deref())?;
}
Command::M2lightscan { prefix } => scan::m2lightscan(&mut chain, prefix.as_deref())?,
Command::M2firescan { prefix } => scan::m2firescan(&mut chain, prefix.as_deref())?,
Command::Skyboxscan => scan::skyboxscan(&mut chain)?,
Command::Lightbands {
map,
@@ -910,6 +940,10 @@ fn main() -> Result<()> {
internal_path,
filter,
} => scan::wmodoodads(&mut chain, &internal_path, filter.as_deref())?,
Command::Wmolights { internal_path } => scan::wmolights(&mut chain, &internal_path)?,
Command::Wmolamps { prefix, detail } => {
scan::wmolamps(&mut chain, prefix.as_deref(), detail.as_deref())?
}
Command::Darkpropscan { prefix } => scan::darkpropscan(&mut chain, prefix.as_deref())?,
Command::Placescan {
map,
@@ -2,8 +2,10 @@
//! lanes, on both sides of the WMO/M2 boundary.
//!
//! `darkpropscan` asks which placed WMO props the interior lane commits as literal black
//! (decision 0969's census), `m2lightscan` which M2s author dynamic light blocks at all, and
//! `shadeat` reads the terrain MCSH shadow bit that decides a doodad's sun gain.
//! (decision 0969's census), `m2lightscan` which M2s author dynamic light blocks at all,
//! `m2firescan` which of the ones that DON'T would have a light SYNTHESISED from their flame
//! emitter (MONKEY, fire GO lights), and `shadeat` reads the terrain MCSH shadow bit that decides a
//! doodad's sun gain.
use std::collections::BTreeMap;
@@ -313,6 +315,167 @@ pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
Ok(())
}
/// MONKEY (fire GO lights) / MONKEY (lamp lights): sweep every `.m2` (optionally under a prefix)
/// and report which models would take a **synthesised** light, and BY WHICH ROUTE — the offline
/// audit of [`benilla_formats::fire_light`]'s two heuristics, which is the only way to see what a
/// rule applied to ~4000 world props actually does before shipping it.
///
/// The three routes, in the order the bake tries them:
/// - `flame` — a flame PARTICLE EMITTER. Per hit: the winning emitter's index/texture, the colour
/// read off its over-life ramp, the size bucket and the emitter's model-local position.
/// - `name` — the model's basename is lamp/lantern/chandelier/sconce vocabulary.
/// - `emissive` — no name hit, but an UNLIT + transparent + glow-textured render batch.
///
/// The lamp routes print `pos` as the emissive geoset's CENTROID and `+Zbase` as its height above
/// the model's own bounding-box floor — the number the whole lamp feature turns on, because a light
/// at the model origin sits at the foot of the pole. `BBOX` marks a hit that found no unlit
/// geometry and fell back to the bounding-box top centre.
///
/// Models that ALREADY author a casting point light are counted separately and skipped — the
/// authored block always wins, and the populations must never be conflated. The closing summary is
/// the deliverable: per-route totals, buckets, per-family counts, and a colour tally so an
/// over-broad texture key shows up as a flood of one hue.
pub fn m2firescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
let names = super::m2_names(chain, prefix)?;
let (mut scanned, mut authored, mut hits) = (0u32, 0u32, 0u32);
let mut buckets: BTreeMap<&'static str, u32> = BTreeMap::new();
// MONKEY (lamp lights): which of the three detection routes fired, per model.
let mut routes: BTreeMap<&'static str, u32> = BTreeMap::new();
let mut families: BTreeMap<String, u32> = BTreeMap::new();
// Rounded `(r, g, b) × 100` -> (count, one example).
let mut color_tally: BTreeMap<(i32, i32, i32), (u32, String)> = BTreeMap::new();
let mut rows: Vec<String> = Vec::new();
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
};
scanned += 1;
// The authored block wins entirely — same gate the asset bake applies.
if benilla_formats::parse_m2_lights(&bytes).iter().any(|l| l.casts()) {
authored += 1;
continue;
}
let emitters = benilla_formats::parse_m2_particle_emitters(&bytes).unwrap_or_default();
// MONKEY (lamp lights): route 1 is the flame emitter; routes 2/3 read the model's RENDER
// BATCHES, so the submesh parse only runs where the flame route found nothing (which is
// most of the corpus, but the parse is the same one the asset bake does anyway).
let (route, color, intensity, bucket, detail) = match
benilla_formats::synthesize_fire_light(&name, &emitters)
{
Some(fire) => {
let e = &emitters[fire.emitter];
let detail = format!(
"E{} {:<44} size {:.3} rate {:.1} strength {:.3} pos ({:.2}, {:.2}, {:.2}) bone {}",
fire.emitter,
e.texture.as_deref().unwrap_or("-"),
benilla_formats::fire_light::peak_size(e),
e.timing.peak_rate(),
benilla_formats::fire_light::fire_strength(e),
e.position[0], e.position[1], e.position[2],
e.bone,
);
(
benilla_formats::LightRoute::Flame,
fire.color,
fire.intensity,
fire.bucket,
detail,
)
}
None => {
let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[]) else {
continue;
};
let batches: Vec<benilla_formats::EmissiveBatch<'_>> =
subs.iter().map(benilla_formats::EmissiveBatch::from).collect();
let bounds = benilla_formats::parse_m2_bounds(&bytes).ok();
let bbox = bounds.as_ref().map(|b| (b.bbox_min, b.bbox_max));
let Some(lamp) = benilla_formats::synthesize_lamp_light(&name, &batches, bbox)
else {
continue;
};
// How high the light ended up above the model's own floor — the whole point of
// reading the geoset instead of using the origin. A lamppost must read ~4-5 yd.
let base = bbox.map_or(0.0, |(lo, _)| lo[2]);
let detail = format!(
"{:<6} {:<44} pos ({:.2}, {:.2}, {:.2}) +Zbase {:.2} bone {}",
match lamp.batch {
Some(i) => format!("B{i}"),
None => "BBOX".to_string(),
},
lamp.batch
.and_then(|i| subs[i].texture.as_deref())
.unwrap_or("(bbox top centre)"),
lamp.position[0], lamp.position[1], lamp.position[2],
lamp.position[2] - base,
lamp.bone,
);
(lamp.route, lamp.color, lamp.intensity, lamp.bucket, detail)
}
};
hits += 1;
*routes.entry(route.label()).or_default() += 1;
*buckets.entry(bucket).or_default() += 1;
*families.entry(super::family_of(&name)).or_default() += 1;
let key = (
(color[0] * 100.0).round() as i32,
(color[1] * 100.0).round() as i32,
(color[2] * 100.0).round() as i32,
);
color_tally.entry(key).or_insert_with(|| (0, name.clone())).0 += 1;
rows.push(format!(
"{name}\n [{:<8}] rgb ({:.3}, {:.3}, {:.3}) {:<11} x{:.2} {detail}",
route.label(),
color[0], color[1], color[2],
bucket, intensity,
));
}
rows.sort();
for r in &rows {
println!("{r}");
}
println!();
println!(
"=== summary === {scanned} models scanned, {authored} already author a casting point light (skipped), {hits} would SYNTHESISE one"
);
println!();
println!("=== by detection route ===");
for (r, n) in &routes {
println!("{r:<10} {n:>4}");
}
println!();
println!("=== by intensity bucket ===");
for (b, n) in &buckets {
println!("{b:<12} {n:>4}");
}
println!();
println!("=== by content family ===");
for (f, n) in &families {
println!("{f:<32} {n:>4}");
}
println!();
println!("=== derived colour tally (peak-normalised, rounded to 0.01) ===");
let mut ranked: Vec<_> = color_tally.iter().collect();
ranked.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count));
for (key, (count, example)) in ranked.iter().take(TALLY_ROWS) {
let (r, g, b) = (
key.0 as f32 / 100.0,
key.1 as f32 / 100.0,
key.2 as f32 / 100.0,
);
println!(
"{count:>4}x ({r:.2}, {g:.2}, {b:.2}) {:<5} e.g. {example}",
hue_tag(r, g, b)
);
}
if let Some(rest) = ranked.len().checked_sub(TALLY_ROWS).filter(|n| *n > 0) {
println!(" … and {rest} rarer colours (top {TALLY_ROWS} shown)");
}
Ok(())
}
/// The terrain MCSH shadow bit at a world position + an ASCII texel neighborhood (`#` shadowed,
/// `.` lit, `?` off-tile/no-chunk). One MCSH texel is `TILE_SIZE/1024` ≈ 0.52 yd; the grid spans
/// ±8 texels so a doodad base sitting one texel from a shadow edge — the 2.5-vs-0.5 intensity
@@ -354,3 +517,519 @@ pub fn shadeat(chain: &mut Chain, map: &str, x: f32, y: f32) -> Result<()> {
}
Ok(())
}
/// MONKEY (interior attenuation): dump ONE WMO root's **MOLT fixture table** — the authored
/// numbers the interior lane is calibrated against, which nothing could read before this.
///
/// Per fixture: type, the `useAtten` byte, the raw colour, the intensity, the product the packer
/// commits (`colour × intensity`, [`benilla_world::lighting::commit_raw`]'s input), the authored
/// attenuation **start/end** in yards (`+0x28`/`+0x2c` — see `read_wmo_light`), model-space
/// position, and the groups whose MOLR names it (its ROOMS: a fixture no group names keeps none
/// and stays ungated). The closing block is the number the flatness question actually needs: for
/// each omni fixture, the value of our fixed falloff `1/(0.7d + 0.03d²)` at its own authored end
/// — i.e. how much reach the curve still has where the artist said the light stops.
/// MONKEY (portal claims): everything ONE WMO root's room-claim rule needs, read off the chain in a
/// single group-file pass — the offline twin of what `benilla_world`'s spawner holds in the loaded
/// `WmoModel`. Both feed [`benilla_formats::room_claims`], so the claim set this tool prints IS the
/// claim set the shader will gate on.
struct RootRooms {
infos: Vec<benilla_formats::WmoGroupInfo>,
/// Per absolute group: its MOGP flags and its `(portal_ref_start, portal_ref_count)` slice.
flags: Vec<u32>,
slices: Vec<(u16, u16)>,
portals: benilla_formats::WmoPortals,
/// Per absolute group: the MOLR light indices it names.
light_refs: Vec<Vec<u16>>,
/// MODD index -> the groups whose MODR names it (the prop's rooms).
doodad_groups: BTreeMap<u16, Vec<u16>>,
}
impl RootRooms {
fn graph(&self) -> benilla_formats::PortalGraph<'_> {
benilla_formats::PortalGraph {
vertices: &self.portals.vertices,
infos: &self.portals.infos,
refs: &self.portals.refs,
slices: &self.slices,
}
}
/// The groups whose MOLR names light `i` — the claim rule's MOLR input.
fn molr(&self, i: u16) -> Vec<u16> {
self.light_refs
.iter()
.enumerate()
.filter(|(_, refs)| refs.contains(&i))
.map(|(g, _)| g as u16)
.collect()
}
}
fn root_rooms(
chain: &mut Chain,
root: &benilla_formats::WmoRoot,
root_path: &str,
bytes: &[u8],
) -> RootRooms {
let stem = root_path
.to_ascii_lowercase()
.strip_suffix(".wmo")
.unwrap_or(root_path)
.to_string();
let n = root.group_count();
let (mut flags, mut slices, mut light_refs) = (Vec::new(), Vec::new(), Vec::new());
let mut doodad_groups: BTreeMap<u16, Vec<u16>> = BTreeMap::new();
for gi in 0..n {
let gbytes = chain.read_file(&format!("{stem}_{gi:03}.wmo")).ok();
let h = gbytes
.as_deref()
.and_then(benilla_formats::wmo_group_header);
flags.push(h.as_ref().map_or(0, |h| h.flags));
slices.push(h.map_or((0, 0), |h| (h.portal_ref_start, h.portal_ref_count)));
light_refs.push(
gbytes
.as_deref()
.map(benilla_formats::wmo_group_light_refs)
.unwrap_or_default(),
);
for r in gbytes
.as_deref()
.map(benilla_formats::wmo_group_doodad_refs)
.unwrap_or_default()
{
let e = doodad_groups.entry(r).or_default();
if e.last() != Some(&(gi as u16)) {
e.push(gi as u16);
}
}
}
RootRooms {
infos: root.group_infos().to_vec(),
flags,
slices,
portals: benilla_formats::parse_wmo_portals(bytes),
light_refs,
doodad_groups,
}
}
/// One claim set as a compact cell: `g4e:in g7i:portal1 3.2/w5.0`. A trailing `*` marks a claim the
/// EXTERIOR batch lane will NOT honour (a district-scale shell — `room_claim::CLAIM_EXT_SHELL_YD`);
/// it still gates the fixture, it just cannot light a whole city block.
///
/// MONKEY (soft portal claims): a portal claim prints
/// `portal<hops> <distance>/w<fade radius>+<doorway slack>x<entry weight>` — the four numbers the
/// shader's weight is built from (`w = entry * (1 - smoothstep(0, radius, |P - door| - slack))`),
/// so the instrument can be read against a screenshot of the seam. A base claim prints no numbers:
/// it is HARD, weight 1 across the whole group.
fn claim_cell(claims: &[benilla_formats::Claim]) -> String {
if claims.is_empty() {
return "- (UNGATED: no room claims it)".to_string();
}
claims
.iter()
.map(|c| {
let (hop, d) = if c.how == benilla_formats::ClaimHow::Portal {
(
c.hops.to_string(),
format!(
" {:.1}/w{:.1}+{:.1}x{:.2}",
c.distance, c.fade_radius, c.fade_slack, c.fade_entry,
),
)
} else {
(String::new(), String::new())
};
format!(
"g{}{}:{}{hop}{d}{}",
c.group,
if c.interior { "i" } else { "e" },
c.how.tag(),
if c.ext_ok { "" } else { "*" },
)
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn wmolights(chain: &mut Chain, raw_path: &str) -> Result<()> {
let root_path = raw_path.replace('/', "\\").to_ascii_lowercase();
let bytes = chain
.read_file(&root_path)
.with_context(|| format!("reading WMO root '{root_path}'"))?;
let root = benilla_formats::parse_wmo_root(&bytes)
.with_context(|| format!("parsing WMO root '{root_path}'"))?;
let lights = benilla_formats::parse_wmo_lights(&bytes);
// MONKEY (portal claims): the group table + the portal graph, read once — the same inputs the
// spawner feeds `room_claims`, so what prints below is what the room gate will enforce.
let rr = root_rooms(chain, &root, &root_path, &bytes);
let infos = root.group_infos();
println!(
"{root_path} — {} MOLT light(s), {} group(s), {} portal(s)",
lights.len(),
root.group_count(),
rr.portals.infos.len(),
);
// The group table: the class the whole lighting split turns on. An `e` group INSIDE a building
// (an inn's basement stairwell, a covered porch) is drawn by the EXTERIOR law and takes the
// night sky unless an interior fixture claims it — that is bug B's second cause, and this is
// where you read which groups are in that population.
println!("=== groups (class, MOGP flags, MOGI box, portals, MOLR fixtures) ===");
for gi in 0..root.group_count() as usize {
let Some(g) = infos.get(gi) else { continue };
let (_, pc) = rr.slices.get(gi).copied().unwrap_or((0, 0));
println!(
" g{gi:<3} {} flags {:#010x} box ({:>8.2},{:>8.2},{:>7.2})..({:>8.2},{:>8.2},{:>7.2}) portals {pc:<3} molr {:?}",
if g.interior { "INT" } else { "ext" },
rr.flags.get(gi).copied().unwrap_or(0),
g.bbox_min[0], g.bbox_min[1], g.bbox_min[2],
g.bbox_max[0], g.bbox_max[1], g.bbox_max[2],
rr.light_refs.get(gi).map(|v| v.len()).unwrap_or(0),
);
}
println!();
for (i, l) in lights.iter().enumerate() {
let prod = [
l.color[0] * l.intensity,
l.color[1] * l.intensity,
l.color[2] * l.intensity,
];
let molr = rr.molr(i as u16);
let reach = benilla_formats::room_claim::claim_reach(l.attenuation_end);
let claims = benilla_formats::room_claims(&rr.infos, rr.graph(), l.position, reach, &molr);
println!(
" L{i:<3} type {}{} colour ({:.3}, {:.3}, {:.3}) x {:.3} = ({:.3}, {:.3}, {:.3}) atten [{:.3}, {:.3}] R {reach:.1} pos ({:>8.2},{:>8.2},{:>8.2})",
l.light_type,
if l.use_atten { " atten" } else { " " },
l.color[0], l.color[1], l.color[2],
l.intensity,
prod[0], prod[1], prod[2],
l.attenuation_start, l.attenuation_end,
l.position[0], l.position[1], l.position[2],
);
if l.is_omni() {
println!(" claims {}", claim_cell(&claims));
}
}
// What our FIXED curve is still worth at the artist's own cutoff — the flatness measurement.
println!();
println!("=== the fixed falloff 1/(0.7d + 0.03d²) at each omni fixture's authored end ===");
let omni: Vec<&benilla_formats::WmoLight> = lights.iter().filter(|l| l.is_omni()).collect();
for (i, l) in omni.iter().enumerate() {
let d = l.attenuation_end.max(1e-3);
println!(
" omni {i:<3} end {:>7.3} yd atten(end) {:.4} atten(end)/atten(1yd) {:.4}",
l.attenuation_end,
1.0 / (0.7 * d + 0.03 * d * d),
(1.0 / (0.7 * d + 0.03 * d * d)) / (1.0 / 0.73),
);
}
let ends: Vec<f32> = omni.iter().map(|l| l.attenuation_end).collect();
if !ends.is_empty() {
let mut sorted = ends.clone();
sorted.sort_by(f32::total_cmp);
println!(
" {} omni: end min {:.3} median {:.3} max {:.3}",
sorted.len(),
sorted[0],
sorted[sorted.len() / 2],
sorted[sorted.len() - 1],
);
}
Ok(())
}
/// MONKEY (interior prop lights): what ONE prop model contributes as a light source, resolved once
/// per model path and memoised across the whole sweep (a city references the same lantern hundreds
/// of times).
#[derive(Clone)]
enum PropLightKind {
/// The M2 authors a casting light block — every lane already spawns it, synthetic or not.
Authored,
/// No authored block, but `fire_light`'s flame or lamp route derives one: the model-space
/// position of the FLAME (or the lamp glass) and its intensity.
Synth { pos: [f32; 3], intensity: f32 },
/// Neither — a chair, a barrel, a rug.
None,
}
/// Rotate `v` by the MODD orientation quaternion `(x, y, z, w)` — `v + 2q×(q×v + wv)`. Written out
/// rather than pulled from a math crate because `benilla-formats` deliberately carries none, and
/// this is the only place the offline audit needs to place a prop.
fn quat_rotate(q: [f32; 4], v: [f32; 3]) -> [f32; 3] {
let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
let cross = |a: [f32; 3], b: [f32; 3]| {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
};
let u = [x, y, z];
let t = cross(u, v);
let t = [t[0] + w * v[0], t[1] + w * v[1], t[2] + w * v[2]];
let t = cross(u, t);
[v[0] + 2.0 * t[0], v[1] + 2.0 * t[1], v[2] + 2.0 * t[2]]
}
/// The prop-light rule resolved for one model path: the SAME chain `benilla_assets::m2` applies at
/// asset load (an authored block wins; else the flame route; else the lamp route), so a prop this
/// sweep counts as a light source is exactly one the runtime will spawn a light for.
fn prop_light_kind(chain: &mut Chain, key: &str) -> PropLightKind {
let Ok(bytes) = chain.read_file(key) else {
return PropLightKind::None; // a prop the client doesn't ship
};
if benilla_formats::parse_m2_lights(&bytes)
.iter()
.any(|l| l.casts())
{
return PropLightKind::Authored;
}
let emitters = benilla_formats::parse_m2_particle_emitters(&bytes).unwrap_or_default();
if let Some(fire) = benilla_formats::synthesize_fire_light(key, &emitters) {
return PropLightKind::Synth {
pos: emitters[fire.emitter].position,
intensity: fire.intensity,
};
}
let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[]) else {
return PropLightKind::None;
};
let batches: Vec<benilla_formats::EmissiveBatch<'_>> = subs
.iter()
.map(benilla_formats::EmissiveBatch::from)
.collect();
let bounds = benilla_formats::parse_m2_bounds(&bytes).ok();
let bbox = bounds.as_ref().map(|b| (b.bbox_min, b.bbox_max));
match benilla_formats::synthesize_lamp_light(key, &batches, bbox) {
Some(lamp) => PropLightKind::Synth {
pos: lamp.position,
intensity: lamp.intensity,
},
None => PropLightKind::None,
}
}
/// MONKEY (wmo exterior points): how close an authored MOLT fixture has to stand to a prop's flame
/// (yd) for the synthesised light to be dropped as a duplicate. **Keep equal to
/// `benilla_world::terrain_stream::spawn::fx`'s `MODD_SYNTH_DEDUPE`** — the point of this sweep is
/// to predict what that dedupe does across the corpus.
const MODD_SYNTH_DEDUPE: f32 = 2.5;
/// One root's audit numbers.
struct LampRow {
path: String,
groups: usize,
interior_groups: usize,
molt: usize,
props: usize,
authored: usize,
synth: usize,
deduped: usize,
/// Interior groups no MOLT fixture claims (the pre-fix darkness).
dark_molt: usize,
/// …and still none after the synthesised prop lights are admitted indoors.
dark_after: usize,
}
/// MONKEY (interior prop lights): the corpus audit behind "make sure all WMOs with built-in light
/// sources have light working". For every WMO root in the chain: its MOLT omni fixtures, its MODD
/// props that WOULD synthesise a light (`fire_light`'s flame/lamp routes, run on the prop's own M2
/// exactly as the asset bake runs them), how many of those the 2.5 yd MOLT dedupe drops, and how
/// many INTERIOR groups end up with no light source claiming them at all — before and after the
/// prop lane is admitted indoors.
///
/// A group left in the `darkAFTER` column is either the artist's intent (a cellar, a closet, a
/// sealed shaft) or a detection miss; `detail` names them per root so the two can be told apart.
pub fn wmolamps(chain: &mut Chain, prefix: Option<&str>, detail: Option<&str>) -> Result<()> {
let roots = super::wmo_roots(chain, prefix)?;
// The buildings the owner actually walks through — always reported, wherever they rank.
const LANDMARKS: [&str; 16] = [
"buildings\\stormwind\\stormwind.wmo",
"buildings\\goldshireinn\\goldshireinn.wmo",
"buildings\\nsabbey\\nsabbey.wmo",
"cities\\ironforge\\ironforge.wmo",
"undercity\\undercity.wmo",
"ogrimmar.wmo",
"darnassis.wmo",
"az_deadmines_a.wmo",
"az_deadmines_b.wmo",
"stormwindjail.wmo",
"stormwindprison.wmo",
"westfall_inn.wmo",
"duskwood_inn.wmo",
"redridge_inn.wmo",
"human_farm",
"monestary_cathedral.wmo",
];
let mut cache: BTreeMap<String, PropLightKind> = BTreeMap::new();
let mut rows: Vec<LampRow> = Vec::new();
let mut n_roots = 0u32;
for root_path in roots {
let Ok(bytes) = chain.read_file(&root_path) else {
continue;
};
// The listfile's own casing varies; every match below (landmarks, `detail`) is on the
// lowercased path, which is also the casing `root_rooms` builds its group-file names in.
let root_path = root_path.to_ascii_lowercase();
let Ok(root) = benilla_formats::parse_wmo_root(&bytes) else {
continue;
};
let lights = benilla_formats::parse_wmo_lights(&bytes);
let omni: Vec<benilla_formats::WmoLight> =
lights.iter().filter(|l| l.is_omni()).cloned().collect();
// Nothing to say about a root with no interior geometry and no fixtures (a bridge, a fence).
if root.group_infos().iter().all(|g| !g.interior) && omni.is_empty() {
continue;
}
n_roots += 1;
let rr = root_rooms(chain, &root, &root_path, &bytes);
let n_groups = rr.infos.len();
// Which groups anything claims — the MOLT fixtures first, then the props.
let mut lit_by_molt = vec![false; n_groups];
let mut lit_by_prop = vec![false; n_groups];
for (i, l) in lights.iter().enumerate() {
if !l.is_omni() {
continue;
}
let molr = rr.molr(i as u16);
let reach = benilla_formats::room_claim::claim_reach(l.attenuation_end);
for c in benilla_formats::room_claims(&rr.infos, rr.graph(), l.position, reach, &molr) {
if let Some(slot) = lit_by_molt.get_mut(usize::from(c.group)) {
*slot = true;
}
}
}
let (mut props, mut authored, mut synth, mut deduped) = (0usize, 0usize, 0usize, 0usize);
for (di, d) in root.doodads().iter().enumerate() {
if d.model.is_empty() {
continue;
}
props += 1;
let key = model_key(&d.model);
if !cache.contains_key(&key) {
let k = prop_light_kind(chain, &key);
cache.insert(key.clone(), k);
}
let (pos, intensity) = match cache[&key] {
PropLightKind::Authored => {
authored += 1;
continue; // an authored prop light is not this fix's population
}
PropLightKind::None => continue,
PropLightKind::Synth { pos, intensity } => (pos, intensity),
};
// The flame in WMO model space: the prop's placement applied to the M2-local point.
let r = quat_rotate(
d.orientation,
[pos[0] * d.scale, pos[1] * d.scale, pos[2] * d.scale],
);
let world = [
d.position[0] + r[0],
d.position[1] + r[1],
d.position[2] + r[2],
];
let dup = omni.iter().any(|l| {
let dv = [
l.position[0] - world[0],
l.position[1] - world[1],
l.position[2] - world[2],
];
dv[0] * dv[0] + dv[1] * dv[1] + dv[2] * dv[2]
< MODD_SYNTH_DEDUPE * MODD_SYNTH_DEDUPE
});
if dup {
deduped += 1;
continue;
}
synth += 1;
let molr = rr
.doodad_groups
.get(&(di as u16))
.cloned()
.unwrap_or_default();
let reach = benilla_formats::room_claim::claim_reach(
benilla_formats::room_claim::m2_light_reach(intensity),
);
for c in benilla_formats::room_claims(&rr.infos, rr.graph(), world, reach, &molr) {
if let Some(slot) = lit_by_prop.get_mut(usize::from(c.group)) {
*slot = true;
}
}
}
let interior: Vec<usize> = (0..n_groups).filter(|&g| rr.infos[g].interior).collect();
let dark_molt = interior.iter().filter(|&&g| !lit_by_molt[g]).count();
let dark: Vec<usize> = interior
.iter()
.copied()
.filter(|&g| !lit_by_molt[g] && !lit_by_prop[g])
.collect();
if detail.is_some_and(|d| root_path.contains(&d.to_ascii_lowercase())) && !dark.is_empty() {
let names: Vec<String> = dark.iter().map(|g| format!("g{g}")).collect();
println!("{root_path}: interior groups still unlit: {}", names.join(" "));
}
rows.push(LampRow {
path: root_path.clone(),
groups: n_groups,
interior_groups: interior.len(),
molt: omni.len(),
props,
authored,
synth,
deduped,
dark_molt,
dark_after: dark.len(),
});
}
let sum = |f: fn(&LampRow) -> usize| rows.iter().map(f).sum::<usize>();
println!();
println!("=== wmolamps: {n_roots} WMO root(s) with interior geometry or MOLT fixtures ===");
println!(
" MOLT omni fixtures {} MODD props {} props with an AUTHORED light {}",
sum(|r| r.molt),
sum(|r| r.props),
sum(|r| r.authored),
);
println!(
" props that SYNTHESISE a light {} of which dropped by the {MODD_SYNTH_DEDUPE} yd MOLT dedupe {} kept {}",
sum(|r| r.synth) + sum(|r| r.deduped),
sum(|r| r.deduped),
sum(|r| r.synth),
);
println!(
" INTERIOR groups {} claimed by no MOLT fixture {} still unclaimed after the prop lights {}",
sum(|r| r.interior_groups),
sum(|r| r.dark_molt),
sum(|r| r.dark_after),
);
let header = " root grp int MOLT props synth dedup darkMOLT darkAFTER";
println!();
println!("=== landmarks ===");
println!("{header}");
for row in rows
.iter()
.filter(|r| LANDMARKS.iter().any(|l| r.path.contains(l)))
{
print_lamp_row(row);
}
let mut ranked: Vec<&LampRow> = rows.iter().filter(|r| r.dark_molt > 0).collect();
ranked.sort_by(|a, b| b.dark_molt.cmp(&a.dark_molt).then(b.molt.cmp(&a.molt)));
println!();
println!("=== the 25 roots with the most interior rooms no MOLT fixture reaches ===");
println!("{header}");
for row in ranked.into_iter().take(25) {
print_lamp_row(row);
}
Ok(())
}
fn print_lamp_row(r: &LampRow) {
let short = r.path.rsplit('\\').next().unwrap_or(&r.path);
println!(
" {short:<58} {:>3} {:>3} {:>4} {:>5} {:>5} {:>5} {:>8} {:>9}",
r.groups, r.interior_groups, r.molt, r.props, r.synth, r.deduped, r.dark_molt, r.dark_after,
);
}
@@ -30,7 +30,7 @@ mod skeleton;
mod world;
pub use geometry::{animboundscan, bbfacescan, bbscan, geosetscan, groundscan, normalscan};
pub use lighting::{darkpropscan, m2lightscan, shadeat};
pub use lighting::{darkpropscan, m2firescan, m2lightscan, shadeat, wmolamps, wmolights};
pub use material::{alphascan, blendscan, envmapscan, texmodescan, uvslotscan, uvwrapscan};
pub use particles::{
cellscan, fxordercensus, partcensus, partscan, partslotscan, ribbonscan, shardcensus,
File diff suppressed because it is too large Load Diff
+12
View File
@@ -218,6 +218,18 @@ pub use particles::{
parse_m2_particle_emitters, CellRamp, OverLife, OverLifeSample, ParticleBlend,
ParticleEmitterDef, ParticleShape, SplineData,
};
// MONKEY (fire GO lights): the SYNTHESISED-light rule for fire props that author no light block —
// derived from the model's own flame emitter. Beside `particles` because that is its whole input.
pub mod fire_light;
pub use fire_light::{synthesize_fire_light, SyntheticFire};
// MONKEY (lamp lights): the SECOND route in that same module — an emissive lamp/lantern
// GEOSET rather than a flame emitter (a lamppost authors no particles at all).
pub use fire_light::{synthesize_lamp_light, EmissiveBatch, LightRoute, SyntheticLamp};
// MONKEY (portal claims): which ROOMS one fixture may light -- containment, MOLR, and one portal
// hop. Shared by the runtime spawner and the `wmolights`/`wmolamps` audits so the gate the shader
// enforces and the gate the instrument prints can never be two different rules.
pub mod room_claim;
pub use room_claim::{claim_groups, room_claims, Claim, ClaimHow, PortalGraph};
mod ribbons;
pub use ribbons::{parse_m2_ribbon_emitters, RibbonEmitterDef, RibbonVisibility};
mod value_track;
+676
View File
@@ -0,0 +1,676 @@
//! MONKEY (portal claims): **which rooms one light fixture may light** — the single rule, shared by
//! the runtime spawner (`benilla_world::terrain_stream::spawn`'s MOLT and MODD-prop lanes, which
//! turn it into the per-light claim list the room gate packs) and the offline audit
//! (`benilla-extract wmolights` / `wmolamps`, which is the only instrument that can measure it
//! across the shipped corpus). One rule, one implementation — the `fire_light` precedent.
//!
//! The gate itself lives in `static_gx.wgsl`: a WMO surface of group `G` takes an interior fixture
//! only if that fixture's claim set contains `G`. The set has three sources, in this priority:
//!
//! 1. **CONTAINMENT** — the interior groups whose authored MOGI bounding box holds the fixture.
//! The dense supplement to the sparse MOLR relation (the Goldshire inn authors a MOLR on 2 of
//! its 12 groups; Northshire abbey leaves 7 of 14 unnamed), ordered TIGHTEST BOX FIRST so the
//! room a fixture actually stands in outranks the building-spanning shell that also contains it.
//! 2. **MOLR** — the groups whose authored light-ref list names the fixture. Authored for the
//! reference's own purpose (register a GL light while drawing a visible group's doodads), which
//! is why it cannot carry the gate alone, but it is the artist speaking and it is never wrong.
//! 3. **PORTAL-ADJACENT** — the groups up to [`ROOM_CLAIM_HOPS`] portal hops from a claimed group,
//! when the doorways lie within the fixture's effective radius. This is the "light crosses an
//! open doorway" rule: without it a fixture stops dead at its room's edge and a continuous floor
//! changes brightness in a straight line at the group boundary, which is exactly the seam the
//! owner reported. Nearest portal first, so a doorway three rooms away never takes the slot a
//! doorway in this wall wants.
//!
//! MONKEY (soft portal claims): a portal claim is **not binary**. It carries the doorway it came
//! through ([`Claim::fade_center`]/[`Claim::fade_slack`]) and the reach left over on the far side
//! ([`Claim::fade_radius`]), and the shader turns those into a WEIGHT in `[0, 1]` — 1 in the
//! doorway, 0 where the reach runs out. That is what actually closes the seam: a binary claim
//! merely moved the step from "this room" to "the last room the hop reached", and admitting a
//! fixture on one side of a group plane while refusing it on the other is a step wherever the
//! FLOOR crosses that plane, which is precisely what a doorway is. Containment and MOLR claims
//! stay hard (weight 1) — a fixture standing in a room lights all of it.
//!
//! Sources 1+2 are the **base**: they must never be truncated (a dropped containment claim is a
//! black room, and the packer's overflow arm deliberately fails OPEN for that reason). Source 3 only
//! ever fills slots the base left free — a dropped portal claim is one doorway that does not carry
//! light, which is the behaviour we already had.
use crate::{WmoGroupInfo, WmoPortalInfo, WmoPortalRef};
/// Claims one fixture can carry. **Must equal `benilla_world::lighting::ROOM_CLAIM_MAX` and
/// `ROOM_CLAIM_MAX` in `static_gx.wgsl`** — past it the packer gives up and packs the fixture
/// UNGATED, so a claim list built here longer than this is not a wider gate, it is no gate at all.
pub const ROOM_CLAIM_MAX: usize = 6;
/// The portal graph of ONE WMO root, as the claim rule reads it: the MOPV vertex pool, the MOPT
/// portal records (each a slice of that pool plus its plane), the MOPR edges, and the per-group
/// slice of those edges (`MOGP +0x24/+0x26`, i.e. [`crate::WmoGroupHeader::portal_ref_start`] /
/// `portal_ref_count`) indexed by ABSOLUTE group index. All in WMO model space, WoW axes — the same
/// space [`WmoGroupInfo`]'s boxes and a MOLT record's position are in, which is why the whole rule
/// can run before anything is placed in the world.
#[derive(Clone, Copy, Default)]
pub struct PortalGraph<'a> {
pub vertices: &'a [[f32; 3]],
pub infos: &'a [WmoPortalInfo],
pub refs: &'a [WmoPortalRef],
pub slices: &'a [(u16, u16)],
}
/// How a group came to be claimed — the audit's readout, and the reason the list is ordered.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ClaimHow {
/// The group's MOGI box contains the fixture.
Contains,
/// The group's MOLR names the fixture.
Molr,
/// One portal hop from a claimed group, the portal within the fixture's reach.
Portal,
}
impl ClaimHow {
pub fn tag(self) -> &'static str {
match self {
ClaimHow::Contains => "in",
ClaimHow::Molr => "molr",
ClaimHow::Portal => "portal",
}
}
}
/// One claimed group.
#[derive(Clone, Copy, Debug)]
pub struct Claim {
pub group: u16,
pub how: ClaimHow,
/// Yards from the fixture to the portal it came through ([`ClaimHow::Portal`] only; 0 else).
pub distance: f32,
/// The claimed group's own class — `false` for an EXTERIOR-flagged group (MOGP `& 0x48`).
/// Carried because an exterior-class group inside a building (an inn's basement stairwell, a
/// covered porch) is drawn by the exterior law and needs a claim to be lit at all.
pub interior: bool,
/// May the EXTERIOR batch lane honour this claim? See [`CLAIM_EXT_SHELL_YD`]: a district-scale
/// shell is kept in the set (dropping it would leave the fixture with NO claims, and the
/// packer's no-claims arm fails OPEN — a far worse leak than the one being avoided) but is
/// barred from the exterior lane, where it would put a tavern candle on the street outside.
/// The interior lane is unaffected either way: it never runs on an exterior-class group's
/// surfaces, so an exterior claim exists for the exterior lane and for nothing else.
pub ext_ok: bool,
/// MONKEY (soft portal claims): how many doorways the light had to cross to reach this group —
/// `0` for the base (containment/MOLR), `1`/`2` for a portal hop. Ordering and audit readout
/// only; the shader never sees it (the fade radius below already encodes "how much reach is
/// left", which is the same fact measured in yards instead of doors).
pub hops: u8,
/// MONKEY (soft portal claims): the centre of the portal this claim came through, in the SAME
/// space `pos` is in (WMO model space, WoW axes) — the point the shader's weight fades away
/// from. Meaningless when `fade_radius == 0`.
pub fade_center: [f32; 3],
/// MONKEY (soft portal claims): the portal polygon's own bounding-SPHERE radius (yd). Distance
/// measured from `fade_center` is reduced by this before the fade, so every fragment standing
/// IN the doorway reads weight 1 — which is what makes the far side of the threshold
/// continuous with the near side (see [`Claim::fade_radius`]).
pub fade_slack: f32,
/// MONKEY (soft portal claims): the yards of the fixture's reach still unspent when it arrived
/// at `fade_center` — the distance over which the shader smoothsteps this claim's weight from
/// 1 (at the doorway) to 0. **`0` means a HARD claim: weight 1 everywhere**, which is what
/// every containment/MOLR claim carries, because a fixture standing in a room lights all of it.
///
/// This is what turns the binary gate soft. The gate used to answer yes/no per group, so a
/// fixture admitted on one side of a group boundary and refused on the other put a straight
/// brightness STEP across any floor that crossed it — the scar on the Lion's Pride Inn's
/// upstairs landing, where two groups meet mid-plank. With a weight the near side is 1 by
/// containment, the far side is ~1 at the threshold (distance to the doorway ~ 0) and decays
/// to 0 where the fixture's reach runs out anyway, so nothing steps at the plane.
pub fade_radius: f32,
/// MONKEY (soft portal claims): the weight this claim ALREADY carries at its own doorway — 1
/// for a base claim and for a first hop, and for a second hop the weight the FIRST hop's fade
/// had already fallen to by the time it reached this door. The shader multiplies its
/// smoothstep by it.
///
/// Without it the fade is continuous at the first threshold and discontinuous at the second,
/// which is the same bug one room further in: the near room reads (say) 0.4 at its far door
/// because the first fade has been decaying across it, while the room beyond restarts its own
/// fade at 1.0 — a step, and a step the WRONG WAY (the further room brighter than the nearer
/// one). Seeding each hop with its predecessor's value at that point makes the whole chain
/// continuous by construction, and monotone: light never gets stronger further from its source.
pub fade_entry: f32,
}
/// The `interiorAttenScale` DEFAULT. A claim set is built ONCE, at spawn, and never rebuilt — but
/// the reach the shader windows a fixture with is live (the cvar). So the portal hop is measured
/// against the DEFAULT reach rather than the live one: turning the cvar up widens every pool but
/// cannot open a doorway that was gated at spawn, and turning it down cannot slam one shut
/// mid-frame. That is the stable half of the trade, and it keeps the claim table free of per-frame
/// work; the alternative (re-claiming every fixture whenever the cvar moves) buys a doorway's worth
/// of light for a rebuild of the whole table. **Mirrors `DynamicInteriors::atten_scale`'s default.**
pub const CLAIM_ATTEN_SCALE: f32 = 1.6;
/// The cap on any claim reach — `benilla_world::lighting`'s `INTERIOR_LEGACY_REACH` /
/// `POINT_LIGHT_RANGE`, i.e. the widest a fixture's window is ever packed.
pub const CLAIM_REACH_MAX: f32 = 48.0;
/// The effective radius (yd) a fixture's portal hop is measured against: its AUTHORED MOLT
/// attenuation end scaled by [`CLAIM_ATTEN_SCALE`], clamped exactly as the packer's
/// `interior_reach` clamps it.
pub fn claim_reach(authored_end: f32) -> f32 {
(authored_end * CLAIM_ATTEN_SCALE).clamp(1.0, CLAIM_REACH_MAX)
}
/// The interior reach (yd) of an **M2** point light of committed intensity `i` — the bucket ladder
/// [`crate::fire_light::fire_intensity`] buckets a synthesised flame onto (candle 0.6 / torch 1.5 /
/// brazier 2.0 / bonfire 3.0), because the authored M2 attenuation pair is demonstrably not a reach
/// in this corpus (a Karazhan BONFIRE authors `end = 0.97` yd). Lives here rather than in the
/// packer that reads it so the offline audit buckets a prop's synthesised light onto the very same
/// rung the runtime will; `benilla_world::lighting::m2_light_reach` delegates to it.
pub fn m2_light_reach(intensity: f32) -> f32 {
match intensity {
i if i < 1.0 => 6.0, // candle
i if i < 1.75 => 12.0, // torch / campfire
i if i < 2.5 => 16.0, // brazier
_ => 24.0, // bonfire / forge
}
}
/// The AABB of one portal's polygon, or `None` when the record names no usable vertices.
fn portal_box(portals: &PortalGraph, portal: u16) -> Option<([f32; 3], [f32; 3])> {
let info = portals.infos.get(usize::from(portal))?;
let start = usize::from(info.start_vertex);
let end = start.checked_add(usize::from(info.count))?;
let verts = portals.vertices.get(start..end)?;
let (first, rest) = verts.split_first()?;
let mut lo = *first;
let mut hi = *first;
for v in rest {
for a in 0..3 {
lo[a] = lo[a].min(v[a]);
hi[a] = hi[a].max(v[a]);
}
}
Some((lo, hi))
}
/// A portal as the claim rule measures it, from `pos`: `(distance, centre, slack)`.
///
/// **distance** — yards from `pos` to the portal's polygon, approximated by its AABB (the closest
/// point on the box), which is exact for the axis-aligned doorways most portals are and never
/// over-states the distance for the rest (erring toward admitting a doorway, which is the safe
/// direction: an extra claim is a room lit one doorway too far, a missing one is the seam we are
/// fixing).
///
/// MONKEY (soft portal claims): **centre** and **slack** (the box's half-diagonal) are the fade
/// geometry the shader gets. Fading from the centre alone would put a step back across the WIDTH
/// of the door — a fragment at the edge of a 1.2 x 2.5 yd doorway is ~1.4 yd from its centre, so
/// with a short remaining reach it would read half-lit one plank away from a fully lit fragment on
/// the other side of the plane. Subtracting the slack collapses the whole doorway to distance 0, so
/// the threshold is weight 1 on BOTH sides by construction and the fade starts where the door frame
/// ends. A sphere rather than the box because it is one number: slightly generous just outside the
/// frame, which is the direction that cannot re-introduce an edge.
fn portal_reach(portals: &PortalGraph, portal: u16, pos: [f32; 3]) -> Option<(f32, [f32; 3], f32)> {
let (lo, hi) = portal_box(portals, portal)?;
let mut d2 = 0.0f32;
let mut center = [0.0f32; 3];
let mut diag2 = 0.0f32;
for a in 0..3 {
let over = (lo[a] - pos[a]).max(pos[a] - hi[a]).max(0.0);
d2 += over * over;
center[a] = 0.5 * (lo[a] + hi[a]);
let half = 0.5 * (hi[a] - lo[a]).max(0.0);
diag2 += half * half;
}
Some((d2.sqrt(), center, diag2.sqrt()))
}
/// MONKEY (soft portal claims): how many portal hops a claim may travel. **1 is not enough**: a
/// fixture two rooms from a fragment is claimed on the near side of a group boundary (one hop) and
/// not at all on the far side, which is a hard step again — the very seam the fade exists to
/// remove, moved one room outward. Two hops means the far side also carries the fixture, at the
/// small weight its remaining reach affords. Three would buy nothing measurable: by the second
/// doorway the unspent reach is already at [`CLAIM_FADE_MIN_YD`] in most vanilla interiors, and
/// each extra hop multiplies the frontier while competing for the same six slots.
pub const ROOM_CLAIM_HOPS: u8 = 2;
/// MONKEY (soft portal claims): the floor on a portal claim's fade radius (yd). A fixture that only
/// just reaches a doorway has ~0 reach left, and a 0-yard smoothstep is a step function — i.e. the
/// hard edge again, at the doorway instead of at the group plane. Two yards is the shortest fade
/// that reads as a fade at walking speed, and a fixture that far gone contributes almost nothing
/// anyway (its own `interior_window` is at the end of its curve), so the floor cannot leak light.
pub const CLAIM_FADE_MIN_YD: f32 = 2.0;
/// MONKEY (soft portal claims): a hop whose [`Claim::fade_entry`] is already below this is not
/// worth one of the six slots — it can contribute at most this fraction of one fixture's already
/// attenuated pool, and the slot is better left to a doorway that can be seen. Dropping it cannot
/// re-open the seam it exists to close: the room on the NEAR side of that door is, by definition,
/// this dim there too, so both sides read ~0 and the boundary stays continuous.
pub const CLAIM_FADE_MIN_ENTRY: f32 = 0.02;
/// `smoothstep(0, edge, x)` — the shader's own curve, so the entry weight this rule bakes and the
/// weight `static_gx.wgsl` evaluates are the same function sampled at the same point. (Rust has no
/// `smoothstep`; WGSL's is the Hermite `t*t*(3 - 2t)` on the clamped ratio.)
fn smoothstep0(edge: f32, x: f32) -> f32 {
let t = (x / edge.max(1e-4)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// The largest HORIZONTAL extent (yd) an EXTERIOR-class group may have and still be honoured by the
/// EXTERIOR batch lane — "a building, not a district".
///
/// An exterior-class group is drawn by the exterior law and takes no interior fixture, so a claim on
/// one is consumed by exactly one thing: the exterior batch's claim-gated room term (bug B's second
/// cause — the inn's basement stairwell is an EXTERIOR group inside a building and reads night-sky
/// green next to its candle-lit neighbours). That term must reach an annex like the inn's shell
/// (`57.7 x 32.2 yd`) and must NOT reach a city's district shell, whose MOGI box swallows the
/// interiors inside it: measured on `Stormwind.wmo`, 494 of its 606 fixtures stand inside one of
/// ~8 exterior shells spanning 122-660 yd (g107 alone contains 90 of them), and admitting those
/// would put a tavern candle's pool on the cobbles outside the tavern wall. The shipped corpus
/// leaves a wide gap between the two populations — no Stormwind shell is under 122 yd — so the cut
/// sits in the middle of it rather than on either edge.
///
/// A PORTAL claim is exempt: a portal is the artist saying these two groups share a doorway, and
/// light through a door onto the street is right (it is how a lit doorway reads at night).
pub const CLAIM_EXT_SHELL_YD: f32 = 96.0;
/// Is a BOX claim (containment or MOLR) on this group eligible for the exterior lane? Interior
/// groups trivially are (the exterior lane never draws them); an exterior group only at building
/// scale (see [`CLAIM_EXT_SHELL_YD`]).
fn claimable_by_box(g: &WmoGroupInfo) -> bool {
g.interior
|| (0..2).all(|a| (g.bbox_max[a] - g.bbox_min[a]) <= CLAIM_EXT_SHELL_YD)
}
/// `pos` inside a group's authored MOGI box.
fn contains(g: &WmoGroupInfo, pos: [f32; 3]) -> bool {
(0..3).all(|a| pos[a] >= g.bbox_min[a] && pos[a] <= g.bbox_max[a])
}
/// The box's volume — the containment ORDER key. A room is small, the shell that spans the whole
/// building is not, and when six slots have to be spent the room the fixture stands in must get one.
fn volume(g: &WmoGroupInfo) -> f32 {
(0..3)
.map(|a| (g.bbox_max[a] - g.bbox_min[a]).max(0.0))
.product()
}
/// The claim set of ONE fixture at `pos` (WMO model space, WoW axes) with effective radius `reach`
/// yards, whose MOLR referrers are `molr`. Ordered by the priority in the module doc and capped at
/// [`ROOM_CLAIM_MAX`] — **except** that the base (containment ∪ MOLR) is returned whole even when it
/// is longer: the packer's own overflow arm decides that case (MONKEY (GO room claims): it keeps
/// the first [`ROOM_CLAIM_MAX`] in this priority order — it used to fail open, which lit the whole
/// building through its walls), so this function keeps the full ordered set for the audits.
///
/// `reach` of 0 or less disables the portal hop entirely (a fixture with no usable window).
pub fn room_claims(
groups: &[WmoGroupInfo],
portals: PortalGraph<'_>,
pos: [f32; 3],
reach: f32,
molr: &[u16],
) -> Vec<Claim> {
let mut out: Vec<Claim> = Vec::new();
// MONKEY (soft portal claims): `fade` is `None` for a HARD claim (containment/MOLR — weight 1
// everywhere) and `Some((centre, slack, radius))` for a portal hop.
let push = |out: &mut Vec<Claim>,
group: u16,
how: ClaimHow,
distance: f32,
hops: u8,
fade: Option<([f32; 3], f32, f32, f32)>| {
if out.iter().any(|c| c.group == group) {
return;
}
let g = groups.get(usize::from(group));
let (fade_center, fade_slack, fade_radius, fade_entry) =
fade.unwrap_or(([0.0; 3], 0.0, 0.0, 1.0));
out.push(Claim {
group,
how,
distance,
interior: g.is_some_and(|g| g.interior),
// A portal is the artist saying these two groups share a doorway, so it is always
// exterior-lane eligible; a box claim has to be building-scale to be.
ext_ok: how == ClaimHow::Portal || g.is_none_or(claimable_by_box),
hops,
fade_center,
fade_slack,
fade_radius,
fade_entry,
});
};
// 1. CONTAINMENT, tightest box first.
let mut inside: Vec<(usize, bool, f32)> = groups
.iter()
.enumerate()
.filter(|(_, g)| contains(g, pos))
.map(|(i, g)| (i, !claimable_by_box(g), volume(g)))
.collect();
// Tightest box first, and a district-scale shell (which no lane will light from) after every
// real room, so it can never take the slot a portal hop into the next room wanted.
inside.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.total_cmp(&b.2)));
for (i, _, _) in inside {
push(&mut out, i as u16, ClaimHow::Contains, 0.0, 0, None);
}
// 2. MOLR.
for &g in molr {
push(&mut out, g, ClaimHow::Molr, 0.0, 0, None);
}
// 3. PORTAL-ADJACENT — up to [`ROOM_CLAIM_HOPS`] doorways off the base, nearest portal first
// within each hop, and every hop's claim carries the fade that makes it SOFT.
//
// MONKEY (soft portal claims): this used to stop at ONE hop, and stopping there was itself a
// hard edge. A fixture two rooms from a group boundary is claimed on the near side (one hop)
// and refused on the far side, so the binary gate's step simply moved one room outward —
// the same scar, a doorway further along. The wavefront below SPENDS the fixture's reach as
// it walks: each hop's remaining reach becomes the fade radius past that doorway, so by the
// second door the weight is already small and a third door would not be worth a slot.
if out.len() >= ROOM_CLAIM_MAX || reach <= 0.0 || portals.infos.is_empty() {
return out;
}
// The wavefront: `(group, yards ALREADY SPENT getting there)`. The base is at zero — the
// fixture physically stands in those groups.
// `(group, yards spent, index of the claim in `out`)` — the index is what lets the next hop
// sample its predecessor's fade at the door it is about to cross (see [`Claim::fade_entry`]).
let mut frontier: Vec<(u16, f32, usize)> = out
.iter()
.enumerate()
.map(|(i, c)| (c.group, 0.0, i))
.collect();
for hop in 1..=ROOM_CLAIM_HOPS {
if out.len() >= ROOM_CLAIM_MAX {
break;
}
// `(group, spent, portal centre, portal slack, seed claim)`, deduped to the CHEAPEST
// way in.
let mut next: Vec<(u16, f32, [f32; 3], f32, usize)> = Vec::new();
for &(g, spent, seed) in &frontier {
let Some(&(start, count)) = portals.slices.get(usize::from(g)) else {
continue;
};
let from = usize::from(start);
let to = from.saturating_add(usize::from(count));
for r in portals.refs.get(from..to).unwrap_or(&[]) {
if out.iter().any(|c| c.group == r.group) {
continue; // already claimed, by a tighter source or a cheaper hop
}
let Some((d, center, slack)) = portal_reach(&portals, r.portal, pos) else {
continue;
};
// The straight line to the SECOND door can be shorter than to the first (an
// L-shaped pair of rooms folds back toward the fixture), but the light still had
// to cross the first one. `max` keeps the spend monotone along the path, so a
// second-hop room can never be claimed more cheaply than the room it hides behind.
let d = d.max(spent);
if d > reach {
continue;
}
match next.iter_mut().find(|(n, ..)| *n == r.group) {
Some(slot) if slot.1 <= d => {}
Some(slot) => *slot = (r.group, d, center, slack, seed),
None => next.push((r.group, d, center, slack, seed)),
}
}
}
next.sort_by(|a, b| a.1.total_cmp(&b.1));
frontier.clear();
for (g, d, center, slack, seed) in next {
if out.len() >= ROOM_CLAIM_MAX {
break;
}
// The weight the SEEDING claim still has at this doorway — 1 off a base claim (which
// is hard), and the first hop's decayed value off a first hop. This is the whole of
// the multi-hop continuity argument: hop N+1 starts at exactly the value hop N ends
// at, so the chain has no step at any threshold and never brightens with distance.
let seed = &out[seed];
let entry = if seed.fade_radius <= 0.0 {
1.0
} else {
let gap = (0..3)
.map(|a| (center[a] - seed.fade_center[a]).powi(2))
.sum::<f32>()
.sqrt();
seed.fade_entry
* (1.0 - smoothstep0(seed.fade_radius, (gap - seed.fade_slack).max(0.0)))
};
if entry < CLAIM_FADE_MIN_ENTRY {
continue; // too dim to be worth a slot (see the constant)
}
// The fade radius is what is LEFT of the reach past this doorway, floored (see
// [`CLAIM_FADE_MIN_YD`]: a zero-length smoothstep is a step function again).
let radius = (reach - d).max(CLAIM_FADE_MIN_YD);
push(
&mut out,
g,
ClaimHow::Portal,
d,
hop,
Some((center, slack, radius, entry)),
);
frontier.push((g, d, out.len() - 1));
}
}
out
}
/// [`room_claims`] reduced to the group ids the packer wants.
pub fn claim_groups(
groups: &[WmoGroupInfo],
portals: PortalGraph<'_>,
pos: [f32; 3],
reach: f32,
molr: &[u16],
) -> Vec<u16> {
room_claims(groups, portals, pos, reach, molr)
.into_iter()
.map(|c| c.group)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn g(interior: bool, lo: [f32; 3], hi: [f32; 3]) -> WmoGroupInfo {
WmoGroupInfo {
interior,
show_skybox: false,
bbox_min: lo,
bbox_max: hi,
}
}
/// A quad portal in the x = 5 plane, spanning y 0..2, z 0..3.
fn one_portal() -> (Vec<[f32; 3]>, Vec<WmoPortalInfo>) {
(
vec![
[5.0, 0.0, 0.0],
[5.0, 2.0, 0.0],
[5.0, 2.0, 3.0],
[5.0, 0.0, 3.0],
],
vec![WmoPortalInfo {
start_vertex: 0,
count: 4,
plane: [1.0, 0.0, 0.0, -5.0],
}],
)
}
#[test]
fn containment_is_tightest_first_and_molr_follows() {
let groups = [
g(true, [-50.0, -50.0, -10.0], [50.0, 50.0, 30.0]), // g0 the building shell
g(true, [-2.0, -2.0, 0.0], [4.0, 4.0, 5.0]), // g1 the room it stands in
];
let c = room_claims(&groups, PortalGraph::default(), [0.0, 0.0, 1.0], 8.0, &[7]);
assert_eq!(
c.iter().map(|c| c.group).collect::<Vec<_>>(),
vec![1, 0, 7],
"the tight room outranks the shell, and MOLR follows both"
);
assert_eq!(c[0].how, ClaimHow::Contains);
assert_eq!(c[2].how, ClaimHow::Molr);
}
#[test]
fn a_portal_within_reach_claims_the_next_room() {
let groups = [
g(true, [-5.0, -5.0, 0.0], [5.0, 5.0, 6.0]), // g0, holds the fixture
g(true, [5.0, -5.0, 0.0], [15.0, 5.0, 6.0]), // g1, through the doorway
];
let (verts, infos) = one_portal();
let refs = [
WmoPortalRef {
portal: 0,
group: 1,
side: 1,
},
WmoPortalRef {
portal: 0,
group: 0,
side: -1,
},
];
let slices = [(0u16, 1u16), (1u16, 1u16)];
let portals = PortalGraph {
vertices: &verts,
infos: &infos,
refs: &refs,
slices: &slices,
};
// 3 yd from the doorway, reach 8 ⇒ the next room is claimed…
let c = room_claims(&groups, portals, [2.0, 1.0, 1.0], 8.0, &[]);
assert_eq!(c.iter().map(|c| c.group).collect::<Vec<_>>(), vec![0, 1]);
assert_eq!(c[1].how, ClaimHow::Portal);
// …and out of reach it is not: the far room stays gated, which is the whole point.
let c = room_claims(&groups, portals, [-4.0, 1.0, 1.0], 5.0, &[]);
assert_eq!(c.iter().map(|c| c.group).collect::<Vec<_>>(), vec![0]);
}
#[test]
fn portal_claims_never_displace_the_base() {
// Six containing groups fill every slot; the doorway beyond gets nothing.
let mut groups: Vec<WmoGroupInfo> = (0..6)
.map(|i| g(true, [-5.0, -5.0, 0.0], [5.0 + i as f32, 5.0, 6.0]))
.collect();
groups.push(g(true, [5.0, -5.0, 0.0], [15.0, 5.0, 6.0]));
let (verts, infos) = one_portal();
let refs = [WmoPortalRef {
portal: 0,
group: 6,
side: 1,
}];
let mut slices = vec![(0u16, 1u16)];
slices.resize(7, (0, 0));
let portals = PortalGraph {
vertices: &verts,
infos: &infos,
refs: &refs,
slices: &slices,
};
let c = room_claims(&groups, portals, [0.0, 0.0, 1.0], 20.0, &[]);
assert_eq!(c.len(), ROOM_CLAIM_MAX);
assert!(c.iter().all(|c| c.how == ClaimHow::Contains));
}
/// MONKEY (soft portal claims): a portal claim carries the DOORWAY it came through and the
/// reach left past it — the two numbers the shader's weight is built from. A base claim
/// carries neither, which is how the shader tells "hard" from "faded".
#[test]
fn a_portal_claim_carries_its_doorway_and_the_reach_left_past_it() {
let groups = [
g(true, [-5.0, -5.0, 0.0], [5.0, 5.0, 6.0]),
g(true, [5.0, -5.0, 0.0], [15.0, 5.0, 6.0]),
];
let (verts, infos) = one_portal();
let refs = [
WmoPortalRef { portal: 0, group: 1, side: 1 },
WmoPortalRef { portal: 0, group: 0, side: -1 },
];
let slices = [(0u16, 1u16), (1u16, 1u16)];
let portals = PortalGraph {
vertices: &verts,
infos: &infos,
refs: &refs,
slices: &slices,
};
// 3 yd short of the doorway (x = 5), reach 8 ⇒ 5 yd of reach left on the far side.
let c = room_claims(&groups, portals, [2.0, 1.0, 1.0], 8.0, &[]);
assert_eq!(c[0].fade_radius, 0.0, "a containment claim is HARD: weight 1 everywhere");
assert_eq!(c[0].hops, 0);
let door = c[1];
assert_eq!(door.how, ClaimHow::Portal);
assert_eq!(door.hops, 1);
// The quad spans y 0..2, z 0..3 in the x = 5 plane: centre (5, 1, 1.5), half-diagonal
// sqrt(0 + 1 + 1.5^2) = 1.803.
assert_eq!(door.fade_center, [5.0, 1.0, 1.5]);
assert!((door.fade_slack - 1.802_775).abs() < 1e-4, "{door:?}");
assert!((door.distance - 3.0).abs() < 1e-5, "closest point on the quad, not its centre");
assert!((door.fade_radius - 5.0).abs() < 1e-5, "reach 8 minus the 3 yd spent");
assert_eq!(door.fade_entry, 1.0, "a first hop is at full weight in its own doorway");
// The floor on the fade: a fixture that only just reaches the doorway must still FADE
// there, not step. Reach 3.0 leaves 0 yd, and a 0-yard smoothstep is the hard edge again.
let c = room_claims(&groups, portals, [2.0, 1.0, 1.0], 3.0, &[]);
assert_eq!(c[1].fade_radius, CLAIM_FADE_MIN_YD, "clamped, never zero");
}
/// MONKEY (soft portal claims): TWO hops. A fixture two rooms away has to be claimed on BOTH
/// sides of the far group boundary or the seam simply moves one room outward — and the
/// second hop's fade is measured from the SECOND doorway, with what is left of the reach.
#[test]
fn the_second_hop_claims_the_room_beyond_and_fades_from_its_own_door() {
// Three rooms in a row along x, doors at x = 5 and x = 15.
let groups = [
g(true, [-5.0, -5.0, 0.0], [5.0, 5.0, 6.0]),
g(true, [5.0, -5.0, 0.0], [15.0, 5.0, 6.0]),
g(true, [15.0, -5.0, 0.0], [25.0, 5.0, 6.0]),
];
let verts = vec![
[5.0, 0.0, 0.0], [5.0, 2.0, 0.0], [5.0, 2.0, 3.0], [5.0, 0.0, 3.0],
[15.0, 0.0, 0.0], [15.0, 2.0, 0.0], [15.0, 2.0, 3.0], [15.0, 0.0, 3.0],
];
let infos = vec![
WmoPortalInfo { start_vertex: 0, count: 4, plane: [1.0, 0.0, 0.0, -5.0] },
WmoPortalInfo { start_vertex: 4, count: 4, plane: [1.0, 0.0, 0.0, -15.0] },
];
let refs = [
WmoPortalRef { portal: 0, group: 1, side: 1 }, // g0 -> g1
WmoPortalRef { portal: 0, group: 0, side: -1 }, // g1 -> g0
WmoPortalRef { portal: 1, group: 2, side: 1 }, // g1 -> g2
WmoPortalRef { portal: 1, group: 1, side: -1 }, // g2 -> g1
];
let slices = [(0u16, 1u16), (1u16, 2u16), (3u16, 1u16)];
let portals = PortalGraph {
vertices: &verts,
infos: &infos,
refs: &refs,
slices: &slices,
};
// The fixture stands in g0 at x = 2, reach 20: 3 yd to the first door, 13 to the second.
let c = room_claims(&groups, portals, [2.0, 1.0, 1.0], 20.0, &[]);
assert_eq!(
c.iter().map(|c| (c.group, c.hops)).collect::<Vec<_>>(),
vec![(0, 0), (1, 1), (2, 2)],
"one claim per room, in hop order"
);
assert_eq!(c[2].fade_center[0], 15.0, "the SECOND door is what g2 fades from");
assert!((c[2].fade_radius - 7.0).abs() < 1e-5, "20 yd reach, 13 spent: {:?}", c[2]);
// CONTINUITY at the second threshold: g2 must START at exactly the weight g1's own fade
// has reached there, or the far room reads BRIGHTER than the near one across one plank.
// g1's door is at (5, 1, 1.5) with slack 1.803 and radius 17; g2's is at (15, 1, 1.5),
// 10 yd away, so g1 is at 1 - smoothstep(0, 17, 10 - 1.803) = 1 - 0.3835.
let g1 = c[1];
let gap = 10.0f32 - g1.fade_slack;
let t = gap / g1.fade_radius;
let expect = 1.0 - t * t * (3.0 - 2.0 * t);
assert_eq!(c[0].fade_entry, 1.0, "a base claim enters at full weight");
assert_eq!(g1.fade_entry, 1.0, "so does the first hop, at its own door");
assert!(
(c[2].fade_entry - expect).abs() < 1e-4,
"the second hop enters at the first hop's value there: {} vs {expect}",
c[2].fade_entry,
);
// Out of the reach of the second door, the third room stays unclaimed — the walk spends
// reach, it does not flood.
let c = room_claims(&groups, portals, [2.0, 1.0, 1.0], 10.0, &[]);
assert_eq!(c.iter().map(|c| c.group).collect::<Vec<_>>(), vec![0, 1]);
}
}
+33 -1
View File
@@ -91,7 +91,11 @@ impl WmoResidency {
/// The change counter — bumped whenever the resident set actually changes. Read by the per-unit
/// room claim (`wmo_portal::track_unit_interiors`), whose re-test gate is otherwise movement
/// alone: a building streaming in under a STANDING unit must still re-claim it.
pub(crate) fn generation(&self) -> u32 {
///
/// MONKEY (GO room claims): `pub` — a CARRIED light's room-claim set is keyed to a resident
/// placement, so it must be rebuilt on the same tick a building streams in or out
/// (`benilla_app::entities::carried_light`). Same generation, same gate, one counter.
pub fn generation(&self) -> u32 {
self.generation
}
@@ -313,6 +317,34 @@ impl InteriorAnchor {
lane_bits(self.lane)
}
/// MONKEY (fire GO lights): the room this anchor's own attach ray claimed — `None` outdoors.
///
/// Exposed so the app can hand a GameObject's/creature's CARRIED lights the same room its
/// meshes were classified into (`benilla_app::entities::carried_light`'s room claim). Two
/// things need it: the faithful gate (a torch in a culled room lights nothing —
/// [`crate::lighting::LightRooms`], decision 0689), and cube-shadow eligibility, since
/// `torch_shadow`'s candidate query is `With<PointLight>, With<LightRooms>` and a light with no
/// room can never be promoted to a caster however indoors it stands.
pub fn room(&self) -> Option<crate::wmo_portal::WmoRoom> {
self.room
}
/// MONKEY (carried light stability): the anchor's own crossfade weight - `0` fully exterior,
/// `1` fully in [`Self::room`], ramping at [`LANE_RAMP_PER_SEC`] (a half-second full travel).
///
/// Exposed because it is the ONE already-smoothed interior/exterior signal a bearer carries,
/// and a CARRIED light's lane must be read off it rather than off the raw per-frame law. The
/// law itself is known to alternate frame to frame at a threshold - `WOW_INTERIOR_LOG` from
/// the 2026-09-09 run shows anchor `320v2` flipping `matte`<->`bake` on ten consecutive
/// resolves between t 73.75 and t 74.14 - and a light that re-decided its lane on that raw
/// verdict would swap consumer families (the exterior `point_light_sum` <-> the interior room
/// lane) at the same rate, i.e. strobe. One frame of a wrong verdict moves this weight by
/// `dt * LANE_RAMP_PER_SEC`, so a blip can never cross the `0.5` midpoint a consumer keys on;
/// only a verdict that HOLDS for ~0.25 s does.
pub fn lane(&self) -> f32 {
self.lane
}
/// One line naming the lane this anchor's parts render under — the inspect card's light
/// readout (`crate::interact`). "Which lane is this object on?" was the exact question decision
/// 0776 was found by, and answering it took a rebuild with `WOW_INTERIOR_LOG` plus an offline
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -18,8 +18,13 @@ mod resolve; // the per-frame time-of-day sample into WowLighting + the WMO inte
mod sh; // the model SH light-probe coefficient math
pub use blob::LightBlob;
pub use global_light::{
new_shared_light_buffer, DynamicInteriors, LightRooms, SharedLightBuffer, ShadowDistance,
ShadowProxyLight, WorldShadowActive,
interior_reach, m2_light_reach, new_shared_light_buffer, DynamicInteriors, FireLightGain,
ClaimFade, LightLane, LightLitRooms, LightReach, LightRooms, RoomClaimTable,
SharedLightBuffer,
ShadowDistance, ShadowProxyLight, SyntheticFireLight, WorldShadowActive,
};
pub use global_light::{
room_claim_bytes, CLAIM_EXT_OK, LIT_ROOM_EXT_DENY, ROOM_CLAIM_MAX, ROOM_CLAIM_STRIDE,
};
pub use prop_probes::{PropProbeSlot, PropProbes, MAX_PROP_PROBES};
// The std430 layout itself — row indices, byte sizes, region offsets and the folds that fill them
+465 -27
View File
@@ -58,6 +58,10 @@ struct WowLight {
grade: vec4<f32>,
wmo_fog_color: vec4<f32>,
wmo_fog_params: vec4<f32>,
// MONKEY (light lanes): each light is TWO rows — `[pos.xyz, range]`, `[rgb, lane]`. `lane` is
// `0` for an EXTERIOR light (read only by `point_light_sum`) and `> 0.5` for an INTERIOR
// fixture (read only by `interior_room_light`), where the value IS that fixture's reach in
// yards. wow_model.wgsl owns the full note.
point_count: vec4<f32>,
points: array<vec4<f32>, 512>,
// The interior-prop SH probe table (B4 — lighting::prop_probes, 7 rows per slot, 8192
@@ -67,10 +71,31 @@ struct WowLight {
prop_probes: array<vec4<f32>, 57344>,
}
@group(2) @binding(0) var<storage, read> wow_light: WowLight;
// MONKEY (room gate): the per-fixture ROOM CLAIM table — which WMO groups each packed INTERIOR
// fixture is allowed to light. Packed by `lighting::global_light`'s `build_light_data` into its own
// storage buffer (NEVER into `WowLight`: that struct is mirrored three ways and must not resize),
// and bound here beside it. `ROOM_CLAIM_STRIDE` u32 per packed light, index-parallel with the
// point table:
// [0] the claiming WMO placement's ENTITY INDEX [1] claim count (**0 = UNGATED**)
// [2 .. 2+ROOM_CLAIM_MAX) the claimed group ids, stored as `group + 1` so 0 stays "empty"
// MONKEY (soft portal claims): then one 4-word FADE record per claim slot, at `ROOM_CLAIM_FADE`:
// [+0..+3] the doorway's WORLD-space centre, raw f32 bits (a world coordinate reaches ~17000 yd,
// so nothing narrower carries it; the CPU side converts out of WMO model space once, at
// spawn, because this side has only a fragment position and no placement matrix)
// [+3] `radius | slack << 16`, both u16 yards x CLAIM_FADE_SCALE. **radius 0 = a HARD claim**
// (containment/MOLR: weight 1 everywhere), which is also what the zero padding reads as
// Every claim of one fixture shares one instance, because a fixture belongs to exactly one
// building (`LightRooms`/`WmoGroupVis` carries one instance and a group list) — which is what lets
// the instance sit in the head word instead of once per claim.
@group(2) @binding(1) var<storage, read> room_claims: array<u32>;
// Per-cell state (static_gx/render.rs `cell_layout`).
struct GxCell {
origin: vec4<f32>, // xyz = the bake's recentring origin (0974): world = vertex + origin
// xyz = the bake's recentring origin (0974): world = vertex + origin.
// MONKEY (room gate): `.w` is this REGION's identity — the WMO placement instance's entity
// index, `bitcast` out of the float lane (the uniform's declared size is 16 bytes and must not
// move; the lane was a hard 0.0 pad). 0 on terrain cells, which name no building.
origin: vec4<f32>,
}
@group(1) @binding(0) var<uniform> cell: GxCell;
// The per-ITEM record table, indexed by the vertex word's low bits:
@@ -105,19 +130,28 @@ const WORD_HAS_VC: u32 = 134217728u; // 1 << 27 — the batch AUTHORED vertex
// point lights.
const WORD_MATTE: u32 = 268435456u; // 1 << 28 — exterior MODD prop: intensity FIXED 1.0
// MONKEY (room gate): record column `w` bits 15..=26 — this item's ROOM KEY, packed as
// `group + 1`, so **0 means the item names no room** and takes every fixture (a terrain-cell item,
// or a WMO prop whose referrer set is not exactly one group). Bit 0 is the exile kill bit, bits
// 1..=13 the interior-prop probe slot and bit 14 the interior fog lane, so 15 is the first free
// bit; 12 bits covers every shipped WMO's group count (the largest, Stratholme, has 92).
const RECORD_ROOM_SHIFT: u32 = 15u;
const RECORD_ROOM_MASK: u32 = 4095u;
// Vanilla cutout ref (224/255) — wow_model.wgsl's VANILLA_ALPHA_KEY.
const VANILLA_ALPHA_KEY: f32 = 0.8784314;
// MONKEY (torch shadows Phase 1): group 3 — the interior torch depth map + the ≤4-entry fixture
// MONKEY (torch shadows Phase 1): group 3 — the interior torch depth map + the ≤16-entry fixture
// table. Declared (and sampled) ONLY under TORCH_SHADOWS, which the static_gx pipeline always sets;
// the wow_model.wgsl copy of interior_room_light has no group 3 and must not see this block.
#ifdef TORCH_SHADOWS
struct TorchTable {
// .x = live fixture count (a vec4 so the 16-byte-aligned std140 layout matches the Rust
// `TorchTableUniform`: count@0, positions@16, view_projs@80 — total 1616 bytes).
// MONKEY (static torch cache): byte-identical in BOTH shaders and TorchTableUniform.
// count@0 (16): x high-water slot count, y soft*100, z dynamic/live-bank mask, w reserved.
// positions@16 (256), view_projs@272 (6144): total 6416 bytes. A mismatch hides buildings.
count: vec4<u32>,
positions: array<vec4<f32>, 4>, // xyz = fixture world pos, w = range
view_projs: array<mat4x4<f32>, 24>, // 6 cube faces per fixture: [fixture*6 + face]
positions: array<vec4<f32>, 16>,
view_projs: array<mat4x4<f32>, 96>,
}
@group(3) @binding(0) var torch_depth: texture_depth_2d_array;
@group(3) @binding(1) var torch_samp: sampler_comparison;
@@ -128,6 +162,12 @@ struct TorchTable {
// value that gave the clean forge-cast floor shadows; the residual acne only showed in the
// worst-case interiorDebug 2 (min over all 4 maps), not the real per-fixture render.
const TORCH_BIAS: f32 = 0.001;
// MONKEY (torch caster selection): the live PCF tap-radius scale, unpacked from the table's
// `count.y` (stored x100 because the row is `vec4<u32>`). Floored so a zero table cannot collapse
// the kernel to a single texel.
fn torch_soft() -> f32 {
return max(f32(torch_table.count.y) * 0.01, 0.05);
}
#endif
// MONKEY (Phase 5): the cube face that contains direction `d` (fixture → fragment) — the major
@@ -152,11 +192,20 @@ fn torch_face(d: vec3<f32>) -> u32 {
fn torch_surface_shadow(light_pos: vec3<f32>, P: vec3<f32>) -> f32 {
#ifdef TORCH_SHADOWS
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
// MONKEY (static torch cache): holes and pending uploads never sample stale layers.
if (torch_table.positions[i].w <= 0.0) { continue; }
let fixture = torch_table.positions[i].xyz;
if (distance(fixture, light_pos) < 1.0) {
let layer = i * 6u + torch_face(P - fixture);
return shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(layer), P, torch_depth, torch_samp, TORCH_BIAS);
let s = shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(layer + select(0u, 96u, (torch_table.count.z & (1u << i)) != 0u)), P, torch_depth, torch_samp, TORCH_BIAS,
torch_soft());
// 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
// churn in a candle-dense room (Northshire's 42 candelabra, 5 yd apart) reads as
// shadows popping on and off as you walk - the bug this lane exists to fix.
return mix(1.0, s, torch_table.positions[i].w);
}
}
#endif
@@ -171,9 +220,15 @@ fn torch_debug_factor(P: vec3<f32>) -> f32 {
#ifdef TORCH_SHADOWS
var s = 1.0;
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
if (torch_table.positions[i].w <= 0.0) { continue; }
let layer = i * 6u + torch_face(P - torch_table.positions[i].xyz);
s = min(s, shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(layer), P, torch_depth, torch_samp, TORCH_BIAS));
let raw = shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(layer + select(0u, 96u, (torch_table.count.z & (1u << i)) != 0u)), P, torch_depth, torch_samp, TORCH_BIAS,
torch_soft());
// 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.
s = min(s, mix(1.0, raw, torch_table.positions[i].w));
}
return s;
#else
@@ -193,6 +248,14 @@ fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
var sel = array<u32, 3>(0u, 0u, 0u);
var sd = array<f32, 3>(1e30, 1e30, 1e30);
for (var i = 0u; i < count; i = i + 1u) {
// MONKEY (light lanes): skip INTERIOR fixtures (colour row `.w > 0.5`) — mirrored from
// wow_model.wgsl. This is the EXTERIOR doodad/MODD-prop family (a WMO surface takes
// `wmo_exterior_point_sum` or nothing), so a building's own fixtures must not reach it — the
// warm pool on the grass at the foot of the inn's wall. Skipped before the ≤3 ranking, so
// an interior fixture cannot take a slot an outdoor fire should have had.
if (wow_light.points[2u * i + 1u].w > 0.5) {
continue;
}
let pos_range = wow_light.points[2u * i];
let dv = pos_range.xyz - anchor;
let d2 = dot(dv, dv);
@@ -225,16 +288,104 @@ fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
return sum;
}
// MONKEY (wmo exterior points): the EXTERIOR-lane point term for a WMO's OUTDOOR-class surfaces —
// Stormwind's cobbles, its city walls, an inn's porch: every group whose MOGP flags carry `0x48`
// (`WORD_INTERIOR` clear). Until now those took NO point term at all: the vertex stage zeroed
// `point_lit` for every `WORD_WMO` batch, because the reference really does zero it on the surfaces
// it lights with the MOCV bake — but that finding is about INTERIOR groups (wow-re
// `trace-forensics-abbey-interior-d3d` §2 measured abbey rooms). An exterior-class group is drawn
// by the exterior law, the same law terrain is drawn by, and terrain takes the point term. The
// symptom of the over-broad zero: a Trade District wall torch at night lit the NPC beside it (an
// M2, on `wow_model.wgsl`'s lane) and the dirt road at the gate (terrain), but not one cobble of
// the street it hangs over.
//
// It is `terrain.wgsl`'s `point_light_sum` semantics DELIBERATELY, not this file's own:
// · the anchor is the **MCNK cell centre** ([`mcnk_cell_anchor`]), not the batch's baked
// placement anchor. A WMO's baked anchor is its PLACEMENT origin — one point for the whole of
// Stormwind — so a nearest-3 ranked from it would commit the same three lights to every street
// in the city. The 33.33 yd cell is the unit terrain uses, so the road and the street it runs
// into rank the SAME candidates and agree at the seam, which is the whole requirement.
// · candidacy is terrain's Chebyshev box `TERRAIN_REACH`, not this file's 48 yd sphere, for the
// same agreement reason (and because it is the byte-verified gather — see `terrain.wgsl`).
// Cost: one bounded pass over the ≤256-entry table per VERTEX (never per fragment), the same walk
// the doodad lane beside it already pays. Gated only on the table's contents — the exterior lane
// is independent of `interiorLight`, and an empty/daytime table makes it exactly zero. Additive
// into the clamped `primary` sum, so a fully sunlit street cannot brighten past white: daytime is
// materially unchanged, exactly as on terrain.
//
// MIRRORED verbatim in `wow_model.wgsl` (which keeps its own `mcnk_cell_anchor`) — the small
// minority of WMO batches the retained collector declines (env-mapped, depth-flag oddities) falls
// through to the entity path, and a street lit on one pipeline and black on the other is worse
// than either. Keep the two bodies identical.
const WMO_EXT_REACH: f32 = 33.570166;
fn mcnk_cell_anchor(P: vec3<f32>) -> vec3<f32> {
let cell = 533.33333 / 16.0;
let half = 32.0 * 533.33333;
let ix = floor((half + P.x) / cell);
let iz = floor((half + P.z) / cell);
return vec3<f32>((ix + 0.5) * cell - half, P.y, (iz + 0.5) * cell - half);
}
fn wmo_exterior_point_sum(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
let anchor = mcnk_cell_anchor(P);
let count = u32(wow_light.point_count.x);
var sel = array<u32, 3>(0u, 0u, 0u);
var sd = array<f32, 3>(1e30, 1e30, 1e30);
for (var i = 0u; i < count; i = i + 1u) {
// Exterior lane only: an interior fixture's `.w` is its reach (≥ 1), an exterior source's
// is 0. A building's own candles must not pool on the street outside its wall.
if (wow_light.points[2u * i + 1u].w > 0.5) {
continue;
}
let dv = wow_light.points[2u * i].xyz - anchor;
// Terrain's hash sweep is horizontal and unbounded vertically; the ranking below is the
// full 3-D distance, as `0x71bf90` does.
if (max(abs(dv.x), abs(dv.z)) > WMO_EXT_REACH) {
continue;
}
let d2 = dot(dv, dv);
if (d2 < sd[0]) {
sd[2] = sd[1]; sel[2] = sel[1];
sd[1] = sd[0]; sel[1] = sel[0];
sd[0] = d2; sel[0] = i;
} else if (d2 < sd[1]) {
sd[2] = sd[1]; sel[2] = sel[1];
sd[1] = d2; sel[1] = i;
} else if (d2 < sd[2]) {
sd[2] = d2; sel[2] = i;
}
}
var sum = vec3<f32>(0.0);
for (var s = 0u; s < 3u; s = s + 1u) {
if (sd[s] > 9.9e29) {
break;
}
let to_light = wow_light.points[2u * sel[s]].xyz - P;
let d = length(to_light);
let atten = 1.0 / (0.7 * d + 0.03 * d * d);
let nl = max(dot(N, to_light / max(d, 1e-4)), 0.0);
sum += wow_light.points[2u * sel[s] + 1u].rgb * (atten * nl);
}
return sum;
}
// MONKEY (dynamic interiors): the per-FRAGMENT room light for interior WMO surfaces AND interior
// props — EVERY in-range light of the room-gated table, no nearest-3 selection (a per-fragment
// selection swap draws a seam where the 4th light drops out). Two terms per light:
// · DIRECT — the reference's falloff × a WRAPPED Lambert, (N·L + wrap)/(1 + wrap): a hearth
// sitting at floor level is horizontal to the floor around it, and a hard max(N·L, 0) lights
// nothing there (the smithy only worked because its forges are raised).
// · FILL — a normal-free bounce, colour × K_FILL × (1 − d/range)²: the "lamps everywhere" glow
// nothing there (the smithy only worked because its forges are raised) — times the fixture's
// AUTHORED ATTENUATION WINDOW (MONKEY, below).
// · FILL — a normal-free bounce, colour × K_FILL × (1 − d/reach)²: the "lamps everywhere" glow
// Blizzard's bake carried, sourced from the room's own fixtures instead of a constant. The
// colour is normalised for this term (the table commits RAW over-gamut colour × intensity),
// so one hot forge doesn't wash a whole room.
// MONKEY (light lanes / interior attenuation): the loop is now over the INTERIOR half of the table
// only (colour row `.w > 0.5`), and that same `.w` is each fixture's REACH in yards — its authored
// MOLT attenuation end (M2 sources bucket by intensity), scaled live by `interiorAttenScale`. The
// reach bounds the loop, shapes the fill, and drives the direct window. `interiorAttenScale 0`
// packs the legacy 48 yd instead — under the soft profile below that is the widest, flattest pool
// the lane can make, the nearest thing left to the pre-window flat lane (it is no longer the
// byte-exact restore it was, because the window's SHAPE moved with it).
// `torch_shadow` (the #2 lane, 1.0 when no torch is promoted) shades the direct term only —
// bounce is indirect. A base ambient keeps a fixture-less nook from going black. The knobs are
// LIVE cvars packed into `point_count.yzw` — `.y` base ambient, `.z` fill gain, `.w` exposure
@@ -242,21 +393,234 @@ fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
// `/script SetCVar("interiorExposure", 2)` retunes a room without a rebuild. The fill is
// half-desaturated: bounce off wood and stone is not candle-orange.
const INTERIOR_WRAP: f32 = 0.5;
fn interior_room_light(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
// MONKEY (soft falloff): the interior pool's PROFILE — **keep this whole block byte-identical
// between wow_model.wgsl and static_gx.wgsl.**
//
// The colour row's `.w` packs each fixture's EFFECTIVE RADIUS `R`: its authored MOLT
// `attenuation_end` (an M2 source buckets by intensity instead) already multiplied by the live
// `interiorAttenScale` (default 1.6). MONKEY (pool energy): the EXTENT terms — the direct window
// and the fill span — are still written as fractions of `R`, so the cvar stays a pure zoom on how
// far every pool REACHES; the core is not, so it is no longer also a brightness dial.
//
// The RETIRED profile was the reference's `1/(0.7d + 0.03d²)` windowed by
// `1 − smoothstep(0.65R, R, d)` at `R` = the authored reach. That window closed a still-BRIGHT
// curve — 0.28 of its 1 yd value at 0.65R — over the last 35 % of the radius, i.e. a bright disc
// with a visible rim and black beyond it. That is the abbey candelabra ring. Three changes replace
// it, and the ring cannot come back because the curve is already dim wherever the window bites:
// · CORE — `1/(1 + (d/r0)²)`: inverse square with a soft core. MONKEY (pool energy): `r0` is a
// CONSTANT `INTERIOR_CORE_YD` **yards**, not a fraction of `R` any more. It was `0.26·R`, and
// that tied a fixture's BRIGHTNESS to its REACH — the pool's total ENERGY grew as `R²`. The
// Goldshire inn's `R ≈ 11-15` fixtures therefore ran a 2.1× (at 3 yd) to 2.9× (at 5 yd) hotter
// curve than Northshire abbey's `R ≈ 7`; ten of them overlap in one room, the
// `1 − exp(−x·exposure)` rolloff saturated, and the inn read as one flat yellow while the
// abbey — whose pools the owner signed off — sat exactly where it should. Reach must BOUND a
// pool, not fuel it. So the energy is now one curve for every fixture and only the WINDOW and
// the fill span still scale with `R`: two fixtures of different reach read IDENTICALLY until
// the shorter one's window closes. `1.75 yd` is what the abbey already had (`0.26 × 6.7` — its
// median authored end 4.2 at the default `interiorAttenScale` 1.6), so the under-candle level
// the exposure was tuned against is preserved to within ±10 % across the abbey's own reach
// spread (−5 % at its median fixture) and nothing about the approved look moves.
// · GAIN — `INTERIOR_CORE_GAIN` normalises the core against the retired hyperbolic's 1 yd value,
// `1/(0.7 + 0.03) = 1.3699`. With a CONSTANT `r0` that normalisation is the same for every
// fixture instead of holding only at the median reach, which is the whole point; it lands at
// `1.5/(1 + (1/1.75)²) = 1.1308`, i.e. 0.83 × the hyperbolic — the abbey's own current level.
// (Gain 1.817 would restore 1.3699 exactly; that is a 21 % brighter room than the approved
// one, so it is deliberately NOT taken. Change the GAIN, never the core radius, to re-level.)
// · WINDOW — `(clamp01(1 − (d/R)^p))²`, the UE4-style windowing: value AND derivative both vanish
// at `d = R` (C1, so no rim), ≈1 over most of the radius, and it only ever removes energy the
// curve has already lost.
// MONKEY (pool energy): the direct term (before the wrapped Lambert) at d = 1 / 3 / 5 / 8 / 12 yd,
// abbey-class `R = 7` beside inn-class `R = 13` —
// was R=7 1.152 / 0.403 / 0.164 / 0 / 0 R=13 1.379 / 0.839 / 0.470 / 0.224 / 0.033
// now R=7 1.131 / 0.381 / 0.153 / 0 / 0 R=13 1.131 / 0.381 / 0.164 / 0.067 / 0.009
// The two "now" rows are the SAME curve until R=7's window bites (where it is already down to
// 0.15); R=13 only keeps a longer, dimmer TAIL, because its window is wider. That is the fix.
//
// FILL keeps its EXACT form (`(1 − d/r)²`, which is `interior_window(d, r, 1.0)`) and only widens:
// its radius is `INTERIOR_FILL_SPAN·R`, so the floor BETWEEN two pools takes a gentle wash instead
// of the bare ambient floor. Its value at the fixture is still 1, so `interiorFill` keeps the
// meaning it was tuned with.
const INTERIOR_CORE_YD: f32 = 1.75;
const INTERIOR_CORE_GAIN: f32 = 1.5;
const INTERIOR_DIRECT_POW: f32 = 10.0;
const INTERIOR_FILL_SPAN: f32 = 1.5;
const INTERIOR_FILL_POW: f32 = 1.0;
// `(clamp01(1 − (d/r)^p))²`. The inner clamp keeps `pow` off a negative base; `max(r, …)` keeps it
// off a zero divisor (the packer floors R at 1.0, but a shader must not lean on a producer's
// invariant). `clamp(…, 0, 1)` rather than `saturate` — nothing else in this shader set uses
// `saturate`, and the two are the same instruction.
fn interior_window(d: f32, r: f32, p: f32) -> f32 {
let w = clamp(1.0 - pow(clamp(d / max(r, 1e-4), 0.0, 1.0), p), 0.0, 1.0);
return w * w;
}
// MONKEY (portal claims): the exterior-lane room term's distance gate (yd) — see its call site.
const EXT_ROOM_FADE_START: f32 = 60.0;
const EXT_ROOM_FADE_END: f32 = 90.0;
// MONKEY (soft portal claims): 32, not 8 — six 4-word fade records follow the six ids. Keep in
// sync with `benilla_world::lighting::{ROOM_CLAIM_STRIDE, ROOM_CLAIM_FADE, CLAIM_FADE_SCALE}`: a
// stride that disagrees reads every fixture's claims out of a neighbour's record, and every
// building in the frame goes dark (or floods) at once.
const ROOM_CLAIM_STRIDE: u32 = 32u;
const ROOM_CLAIM_MAX: u32 = 6u;
const ROOM_CLAIM_FADE: u32 = 8u;
const CLAIM_FADE_SCALE: f32 = 256.0;
// MONKEY (portal claims): bit 16 of a packed claim word — "the EXTERIOR batch lane may honour this
// claim". Keep in sync with `benilla_world::lighting::CLAIM_EXT_OK`; the group id (`group + 1`, 12
// bits) lives below it, hence the mask.
const CLAIM_EXT_OK: u32 = 65536u;
// MONKEY (soft portal claims): bits 17..=24 of a claim word — the ENTRY weight as a byte. Keep in
// sync with `benilla_world::lighting::CLAIM_ENTRY_SHIFT`.
const CLAIM_ENTRY_SHIFT: u32 = 17u;
const CLAIM_GROUP_MASK: u32 = 0xffffu;
// MONKEY (room gate): may fixture `i` light a surface of room `(room_inst, room_group)`? Three
// fail-OPEN arms, deliberately, because a wrong "no" is a black room while a wrong "yes" is only
// the leak we already had: a fragment with no room key (`room_group == 0`), a fixture that claims
// nothing (count 0 — an unclaimed MOLT fixture, or the whole gate switched off from `cvars`, which
// simply packs every light that way), and any fixture whose claim list overflowed the table. Only
// a fixture that positively claims SOME room of THIS building can be refused.
//
// MONKEY (portal claims): `strict` inverts all three of those arms, and it is what the EXTERIOR
// batch lane passes. That lane is an ADDITION — an exterior-class group inside a building (the
// inn's shell, its basement stairwell) is drawn by the night-sky law and gains its room's candle
// light — so its wrong answers are the other way round: a wrong "yes" would light a whole city
// street from a tavern candle through a wall, while a wrong "no" only leaves a surface exactly as
// it renders today. So it fails CLOSED: no room key, no claims, another building's claim, or a
// claim not marked [`CLAIM_EXT_OK`] (a district-scale shell) all mean "not this fixture".
//
// MONKEY (soft portal claims): the answer is a WEIGHT in [0, 1], not a yes/no — 1 admits the
// fixture in full, 0 refuses it, and a PORTAL claim lands in between. The binary form is what put
// the reported scar on the Lion's Pride Inn's upstairs floor: two groups meet mid-plank at the
// doorway, the fixture was admitted at full strength on one side and refused outright on the
// other, so the boards changed brightness along a straight line. Every fail-open/fail-closed arm
// keeps its old meaning (1.0 / 0.0), so `interiorRoomGate 0` — which packs every fixture with
// count 0 — is still exactly "ungated".
//
// The weight of a portal claim is `entry * (1 − smoothstep(0, radius, max(|P − center| − slack,
// 0)))`: `entry` at the doorway itself, gone where the fixture's remaining reach runs out.
// `slack` is the doorway's own bounding-sphere radius, and it is what makes the threshold
// continuous: on the near side the fixture is admitted at 1 by CONTAINMENT, and on the far side
// every fragment within the door frame measures distance 0 and is admitted at `entry` — which the
// claim rule sets to exactly the weight the near side has there (1 off a base claim, the first
// hop's decayed value off a first hop). Away from the frame the far side falls off smoothly, but
// there the two sides are separated by a WALL rather than by continuous floor, so nothing reads as
// a step.
fn interior_room_admits(i: u32, room_inst: u32, room_group: u32, strict: bool, P: vec3<f32>) -> f32 {
if (room_group == 0u) {
return select(1.0, 0.0, strict);
}
let base = ROOM_CLAIM_STRIDE * i;
let n = room_claims[base + 1u];
if (n == 0u) {
return select(1.0, 0.0, strict);
}
// A different building's fixture never lights this one — one compare rejects a neighbouring
// inn's whole table, which is why the instance sits in the head word.
if (room_claims[base] != room_inst) {
return 0.0;
}
for (var k = 0u; k < min(n, ROOM_CLAIM_MAX); k = k + 1u) {
let claim = room_claims[base + 2u + k];
if ((claim & CLAIM_GROUP_MASK) == room_group && (!strict || (claim & CLAIM_EXT_OK) != 0u)) {
// A group appears at most once in a claim list (the rule dedupes), so the first match
// is the only one — no need to keep looking for a better weight.
let f = base + ROOM_CLAIM_FADE + 4u * k;
let packed = room_claims[f + 3u];
let radius = f32(packed & 0xffffu) / CLAIM_FADE_SCALE;
if (radius <= 0.0) {
return 1.0; // a HARD claim: the fixture stands in this room, or MOLR names it
}
let center = vec3<f32>(
bitcast<f32>(room_claims[f]),
bitcast<f32>(room_claims[f + 1u]),
bitcast<f32>(room_claims[f + 2u]),
);
let slack = f32(packed >> 16u) / CLAIM_FADE_SCALE;
let entry = f32((claim >> CLAIM_ENTRY_SHIFT) & 0xffu) / 255.0;
let d = max(distance(P, center) - slack, 0.0);
return entry * (1.0 - smoothstep(0.0, radius, d));
}
}
return 0.0;
}
// This region's building identity (the cell uniform's `.w` lane; 0 on a terrain cell). Carried as
// a NUMBER, not as bits: an entity index is a small integer, and `f32::from_bits(12345)` is a
// DENORMAL float that a driver flushing denormals to zero would silently collapse to 0 — every
// building would then share identity 0. An f32 represents every integer below 2^24 exactly, which
// is far above any live entity index, so the round trip is lossless and FTZ-proof; the packer
// refuses to gate anything at or above that bound (see `assemble_region`).
fn gx_room_inst() -> u32 {
return u32(cell.origin.w);
}
// This item's room key — `group + 1`, 0 when the item names no room (see RECORD_ROOM_SHIFT).
fn gx_room_group(word: u32) -> u32 {
return (recs[word & 0xffffu].w >> RECORD_ROOM_SHIFT) & RECORD_ROOM_MASK;
}
// MONKEY (room gate): `room_inst`/`room_group` are the RECEIVING surface's room (see
// `interior_room_admits`). `wow_model.wgsl`'s copy takes the same room arguments and stubs the
// predicate to `true` — it has neither the claim binding nor a per-fragment room key, which is
// also why it needs no `strict` (MONKEY, portal claims: only this file has an exterior WMO lane).
// **The PROFILE block above stays byte-identical between the two files; the signature does not.**
fn interior_room_light(
P: vec3<f32>,
N: vec3<f32>,
room_inst: u32,
room_group: u32,
strict: bool,
) -> vec3<f32> {
let count = u32(wow_light.point_count.x);
let k_fill = wow_light.point_count.z;
var direct = vec3<f32>(0.0);
var fill = vec3<f32>(0.0);
for (var i = 0u; i < count; i = i + 1u) {
let color_lane = wow_light.points[2u * i + 1u];
// MONKEY (light lanes): only a fixture that CLAIMS a room lights this room (keep in sync
// with wow_model.wgsl). `.w` is 0 on every exterior source, so a campfire burning just
// outside the door stops reaching the floor inside it — the leak's other direction.
if (color_lane.w < 0.5) {
continue;
}
// MONKEY (room gate): and only a fixture that claims THIS ROOM lights this room's surfaces.
// Before this, the whole INT half of the table lit every interior fragment of every
// building in range and the only occlusion was the <=6 promoted cube-shadow casters — so an
// inn's ground-floor candles lit its basement THROUGH the floor, and an upstairs corridor
// wall glowed from the fixture in the room behind it. Tested here, before the distance
// test, because it is the term that throws away the most: a room claims one or two of the
// table's fixtures, not all of them.
// MONKEY (soft portal claims): `w` is the claim's WEIGHT, and it multiplies BOTH terms
// below. `wow_model.wgsl`'s copy of this loop keeps the old boolean stub (entities are
// ungated — it has neither the claim binding nor a per-fragment room key), which is the
// one place the two files' loop bodies deliberately differ; the PROFILE constants above
// stay byte-identical.
let w = interior_room_admits(i, room_inst, room_group, strict, P);
if (w <= 0.0) {
continue;
}
// MONKEY (soft falloff): `.w` is ALSO this fixture's EFFECTIVE RADIUS `R` in yards — the
// authored MOLT `attenuation_end` (or the M2 intensity bucket) already multiplied by the
// live `interiorAttenScale` at pack time. It replaces the flat 48 yd candidacy radius in
// `pos_range.w` on this lane, which is why the inn's 10 candles read as one uniform wash
// while the smithy's 3 forges read fine.
let reach_yd = color_lane.w;
// Candidacy is the FILL radius, not R: the wash reaches further than the direct pool (see
// the profile block), and rejecting at R would cut it off exactly at the pool's own edge —
// putting the rim back one term down.
let fill_yd = INTERIOR_FILL_SPAN * reach_yd;
let pos_range = wow_light.points[2u * i];
let to_light = pos_range.xyz - P;
let d2 = dot(to_light, to_light);
if (d2 > pos_range.w * pos_range.w) {
if (d2 > fill_yd * fill_yd) {
continue;
}
let d = sqrt(d2);
let c = wow_light.points[2u * i + 1u].rgb;
let atten = 1.0 / (0.7 * d + 0.03 * d2);
let c = color_lane.rgb;
// MONKEY (soft falloff): inverse square with the authored-start soft core, normalised so
// the 1 yd value is the retired hyperbolic's (profile block above).
// MONKEY (pool energy): a CONSTANT core radius in yards (profile block above) — the reach
// no longer scales the pool's brightness, only its window. `max` keeps the divide honest
// if the constant is ever tuned toward 0.
let r0 = max(INTERIOR_CORE_YD, 1e-3);
let atten = INTERIOR_CORE_GAIN / (1.0 + (d / r0) * (d / r0));
let window = interior_window(d, reach_yd, INTERIOR_DIRECT_POW);
let nl = max(
(dot(N, to_light / max(d, 1e-4)) + INTERIOR_WRAP) / (1.0 + INTERIOR_WRAP),
0.0,
@@ -272,14 +636,18 @@ fn interior_room_light(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
// lit-and-shadowed instead of flat. (`static_gx.wgsl` only; the wow_model copy of this
// function keeps the 1.0 `torch_shadow_for` stub — it has no group 3.)
let s = torch_surface_shadow(pos_range.xyz, P);
direct += c_norm * (atten * nl) * s;
let reach = 1.0 - d / max(pos_range.w, 1e-4);
direct += c_norm * (atten * nl * window) * s * w;
// MONKEY (soft falloff): the fill's profile is UNCHANGED in FORM — `(1 − d/r)²`, which is
// exactly `interior_window(d, r, 1.0)` — and only its radius moved, from R to
// `INTERIOR_FILL_SPAN·R`. That is the whole "gentle wash between the pools": at the fixture
// it is still 1 (so `interiorFill` keeps its tuned meaning) and it decays to 0 at 2R with a
// vanishing derivative, so the floor half-way between two candles is dim, not black.
let c_fill = mix(c_norm, vec3<f32>(dot(c_norm, vec3<f32>(0.299, 0.587, 0.114))), 0.5);
// The DOMINANT fixture's fill, not the SUM: an ambient room glow must not scale with the
// candle count, or a dense room (the inn's ~10 fixtures vs the smithy's 3) piles fill up
// until the rolloff saturates every surface to a flat white. `max` keeps the nearest/
// brightest fixture's glow and leaves the direct term to carry the per-fixture relief.
fill = max(fill, c_fill * (k_fill * reach * reach));
fill = max(fill, c_fill * (k_fill * interior_window(d, fill_yd, INTERIOR_FILL_POW) * w));
}
// Fill is INDIRECT bounce, so it is not shadowed; direct already carries each fixture's shadow.
return direct + fill + vec3<f32>(wow_light.point_count.y);
@@ -351,12 +719,20 @@ fn vertex(v: GxVertex) -> GxVsOut {
// (wow-re trace-forensics-abbey-interior-d3d §2: zero on every observed WMO surface) —
// and so do interior M2 props (B4): their group-MOLR point lobes are folded into the
// per-item SH probe at spawn, the entity path's own vertex-stage zeroing.
if ((v.word & WORD_WMO) != 0u) {
// WMO surfaces stay zero here, the reference's own vertex-stage zeroing. MONKEY (dynamic
// interiors): interior groups light from the live torches PER-FRAGMENT in the fragment
if ((v.word & WORD_WMO) != 0u && (v.word & WORD_INTERIOR) != 0u) {
// INTERIOR WMO surfaces stay zero here, the reference's own vertex-stage zeroing. MONKEY
// (dynamic interiors): they light from the live torches PER-FRAGMENT in the fragment
// stage (`interior_room_light`) — a per-vertex sum on a WMO's huge floor triangles is
// Gouraud: straight-edged wedges, and a torch mid-triangle lights nothing.
out.point_lit = vec3<f32>(0.0);
} else if ((v.word & WORD_WMO) != 0u) {
// MONKEY (wmo exterior points): an EXTERIOR-class group (MOGP `& 0x48`) is a street, a
// courtyard, a porch — drawn by the exterior law, so it takes the exterior point term the
// terrain beside it takes. Its own anchor is the placement origin (one point for all of
// Stormwind), so `wmo_exterior_point_sum` re-anchors on the MCNK cell; see its comment.
// Per-vertex like terrain's, not per-fragment: WMO surfaces are MOCV-baked per vertex, so
// they carry the tessellation a Gouraud term needs, and this stays one bounded walk.
out.point_lit = wmo_exterior_point_sum(world, v.normal);
} else {
out.point_lit = point_light_sum(world, v.normal, v.anchor);
}
@@ -529,10 +905,57 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
}
}
let sidn_e = sidn_rgb * (wow_light.grade.x * sidn_w);
// GL_COLOR_MATERIAL: MOCV multiplies the lit terms INSIDE the clamp (point term is
// zero on WMO surfaces, kept in the sum for exact mirroring), emission adds beside.
// MONKEY (portal claims): the EXTERIOR-class group's own room light. Bug B's second
// cause: plenty of groups INSIDE a building are authored EXTERIOR-class (MOGP `& 0x48`) —
// the Goldshire inn's whole shell is one, and its basement stairwell reads night-sky GREEN
// a step away from candle-lit rooms — and the interior lane never runs on them, so no
// fixture could reach them at all. It does now, through the SAME soft profile and the SAME
// claim table, in STRICT mode: only a fixture that positively claims THIS group of THIS
// building, and only when that claim is exterior-lane eligible (`CLAIM_EXT_OK` — a
// building's shell is, a city's district shell is not; see
// `benilla_world::lighting::LIT_ROOM_EXT_DENY`). Stormwind's streets are unaffected twice
// over: their own torches are EXTERIOR-lane sources, which this loop skips, and the
// district shells they belong to carry no eligible claim.
//
// ADDITIVE into the same clamped sum `in.point_lit` rides, rather than a blend toward the
// interior law: an exterior group's batches are EXT class, so the interior lane's
// batch-class blend would weight them 1.0 and change nothing. The clamp is why daylight is
// unaffected — the sky term already saturates it.
//
// MINUS the interior lane's BASE AMBIENT floor, which `interior_room_light` adds
// unconditionally: indoors that floor is what keeps a fixture-less nook off pure black, but
// an exterior-class surface already has the sky for that, and adding it here would lift
// every street and every outer wall in the world by a constant. Subtracting it is what
// makes an UNCLAIMED exterior fragment come out at exactly zero — rendering byte-identical
// to before — and leaves a claimed one with just its fixtures' direct + fill.
//
// COST: this is a per-FRAGMENT walk of the point table on surfaces that never had one —
// and exterior WMO geometry is most of a city's screen. So it is distance-gated, with a
// fade rather than a cliff: past `EXT_ROOM_FADE_END` the loop is not entered at all. The
// widest pool the lane can make is `1.5 x INTERIOR_LEGACY_REACH`, but every shipped fixture
// sits at `R = 7..15` yd (fill 10..22), so the gate costs nothing visible and is the
// difference between paying for the effect where it can be seen and paying for it across
// the whole skyline.
var ext_room = vec3<f32>(0.0);
let ext_room_d = distance(in.world_position.xyz, view.world_position.xyz);
if (!interior && wow_light.wmo_fog_params.w > 0.5 && ext_room_d < EXT_ROOM_FADE_END) {
let lit = interior_room_light(
in.world_position.xyz,
n_lit,
gx_room_inst(),
gx_room_group(in.word),
true, // fail CLOSED: an unclaimed exterior surface renders exactly as before
);
let far = 1.0 - smoothstep(EXT_ROOM_FADE_START, EXT_ROOM_FADE_END, ext_room_d);
ext_room = max(lit - vec3<f32>(wow_light.point_count.y), vec3<f32>(0.0)) * far;
}
// GL_COLOR_MATERIAL: MOCV multiplies the lit terms INSIDE the clamp, emission adds
// beside. MONKEY (wmo exterior points): `in.point_lit` is zero on INTERIOR groups (the
// reference's own vertex-stage zeroing — they light per fragment below) and carries the
// exterior nearest-3 point term on EXTERIOR-class ones, which is what makes a street
// torch reach the cobbles. The clamp is why it costs nothing in daylight.
let primary = clamp(
vc.rgb * (lit_wmo + in.point_lit) + sidn_e,
vc.rgb * (lit_wmo + in.point_lit + ext_room) + sidn_e,
vec3<f32>(0.0),
vec3<f32>(1.0),
);
@@ -562,7 +985,13 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
// never draws the iso-brightness contour a hard `clamp` did. `tex × illum` can't clip.
// SIDN rides inside as the emission it is. Ambient 0.15 fills the dark (cvar/slider
// once the look is confirmed).
let room = interior_room_light(in.world_position.xyz, n_lit);
let room = interior_room_light(
in.world_position.xyz,
n_lit,
gx_room_inst(),
gx_room_group(in.word),
false, // the interior lane's gate fails OPEN (see `interior_room_admits`)
);
var day_w = 0.0;
if (class_trans) {
day_w = trans_a;
@@ -618,7 +1047,16 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
// the light"). Same soft rolloff as the surface lane. UNCONDITIONAL for the prove-out; the
// probe path above is the faithful baked result the cvar will restore.
if (wow_light.wmo_fog_params.w > 0.5) {
let room = interior_room_light(in.world_position.xyz, n_lit);
// MONKEY (room gate): a WMO prop takes its OWNER GROUP's key too, where the bake
// could resolve one (a referrer set naming exactly one group); an ADT map doodad and a
// multi-room prop pass 0 and stay ungated.
let room = interior_room_light(
in.world_position.xyz,
n_lit,
gx_room_inst(),
gx_room_group(in.word),
false, // the interior lane's gate fails OPEN (see `interior_room_admits`)
);
rgb = folded * (vec3<f32>(1.0) - exp(-room * wow_light.point_count.w));
let idbg = u32(max(wow_light.wmo_fog_params.w - 1.0, 0.0) + 0.5);
if (idbg == 2u) {
+119 -12
View File
@@ -168,6 +168,33 @@ use super::pool::GxTexturePool;
/// and bits 1..=13 the interior-prop probe slot, so 14 is the first free bit.
const RECORD_FOG_BIT: u32 = 1 << 14;
/// MONKEY (room gate): record column `w` bits 15..=26 — the item's ROOM KEY, `group + 1` so that
/// **0 means "this item names no room"** and the shader's gate lets every fixture through (a
/// terrain-cell item; a WMO prop whose referrer set is not exactly one group). Bit 14 is the fog
/// lane, so 15 is the first free bit, and 12 bits covers every shipped WMO (the largest,
/// Stratholme, has 92 groups). **Keep in sync with `static_gx.wgsl`'s `RECORD_ROOM_SHIFT` /
/// `RECORD_ROOM_MASK`.**
const RECORD_ROOM_SHIFT: u32 = 15;
const RECORD_ROOM_MASK: u32 = 0xfff;
/// The room key for one baked item: `group + 1` for a WMO surface batch, and for a PROP batch whose
/// referrer set names exactly one group (a prop in one room — the common case); 0 for a terrain
/// cell, and for a multi-room prop, which stays ungated because a single key cannot express it.
fn room_key(item: &GxItemDraw, sets: &[std::sync::Arc<[u16]>]) -> u32 {
let Some(sel) = item.group else {
return 0; // a cell item: no building, no room
};
let group = if sets.is_empty() {
sel // a WMO region's selection grain IS the group
} else {
match sets.get(usize::from(sel)).map(|s| &s[..]) {
Some([g]) => *g,
_ => return 0, // unnamed or multi-room prop
}
};
(u32::from(group) + 1).min(RECORD_ROOM_MASK) << RECORD_ROOM_SHIFT
}
/// One coalesced draw run: adjacent live bake items sharing (bind-group slot, pipeline
/// bucket, group). Killed items are SKIPPED at build time (B3): a run never carries an exiled
/// or gone item, so a far cell of fully-faded faders submits no vertex work at all (the WGSL
@@ -222,7 +249,7 @@ struct GxPipelines {
cell_layout: BindGroupLayoutDescriptor,
light_layout: BindGroupLayoutDescriptor,
/// MONKEY (torch shadows Phase 1): group 3 — the torch depth array + comparison sampler + the
/// ≤4-entry `TorchTable` uniform. Present on every static_gx pipeline (the `TORCH_SHADOWS` def).
/// ≤16-entry `TorchTable` uniform. Present on every static_gx pipeline (`TORCH_SHADOWS` def).
torch_layout: BindGroupLayoutDescriptor,
sampler: Sampler,
sampler_clamp: Sampler,
@@ -246,11 +273,18 @@ fn init_pipelines(
.main_layout
.clone();
// The pass's own extra (group 2): the shared light storage.
// MONKEY (room gate): binding 1 is the per-fixture ROOM CLAIM table — which WMO groups each
// packed interior fixture may light. Its own buffer, never a `WowLight` extension: that struct
// is mirrored by three shaders plus the portrait booth and a resize there vanishes every
// building in the world.
let light_layout = BindGroupLayoutDescriptor::new(
"static_gx_light_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::VERTEX_FRAGMENT,
(storage_buffer_read_only_sized(false, None),),
(
storage_buffer_read_only_sized(false, None),
storage_buffer_read_only_sized(false, None),
),
),
);
let cell_layout = BindGroupLayoutDescriptor::new(
@@ -279,7 +313,7 @@ fn init_pipelines(
// shader's half-texel inset clamp on its clamped axis — an approximation confined to that
// class (decision 0763's silhouette concern, honoured per axis).
// MONKEY (torch shadows Phase 1): group 3 — the interior torch depth map (a Depth32Float
// 2D-array sampled through a `GreaterEqual` comparison sampler) + the ≤4-entry `TorchTable`
// 2D-array sampled through a `GreaterEqual` comparison sampler) + the ≤16-entry `TorchTable`
// uniform (count/positions/view_projs). Fragment-only; declared behind the shader's
// `TORCH_SHADOWS` def, which the specialization below always sets for this pipeline family.
let torch_layout = BindGroupLayoutDescriptor::new(
@@ -291,9 +325,16 @@ fn init_pipelines(
// `texture_2d_array` with the Depth sample type.
texture_2d_array(TextureSampleType::Depth),
sampler(SamplerBindingType::Comparison),
// TorchTable: count (u32 + pad to 16) + positions[4] (64) + view_projs[24] (1536)
// — six cube faces per fixture (Phase 5).
uniform_buffer_sized(false, Some(std::num::NonZero::new(1616).unwrap())),
// MONKEY (static torch cache): count@0 (16), positions[16]@16 (256),
// view_projs[96]@272 (6144) = 6416 bytes. Both shader copies and the shared
// material storage buffer use these same bytes; a drift hides every building.
uniform_buffer_sized(
false,
Some(
std::num::NonZero::new(super::torch_depth::TORCH_TABLE_BYTES)
.expect("TORCH_TABLE_BYTES is non-zero"),
),
),
),
),
);
@@ -310,6 +351,15 @@ fn init_pipelines(
..Default::default()
})
};
// MONKEY (room gate): the claim table's persistent GPU buffer, written every frame by
// `prepare_room_claims`. Created here (RenderStartup) rather than beside the shared light
// buffer so the whole binding — layout, buffer, upload — lives with its one reader.
commands.insert_resource(GxRoomClaims(render_device.create_buffer(&BufferDescriptor {
label: Some("static_gx_room_claims"),
size: crate::lighting::room_claim_bytes(),
usage: BufferUsages::STORAGE | BufferUsages::COPY_DST,
mapped_at_creation: false,
})));
commands.insert_resource(GxPipelines {
view_layout,
cell_layout,
@@ -506,6 +556,7 @@ fn prepare_static_gx(
};
if let Some(gpu) = assemble_region(
draw,
0, // a terrain cell belongs to no building (MONKEY, room gate)
&mut pool,
&pipes,
&pipeline_cache,
@@ -525,6 +576,7 @@ fn prepare_static_gx(
};
if let Some(gpu) = assemble_region(
draw,
entity.index().index(),
&mut pool,
&pipes,
&pipeline_cache,
@@ -546,6 +598,7 @@ fn prepare_static_gx(
};
if let Some(gpu) = assemble_region(
draw,
entity.index().index(),
&mut pool,
&pipes,
&pipeline_cache,
@@ -576,7 +629,8 @@ fn prepare_static_gx(
continue;
}
for (i, rec) in gpu.records.iter_mut().enumerate() {
// Column w carries the probe slot in the high bits (B4) — rewrite only bit 0.
// Column w carries the probe slot (B4) and the room key (MONKEY, room gate) in the
// high bits — rewrite only bit 0.
rec[3] = (rec[3] & !1) | kill_bit(&draw.killed, i);
}
render_queue.write_buffer(&gpu.record_table, 0, bytemuck::cast_slice(&gpu.records));
@@ -625,6 +679,9 @@ fn prepare_static_gx(
/// assigned stay assigned, so the retry finishes cheaper).
fn assemble_region(
draw: &GxCellDraw,
// MONKEY (room gate): this region's WMO placement instance (entity index), or 0 for a terrain
// cell — half of the surface room key the interior lane gates on.
room_instance: u32,
pool: &mut GxTexturePool,
pipes: &GxPipelines,
pipeline_cache: &PipelineCache,
@@ -637,6 +694,9 @@ fn assemble_region(
// distinct BLPs, and neighbouring cells repeat most of them; per-item layers blew the
// D2-array limit the moment a city root baked, and per-CELL arrays paid the driver churn
// 1431 measured). Untextured items ride the white class (never sampled — TEXTURED clear).
// MONKEY (room gate): whether this region can carry a room key at all (see the cell uniform
// below for the 2^24 bound and why both halves of the key must fail open together).
let gated = room_instance > 0 && room_instance < (1 << 24);
let mut white: Option<u16> = None;
let mut item_class_layer: Vec<(u16, u16)> = Vec::with_capacity(draw.draws.len());
for item in &draw.draws {
@@ -664,7 +724,9 @@ fn assemble_region(
u32::from(item.sidn[0])
| (u32::from(item.sidn[1]) << 8)
| (u32::from(item.sidn[2]) << 16),
kill_bit(&draw.killed, i) | (u32::from(item.slot) << 1),
kill_bit(&draw.killed, i)
| (u32::from(item.slot) << 1)
| if gated { room_key(item, &draw.sets) } else { 0 },
]
})
.collect();
@@ -673,9 +735,27 @@ fn assemble_region(
contents: bytemuck::cast_slice(&records),
usage: BufferUsages::STORAGE | BufferUsages::COPY_DST,
});
// MONKEY (room gate): the uniform's fourth lane was a hard 0.0 pad and now carries this
// REGION's building identity — the WMO placement instance's entity index — so the declared
// 16-byte size (and every bind-group layout built against it) does not move. Per REGION rather
// than per item, because a region is exactly one placement; that is what keeps the per-item key
// down to the group alone.
//
// As a NUMBER, not as bits: an entity index is a small integer and `f32::from_bits(12345)` is a
// DENORMAL, which a driver flushing denormals to zero would collapse to 0 — every building
// sharing identity 0, and every gate then a coin toss. An f32 holds every integer below 2^24
// exactly, which no live entity index approaches, so `as f32` / `u32()` is lossless. At or above
// that bound (and for a terrain cell, index 0) the region is packed UNGATED on BOTH lanes —
// identity AND per-item key — because half a key would fail CLOSED and black the building out.
let room_instance = if gated { room_instance } else { 0 };
let cell_uniform = render_device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("static_gx_cell"),
contents: bytemuck::cast_slice(&[draw.origin.x, draw.origin.y, draw.origin.z, 0.0f32]),
contents: bytemuck::cast_slice(&[
draw.origin.x,
draw.origin.y,
draw.origin.z,
room_instance as f32,
]),
usage: BufferUsages::UNIFORM,
});
// One bind group per DISTINCT pool class this region touches; items collapse to slots.
@@ -768,21 +848,44 @@ fn kill_bit(killed: &[u64], i: usize) -> u32 {
#[derive(Resource)]
struct GxLightBind(BindGroup);
/// MONKEY (room gate): the persistent room-claim storage buffer (group 2, binding 1). Written whole
/// every frame from the extracted [`crate::lighting::RoomClaimTable`] — 32 KB since MONKEY (soft
/// portal claims) widened the record from 8 to 32 words (the per-claim fade), one `write_buffer`,
/// and the table is rebuilt from scratch by the packer anyway, so there is nothing cheaper to
/// diff against. Persistent so the per-region bind groups that reference it never need rebuilding.
#[derive(Resource)]
struct GxRoomClaims(Buffer);
fn prepare_room_claims(
queue: Res<RenderQueue>,
buffer: Option<Res<GxRoomClaims>>,
claims: Option<Res<crate::lighting::RoomClaimTable>>,
) {
let (Some(buffer), Some(claims)) = (buffer, claims) else {
return;
};
queue.write_buffer(&buffer.0, 0, bytemuck::cast_slice(&claims.0[..]));
}
fn prepare_view_bind(
mut commands: Commands,
pipes: Res<GxPipelines>,
pipeline_cache: Res<PipelineCache>,
render_device: Res<RenderDevice>,
light: Option<Res<crate::lighting::SharedLightBuffer>>,
claims: Option<Res<GxRoomClaims>>,
) {
let Some(light) = light else {
let (Some(light), Some(claims)) = (light, claims) else {
return;
};
let layout = pipeline_cache.get_bind_group_layout(&pipes.light_layout);
commands.insert_resource(GxLightBind(render_device.create_bind_group(
"static_gx_light",
&layout,
&BindGroupEntries::sequential((light.0.as_entire_binding(),)),
&BindGroupEntries::sequential((
light.0.as_entire_binding(),
claims.0.as_entire_binding(),
)),
)));
}
@@ -827,7 +930,9 @@ fn prepare_torch_bind(
"static_gx_torch",
&layout,
&BindGroupEntries::sequential((
// The image's default view: D2Array over all 24 layers (wgpu's default for a
// The image's default view: D2Array over all 192 layers — the 96 STATIC faces the
// table's matrices address plus the 96 LIVE copies (MONKEY, static torch cache); the
// shader picks the bank per slot from `count.z`. (wgpu's default view for a
// multi-layer 2D texture; `torch_depth.rs` spells out the guarantee).
&gpu_image.texture_view,
targets.sampler(),
@@ -1030,6 +1135,8 @@ pub(super) fn build(app: &mut App) {
Render,
(
prepare_static_gx.in_set(RenderSystems::PrepareResources),
// MONKEY (room gate): before the bind groups, beside the shared light's upload.
prepare_room_claims.in_set(RenderSystems::PrepareResources),
prepare_view_bind.in_set(RenderSystems::PrepareBindGroups),
prepare_torch_bind.in_set(RenderSystems::PrepareBindGroups),
),
+358 -114
View File
@@ -1,13 +1,10 @@
//! MONKEY (torch shadows, Phase 1): a from-scratch depth-map render for the nearest interior torch
//! fixtures.
//!
//! benilla's world camera sets `ClusterConfig::None`, which disables Bevy's point/spot clustering AND
//! starves the point-light shadow-map prep — so Bevy's built-in `fetch_point_shadow` path is dead for
//! us (see `benilla_app::torch_shadow`). This module replaces it: for each of the nearest ≤4 interior
//! fixtures (published by the app as [`TorchShadowViews`]) it renders the retained interior caster
//! geometry from the fixture's viewpoint into one layer of a `Depth32Float` 2D-array texture.
//! `static_gx.wgsl`'s interior surface lane then samples that array (group 3, built in `render.rs`)
//! so a pillar throws a radial shadow on the floor.
//! MONKEY (static torch cache): persistent six-face static maps for up to sixteen fixtures.
//! Bevy point-light clustering is disabled on the world view, so this dedicated depth lane
//! supplies both receivers. Static geometry renders only on promotion/residency/fixture changes;
//! nearest-N fixtures copy their six cached faces into a live bank and overlay moving entities.
//! With the defaults (12 resident, 4 dynamic), a steady frame records 24 copies, 24 entity draws
//! and zero static draws. At most two dirty fixtures rebuild per frame; physical slots survive
//! contribution ranking and cross-fades. See benilla_app::torch_shadow for eligibility and keys.
//!
//! **Phase 3A: the depth array is a shared `Image` ASSET**, not a render-world-owned texture. The
//! entity receiver (`wow_model.wgsl`, every unit/NPC/GameObject/interior prop) has no spare bind
@@ -18,7 +15,7 @@
//! would stall every model material's bind group and blank every model), extracted here, and BOTH
//! receivers read the same `GpuImage`: the depth node renders into per-layer views of its texture,
//! static_gx binds its D2Array view, the material binds the same view + the image's comparison
//! sampler. The ≤4-fixture table gets the same treatment ([`SharedTorchBuffer`]: one raw buffer,
//! sampler. The 16-fixture table gets the same treatment ([`SharedTorchBuffer`]: one raw buffer,
//! rewritten in place every frame from [`TorchShadowViews`] with the SAME [`TorchTableUniform`]
//! bytes static_gx's group-3 uniform carries).
//!
@@ -27,6 +24,9 @@
//! too. The per-fixture `view_proj` is a reverse-Z perspective built app-side in f64 (fixtures sit at
//! absolute Bevy world coordinates, ~9,300 out) and downcast to f32.
use std::hash::{Hash, Hasher};
use std::sync::Mutex;
use bevy::asset::RenderAssetUsages;
use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d};
use bevy::ecs::query::QueryItem;
@@ -52,46 +52,74 @@ use bytemuck::Zeroable;
use super::render::StaticGxView;
use benilla_assets::materials::TorchBinds;
/// The depth-map edge (square) of ONE cube face. 512 keeps 24 faces at 24 MB of Depth32Float.
/// MONKEY (static torch cache): each 512-square Depth32 face is 1 MiB. Two banks support
/// sixteen resident static cubes and up to sixteen live entity overlays: 192 MiB total. Keep
/// the edge here so a memory-constrained port can choose 384 (108 MiB, 25% less linear detail).
const TORCH_MAP_EDGE: u32 = 512;
/// The fixture cap — four fixtures, four positions.
pub(crate) const MAX_TORCH_MAPS: usize = 4;
/// MONKEY (Phase 5): six cube faces per fixture — a 90° reverse-Z perspective down each of ±X, ±Y,
/// ±Z — so the maps cover EVERY direction. The single aimed cone this replaces always had an edge,
/// and both the vertical (walk up to the forge) and lateral (stand beside the player) shadow
/// collapses were that edge. Layer index = `fixture * CUBE_FACES + face`; the face order is the
/// contract with `benilla_app::torch_shadow::cube_view_projs` and `static_gx.wgsl`'s `torch_face`.
pub(crate) const MAX_TORCH_MAPS: usize = 16;
pub(crate) const CUBE_FACES: usize = 6;
/// Total array layers: four fixtures × six faces.
/// MONKEY (static torch cache): matrices address the 96 STATIC faces. Physical texture layers
/// 0..96 hold those faces permanently; 96..192 are live copies used only by dynamic-mask bits.
pub(crate) const MAX_TORCH_LAYERS: usize = MAX_TORCH_MAPS * CUBE_FACES;
/// The byte size of [`TorchTableUniform`] — 16 + 64 + 1536; the group-3 layout's uniform size and
/// the [`SharedTorchBuffer`]'s allocation, checked against the struct below.
pub(crate) const TORCH_TABLE_BYTES: u64 = 1616;
const TORCH_TEXTURE_LAYERS: usize = MAX_TORCH_LAYERS * 2;
/// MONKEY (static torch cache): count@0 (16), positions@16 (256), view_projs@272 (6144).
/// Total 6416 bytes, identical under WGSL uniform/storage alignment. count.z is the live-bank
/// bit mask, count.w reserved. Both WGSL TorchTable copies and render.rs MUST agree with this.
pub(crate) const TORCH_TABLE_BYTES: u64 = 6416;
/// **The app→render publication** (`benilla_app::torch_shadow` writes it each frame, extracted here):
/// the nearest interior fixtures' down-looking `view_proj`s + world positions and the shared interior
/// caster mesh. `count` is how many of the four slots are live this frame (0 when the lane is off).
#[derive(Resource, Clone, ExtractResource, Default)]
/// the promoted fixtures' cube-face matrices, world positions and static mesh revisions.
/// `count` is the sparse slot high-water mark (0 when the lane is off).
#[derive(Resource, Clone, ExtractResource)]
pub struct TorchShadowViews {
pub count: u32,
/// `positions[i].xyz` = fixture world position (absolute Bevy), `.w` = its range (yd).
// MONKEY (static torch cache): sparse physical slots; count is the high-water mark, not the
// population. Holes have weight zero. ready_mask is assigned only in render preparation.
pub dynamic_mask: u32,
pub ready_mask: u32,
/// MONKEY (torch caster selection): the live PCF tap-radius scale (`interiorShadowSoft`,
/// 0.5..3). Carried here rather than in `DynamicInteriors` because it belongs to the shadow
/// table's own bytes — [`TorchTableUniform::pack`] puts it in the otherwise-padding `count.y`.
pub soft: f32,
/// `positions[i].xyz` = fixture world position (absolute Bevy), `.w` = the slot's FADE WEIGHT
/// `0..1` (MONKEY, torch caster selection). The `.w` lane used to carry the fixture's range,
/// which no shader ever read; it now carries the cross-fade, and both receivers return
/// `mix(1.0, shadow, w)` so a promoted fixture's shadow ramps in over ~1/3 s and a demoted one
/// ramps out before its slot is reused — the fix for the pop this whole change is about.
pub positions: [Vec4; MAX_TORCH_MAPS],
/// The reverse-Z `view_proj` of cube face `f` of fixture `i` at `[i * CUBE_FACES + f]`
/// (identity in unused slots).
pub view_projs: [Mat4; MAX_TORCH_LAYERS],
/// The interior shadow-caster mesh (retained static geometry near the camera), or `None` when the
/// lane has not built one this frame.
pub caster_mesh: Option<AssetId<Mesh>>,
/// MONKEY (Phase 3B): the per-frame ENTITY caster — furniture, NPCs, the player — rebuilt every
/// frame by the lane (entities move), drawn into every layer alongside `caster_mesh` so a table
/// or a character throws a torch shadow on the floor too.
/// MONKEY (static torch cache): fresh asset identity per geometry revision/slot owner;
/// None while the app's two-rebuild budget defers this slot. Static uploads must be resident
/// before depth can be marked valid. Empty resident meshes still clear all six faces.
pub caster_meshes: [Option<AssetId<Mesh>>; MAX_TORCH_MAPS],
/// MONKEY (static torch cache): moving/skinned entities only, regenerated each frame.
pub entity_mesh: Option<AssetId<Mesh>>,
}
/// Hand-written because `[Mat4; 96]` has no `Default` — std only derives array `Default` up to
/// `N = 32`, so the derive that worked at 24 layers stops compiling at 96. (It also lets `soft`
/// default to a sane `1.0` rather than a zero tap radius.)
impl Default for TorchShadowViews {
fn default() -> Self {
Self {
count: 0,
dynamic_mask: 0,
ready_mask: 0,
soft: 1.0,
positions: [Vec4::ZERO; MAX_TORCH_MAPS],
view_projs: [Mat4::ZERO; MAX_TORCH_LAYERS],
caster_meshes: [None; MAX_TORCH_MAPS],
entity_mesh: None,
}
}
}
/// MONKEY (torch shadows Phase 3A): the ONE shared torch depth array — a `Depth32Float`
/// 512×512×24-layer `Image` asset (`RENDER_ATTACHMENT | TEXTURE_BINDING`, `RENDER_WORLD`-only,
/// MONKEY (static torch cache): 512×512×192-layer `Image` asset (render, sample, copy-src/dst),
/// no CPU data) whose sampler descriptor carries `compare: Some(GreaterEqual)`, so its `GpuImage`
/// sampler is a real comparison sampler and its default view is a `D2Array` over all 24 layers
/// sampler is a real comparison sampler and its default view is a `D2Array` over all 192 layers
/// (wgpu's default view dimension for a multi-layer 2D texture). Created ONCE at startup by
/// [`new_torch_shared`] (always — regardless of the interior-shadow cvars — because every model
/// material binds it; a missing image would stall every material's bind group), inserted in the
@@ -100,7 +128,7 @@ pub struct TorchShadowViews {
#[derive(Resource, Clone, ExtractResource)]
pub struct TorchDepthImage(pub Handle<Image>);
/// MONKEY (torch shadows Phase 3A): the shared ≤4-fixture torch TABLE buffer — 1616 bytes of
/// MONKEY (torch shadows Phase 3A): the shared 16-fixture torch TABLE buffer — 6416 bytes of
/// [`TorchTableUniform`] (`STORAGE | COPY_DST`), the entity receiver's `torch_table` at material
/// binding 93. Created once beside [`TorchDepthImage`], cloned into every model material, and
/// rewritten in place every frame by [`upload_torch_table`] (render world, `PrepareResources`) —
@@ -143,13 +171,16 @@ pub fn new_torch_shared(
size: Extent3d {
width: TORCH_MAP_EDGE,
height: TORCH_MAP_EDGE,
depth_or_array_layers: MAX_TORCH_LAYERS as u32,
depth_or_array_layers: TORCH_TEXTURE_LAYERS as u32,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Depth32Float,
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
// MONKEY (static torch cache): wgpu 27 transfer.rs permits partial ARRAY layers
// for depth, provided x/y cover the entire mip. Banks are disjoint subresources.
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING
| TextureUsages::COPY_SRC | TextureUsages::COPY_DST,
view_formats: &[],
},
// The receivers' `GreaterEqual` comparison sampler (reverse-Z: lit iff the fragment's own
@@ -180,18 +211,21 @@ pub fn new_torch_shared(
(TorchDepthImage(images.add(image)), SharedTorchBuffer(buffer))
}
/// The std140/std430 `TorchTable` — mirrored in `static_gx.wgsl` (group 3, binding 2, uniform) and
/// `wow_model.wgsl` (material binding 93, storage). `count` pads to a vec4; `positions[i].xyz` is
/// the fixture, `.w` its range; `view_projs[i*6 + f]` is fixture `i`'s reverse-Z matrix for cube
/// face `f` (Phase 5: six 90° faces cover every direction, replacing the single aimed cone whose
/// edge kept eating shadows). Unused slots stay zero (never sampled: the shaders loop `count`).
/// 16 + 64 + 1536 = 1616 bytes — [`TORCH_TABLE_BYTES`]; identical bytes under both layouts
/// because every member is 16-byte aligned.
/// MONKEY (static torch cache): byte-identical std140/std430 table in BOTH receiver shaders.
/// count@0: live high-water mark, soft*100, dynamic mask, reserved (16 bytes).
/// positions[16]@16: world xyz + fade weight (256 bytes).
/// view_projs[96]@272: six static-bank face matrices per physical slot (6144 bytes).
/// Total 6416. Unready/hole slots have zero weight; no stale owner's depth can be sampled.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub(crate) struct TorchTableUniform {
count: u32,
_pad: [u32; 3],
/// MONKEY (torch caster selection): `soft x 100` — the PCF tap-radius scale, riding the first
/// of the three padding words the `vec4<u32>` alignment already forced us to carry. The
/// alternative was a whole new 16-byte row for one dial.
soft_x100: u32,
dynamic_mask: u32,
_pad: u32,
positions: [[f32; 4]; MAX_TORCH_MAPS],
view_projs: [[[f32; 4]; 4]; MAX_TORCH_LAYERS],
}
@@ -208,8 +242,14 @@ impl TorchTableUniform {
};
let count = (views.count as usize).min(MAX_TORCH_MAPS);
table.count = count as u32;
table.dynamic_mask = views.dynamic_mask & views.ready_mask;
// MONKEY (torch caster selection): 0 would mean a zero tap radius (four identical taps =
// a hard edge), so a table published without a scale reads as the neutral 1.0.
let soft = if views.soft > 0.01 { views.soft } else { 1.0 };
table.soft_x100 = (soft * 100.0).round().max(1.0) as u32;
for i in 0..count {
table.positions[i] = views.positions[i].to_array();
if views.ready_mask & (1 << i) == 0 { table.positions[i][3] = 0.0; }
// Six cube faces per fixture: layer = fixture * 6 + face (the depth node renders and
// the shaders' `torch_face` pick by the same index).
for f in 0..CUBE_FACES {
@@ -223,7 +263,7 @@ impl TorchTableUniform {
/// Render-world: rewrite the shared torch table in place every frame, before any draw reads it
/// (`RenderSystems::PrepareResources` — the same stage `upload_light` writes the light blob). One
/// 1.6 KB upload per frame, independent of material count. static_gx's group-3 uniform is packed
/// MONKEY (static torch cache): 6416-byte upload per frame, independent of material count. static_gx's group-3 uniform is packed
/// from the same [`TorchTableUniform::pack`] in `render.rs`, so the two receivers can never
/// disagree on a fixture.
fn upload_torch_table(
@@ -266,11 +306,23 @@ struct TorchLayerViews {
views: Vec<TextureView>,
}
/// MONKEY (static torch cache): only a completed six-face command recording certifies a cache
/// entry. Preparation may run while there is no world view or the pipeline is still compiling;
/// such a frame must not mark a never-rendered map cached. Texture recreation resets all keys.
#[derive(Resource, Default)]
struct TorchStaticCache(Mutex<[Option<AssetId<Mesh>>; MAX_TORCH_MAPS]>);
// MONKEY (static torch cache): the app logs requests; this records actual GPU cache work.
#[derive(Resource, Default)]
struct TorchCacheTrace(Mutex<Option<std::time::Instant>>);
/// This frame's per-fixture view-uniform bind groups (one per live map), rebuilt in
/// [`RenderSystems::PrepareBindGroups`].
/// MONKEY (static torch cache): retained across frames; refreshed in PrepareResources.
#[derive(Resource, Default)]
struct TorchDepthDraw {
view_projs: Vec<Mat4>,
count: usize,
rebuild_mask: u32,
/// `bind_groups[i]` binds layer `i`'s `view_proj` at group 0 of the depth pipeline.
bind_groups: Vec<BindGroup>,
}
@@ -346,20 +398,28 @@ fn init_torch_depth(
/// Build one view-uniform bind group per live layer from the extracted [`TorchShadowViews`], and
/// (re)build the per-layer attachment views whenever the shared image's texture changes identity.
fn prepare_torch_depth(
mut commands: Commands,
mut draw: ResMut<TorchDepthDraw>,
targets: Option<Res<TorchDepthTargets>>,
views: Option<Res<TorchShadowViews>>,
mut views: Option<ResMut<TorchShadowViews>>,
cache: Res<TorchStaticCache>,
meshes: Res<RenderAssets<RenderMesh>>,
allocator: Res<MeshAllocator>,
image: Option<Res<TorchDepthImage>>,
images: Res<RenderAssets<GpuImage>>,
mut layers: ResMut<TorchLayerViews>,
pipeline_cache: Res<PipelineCache>,
render_device: Res<RenderDevice>,
) {
// The attachment views, cached by texture id — 24 views once, not per frame.
draw.count = 0;
draw.rebuild_mask = 0;
// MONKEY (static torch cache): fail closed until every prerequisite for this frame exists.
if let Some(views) = views.as_mut() { views.ready_mask = 0; }
// MONKEY (static torch cache): attachment views are allocated once per texture identity.
let gpu_image = image.as_ref().and_then(|h| images.get(&h.0));
match gpu_image {
Some(gpu) if layers.texture != Some(gpu.texture.id()) => {
layers.views = (0..MAX_TORCH_LAYERS as u32)
*cache.0.lock().unwrap() = [None; MAX_TORCH_MAPS];
layers.views = (0..TORCH_TEXTURE_LAYERS as u32)
.map(|i| {
gpu.texture.create_view(&TextureViewDescriptor {
label: Some("torch_depth_layer"),
@@ -384,22 +444,33 @@ fn prepare_torch_depth(
}
}
let Some(targets) = targets else {
commands.insert_resource(TorchDepthDraw::default());
return;
};
let count = views
.as_ref()
.map_or(0, |v| (v.count as usize).min(MAX_TORCH_MAPS));
if count == 0 {
commands.insert_resource(TorchDepthDraw::default());
return;
}
let views = views.expect("count > 0 implies views present");
let mut views = views.expect("count > 0 implies views present");
if layers.texture.is_none() || pipeline_cache.get_render_pipeline(targets.pipeline).is_none() {
return;
}
let cached = cache.0.lock().unwrap();
let (ready_mask, rebuild_mask) = torch_cache_plan(&*cached, &views.caster_meshes[..count],
|id| torch_mesh_ready(id, &meshes, &allocator));
views.ready_mask = ready_mask;
// If entity upload is late, bind the static bank for this frame, never last frame's overlay.
if !views.entity_mesh.is_some_and(|id| torch_mesh_ready(id, &meshes, &allocator)) {
views.dynamic_mask = 0;
}
let layout = pipeline_cache.get_bind_group_layout(&targets.view_layout);
// Six cube faces per live fixture: layer = fixture * CUBE_FACES + face.
let layer_count = count * CUBE_FACES;
let mut bind_groups = Vec::with_capacity(layer_count);
for i in 0..layer_count {
// MONKEY (static torch cache): fixture projections do not move with the camera.
// Keep their buffers/bind groups too: zero resource allocation on a steady frame.
if draw.view_projs.get(i) == Some(&views.view_projs[i]) { continue; }
let vp = views.view_projs[i].to_cols_array();
let buffer = render_device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("torch_depth_view"),
@@ -407,16 +478,21 @@ fn prepare_torch_depth(
usage: BufferUsages::UNIFORM,
});
// The wgpu bind group keeps the buffer alive; the local handle can drop.
bind_groups.push(render_device.create_bind_group(
let bind_group = render_device.create_bind_group(
"torch_depth_view",
&layout,
&BindGroupEntries::sequential((buffer.as_entire_binding(),)),
));
);
if i < draw.bind_groups.len() {
draw.bind_groups[i] = bind_group;
draw.view_projs[i] = views.view_projs[i];
} else {
draw.bind_groups.push(bind_group);
draw.view_projs.push(views.view_projs[i]);
}
}
commands.insert_resource(TorchDepthDraw {
count: layer_count,
bind_groups,
});
draw.count = layer_count;
draw.rebuild_mask = rebuild_mask;
}
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
@@ -465,58 +541,77 @@ impl ViewNode for TorchDepthNode {
};
let meshes = world.resource::<RenderAssets<RenderMesh>>();
let allocator = world.resource::<MeshAllocator>();
// MONKEY (Phase 3B): BOTH casters — the drift-rebuilt static interior (walls/columns) and the
// per-frame entity set (furniture, NPCs, the player) — resolved up front. One that is not
// resident this frame (just built, or empty) is skipped, never fatal to the other.
let mut draws = Vec::with_capacity(2);
for mesh_id in [views.caster_mesh, views.entity_mesh].into_iter().flatten() {
let Some(mesh) = meshes.get(mesh_id) else {
continue;
};
let (Some(vslice), Some(islice)) = (
allocator.mesh_vertex_slice(&mesh_id),
allocator.mesh_index_slice(&mesh_id),
) else {
continue;
};
let RenderMeshBufferInfo::Indexed { index_format, .. } = &mesh.buffer_info else {
continue;
};
if islice.range.is_empty() {
continue;
let image = world.resource::<TorchDepthImage>();
let images = world.resource::<RenderAssets<GpuImage>>();
let Some(gpu) = images.get(&image.0) else { return Ok(()) };
let cache = world.resource::<TorchStaticCache>();
let mut cached = cache.0.lock().unwrap();
let trace = world.resource::<TorchCacheTrace>();
let trace_on = std::env::var_os("WOW_TORCH_TRACE").is_some();
let mut last_trace = trace.0.lock().unwrap();
let trace_due = trace_on && last_trace.is_none_or(|t| t.elapsed().as_secs_f32() >= 1.0);
if trace_due { *last_trace = Some(std::time::Instant::now()); }
for slot in 0..count / CUBE_FACES {
if views.ready_mask & (1 << slot) == 0 { continue; }
let rebuild = draw.rebuild_mask & (1 << slot) != 0
&& cached[slot] != views.caster_meshes[slot];
let dynamic = views.dynamic_mask & (1 << slot) != 0;
// MONKEY (static torch cache): render static once, then copy to the disjoint live
// bank BEFORE each entity pass. GreaterEqual + Load preserves the nearest surface.
for face in 0..CUBE_FACES {
let layer = slot * CUBE_FACES + face;
if rebuild {
let mut pass = render_context.begin_tracked_render_pass(RenderPassDescriptor {
label: Some("torch_static_rebuild"),
color_attachments: &[],
depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
view: &layers.views[layer],
depth_ops: Some(Operations { load: LoadOp::Clear(0.0), store: StoreOp::Store }),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_render_pipeline(pipeline);
pass.set_bind_group(0, &draw.bind_groups[layer], &[]);
draw_torch_mesh(&mut pass, views.caster_meshes[slot], meshes, allocator);
}
if dynamic {
let live = layer + MAX_TORCH_LAYERS;
render_context.command_encoder().copy_texture_to_texture(
TexelCopyTextureInfo {
texture: &gpu.texture, mip_level: 0,
origin: Origin3d { x: 0, y: 0, z: layer as u32 },
aspect: TextureAspect::DepthOnly,
},
TexelCopyTextureInfo {
texture: &gpu.texture, mip_level: 0,
origin: Origin3d { x: 0, y: 0, z: live as u32 },
aspect: TextureAspect::DepthOnly,
},
Extent3d { width: TORCH_MAP_EDGE, height: TORCH_MAP_EDGE, depth_or_array_layers: 1 },
);
let mut pass = render_context.begin_tracked_render_pass(RenderPassDescriptor {
label: Some("torch_dynamic_overlay"),
color_attachments: &[],
depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
view: &layers.views[live],
depth_ops: Some(Operations { load: LoadOp::Load, store: StoreOp::Store }),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_render_pipeline(pipeline);
pass.set_bind_group(0, &draw.bind_groups[layer], &[]);
draw_torch_mesh(&mut pass, views.entity_mesh, meshes, allocator);
}
}
draws.push((vslice, islice, *index_format));
}
if draws.is_empty() {
return Ok(());
}
for i in 0..count {
let mut pass = render_context.begin_tracked_render_pass(RenderPassDescriptor {
label: Some("torch_depth"),
color_attachments: &[],
depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
view: &layers.views[i],
// Reverse-Z: clear to 0.0 (far), keep the nearest surface (GreaterEqual).
depth_ops: Some(Operations {
load: LoadOp::Clear(0.0),
store: StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_render_pipeline(pipeline);
pass.set_bind_group(0, &draw.bind_groups[i], &[]);
// Static walls + moving entities into the SAME layer under the one clear.
for (vslice, islice, index_format) in &draws {
pass.set_vertex_buffer(0, vslice.buffer.slice(..));
pass.set_index_buffer(islice.buffer.slice(..), *index_format);
pass.draw_indexed(
islice.range.start..islice.range.end,
i32::try_from(vslice.range.start).unwrap_or(0),
0..1,
);
if rebuild { cached[slot] = views.caster_meshes[slot]; }
if trace_on && (trace_due || rebuild) {
info!("torch-cache: slot {slot} static {} dynamic {}",
if rebuild { "rebuilt" } else { "cached" },
if dynamic { "yes" } else { "no" });
}
}
Ok(())
@@ -550,10 +645,15 @@ pub(super) fn build(app: &mut App) {
};
render_app
.init_resource::<TorchLayerViews>()
.init_resource::<TorchStaticCache>()
.init_resource::<TorchDepthDraw>()
.init_resource::<TorchCacheTrace>()
.add_systems(RenderStartup, init_torch_depth)
.add_systems(
Render,
prepare_torch_depth.in_set(RenderSystems::PrepareBindGroups),
// MONKEY (static torch cache): ready/dynamic masks must be final before BOTH
// the shared storage upload and static_gx's PrepareBindGroups uniform pack.
prepare_torch_depth.in_set(RenderSystems::PrepareResources).before(upload_torch_table),
)
.add_render_graph_node::<ViewNodeRunner<TorchDepthNode>>(Core3d, TorchDepthLabel)
.add_render_graph_edges(
@@ -567,3 +667,147 @@ pub(super) fn build(app: &mut App) {
),
);
}
// MONKEY (static torch cache): planning does not commit residency. A missing upload cannot
// certify an empty map, and the two-rebuild budget also covers GPU texture recreation.
fn torch_cache_plan<T: Copy + Eq>(cached: &[Option<T>], requested: &[Option<T>],
mut mesh_ready: impl FnMut(T) -> bool) -> (u32, u32) {
let (mut ready, mut rebuild) = (0u32, 0u32);
for (i, requested) in requested.iter().take(MAX_TORCH_MAPS).enumerate() {
let Some(id) = *requested else { continue };
if cached[i] == Some(id) {
ready |= 1 << i;
} else if rebuild.count_ones() < 2 && mesh_ready(id) {
rebuild |= 1 << i;
ready |= 1 << i;
}
}
(ready, rebuild)
}
// MONKEY (static torch cache): an empty mesh is a valid all-clear cube. A nonempty mesh without
// allocator slices is still uploading, and MUST NOT become a permanently cached empty shadow.
fn torch_mesh_ready(id: AssetId<Mesh>, meshes: &RenderAssets<RenderMesh>, allocator: &MeshAllocator) -> bool {
let Some(mesh) = meshes.get(id) else { return false };
match mesh.buffer_info {
RenderMeshBufferInfo::Indexed { count, .. } => count == 0
|| (allocator.mesh_vertex_slice(&id).is_some() && allocator.mesh_index_slice(&id).is_some()),
_ => false,
}
}
fn draw_torch_mesh<'a>(
pass: &mut bevy::render::render_phase::TrackedRenderPass<'a>, id: Option<AssetId<Mesh>>,
meshes: &'a RenderAssets<RenderMesh>, allocator: &'a MeshAllocator,
) {
let Some(id) = id else { return };
let Some(mesh) = meshes.get(id) else { return };
let RenderMeshBufferInfo::Indexed { index_format, count } = mesh.buffer_info else { return };
if count == 0 { return; }
let (Some(v), Some(i)) = (allocator.mesh_vertex_slice(&id), allocator.mesh_index_slice(&id)) else { return };
pass.set_vertex_buffer(0, v.buffer.slice(..));
pass.set_index_buffer(i.buffer.slice(..), index_format);
pass.draw_indexed(i.range.clone(), i32::try_from(v.range.start).unwrap_or(0), 0..1);
}
impl super::StaticGx {
/// MONKEY (static torch cache): fixture-local static collection uses batch bounds, not the
/// building's placement origin. A distant Abbey room is still part of a WMO anchored more
/// than 48 yards away. The key and the mesh MUST use this identical admission predicate.
pub fn append_torch_triangles(&self, center: Vec3, reach: f32,
positions: &mut Vec<[f32; 3]>, indices: &mut Vec<u32>) {
for cell in self.cells.values().chain(self.wmos.values()).chain(self.props.values()) {
for item in &cell.items {
if !torch_item_in_range(item, center, reach) { continue; }
let base = positions.len() as u32;
positions.extend(item.geometry.positions.iter().map(|p|
item.transform.transform_point(benilla_assets::coords::wow_to_bevy(*p)).to_array()));
let added = positions.len() as u32 - base;
for tri in item.geometry.indices.chunks_exact(3) {
if tri.iter().all(|i| *i < added) { indices.extend(tri.iter().map(|i| base + *i)); }
}
}
}
}
/// MONKEY (static torch cache): residency fingerprint of the exact opaque source set used
/// by append_torch_triangles. Order-independent per-item hashing ignores HashMap reordering
/// and camera/portal/fader bookkeeping. Arrival, unload, replacement or transform edits in
/// THIS fixture's sphere invalidate it even when neither camera nor fixture moves.
pub fn torch_geometry_key(&self, center: Vec3, reach: f32) -> u64 {
let mut sum = 0u64;
for cell in self.cells.values().chain(self.wmos.values()).chain(self.props.values()) {
for item in &cell.items {
if !torch_item_in_range(item, center, reach) { continue; }
let mut hash = std::collections::hash_map::DefaultHasher::new();
// MONKEY (static torch cache): source residency stamp prevents allocator address
// reuse after unload/reload from impersonating the previous geometry Arc.
cell.last_change.hash(&mut hash);
(std::sync::Arc::as_ptr(&item.geometry) as usize).hash(&mut hash);
item.transform.to_matrix().to_cols_array().map(f32::to_bits).hash(&mut hash);
sum = sum.wrapping_add(hash.finish());
}
}
sum
}
}
// MONKEY (static torch cache): a conservative transformed bounding sphere, independent of
// portal visibility. Missing bounds admit the source rather than silently losing a wall.
fn torch_item_in_range(item: &super::GxItem, center: Vec3, reach: f32) -> bool {
!item.cutout && item.local_aabb.is_none_or(|aabb| torch_bound_in_range(
&item.transform, aabb.center.into(), aabb.half_extents.into(), center, reach))
}
fn torch_bound_in_range(transform: &Transform, local_center: Vec3, half_extents: Vec3,
center: Vec3, reach: f32) -> bool {
let origin = transform.transform_point(local_center);
let radius = (half_extents * transform.scale.abs()).length();
origin.distance_squared(center) <= (reach + radius) * (reach + radius)
}
#[cfg(test)]
mod tests {
use super::*;
// MONKEY (static torch cache): unchanged owners do no work; replacement, streaming,
// absent uploads, sparse holes and texture recreation cannot certify stale depth.
#[test]
fn cache_plan_reuses_and_limits_rebuilds() {
let cached = [Some(10), Some(11), None, Some(13)];
assert_eq!(torch_cache_plan(&cached, &cached, |_| false), (0b1011, 0));
let requested = [Some(20), Some(11), Some(12), Some(23)];
assert_eq!(torch_cache_plan(&cached, &requested, |_| true), (0b0111, 0b0101));
assert_eq!(torch_cache_plan(&cached, &requested, |id| id == 23), (0b1010, 0b1000));
assert_eq!(torch_cache_plan(&cached, &[None, Some(11)], |_| true), (0b10, 0));
assert_eq!(torch_cache_plan(&[None; 4], &requested, |_| true), (0b11, 0b11));
}
#[test]
fn torch_bounds_include_remote_building_batches() {
let transform = Transform::from_xyz(200.0, 0.0, 0.0).with_scale(Vec3::splat(2.0));
assert!(torch_bound_in_range(&transform, Vec3::new(-100.0, 0.0, 0.0),
Vec3::splat(10.0), Vec3::ZERO, 48.0));
assert!(!torch_bound_in_range(&transform, Vec3::ZERO,
Vec3::splat(10.0), Vec3::ZERO, 48.0));
}
// MONKEY (static torch cache): protect uniform/storage byte agreement and sparse-slot bank
// selection, including the highest slot and an upload that has not reached the GPU yet.
#[test]
fn torch_table_layout_and_readiness() {
assert_eq!(std::mem::size_of::<TorchTableUniform>(), 6416);
assert_eq!(std::mem::offset_of!(TorchTableUniform, positions), 16);
assert_eq!(std::mem::offset_of!(TorchTableUniform, view_projs), 272);
let mut views = TorchShadowViews { count: 16, dynamic_mask: (1 << 15) | 1,
ready_mask: 1 << 15, ..Default::default() };
views.positions[0] = Vec4::ONE;
views.positions[15] = Vec4::ONE;
let table = TorchTableUniform::pack(Some(&views));
assert_eq!(table.dynamic_mask, 1 << 15);
assert_eq!(table.positions[0][3], 0.0);
assert_eq!(table.positions[15][3], 1.0);
assert_eq!(MAX_TORCH_LAYERS, 96);
assert_eq!(TORCH_TEXTURE_LAYERS, 192);
}
}
+4 -1
View File
@@ -62,7 +62,10 @@ pub use spawn::prop_light::{fold_interior_probe, interior_light_up, PropLobeLigh
// doodad-prop path's spawner (`crate::entities`' `wmo_props`: the ship's sails ride the streamed
// gameobject entity, and its cargo hulls ride the boat's kinematic body).
pub use collider::{build_collider_task, placement_collider_data, PendingCollider};
pub use spawn::{m2_anim_bound, m2_fade, point_light, spawn_model_entities, SpawnedModel};
pub use spawn::{
carried_light_claims, m2_anim_bound, m2_fade, point_light, spawn_model_entities,
CarriedClaimSet, SpawnedModel,
};
// The position queries + area authority (their home is `queries`; paths stay `terrain_stream::X`).
use queries::update_current_area;
pub use queries::{
@@ -3,9 +3,10 @@
//! trails). A transport prop takes the entity-owned shapes instead (`crate::entities`' `wmo_props`,
//! decision 0474) — riding owners and prop-local children, never a world bake.
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::{ModelEmitter, ModelLight};
use benilla_formats::WmoLight;
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_assets::{ModelEmitter, ModelLight, WmoModel};
use benilla_formats::room_claim::{self, PortalGraph};
use benilla_formats::{WmoGroupInfo, WmoLight};
use bevy::prelude::*;
use crate::particles;
@@ -24,6 +25,12 @@ const POINT_LIGHT_INTENSITY: f32 = 4.0 * std::f32::consts::PI;
/// still gathers them — erring wide only ever admits negligible candidates. Also Bevy's clustering
/// range for the `PointLight` itself, which our shaders don't consume.
const POINT_LIGHT_RANGE: f32 = 48.0;
/// MONKEY (wmo exterior points): how close an authored MOLT fixture has to stand to a prop's flame
/// (yd) for that flame's SYNTHESISED light to be dropped as a duplicate. The vanilla convention is
/// a MOLT authored at the flame itself, so the real pairs sit well under a yard apart; 2.5 yd
/// covers a bracket-mounted torch whose fixture the artist put at the wall instead, without
/// reaching the next prop along a street (Stormwind's street lamps are ≥ 8 yd apart).
const MODD_SYNTH_DEDUPE: f32 = 2.5;
/// The one `PointLight` recipe for an authored WoW light source (M2 or WMO MOLT). The PointLight only
/// carries position + colour into the shared packed table; every lit surface — terrain, M2
@@ -56,6 +63,22 @@ fn spawn_point_light(
// a culled room is one the reference never instantiated, so it lights nothing
// ([`crate::lighting::LightRooms`], decision 0689).
room: Option<crate::wmo_portal::WmoGroupVis>,
// MONKEY (fire GO lights): tag a SYNTHESISED source so the packer can ride the live
// `fireLightGain` cvar over it without respawning the world
// ([`crate::lighting::SyntheticFireLight`]).
synthetic: bool,
// MONKEY (interior attenuation): the source's AUTHORED attenuation end (yd) where the record
// carries a usable one — i.e. a WMO MOLT fixture, and nothing else. `None` for every M2 light,
// whose authored pair is a template default rather than a reach; the packer buckets those from
// intensity instead ([`crate::lighting::m2_light_reach`], and see [`crate::lighting::LightReach`]
// for the corpus evidence).
reach: Option<f32>,
// MONKEY (room gate): the rooms this source may LIGHT, over and above the ones that must be
// visible for it to exist ([`crate::lighting::LightLitRooms`] carries the whole argument).
// MONKEY (review fixes): MOLT and MODD claims keep their placement even with no MOLR/MODR.
// MONKEY (soft portal claims): the component, built whole by [`lit_rooms`] — the group ids
// and their per-claim fades travel together or not at all.
lit_rooms: Option<crate::lighting::LightLitRooms>,
out: &mut Vec<Entity>,
) {
let mut e = commands.spawn((
@@ -65,6 +88,15 @@ fn spawn_point_light(
if let Some(room) = room {
e.insert(crate::lighting::LightRooms(room));
}
if let Some(lit) = lit_rooms.filter(|l| !l.rooms.groups.is_empty()) {
e.insert(lit);
}
if synthetic {
e.insert(crate::lighting::SyntheticFireLight);
}
if let Some(reach) = reach.filter(|r| *r > 0.5) {
e.insert(crate::lighting::LightReach(reach));
}
out.push(e.id());
}
@@ -76,29 +108,326 @@ fn spawn_point_light(
/// `room` is the prop's own rooms for a WMO prop (`None` for an ADT map doodad) — taken straight off
/// the [`emitter_fade`] built for this placement, so the prop's mesh, its flames, its streamers and
/// its glow are all admitted or refused by one value.
///
/// MONKEY (fire GO lights): `synthetic` says whether this lane accepts lights DERIVED from a
/// model's flame emitter ([`benilla_assets::ModelLight::synthetic`]). The ADT map-doodad lane does
/// — an outdoor campfire or wall torch has nothing else to light it.
///
/// MONKEY (wmo exterior points) / MONKEY (interior prop lights): the **WMO MODD prop lane now does
/// too — everywhere, indoors included.** It was first opened for EXTERIOR-class groups only (a
/// street, a courtyard, a porch), on the reasoning that a building's artists put a companion MOLT
/// fixture at each of its indoor torch flames, so a synthesised twin would double-light exactly the
/// rooms the interior lane is calibrated against. That assumption is FALSE for most buildings: the
/// `benilla-extract wmolamps` sweep counts 14,937 props across 506 roots whose model would
/// synthesise a light, and only 4,477 of them (30%) have an authored MOLT fixture within
/// [`MODD_SYNTH_DEDUPE`]. The Stormwind Bank's ceiling lanterns, the Crawford Winery's lanterns and
/// the Wizard's Sanctum's candelabras are all in the other 70%, and they lit nothing at all — the
/// reported bug. The DEDUPE, not the group class, is what stops the double-lighting where the
/// assumption did hold (the abbey pairs 41 of its 42 flames with a fixture; the inn 8 of 33).
///
/// `molt_world` is the same placement's authored MOLT fixtures in world space — that DUPLICATE
/// GUARD, applied to the synthesised light's own world position (the flame, not the prop origin,
/// which is why the check is here and not at resolve time).
///
/// MONKEY (portal claims): `claims` is the owning WMO's claim inputs (`None` for an ADT map
/// doodad). With it, a synthesised prop light gets the SAME room-claim set an authored MOLT fixture
/// gets — its own MODR rooms, unioned with the groups whose MOGI box holds the flame and the ones
/// one portal away — so the room gate admits a hanging lantern in the room it hangs in. Without it
/// the light would be packed with the prop's MODR rooms alone, which for a prop no group names is
/// no claim at all, i.e. UNGATED: one lantern lighting every room of the building through its
/// floors. That is why the claims travel with the lane rather than being optional dressing.
pub(super) fn spawn_lights_for(
commands: &mut Commands,
lights: &[ModelLight],
transform: Transform,
room: Option<&crate::wmo_portal::WmoGroupVis>,
synthetic: bool,
molt_world: &[Vec3],
claims: Option<&PropClaims<'_>>,
out: &mut Vec<Entity>,
) {
for l in lights.iter().map(|l| &l.def) {
if !l.casts() {
for l in lights.iter().filter(|l| synthetic || !l.synthetic) {
let def = &l.def;
if !def.casts() {
continue; // directional lights feed an ambient term; a static `0` visibility key is dark
}
let world = transform.transform_point(wow_to_bevy(l.position));
let world = transform.transform_point(wow_to_bevy(def.position));
if l.synthetic
&& molt_world
.iter()
.any(|p| p.distance_squared(world) < MODD_SYNTH_DEDUPE * MODD_SYNTH_DEDUPE)
{
continue; // an authored MOLT fixture already stands at this flame
}
spawn_point_light(
commands,
world,
l.diffuse_color,
l.diffuse_intensity,
def.diffuse_color,
def.diffuse_intensity,
room.cloned(),
l.synthetic,
None, // an M2 light authors no usable reach — the packer buckets it
// MONKEY (portal claims): the same claim set a MOLT fixture gets, measured at the
// FLAME. An M2 source's reach is the intensity bucket (`room_claim::m2_light_reach`,
// the packer's own ladder), scaled by the default `interiorAttenScale` — the portal
// hop has to be decided at spawn, and the live cvar is not knowable here.
claims.map(|c| {
c.claim_set(
world,
room_claim::claim_reach(room_claim::m2_light_reach(def.diffuse_intensity)),
room.map_or(&[][..], |r| &r.groups[..]),
)
}),
out,
);
}
}
/// The claim inputs of one placed WMO, borrowed straight off the loaded asset — no clone, because
/// this is rebuilt every spawn wave while a city's props are still landing.
pub(super) struct PropClaims<'a> {
// MONKEY (review fixes): lighting identity must survive an empty MODR relation.
instance: Entity,
groups: &'a [WmoGroupInfo],
portals: PortalGraph<'a>,
/// World (Bevy) → this placement's WMO model space, so a prop light's WORLD position (the
/// flame, resolved through the prop's own transform) can be asked the model-space question the
/// MOGI boxes and MOPT polygons answer.
to_model: bevy::math::Affine3A,
/// MONKEY (soft portal claims): and back out again — a claim's fade centre is a PORTAL, which
/// the rule answers in model space and the shader can only use in world space.
to_world: Transform,
}
impl<'a> PropClaims<'a> {
/// The claim inputs of `model`, with `slices` the caller-owned per-group portal-ref ranges
/// ([`portal_slices`] — owned outside because [`PortalGraph`] borrows them).
pub(super) fn new(
model: &'a WmoModel,
slices: &'a [(u16, u16)],
transform: Transform,
instance: Entity,
) -> Self {
Self {
instance,
groups: &model.group_bounds,
portals: PortalGraph {
vertices: &model.portal_vertices,
infos: &model.portal_infos,
refs: &model.portal_refs,
slices,
},
to_model: transform.compute_affine().inverse(),
to_world: transform,
}
}
/// The claim set of a source at `world` (Bevy) with effective radius `reach`, in the packed
/// [`crate::lighting::LightLitRooms`] form.
fn claim_set(&self, world: Vec3, reach: f32, molr: &[u16]) -> crate::lighting::LightLitRooms {
let pos = bevy_to_wow(self.to_model.transform_point3(world));
lit_rooms(
self.instance,
&room_claim::room_claims(self.groups, self.portals, pos, reach, molr),
self.to_world,
)
}
}
/// MONKEY (GO room claims): the claim set of ONE **carried** light — a server-placed brazier
/// GameObject, a campfire, an NPC's torch — standing inside a placed WMO, in the packed
/// [`crate::lighting::LightLitRooms`] form its caller applies.
///
/// The same rule the placed lanes above run ([`benilla_formats::room_claim::room_claims`]), reached
/// from the OTHER side. A MOLT fixture and a MODD prop are authored INSIDE the building, so the
/// spawner already holds the model, the placement transform and the instance entity when it makes
/// the light. A GameObject is not: it walks (or is `.gobject add`-ed) into a building the WMO's own
/// MODR/MOLR tables know nothing about, and until now the only room it could ever claim was the
/// single one the interior classifier's down-ray put its BEARER in
/// (`benilla_app::entities::carried_light`'s [`crate::lighting::LightRooms`]). One room is one
/// group: the brazier lit the room it stood in and stopped dead at every doorway, and standing in
/// an EXTERIOR-class group (Orgrimmar's canyon "rooms", MOGP `& 0x48`) it named no room at all.
///
/// So the app asks HERE instead, with the light's own WORLD position, and gets the identical
/// containment/MOLR/portal-hop set an authored fixture gets. `molr` is empty by construction — no
/// group's MOLR can name a light that is not in the file — which is exactly the case the
/// containment source was added for (`room_claim`'s module doc: the dense supplement to the sparse
/// relation).
///
/// `None` when no group's authored MOGI box holds the light. That is not merely "the set is empty":
/// containment is the ONLY base source available here, and with no base claim the portal hop has
/// nothing to seed from, so the answer could not be anything else — returning `None` lets the
/// caller move on to the next resident placement with one test instead of a wasted allocation.
pub fn carried_light_claims(
model: &WmoModel,
instance: Entity,
// The placement's Bevy model→world (`WmoPortalInstance::world_from_local`). Inverted here, per
// call: a carried light's claims are rebuilt only on the gates its caller change-gates on
// (settle / 0.5 yd drift / residency generation / bearer room), never per frame, so caching the
// inverse would be state kept for an operation that does not repeat.
world_from_local: bevy::math::Affine3A,
world: Vec3,
reach: f32,
) -> Option<CarriedClaimSet> {
let pos = bevy_to_wow(world_from_local.inverse().transform_point3(world));
let slices = portal_slices(model);
let claims = room_claim::room_claims(
&model.group_bounds,
PortalGraph {
vertices: &model.portal_vertices,
infos: &model.portal_infos,
refs: &model.portal_refs,
slices: &slices,
},
pos,
reach,
&[],
);
if !claims
.iter()
.any(|c| c.how == room_claim::ClaimHow::Contains)
{
return None; // not in this building at all
}
Some(CarriedClaimSet {
instance,
any_interior: claims.iter().any(|c| c.interior),
groups: pack_claims(&claims),
// MONKEY (soft portal claims): the placement's own transform, rebuilt from the affine the
// caller already holds — the fade centres are portals of THIS building and must reach the
// shader in world space (see [`crate::lighting::ClaimFade`]).
fades: pack_fades(&claims, Transform::from_matrix(world_from_local.into())),
})
}
/// MONKEY (GO room claims): what [`carried_light_claims`] hands back — the packed claim list plus
/// the two facts the caller's LANE decision needs, which the packed ids alone cannot answer.
#[derive(Clone)]
pub struct CarriedClaimSet {
/// The claiming placement's [`crate::wmo_portal::WmoPortalInstance`] entity — the head word of
/// the GPU claim record, and the identity a claim is worthless without.
pub instance: Entity,
/// The packed group ids (`group | `[`crate::lighting::LIT_ROOM_EXT_DENY`]) — the
/// [`crate::lighting::LightLitRooms`] payload. Exposed raw so the caller can change-gate its
/// component write against the set it applied last time (a `LightLitRooms` cannot be read back:
/// its field is crate-private, and re-inserting an identical one would churn the archetype).
pub groups: std::sync::Arc<[u16]>,
/// MONKEY (soft portal claims): index-parallel with `groups` — each claim's doorway + fade
/// (world space). Exposed for the same change-gate reason `groups` is: a light that drifts
/// without changing WHICH rooms it claims still changes HOW FAR past each doorway it reaches,
/// and re-applying an identical component would churn the archetype for nothing.
pub fades: std::sync::Arc<[crate::lighting::ClaimFade]>,
/// Does the set name at least one INTERIOR-class group? The lane question: claims are read by
/// `interior_room_light` alone, and that function is only ever reached by a light the packer
/// filed on the INTERIOR lane (`static_gx.wgsl` skips every entry whose colour-row `.w < 0.5`).
/// So a light that claims a real room has to be moved onto that lane for its claims to mean
/// anything at all — see the caller for why an all-exterior set is deliberately left alone.
pub any_interior: bool,
}
impl CarriedClaimSet {
/// The component form.
pub fn component(&self) -> crate::lighting::LightLitRooms {
crate::lighting::LightLitRooms {
rooms: crate::wmo_portal::WmoGroupVis {
instance: self.instance,
groups: self.groups.clone(),
},
fades: self.fades.clone(),
}
}
/// Is EVERY claim exterior-lane eligible — i.e. does the strict exterior-batch room term
/// (`static_gx.wgsl`, `interior_room_admits(strict = true)`) admit this light on all of them?
///
/// The caller's safety condition for moving a light onto the interior lane. A
/// [`crate::lighting::LIT_ROOM_EXT_DENY`] claim is a district-scale EXTERIOR shell, whose
/// surfaces the strict term refuses; moving such a light off the exterior lane would take its
/// pool off the very cobbles it stands on and give nothing back that reached them.
pub fn all_ext_ok(&self) -> bool {
self.groups
.iter()
.all(|g| g & crate::lighting::LIT_ROOM_EXT_DENY == 0)
}
}
/// The per-group `(portal_ref_start, portal_ref_count)` ranges of a WMO's MOPR list — the shape
/// [`PortalGraph`] wants, which the loaded asset carries spread across [`WmoGroupNav`]
/// (`benilla_assets`). One small Vec per placement per spawn wave; the graph itself is borrowed.
pub(super) fn portal_slices(model: &WmoModel) -> Vec<(u16, u16)> {
model
.group_nav
.iter()
.map(|n| (n.ref_start, n.ref_count))
.collect()
}
/// MONKEY (portal claims): fold each claim's EXTERIOR-LANE eligibility into its group id — bit 15,
/// which no group index can reach (the record's room key is 12 bits). A district-scale exterior
/// shell must stay in the set (dropping it would leave some fixtures with no claims at all, and the
/// packer's no-claims arm fails OPEN) while being invisible to the exterior batch lane, and one
/// `Arc<[u16]>` carrying both facts is cheaper than a second parallel component.
/// **Decoded by `RoomClaim::write` → `static_gx.wgsl`'s `CLAIM_EXT_OK`.**
fn pack_claims(claims: &[room_claim::Claim]) -> std::sync::Arc<[u16]> {
claims
.iter()
.map(|c| c.group | if c.ext_ok { 0 } else { crate::lighting::LIT_ROOM_EXT_DENY })
.collect()
}
/// MONKEY (soft portal claims): the per-claim FADE array, index-parallel with [`pack_claims`].
///
/// The rule answers in the WMO's own model space (WoW axes) because that is where the MOGI boxes
/// and MOPT polygons live; the shader has only a Bevy WORLD fragment position. So the doorway is
/// converted here, once per claim at spawn, rather than by handing the shader a matrix to invert
/// per fragment. A HARD claim (containment/MOLR, `fade_radius == 0`) converts to `ClaimFade`'s
/// default and reads as "weight 1 everywhere" — the binary gate, preserved exactly where it was
/// right.
///
/// The SLACK is a length and the transform is a rigid placement plus a uniform scale, so it scales
/// with `transform.scale.x` rather than riding the full affine: a WMO placement's scale is uniform
/// by construction (ADT/WDT store one factor), and taking the x component keeps the doorway's
/// tolerance in the same units as the world distance the shader measures against it.
fn pack_fades(
claims: &[room_claim::Claim],
transform: Transform,
) -> std::sync::Arc<[crate::lighting::ClaimFade]> {
let scale = transform.scale.x.abs().max(1e-4);
claims
.iter()
.map(|c| {
if c.fade_radius <= 0.0 {
return crate::lighting::ClaimFade::default();
}
crate::lighting::ClaimFade {
center: transform.transform_point(wow_to_bevy(c.fade_center)),
slack: c.fade_slack * scale,
radius: c.fade_radius * scale,
// Dimensionless — the placement's scale does not touch it.
entry: c.fade_entry,
}
})
.collect()
}
/// MONKEY (soft portal claims): the whole [`crate::lighting::LightLitRooms`] component of one
/// claim set — ids and fades built in one place so the two arrays cannot be produced at different
/// lengths (the shader indexes them at a fixed stride: a short fade array would gate a claim by
/// another claim's doorway).
fn lit_rooms(
instance: Entity,
claims: &[room_claim::Claim],
transform: Transform,
) -> crate::lighting::LightLitRooms {
crate::lighting::LightLitRooms {
rooms: crate::wmo_portal::WmoGroupVis {
instance,
groups: pack_claims(claims),
},
fades: pack_fades(claims, transform),
}
}
/// Spawn a `PointLight` for each omni (`type==0`) **WMO MOLT** light at the building's `transform` (the
/// interior fixture sources — forge fire, inn fireplaces, chapel candles/chandeliers). These radiate onto
/// the NPCs/doodads near each fixture and onto the building's own walls/floor (decision 0273). Position
@@ -109,12 +438,30 @@ pub(super) fn spawn_lights_for(
/// A fixture belongs to the rooms whose **MOLR** names it (`group_light_refs`, per absolute group
/// index — the same list an interior GameObject's committed light folds), so a dungeon's brazier
/// stops lighting the hillside above it the moment the flood leaves its room. The refs are inverted
/// here, once per placement: a MOLT light no group names keeps no rooms and stays ungated, the same
/// fail-open arm the prop path's empty MODR list takes.
/// here, once per placement. MONKEY (review fixes): a MOLT light no group names keeps no VISIBILITY
/// rooms (the prop path's empty-MODR fail-open PVS arm); its lighting claims below still gate which
/// rooms it may illuminate, keyed to the placement independently of that sparse MOLR relation.
///
/// MONKEY (room gate) / MONKEY (portal claims): the rooms a fixture may LIGHT are a wider set than
/// that, and it is built by [`benilla_formats::room_claims`] — the one rule, shared with the
/// `benilla-extract wmolights`/`wmolamps` audits so what the instrument prints is what the shader
/// enforces. Containment (the interior groups whose authored MOGI box holds the fixture) is the
/// dense supplement the sparse MOLR relation needs — the Goldshire inn authors a MOLR on 2 of its
/// 12 groups and NSabbey leaves seven rooms unnamed, so a MOLR-only gate blacks both buildings out;
/// the PORTAL hop is what carries a fixture's light through an open doorway into the next room,
/// without which a continuous floor changes brightness in a straight line at the group boundary.
/// See that module for the whole argument, including why a district-scale exterior shell is kept in
/// the set but barred from the exterior lane.
pub(super) fn spawn_wmo_lights_for(
commands: &mut Commands,
lights: &[WmoLight],
group_light_refs: &[Vec<u16>],
// MONKEY (room gate) / MONKEY (portal claims): the root's group table + portal graph, for
// [`benilla_formats::room_claims`]. A MOLT record's position is already in the space they are
// in, so this lane needs no inverse transform (the prop lane, whose flame is resolved in world
// space, does — see [`PropClaims`]).
groups: &[WmoGroupInfo],
portals: PortalGraph<'_>,
instance: Option<Entity>,
transform: Transform,
out: &mut Vec<Entity>,
@@ -124,7 +471,7 @@ pub(super) fn spawn_wmo_lights_for(
continue; // spot/directional/ambient MOLT types are not point sources
}
let i = i as u16;
let groups: std::sync::Arc<[u16]> = group_light_refs
let molr: std::sync::Arc<[u16]> = group_light_refs
.iter()
.enumerate()
.filter(|(_, refs)| refs.contains(&i))
@@ -137,8 +484,35 @@ pub(super) fn spawn_wmo_lights_for(
l.color,
l.intensity,
instance
.filter(|_| !groups.is_empty())
.map(|instance| crate::wmo_portal::WmoGroupVis { instance, groups }),
.filter(|_| !molr.is_empty())
.map(|instance| crate::wmo_portal::WmoGroupVis {
instance,
groups: molr.clone(),
}),
false, // a MOLT fixture is authored, never synthesised
// MONKEY (interior attenuation): the fixture's authored `attenuation_end` (`+0x2c`) —
// the reference's own fold window (wow-re `trace-forensics-abbey-interior-d3d` §4), and
// the number that turns a 10-candle inn from a uniform warm wash into ten pools. The
// 48 yd `PointLight::range` beside it stays what it always was: candidacy, not reach.
Some(l.attenuation_end),
// MONKEY (room gate) / MONKEY (portal claims): the rooms this fixture may LIGHT — its
// MOLR rooms, the ones whose authored box holds it, and the ones one open doorway away
// within its reach, in that priority. Dropped when the placement has no portal instance
// — without one there is nothing for a claim to be keyed to, and the packer leaves the
// fixture ungated.
instance.map(|instance| {
lit_rooms(
instance,
&room_claim::room_claims(
groups,
portals,
l.position,
room_claim::claim_reach(l.attenuation_end),
&molr,
),
transform,
)
}),
out,
);
}
@@ -287,3 +661,137 @@ pub(super) fn spawn_ribbons_for(
);
}
}
#[cfg(test)]
mod tests {
use super::{carried_light_claims, pack_claims};
use benilla_formats::room_claim::{room_claims, PortalGraph};
use benilla_formats::WmoGroupInfo;
use benilla_assets::WmoModel;
use bevy::prelude::*;
fn g(interior: bool, min: [f32; 3], max: [f32; 3]) -> WmoGroupInfo {
WmoGroupInfo {
interior,
show_skybox: false,
bbox_min: min,
bbox_max: max,
}
}
/// MONKEY (room gate): the Goldshire inn's real numbers — the measurement the whole gate rests
/// on. Its ground-floor fixture L0 stands at model-space `(-9.13, -1.13, 4.48)`; the basement
/// (`room03`, `z -6.76 .. 0.45`) does not contain it and so takes no claim, which is exactly
/// the reported leak (a basement lit bright through its own floor). The `kitchen` group, whose
/// authored box does contain it, gets it — first, because it is the tightest box that does.
///
/// MONKEY (portal claims): the building's `upstairs` SHELL is claimed too now, and that is the
/// change. It is EXTERIOR-flagged, so it can never call the interior lane; the claim exists for
/// the exterior batch lane, which is what stops that shell reading night-sky green a step away
/// from a candle-lit room. At `57.7 x 32.2 yd` it is building-scale, so it is exterior-lane
/// eligible (no `LIT_ROOM_EXT_DENY` bit) — a city's district shell would not be.
#[test]
fn a_fixture_claims_the_rooms_whose_authored_box_holds_it() {
let groups = [
g(true, [14.11, -4.86, 0.40], [20.47, -0.38, 4.37]), // g0, far corner room
g(true, [-17.07, -11.08, -0.21], [14.39, 15.54, 16.32]), // g1 (inn "kitchen")
g(true, [-36.75, -8.67, -6.76], [-17.01, 12.68, 0.45]), // g2 (inn "room03", basement)
g(false, [-37.77, -13.53, -0.18], [19.91, 18.68, 23.28]), // g3 (inn "upstairs" SHELL)
];
let claim = |p: [f32; 3]| room_claims(&groups, PortalGraph::default(), p, 11.2, &[]);
assert_eq!(
&pack_claims(&claim([-9.13, -1.13, 4.48]))[..],
&[1, 3],
"the room it stands in first, then the shell around it — and NOT the basement"
);
assert!(
claim([-31.84, 1.48, -3.0]).iter().any(|c| c.group == 2),
"a fixture actually IN the basement does claim it"
);
assert!(
claim([500.0, 0.0, 0.0]).is_empty(),
"a fixture outside every box claims nothing (the packer then leaves it ungated)"
);
}
/// MONKEY (portal claims): a DISTRICT-scale exterior shell (Stormwind's, 200-660 yd) still
/// gates the fixture — dropping it would leave some fixtures claimless, and no claims fails
/// OPEN — but carries the deny bit so the exterior lane cannot light a whole city block from
/// one tavern candle.
#[test]
fn a_district_shell_is_claimed_but_barred_from_the_exterior_lane() {
let groups = [g(false, [-510.81, -82.83, -5.78], [-315.12, 101.35, 126.18])];
let packed = pack_claims(&room_claims(
&groups,
PortalGraph::default(),
[-400.0, 0.0, 10.0],
11.2,
&[],
));
assert_eq!(&packed[..], &[crate::lighting::LIT_ROOM_EXT_DENY]);
}
/// GOLDEN — MONKEY (GO room claims). A CARRIED light (a brazier GameObject) asks the placed
/// lanes' claim rule from the outside, at its own WORLD position, and gets the identical
/// answer an authored MOLT fixture standing there would: the tightest containing room first,
/// then the shell around it. It also reports the two facts the caller's LANE decision needs and
/// the packed ids cannot carry — whether any claim is on an INTERIOR-class group, and whether
/// every claim is exterior-lane eligible.
///
/// `None` outside every authored box is the contract the caller's placement scan rides: with no
/// containment there is no base claim, so the portal hop has nothing to seed from and the
/// answer could not have been anything else. One test, and the scan moves to the next building.
#[test]
fn a_carried_light_claims_the_building_it_stands_in() {
// The Goldshire inn again (the numbers the whole gate was measured on), placed 100 yd east
// so the world→model inverse is doing real work rather than being identity.
let mut model = WmoModel {
group_bounds: vec![
g(true, [-17.07, -11.08, -0.21], [14.39, 15.54, 16.32]), // g0 "kitchen"
g(true, [-36.75, -8.67, -6.76], [-17.01, 12.68, 0.45]), // g1 "room03", basement
g(false, [-37.77, -13.53, -0.18], [19.91, 18.68, 23.28]), // g2 "upstairs" SHELL
],
..default()
};
let placement = Transform::from_xyz(100.0, 0.0, 0.0);
let world_from_local = placement.compute_affine();
// Model-space (-9.13, -1.13, 4.48) — the inn's real ground-floor fixture L0.
let at = placement.transform_point(benilla_assets::coords::wow_to_bevy([
-9.13, -1.13, 4.48,
]));
let inst = Entity::from_raw_u32(12).unwrap();
let set = carried_light_claims(&model, inst, world_from_local, at, 11.2)
.expect("the inn's authored boxes hold it");
assert_eq!(set.instance, inst);
assert_eq!(
&set.groups[..],
&[0, 2],
"the room it stands in, then the building shell — never the basement below it"
);
assert!(set.any_interior, "g0 is interior-class: the lane may move");
assert!(
set.all_ext_ok(),
"the inn's shell is building-scale, so the strict exterior term still admits it"
);
// A light 500 yd away is in no building: the caller tries the next placement.
assert!(
carried_light_claims(&model, inst, world_from_local, at + Vec3::X * 500.0, 11.2)
.is_none()
);
// A DISTRICT-scale exterior shell (a city's canyon) still gates the light — dropping the
// claim would pack it UNGATED, which fails open — but it is barred from the exterior lane,
// so `all_ext_ok` is false and the caller leaves the light on the EXTERIOR lane rather than
// taking its pool off the very cobbles it stands on.
model.group_bounds = vec![g(false, [-510.81, -82.83, -5.78], [-315.12, 101.35, 126.18])];
let canyon = placement.transform_point(benilla_assets::coords::wow_to_bevy([
-400.0, 0.0, 10.0,
]));
let set = carried_light_claims(&model, inst, world_from_local, canyon, 11.2)
.expect("the district shell contains it");
assert!(!set.any_interior);
assert!(!set.all_ext_ok());
}
}
@@ -8,7 +8,9 @@ mod fx;
pub(crate) mod prop_light;
pub use assemble::{spawn_model_entities, SpawnedModel};
pub use fx::point_light;
// MONKEY (GO room claims): `carried_light_claims` is the placed lanes' claim rule reached from
// the app side, for a light a WMO's own tables never named (a brazier GameObject).
pub use fx::{carried_light_claims, point_light, CarriedClaimSet};
use fx::{
emitter_fade, spawn_emitters_for, spawn_lights_for, spawn_ribbons_for, spawn_wmo_lights_for,
};
@@ -154,7 +156,19 @@ pub(super) fn spawn_loaded_placements(
let entities = match &p.model {
ModelHandle::M2(h) => {
let Some(m) = m2s.get(h) else {
continue; // model still loading (or missing) — try next frame
// MONKEY (missing model): a placement whose M2 the client doesn't ship
// (`Path not found`) can never spawn — left pending it holds the loading
// screen's scene term down FOREVER (Stormwind harbour's
// `alliancebrasscannon_flat.m2`, 2026-09-08). Retire it as an empty
// spawn so the accounting completes; the reference draws nothing there
// either.
if matches!(asset_server.load_state(h), bevy::asset::LoadState::Failed(_)) {
warn!("placement {unique_id}: its model failed to load — spawning nothing");
p.spawned = true;
*pending_spawns -= 1;
activity.placements_spawned += 1;
}
continue; // model still loading — try next frame
};
// The model's app-built render forms (decision 0834): request static — plus
// the skinned twins iff the anim host will rig this model — and wait for the
@@ -291,7 +305,24 @@ pub(super) fn spawn_loaded_placements(
ents.first().copied(),
&fade,
);
spawn_lights_for(&mut commands, &m.lights, p.transform, None, &mut ents);
// MONKEY (fire GO lights): `true` — an ADT map doodad's campfire/wall
// torch/brazier takes a SYNTHESISED light when it authors none; outdoors
// there is no MOLT fixture beside it and nothing else would light it.
// Empty dedupe list: an ADT map doodad belongs to no WMO, so there is no MOLT
// table its flame could be duplicating.
spawn_lights_for(
&mut commands,
&m.lights,
p.transform,
None,
true,
&[],
// MONKEY (portal claims): an ADT map doodad belongs to no building, so
// there is no group table to claim a room in — the packer leaves its
// light ungated, exactly as before.
None,
&mut ents,
);
tag_world_object(&mut commands, &ents, &object);
if let Some((target, r)) = fade_near_target() {
let pos = p.transform.translation;
@@ -312,6 +343,14 @@ pub(super) fn spawn_loaded_placements(
}
ModelHandle::Wmo(h) => {
let Some(m) = wmos.get(h) else {
// MONKEY (missing model): see the M2 arm — a WMO the client lacks
// retires as an empty spawn rather than pinning the loading screen.
if matches!(asset_server.load_state(h), bevy::asset::LoadState::Failed(_)) {
warn!("placement {unique_id}: its WMO failed to load — spawning nothing");
p.spawned = true;
*pending_spawns -= 1;
activity.placements_spawned += 1;
}
continue;
};
// The building's app-built render forms (0834): static only — WMO group
@@ -609,10 +648,21 @@ pub(super) fn spawn_loaded_placements(
// Interior MOLT lights (forge fire, inn fireplaces, chapel candles) — the radiating
// sources that light nearby NPCs/doodads AND the building's own walls/floor over
// their baked MOCV (decision 0273).
// MONKEY (portal claims): the per-group MOPR ranges the claim rule's portal
// hop indexes — one small Vec per placement, built here because the graph it
// completes is borrowed from the asset.
let slices = fx::portal_slices(m);
spawn_wmo_lights_for(
&mut commands,
&m.lights,
&m.group_light_refs,
&m.group_bounds,
benilla_formats::PortalGraph {
vertices: &m.portal_vertices,
infos: &m.portal_infos,
refs: &m.portal_refs,
slices: &slices,
},
p.portal_instance,
p.transform,
&mut ents,
@@ -644,11 +694,47 @@ pub(super) fn spawn_loaded_placements(
// Copied out before the loop borrows `p.doodads`: the props tag onto the same portal
// instance their building's groups did (decision 0689).
let portal_instance = p.portal_instance;
// MONKEY (wmo exterior points): this placement's authored MOLT fixtures in world space —
// the duplicate guard for the synthesised prop lights below (`spawn_lights_for`). Built
// here, once per placement per spawn wave, and only while props are still landing: it is
// read by the prop loop, which cannot reach `p.model`/`p.transform` while it holds
// `p.doodads` mutably. Empty for an M2 placement, and for a WMO whose root isn't resident.
//
// MONKEY (interior prop lights): the owning WMO asset itself is held alongside, for the
// ROOM CLAIMS a synthesised prop light now needs (`fx::PropClaims`). It borrows `wmos`
// (a `Res`), never the placement, so it survives the `&mut p.doodads` below — which is
// exactly why `p.transform` and the fixture list are copied out here instead.
let wmo: Option<&WmoModel> = match &p.model {
ModelHandle::Wmo(h) if p.doodads.iter().any(|d| !d.spawned) => wmos.get(h),
_ => None,
};
let placement = p.transform;
let molt_world: Vec<Vec3> = wmo
.map(|w| {
w.lights
.iter()
.filter(|l| l.is_omni())
.map(|l| placement.transform_point(wow_to_bevy(l.position)))
.collect()
})
.unwrap_or_default();
let slices = wmo.map(fx::portal_slices).unwrap_or_default();
// MONKEY (review fixes): the claim list owns its placement even when no MODR names it.
let claims = wmo.zip(portal_instance)
.map(|(w, instance)| fx::PropClaims::new(w, &slices, placement, instance));
for d in &mut p.doodads {
if d.spawned {
continue;
}
let Some(m) = m2s.get(&d.handle) else {
// MONKEY (missing model): a prop whose M2 the client doesn't ship would hold its
// BUILDING un-"up" (`placements_pending`) for the loading screen's whole life —
// the 2371 s Stormwind hang on `alliancebrasscannon_flat.m2`. Retire the prop.
if matches!(asset_server.load_state(&d.handle), bevy::asset::LoadState::Failed(_)) {
warn!("placement {unique_id}: a WMO prop's model failed to load — skipping it");
d.spawned = true;
*pending_spawns -= 1;
}
continue; // this prop's M2 still loading
};
// The prop's app-built render forms (0834) — same gate as its owning placement's.
@@ -877,6 +963,24 @@ pub(super) fn spawn_loaded_placements(
&m.lights,
d.transform,
fade.room.as_ref(), // the prop's glow rides its rooms like its mesh (0689)
// MONKEY (interior prop lights): a synthesised flame/lamp light is accepted for
// EVERY WMO prop now, indoors included. This was `matches!(d.light,
// PropLight::Exterior)` — exterior-group props only — on the reasoning that a
// building's artists put a MOLT fixture at each of its indoor flames, so a
// synthesised twin would double-light the interiors the lane is calibrated
// against. The `wmolamps` sweep measures that assumption at 30% true: of the
// 14,937 props corpus-wide whose model would synthesise a light, only 4,477 have
// an authored fixture within `MODD_SYNTH_DEDUPE`. The Stormwind Bank's ceiling
// lanterns, the Crawford Winery's lanterns and the Wizard's Sanctum's candelabras
// are all in the other 70% and lit nothing at all. The DEDUPE below, not the
// group class, is what holds the double-lighting back where the assumption is
// true (NSabbey pairs 41 of its 42 flames with a fixture, and keeps 0).
true,
// …so skip a flame an authored fixture already stands at.
&molt_world,
// MONKEY (portal claims): and give the kept ones the same room claims a MOLT
// fixture gets, or the gate would refuse a lantern in the room it hangs in.
claims.as_ref(),
&mut ents,
);
tag_world_object(&mut commands, &ents, &object);
@@ -170,6 +170,22 @@ pub struct WmoGroupVis {
}
impl WmoGroupVis {
/// MONKEY (fire GO lights): a claim on exactly ONE room, for a rider whose membership comes
/// from the interior CLASSIFIER's down-ray ([`crate::interior::InteriorAnchor::room`]) rather
/// than from a building's own MODR/MOLR tables — an entity's carried light, whose owner walked
/// in through the door and is named by no chunk.
///
/// One group, not the referrer set the table-driven path builds, and that is the honest shape:
/// the ray answers "which group is this standing in", singular. The `Arc` is a one-element
/// allocation per claim, which is nothing beside the per-frame ray it replaces — and it is
/// re-made only when the anchor's room actually changes (the claim system is change-gated).
pub fn single(instance: Entity, group: u16) -> Self {
Self {
instance,
groups: std::sync::Arc::from([group]),
}
}
/// The portal verdict: drawn iff **any** referencing group is in this frame's PVS. An index
/// past the instance's visible set (a stale or short set) reads as visible — the same fail-open
/// the rest of the cull takes, so a lookup miss can never blank a building.