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:
co-authored by
Claude Fable 5.1
parent
7309297f1c
commit
082f3ebe50
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user