Merge pull request #1 from pkuzic/lighting

Lighting
This commit is contained in:
Paul K
2026-09-17 21:31:42 +01:00
committed by GitHub
68 changed files with 22107 additions and 295 deletions
+69
View File
@@ -0,0 +1,69 @@
# benilla-lighting
A dynamic light and shadow system for [benilla](https://github.com/samwhosung/benilla), the Rust +
Bevy reimplementation of the 1.12.1 client. This branch is upstream benilla (merged at
`66388278`) plus the lighting work, and nothing else. It needs no data changes: everything is
derived from what a 1.12 install already carries (WMO `MOLT` lights, `MOCV` vertex colour, portals,
M2 particle emitters, `Light.dbc`).
Everything here is a departure from the reference client's look, so every lane has a live cvar and
most can be switched off. Code is tagged `MONKEY (<topic>)` with the rationale and the measurements
behind each constant.
## What it does
| Lane | What you see | Main switch |
|---|---|---|
| Sun shadows, characters | real silhouettes for units instead of the oval blob | `characterShadows` |
| Sun shadows, world | trees, buildings and alpha-tested foliage cast; baked terrain shadows step aside | `worldShadows` |
| Dynamic interiors | WMO rooms are lit per fragment by their own fixtures with a soft falloff, gated per room so light does not leak through walls | `interiorLight`, `interiorRoomGate` |
| Synthesised lights | torches, braziers, lanterns, campfires and lampposts emit a light in the colour of their flame, with flicker | `fireLightGain`, `fireFlicker` |
| Torch shadows | cube-map shadows from point lights onto buildings, models and terrain, with a contact-hardening penumbra and a shadow floor | `interiorShadows`, `exteriorShadows`, `torchShadowStrength`, `interiorShadowSoft` |
| Daylight and doorways | calibrated fixtures at doors, windows and open boundaries carry daylight into a room, and doorways between rooms bleed light | `interiorDaylight`, env `WOW_DAYLIGHT`, `WOW_BLEED` |
| Spell and firework lights | fire, holy and fel effects light their surroundings for their lifetime; frost, nature, arcane and shadow do not | `spellLightGain`, env `WOW_SPELL_LIGHT=0` |
| Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` |
The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default /
Bright presets. The Video options page gains Real Character Shadows, World Shadows and Shadow
Distance.
## Where the code lives
- **Shaders**: `crates/benilla-assets/src/shaders/shadow_hook.wgsl` (shared shadow sampling),
`crates/benilla-world/src/shaders/torch_depth.wgsl`, and the lighting lanes inside
`static_gx.wgsl`, `wow_model.wgsl` and `terrain.wgsl`. The three receivers mirror each other; the
comments say where.
- **World** (`crates/benilla-world/src`): `lighting/global_light.rs` (light table packer, lanes,
room claims, gains), `lighting/daylight.rs`, `lighting/flicker.rs`, `static_gx/torch_depth.rs`
(cube-map cache), `static_gx/shadow.rs`, and the light spawning in `terrain_stream/spawn/`.
- **Data rules** (`crates/benilla-formats/src`): `fire_light.rs` (which models emit light, their
colour and reach, the spell school rules) and `room_claim.rs` (which rooms a light may light).
- **App** (`crates/benilla-app/src`): `torch_shadow.rs` (caster selection), `shadow_core.rs`,
`character_shadow.rs`, `world_shadow.rs`, `blob_shadow.rs`, `entities/carried_light.rs`,
`entities/spell_fx/lifecycle.rs`, `dynamic_interior.rs`, `debug_panel/lighting_controls.rs`, and
the cvars in `cvars.rs` / `video.rs`.
- **Tools**: `benilla-extract <Data> wmolights <wmo> [--verts <group>]`, `wmolamps`, `m2firescan`
print the inputs the system works from (groups, batch classes, portals, claims, flame emitters).
## Invariants worth knowing before editing
- The shared light buffer stays 8528 bytes and the torch table 6416 bytes; both are mirrored in
three shaders and pinned by tests.
- World lights use upstream's `WorldPointLight`, never Bevy's `PointLight`.
- WGSL only fails at pipeline creation, so a shader edit is verified by running a world scene, not
by `cargo check`.
- Exterior point lights are selected per draw unit (12 slots, ranked against the chunk's box); the
cube-map occlusion is evaluated for the nearest three.
## Build
```bash
cargo build --release -p benilla
```
Debug tracing: `WOW_TORCH_TRACE=1`, `WOW_POINTS_DUMP=1`, `WOW_SHADOW_TRACE=1`, and the cvar
`interiorDebug` 1..4.
## Licence
Same as upstream: MIT OR Apache-2.0.
+55 -2
View File
@@ -804,6 +804,20 @@
.. "which it could only do in exclusive fullscreen. benilla applies it to the "
.. "finished image instead, so it works in both display modes."
BENILLA_TOOLTIP_CHARACTER_SHADOWS =
"Renders real dynamic silhouettes for players, NPCs, creatures, and mounts, replacing "
.. "the legacy oval blob. Matches Wrath's character-only shadow tier. This uses "
.. "additional GPU time and memory."
BENILLA_TOOLTIP_WORLD_SHADOWS =
"Casts real dynamic shadows from the static world (trees, buildings, and foliage) "
.. "following the sun, and switches off the baked terrain shadows they replace. "
.. "Independent of Real Character Shadows. This uses additional GPU time and memory."
BENILLA_TOOLTIP_SHADOW_DISTANCE =
"How far realtime shadows are drawn, in yards. Higher covers more ground but spreads "
.. "the shadow map thinner (softer edges) and costs more. Applies on Okay/Apply."
OPTIONS_PAGE_ROWS = {
-- Seven rows became sixteen at 2180, and the nine that arrived were not new
-- settings: every one names a CVar this client already registered AND read, with no
@@ -836,7 +850,9 @@
Graphics = {
"RowUiScale", "RowFarclip", "RowWorldDetail", "RowBrightness",
"RowWeatherDensity",
"RowDisplayMode", "RowVerticalSync", "RowRenderScale",
"RowDisplayMode", "RowVerticalSync",
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
"RowRenderScale",
},
-- Player Guild Names (2180) rides as a CHILD of Player Names, which is the
-- reference's own relationship twice over: its CheckButton22 hangs off 21 with a +26
@@ -3083,11 +3099,48 @@
"OPTION_TOOLTIP_VERTICAL_SYNC")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowRenderScale" inherits="OptionsSliderRowTemplate">
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowVerticalSync" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowVerticalSync" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "characterShadows", "Real Character Shadows",
"BENILLA_TOOLTIP_CHARACTER_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowWorldShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowCharacterShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowCharacterShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "worldShadows", "World Shadows",
"BENILLA_TOOLTIP_WORLD_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowShadowDistance" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- Realtime-shadow render distance (yd): the cascade
-- range + caster reach. 40-200, matching the CVar's
-- own clamp (SHADOW_DISTANCE_RANGE). DEFERRED to
-- Apply so a drag doesn't re-fit the cascade + recollect
-- casters on every tick (one rebuild on commit instead).
OptionsRow_OnLoad(self, "shadowDistance", "Shadow Distance",
"BENILLA_TOOLTIP_SHADOW_DISTANCE")
OptionsSliderRow_Setup(self, 40, 200, 10, "yards")
self.deferred = 1
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowRenderScale" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowShadowDistance" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowShadowDistance" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- Render Scale (decision 1639) -- benilla's own
-- row, no 1.12 counterpart: the reference renders
+115 -2
View File
@@ -99,9 +99,11 @@ use bevy::prelude::*;
use crate::creature_anim::AnimData;
use crate::net::{Embodied, NetEntity};
use benilla_world::decal::{DecalFrame, WorldDecal};
use benilla_world::lighting::WowLighting;
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
use benilla_world::schedule::WorldStage;
use benilla_world::view::WorldCamera;
use benilla_world::wmo_portal::UnitWmoRoom;
/// The reference's shadow disc (`Textures\ShadowBlob.blp`, wow-re unit-blob-shadow RE): grayscale
/// radial blob (gray-160 core → white rim) under a binary alpha disc, multiplied onto the ground.
@@ -112,6 +114,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.
@@ -229,11 +235,18 @@ fn sync_shadows(
/// reaches zero, or no receiving surface is in the box (the reference's no-ground gate).
#[allow(clippy::type_complexity)]
fn update_shadows(
video: Res<crate::video::VideoConfig>,
time: Res<Time>,
catalog: Option<Res<AnimData>>,
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>>,
// The unit's render alpha, asked of the unit — see the alpha block below.
unit_alpha: benilla_world::model_fade::UnitRenderAlpha,
owners: Query<
@@ -246,6 +259,10 @@ fn update_shadows(
// pass 2 is not in the reference's scene, so it casts nothing (decision 1277). The
// election writes the ROOT's `Visibility`; this is that verdict after propagation.
Option<&InheritedVisibility>,
// …and the unit's WMO room. `Some(room)` = indoors — the realtime SUN shadow is gated
// out of interiors, so an INDOOR unit keeps its oval even when `characterShadows` is on
// (otherwise it would have no ground shadow at all). Absent / no-room = outdoors.
Option<&UnitWmoRoom>,
),
Without<BlobShadow>,
>,
@@ -262,6 +279,27 @@ fn update_shadows(
// report, answerable from a log instead of a debugger.
mut census_at: Local<f32>,
) {
// `characterShadows 1`: the realtime shadow-map path owns OUTDOOR unit shadows — the per-unit
// 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 {
@@ -276,12 +314,31 @@ fn update_shadows(
let surface_count = decals.receiver_count();
for (shadow, mut key, mut verts) in &mut shadows {
n_total += 1;
let Ok((unit, anims, is_self, mount_child, drawn)) = owners.get(shadow.owner) else {
let Ok((unit, anims, is_self, mount_child, drawn, room)) = owners.get(shadow.owner) else {
// sync_shadows despawns next frame; keep it cleared meanwhile.
hide(&mut key, &mut verts);
n_no_owner += 1;
continue;
};
// `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 {
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
// mobs overhead, and their shadows carried on being projected onto the cavern floor,
@@ -341,7 +398,13 @@ fn update_shadows(
//
// The self first-person fade comes with it (the reference rides the same slot), which is
// why there is no `is_self` term here any more.
let alpha = unit_alpha.get(shadow.owner);
let mut alpha = unit_alpha.get(shadow.owner);
// 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;
@@ -568,6 +631,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 {
@@ -601,6 +688,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() {
+143
View File
@@ -0,0 +1,143 @@
//! The CHARACTER shadow lane — a thin plug-in on top of [`super::shadow_core`].
//!
//! It owns nothing but its own per-frame caster (players, NPCs, creatures, mounts). When
//! `characterShadows` is on it declares demand to the shared rig ([`ShadowDemand`]); while the rig
//! is live it collects the CREATURE entity parts into its caster using the rig's shared material and
//! the per-frame facts the rig publishes ([`ShadowFrame`]). It does not know [`super::world_shadow`]
//! exists — remove either lane and the other is untouched.
use bevy::pbr::MeshMaterial3d;
use bevy::prelude::*;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::PickMesh;
use benilla_world::model_render::{ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
use crate::shadow_core::{
collect_entity_geometry, empty_shadow_mesh, restore_mesh_buffers, shadow_trace,
spawn_solid_caster, take_mesh_buffers, RebuildRate, ShadowDemand, ShadowFrame, ShadowSet,
};
use crate::video::VideoConfig;
pub(crate) struct CharacterShadowPlugin;
impl Plugin for CharacterShadowPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CharacterLane>()
.add_systems(Last, collect_character_shadows.in_set(ShadowSet::Lanes));
}
}
/// The character lane's retained per-frame caster.
#[derive(Resource, Default)]
struct CharacterLane {
caster: Option<Entity>,
mesh: Option<Handle<Mesh>>,
/// [`shadow_trace`]'s change detector: (tris, admitted, rejected).
traced: (u32, u32, u32),
/// MONKEY (sun shadow perf): the `characterShadowRate` cadence gate — see the rebuild comment
/// in [`collect_character_shadows`].
rate: RebuildRate,
}
#[allow(clippy::too_many_arguments)]
fn collect_character_shadows(
video: Res<VideoConfig>,
time: Res<Time>,
mut demand: ResMut<ShadowDemand>,
frame: Res<ShadowFrame>,
mut lane: ResMut<CharacterLane>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
parts: Query<
(
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
Option<&RigPart>,
&ShadowOccluder,
Option<&MeshMaterial3d<WowModelMaterial>>,
),
Without<BillboardCard>,
>,
rigs: Query<&RigSkin>,
palettes: Res<RigPalettes>,
) {
let on = video.character_shadows;
// Declare demand so the shared rig stays up while this lane is on (the rig reads it next frame).
demand.0 = demand.0 || on;
// Rig down or lane off → drop our caster so it stops casting.
if !(frame.active && on) {
if let Some(entity) = lane.caster.take() {
commands.entity(entity).despawn();
}
if let Some(handle) = lane.mesh.take() {
meshes.remove(handle.id());
}
// The mesh this gate was pacing is gone — re-arm so a re-enable builds on its first frame.
lane.rate.reset();
return;
}
let Some(material) = frame.material.clone() else {
return;
};
// Animated rigs re-skin every frame and creatures spawn/despawn under lifecycles this lane
// can't cheaply observe — so the caster is collected fresh, at a CAPPED cadence.
if lane.caster.is_none() {
let mesh = meshes.add(empty_shadow_mesh());
let entity = spawn_solid_caster(&mut commands, mesh.clone(), material);
lane.caster = Some(entity);
lane.mesh = Some(mesh);
// A brand-new caster is an EMPTY mesh; make sure this frame fills it.
lane.rate.reset();
}
// MONKEY (sun shadow perf): the `characterShadowRate` gate. This rebuild is the expensive half
// of the ~3 ms the character lane costs — it CPU-skins every admitted unit and then mutates the
// `Mesh` asset, and a mutated mesh is re-extracted and re-uploaded (vertices AND indices) to the
// GPU. Skipping it simply leaves the previous proxy in place: the shadow MAP is still rendered
// from that proxy every frame, so nothing flickers or disappears — a moving unit's silhouette
// merely lags by up to 1/rate second.
//
// Why a rate cap and not change detection: the obvious `Changed<GlobalTransform>` gate does not
// work here. A unit's shadow geometry comes from the RIG PALETTE (`append_skinned` reads
// `RigPalettes`, the per-frame skinning pose), not from the part's transform — so a standing NPC
// whose idle animation is breathing has an unchanged `GlobalTransform` and changed VERTICES.
// Gating on the transform would freeze exactly the poses this lane exists to draw. The rate cap
// is the honest saving, and it is bounded work rather than a guess about content.
if !lane.rate.due(time.elapsed_secs(), video.character_shadow_rate) {
return;
}
if let Some(handle) = lane.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, // want_creatures
false, // want_environment (the world lane's job)
frame.light_position,
frame.entity_reach,
frame.tall_reach,
&mut positions,
&mut indices,
);
let tris = (indices.len() / 3) as u32;
restore_mesh_buffers(mesh, positions, indices);
if shadow_trace() {
let now = (tris, admitted, rejected);
if lane.traced != now {
info!(
"shadow-trace: character {} tris | creatures admitted {} / rejected {} (was {:?})",
tris, admitted, rejected, lane.traced
);
lane.traced = now;
}
}
}
}
}
+323
View File
@@ -908,6 +908,250 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `gxRestart = 1` does not apply (wgpu swaps the presentation interval live, so the box takes
// effect on click), and `$WOW_NOVSYNC=1` overrides it session-only, below.
same("gxVSync", "1").latched(),
// Benilla's opt-in realtime shadow-map path, split into two INDEPENDENT lanes over one shared
// shadow rig (one sun / one map). `worldShadows` = the static world (trees, buildings, foliage)
// casts realtime shadows and baked MCSH terrain shadows switch off; `characterShadows` =
// players/NPCs/creatures/mounts cast realtime silhouettes instead of the legacy oval blob.
//
// `ours(...)`, not `same(...)`: the reference has no realtime shadow at all — it bakes MCSH
// into the terrain and draws an oval under every unit — so there is no registered default for
// these two to agree with, and claiming `Same` would put a false entry in the one column
// [`Reference`] exists to keep honest. (They read `same("…", "1")` until the 2303 registry
// port; the default string is unchanged.)
ours(
"worldShadows",
"1",
"benilla's own: the static world (trees, buildings, alpha-tested foliage) casts a realtime \
shadow and the baked MCSH terrain shadows stand aside; the reference bakes and has no \
such switch",
),
ours(
"characterShadows",
"1",
"benilla's own: units cast a realtime silhouette instead of the reference's oval blob \
decal, which is all 1.12 has and is therefore not a setting there",
),
// Realtime-shadow render distance in yards (the shadow-map cascade range + caster reach).
// benilla's own — the reference has no realtime shadow to size. Clamped to SHADOW_DISTANCE_RANGE.
ours(
"shadowDistance",
"80",
"1900: benilla's own realtime-shadow render-distance slider; the reference bakes MCSH and \
has no cascade to size",
),
// MONKEY (sun shadow perf): the five live dials over the sun lanes' ~5 ms/frame (RTX 3070,
// 1080p, both lanes on: 45-47 fps, and 68-73 with both off). All benilla's own — the reference
// bakes MCSH and has no realtime shadow to tune. `shadow_core`'s constants block holds the cost
// split each one takes; every row is LIVE, so the whole set A/Bs from one chat line.
ours(
"shadowMapSize",
"2048",
"benilla's own: directional shadow-map edge in texels, 1024/2048/4096 (cost is quadratic)",
),
ours(
"shadowFilter",
"1",
"benilla's own: shadow PCF kernel: 0 hardware-2x2 (1 sample), 1 gaussian (9, the look)",
),
ours(
"characterShadowRate",
"30",
"benilla's own: Hz cap on the character shadow proxy re-skin+upload, 0..120 (0 = per frame)",
),
ours(
"worldShadowRate",
"30",
"benilla's own: Hz cap on the world lane's environment caster, 0..120 (0 = per frame)",
),
ours(
"shadowCasterReach",
"1",
"benilla's own: multiplier on the shadow caster-collection reach, 0.25..2 (1 = unchanged)",
),
// MONKEY (dynamic interiors): WMO interiors + their props light from the room's LIVE fixtures
// instead of the MOCV bake / the baked prop probe (`static_gx.wgsl` `interior_room_light`;
// bridged by `dynamic_interior`). The three numeric knobs are live-tunable from chat —
// `/script SetCVar("interiorExposure", 2)` — which is how their defaults were found.
ours(
"interiorLight",
"1",
"benilla's own: fixture-lit WMO interiors (0 = the reference's baked interior path)",
),
ours(
"interiorAmbient",
"0.015",
"benilla's own: interior base ambient, 0..1",
),
ours(
"interiorFill",
"0.08",
"benilla's own: interior per-fixture bounce gain, 0..2",
),
ours(
"interiorExposure",
"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 (outdoor torch shadows): the outdoor half of the same cube-map lane. Its own row
// because it is its own audience (a night camp, a lit village) and its own cost profile — and
// because "turn the outdoor shadows off" must not also turn the inn's candles' shadows off.
ours(
"exteriorShadows",
"1",
"benilla's own: outdoor fire lights (campfires, braziers, lampposts) cast real shadows at night; no effect by day",
),
// 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 lane perf): the moving-caster REGATHER cadence. Its own row (and not folded
// into `interiorShadowDynamic`) because it trades a different currency: `Dynamic` buys how
// MANY fixtures overlay moving casters, this buys how OFTEN the one shared overlay mesh is
// rebuilt. Neither of the count dials moved the frame time at all, so the cost was never per
// map -- it was this gather + mesh mutation, paid once a frame no matter what the counts said.
// `0` is the pre-feature every-frame behaviour, kept as the live A/B.
ours(
"interiorShadowEntityRate",
"30",
"benilla's own: how often (Hz) moving torch-shadow casters are regathered; 0 = every frame",
),
// 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.5",
"benilla's own: torch-shadow edge softness — the PCF tap radius scale at a CONTACT, 0.5..3",
),
// MONKEY (shadow floor): how BLACK a torch shadow is allowed to get. The lane's shadows were
// the only occlusion in the direct term and took all of it, which is what made them read as
// scars rather than as shadows; 0.7 leaves 30 % standing in place of the bounce light this
// renderer does not have. `1` is the shipped look, `0` is off.
ours(
"torchShadowStrength",
"0.7",
"benilla's own: torch-shadow darkness — how much of the direct term a shadow removes, 0..1",
),
ours(
"interiorDebug",
"0",
"benilla's own: interior diagnostic overlay — 1 classification, 2 shadow, 3 caster count, 4 WMO lane map",
),
// MONKEY (darkness gains): the two live dim dials. `nightGain` scales the EXTERIOR day/night
// law (the packed ambient/diffuse/specular rows) by `mix(1, gain, night_w)`, so it is exactly
// inert by day and full strength after dark; `interiorGain` scales the room lane's inputs (base
// ambient, per-fixture fill, and every interior fixture's colour). Both fold in at PACK time in
// `build_light_data`, so `SetCVar` moves the whole world on the very next frame — which is how
// "20 % / 30 % darker" gets judged at all, and `1` on either is the restore.
//
// Neither touches the fires: a point light keeps its brightness under both dials, because the
// ask is for a darker night AROUND the flame, not a dimmer flame.
ours(
"nightGain",
"0.45",
"benilla's own: exterior night brightness, 0.2..1.5 (1 = the reference's own night)",
),
// MONKEY (lighting debug panel): weaker fresh interiors; persisted gains still win at boot.
ours(
"interiorGain",
"0.5",
"benilla's own: WMO interior brightness, 0.2..1.5 (1 = the pre-dial fixture-lit room)",
),
// MONKEY (enclosed day floor): the daylight a room INSIDE A BUILDING gets by day, for the
// doorways this renderer cannot locate in the data (the Goldshire inn's entry group authors no
// portal, no EXT-class batch, no stitched vertex and no bake hot spot — there is nowhere to
// stand a fixture). An additive ambient in `interiorAmbient`'s own units, scaled by the sun's
// day envelope, so it is exactly 0 at night and the night look never moves. `0` is the restore.
ours(
"interiorDaylight",
"0.0",
"benilla's own: daylight floor for rooms inside a building, 0..1 (0 = none, the old look)",
),
// MONKEY (bake floor): the share of an interior batch's own MOCV bake that survives the live-
// fixture lane. The lane throws the bake away and lets the fixtures decide, which leaves a room
// no fixture reaches (the Lion's Pride Inn's east vestibule: MOLR 0, no claims, one faded
// portal hop) rendering black between a sky-lit porch and a candle-lit hall — something the
// reference client cannot do, because it draws every interior batch at its bake regardless of
// lights. A fraction of the bake, inside the room law's rolloff, so a lit surface barely moves.
ours(
"interiorBakeFloor",
"0.12",
"benilla's own: share of an interior batch's baked light kept where no fixture reaches, 0..1 (0 = the old look)",
),
// 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)",
),
// MONKEY (spellLightGain): the same dial for the SPELL lane — a kit's aura glow, a missile's
// core, an impact flash, a firework's burst. A separate knob from the one above it because the
// two are separate judgements: that one tunes SCENERY (how bright is the invented campfire),
// this one tunes COMBAT (how hard does a fight flash the room), and a spell light carries both
// markers, so one dial over both would mean dimming the world's hearths to calm a fireball.
// `0` is this lane's kill switch and reaches nothing else.
ours(
"spellLightGain",
"1",
"benilla's own: brightness of spell, missile and impact lights (0 = off)",
),
// MONKEY (flame flicker): how hard every FLAME breathes — candles fast and shallow, bonfires
// slow and shallower still (`benilla_world::lighting::FlameKind`). Live like the gain beside
// it, and `0` restores the steady constants every fire had before the feature, which is the
// escape hatch this needs precisely because "subtle" is a judgement call and a flicker that
// reads as a strobe is worse than none.
ours(
"fireFlicker",
"1",
"benilla's own: how strongly fire lights flicker — 0 steady, 1 default, 2 pronounced",
),
// **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
@@ -2405,6 +2649,85 @@ mod tests {
// VSync welds to the video knob, which in turn welds to the window literal's boot
// mode (`video::tests`) — so the registered "1" cannot drift from what we ship.
assert_eq!(d["gxVSync"] != 0.0, VideoConfig::default().vsync);
// ── MONKEY (lighting): the weld for the dynamic light + shadow system's whole row set ──
//
// **All 29, as one census, with the count asserted.** Every one of them lands on
// `VideoConfig` and is read from there per frame (`shadow_core::update_shadows`,
// `dynamic_interior::bridge`, `torch_shadow`), so a registered default that drifts from
// the struct's literal is a setting that reads one way in `config.toml` and renders
// another — invisible until someone compares a fresh install against a configured one.
// Spelling the table out rather than asserting a favourite handful is what makes "added
// a row, forgot its weld" fail HERE: the length check below is the gate.
let shadows = VideoConfig::default();
let flag = |b: bool| if b { 1.0 } else { 0.0 };
let lighting: [(&str, f32); 29] = [
// The two sun lanes and the cascade they share.
("worldShadows", flag(shadows.world_shadows)),
("characterShadows", flag(shadows.character_shadows)),
("shadowDistance", shadows.shadow_distance),
// MONKEY (sun shadow perf): the five cost dials.
("shadowMapSize", shadows.shadow_map_size as f32),
("shadowFilter", shadows.shadow_filter as f32),
("characterShadowRate", shadows.character_shadow_rate as f32),
("worldShadowRate", shadows.world_shadow_rate as f32),
("shadowCasterReach", shadows.shadow_caster_reach),
// MONKEY (dynamic interiors): the room lane's switch and its light law.
("interiorLight", flag(shadows.interior_light)),
("interiorAmbient", shadows.interior_ambient),
("interiorFill", shadows.interior_fill),
("interiorExposure", shadows.interior_exposure),
("interiorAttenScale", shadows.interior_atten_scale),
("interiorRoomGate", flag(shadows.interior_room_gate)),
("interiorDebug", shadows.interior_debug as f32),
// MONKEY (torch shadows): the cube-map lane, indoors and out.
("interiorShadows", flag(shadows.interior_shadows)),
("exteriorShadows", flag(shadows.exterior_shadows)),
(
"interiorShadowCasters",
shadows.interior_shadow_casters as f32,
),
(
"interiorShadowDynamic",
shadows.interior_shadow_dynamic as f32,
),
(
"interiorShadowEntityRate",
shadows.interior_shadow_entity_rate as f32,
),
("interiorShadowSoft", shadows.interior_shadow_soft),
("torchShadowStrength", shadows.torch_shadow_strength),
// MONKEY (darkness gains) / (enclosed day floor) / (bake floor): the four level dials.
("nightGain", shadows.night_gain),
("interiorGain", shadows.interior_gain),
("interiorDaylight", shadows.interior_daylight),
("interiorBakeFloor", shadows.interior_bake_floor),
// MONKEY (fire GO lights) / (spellLightGain) / (flame flicker): the invented lanes.
("fireLightGain", shadows.fire_light_gain),
("spellLightGain", shadows.spell_light_gain),
("fireFlicker", shadows.fire_flicker),
];
for (name, want) in lighting {
assert_eq!(d[name], want, "{name}: registered default left the knob");
}
// …and the census IS the row set. A name here that nothing registers would weld against a
// row the client does not have; the length is the other half — 29 rows, 29 welds.
let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect();
assert_eq!(welded.len(), 29, "the lighting lane welds 29 distinct rows");
for name in &welded {
assert!(
REGISTERED.iter().any(|r| r.name == *name),
"{name}: welded but not registered"
);
}
// Three of them are CALIBRATED rather than chosen, so the weld alone is not enough — it
// would pass just as happily if a tuning session left the struct's literal wherever it was
// last dragged in the debug panel. These pin the measured numbers themselves.
assert_eq!(d["interiorGain"], 0.5, "MONKEY (lighting debug panel)");
// MONKEY (bake floor): the inn's black door band measured up to 0.108 x tex with
// candle-lit surfaces moving under +10 % — only true at this number.
assert_eq!(d["interiorBakeFloor"], 0.12);
assert_eq!(d["spellLightGain"], 1.0, "the spell lane ships neutral");
// ── end MONKEY (lighting) ─────────────────────────────────────────────────────────────
// The pane half-rate (1444) welds to the portrait knob's shipped default.
assert_eq!(d["boothHalfRate"] != 0.0, PaneRate::default().half);
// Render scale (1639) welds to OFF. Not a taste default: the whole tree of visual
@@ -0,0 +1,192 @@
//! MONKEY (lighting debug panel): a native editor for the LIVE video CVars. Reads are typed
//! `VideoConfig` fields, never a second settings store; changed widgets alone call
//! `UiScript::set_cvar_engine`, whose queue is the same one Lua `SetCVar` feeds. The normal host
//! sync owns clamps, renderer bridges and persistence, so a slider and a chat command cannot
//! acquire separate truths. Registry lookup is reserved for an explicit reset, not idle frames.
use std::ops::RangeInclusive;
use benilla_ui::script::UiScript;
use bevy_egui::egui;
use crate::cvars::REGISTERED;
use crate::shadow_core::{CASTER_REACH_RANGE, MAX_SHADOW_RATE, SHADOW_DISTANCE_RANGE, SHADOW_MAP_SIZES};
use crate::video::VideoConfig;
enum Widget {
Toggle,
Slider(RangeInclusive<f32>),
Integer(RangeInclusive<f32>),
Choice(&'static [u32]),
}
struct Knob {
name: &'static str,
read: fn(&VideoConfig) -> f32,
widget: Widget,
help: &'static str,
}
// MONKEY (lighting debug panel): keep names, typed reads and presentation together. The numeric
// bounds mirror `video::on_cvar`'s MONKEY (lighting) arms — the registry's change-callback
// observer since 2303 — including their wider ambient/fill/attenuation ranges;
// shared shadow constants follow the setter directly. Casting the booleans through u32 keeps
// the descriptor read-only without inventing a parallel configuration type.
macro_rules! knob {
($name:literal, $field:ident, $widget:expr, $help:literal) => {
Knob { name: $name, read: |v| v.$field as f32, widget: $widget, help: $help }
};
(flag $name:literal, $field:ident, $help:literal) => {
Knob { name: $name, read: |v| v.$field as u32 as f32, widget: Widget::Toggle, help: $help }
};
}
use Widget::{Choice, Integer, Slider};
const INTERIOR: &[Knob] = &[
knob!(flag "interiorLight", interior_light, "Fixture-lit interiors; off restores the baked path."),
knob!("interiorGain", interior_gain, Slider(0.2..=1.5), "Overall room input brightness."),
knob!("interiorAmbient", interior_ambient, Slider(0.0..=1.0), "Base room ambient."),
knob!("interiorFill", interior_fill, Slider(0.0..=2.0), "Per-fixture bounce gain."),
// MONKEY (bake floor): index 4, immediately after fill, because the three presets below take
// `INTERIOR[1..5]` as one group — this dial belongs with gain/ambient/fill, not with the
// exposure and reach knobs the presets deliberately leave alone.
knob!("interiorBakeFloor", interior_bake_floor, Slider(0.0..=1.0), "Share of a room's baked light kept where no fixture reaches; 0 = fixtures only."),
knob!("interiorExposure", interior_exposure, Slider(0.25..=8.0), "Light budget before soft rolloff."),
knob!("interiorAttenScale", interior_atten_scale, Slider(0.0..=8.0), "Pool radius scale; 0 disables the authored window."),
knob!("interiorDaylight", interior_daylight, Slider(0.0..=1.0), "Sun-driven day floor in enclosed rooms."),
knob!(flag "interiorRoomGate", interior_room_gate, "Restrict fixtures to the rooms they claim."),
];
const GROUPS: &[(&str, &[Knob])] = &[
("Interior light", INTERIOR),
("Fire", &[
knob!("fireLightGain", fire_light_gain, Slider(0.0..=4.0), "Brightness of lights synthesized from flame emitters."),
// MONKEY (spellLightGain): in the "Fire" group because a spell light IS one of the invented
// sources this group tunes — it is just the one whose brightness is a combat setting rather
// than a scenery one, which is exactly why it needs its own dial and not the one above it.
knob!("spellLightGain", spell_light_gain, Slider(0.0..=4.0), "Brightness of spell, missile and impact lights."),
knob!("fireFlicker", fire_flicker, Slider(0.0..=2.0), "0 steady, 1 default, 2 pronounced."),
]),
("Torch shadows", &[
knob!(flag "interiorShadows", interior_shadows, "Interior fixture shadows; requires interiorLight."),
knob!(flag "exteriorShadows", exterior_shadows, "Outdoor fire shadows at night."),
knob!("interiorShadowCasters", interior_shadow_casters, Integer(1.0..=16.0), "Resident fixture shadow maps."),
knob!("interiorShadowDynamic", interior_shadow_dynamic, Integer(1.0..=crate::torch_shadow::MAX_TORCH_DYNAMIC as f32), "Nearest fixtures with moving entity shadows."),
knob!("interiorShadowSoft", interior_shadow_soft, Slider(0.5..=3.0), "Shadow edge softness at a contact; the penumbra grows from it."),
knob!("torchShadowStrength", torch_shadow_strength, Slider(0.0..=1.0), "Shadow darkness: how much direct light a shadow removes. 1 = black."),
knob!("interiorShadowEntityRate", interior_shadow_entity_rate, Integer(0.0..=240.0), "Moving caster regather Hz; 0 = every frame."),
]),
("Sun shadows", &[
knob!(flag "characterShadows", character_shadows, "Realtime character silhouettes instead of blobs."),
knob!(flag "worldShadows", world_shadows, "Realtime static-world shadows instead of baked terrain shadows."),
knob!("shadowDistance", shadow_distance, Slider(SHADOW_DISTANCE_RANGE), "Shadow distance in yards."),
knob!("shadowMapSize", shadow_map_size, Choice(&SHADOW_MAP_SIZES), "Map edge in texels; larger maps cost more GPU time and memory."),
knob!("shadowFilter", shadow_filter, Widget::Toggle, "0 hardware 2x2, 1 Gaussian PCF."),
knob!("characterShadowRate", character_shadow_rate, Integer(0.0..=MAX_SHADOW_RATE as f32), "Character shadow update Hz; 0 = every frame."),
knob!("worldShadowRate", world_shadow_rate, Integer(0.0..=MAX_SHADOW_RATE as f32), "World shadow update Hz; 0 = every frame."),
knob!("shadowCasterReach", shadow_caster_reach, Slider(CASTER_REACH_RANGE), "Caster collection reach multiplier."),
]),
("Night", &[
knob!("nightGain", night_gain, Slider(0.2..=1.5), "Exterior night brightness; inert by day."),
]),
("Debug", &[
knob!("interiorDebug", interior_debug, Choice(&[0, 1, 2, 3, 4]), "0 off, 1 classification, 2 shadow, 3 caster count, 4 WMO lane map."),
]),
];
fn reset(script: &mut UiScript, knobs: &[Knob]) {
for knob in knobs {
let registered = REGISTERED.iter().find(|r| r.name == knob.name)
.expect("lighting panel knob must be registered");
script.set_cvar_engine(registered.name, registered.default);
}
}
fn row(ui: &mut egui::Ui, video: &VideoConfig, script: &mut UiScript, knob: &Knob) {
let mut value = (knob.read)(video);
// MONKEY (lighting debug panel): stack the label over numeric controls so even the longest
// CVar fits the existing 280 px panel. Sliders retain their editable current-value readout;
// integer knobs cannot queue fractional counts/rates, and flags explicitly show their 0/1.
let changed = ui.push_id(knob.name, |ui| match &knob.widget {
Widget::Toggle => {
let mut enabled = value != 0.0;
let response = ui.checkbox(&mut enabled, format!("{} {}", knob.name, value as u32))
.on_hover_text(knob.help);
value = enabled as u32 as f32;
response.changed()
}
Slider(range) | Integer(range) => {
ui.label(knob.name).on_hover_text(knob.help);
let mut slider = egui::Slider::new(&mut value, range.clone());
if matches!(knob.widget, Integer(_)) {
slider = slider.integer();
}
ui.add(slider).on_hover_text(knob.help).changed()
}
Choice(options) => {
ui.label(knob.name).on_hover_text(knob.help);
let mut changed = false;
egui::ComboBox::from_id_salt("value")
.selected_text((value as u32).to_string())
.show_ui(ui, |ui| {
for &option in *options {
changed |= ui.selectable_value(&mut value, option as f32, option.to_string()).changed();
}
});
changed
}
}).inner;
if changed {
script.set_cvar_engine(knob.name, &value.to_string());
}
}
pub(super) fn section(ui: &mut egui::Ui, video: &VideoConfig, script: Option<&mut UiScript>) {
let Some(script) = script else {
ui.weak("Lighting controls are available once the UI session is ready.");
return;
};
ui.horizontal(|ui| {
ui.label("Presets");
for (label, values) in [
// MONKEY (bake floor): the fourth value is `interiorBakeFloor`, and it rides the
// presets because it is a room BRIGHTNESS INPUT exactly like the three beside it.
// Leaving it out would make the presets change the RATIO between a fixture-lit room
// and a fixture-starved one rather than the level of both: Bright would raise the
// candles and leave the vestibule at the Default floor. (The `interiorGain` the
// presets also set does scale it at pack time, so its own value composes with that
// — which is why Dim asks for 0.08 and not for 0.12 halved twice.)
("Dim", Some(["0.5", "0.015", "0.08", "0.08"])),
("Default", None),
("Bright", Some(["1.0", "0.04", "0.16", "0.2"])),
] {
if ui.small_button(label).on_hover_text("Only gain, ambient, fill and bake floor; other settings stay as they are.").clicked() {
if let Some(values) = values {
for (knob, value) in INTERIOR[1..5].iter().zip(values) {
script.set_cvar_engine(knob.name, value);
}
} else {
reset(script, &INTERIOR[1..5]);
}
}
}
});
if ui.small_button("Reset all").on_hover_text("Restore registry defaults for every group in Lighting & shadows.").clicked() {
for (_, knobs) in GROUPS {
reset(script, knobs);
}
}
for (name, knobs) in GROUPS {
ui.push_id(name, |ui| {
ui.separator();
ui.strong(*name);
for knob in *knobs {
row(ui, video, script, knob);
}
if ui.small_button("Reset group to defaults").clicked() {
reset(script, knobs);
}
});
}
}
+12 -1
View File
@@ -50,6 +50,7 @@ use crate::ui_script::EguiPointerOver;
mod inspect;
mod journal;
mod lighting_controls;
/// The dev state this panel edits — resource-only, faithful defaults (decision 0026: the
/// always-present config layer; this module is only its editor). The engine owns and inits it,
@@ -397,7 +398,11 @@ fn debug_panel_ui(
ping: Res<crate::net::PingShared>,
dropped: Res<crate::net::DroppedOpcodes>,
mut weather_state: Option<ResMut<benilla_world::weather::WeatherState>>,
mut world: WorldReadout,
(mut world, video, mut script): (
WorldReadout,
Res<crate::video::VideoConfig>,
Option<NonSendMut<benilla_ui::script::UiScript>>,
),
) -> Result {
if !debug.open {
return Ok(());
@@ -583,6 +588,12 @@ fn debug_panel_ui(
ui.checkbox(&mut l.disable_sky_dome, "disable sky dome");
});
egui::CollapsingHeader::new("Lighting & shadows")
.default_open(false)
.show(ui, |ui| {
lighting_controls::section(ui, &video, script.as_deref_mut());
});
egui::CollapsingHeader::new("Weather")
.default_open(false)
.show(ui, |ui| {
@@ -0,0 +1,94 @@
//! MONKEY (dynamic interiors): the app-side half of the fixture-lit WMO interior lane — a
//! plug-and-play bridge from the `interiorLight` / `interiorAmbient` / `interiorFill` /
//! `interiorExposure` cvars ([`VideoConfig`]) to benilla-world's [`DynamicInteriors`] resource,
//! which `global_light` packs for `static_gx.wgsl`'s `interior_room_light` (WMO interior surfaces
//! AND interior props light from the room's live fixtures instead of the MOCV bake / the baked prop
//! probe). Independent of the shadow lanes: removing this plugin leaves the resource at its
//! shipped defaults; `interiorLight 0` restores the faithful baked interior path.
//!
//! The knobs are LIVE — `/script SetCVar("interiorExposure", 2)` in chat retunes the room without
//! a rebuild, which is how the defaults were found.
//!
//! 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.
//!
//! MONKEY (spellLightGain): and `spellLightGain` → [`benilla_world::lighting::SpellLightGain`],
//! the third of the same shape.
use benilla_world::lighting::{DynamicInteriors, FireLightGain, SpellLightGain};
use bevy::prelude::*;
use crate::video::VideoConfig;
pub(crate) struct DynamicInteriorPlugin;
impl Plugin for DynamicInteriorPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, bridge);
}
}
/// Publish the cvars to benilla-world when they change. Guarded so the resource's change ticks
/// stay quiet on idle frames.
///
/// 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>,
// MONKEY (spellLightGain): the spell lane's own gain rides the same bridge as the fire gain,
// for the same reasons — same shape (a live video cvar → a benilla-world resource the packer
// reads), same one-line guard, and a third plugin to carry a second `f32` is not worth its
// wiring. Kept a SEPARATE resource rather than a field on `DynamicInteriors` because it is not
// an interior knob at all: a fireball down a street takes it exactly as one down a corridor
// does, and `FireLightGain` beside it already stands as the precedent for that.
mut spell: ResMut<SpellLightGain>,
) {
if fire.0 != video.fire_light_gain {
fire.0 = video.fire_light_gain;
}
if spell.0 != video.spell_light_gain {
spell.0 = video.spell_light_gain;
}
let want = DynamicInteriors {
enabled: video.interior_light,
ambient: video.interior_ambient,
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,
// MONKEY (flame flicker): the wobble gain rides the same bridge — it is consumed entirely
// on the CPU in `build_light_data`, so `SetCVar("fireFlicker", 0)` freezes every flame on
// the very next frame with no respawn and no shader change.
flicker: video.fire_flicker,
// MONKEY (darkness gains): both dim dials ride this same bridge — they are consumed
// entirely on the CPU in `build_light_data` (folded into the packed rows / the packed
// colours), so `SetCVar("nightGain", 1)` restores the reference night on the very next
// frame with no respawn and no shader change. `night_gain` is an EXTERIOR knob living on an
// interior-named resource for the sake of one bridge rather than two; the packer reads it
// ungated by `enabled`.
night_gain: video.night_gain,
interior_gain: video.interior_gain,
// MONKEY (enclosed day floor): the daylight floor rides the same bridge, but is consumed in
// the SHADER (it rides the packed `wmo_fog_params.w` fraction) rather than folded on the
// CPU — the packer's only job is to put it in the lane.
daylight: video.interior_daylight,
// MONKEY (bake floor): the bake floor rides the same bridge. Half CPU, half shader: the
// packer folds `interiorGain` in and puts the product in the `sh_c16.w` fraction, and the
// two interior lanes read it from there — so `SetCVar("interiorBakeFloor", 0)` restores
// the pre-feature look on the very next frame with no respawn and no pipeline rebuild.
bake_floor: video.interior_bake_floor,
};
if *out != want {
*out = want;
}
}
+45
View File
@@ -47,6 +47,16 @@ 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 (spell light): the same file's second spawner — the ONE light a luminous spell effect /
// firework throws, hung on the effect's own root so the effect's death reaps it. Used by the kit,
// missile and dest-anchored lanes.
use carried_light::spawn_spell_light;
// 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;
// MONKEY (outdoor torch shadows): …and the HELD/creature-owned marker, so the exterior shadow lane
// can refuse a torch that a body is carrying.
pub(crate) use carried_light::HeldLight;
/// 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
@@ -965,6 +975,41 @@ 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),
)
// MONKEY (spell light): the luminous-effect lights' envelope and their ceiling. `Update`,
// after the attach passes that spawn them (`EntityVisualsSet`) so a light born this frame
// is already on its own clock, and well before `PostUpdate`'s packer reads the intensity
// they write. The budget runs after the envelope so the age it orders on is this frame's.
.add_systems(
Update,
(
spell_fx::advance_spell_lights,
spell_fx::budget_spell_lights,
)
.chain()
.after(EntityVisualsSet),
)
// 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
@@ -1017,7 +1017,13 @@ pub(super) fn attach_entity_visuals(
.map(|a| (l.def.bone, a))
})
.collect();
super::spawn_carried_lights(&mut commands, model_lights, entity, |bone| {
// MONKEY (outdoor torch shadows): a CREATURE's own glow is body-carried and may not
// cast an outdoor shadow (`carried_light::HeldLight` holds the three reasons); a
// placed GameObject — a brazier, a campfire, a lit quest object — is a fixture that
// stands still, so it stays eligible. `net.kind` is the only place the two are told
// apart: downstream both are just a `PointLight` under a `ChildOf`.
let body_carried = !matches!(net.kind, EntityKind::GameObject);
super::spawn_carried_lights(&mut commands, model_lights, entity, body_carried, |bone| {
light_anchors.get(&bone).copied()
});
// Ribbon trails (wisp streamers, trailing quest-object crystals) — the same host-bone
File diff suppressed because it is too large Load Diff
@@ -313,6 +313,22 @@ pub(super) fn attach_ground_fx_models(
continue; // model still building — attach on a later pass
}
inst.spawned = true;
// MONKEY (spell light): the IMPACT flash. A dest-anchored effect is the one lane whose
// light must not hold — a Fire Nova lights the ground it lands on and is gone. The
// burst span is the model's own first-sequence duration where it authors one (so the
// light fades with the effect rather than on a guess), the shared default otherwise;
// a LOOPING plant (a persistent ground aura) takes the default too, because "as long
// as the aura lasts" is exactly what a burst must not be.
let span = dm
.first_seq_span
.filter(|_| !inst.looping)
.unwrap_or(super::spell_fx::SPELL_BURST_SPAN);
super::spawn_spell_light(
&mut commands,
&dm.lights,
entity,
super::spell_fx::SpellLightMode::Burst { span },
);
if !inst.looping {
// One pass of the first sequence — the kit pipeline's completion-callback
// stand-in (`spell_fx`'s span clock, same law).
@@ -848,7 +848,9 @@ fn spawn_slot(
// the emitters and for the same reason (the item poses at rest, so its model space IS
// the bone-local frame), which walks it through the hand's swing; the fence rails and
// grass around the bearer then gather it like any other scene point light.
spawn_carried_lights(commands, &dm.lights, root, |_| None);
// MONKEY (outdoor torch shadows): `held = true` — this is THE held torch, the canonical
// body-carried light (see `carried_light::HeldLight`): it may not cast an outdoor shadow.
spawn_carried_lights(commands, &dm.lights, root, true, |_| None);
// The item's ribbon trails (weapon enchant streaks): ride the item root — a held item
// poses at rest, so the bone-local origin is model-space (no pivot rebase). A held item
// rests in Stand (anim 0): a thrown weapon's trail is keyed dark there, so the flight
@@ -783,6 +783,17 @@ pub(super) fn attach_missile_models(
) {
continue;
}
// MONKEY (spell light): a luminous projectile carries its light with it — the Fireball's
// core lights the road it flies over, and the flash at the end is the impact effect's own
// (`dest_fx` / the kit on the target), not this one's. `Missile` = constant: a projectile
// has no lifecycle to follow and no fade to run, because its ending is the arrival despawn
// that takes the light with it.
super::spawn_spell_light(
&mut commands,
&dm.lights,
entity,
super::spell_fx::SpellLightMode::Missile,
);
missile.parts_spawned = true;
}
}
@@ -59,6 +59,8 @@ use benilla_assets::ModelAnimations;
use bevy::animation::{graph::AnimationNodeIndex, RepeatAnimation};
use bevy::prelude::*;
use benilla_world::lighting::WorldPointLight;
use crate::creature_anim::FxStage;
/// `AnimationData.dbc` **158 `Hold`** — the sustained pulse leg (`0x9e` at `0x5ff188`/`0x5ff1bb`).
@@ -217,6 +219,188 @@ pub(crate) fn decay_span(anims: Option<&ModelAnimations>) -> Option<f32> {
anims?.find(ANIM_DECAY).map(|c| c.duration)
}
// -------------------------------------------------------------------------------------------
// MONKEY (spell light): the LIGHT a luminous effect throws, and the envelope it throws it on.
// -------------------------------------------------------------------------------------------
//
// The synthesis — which effects are luminous at all (fire / holy / fel, never frost, nature,
// arcane or shadow), what colour they burn and how far they reach — is
// [`benilla_formats::fire_light`]'s spell route, decided once per MODEL at asset load. What lives
// here is everything that is per-INSTANCE: when the light comes up, how it goes out, and how many
// of them may exist at once.
//
// It sits in this file rather than beside the other carried lights because it is a LIFECYCLE, and
// this file is where an effect instance's lifecycle lives (`Stand` → `Hold` → `Decay`). The light
// is a child of the effect's own root, so the reap that despawns the root takes the light with it
// and there is no orphaning path to get wrong; the envelope below only shapes the brightness
// inside that life.
/// Seconds a spell light takes to come up. Short enough to read as a flash rather than a fade-in —
/// the point is only that a light never appears at full strength on a single frame, which reads as
/// a rendering fault rather than as a spell.
pub(crate) const SPELL_LIGHT_RAMP: f32 = 0.1;
/// The default burst fade ([`SpellLightMode::Burst`]) when the caller knows no better span: an
/// impact's flash. Long enough to see, short enough that a volley of them never overlaps into a
/// standing glow.
pub(crate) const SPELL_BURST_SPAN: f32 = 0.6;
/// How fast a KIT light goes dark once its instance is reaped ([`FxDecay`]). Deliberately quicker
/// than the model's own `Decay` sequence: the aura is over the moment the server said so, and a
/// light that outlived the visual by a second would read as a stuck effect.
pub(crate) const SPELL_REAP_FADE: f32 = 0.25;
/// How many spell lights may be alive at once, across every lane. The shared point-light table is
/// 512 rows packed from the 256 nearest sources every frame, and the whole feature is worth
/// nothing if a raid's worth of casts can evict a city's torches: 24 is a generous ceiling for
/// what one camera can see cast at once and still a small fraction of the pack.
pub(crate) const SPELL_LIGHTS_MAX: usize = 24;
/// MONKEY (spell light): the env kill switch, `WOW_SPELL_LIGHT=0`. Read ONCE — the value is
/// latched on first call, so it cannot change under a running frame and costs one atomic load at
/// each spawn site thereafter.
///
/// An env var and STILL not a cvar, now that `spellLightGain` exists beside `fireLightGain`
/// (MONKEY (spellLightGain) — `cvars.rs`, folded at pack time in
/// `benilla_world::lighting::global_light::build_light_data`). The two are different tools and both
/// are wanted: the cvar is the live BRIGHTNESS dial, and its `0` darkens a spell light that is
/// still spawned, still parented, still aged and still counted against
/// [`SPELL_LIGHTS_MAX`]; this switch stops the lights being CREATED at all, which is what an A/B
/// against the pre-feature build needs (`WOW_SPELL_LIGHT=0` costs the frame nothing, and takes the
/// claim walk in `carried_light::claim_carried_light_rooms` with it).
pub(crate) fn spell_lights_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| !matches!(std::env::var("WOW_SPELL_LIGHT").as_deref(), Ok("0")))
}
/// Which lifecycle a spell light follows — the one thing its spawn site knows that the model
/// cannot.
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) enum SpellLightMode {
/// A kit/aura effect attached to a unit: up on the ramp, HELD for as long as the instance
/// stands, out on the reap ([`FxDecay`] → [`SPELL_REAP_FADE`]). Ice Barrier's shield holds,
/// Immolate burns for its duration, a precast glows while it is cast.
Kit,
/// A missile: constant while it flies. The light rides the projectile root, so it sweeps the
/// ground and the walls on the way — and the missile's own arrival despawn ends it, which is
/// the only ending a projectile has.
Missile,
/// An impact / destination effect / firework shell: full at onset, gone `span` seconds later.
/// The one shape whose *whole point* is that it does not persist.
Burst { span: f32 },
}
/// MONKEY (spell light): one live spell light. A child of the effect root it belongs to, so the
/// effect's own despawn reaps it; this component only shapes its brightness while it lives.
#[derive(Component)]
pub(crate) struct SpellLight {
/// The `PointLight::intensity` this light sits at when the envelope is at full — captured at
/// spawn from the synthesised colour/intensity, never recomputed. The envelope only ever
/// SCALES this, so nothing downstream can drift the calibration.
base: f32,
/// Seconds from spawn before the light comes up — the emitter's own rate-track onset
/// (`benilla_formats::fire_light::emit_onset`). A firework shell detonates half a second into
/// its model's clip and a rocket that lit the sky on the way UP would be exactly backwards.
onset: f32,
/// The lifecycle ([`SpellLightMode`]).
mode: SpellLightMode,
/// Seconds alive. Also the budget's age ordering — the OLDEST spell light is the one dropped
/// when the ceiling is hit, because it is the one whose moment has most passed.
age: f32,
/// Seconds this light's HOST instance has been reaped ([`FxDecay`]), i.e. how far into
/// [`SPELL_REAP_FADE`] it is. Only ticks in [`SpellLightMode::Kit`].
reaped: f32,
}
impl SpellLight {
/// A fresh light at `base` intensity, coming up `onset` seconds from now.
pub(crate) fn new(base: f32, onset: f32, mode: SpellLightMode) -> Self {
Self {
base,
onset,
mode,
age: 0.0,
reaped: 0.0,
}
}
/// The envelope's current multiplier, `0..=1` — pure, so the shape is testable without a
/// world. Ramp in, then the mode's own decay, then the reap fade, multiplied.
fn envelope(&self) -> f32 {
let t = self.age - self.onset;
if t <= 0.0 {
return 0.0; // the flame this light stands for does not exist yet
}
let up = (t / SPELL_LIGHT_RAMP).min(1.0);
let down = match self.mode {
// The fade starts where the ramp ended, so a burst's peak is a real (if brief) plateau
// rather than a single-frame spike that a low frame rate could skip entirely.
SpellLightMode::Burst { span } => {
(1.0 - (t - SPELL_LIGHT_RAMP).max(0.0) / span.max(1e-3)).clamp(0.0, 1.0)
}
SpellLightMode::Kit | SpellLightMode::Missile => 1.0,
};
let reap = (1.0 - self.reaped / SPELL_REAP_FADE).clamp(0.0, 1.0);
up * down * reap
}
}
/// MONKEY (spell light): run every live spell light's envelope.
///
/// `Update`, beside the rest of the effect lane, and it writes `PointLight::intensity` ONLY — the
/// packer reads that in `PostUpdate` (`lighting::build_light_data`), so a light's brightness is
/// always this frame's. Deliberately NOT a `FlameFlicker`: that modulation exists to make a fire
/// breathe, and a spell light already has a shape of its own; the two together read as the effect
/// stuttering.
///
/// The KIT arm is the only one that has to look outside itself: a reaped instance
/// ([`FxDecay`] on the effect root, written by the reap) must take its light down with it, and the
/// light's parent IS that root.
pub(crate) fn advance_spell_lights(
time: Res<Time>,
mut lights: Query<(&mut SpellLight, &mut WorldPointLight, Option<&ChildOf>)>,
reaped: Query<Has<FxDecay>>,
) {
let dt = time.delta_secs();
for (mut light, mut point, parent) in &mut lights {
light.age += dt;
if light.mode == SpellLightMode::Kit
&& parent.is_some_and(|c| reaped.get(c.parent()).unwrap_or(false))
{
light.reaped += dt;
}
let want = light.base * light.envelope();
// Write only on a real change: a held kit light sits at its plateau for the whole aura,
// and a per-frame write there would wake every change-detection consumer of `PointLight`
// for nothing.
if (point.intensity - want).abs() > 1e-3 {
point.intensity = want;
}
}
}
/// MONKEY (spell light): the ceiling ([`SPELL_LIGHTS_MAX`]) — drop the OLDEST beyond it.
///
/// Oldest rather than dimmest or furthest, for one reason: the lane exists to light what is
/// HAPPENING, and the newest cast is the one the player is looking at. A dimness rule would evict
/// the burst that is mid-fade (i.e. exactly the flash being watched) and a distance rule would
/// fight the packer, which already sorts by distance and would then be given a hole it had no say
/// in. Age is also the only ordering that is stable frame to frame, so the set does not churn.
///
/// Despawning the light alone never disturbs its effect: the light is a leaf child of the effect
/// root and nothing reads back from it.
pub(crate) fn budget_spell_lights(mut commands: Commands, lights: Query<(Entity, &SpellLight)>) {
let live = lights.iter().count();
if live <= SPELL_LIGHTS_MAX {
return;
}
let mut by_age: Vec<(Entity, f32)> = lights.iter().map(|(e, s)| (e, s.age)).collect();
by_age.sort_by(|a, b| b.1.total_cmp(&a.1)); // oldest first
for (light, _) in by_age.into_iter().take(live - SPELL_LIGHTS_MAX) {
commands.entity(light).try_despawn();
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -306,6 +490,50 @@ mod tests {
assert_eq!(decay_span(None), None);
}
/// MONKEY (spell light) — GOLDEN: the envelope's four legs, on the pure function so the shape
/// is pinned without a world. The ONSET leg is the one with a real bug behind it: a firework
/// shell detonates half a second into its clip, and a light lit at spawn flashes the rocket's
/// flight instead of its burst.
#[test]
fn the_spell_envelope_ramps_holds_and_bursts() {
let at = |mut l: SpellLight, age: f32| {
l.age = age;
l.envelope()
};
// A kit light: dark before its onset, ramped over SPELL_LIGHT_RAMP, then HELD.
let kit = || SpellLight::new(100.0, 0.0, SpellLightMode::Kit);
assert_eq!(at(kit(), 0.0), 0.0, "nothing on the spawn frame");
assert!((at(kit(), SPELL_LIGHT_RAMP * 0.5) - 0.5).abs() < 1e-3);
assert_eq!(at(kit(), SPELL_LIGHT_RAMP), 1.0);
assert_eq!(at(kit(), 30.0), 1.0, "an aura holds for as long as it stands");
// A missile is the same minus any ending of its own — the arrival despawn is the ending.
assert_eq!(at(SpellLight::new(1.0, 0.0, SpellLightMode::Missile), 5.0), 1.0);
// A burst peaks at the ramp's end and is gone `span` later; the plateau is real, not a
// one-frame spike a low frame rate could step over.
let burst = || SpellLight::new(1.0, 0.0, SpellLightMode::Burst { span: 0.6 });
assert_eq!(at(burst(), SPELL_LIGHT_RAMP), 1.0);
assert!((at(burst(), SPELL_LIGHT_RAMP + 0.3) - 0.5).abs() < 1e-3);
assert_eq!(at(burst(), SPELL_LIGHT_RAMP + 0.6), 0.0);
assert_eq!(at(burst(), 10.0), 0.0, "and stays gone");
// The ONSET: a firework's fuse. Dark for the whole flight, up at the detonation.
let fuse = || SpellLight::new(1.0, 1.5, SpellLightMode::Burst { span: 0.6 });
assert_eq!(at(fuse(), 1.4), 0.0);
assert_eq!(at(fuse(), 1.5 + SPELL_LIGHT_RAMP), 1.0);
// The reap fade: a kit light goes dark on its instance's `FxDecay`, quicker than the
// model's own Decay sequence — the aura is over the moment the server said so.
let mut reaped = kit();
reaped.age = 5.0;
reaped.reaped = SPELL_REAP_FADE * 0.5;
assert!((reaped.envelope() - 0.5).abs() < 1e-3);
reaped.reaped = SPELL_REAP_FADE;
assert_eq!(reaped.envelope(), 0.0);
}
fn test_anims(clips: &[AnimClip]) -> ModelAnimations {
ModelAnimations {
graph: Handle::default(),
@@ -79,6 +79,14 @@ use benilla_world::vis_chain::VisChainOnly;
use super::{BoneAttach, DisplayModel, EntityPart, ModelHandle};
pub(super) use lifecycle::advance_fx_anim;
// MONKEY (spell light): the luminous-effect light's lifecycle — its envelope, its budget, its kill
// switch and the mode each lane spawns it in. Exported at `pub(crate)` because the spawners live
// outside this module: `entities::carried_light` holds the one spawn helper (and the firework
// GameObject's own branch), and `entities.rs` registers the two systems.
pub(crate) use lifecycle::{
advance_spell_lights, budget_spell_lights, spell_lights_enabled, SpellLight, SpellLightMode,
SPELL_BURST_SPAN,
};
use lifecycle::{decay_span, FxAnimLife, FxDecay};
/// The client's attach fallback cascade when a model lacks the requested point (wow-re
@@ -1140,6 +1148,12 @@ pub(super) fn attach_spell_fx(
&mut palettes,
None, // an attach-point kit effect opens on its model's own `Stand`
);
// MONKEY (spell light): and the light the effect throws, if its school is a luminous
// one (fire / holy / fel — never frost, nature, arcane or shadow). A child of the
// instance root, so the reap that despawns the root takes it. `Kit` because that is
// this lane's lifecycle exactly: up on the ramp, held for the aura's life, out when
// `FxDecay` lands.
super::spawn_spell_light(&mut commands, &dm.lights, root, SpellLightMode::Kit);
inst.root = Some(root);
if !inst.persistent {
// The client's completion-callback moment: one full pass of the sequence the
+32 -13
View File
@@ -202,10 +202,11 @@ pub(super) fn spawn_wmo_gameobject_props(
time: Res<Time>,
mut hosts: Query<(Entity, &GlobalTransform, &mut WmoProps)>,
) {
let Some((mat_cache, materials, light)) = mats.pieces() else {
return; // no shared light buffer yet
let Some((mat_cache, materials, light, torch)) = mats.pieces() else {
return; // no shared light buffer / torch bindings yet
};
let light = &light;
let torch = &torch;
// The animated-prop clock origin (decision 0130) — per-instance phase = spawn time.
let now = time.elapsed_secs();
for (entity, host_gt, mut props) in &mut hosts {
@@ -329,6 +330,7 @@ pub(super) fn spawn_wmo_gameobject_props(
mat_cache,
materials,
light,
torch,
&m.submeshes,
forms.slices(&prop.handle),
prop.local, // doodad-LOCAL — the parent composes the world pose
@@ -468,19 +470,36 @@ 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 local = prop.local.transform_point(wow_to_bevy(l.def.position));
let mut glow = commands.spawn((
point_light(l.def.diffuse_color, l.def.diffuse_intensity),
Transform::from_translation(local),
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);
}
// MONKEY (flame flicker): a deck brazier on a moving zeppelin burns too. The
// seed is the prop-LOCAL position (the hull moves; the phase must not) mixed
// with the host, so two identical decks don't beat together.
if let Some(kind) = benilla_world::lighting::flame_kind_for(
l.flame,
l.synthetic,
l.def.diffuse_color,
l.def.diffuse_intensity,
) {
let seed = benilla_world::lighting::flicker_seed(local)
^ entity.to_bits().rotate_left(11) as u32;
glow.insert(benilla_world::lighting::FlameFlicker::new(kind, seed));
}
let glow = glow.id();
commands.entity(entity).add_child(glow);
lights += 1;
}
+74 -2
View File
@@ -133,7 +133,10 @@ impl PluginGroup for GamePlugins {
.add(CreatureAnimPlugin)
// The unit blob shadow: the dark ground oval under every unit, sized from the playing
// animation's box (the byte-verified law — wow-re unit-blob-shadow RE), on the same
// surface-decal projector as the selection ring.
// surface-decal projector as the selection ring. `worldShadows 0` — the shipped default,
// the faithful 1.12 frame — draws it exactly as the reference does; while `worldShadows 1`
// the realtime shadow-map path owns unit shadows and this lane hides (the gate inside
// `update_shadows`), so a unit never wears the oval underneath its cast shadow.
.add(BlobShadowPlugin)
// Footprint decals (B212, decision 1006): the prints a walking unit leaves on snow/sand,
// spawn-once projections on the same decal projector, fading off the effect stream.
@@ -225,6 +228,17 @@ impl PluginGroup for GamePlugins {
// The video knobs the CVar host writes into (today: `gxVSync`). Before CvarPlugin so the
// resource exists when `load_config` applies the saved value at Startup.
.add(crate::video::VideoPlugin)
// MONKEY (shadows): the shared rig (loads first) + the independent lane plugins. Here,
// between `VideoPlugin` and `RealmlistPlugin`, because every one of them registers cvars
// that `CvarPlugin` (two lines down) must already see when `load_config` applies the
// saved values at Startup — the same edge that pins the two knob plugins around them.
.add(crate::shadow_core::ShadowCorePlugin)
.add(crate::character_shadow::CharacterShadowPlugin)
.add(crate::world_shadow::WorldShadowPlugin)
.add(crate::torch_shadow::TorchShadowPlugin)
// MONKEY (dynamic interiors): the fixture-lit interior lane's cvar bridge — independent
// of the shadow lanes, a plain drop-in.
.add(crate::dynamic_interior::DynamicInteriorPlugin)
// The realmlist (decision 1667) — the logon address the login screen edits. Same reason as
// VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`.
.add(crate::realmlist::RealmlistPlugin)
@@ -866,7 +880,38 @@ pub(crate) mod schedule_tests {
}
/// `PostUpdate`, 181 systems: `GlobalTransform` and the particle `EffectQuads` are most of it.
const POST_UPDATE_CEILING: usize = 351;
///
/// **375 (MONKEY, the dynamic light and shadow system)** — upstream's own number is 351; this
/// branch adds **+24**, and the pairs were READ off the dump rather than reasoned about (the
/// lesson the `UPDATE_ACTIONABLE_CEILING` paragraphs paid for). `WOW_AMBIGUITY_DUMP_POST=1`
/// on this test prints them; three systems carry every one:
///
/// - `entities::carried_light::track_carried_light_motion` (12 pairs) — the system that walks
/// a torch's light to its carrier's hand. It meets `billboard::face_billboards`,
/// `billboard_joint_palette`, `particles::{sim, model}`, `ribbons::simulate_ribbons`,
/// `rig_anim::compose::finalize_rig_worlds`, the four sky/sun/moon/star followers,
/// `nameplates::place_nameplates` and `raid_marks::place_raid_marks`.
/// - `lighting::global_light::classify_light_lanes` (12 pairs) — the interior/exterior lane
/// classifier, against the same family for the same reason.
/// - `bevy_pbr::material::check_entities_needing_specialization` for the two shadow-proxy
/// materials (`shadow_core::ShadowCasterMaterial`, `world_shadow::CutoutShadowCasterMaterial`)
/// against `straddle::sync_straddle_twins` (2 pairs).
///
/// **Every one of the 24 is over `GlobalTransform` (22) or `Mesh3d` (2), and both of ours are
/// READS.** Neither lighting system writes a transform: they ask where a light already is and
/// then write their own state (a `WorldPointLight`'s position, a `LightLane`). So the
/// undeclared order decides only whether a light is placed from this frame's pose or the last
/// one — the `AreaSpiritHealer` poll's class exactly (the 3,288 paragraph): both are
/// **level**-triggered, re-deriving from scratch every frame, so a frame-late read is a light
/// trailing a walking carrier by one stride, re-corrected on the next frame and invisible at
/// any frame rate the client runs at. The `Mesh3d` pair is Bevy's own specialization check
/// against a straddle twin, which is a read of the mesh handle on both sides.
///
/// Declaring the orders instead would mean pinning two lighting systems after eleven unrelated
/// animation, particle and celestial-follow systems to buy a one-frame-fresher transform that
/// nothing can see — a real serialisation cost for no observable difference. Raised, with the
/// reason, as the failure message offers.
const POST_UPDATE_CEILING: usize = 375;
const POST_UPDATE_SLACK: usize = 20;
/// The **actionable** pairs in `Update` — two systems with conflicting access and no
/// declared order, where [`Classes`] explains none of what they share, so the executor
@@ -1024,6 +1069,33 @@ pub(crate) mod schedule_tests {
eprintln!("{r}");
}
}
// MONKEY (lighting): the `PostUpdate` half of the dump, which upstream's `Update`-only
// lever had no equivalent of — and without which `POST_UPDATE_CEILING` could only ever be
// raised by reasoning about what the new pairs must be, the exact mistake the ceiling's
// own comments record. Its own env var, not `WOW_AMBIGUITY_DUMP`, because `PostUpdate`
// prints every pair (there is no `Classes` pass over it) and 375 rows would bury the
// actionable `Update` list this test is usually run for.
if std::env::var_os("WOW_AMBIGUITY_DUMP_POST").is_some() {
let mut rows: Vec<String> = post
.conflicts
.iter()
.map(|(a, b, what)| {
format!(
"POST {} <-> {}\n on {}",
post.name(*a),
post.name(*b),
what.iter()
.map(|id| post.component(*id))
.collect::<Vec<_>>()
.join(", ")
)
})
.collect();
rows.sort();
for r in rows {
eprintln!("{r}");
}
}
ratchet(
"Update (actionable)",
actionable.len(),
+6
View File
@@ -71,6 +71,8 @@ mod debug_panel;
/// rule, in one file. Always compiled; what it *holds* is not.
mod dev;
mod doodad_events;
/// MONKEY (dynamic interiors): the cvar → benilla-world bridge for the fixture-lit interior lane.
mod dynamic_interior;
mod entities;
mod fishing_line;
mod footprints;
@@ -93,6 +95,7 @@ mod npc_text;
mod pending_item_ops;
/// Ships in part: the FPS journal and the clocks it reads (2008); the rest is `dev` (1173).
mod perf;
mod character_shadow;
mod pipe_warm;
mod player;
mod poi_marker;
@@ -111,6 +114,7 @@ mod run_mode;
mod screen_fade;
mod screenshot;
mod shaders;
mod shadow_core;
mod game_tip;
mod name_persist;
@@ -202,8 +206,10 @@ mod ui_unit;
mod ui_world_map;
mod video;
mod vplates;
mod torch_shadow;
mod weapon_trail;
mod world_backdrop;
mod world_shadow;
mod world_state;
mod world_state_ui;
@@ -13,7 +13,7 @@ use bevy::mesh::{Indices, MeshTag, PrimitiveTopology};
use bevy::prelude::*;
use bevy::render::render_resource::Buffer;
use benilla_assets::materials::{LiquidMaterial, WowModelMaterial};
use benilla_assets::materials::{LiquidMaterial, TorchBinds, WowModelMaterial};
use benilla_world::clouds::CloudMaterial;
use benilla_world::model_render::{
far_twin_of, model_material, zfill_material, MaterialCache, ShadeSel,
@@ -103,6 +103,8 @@ pub(super) fn spawn_menagerie(
lanes: &mut WarmLanes,
cache: &mut MaterialCache,
light: &Buffer,
// MONKEY (torch shadows Phase 3A): the shared torch bindings, beside the light buffer.
torch: &TorchBinds,
) -> usize {
// The four vertex layouts the model lane ships (0837 dump: strides 32/48/56/72): static ×
// {plain, vertex-colours} and their skinned twins. Statics are RENDER_WORLD-only, so their
@@ -162,6 +164,7 @@ pub(super) fn spawn_menagerie(
false,
false, // warms the WORLD lane; the sky lane warms with its own model
light,
torch,
// The pipeline menagerie warms the SHARED batch material; the
// per-placement lane (1408) builds an identical pipeline, so a clone
// needs no row of its own here.
@@ -205,6 +208,7 @@ pub(super) fn spawn_menagerie(
false,
false, // warms the WORLD lane; the sky lane warms with its own model
light,
torch,
// The pipeline menagerie warms the SHARED batch material; the
// per-placement lane (1408) builds an identical pipeline, so a clone
// needs no row of its own here.
@@ -216,7 +220,7 @@ pub(super) fn spawn_menagerie(
// The depth-prime twin (colour writes masked off), plain and cutout.
for cutout in [false, true] {
mats.push(zfill_material(
cache, materials, None, two_sided, cutout, light,
cache, materials, None, two_sided, cutout, light, torch,
));
}
// The **WMO-skybox lane** (decision 1264): `sky_depth` is a `WowModelKey` axis — it
@@ -259,6 +263,7 @@ pub(super) fn spawn_menagerie(
false,
true, // the sky lane
light,
torch,
None, // the shared lane — see the note above
));
}
@@ -301,6 +306,7 @@ pub(super) fn spawn_menagerie(
false,
false, // warms the WORLD lane; the sky lane warms with its own model
light,
torch,
None, // the shared lane — see the note above
);
// The warmer reads what it builds (`model_render::lazy` parks a built material
@@ -353,6 +359,7 @@ pub(super) fn spawn_menagerie(
false,
false, // warms the WORLD lane; the sky lane warms with its own model
light,
torch,
None, // the shared lane — see the note above
);
benilla_world::model_render::lazy::realize_all(materials);
+6
View File
@@ -428,6 +428,8 @@ fn run_warm_pass(
mut gizmos: Gizmos,
mut cache: Local<MaterialCache>,
shared_light: Option<Res<benilla_world::lighting::SharedLightBuffer>>,
// MONKEY (torch shadows Phase 3A): the shared torch bindings every warmed material takes.
torch: benilla_world::static_gx::TorchShared,
) {
// `EntryCover` already IS "a loading cover is up and we are in world", counted once for the
// whole client (see its doc); this used to spell the pair out for itself.
@@ -463,6 +465,9 @@ fn run_warm_pass(
let Some(light) = shared_light.as_ref() else {
return;
};
let Some(torch) = torch.binds() else {
return;
};
if !cover.presented() {
return;
}
@@ -499,6 +504,7 @@ fn run_warm_pass(
&mut lanes,
&mut cache,
&light.0,
&torch,
);
info!("pipeline warm: menagerie up ({count} variants, {WARM_REVEAL_PER_FRAME}/frame)");
return;
@@ -2153,6 +2153,9 @@ mod tests {
visibility_off: false,
},
bone_pivot: [0.0; 3],
synthetic: false,
flame: false,
spell: None,
}
}
+3 -1
View File
@@ -24,6 +24,8 @@ pub(crate) fn plugin(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/ui_gamma.wgsl");
bevy::asset::embedded_asset!(app, "shaders/ui_node_gamma.wgsl");
bevy::asset::embedded_asset!(app, "shaders/ui_slice_gamma.wgsl");
bevy::asset::embedded_asset!(app, "shaders/shadow_caster.wgsl");
bevy::asset::embedded_asset!(app, "shaders/shadow_caster_cutout_prepass.wgsl");
}
#[cfg(test)]
@@ -59,6 +61,6 @@ mod tests {
`shaders::plugin`, or was registered from a file that is not directly under src/"
);
}
assert_eq!(found, 5, "the game's shader set changed size");
assert_eq!(found, 7, "the game's shader set changed size");
}
}
@@ -0,0 +1,7 @@
// The proxy is deliberately invisible in the camera's forward pass. Its material remains
// opaque, so Bevy still queues it for the depth-only directional-light shadow pass.
@fragment
fn fragment() -> @location(0) vec4<f32> {
discard;
return vec4<f32>(0.0);
}
@@ -0,0 +1,63 @@
// MONKEY (world shadows): alpha-tested foliage shadow caster — the PREPASS fragment.
//
// The proxy caster is invisible in the camera's forward pass (its forward fragment `discard`s every
// fragment — `shadow_caster.wgsl`). Its material is `AlphaMode::Mask`, which sets Bevy's
// `MAY_DISCARD` pipeline key; paired with this EXPLICIT prepass fragment shader that decides Bevy
// runs a fragment during the directional-light SHADOW pass (which is otherwise depth-only). So a
// leaf card can discard its transparent texels and cast a leaf-SHAPED silhouette instead of the
// solid rectangular box a positions-only proxy throws.
//
// The leaf sheet is bound as the material's own texture at group `MATERIAL_BIND_GROUP` — the
// prepass material slot (index 3; substituted by the prepass pipeline). Sampling the SAME image the
// forward pass draws keeps the clamp/repeat address mode identical, so the shadow silhouette
// matches the visible foliage exactly (a cutout card's UVs run outside 0..1 into the transparent
// border — sampled with the wrong wrap mode the margin wraps back into the opaque middle and the
// card casts solid again).
//
// Structure mirrors Bevy's own `pbr_prepass.wgsl`: a `FragmentOutput`-returning entry under
// `PREPASS_FRAGMENT` (the unclipped-depth-emulation path for GPUs without `depth_clip_control`) and
// a void entry otherwise (the common desktop depth-only path); both run the same cutout discard.
#import bevy_pbr::prepass_io
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var leaf_texture: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var leaf_sampler: sampler;
// WoW ≤ WotLK alpha-test key (`benilla_assets::materials::VANILLA_ALPHA_KEY_REF` = 224/255): the
// value below which a cutout fragment is discarded. The exact ref the forward cutout pass uses.
const VANILLA_ALPHA_KEY: f32 = 0.8784314;
// Sample the leaf sheet at the interpolated UV and discard transparent texels, exactly as the
// forward cutout does. Sampled BEFORE any `discard` so the texture access stays in uniform control
// flow. A caster mesh always carries `UV_0` (`VERTEX_UVS_A`); lacking it, nothing discards (the
// card falls back to a solid cast rather than vanishing).
fn cutout_discard(in: prepass_io::VertexOutput) {
#ifdef VERTEX_UVS_A
let alpha = textureSample(leaf_texture, leaf_sampler, in.uv).a;
if alpha < VANILLA_ALPHA_KEY {
discard;
}
#endif
}
#ifdef PREPASS_FRAGMENT
// A GPU without `depth_clip_control`: Bevy emulates the directional-shadow view's unclipped depth
// by writing `frag_depth` in the fragment (`prepass/mod.rs`, `UNCLIPPED_DEPTH_ORTHO_EMULATION`).
// Mirror the default prepass fragment's single field so that depth-clamp fix still applies here.
@fragment
fn fragment(in: prepass_io::VertexOutput) -> prepass_io::FragmentOutput {
cutout_discard(in);
var out: prepass_io::FragmentOutput;
#ifdef UNCLIPPED_DEPTH_ORTHO_EMULATION
out.frag_depth = in.unclipped_depth;
#endif // UNCLIPPED_DEPTH_ORTHO_EMULATION
return out;
}
#else
// The common desktop path: the shadow pass is depth-only (no color targets), so the fragment
// returns nothing and exists ONLY to run the cutout discard against the depth write.
@fragment
fn fragment(in: prepass_io::VertexOutput) {
cutout_discard(in);
}
#endif // PREPASS_FRAGMENT
+933
View File
@@ -0,0 +1,933 @@
//! The shared **shadow rig** — the framework both shadow lanes plug into.
//!
//! A realtime shadow needs three things: something to CAST (a lane's caster geometry), a LIGHT +
//! shadow map to cast INTO, and RECEIVERS that read the map and darken. This module owns the middle
//! part — one directional "sun" on a private render layer, that layer's membership on the world
//! camera, the shadow-map resource, the invisible proxy caster material, and the sun aiming — plus
//! the geometry-collection helpers the lanes share. The receivers live in benilla's own shaders.
//!
//! The two lanes ([`super::character_shadow`], [`super::world_shadow`]) are EQUAL and independent:
//! each is its own plugin that depends only on this core, never on the other. They coordinate with
//! the rig through two resources, with a deliberate one-frame handshake:
//! - [`ShadowDemand`] — each active lane ORs `true` in (in the [`ShadowSet::Lanes`] set); the rig
//! reads it next frame to decide whether the sun should exist. A lane that is removed simply stops
//! contributing demand — the core needs no edit.
//! - [`ShadowFrame`] — the rig publishes the shared per-frame facts (is it active, the caster
//! material, the camera/light position, the sun direction, the two collection reaches) for the
//! lanes to read when they build their casters.
//!
//! Why ONE shared light and not one per lane: benilla's custom receiver shaders sample a SINGLE
//! directional light (they assign, not accumulate, over the light loop). Two lights would double the
//! shadow-pass cost and the last would win the receiver. So the lanes share this rig and each merely
//! contributes its own caster geometry into the one map.
//!
//! The rig deliberately does NOT put a directional light on the normal world layer: that would make
//! every existing WoW material enter Bevy's shadow-prepass path, the source of the pipeline
//! corruption seen during the first experiment. Only the private-layer proxy casts.
use bevy::asset::RenderAssetUsages;
use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
use bevy::light::{
CascadeShadowConfigBuilder, DirectionalLight, DirectionalLightShadowMap, NotShadowReceiver,
ShadowFilteringMethod,
};
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::pbr::{Material, MaterialPipeline, MaterialPlugin, MeshMaterial3d};
use bevy::prelude::*;
use bevy::reflect::TypePath;
use bevy::render::render_resource::{
AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
};
use bevy::shader::ShaderRef;
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::materials::WowModelMaterial;
use benilla_formats::ModelBlend;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::PickMesh;
use benilla_world::lighting::{ShadowDistance, ShadowFilterGaussian, WowLighting};
use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
use benilla_world::view::WorldCamera;
use crate::char_select::ClientState;
use crate::video::VideoConfig;
/// Layer 31 is deliberately private to this feature. Bevy supports 32 render layers; the normal
/// world is layer 0 and the UI/portrait layers occupy the low numbered slots.
pub(crate) const PLAYER_SHADOW_LAYER: usize = 31;
/// Shipped realtime-shadow render distance in yards — the `shadowDistance` default. How far from the
/// camera the shadow map RESOLVES (the single cascade's `maximum_distance`). Collection reaches
/// farther than this (a caster's body may stand outside the cascade while its shadow lands inside).
pub(crate) const DEFAULT_SHADOW_DISTANCE: f32 = 80.0;
/// The `shadowDistance` slider range (yd). Min keeps a useful near shadow; max is bounded because
/// the ONE shadow map spreads thinner (softer) over a larger area and the caster-collection cost
/// grows with distance. (The map's edge is no longer the fixed 4096 this note was written against —
/// it is `shadowMapSize`, default 2048 — which makes the softening at long range twice as quick.)
pub(crate) const SHADOW_DISTANCE_RANGE: std::ops::RangeInclusive<f32> = 40.0..=200.0;
/// How far the camera may drift before a cached caster-collection pass must refresh. The collection
/// reach carries this as margin so coverage holds everywhere between refreshes.
pub(crate) const STATIC_REBUILD_STEP: f32 = 16.0;
// -------------------------------------------------------------------------------------------
// MONKEY (sun shadow perf): the five live dials over this rig's cost. Measured on an RTX 3070 at
// 1080p in the Lion's Pride Inn: 45-47 fps with both lanes on, 60-62 with `characterShadows 0`,
// 68-73 with `worldShadows 0` too — i.e. ~5 ms/frame in the sun lanes, the renderer's single
// biggest line item. The cost splits three ways and each dial takes one of them:
// * the shadow PASS's fill + its depth texture .... `shadowMapSize` (quadratic in the edge)
// * the RECEIVERS' PCF fetches ..................... `shadowFilter` (9 samples vs 1)
// * the CPU caster rebuild + GPU re-upload ......... `characterShadowRate` / `worldShadowRate`
// and `shadowCasterReach` trims the caster POPULATION those rebuilds walk. All live: nothing here
// is latched at boot, so the user A/Bs the whole set from one chat line.
// -------------------------------------------------------------------------------------------
/// The `shadowMapSize` ladder. Powers of two only — Bevy's `validate_shadow_map_size` rounds a
/// non-power-of-two up with a warning, so an off-ladder value would silently become a different
/// (and larger) map than the one the user typed.
pub(crate) const SHADOW_MAP_SIZES: [u32; 3] = [1024, 2048, 4096];
/// The shipped shadow-map edge. **2048**, down from the rig's original 4096 literal: the pass is
/// quadratic in this, and over ONE 80 yd cascade 2048 is ~26 texels/yd — finer than the Gaussian
/// receiver kernel resolves. The softer edge is the accepted trade; `shadowMapSize 4096` restores
/// the old crispness at the old price.
pub(crate) const DEFAULT_SHADOW_MAP_SIZE: u32 = 2048;
/// `shadowFilter` default: **1 = Gaussian**, the look the rig has always had. The cheap arm (0 =
/// Hardware2x2) is one comparison sample instead of nine and is where the receiver-side win is,
/// but it stair-steps edges, so it ships OFF and the user judges it.
pub(crate) const DEFAULT_SHADOW_FILTER: u32 = 1;
/// The `shadowFilter` ladder's top. Bevy also has `Temporal`, which is deliberately NOT offered:
/// it is a randomized filter that only resolves under `TemporalAntiAliasing`, which benilla's
/// world camera does not run — it would read as noise.
pub(crate) const MAX_SHADOW_FILTER: u32 = 1;
/// The shipped Hz cap on both lanes' per-frame caster rebuild. **30** — half of a 60 Hz frame's
/// rebuilds for a silhouette that lags at most 33 ms, which is under the reaction threshold for a
/// shadow you are not looking directly at.
pub(crate) const DEFAULT_SHADOW_RATE: u32 = 30;
/// The rate ladder's top. Above the frame rate the cap is inert, so 120 is "off" with headroom for
/// a high-refresh panel; `0` is the explicit "every frame" (the pre-cvar behaviour).
pub(crate) const MAX_SHADOW_RATE: u32 = 120;
/// The `shadowCasterReach` multiplier range. `1` is the untouched reach law. The floor is 0.25 and
/// not 0 because a 0 reach admits nothing and would read as "shadows broke", which is what
/// `worldShadows 0` / `characterShadows 0` are for.
pub(crate) const CASTER_REACH_RANGE: std::ops::RangeInclusive<f32> = 0.25..=2.0;
/// Snap a requested `shadowMapSize` onto [`SHADOW_MAP_SIZES`] — nearest in LOG space, so 1500 lands
/// on 1024 and 3000 on 4096 (halfway in ratio, not in texels, is what "one step" means here).
pub(crate) fn clamp_shadow_map_size(asked: u32) -> u32 {
let asked = asked.clamp(SHADOW_MAP_SIZES[0], SHADOW_MAP_SIZES[SHADOW_MAP_SIZES.len() - 1]);
*SHADOW_MAP_SIZES
.iter()
.min_by(|a, b| {
let d = |v: u32| ((v as f32).ln() - (asked as f32).ln()).abs();
d(**a).total_cmp(&d(**b))
})
.unwrap_or(&DEFAULT_SHADOW_MAP_SIZE)
}
/// A lane's rebuild cadence gate. Holds the timestamp of the last rebuild and answers "may I
/// rebuild now?" against a live Hz cap.
///
/// Deliberately time-based rather than frame-counted: the point is to bound the rebuild WORK per
/// second, and a frame counter would tighten the real cadence exactly when the frame rate is
/// already high (where the work is affordable) and loosen it when it drops (where it is not).
///
/// `rate == 0` means "every frame" — the pre-cvar behaviour, kept reachable so the cap can be
/// ruled out as the cause of any artefact in one keystroke.
#[derive(Default)]
pub(crate) struct RebuildRate {
last: Option<f32>,
}
impl RebuildRate {
/// True when a rebuild is due at `now` (seconds since app start) for a cap of `rate` Hz; the
/// timestamp is taken on the way out, so a `true` consumes the slot. The FIRST call is always
/// due — a lane that has never built has nothing to show.
pub(crate) fn due(&mut self, now: f32, rate: u32) -> bool {
if rate == 0 {
self.last = Some(now);
return true;
}
let interval = 1.0 / rate as f32;
// `now < last` (a time reset) is treated as due rather than as a very long wait.
let due = self.last.is_none_or(|last| now - last >= interval || now < last);
if due {
self.last = Some(now);
}
due
}
/// Forget the last rebuild, so the lane's next frame rebuilds immediately. Called when a lane
/// tears its caster down — the mesh it was pacing no longer exists.
pub(crate) fn reset(&mut self) {
self.last = None;
}
}
/// The tallest COMMON caster the reach law budgets for (the big Elwynn/Duskwood tree class, in world
/// units). Not a clamp on what casts — only on how far past the resolve range collection hunts.
const MAX_CASTER_HEIGHT: f32 = 35.0;
/// Ceiling on the shadow-reach extension: a degenerate near-horizontal sun must not explode the
/// collection radius (and with it the rebuild cost) unbounded.
const SHADOW_REACH_CAP: f32 = 120.0;
/// How far the LIVE sun may drift from the last-written direction before the shadow light snaps to
/// it — about one game minute of travel. The basis is quantised because a light that rotates EVERY
/// frame defeats the cascade fit's texel snap and crawls every shadow edge at WoW map coordinates
/// (~9,300 units out). Held between snaps the basis is bit-stable; only the shadow basis steps.
const SUN_SNAP_RADIANS: f32 = 8.0e-4;
/// The daytime elevation band (radians above the horizon) the shadow sun is clamped to. The visible
/// celestial sun sweeps roughly −10°..+85° and dips below the horizon at night; unclamped that would
/// INVERT the shadow (a set sun's travel points upward) or DEGENERATE the `looking_to` basis at high
/// noon (travel nearly ∥ up). Pinning to ~18°..65° keeps shadows visible, right-way-up and bounded
/// while the AZIMUTH still tracks the real sun — so shadows sweep through the day ("moving sun").
const MIN_SHADOW_SUN_ELEVATION: f32 = 0.314; // ~18°
const MAX_SHADOW_SUN_ELEVATION: f32 = 1.134; // ~65°
fn shadow_reach_extension(sun_travel: Vec3) -> f32 {
// `sun_travel` is the direction the light TRAVELS (downward): -y is the vertical drop.
let down = -sun_travel.y;
if down <= 1e-3 {
return SHADOW_REACH_CAP;
}
let horizontal = Vec3::new(sun_travel.x, 0.0, sun_travel.z).length();
(MAX_CASTER_HEIGHT * horizontal / down).min(SHADOW_REACH_CAP)
}
/// The collection law for a lane that can contribute a TALL caster (a doodad/WMO exile, or the whole
/// static world): resolve range + rebuild margin + the sun-dependent shadow reach.
pub(crate) fn static_collection_reach(sun_travel: Vec3, distance: f32) -> f32 {
distance + STATIC_REBUILD_STEP + shadow_reach_extension(sun_travel)
}
/// `WOW_SHADOW_TRACE=1` — log the caster population when it CHANGES, to attribute a vanished shadow
/// to a lane rather than the map/receiver.
pub(crate) fn shadow_trace() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_SHADOW_TRACE").is_ok_and(|v| v != "0"))
}
fn sun_snap_due(held: Option<Vec3>, live: Vec3) -> bool {
match held {
None => true,
Some(held) => held.angle_between(live) > SUN_SNAP_RADIANS,
}
}
/// The shadow sun's TRAVEL direction (light propagation — points AWAY from the sun) from the visible
/// celestial TO-sun direction, elevation clamped to [`MIN_SHADOW_SUN_ELEVATION`,
/// `MAX_SHADOW_SUN_ELEVATION`]. Azimuth is preserved so shadows sweep with the real sun; only the
/// height is tamed so a set/zenith sun can't invert or degenerate the basis.
fn shadow_sun_travel(to_sun: Vec3) -> Vec3 {
let s = to_sun.normalize_or_zero();
if s == Vec3::ZERO {
return Vec3::NEG_Z;
}
let horizontal = Vec3::new(s.x, 0.0, s.z);
let bearing = if horizontal.length() > 1e-4 {
horizontal.normalize()
} else {
Vec3::NEG_Z
};
let elevation = s
.y
.clamp(-1.0, 1.0)
.asin()
.clamp(MIN_SHADOW_SUN_ELEVATION, MAX_SHADOW_SUN_ELEVATION);
let to_sun_clamped = bearing * elevation.cos() + Vec3::Y * elevation.sin();
-to_sun_clamped
}
/// Marks a layer-31 caster mesh (either lane's). Kept for teardown/inspection symmetry.
#[derive(Component)]
pub(crate) struct ShadowCaster;
#[derive(Component)]
struct ShadowSun;
#[derive(Component)]
struct ShadowCameraLayer {
had_layers: bool,
}
/// The two ordered phases of the shadow feature within `Last`: the rig runs FIRST (spawns/aims the
/// sun, publishes [`ShadowFrame`]), then every lane runs in [`ShadowSet::Lanes`] — reading the frame
/// to build its casters and ORing its demand back in for the rig to read next frame.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) enum ShadowSet {
Rig,
Lanes,
}
/// Each active lane ORs `true` here (in [`ShadowSet::Lanes`]); the rig reads it the next frame to
/// decide whether the sun should exist, then resets it. A removed lane just stops contributing —
/// the core needs no edit to notice a lane is gone.
#[derive(Resource, Default)]
pub(crate) struct ShadowDemand(pub bool);
/// The rig's per-frame publication for the lanes: whether the rig is live, the shared proxy caster
/// material, and the shared collection inputs (camera/light position, sun direction, and the two
/// reaches). A lane builds nothing until `active` and only with the `material` here.
#[derive(Resource, Default)]
pub(crate) struct ShadowFrame {
pub active: bool,
pub light_position: Vec3,
pub sun_direction: Vec3,
/// Reach for short casters (creatures/gameobjects — at most a body tall).
pub entity_reach: f32,
/// Reach for tall casters (doodads/WMO/static world) — carries the sun-dependent shadow reach.
pub tall_reach: f32,
pub material: Option<Handle<ShadowCasterMaterial>>,
}
/// The rig's own retained state — private to the core.
#[derive(Resource, Default)]
struct ShadowRigState {
sun: Option<Entity>,
/// The direction the sun was LAST GIVEN — the held shadow basis (see [`SUN_SNAP_RADIANS`]).
sun_written: Option<Vec3>,
/// The invisible proxy material shared by every lane's SOLID caster.
material: Option<Handle<ShadowCasterMaterial>>,
/// The cascade's currently-applied `maximum_distance` (the `shadowDistance` slider). The
/// `CascadeShadowConfig` is only re-inserted when this changes — rebuilding it every frame would
/// re-fit the cascade and defeat the texel snap (crawling every shadow edge).
cascade_distance: Option<f32>,
/// MONKEY (sun shadow perf): the last (map size, gaussian, character Hz, world Hz, reach)
/// [`shadow_trace`] reported. Purely the trace's change detector — the values themselves are
/// applied straight from [`VideoConfig`], never cached here.
traced_quality: Option<(u32, bool, u32, u32, f32)>,
}
pub(crate) struct ShadowCorePlugin;
/// Opaque to Bevy's shadow pass, discarded by the forward pass — the private-layer proxy that lets a
/// caster participate in the real shadow map without drawing a second copy of the world. Shared by
/// every lane's SOLID caster.
#[derive(Asset, AsBindGroup, TypePath, Debug, Clone, Default)]
pub(crate) struct ShadowCasterMaterial {}
impl Material for ShadowCasterMaterial {
fn fragment_shader() -> ShaderRef {
"embedded://benilla_app/shaders/shadow_caster.wgsl".into()
}
fn specialize(
_pipeline: &MaterialPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &bevy::mesh::MeshVertexBufferLayoutRef,
_key: bevy::pbr::MaterialPipelineKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
// WoW batches are frequently two-sided; keep both faces so a mixed-winding batch's
// projected silhouette has no holes.
descriptor.primitive.cull_mode = None;
Ok(())
}
}
impl Plugin for ShadowCorePlugin {
fn build(&self, app: &mut App) {
// MONKEY (sun shadow perf): the shipped edge is now 2048, and it is a LIVE cvar
// (`apply_shadow_quality` writes this resource) rather than the 4096 boot literal this
// line used to be. Bevy re-extracts the resource on change and `prepare_lights` re-keys
// the texture cache with the new descriptor, so the map is genuinely re-created.
app.insert_resource(DirectionalLightShadowMap {
size: DEFAULT_SHADOW_MAP_SIZE as usize,
})
.add_plugins(MaterialPlugin::<ShadowCasterMaterial>::default())
.init_resource::<ShadowRigState>()
.init_resource::<ShadowDemand>()
.init_resource::<ShadowFrame>()
// The rig runs before the lanes every frame; the lanes read the frame it publishes.
.configure_sets(Last, (ShadowSet::Rig, ShadowSet::Lanes).chain())
// MONKEY (sun shadow perf): the quality dials land BEFORE the rig each frame, and
// unconditionally — a `shadowMapSize` write must take even while the lanes are off, so
// turning shadows back on doesn't render one frame at the previous size.
.add_systems(
Last,
(apply_shadow_quality, manage_rig)
.chain()
.in_set(ShadowSet::Rig),
);
}
}
/// MONKEY (sun shadow perf): push the "how expensive is a shadow" QUALITY dials from [`VideoConfig`]
/// to the places that own them, every frame, whether or not the lanes are up.
///
/// Three destinations, because Bevy spreads the shadow cost over three unrelated mechanisms:
/// - **`shadowMapSize`** → the [`DirectionalLightShadowMap`] resource. Bevy's `extract_lights`
/// re-publishes it into the render world on change and `prepare_lights` asks the texture cache
/// for a descriptor carrying the new edge — a different descriptor is a different cache entry, so
/// the depth texture is really re-created. Written through `ResMut` only on a genuine difference:
/// touching it every frame would re-extract (and re-validate) it every frame for nothing.
/// - **`shadowFilter`** → the world camera's [`ShadowFilteringMethod`], which keys the mesh
/// pipeline for every Bevy-material receiver (terrain, `wow_model`). Bevy has no "default"
/// component here — absent means Gaussian — but the component is inserted either way so the
/// value on the camera always names what is actually running.
/// - the same **`shadowFilter`** → [`ShadowFilterGaussian`], which `static_gx`'s hand-specialized
/// retained pipeline reads in the render world. Both must move together or the ground and the
/// buildings on it filter differently.
///
/// The rate dials need no push at all: each lane reads `VideoConfig` directly.
fn apply_shadow_quality(
video: Res<VideoConfig>,
mut rig: ResMut<ShadowRigState>,
mut shadow_map: ResMut<DirectionalLightShadowMap>,
mut filter_out: ResMut<ShadowFilterGaussian>,
mut commands: Commands,
cameras: Query<(Entity, Option<&ShadowFilteringMethod>), With<WorldCamera>>,
) {
let size = clamp_shadow_map_size(video.shadow_map_size) as usize;
if shadow_map.size != size {
shadow_map.size = size;
}
let gaussian = video.shadow_filter != 0;
let method = if gaussian {
ShadowFilteringMethod::Gaussian
} else {
ShadowFilteringMethod::Hardware2x2
};
for (entity, current) in &cameras {
if current != Some(&method) {
commands.entity(entity).insert(method);
}
}
if filter_out.0 != gaussian {
filter_out.0 = gaussian;
}
if shadow_trace() {
let now = (
size as u32,
gaussian,
video.character_shadow_rate,
video.world_shadow_rate,
video.shadow_caster_reach,
);
if rig.traced_quality != Some(now) {
info!(
"shadow-trace: quality — map {}² | filter {} | rebuild caps character {} / world {} Hz | caster reach ×{:.2}",
now.0,
if gaussian { "gaussian(9)" } else { "hardware2x2(1)" },
if now.2 == 0 { "every-frame".to_string() } else { now.2.to_string() },
if now.3 == 0 { "every-frame".to_string() } else { now.3.to_string() },
now.4,
);
rig.traced_quality = Some(now);
}
}
}
#[allow(clippy::too_many_arguments)]
fn manage_rig(
state: Res<State<ClientState>>,
video: Res<VideoConfig>,
lighting: Res<WowLighting>,
mut demand: ResMut<ShadowDemand>,
mut frame: ResMut<ShadowFrame>,
mut rig: ResMut<ShadowRigState>,
// MONKEY (distance slider): bridge the app-side `shadowDistance` to benilla-world so
// `global_light` can pack it for the receivers' edge fade (which must fade at THIS distance).
mut shadow_distance_out: ResMut<ShadowDistance>,
mut commands: Commands,
mut materials: ResMut<Assets<ShadowCasterMaterial>>,
mut cameras: Query<
(Entity, Option<&mut RenderLayers>, Option<&ShadowCameraLayer>),
With<WorldCamera>,
>,
mut suns: Query<&mut Transform, With<ShadowSun>>,
cameras_for_position: Query<&GlobalTransform, With<WorldCamera>>,
) {
let in_world = *state.get() == ClientState::InWorld;
// The `shadowDistance` slider (already clamped by the cvar). Publish it to benilla-world every
// frame so the receiver edge fade tracks it.
let distance = video.shadow_distance;
if shadow_distance_out.0 != distance {
shadow_distance_out.0 = distance;
}
// Read the demand the lanes accumulated LAST frame, then reset so they re-accumulate this frame
// (they run after us, in ShadowSet::Lanes). One-frame lag on rig spawn — imperceptible.
let wanted = demand.0 && in_world;
demand.0 = false;
if !wanted {
if let Some(entity) = rig.sun.take() {
commands.entity(entity).despawn();
}
// Forget the held basis with the light, so a re-enable aims the fresh sun immediately.
rig.sun_written = None;
if let Some(handle) = rig.material.take() {
materials.remove(handle.id());
}
for (entity, layers, marker) in &mut cameras {
let Some(marker) = marker else { continue };
if marker.had_layers {
if let Some(mut layers) = layers {
*layers = layers.clone().without(PLAYER_SHADOW_LAYER);
}
} else {
commands.entity(entity).remove::<RenderLayers>();
}
commands.entity(entity).remove::<ShadowCameraLayer>();
}
frame.active = false;
frame.material = None;
return;
}
if rig.material.is_none() {
rig.material = Some(materials.add(ShadowCasterMaterial {}));
}
if rig.sun.is_none() {
let sun = commands
.spawn((
DirectionalLight {
// The light exists to populate a shadow map; WoW's shader lighting stays
// authoritative, so it must not add a second diffuse term.
illuminance: 0.0,
shadows_enabled: true,
shadow_depth_bias: 0.02,
shadow_normal_bias: 0.8,
..default()
},
CascadeShadowConfigBuilder {
num_cascades: 1,
minimum_distance: 0.1,
maximum_distance: distance,
..default()
}
.build(),
Transform::IDENTITY,
Visibility::Visible,
RenderLayers::layer(PLAYER_SHADOW_LAYER),
ShadowSun,
))
.id();
rig.sun = Some(sun);
rig.sun_written = None;
rig.cascade_distance = Some(distance);
}
// Re-fit the cascade only when the slider changed (not every frame — that would defeat the
// texel snap and crawl every shadow edge).
if rig.cascade_distance != Some(distance) {
if let Some(sun) = rig.sun {
commands.entity(sun).insert(
CascadeShadowConfigBuilder {
num_cascades: 1,
minimum_distance: 0.1,
maximum_distance: distance,
..default()
}
.build(),
);
}
rig.cascade_distance = Some(distance);
}
for (entity, layers, marker) in &mut cameras {
if marker.is_some() {
if let Some(mut layers) = layers {
*layers = layers.clone().with(PLAYER_SHADOW_LAYER);
}
} else if let Some(mut layers) = layers {
*layers = layers.clone().with(PLAYER_SHADOW_LAYER);
commands
.entity(entity)
.insert(ShadowCameraLayer { had_layers: true });
} else {
commands.entity(entity).insert((
RenderLayers::default().with(PLAYER_SHADOW_LAYER),
ShadowCameraLayer { had_layers: false },
));
}
}
let light_position = cameras_for_position
.iter()
.next()
.map(GlobalTransform::translation)
.unwrap_or(Vec3::ZERO);
// MONKEY (moving sun): aim at the VISIBLE celestial sun (rises/sets), clamped in elevation.
let sun_direction = shadow_sun_travel(lighting.celestial_dir());
// MONKEY (sun shadow perf): `shadowCasterReach` scales BOTH reaches by the same factor, after
// the reach law rather than inside it — the law's terms (resolve range, rebuild margin, the
// sun-elevation extension) each mean something, and a dial that rewrote one of them would
// change what the others compensate for. A flat multiplier is honestly just "collect less".
let reach_scale = video
.shadow_caster_reach
.clamp(*CASTER_REACH_RANGE.start(), *CASTER_REACH_RANGE.end());
let entity_reach = (distance + STATIC_REBUILD_STEP) * reach_scale;
let tall_reach = static_collection_reach(sun_direction, distance) * reach_scale;
if let Some(sun) = rig.sun {
// Only the rotation matters; QUANTISED to hold the cascade texel snap (see SUN_SNAP_RADIANS).
if sun_snap_due(rig.sun_written, sun_direction) {
if let Ok(mut current) = suns.get_mut(sun) {
*current = Transform::IDENTITY.looking_to(sun_direction, Vec3::Y);
rig.sun_written = Some(sun_direction);
}
}
}
frame.active = true;
frame.light_position = light_position;
frame.sun_direction = sun_direction;
frame.entity_reach = entity_reach;
frame.tall_reach = tall_reach;
frame.material = rig.material.clone();
}
// ---------------------------------------------------------------------------------------------
// Shared caster-building helpers, used by both lanes.
// ---------------------------------------------------------------------------------------------
/// Spawn a SOLID caster entity on the private shadow layer with the shared proxy material.
pub(crate) fn spawn_solid_caster(
commands: &mut Commands,
mesh: Handle<Mesh>,
material: Handle<ShadowCasterMaterial>,
) -> Entity {
commands
.spawn((
Mesh3d(mesh),
MeshMaterial3d(material),
Transform::IDENTITY,
Visibility::Visible,
RenderLayers::layer(PLAYER_SHADOW_LAYER),
NoFrustumCulling,
NotShadowReceiver,
ShadowCaster,
))
.id()
}
/// Append a triangle list, dropping WHOLE triangles whose indices exceed the vertex range (filtering
/// single indices out of a `TriangleList` rewires the rest of the submesh into garbage).
pub(crate) fn append_triangles(source: &[u32], vertex_count: u32, base: u32, indices: &mut Vec<u32>) {
for triangle in source.chunks_exact(3) {
if triangle.iter().all(|index| *index < vertex_count) {
indices.extend(triangle.iter().map(|index| base + *index));
}
}
}
/// Take a caster mesh's own buffers so a rebuild reuses their allocations. The mesh is left
/// attribute-less until [`restore_mesh_buffers`] puts them back.
pub(crate) fn take_mesh_buffers(mesh: &mut Mesh) -> (Vec<[f32; 3]>, Vec<u32>) {
let mut positions = match mesh.remove_attribute(Mesh::ATTRIBUTE_POSITION) {
Some(bevy::mesh::VertexAttributeValues::Float32x3(values)) => values,
_ => Vec::new(),
};
let mut indices = match mesh.remove_indices() {
Some(Indices::U32(values)) => values,
_ => Vec::new(),
};
positions.clear();
indices.clear();
(positions, indices)
}
pub(crate) fn restore_mesh_buffers(mesh: &mut Mesh, positions: Vec<[f32; 3]>, indices: Vec<u32>) {
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_indices(Indices::U32(indices));
}
pub(crate) fn empty_shadow_mesh() -> Mesh {
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, Vec::<[f32; 3]>::new());
mesh.insert_indices(Indices::U32(Vec::new()));
mesh
}
/// A cutout caster mesh: like [`empty_shadow_mesh`] but carrying a `UV_0` lane, because the
/// alpha-tested prepass fragment samples the leaf sheet at these UVs. The mesh MUST declare UV_0
/// (even empty) so Bevy's prepass specializer sets `VERTEX_UVS_A` and the fragment's `in.uv` exists.
pub(crate) fn empty_cutout_mesh() -> Mesh {
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, Vec::<[f32; 3]>::new());
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, Vec::<[f32; 2]>::new());
mesh.insert_indices(Indices::U32(Vec::new()));
mesh
}
/// Collect the per-frame ENTITY caster geometry a lane wants into its buffers. `want_creatures` /
/// `want_environment` select which kinds this caster admits — the CHARACTER lane wants creatures,
/// the WORLD lane wants entity-resident environment (gameobjects, faded doodads, WMO props). Returns
/// `(admitted, occluder-rejected)` for the trace. Range-gates before any vertex work. The `parts`
/// query is written inline (not aliased) so a lane can pass its own identically-typed query.
#[allow(clippy::too_many_arguments)]
pub(crate) fn collect_entity_geometry(
parts: &Query<
(
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
Option<&RigPart>,
&ShadowOccluder,
Option<&MeshMaterial3d<WowModelMaterial>>,
),
Without<BillboardCard>,
>,
rigs: &Query<&RigSkin>,
palettes: &RigPalettes,
want_creatures: bool,
want_environment: bool,
light_position: Vec3,
creature_reach: f32,
doodad_reach: f32,
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) -> (u32, u32) {
let (mut admitted, mut rejected) = (0u32, 0u32);
for (pick, part, global, rig_part, occluder, _material) in parts.iter() {
if !casts_realtime_shadow(part.kind, part.blend, want_creatures, want_environment) {
continue;
}
// The content half of the visibility law (toggles, far clip, distance fade, material
// alpha) — NOT `InheritedVisibility`, which folds camera-orientation verdicts a shadow
// caster must ignore (a tree behind the camera still blocks the sun).
if !occluder.0 {
rejected += 1;
continue;
}
let reach = match part.kind {
ModelKind::Creature | ModelKind::GameObject => creature_reach,
ModelKind::Doodad | ModelKind::Wmo => doodad_reach,
};
let anchor = global.map(GlobalTransform::translation).or_else(|| {
rig_part
.and_then(|rig_part| rigs.get(rig_part.0).ok())
.and_then(|rig| palettes.slot_origin(rig.slot))
});
let Some(anchor) = anchor else { continue };
if anchor.distance_squared(light_position) > reach * reach {
continue;
}
admitted += 1;
let base = positions.len() as u32;
if let Some(rig_part) = rig_part {
append_skinned(pick, rig_part, rigs, palettes, positions);
} else 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 {
append_triangles(&pick.0.indices, added, base, indices);
}
}
(admitted, rejected)
}
/// The per-frame entity caster's admit law. CHARACTERS (creatures/players/mounts) are opaque +
/// alpha-test (hair/fringe cards are small, so casting them solid doesn't box out). Entity-resident
/// ENVIRONMENT (gameobjects, faded doodads, WMO props) is OPAQUE only — a foliage leaf-card cast
/// solid becomes a box; retained-world cutout is cast leaf-shaped by the world lane's alpha-tested
/// material instead. Additive/transparent geometry never casts a solid shadow.
fn casts_realtime_shadow(
kind: ModelKind,
blend: ModelBlend,
want_creatures: bool,
want_environment: bool,
) -> bool {
match kind {
ModelKind::Creature => {
want_creatures && matches!(blend, ModelBlend::Opaque | ModelBlend::AlphaTest)
}
_ => want_environment && matches!(blend, ModelBlend::Opaque),
}
}
fn append_skinned(
pick: &PickMesh,
rig_part: &RigPart,
rigs: &Query<&RigSkin>,
palettes: &RigPalettes,
positions: &mut Vec<[f32; 3]>,
) {
let Ok(rig) = rigs.get(rig_part.0) else {
return;
};
let Some(palette) = palettes.world_palette(rig.slot, rig.bones() as usize) else {
return;
};
for (index, position) in pick.0.positions.iter().enumerate() {
// A vertex without skinning data still gets an entry, or `positions` shortens against the
// index buffer and rewires later triangles. Seat it on the root matrix.
let (Some(joints), Some(weights)) = (pick.0.joints.get(index), pick.0.weights.get(index))
else {
let fallback = palette
.get(0)
.map(|matrix| matrix.transform_point3(wow_to_bevy(*position)))
.unwrap_or(Vec3::ZERO);
positions.push(fallback.to_array());
continue;
};
let mut skinned = Vec3::ZERO;
for lane in 0..4 {
if weights[lane] > 0.0 {
if let Some(matrix) = palette.get(joints[lane] as usize) {
skinned += matrix.transform_point3(wow_to_bevy(*position)) * weights[lane];
}
}
}
positions.push(skinned.to_array());
}
}
#[cfg(test)]
mod tests {
use super::{
append_triangles, casts_realtime_shadow, clamp_shadow_map_size, shadow_reach_extension,
shadow_sun_travel, static_collection_reach, sun_snap_due, RebuildRate,
DEFAULT_SHADOW_DISTANCE, DEFAULT_SHADOW_MAP_SIZE, MAX_CASTER_HEIGHT,
MAX_SHADOW_SUN_ELEVATION, MIN_SHADOW_SUN_ELEVATION, SHADOW_MAP_SIZES, SHADOW_REACH_CAP,
STATIC_REBUILD_STEP, SUN_SNAP_RADIANS,
};
use benilla_formats::ModelBlend;
use benilla_world::model_render::ModelKind;
use bevy::prelude::{Quat, Vec3};
#[test]
fn each_lane_admits_only_its_own_kinds() {
// The character lane wants creatures (opaque + alpha-test), not environment.
assert!(casts_realtime_shadow(ModelKind::Creature, ModelBlend::Opaque, true, false));
assert!(casts_realtime_shadow(ModelKind::Creature, ModelBlend::AlphaTest, true, false));
assert!(!casts_realtime_shadow(ModelKind::Creature, ModelBlend::Blend, true, false));
assert!(!casts_realtime_shadow(ModelKind::Doodad, ModelBlend::Opaque, true, false));
// The world lane wants environment (opaque only), not creatures.
for kind in [ModelKind::GameObject, ModelKind::Doodad, ModelKind::Wmo] {
assert!(casts_realtime_shadow(kind, ModelBlend::Opaque, false, true));
assert!(!casts_realtime_shadow(kind, ModelBlend::AlphaTest, false, true));
}
assert!(!casts_realtime_shadow(ModelKind::Creature, ModelBlend::Opaque, false, true));
}
#[test]
fn the_moving_sun_is_clamped_in_elevation_but_keeps_its_bearing() {
// A SET sun (to-sun below the horizon) is lifted to the floor; travel still points DOWN.
let set = Vec3::new(0.6, -0.5, 0.6).normalize();
let t = shadow_sun_travel(set);
assert!(t.y < 0.0, "travel points down even for a set sun");
assert!(
((-t.y).asin() - MIN_SHADOW_SUN_ELEVATION).abs() < 1e-3,
"a set sun is pinned to the floor elevation"
);
// A near-ZENITH sun is capped so `looking_to(travel, Y)` never degenerates.
let high = Vec3::new(0.02, 0.999, 0.02).normalize();
assert!(
((-shadow_sun_travel(high).y).asin() - MAX_SHADOW_SUN_ELEVATION).abs() < 1e-2,
"a near-zenith sun is capped at the ceiling elevation"
);
// AZIMUTH preserved: a sun bearing due +X throws its travel toward -X.
let east = Vec3::new(1.0, 0.4, 0.0).normalize();
let te = shadow_sun_travel(east);
assert!(
te.x < 0.0 && te.z.abs() < 1e-3,
"travel points away from the sun's horizontal bearing"
);
assert_eq!(shadow_sun_travel(Vec3::ZERO), Vec3::NEG_Z);
}
#[test]
fn the_shadow_sun_snaps_on_first_write_holds_under_drift_and_rearms() {
let held = Vec3::new(0.3, -0.8, 0.52).normalize();
assert!(sun_snap_due(None, held), "first write always fires");
let axis = Vec3::Y;
let small = Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 0.5) * held;
assert!(!sun_snap_due(Some(held), small), "sub-threshold drift holds");
let large = Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 2.0) * held;
assert!(sun_snap_due(Some(held), large), "over-threshold snaps");
assert!(!sun_snap_due(Some(large), large), "identical direction never rewrites");
}
#[test]
fn shadow_reach_follows_the_sun_and_is_capped() {
let diagonal = Vec3::new(1.0, -1.0, 0.0).normalize();
assert!((shadow_reach_extension(diagonal) - MAX_CASTER_HEIGHT).abs() < 1e-3);
assert_eq!(shadow_reach_extension(Vec3::NEG_Y), 0.0);
assert_eq!(shadow_reach_extension(Vec3::NEG_Z), SHADOW_REACH_CAP);
assert!(
static_collection_reach(Vec3::NEG_Y, DEFAULT_SHADOW_DISTANCE)
>= DEFAULT_SHADOW_DISTANCE + STATIC_REBUILD_STEP
);
}
#[test]
fn an_out_of_range_index_drops_its_whole_triangle_and_no_others() {
let mut out = Vec::new();
append_triangles(&[0, 1, 2, 1, 2, 9, 2, 0, 1], 3, 10, &mut out);
assert_eq!(out, vec![10, 11, 12, 12, 10, 11]);
}
/// MONKEY (sun shadow perf): the rate gate's arithmetic, which is the whole of the two lanes'
/// new behaviour. Three properties, each one a way the cap could go wrong in a way you would
/// only notice as a stuttering or frozen shadow:
/// 1. the FIRST call is always due (a lane with no mesh yet must not wait a frame-interval),
/// 2. a due call CONSUMES the slot (otherwise a 30 Hz cap rebuilds every frame forever),
/// 3. the interval is measured from the last GRANT, not from the last ask.
#[test]
fn the_rebuild_gate_paces_from_the_last_grant_and_first_call_is_always_due() {
let mut gate = RebuildRate::default();
assert!(gate.due(10.0, 30), "first call has nothing to show yet");
assert!(!gate.due(10.01, 30), "10 ms later is inside a 33 ms interval");
assert!(!gate.due(10.03, 30), "still inside, measured from the grant");
assert!(gate.due(10.04, 30), "past 1/30 s — due again");
assert!(!gate.due(10.05, 30), "the grant reset the clock");
// A reset (the lane tore its caster down) re-arms immediately.
gate.reset();
assert!(gate.due(10.051, 30));
}
/// `rate == 0` is the documented "every frame" escape hatch, and a backwards clock (a time
/// reset) must read as due rather than as a very long wait — a lane frozen until the clock
/// catches up would look exactly like a broken shadow.
#[test]
fn the_rebuild_gate_has_an_every_frame_arm_and_survives_a_clock_reset() {
let mut gate = RebuildRate::default();
for t in 0..5 {
assert!(gate.due(100.0 + t as f32 * 0.001, 0), "rate 0 never gates");
}
let mut gate = RebuildRate::default();
assert!(gate.due(100.0, 15));
assert!(!gate.due(100.01, 15));
assert!(gate.due(0.0, 15), "a clock that went backwards is due, not stuck");
}
/// The map-size ladder snaps in LOG space, so "halfway" means halfway in ratio (1448 = √2·1024)
/// rather than in texels — and every answer is a power of two, because Bevy silently rounds a
/// non-power-of-two UP and the user would get a bigger, slower map than the one they typed.
#[test]
fn the_shadow_map_size_snaps_onto_the_power_of_two_ladder() {
assert_eq!(clamp_shadow_map_size(2048), 2048);
assert_eq!(clamp_shadow_map_size(1024), 1024);
assert_eq!(clamp_shadow_map_size(4096), 4096);
assert_eq!(clamp_shadow_map_size(0), 1024, "clamped to the floor");
assert_eq!(clamp_shadow_map_size(99_999), 4096, "clamped to the ceiling");
assert_eq!(clamp_shadow_map_size(1500), 2048, "1500 is above the 1448 log midpoint");
assert_eq!(clamp_shadow_map_size(1400), 1024, "1400 < 1448");
assert!(SHADOW_MAP_SIZES.iter().all(|s| s.is_power_of_two()));
assert!(SHADOW_MAP_SIZES.contains(&DEFAULT_SHADOW_MAP_SIZE));
}
}
File diff suppressed because it is too large Load Diff
@@ -2560,6 +2560,9 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
"AudioRowBackgroundSound",
),
("BENILLA_TOOLTIP_BRIGHTNESS", "GraphicsRowBrightness"),
("BENILLA_TOOLTIP_CHARACTER_SHADOWS", "GraphicsRowCharacterShadows"),
("BENILLA_TOOLTIP_WORLD_SHADOWS", "GraphicsRowWorldShadows"),
("BENILLA_TOOLTIP_SHADOW_DISTANCE", "GraphicsRowShadowDistance"),
];
if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) {
assert_eq!(row, *want_row, "{row}: not this row's string");
@@ -2618,7 +2621,9 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// …and Brightness (2182), the FOURTH row whose description is benilla's own rather than 1.12
// GlobalStrings — OPTION_TOOLTIP_GAMMA spends its second sentence on the stock window's
// 21-step grey ramp, which this page does not have (see the guard above). 77 -> 78.
assert_eq!(checked, 78, "every tipped row carries a live key");
// MONKEY (world shadows): +3 for Real Character Shadows, World Shadows and the Shadow
// Distance slider (all benilla-owned keys, carved out in BENILLA_OWNED above). 78 -> 81.
assert_eq!(checked, 81, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -2738,7 +2743,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// own key, which is what makes the count move by exactly the number of rows added.
// …and Weather Intensity (2181) and Brightness (2182), the Graphics page's fourth and fifth
// sliders.
assert_eq!(raised, 78, "every row but Auto Loot raises a description");
// MONKEY (world shadows): +3 for Real Character Shadows, World Shadows and the Shadow
// Distance slider (all benilla-owned keys, carved out in BENILLA_OWNED above). 78 -> 81.
assert_eq!(raised, 81, "every row but Auto Loot raises a description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
+349 -2
View File
@@ -213,9 +213,186 @@ pub(crate) fn boot_windowed_size() -> UVec2 {
/// since 0294): the window literal resolves env-then-file for itself, `load_config` applies the
/// file to this resource at `Startup`, and because both read the same key the reconcile is a no-op
/// rather than a mode change one frame into the run.
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)]
// NB: no `Eq` — `shadow_distance` is an f32 (only `PartialEq` is needed, for `!=` change detection).
#[derive(Resource, Clone, Copy, PartialEq, Debug)]
pub(crate) struct VideoConfig {
pub(crate) vsync: bool,
/// Whether the STATIC WORLD (trees, buildings, foliage) casts realtime shadows and baked MCSH
/// terrain shadows are suppressed. Independent of [`Self::character_shadows`] — either drives
/// the shared shadow rig (`character_shadow` / `world_shadow`).
pub(crate) world_shadows: bool,
/// Whether CHARACTERS (players, NPCs, creatures, mounts) cast realtime silhouettes instead of
/// the legacy oval blob. Independent of [`Self::world_shadows`].
pub(crate) character_shadows: bool,
/// Realtime-shadow render distance in yards (the `shadowDistance` slider) — the shadow-map
/// cascade range + caster reach. Clamped to `shadow_core::SHADOW_DISTANCE_RANGE`.
pub(crate) shadow_distance: f32,
/// MONKEY (sun shadow perf): the directional shadow map's edge in texels (`shadowMapSize`;
/// 1024/2048/4096, default 2048). The rig shipped a 4096 literal, and at 1080p the two sun
/// lanes measured ~5 ms/frame together — a shadow map is quadratic in this number, so halving
/// the edge quarters the pass's fill AND the depth texture (4096² D32 = 64 MB, 2048² = 16 MB).
/// 2048 over one 80 yd cascade is ~26 texels/yd, still finer than the receivers' Gaussian
/// kernel resolves. Applied to Bevy's `DirectionalLightShadowMap` resource, which
/// `extract_lights` re-publishes on change and `prepare_lights` re-keys the texture cache with
/// — so the map is genuinely re-created, live, with no restart.
pub(crate) shadow_map_size: u32,
/// MONKEY (sun shadow perf): the receivers' PCF kernel (`shadowFilter`; 0 = Hardware2x2, one
/// hardware comparison sample, 1 = Gaussian, nine). Default **1** — the current look — because
/// this one is a taste call the user A/Bs, not a free win: `0` is 9× fewer shadow-map fetches
/// per lit fragment (the RECEIVER half of the cost, where `shadowMapSize` is the caster half)
/// at the price of visibly stair-stepped edges. Applied BOTH to the world camera's
/// `ShadowFilteringMethod` (which keys every Bevy-material receiver — terrain, `wow_model`)
/// AND to the retained `static_gx` pipeline's shader def, which is specialized by hand and
/// would otherwise keep whichever branch was compiled in.
pub(crate) shadow_filter: u32,
/// MONKEY (sun shadow perf): Hz cap on the CHARACTER lane's proxy re-skin
/// (`characterShadowRate`, 0..120, default 30; `0` = every frame, the pre-cvar behaviour). The
/// lane CPU-skins every admitted unit and mutates a `Mesh` asset, which costs a full
/// vertex+index re-upload — the character lane's ~3 ms. The shadow MAP is still rendered every
/// frame from the last proxy, so a capped rate does not flicker; it only lets a running NPC's
/// silhouette lag by up to 1/rate s.
pub(crate) character_shadow_rate: u32,
/// MONKEY (sun shadow perf): the same cap for the WORLD lane's per-frame ENTITY caster
/// (`worldShadowRate`, 0..120, default 30) — gameobjects, distance-faded doodads, WMO props.
/// A SEPARATE row from [`Self::character_shadow_rate`] on purpose: its population is nearly
/// static (a swinging lamp, a fading doodad) where the character lane's is animated every
/// frame, so it tolerates a much lower rate — and one dial named for characters silently
/// governing the world lane is the kind of thing nobody finds again. The world lane's STATIC
/// casters are untouched: they already rebuild only on 16 yd camera drift.
pub(crate) world_shadow_rate: u32,
/// MONKEY (sun shadow perf): multiplier on the CASTER-COLLECTION reach (`shadowCasterReach`,
/// 0.25..2, default 1 = unchanged). Collection reaches past the resolve range on purpose (a
/// tree standing outside the cascade still throws a shadow into it), which at `shadowDistance`
/// 80 admits 112 yd of entities and up to 204 yd of statics, with `NoFrustumCulling` on the
/// proxies. Trimming it is the direct dial on caster POPULATION — the input to both lanes'
/// per-rebuild cost — at the risk of a tall caster's shadow popping in as you approach.
pub(crate) shadow_caster_reach: f32,
/// MONKEY (dynamic interiors): WMO interiors + their props light from the room's live fixtures
/// (`interiorLight`) instead of the baked path. The three knobs are `interiorAmbient` (base
/// ambient, 0..1), `interiorFill` (per-fixture bounce gain, 0..2) and `interiorExposure`
/// (light-budget multiplier, 0.25..8) — bridged to benilla-world by `dynamic_interior`.
pub(crate) interior_light: bool,
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 (outdoor torch shadows): whether EXTERIOR fire lights (campfires, braziers,
/// lampposts, bonfires — the point table's exterior half, colour row `.w == 0`) cast real
/// cube-map shadows onto WMO outdoor surfaces, doodads and models AT NIGHT (`exteriorShadows`,
/// default on). Deliberately NOT gated on `interior_light`: the exterior receivers were never
/// part of the dynamic-interior feature and draw identically with it off. By day the lane is
/// inert on both sides — no candidates, no maps, and the receivers' own `night_w` is exactly 0
/// — so this dial has no daylight effect to have.
///
/// It shares `interior_shadow_casters`' sixteen resident cube slots, capped at half of them
/// (`torch_shadow::exterior_budget`) so a village square cannot evict an inn's candles.
pub(crate) exterior_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 lane perf): how often (Hz) the moving-caster mesh is REGATHERED
/// (`interiorShadowEntityRate`, 0..240, default 30; `0` = every frame, the pre-feature
/// behaviour). The gather CPU-skins every admitted unit inside the dynamic fixtures' reach and
/// then MUTATES the aggregate `Mesh` asset, which costs a full vertex+index re-extraction and
/// GPU re-upload plus an `AssetChanged<Mesh3d>` fan-out through material specialisation - a
/// fixed per-frame charge that neither `interiorShadowCasters` nor `interiorShadowDynamic`
/// could reduce (both were measured to change nothing). The six overlay passes still run EVERY
/// frame from the LAST mesh, so lowering this cannot blink a shadow off; it only ages the pose
/// the mesh was gathered at. At 30 Hz on a 46 fps frame that is "regather about two frames in
/// three", and a walking NPC's shadow lags its body by at most one frame's stride.
pub(crate) interior_shadow_entity_rate: u32,
/// MONKEY (torch caster selection): the PCF tap-radius scale for the torch maps
/// (`interiorShadowSoft`, 0.5..3, default **1.5**). 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`, LOW half) rather than a `DynamicInteriors` field, because it
/// belongs to the shadow table's own bytes.
///
/// MONKEY (pcss): it is now the CONTACT radius, not the radius everywhere — the projector's
/// blocker search grows the kernel with the receiver's distance from its caster and clamps at
/// 4x this. So this dial sets how sharp the sharpest edge in the scene is, and 1 (the old
/// default) now reads sharper at a contact than it used to read anywhere; 1.5 restores the
/// shipped softness at a contact and lets the penumbra open up from there.
pub(crate) interior_shadow_soft: f32,
/// MONKEY (shadow floor): how much of the DIRECT term a torch shadow removes
/// (`torchShadowStrength`, 0..1, default **0.7**). A torch map is the only occlusion the
/// direct arm has, so a blocked fragment used to lose all of it — the pitch-black razor-edged
/// "scars" the Darkmoon tents printed on the grass and the Darkshire chairs printed on the inn
/// floor. Nothing in this renderer bounces light, so the 30 % left standing at the default IS
/// the bounce. Rides the torch table's `count.y` HIGH half beside `interior_shadow_soft`, and
/// the receivers fold it into the slot's cross-fade weight (one multiply, no extra tap), so it
/// touches the direct arm only — fill and ambient never saw this factor. `1` restores the
/// shipped look exactly; `0` turns torch shadows off without disturbing the lane behind them.
pub(crate) torch_shadow_strength: 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..4). See
/// [`benilla_world::lighting::DynamicInteriors::debug`].
pub(crate) interior_debug: u32,
/// MONKEY (darkness gains): the exterior night dim (`nightGain`, 0.2..1.5, default **0.8** =
/// nights 20 % darker). Bridged to `DynamicInteriors::night_gain`, which the light packer folds
/// into the packed ambient/diffuse/specular rows on a `mix(1, gain, night_w)` ramp — so it is
/// exactly inert while the sun is up and live the frame it changes after dark.
pub(crate) night_gain: f32,
/// MONKEY (lighting debug panel): the interior dim (`interiorGain`, 0.2..1.5, default **0.5** =
/// room inputs 50 % weaker). Bridged to `DynamicInteriors::interior_gain`, which scales the room
/// lane's INPUTS (base ambient, per-fixture fill, every interior fixture's colour) and not
/// `interiorExposure` — that stays the user's own dial, and this composes with it.
pub(crate) interior_gain: f32,
/// MONKEY (enclosed day floor): the DAYLIGHT floor a room inside a building gets by day
/// (`interiorDaylight`, 0..1, default **0.12**). Bridged to
/// [`benilla_world::lighting::DynamicInteriors::daylight`], packed into the free fraction of
/// the interior lane's on/off word, and added to the room law's ambient budget for batches the
/// record table flags as enclosed. `0` restores the pre-feature look exactly; the night look is
/// unaffected at any value (the term is scaled by the sun's own day envelope).
pub(crate) interior_daylight: f32,
/// MONKEY (bake floor): the share of a WMO interior batch's OWN MOCV bake every interior-lane
/// fragment keeps whether or not a fixture reaches it (`interiorBakeFloor`, 0..1, default
/// **0.12**). Bridged to [`benilla_world::lighting::DynamicInteriors::bake_floor`], packed
/// (times `interiorGain`) into the free fraction of the world-shadow lane, and added to the
/// room law's budget inside its rolloff. It is what stops a room the fixture table cannot
/// reach — the Lion's Pride Inn's east vestibule — rendering black; `0` restores the
/// pre-feature look exactly.
pub(crate) interior_bake_floor: f32,
/// 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,
/// MONKEY (spellLightGain): gain on every light a SPELL EFFECT invented — a kit's aura glow, a
/// missile's core, an impact flash, a firework shell's burst (`spellLightGain`, 0..4; `0` = the
/// spell-light lane off). Bridged to benilla-world's
/// [`benilla_world::lighting::SpellLightGain`] by `dynamic_interior` and applied at PACK time,
/// so it is live. Deliberately NOT folded into `fireLightGain`: a spell light is tagged
/// synthetic too, and one dial over both would mean turning the world's hearths down darkened
/// every fireball in the game.
pub(crate) spell_light_gain: f32,
/// MONKEY (flame flicker): how strongly every FLAME's brightness wobbles (`fireFlicker`, 0..2;
/// `1` = the authored per-kind amplitudes, `0` = the pre-feature steady constants, `2` =
/// doubled). Bridged to `DynamicInteriors::flicker` and applied at PACK time, so it is live —
/// and separate from `fireLightGain`, which scales only the SYNTHESISED lane while a flicker
/// belongs to authored wall torches too.
pub(crate) fire_flicker: f32,
pub(crate) display: DisplayMode,
/// The windowed size, `gxResolution`. Kept while fullscreen so leaving it can restore it.
pub(crate) windowed: UVec2,
@@ -225,6 +402,52 @@ impl Default for VideoConfig {
fn default() -> Self {
Self {
vsync: !novsync_env(),
world_shadows: true,
character_shadows: true,
shadow_distance: crate::shadow_core::DEFAULT_SHADOW_DISTANCE,
// MONKEY (sun shadow perf): 2048, not the rig's old 4096 literal — see the field docs.
shadow_map_size: crate::shadow_core::DEFAULT_SHADOW_MAP_SIZE,
shadow_filter: crate::shadow_core::DEFAULT_SHADOW_FILTER,
character_shadow_rate: crate::shadow_core::DEFAULT_SHADOW_RATE,
world_shadow_rate: crate::shadow_core::DEFAULT_SHADOW_RATE,
shadow_caster_reach: 1.0,
// The cvar defaults are the source of truth at load; these only stand in until then.
interior_light: true,
interior_ambient: 0.015,
interior_fill: 0.08,
interior_exposure: 2.5,
// MONKEY (soft falloff): 2.5, not 1 — see the field doc.
interior_atten_scale: 1.6,
interior_shadows: true,
// MONKEY (outdoor torch shadows): on — a night campfire with no shadow is the thing
// this lane exists to fix, and it costs nothing whenever the sun is up.
exterior_shadows: true,
// MONKEY (static torch cache): 12 resident maps, four moving-caster overlays.
interior_shadow_casters: 12,
interior_shadow_dynamic: 4,
// MONKEY (torch lane perf): 30 Hz - see the field doc.
interior_shadow_entity_rate: 30,
// MONKEY (pcss): 1.5 — see the field doc; `soft` is now the CONTACT radius.
interior_shadow_soft: 1.5,
// MONKEY (shadow floor): 0.7 — a shadow takes 70 % of the direct term, not all of it.
torch_shadow_strength: 0.7,
// MONKEY (room gate): on — without it a building's fixtures light through its own
// floors and walls.
interior_room_gate: true,
interior_debug: 0,
// MONKEY (lighting debug panel): nights 20 % darker, interior inputs 50 % weaker.
night_gain: 0.45,
interior_gain: 0.5,
// MONKEY (enclosed day floor): calibrated so the Goldshire inn's entry floor reads
// ~50 % of the sunlit threshold beside it — see `lighting::DAYLIGHT_LANE_SCALE`.
interior_daylight: 0.0,
// MONKEY (bake floor): an eighth of the authored bake — measured to lift the inn's
// black door band from 0.019 to 0.108 x tex while moving candle-lit surfaces by
// under 10 % (see `lighting::DynamicInteriors::bake_floor`).
interior_bake_floor: 0.12,
fire_light_gain: 1.0,
spell_light_gain: 1.0,
fire_flicker: 1.0,
display: if windowed_env() {
DisplayMode::Windowed
} else {
@@ -284,6 +507,115 @@ pub(crate) fn on_cvar(
// Display mode (1627) — the reference's own polarity: `1` is WINDOWED (the row is
// "Windowed Mode"). `apply_window_mode` pushes it to the window when this moves.
"gxwindow" => cfg.display = display_from_flag(v),
// ── MONKEY (lighting): the dynamic light + shadow system's 29 rows ────────────────────
// They live in THIS observer, and not in one of their own beside `shadow_core` /
// `dynamic_interior`, because of the law the arm above states: *each arm writes only its
// own resource*. Every one of these knobs IS a field of [`VideoConfig`] — the lanes read
// that resource per frame (`shadow_core::update_shadows`, `dynamic_interior::bridge`,
// `torch_shadow`), none of them owns a resource of its own — so a second observer beside
// them would be a second writer of this one resource for no gain, splitting one match
// over two files while dirtying exactly the same thing.
//
// What it DOES cost is the precision of `Res<VideoConfig>::is_changed()`: on upstream's
// struct that signal means "a Video Options row moved", and here it means "a video OR a
// lighting row moved". The one consumer that cares is `apply_present_mode`, which keeps
// 2303's retired value compare for exactly this reason — see its doc.
//
// Clamps are each row's own, stated beside it, exactly as for the reference rows above;
// the `ours(...)` entries in `cvars::REGISTERED` carry the matching defaults, and
// `cvars::tests::registered_defaults_mirror_the_code_truths` welds all 29 pairs.
"worldshadows" => cfg.world_shadows = ev.flag(),
"charactershadows" => cfg.character_shadows = ev.flag(),
"shadowdistance" => {
cfg.shadow_distance = v.clamp(
*crate::shadow_core::SHADOW_DISTANCE_RANGE.start(),
*crate::shadow_core::SHADOW_DISTANCE_RANGE.end(),
)
}
// MONKEY (sun shadow perf): the five cost dials, clamped at the edge like every numeric row
// here. `shadowMapSize` SNAPS onto the power-of-two ladder rather than clamping into a
// range — an off-ladder value is not a weaker setting, it is one Bevy silently rounds UP
// into a bigger and slower map than the one that was typed.
"shadowmapsize" => {
cfg.shadow_map_size = crate::shadow_core::clamp_shadow_map_size(v.max(0.0) as u32)
}
"shadowfilter" => {
cfg.shadow_filter = (v.max(0.0) as u32).min(crate::shadow_core::MAX_SHADOW_FILTER)
}
// `0` is MEANINGFUL on both rate rows (the pre-cvar every-frame rebuild), so they floor at
// 0 rather than at 1 — the shadow off-switches are `characterShadows` / `worldShadows`.
"charactershadowrate" => {
cfg.character_shadow_rate =
(v.max(0.0) as u32).min(crate::shadow_core::MAX_SHADOW_RATE)
}
"worldshadowrate" => {
cfg.world_shadow_rate = (v.max(0.0) as u32).min(crate::shadow_core::MAX_SHADOW_RATE)
}
"shadowcasterreach" => {
cfg.shadow_caster_reach = v.clamp(
*crate::shadow_core::CASTER_REACH_RANGE.start(),
*crate::shadow_core::CASTER_REACH_RANGE.end(),
)
}
// MONKEY (dynamic interiors): the interior lane's on/off + knobs, clamped at the edge like
// every other numeric row. `dynamic_interior::bridge` publishes them to benilla-world.
"interiorlight" => cfg.interior_light = ev.flag(),
"interiorambient" => cfg.interior_ambient = v.clamp(0.0, 1.0),
"interiorfill" => cfg.interior_fill = v.clamp(0.0, 2.0),
"interiorexposure" => cfg.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" => cfg.interior_atten_scale = v.clamp(0.0, 8.0),
"interiorroomgate" => cfg.interior_room_gate = ev.flag(),
"interiorshadows" => cfg.interior_shadows = ev.flag(),
// MONKEY (outdoor torch shadows): a flag like every other checkbox here. Live — the lane
// reads `VideoConfig` every frame, so `0` fades the outdoor shadows out (the slots evict
// through the same cross-fade a walked-away fixture does) and `1` fades them back in.
"exteriorshadows" => cfg.exterior_shadows = ev.flag(),
// 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" => cfg.interior_shadow_casters = (v.max(1.0) as u32).clamp(1, 16),
// MONKEY (static torch cache): the live bank rank, 1..`MAX_TORCH_DYNAMIC`.
"interiorshadowdynamic" => {
cfg.interior_shadow_dynamic =
(v.max(1.0) as u32).clamp(1, crate::torch_shadow::MAX_TORCH_DYNAMIC as u32)
}
// MONKEY (torch lane perf): the moving-caster regather cadence in Hz. `0` is MEANINGFUL
// here (every frame -- the behaviour before the gate), so unlike `casters` this floors at
// 0 rather than at 1. Ceiling 240 so a typo cannot ask for a per-frame rebuild AND a
// divide by a huge number; anything at or above the frame rate is already "every frame".
"interiorshadowentityrate" => {
cfg.interior_shadow_entity_rate = (v.max(0.0) as u32).min(240)
}
"interiorshadowsoft" => cfg.interior_shadow_soft = v.clamp(0.5, 3.0),
// MONKEY (shadow floor): 0 IS meaningful (shadows off), so this floors at 0, not at a
// minimum-useful value; 1 is the pre-feature pitch black.
"torchshadowstrength" => cfg.torch_shadow_strength = v.clamp(0.0, 1.0),
"interiordebug" => cfg.interior_debug = (v.max(0.0) as u32).min(4),
// MONKEY (darkness gains): the two dim dials, clamped at the edge like every numeric row
// here. The floor is 0.2 rather than 0: a true 0 would be indistinguishable from a broken
// light pack (black world / black room), and the off switch people actually want is `1`.
"nightgain" => cfg.night_gain = v.clamp(0.2, 1.5),
"interiorgain" => cfg.interior_gain = v.clamp(0.2, 1.5),
// MONKEY (enclosed day floor): 0 IS meaningful here (it restores the pre-feature look
// exactly), unlike the two dim dials above whose 0 would be a broken-looking world.
"interiordaylight" => cfg.interior_daylight = v.clamp(0.0, 1.0),
// MONKEY (bake floor): 0 IS meaningful here too (it restores the pre-feature look exactly).
// The upper clamp matters more than usual: the packer multiplies this by `interiorGain`
// (up to 1.5) and rides the product in a lane fraction that must stay under 0.5 after
// scaling, so a value that escaped this clamp would reach the world-shadow flag it shares
// a lane with. `pack_bake_lane` clamps the product too — belt and braces, one at each end.
"interiorbakefloor" => cfg.interior_bake_floor = v.clamp(0.0, 1.0),
// MONKEY (fire GO lights): the synthesised-fire gain, clamped at the edge like the rest.
"firelightgain" => cfg.fire_light_gain = v.clamp(0.0, 4.0),
// MONKEY (spellLightGain): the spell lane's gain, same range and same edge clamp — and `0`
// is meaningful here (the lane off) exactly as it is for the fire gain above.
"spelllightgain" => cfg.spell_light_gain = v.clamp(0.0, 4.0),
// MONKEY (flame flicker): 0..2 — the amplitudes are authored at 1, and 2 is the deliberate
// over-drive for judging the shape. Clamped at the edge like every knob here.
"fireflicker" => cfg.fire_flicker = v.clamp(0.0, 2.0),
// ── end MONKEY (lighting) ─────────────────────────────────────────────────────────────
"farclip" => view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()),
// The reference REFUSES an out-of-range write here rather than clamping (`0x688d90`
// echoes "NearClip must be in range 0.01 - 0.33" and returns 0). We clamp, which is the
@@ -723,15 +1055,25 @@ fn display_session() -> String {
/// First sight deliberately does **not** just arm: an inserted resource reads as changed on its
/// first frame, and `load_config` applies the saved value at `Startup`, after the window already
/// exists at its boot mode — so the first run is the one that reconciles them.
///
/// **MONKEY (lighting): the value compare 2303 retired is kept here, on top of `is_changed()`.**
/// Upstream could drop it because on ITS `VideoConfig` every row is a Video Options row, so
/// "the resource moved" and "a display row moved" are the same fact. This branch hangs 29
/// lighting knobs off the same resource (`interiorGain`, `torchShadowStrength`, …), each written
/// by its own arm of [`on_cvar`] — so `is_changed()` alone would let a `SetCVar("interiorGain")`
/// re-assert the present mode, and the inner `!=` guard below would then happily undo exactly the
/// probe override this gate exists to protect (a probe writes `AutoNoVsync` on the window while
/// `cfg.vsync` still says on). `is_changed()` stays as the cheap pre-filter.
fn apply_present_mode(
cfg: Res<VideoConfig>,
restarts: Res<GxRestarts>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
mut last: Local<Option<bool>>,
mut last_restart: Local<u32>,
) {
// A `RestartGx()` re-asserts even when nothing moved — that is what the caller asked for.
let forced = std::mem::replace(&mut *last_restart, restarts.0) != restarts.0;
if !cfg.is_changed() && !forced {
if (!cfg.is_changed() || last.replace(cfg.vsync) == Some(cfg.vsync)) && !forced {
return;
}
let want = present_mode(cfg.vsync);
@@ -861,8 +1203,13 @@ mod tests {
let mut app = App::new();
app.insert_resource(VideoConfig {
vsync: true,
world_shadows: false,
character_shadows: false,
shadow_distance: 80.0,
display: DisplayMode::Fullscreen,
windowed: UVec2::new(1024, 768),
// MONKEY (review fixes): this window test inherits unrelated lighting defaults.
..Default::default()
})
// The plugin's resource, seated by hand because this test runs the one system rather than
// the plugin — `apply_window_mode` reads it to know a `RestartGx()` asked for a re-assert.
+344
View File
@@ -0,0 +1,344 @@
//! The WORLD shadow lane — a thin plug-in on top of [`super::shadow_core`], equal to and independent
//! of [`super::character_shadow`].
//!
//! It owns the shadows cast by the WORLD: the retained `static_gx` geometry (trees + buildings, cast
//! solid), the alpha-tested foliage (leaf canopies, one caster per leaf texture), and the per-frame
//! ENTITY-resident environment (gameobjects, distance-faded doodads, WMO props). When `worldShadows`
//! is on it declares demand to the shared rig and, while the rig is live, builds those casters using
//! the rig's shared material + per-frame facts ([`ShadowFrame`]). It also drives the terrain
//! MCSH-suppression flag ([`WorldShadowActive`]) — baked terrain shadows switch off only for THIS
//! lane. Remove this module + its plugin registration + the `worldShadows` cvar and the character
//! lane is untouched.
//!
//! Cadence: the static world doesn't move, so its solid + cutout casters rebuild only on camera
//! drift ([`STATIC_REBUILD_STEP`]); the environment-entity caster (doodads fade in/out, gameobjects
//! spawn) is collected fresh every frame like the character lane.
use bevy::asset::AssetId;
use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
use bevy::image::Image;
use bevy::light::NotShadowReceiver;
use bevy::mesh::Indices;
use bevy::pbr::{Material, MaterialPipeline, MaterialPlugin, MeshMaterial3d};
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use bevy::reflect::TypePath;
use bevy::render::render_resource::{
AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
};
use bevy::shader::ShaderRef;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::PickMesh;
use benilla_world::lighting::WorldShadowActive;
use benilla_world::model_render::{ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
use benilla_world::static_gx::StaticGx;
use crate::shadow_core::{
collect_entity_geometry, empty_cutout_mesh, empty_shadow_mesh, restore_mesh_buffers,
shadow_trace, spawn_solid_caster, take_mesh_buffers, RebuildRate, ShadowCaster, ShadowDemand,
ShadowFrame, ShadowSet, PLAYER_SHADOW_LAYER, STATIC_REBUILD_STEP,
};
use crate::video::VideoConfig;
pub(crate) struct WorldShadowPlugin;
impl Plugin for WorldShadowPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(MaterialPlugin::<CutoutShadowCasterMaterial>::default())
.init_resource::<WorldLane>()
.add_systems(Last, update_world_shadows.in_set(ShadowSet::Lanes));
}
}
/// The alpha-tested twin of [`ShadowCasterMaterial`]: same invisible-in-forward proxy, but its
/// prepass fragment samples a leaf sheet and discards transparent texels so cutout FOLIAGE casts a
/// leaf-shaped silhouette instead of the solid box a positions-only proxy throws. One instance per
/// distinct leaf texture. `AlphaMode::Mask` sets Bevy's `MAY_DISCARD` key and the EXPLICIT
/// `prepass_fragment_shader` makes the otherwise depth-only directional-shadow pass run a fragment —
/// the exact pair Bevy's prepass specializer requires to alpha-test in the shadow map.
#[derive(Asset, AsBindGroup, TypePath, Debug, Clone)]
struct CutoutShadowCasterMaterial {
/// The leaf sheet, bound at material binding 0/1. Reusing the SAME image handle the forward pass
/// draws makes the sampler inherit that image's clamp/repeat address mode, so the shadow
/// silhouette matches the visible foliage (a cutout card's out-of-range UVs must clamp).
#[texture(0)]
#[sampler(1)]
leaf: Handle<Image>,
}
impl Material for CutoutShadowCasterMaterial {
fn fragment_shader() -> ShaderRef {
// Forward pass: still fully invisible — the shared proxy fragment discards every fragment.
"embedded://benilla_app/shaders/shadow_caster.wgsl".into()
}
fn prepass_fragment_shader() -> ShaderRef {
// EXPLICIT → the shadow pass runs this fragment for the MAY_DISCARD material and alpha-tests.
"embedded://benilla_app/shaders/shadow_caster_cutout_prepass.wgsl".into()
}
fn alpha_mode(&self) -> AlphaMode {
AlphaMode::Mask(benilla_assets::materials::VANILLA_ALPHA_KEY_REF)
}
fn specialize(
_pipeline: &MaterialPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &bevy::mesh::MeshVertexBufferLayoutRef,
_key: bevy::pbr::MaterialPipelineKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
// Two-sided like the solid caster; applies to the shadow (prepass) pipeline too.
descriptor.primitive.cull_mode = None;
Ok(())
}
}
/// One live alpha-tested foliage caster: its layer-31 entity, its per-texture mesh, and the material
/// carrying that one leaf sheet. Keyed by the leaf texture's `AssetId` so it persists across rebuilds.
struct CutoutCaster {
entity: Entity,
mesh: Handle<Mesh>,
material: Handle<CutoutShadowCasterMaterial>,
}
/// The world lane's retained casters.
#[derive(Resource, Default)]
struct WorldLane {
/// The cached static caster — the retained `static_gx` world (trees + buildings), rebuilt on drift.
static_caster: Option<Entity>,
static_mesh: Option<Handle<Mesh>>,
static_rebuilt_at: Option<Vec3>,
/// The alpha-tested foliage casters — one per distinct leaf texture.
cutout: bevy::platform::collections::HashMap<AssetId<Image>, CutoutCaster>,
/// The per-frame ENTITY-resident environment caster (gameobjects, faded doodads, WMO props).
env_caster: Option<Entity>,
env_mesh: Option<Handle<Mesh>>,
env_traced: (u32, u32, u32),
/// MONKEY (sun shadow perf): the `worldShadowRate` cadence gate on the ENVIRONMENT caster only.
/// The static solid + cutout casters keep their own, much coarser cadence (16 yd camera drift).
env_rate: RebuildRate,
}
#[allow(clippy::too_many_arguments)]
fn update_world_shadows(
video: Res<VideoConfig>,
time: Res<Time>,
mut demand: ResMut<ShadowDemand>,
frame: Res<ShadowFrame>,
mut lane: ResMut<WorldLane>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut cutout_materials: ResMut<Assets<CutoutShadowCasterMaterial>>,
// The retained static world — `Option` because it exists only when the retained pass is armed.
gx: Option<Res<StaticGx>>,
// The terrain MCSH-suppression flag (this lane owns it).
mut world_active: ResMut<WorldShadowActive>,
parts: Query<
(
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
Option<&RigPart>,
&ShadowOccluder,
Option<&MeshMaterial3d<WowModelMaterial>>,
),
Without<BillboardCard>,
>,
rigs: Query<&RigSkin>,
palettes: Res<RigPalettes>,
) {
// Declare demand so the shared rig stays up while this lane is on (rig reads it next frame).
demand.0 = demand.0 || video.world_shadows;
// The lane is casting when the rig is live AND its cvar is on. The MCSH terrain-shadow
// suppression keys on exactly this (a character-only shadow sun must leave baked MCSH alone).
let world_on = frame.active && video.world_shadows;
if world_active.0 != world_on {
world_active.0 = world_on;
}
if !world_on {
teardown(&mut lane, &mut commands, &mut meshes, &mut cutout_materials);
return;
}
let Some(material) = frame.material.clone() else {
return;
};
// (1) The per-frame ENTITY-resident environment caster (opaque doodads/gameobjects/WMO props).
if lane.env_caster.is_none() {
let mesh = meshes.add(empty_shadow_mesh());
let entity = spawn_solid_caster(&mut commands, mesh.clone(), material.clone());
lane.env_caster = Some(entity);
lane.env_mesh = Some(mesh);
// A brand-new caster is an EMPTY mesh; make sure this frame fills it.
lane.env_rate.reset();
}
// MONKEY (sun shadow perf): the `worldShadowRate` gate — the world lane's twin of the character
// lane's. Only THIS caster is paced: the static solid + cutout casters below already rebuild on
// 16 yd of camera drift and cost nothing on a standing frame. Skipping leaves the previous
// environment proxy in place (the map is still rendered from it every frame), so the only
// visible effect is a swinging lamp's or a fading doodad's shadow lagging by up to 1/rate s.
//
// Its own cvar rather than sharing `characterShadowRate`: this population barely moves, so it
// tolerates a far lower rate than an animated crowd does, and a dial named for characters that
// silently also governs the world is a trap for whoever reads this next.
let env_due = lane.env_rate.due(time.elapsed_secs(), video.world_shadow_rate);
if let Some(handle) = lane.env_mesh.clone().filter(|_| env_due) {
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,
false, // want_creatures (the character lane's job)
true, // want_environment
frame.light_position,
frame.entity_reach,
frame.tall_reach,
&mut positions,
&mut indices,
);
let tris = (indices.len() / 3) as u32;
restore_mesh_buffers(mesh, positions, indices);
if shadow_trace() {
let now = (tris, admitted, rejected);
if lane.env_traced != now {
info!(
"shadow-trace: world-entity {} tris | env admitted {} / rejected {} (was {:?})",
tris, admitted, rejected, lane.env_traced
);
lane.env_traced = now;
}
}
}
}
// (2) The cached static world (trees + buildings) + alpha-tested foliage — rebuilt on drift.
let Some(gx) = gx.as_ref() else {
return;
};
// Ensure the static SOLID caster.
if lane.static_caster.is_none() {
let mesh = meshes.add(empty_shadow_mesh());
let entity = spawn_solid_caster(&mut commands, mesh.clone(), material);
lane.static_caster = Some(entity);
lane.static_mesh = Some(mesh);
lane.static_rebuilt_at = None;
}
let due = lane
.static_rebuilt_at
.map_or(true, |at| at.distance(frame.light_position) > STATIC_REBUILD_STEP);
if !due {
return;
}
// Solid pass — the retained world's opaque geometry (minus the cutout foliage it skips).
if let Some(handle) = lane.static_mesh.clone() {
if let Some(mesh) = meshes.get_mut(&handle) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
gx.append_shadow_triangles(
frame.light_position,
frame.tall_reach,
&mut positions,
&mut indices,
);
let tris = (indices.len() / 3) as u32;
restore_mesh_buffers(mesh, positions, indices);
lane.static_rebuilt_at = Some(frame.light_position);
if shadow_trace() {
info!(
"shadow-trace: static rebuild — {} tris within {:.0}yd",
tris, frame.tall_reach
);
}
}
}
// Alpha-tested foliage pass — the cutout leaf cards, grouped one caster per leaf texture.
let buckets = gx.collect_cutout_shadow_triangles(frame.light_position, frame.tall_reach);
let mut seen: HashSet<AssetId<Image>> = HashSet::new();
let mut cutout_tris = 0u32;
for bucket in buckets {
seen.insert(bucket.texture_id);
cutout_tris += (bucket.indices.len() / 3) as u32;
if !lane.cutout.contains_key(&bucket.texture_id) {
let mesh = meshes.add(empty_cutout_mesh());
let material = cutout_materials.add(CutoutShadowCasterMaterial {
leaf: bucket.texture.clone(),
});
let entity = commands
.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(material.clone()),
Transform::IDENTITY,
Visibility::Visible,
RenderLayers::layer(PLAYER_SHADOW_LAYER),
NoFrustumCulling,
NotShadowReceiver,
ShadowCaster,
))
.id();
lane.cutout
.insert(bucket.texture_id, CutoutCaster { entity, mesh, material });
}
let mesh_handle = lane.cutout[&bucket.texture_id].mesh.clone();
if let Some(mesh) = meshes.get_mut(&mesh_handle) {
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, bucket.positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, bucket.uvs);
mesh.insert_indices(Indices::U32(bucket.indices));
}
}
// Despawn casters whose leaf texture left the reach, so the set stays bounded across zones.
let stale: Vec<AssetId<Image>> = lane
.cutout
.keys()
.filter(|id| !seen.contains(*id))
.copied()
.collect();
for id in stale {
if let Some(caster) = lane.cutout.remove(&id) {
commands.entity(caster.entity).despawn();
meshes.remove(caster.mesh.id());
cutout_materials.remove(caster.material.id());
}
}
if shadow_trace() {
info!(
"shadow-trace: cutout rebuild — {} tris across {} leaf textures",
cutout_tris,
lane.cutout.len()
);
}
}
/// Tear down every world-lane caster (static solid, all foliage casters, the environment caster).
fn teardown(
lane: &mut WorldLane,
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
cutout_materials: &mut Assets<CutoutShadowCasterMaterial>,
) {
if let Some(entity) = lane.static_caster.take() {
commands.entity(entity).despawn();
}
if let Some(handle) = lane.static_mesh.take() {
meshes.remove(handle.id());
}
lane.static_rebuilt_at = None;
for (_texture, caster) in lane.cutout.drain() {
commands.entity(caster.entity).despawn();
meshes.remove(caster.mesh.id());
cutout_materials.remove(caster.material.id());
}
if let Some(entity) = lane.env_caster.take() {
commands.entity(entity).despawn();
}
if let Some(handle) = lane.env_mesh.take() {
meshes.remove(handle.id());
}
// The mesh the gate was pacing is gone — re-arm so a re-enable builds on its first frame.
lane.env_rate.reset();
}
+3
View File
@@ -57,6 +57,9 @@ mod m2;
pub use m2::{
EmitterBillboard, M2Model, M2ModelLoader, M2SequenceInfo, ModelEmitter, ModelLight,
ModelRibbon, PortraitCamera,
// MONKEY (spell light): what a spell/firework-derived light carries beyond the light itself
// (its school and its onset) — see [`ModelLight::spell`].
SpellLightInfo,
};
mod wmo;
pub use benilla_formats::{WmoPortalInfo, WmoPortalRef};
+175 -1
View File
@@ -367,6 +367,59 @@ 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,
/// MONKEY (flame flicker): `true` when the synthesis took the **FLAME** route — an additive
/// fire particle emitter ([`benilla_formats::synthesize_fire_light`]) — as opposed to the LAMP
/// route ([`benilla_formats::synthesize_lamp_light`]: a lamppost's unlit glass geoset). Always
/// `false` on an authored block.
///
/// `synthetic` alone cannot answer this, and the difference is exactly what decides whether the
/// light FLICKERS: an open flame does, and a flame of any colour does (the ogre's purple wall
/// torch and `HumanBrazierMagic`'s green fire are fires with odd chemistry); a lamp behind
/// glass does not, and a street lamp that breathed would read as a fault in the city rather
/// than as fire. Keying on the ROUTE rather than on the hue is what gets both right — see
/// `benilla_world::lighting::flame_kind_for`.
pub flame: bool,
/// MONKEY (spell light): `true` when this light came from the **SPELL** route
/// ([`benilla_formats::synthesize_spell_light`]) — a spell effect's or a firework's emitter.
/// Always `false` on an authored block and on the two world routes.
///
/// It exists so the PLACED lanes can keep refusing spell content exactly as they did before
/// this feature (`benilla_world::terrain_stream::spawn`'s `spawn_lights_for`): the world light
/// table is built for fixtures that stand still for minutes, and an effect model that happens
/// to be placed as scenery must not enter it. Only the ENTITY lanes — a spell-visual instance,
/// a missile, a firework GameObject — take it, and there it is budgeted, enveloped and reaped
/// with its effect (`entities::carried_light`'s spell-light helper).
///
/// It also carries the light's **onset**: `benilla_formats::fire_light::emit_onset`, the delay
/// before the emitter this light stands for actually fires. A firework's shell detonates half
/// a second into its model's clip, and a light lit at spawn would flash the rocket's flight
/// instead of its burst.
pub spell: Option<SpellLightInfo>,
}
/// MONKEY (spell light): what a spell/firework-derived [`ModelLight`] carries beyond the light
/// itself — see [`ModelLight::spell`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpellLightInfo {
/// The school judged at synthesis ([`benilla_formats::SpellLightKind`]) — for the trace and
/// for any future per-school gain. Never `None` (that case yields no light at all).
pub kind: benilla_formats::SpellLightKind,
/// Seconds from the instance's birth before the light comes up (the emitter's rate-track
/// onset). `0.0` for everything with a constant emission rate, which is every missile and
/// every kit glow.
pub onset: f32,
}
/// Bevy [`AssetLoader`] decoding `*.m2` → [`M2Model`].
@@ -549,7 +602,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
@@ -557,8 +610,129 @@ impl AssetLoader for M2ModelLoader {
.get(def.bone as usize)
.map_or([0.0; 3], |b| b.pivot),
def,
synthetic: false,
flame: false,
spell: None,
})
.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 —
// plus, MONKEY (flame flicker), WHICH route won, because that is what decides whether
// the light burns (flickers) or merely shines (see [`ModelLight::flame`]).
//
// MONKEY (spell light): and a THIRD route ahead of both — SPELL effects and FIREWORKS,
// which the two world rules veto by path (`fire_light::is_spell_path`). They are put
// first because the veto is theirs: a model that IS spell content can never be a
// placed prop, so there is nothing for the other two to say about it. What comes back
// is flagged ([`ModelLight::spell`]) so the PLACED lanes can keep refusing it exactly
// as they do today — only the entity lanes, which reap a light with its effect, take
// one.
let synth = benilla_formats::synthesize_spell_light(
&path,
emitters.iter().map(|e| &e.def),
)
.map(|fx| {
let src = &emitters[fx.emitter].def;
(
src.position,
src.bone,
fx.color,
fx.intensity,
// Never a FLAME for the flicker's purposes: a spell light runs its own
// lifecycle envelope (ramp in / hold / decay) and a fire wobble on top of it
// would read as the effect stuttering, not as fire breathing.
false,
Some(SpellLightInfo {
kind: fx.kind,
onset: fx.onset,
}),
)
})
.or_else(|| {
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,
true,
None,
)
},
)
})
.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, false, None))
});
if let Some((position, src_bone, color, intensity, flame, spell)) = 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,
flame,
spell,
});
}
}
let (skeleton, inverse_bindposes) = build_skeleton(&skeleton_raw);
let inverse_bindposes = ctx.add_labeled_asset(
"inverse_bindposes".to_string(),
+134 -2
View File
@@ -36,9 +36,9 @@ use bevy::render::render_resource::{
AsBindGroup, BlendComponent, BlendFactor, BlendOperation, BlendState, Buffer, ColorWrites,
CompareFunction, RenderPipelineDescriptor, SpecializedMeshPipelineError,
};
use bevy::shader::ShaderRef;
use bevy::shader::{load_shader_library, ShaderRef};
/// Compile the four WGSL files into the binary and register them under
/// Compile the WGSL files into the binary and register them under
/// `embedded://benilla_assets/shaders/…`. Call **after** Bevy's `AssetPlugin` (it fills the
/// registry that plugin creates); [`crate::register_asset_loaders`] already does.
pub fn register_shaders(app: &mut App) {
@@ -46,6 +46,10 @@ pub fn register_shaders(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/wow_model.wgsl");
bevy::asset::embedded_asset!(app, "shaders/wdl.wgsl");
bevy::asset::embedded_asset!(app, "shaders/liquid.wgsl");
// MONKEY (shadow hook): register the importable shadow-contribution library `benilla::shadow_hook`
// (loaded eagerly so `#import benilla::shadow_hook` resolves in terrain/model/static_gx). This is
// the ONE place the realtime directional-shadow term lives; the receivers just call into it.
load_shader_library!(app, "shaders/shadow_hook.wgsl");
}
/// The WDL far-band shader's source, for the law tests that live beside the renderer rather than
@@ -224,6 +228,43 @@ pub struct WowModelExt {
/// Set once, never mutated.
#[storage(90, read_only, buffer, visibility(vertex, fragment))]
pub light_buf: Buffer,
/// MONKEY (torch shadows Phase 3A): the interior torch depth-map ARRAY — the one shared
/// `Depth32Float` 512×512×24 `Image` (4 fixtures × 6 cube faces) the torch depth node renders
/// into each frame (`benilla_world::static_gx::torch_depth`, `TorchDepthImage`). Bound as a
/// depth 2D-array + the image's own `GreaterEqual` comparison sampler (the `Image`'s
/// `ImageSamplerDescriptor { compare: Some(GreaterEqual) }` — bevy builds a real comparison
/// sampler from it; the derive's prepare-time check requires the format's sample type to be
/// `Depth`, which `Depth32Float` is). The image is created ONCE at app startup regardless of
/// the cvars — an absent image would stall every model material's bind group on
/// `RetryNextUpdate` and blank every model. Fragment-only, like static_gx's group 3.
#[texture(91, dimension = "2d_array", sample_type = "depth", visibility(fragment))]
#[sampler(92, sampler_type = "comparison", visibility(fragment))]
pub torch_depth: Handle<Image>,
/// 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 6416 bytes: every
/// member is 16-aligned). Set once, never mutated.
#[storage(93, read_only, buffer, visibility(fragment))]
pub torch_buf: Buffer,
}
/// MONKEY (torch shadows Phase 3A): the two torch-receiver bindings every [`WowModelExt`] shares —
/// threaded into the material builders beside the shared light `Buffer`, and stored on
/// `WorldAssets` the same way. Both come from the client's startup resources
/// (`benilla_world::static_gx::{TorchDepthImage, SharedTorchBuffer}`); this crate only clones them
/// into materials, which is why it takes the raw handle + buffer and not those resources (the same
/// severing `WorldAssets::open` does for the light buffer, decision 1164).
#[derive(Clone)]
pub struct TorchBinds {
/// The shared torch depth array image (→ `WowModelExt::torch_depth`).
pub depth: Handle<Image>,
/// The shared 6416-byte torch table buffer (→ `WowModelExt::torch_buf`).
pub table: Buffer,
}
impl MaterialExtension for WowModelExt {
@@ -259,6 +300,12 @@ impl MaterialExtension for WowModelExt {
// at 10/11. Bevy's own SKINNED branch never fires for these meshes — that's the point.
if layout.0.contains(crate::ATTRIBUTE_WOW_JOINT_INDEX) {
descriptor.vertex.shader_defs.push("WOW_RIG_SKIN".into());
// The FRAGMENT stage keys on the same def: a skinned unit takes the whole-unit
// ground-point shadow sample (one fetch at its rig origin) instead of the
// per-fragment fetch, which would read the unit's own caster copy as an occluder.
if let Some(fragment) = descriptor.fragment.as_mut() {
fragment.shader_defs.push("WOW_RIG_SKIN".into());
}
let mut attrs = Vec::with_capacity(7);
for (attr, loc) in [
(Mesh::ATTRIBUTE_POSITION, 0),
@@ -638,6 +685,24 @@ pub struct TerrainExtension {
/// `terrain.wgsl` reads it as `var<storage, read> wow_light` (rows 0-5: light + fog + farclip).
#[storage(90, read_only, buffer)]
pub light_buf: Buffer,
/// MONKEY (outdoor torch shadows: terrain): the torch receiver's three bindings, at the SAME
/// numbers [`WowModelExt`] uses (91 depth array / 92 comparison sampler / 93 table) and for the
/// same reason - a Bevy material draw sets groups 0/1/2 only, so a receiver's shadow inputs have
/// to ride its own material group. Both resources are created ONCE at startup
/// (`benilla_world::assets`, beside the shared light buffer) and are ALWAYS bound: a missing
/// image would stall the tile's bind group on `RetryNextUpdate` and blank the GROUND, so the
/// spawn site idles until `TorchShared::binds()` is `Some` rather than baking a default.
///
/// Fragment-only, like the splat/alpha/shadow arrays above (Bevy 0.18 narrows textures and
/// samplers but not uniforms) - the vertex stage only picks WHICH lights, it never samples.
#[texture(91, dimension = "2d_array", sample_type = "depth", visibility(fragment))]
#[sampler(92, sampler_type = "comparison", visibility(fragment))]
pub torch_depth: Handle<Image>,
/// The 6416-byte `TorchTableUniform` bytes (count / positions[16] / view_projs[96]) as the same
/// shared raw storage buffer the model lane binds, rewritten in place each frame - read by
/// `terrain.wgsl` as the byte-identical `var<storage, read> torch_table: TorchTable`.
#[storage(93, read_only, buffer, visibility(fragment))]
pub torch_buf: Buffer,
}
impl MaterialExtension for TerrainExtension {
@@ -653,6 +718,73 @@ impl MaterialExtension for TerrainExtension {
#[cfg(test)]
mod tests {
/// MONKEY (outdoor torch shadows: terrain): the GROUND receiver's three contracts with the
/// model lane, all of which fail SILENTLY at runtime rather than at compile time.
///
/// 1. **The bindings.** `TerrainExtension` and [`WowModelExt`] must claim the SAME numbers
/// (91 depth / 92 comparison sampler / 93 table). The Rust attributes and the WGSL `@binding`
/// lines are two independent declarations of one layout, and a mismatch is not a build error
/// — it is a bind-group validation failure at first tile spawn, i.e. no ground.
/// 2. **The table layout.** All three receivers (`terrain.wgsl`, `wow_model.wgsl`, and
/// `static_gx.wgsl`'s std140 twin) mirror ONE 6416-byte buffer. Comments differ per lane on
/// purpose; the member lines must not, or terrain reads matrices at the wrong offsets and
/// projects shadows to the wrong place with nothing in any log.
/// 3. **Daytime identity.** The night lane must stay behind `ext_night_w > 0.0`. `fog_params.z`
/// saturates to exactly 1.0 above the daylight threshold, so that guard is what makes the
/// day render the same instructions AND the same bits it was before this feature — the whole
/// "cost zero by day" claim, in one line of shader.
#[test]
fn the_terrain_lane_receives_the_torch_maps() {
let terrain = include_str!("shaders/terrain.wgsl");
let model = include_str!("shaders/wow_model.wgsl");
for binding in [
"@binding(91) var torch_depth: texture_depth_2d_array;",
"@binding(92) var torch_samp: sampler_comparison;",
"@binding(93) var<storage, read> torch_table: TorchTable;",
] {
assert!(
terrain.contains(binding) && model.contains(binding),
"the terrain and model torch receivers disagree on `{binding}` — the WGSL and the \
AsBindGroup attributes are two declarations of one layout, and this one blanks \
the ground at the first tile spawn"
);
}
// The struct's MEMBER lines only: each copy carries its own prose about its own lane.
let layout = |src: &str| -> Vec<String> {
let body = src
.split_once("struct TorchTable {")
.expect("every torch receiver declares the table")
.1;
body.split_once("\n}")
.expect("the struct is closed")
.0
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("//"))
.map(str::to_owned)
.collect()
};
assert_eq!(
layout(terrain),
layout(model),
"the terrain and model `TorchTable` copies have drifted — they mirror ONE 6416-byte \
buffer (count / positions[16] / view_projs[96]); a member added, reordered or resized \
on one side moves every matrix offset on that side only"
);
assert!(
terrain.contains(
"let ext_night_w = select(0.0, clamp(1.0 - sun_shadow_strength, 0.0, 1.0), \
torch_ext_on());"
) && terrain.contains("if (ext_night_w > 0.0) {"),
"terrain's exterior torch lane is no longer gated on `ext_night_w > 0.0` — daylight is \
now paying for (and possibly rendering) the night blend, and the day look is free to \
drift by a rounding step"
);
}
/// The sky depth law, for the one sky element that draws on the MODEL lane: the WMO skybox
/// ([`WowModelKey::sky_depth`]). Every other sky shader is checked the same way, together, in
/// `benilla_world::sky_order::the_sky_depth_is_pinned_at_the_vertex_and_nowhere_else` — this
@@ -0,0 +1,468 @@
#define_import_path benilla::shadow_hook
// MONKEY (shadow extension hook): the ONE place the realtime directional-shadow term is computed.
//
// benilla's receivers — `terrain.wgsl`, `wow_model.wgsl`, `static_gx.wgsl` — call `realtime_shadow`
// instead of each hand-rolling the fetch + fades. Adding or changing the realtime shadow now touches
// THIS file only; the three receiver shaders (the ones that blanked the terrain when edited by hand)
// stay untouched. The receivers keep their own downstream gating (terrain's MCSH suppression + spec
// gate, the model's interior/rig-skin exclusion + ambient-preserving apply, static_gx's ambient arm)
// — those are lighting composition, not shadow sampling, so they belong to each receiver.
//
// It is a no-op whenever no shadow sun exists: the only Bevy `DirectionalLight` benilla ever spawns
// is the shadow rig's (`benilla_app::shadow_core`), so `lights.n_directional_lights == 0` exactly
// when shadows are off, and the loop below never runs. No shader-def toggle is needed.
#import bevy_pbr::{
shadows,
mesh_view_bindings::lights,
}
// The realtime shadow lightens over the last SHADOW_EDGE_BAND yards of the cascade's max distance so
// it fades in rather than popping at the resolve boundary.
const SHADOW_EDGE_BAND: f32 = 14.0;
// The realtime directional-shadow factor at `sample_pos`: 1.0 = lit, 0.0 = fully shadowed.
//
// - `sample_pos` world-space point to sample the shadow map at — a per-fragment position, or a
// unit's nudged rig anchor (the caller chooses; that is the rig-skin decision).
// - `normal` receiver normal, for the slope-aware depth bias.
// - `view_z` view-space z, for cascade selection.
// - `cam_dist` distance from the camera to the receiver fragment, for the EDGE fade.
// - `shadow_range` the cascade's max distance (the `shadowDistance` slider) — where the edge fade ends.
// - `night` the sun-height strength (0 at night .. 1 by day) — the NIGHT fade.
//
// Both fades lighten the result toward 1.0 (no shadow). The caller passes `shadow_range`/`night`
// from its own light buffer (the receivers declare that buffer three incompatible ways, so the hook
// takes the scalars as arguments rather than reading it).
fn realtime_shadow(
sample_pos: vec4<f32>,
normal: vec3<f32>,
view_z: f32,
cam_dist: f32,
shadow_range: f32,
night: f32,
) -> f32 {
var shadow = 1.0;
if (lights.n_directional_lights > 0u) {
for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
shadow = shadows::fetch_directional_shadow(light_id, sample_pos, normal, view_z);
break;
}
}
}
let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist);
return 1.0 - (1.0 - shadow) * night * (1.0 - edge_fade);
}
// MONKEY (torch shadows Phase 1): the point/cluster-based torch shadow is DEAD. benilla's world
// camera sets `ClusterConfig::None`, which starves `clusterable_objects` AND the point-shadow prep,
// so `fetch_point_shadow`/the clusterable scan can never fire. The replacement is a from-scratch
// depth map (`benilla_world::static_gx::torch_depth` + `torch_map_shadow` below), sampled by the
// static_gx interior lane through its own group-3 bindings, and (Phase 3A) by `wow_model.wgsl`'s
// entity lane through its material's own bindings 91/92/93 — both call `torch_map_shadow` below.
// These two functions are kept only so `interior_debug_override` (mode 2) compiles; they return
// "unshadowed" and no receiver's lighting path calls them any more.
fn torch_shadow(sample_pos: vec4<f32>, normal: vec3<f32>) -> f32 {
return 1.0;
}
fn torch_shadow_for(light_pos: vec3<f32>, sample_pos: vec4<f32>, normal: vec3<f32>) -> f32 {
return 1.0;
}
// MONKEY (torch shadows Phase 1): the old clusterable-caster count diagnostic. The clustering is
// dead (see above), so this is now a constant 0 — kept only so `interior_debug_override` compiles.
fn torch_caster_count() -> u32 {
return 0u;
}
// MONKEY (pcss): the contact-hardening constants.
//
// `TORCH_DEPTH_B` is the reverse-Z projection's depth OFFSET, mirrored from
// `benilla_app::torch_shadow::{reverse_z_perspective, cube_view_projs}` (near 0.1, far TORCH_RANGE
// 48 — every matrix this function is ever handed is one of those six cube faces). Under reverse-Z
// the stored depth is an AFFINE function of the RECIPROCAL distance along the face axis:
// `z = A/t + B`, `A = near*far/(far-near)`, `B = -near/(far-near)`. So `z - B` is proportional to
// `1/t`, and the one quantity a penumbra needs — the ratio of two distances — is
// `(z_blocker - B) / (z_receiver - B)` with `A` cancelling: no linearisation, no extra uniform, one
// subtraction per term. A constant rather than something decoded out of `view_proj` because the
// w-row of a look-at product carries the eye translation, so `B` cannot be read off a single
// element of that matrix without dividing two of them — and a wrong `B` would silently mis-size
// every penumbra instead of failing.
const TORCH_DEPTH_B: f32 = -0.1 / 47.9;
// How far out (in `soft` texels) the blocker search looks. Two: wide enough to find the caster of
// an edge a texel or two away, narrow enough that a fragment well inside a lit area still reads
// four empty texels and takes the cheap path.
const TORCH_PCSS_SEARCH: f32 = 2.0;
// Penumbra gain — extra `soft` radii per unit of `(d_receiver - d_blocker)/d_blocker`. A receiver
// twice as far from the fixture as its blocker (ratio 1) is filtered three times as wide; the
// Darkmoon tent (post ~2 yd from the torch, grass ~10) saturates the clamp below, and a chair leg
// resting ON the floor (ratio ~0.05) stays within 10 % of the hard kernel — the whole point.
const TORCH_PCSS_K: f32 = 2.0;
// …clamped at four radii. Past that the 8 taps are too sparse for the area they cover and the
// penumbra starts to band, and the shadow there is faint enough that widening it further is
// invisible anyway.
const TORCH_PCSS_MAX: f32 = 4.0;
// Above this filter radius (texels) the 4-tap box is too sparse and earns the second ring.
const TORCH_PCSS_WIDE: f32 = 1.5;
// MONKEY (slope bias): the per-tap RECEIVER-PLANE correction's ceiling, in multiples of the
// constant `bias`. A PCF/PCSS tap `n` texels off centre lands on a piece of the SAME surface whose
// stored depth differs by the receiver plane's own depth gradient times `n`; `torch_map_shadow`
// walks that tap's compare reference along the plane by exactly that much, so a tap on the
// receiver's own plane compares equal-to-equal and can never fail, at any grazing angle. What the
// correction must NOT do is walk so far that a tap on a real OCCLUDER also passes - that is
// peter-panning, a shadow lifting off its contact - so it is clamped at `TORCH_SLOPE_MAX * bias`.
//
// THE ARITHMETIC - and a CORRECTION to round 3's. The receivers' `TORCH_NORMAL_OFFSET` note prices
// all of this in "yards of RAY distance", which is not what `textureSampleCompareLevel` compares:
// the stored value is `ndc.z` on the cube FACE the fragment lands in, and under a perspective
// projection that is a function of the VIEW-SPACE Z - the component along the FACE AXIS - not of
// the ray length. Two things follow that the round-3 table cannot show:
//
// · a floor directly under a fixture lands in the DOWN face, where its view-space Z is the
// constant `h`, so its stored depth is CONSTANT across that whole face and there is no
// gradient to clear at all - acne is impossible there. It begins the moment `r > h` and the
// fragment crosses into a SIDE face, whose view-space Z is the HORIZONTAL distance and along
// which the floor runs away from the eye. On a side face the gradient works out to `2A/(f*h)`
// per unit uv (`A = near*far/(far-near)`, `f = 1/tan(fov/2)`) - i.e. it depends on the
// fixture's HEIGHT and not at all on `r`: the same at 3 yd out as at 30.
// · the note's normal-offset term `0.15*(h/t)` is INVERTED. Moving the sample point 0.15 yd up
// off a grazing floor moves the place where its own shadow ray meets that floor by
// `0.15*(t/h)` - amplified by the grazing angle, not reduced by it. So the offset is worth
// about `(t/h)^2` times what round 3 credited it with, which is why a knee-high fixture's pool
// was in fact clean to ~10 yd rather than to ~2.1.
//
// Re-measured exactly (scratchpad `slopebias_exact.py`: the real reverse-Z cube face, the real
// `torch_face` pick on the OFFSET point, the widened kernel's 8 taps at 3 and 6 texels, each
// SNAPPED to its texel centre, compared against the real floor plane). BREAK RADIUS = how far a
// fixture `h` yd above a flat floor lights before its own worst tap starts to fail:
//
// h (yd) before after
// 4.0 clean clean a chandelier, a lamppost head
// 3.0 clean clean a WALL TORCH - always was fine, and stays fine
// 2.0 25.8 yd clean a brazier
// 1.5 14.1 yd clean
// 1.0 10.1 yd clean the IMP's hand flame - the reported case
// 0.5 7.3 yd clean a candle standing on the floor
// 0.25 7.1 yd 11.7 yd CLAMPED - see below
//
// "clean" = no tap fails anywhere out to the projection's usable 46 yd, i.e. the acne is GONE and
// not merely pushed outward. Where a tap was already passing the margin simply widens, at h = 1 yd:
// +3.5e-4 -> +2.3e-3 at r = 9, +1.7e-3 -> +3.6e-3 at r = 5; and where it was failing, -5.1e-4 ->
// +1.5e-3 at r = 15. The h = 3 wall-torch rows move 1.0x-2.7x in the same direction and never
// change verdict, which is the "forge floor unchanged, still attached at contact" requirement.
//
// The BLOCKER SEARCH matters as much as the taps. At h = 1, r = 15 its own 3-texel taps were
// self-detecting (-5.1e-4). That darkens nothing, but it hands `TORCH_PCSS_K` a blocker ratio made
// entirely of bias error - a pool going soft with nothing in it. Corrected: +1.5e-3.
//
// WHY 4 AND NOT MORE. The correction the outermost tap WANTS is 2.8e-3 at h = 1 and 6.8e-3 at
// h = 0.5, against the `4*bias` = 4e-3 ceiling: free at h = 1, clamped at h = 0.5 - and clamped is
// still enough there (+3.5e-3 at r = 9). It only falls short under a QUARTER-yard fixture, a light
// effectively lying on the ground, where the surface is near-edge-on to it, `max(N.L, 0)` has
// already taken the term toward zero, and refusing to move further is the conservative answer. A
// TILTED receiver wants LESS, not more (2.7e-4 at 45 deg, 3.0e-4 at 75 deg: the face pick swings
// back to the DOWN face and the gradient collapses), so raising the ceiling would buy nothing
// anywhere except the one case where it is deliberately declining to act, while starting to lift
// genuine contact shadows - the single failure mode a shadow bias must never have.
const TORCH_SLOPE_MAX: f32 = 4.0;
// MONKEY (slope bias): ONE tap's compare reference - the centre reference walked along the RECEIVER
// PLANE by `dot(grad, off)` and bounded at +/-`lim`. `grad` is `d(ndc.z)/d(uv)` restricted to that
// plane (built once per call in `torch_map_shadow`), `off` is the tap's uv displacement from the
// centre. Three ALU and a clamp, no texture read. It is a function rather than four inline
// expressions so a tap's OFFSET and its REFERENCE cannot drift apart - they are the same argument.
fn torch_plane_ref(ref_depth: f32, grad: vec2<f32>, off: vec2<f32>, lim: f32) -> f32 {
return ref_depth + clamp(dot(grad, off), -lim, lim);
}
// MONKEY (pcss): one RAW depth texel of a torch map — the blocker search's read. `textureLoad`
// takes no sampler and imposes no uniformity requirement, which is what makes the search legal
// inside the receivers' per-fixture loop. The clamp is the border rule: a search that walks off the
// face re-reads its edge texel, the same answer the comparison sampler's ClampToEdge address mode
// gives the PCF taps beside it.
fn torch_map_depth(
depth_tex: texture_depth_2d_array,
layer: i32,
uv: vec2<f32>,
dims: vec2<f32>,
) -> f32 {
let c = clamp(vec2<i32>(floor(uv * dims)), vec2<i32>(0, 0), vec2<i32>(dims) - vec2<i32>(1, 1));
return textureLoad(depth_tex, c, layer, 0);
}
// MONKEY (torch shadows Phase 1): the depth-map projector. Because `shadow_hook` is imported by
// receivers WITHOUT the group-3 torch bindings (wow_model, terrain), the texture + comparison
// sampler come in AS PARAMETERS — the caller supplies them only where they are bound (static_gx).
// - `view_proj` the fixture's down-looking reverse-Z matrix.
// - `layer` which array layer (fixture index) to sample.
// - `world_pos` the receiving fragment's world position.
// - `normal` MONKEY (slope bias): the receiving surface's world-space normal, used ONLY to
// build the receiver plane whose depth gradient each tap's compare reference is
// walked along (see `TORCH_SLOPE_MAX`). It need not be unit and may be the zero
// vector - the M2 corpus authors those, and a zero normal simply switches the
// slope term off, leaving the constant bias exactly as it was. Callers pass the
// SAME normal they already offset `world_pos` along (`TORCH_NORMAL_OFFSET`), so
// the offset point and the plane through it agree by construction.
// - `depth_tex`/`comp` the group-3 depth array + `GreaterEqual` comparison sampler.
// - `bias` reverse-Z receiver bias (nudges the compare ref up to kill self-shadow acne).
// MONKEY (slope bias): still a CONSTANT floor under every tap - the slope term is
// added on top of it per tap, never in place of it.
// - `soft` MONKEY (torch caster selection): the BASE PCF tap-radius scale
// (`interiorShadowSoft`, 0.5..3, carried in the table's `count.y` LOW half as
// `soft x 100`). The four taps sit at ±0.5 texel × this; 1 is the historical
// half-texel box. MONKEY (pcss): it is now the radius at a CONTACT (a receiver
// touching its blocker) — the contact-hardening search below grows it with the
// blocker distance, so `soft` sets how sharp the sharpest edge in the scene is
// and every penumbra scales off it.
// - `fade` MONKEY (outdoor torch shadows): WHICH far fade ends the shadow.
// **Negative = the interior/legacy behaviour, bit-for-bit** — the reverse-Z
// far-plane fade computed from `ndc.z` below. Zero-or-positive = the CALLER's own
// fade weight, used verbatim (0 = no shadow left, 1 = full strength).
//
// Why an override at all: the projection is near/far `0.1/48`
// (`torch_shadow::cube_view_projs`), so under reverse-Z `ndc.z` is
// `near·(far−t)/((far−near)·t)` = **0.0079 at t = 10 yd**, which
// `smoothstep(0, 0.06, ·)` turns into a 5 % shadow. For a candle whose pool is
// 3-4 yd that is a correct soft end; for a campfire whose pool is 15-25 yd it
// erases the shadow exactly where the shadow IS the effect. The fix cannot live
// in here — it wants to be a function of WORLD distance from the fixture, and
// this function is deliberately a pure projector that never sees the fixture — so
// the exterior lane computes `1 − smoothstep(0.8R, R, d)` at its call site.
// Returns 1.0 outside the frustum / behind the light; a PCF factor otherwise (0 = shadowed).
//
// MONKEY (pcss): CONTACT-HARDENING. The old kernel was one fixed ±0.5·soft texel box everywhere,
// which is why a torch beside a Darkmoon tent post threw the canvas onto the grass fifteen yards
// away as a razor-edged cut-out: a real penumbra grows with the receiver's distance FROM ITS
// BLOCKER, and a constant kernel has no way to know that distance. The three steps below recover
// it from the map itself — search for blockers, estimate how far in front of the receiver they
// are, widen the filter by that much — so a chair leg stays crisp where it meets the floor while
// the tent's edge goes soft where the canvas is metres above the grass.
//
// The blocker search reads RAW depth with `textureLoad`, not through the comparison sampler: the
// array is bound as `texture_depth_2d_array` (wgpu sample type Depth), and a Depth binding serves
// BOTH `textureSampleCompare*` (through `comp`) and an unfiltered `textureLoad` that takes no
// sampler at all — so no binding, layout or pipeline changed. That is worth insisting on, because
// the alternative approximation (infer the blocker from the FRACTION of blocked compares at two or
// three radii) only ever answers "how deep inside the shadow am I", never "how far in front of me
// is the caster", and those two differ by exactly the quantity a penumbra is made of.
//
// Cost: 4 unfiltered loads, then 4 comparison taps (the shipped kernel, unchanged) whenever the
// search finds no blocker — which is every fully-lit fragment, i.e. most of a pool — or 8 when the
// widened radius earns them. Worst case 8 compares + 4 loads per fixture per fragment.
// MONKEY (slope bias) adds NO texture read at all: ~90 ALU once per call (two crosses, two
// `mat4x4 * vec4`, a 2x2 solve) to build the receiver-plane gradient, then a dot-clamp-add — about
// 5 ALU — on each of the 4 search taps and 4 or 8 PCF taps. The gradient cannot hoist out of the
// receivers' per-fixture loop (it depends on the fixture's own face matrix), but it is bounded by
// `EXT_SEL_SHADOWED` there exactly as the taps are.
fn torch_map_shadow(
view_proj: mat4x4<f32>,
layer: i32,
world_pos: vec3<f32>,
normal: vec3<f32>,
depth_tex: texture_depth_2d_array,
comp: sampler_comparison,
bias: f32,
soft: f32,
fade: f32,
) -> f32 {
let clip = view_proj * vec4<f32>(world_pos, 1.0);
if (clip.w <= 0.0) {
return 1.0;
}
let ndc = clip.xyz / clip.w;
if (ndc.x < -1.0 || ndc.x > 1.0 || ndc.y < -1.0 || ndc.y > 1.0 || ndc.z < 0.0 || ndc.z > 1.0) {
return 1.0;
}
let uv = ndc.xy * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5);
let dims = vec2<f32>(textureDimensions(depth_tex).xy);
let inv_dims = 1.0 / dims;
// MONKEY (torch caster selection): the base radius, clamped off zero so a table that never got
// a scale still samples a real (hard) box rather than four copies of one texel.
let base = max(soft, 0.05);
// Reverse-Z: the fragment is lit iff its own depth is at least the stored nearest depth, so the
// compare ref is `ndc.z + bias` against `GreaterEqual`. UNCHANGED bias semantics: this is the
// CENTRE reference, and the blocker test and every comparison tap are derived from this one
// expression, so a surface can no more blocker-detect itself than it could shadow itself.
// MONKEY (slope bias): "derived from" rather than "is" - each tap now adds its own
// receiver-plane term on top (`torch_plane_ref` below), which strengthens that property rather
// than weakening it: the taps that used to self-detect were exactly the off-centre ones.
let ref_depth = ndc.z + bias;
// MONKEY (slope bias) — RECEIVER-PLANE DEPTH BIAS. `bias` is a constant and the thing it has to
// clear is not: a tap `n` texels off centre reads a piece of the SAME surface whose stored depth
// differs by the receiver plane's own gradient times `n`, and on a floor lit from a low angle
// that gradient is the whole problem (`TORCH_SLOPE_MAX` has the measured numbers — a knee-high
// flame's floor starts losing to it past 10 yd, a candle's past 7, and the taps that fail
// print as faint rings out at the rim of the pool where the term is still visible). So every
// tap gets its OWN reference, walked along the receiver's plane by what
// that plane does over that tap's displacement: a tap sitting on the plane then compares
// equal-to-equal and cannot fail at any grazing angle, while a tap on a real occluder is
// untouched — the occluder is not on this plane, which is what makes it an occluder.
//
// `grad` is `d(ndc.z)/d(uv)` restricted to the plane through `world_pos` with normal `normal`,
// computed ANALYTICALLY rather than from `dpdx`/`dpdy` of the ndc. Two reasons, both hard:
// · every call site is inside a receiver's per-fixture table scan, with data-dependent
// `continue`s and an early `return` (`torch_map_at`, `torch_terrain_shadow`,
// `torch_entity_shadow_at`) — NON-UNIFORM control flow, where WGSL's derivative-uniformity
// rule makes `dpdx` a diagnostic at best and a neighbouring-fixture read at worst;
// · screen-space derivatives are a QUAD difference, so they are wrong by construction on a
// two-pixel-wide sliver and along every silhouette — exactly the geometry (fence rails,
// chair legs, tent ropes) this lane spends its taps on.
// The analytic form needs no quad and no uniformity, and is EXACT for a plane.
//
// Any two independent vectors IN the plane give the same gradient, so the tangents are left
// un-normalised and `normal` need not be unit: only the plane's direction SPACE matters and the
// 2x2 solve is invariant to how it is spanned. `a` picks the axis the normal is least aligned
// with, so the first cross is never near-degenerate. A ZERO normal (the M2 corpus authors them
// — see `wow_normalize` in the receivers) leaves `grad` at zero, i.e. the old constant-bias
// behaviour, bit-for-bit.
var grad = vec2<f32>(0.0);
if (dot(normal, normal) > 1e-12) {
let a = select(vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(0.0, 0.0, 1.0),
abs(normal.x) > abs(normal.z));
let t1 = cross(normal, a);
let t2 = cross(normal, t1);
// d(clip) along each tangent, then through the perspective divide:
// `d(ndc) = (d(clip).xyz - ndc * d(clip).w) / clip.w`.
let c1 = view_proj * vec4<f32>(t1, 0.0);
let c2 = view_proj * vec4<f32>(t2, 0.0);
let inv_w = 1.0 / clip.w;
let g1 = (c1.xyz - ndc * c1.w) * inv_w;
let g2 = (c2.xyz - ndc * c2.w) * inv_w;
let u1 = g1.xy * vec2<f32>(0.5, -0.5); // the same ndc -> uv flip `uv` above uses
let u2 = g2.xy * vec2<f32>(0.5, -0.5);
// Solve `dot(grad, u_i) = g_i.z` for i = 1, 2. `det` collapses only as the plane goes
// EDGE-ON to the fixture, where the plane projects to a line in the map, `max(N·L, 0)` has
// already taken the lit term to zero, and a zero gradient is the right answer anyway.
let det = u1.x * u2.y - u1.y * u2.x;
if (abs(det) > 1e-9) {
let inv = 1.0 / det;
grad = vec2<f32>((g1.z * u2.y - u1.y * g2.z) * inv,
(u1.x * g2.z - g1.z * u2.x) * inv);
}
}
// …and the ceiling on what any one tap may be moved by. See `TORCH_SLOPE_MAX`.
let slope_lim = TORCH_SLOPE_MAX * bias;
// MONKEY (pcss) 1/3 — BLOCKER SEARCH. Four raw reads on the diagonals of a box of half-width
// `soft * TORCH_PCSS_SEARCH` texels, averaging the depths that lie IN FRONT of the receiver.
// Four taps at one radius rather than a ring at several: they only have to answer "is there a
// caster near me, and roughly how far in front", and denser searching costs more than the extra
// precision buys on a 512² face whose texels are ~10 cm at a candle's range.
let search = base * TORCH_PCSS_SEARCH * inv_dims;
// MONKEY (slope bias): the offsets are NAMED so the search's reference and its sample are the
// same displacement, exactly as in the PCF kernel below.
let s0 = vec2<f32>(-1.0, -1.0) * search;
let s1 = vec2<f32>( 1.0, -1.0) * search;
let s2 = vec2<f32>(-1.0, 1.0) * search;
let s3 = vec2<f32>( 1.0, 1.0) * search;
let b0 = torch_map_depth(depth_tex, layer, uv + s0, dims);
let b1 = torch_map_depth(depth_tex, layer, uv + s1, dims);
let b2 = torch_map_depth(depth_tex, layer, uv + s2, dims);
let b3 = torch_map_depth(depth_tex, layer, uv + s3, dims);
var blocker = 0.0;
var blockers = 0.0;
// MONKEY (slope bias): the SEARCH is plane-corrected too, and it has to be. Left on the flat
// reference, a grazing floor detects ITSELF as its own blocker, and the penumbra width below is
// then estimated from a depth difference that is pure bias error — a pool that goes soft with
// nothing in it. Same law, same `grad`, same clamp as the taps.
if (b0 > torch_plane_ref(ref_depth, grad, s0, slope_lim)) { blocker += b0; blockers += 1.0; }
if (b1 > torch_plane_ref(ref_depth, grad, s1, slope_lim)) { blocker += b1; blockers += 1.0; }
if (b2 > torch_plane_ref(ref_depth, grad, s2, slope_lim)) { blocker += b2; blockers += 1.0; }
if (b3 > torch_plane_ref(ref_depth, grad, s3, slope_lim)) { blocker += b3; blockers += 1.0; }
// MONKEY (pcss) 2/3 — PENUMBRA WIDTH. `radius` stays at `base` when the search found nothing: a
// caster thinner than the search box (a chair leg, a tent rope, a candlestick) must NOT be
// widened away to nothing, and such a fragment is then filtered by exactly the kernel this
// function has always used.
var radius = base;
if (blockers > 0.0) {
// `(d_receiver - d_blocker) / d_blocker`, exact — see TORCH_DEPTH_B: `z - B` is
// proportional to `1/distance`, so the quotient of the two `z - B` terms IS the quotient of
// the two distances, and the projection's scale factor cancels. The receiver term uses the
// UNBIASED `ndc.z` (the bias exists to keep a surface off its own stored depth, not to move
// the surface), and `max` only guards f32 noise: inside the frustum a detected blocker is
// always the nearer of the two.
let ratio = max((blocker / blockers - TORCH_DEPTH_B) / (ndc.z - TORCH_DEPTH_B) - 1.0, 0.0);
radius = min(base * (1.0 + TORCH_PCSS_K * ratio), base * TORCH_PCSS_MAX);
}
// MONKEY (pcss) 3/3 — PCF at that radius. The 4-tap box below `TORCH_PCSS_WIDE` texels is the
// shipped kernel bit-for-bit (`radius == base` reproduces the old `texel` expression exactly);
// past it that box would alias into four separate soft bands, so a second ring of four cardinal
// taps at the full radius fills the area in.
let texel = inv_dims * radius;
// MONKEY (slope bias): every tap carries its OWN plane-corrected reference. With `grad` zero -
// a receiver square-on to the fixture, or a zero normal - `torch_plane_ref` returns `ref_depth`
// unchanged and this kernel is the shipped one bit-for-bit.
let p0 = vec2<f32>(-0.5, -0.5) * texel;
let p1 = vec2<f32>( 0.5, -0.5) * texel;
let p2 = vec2<f32>(-0.5, 0.5) * texel;
let p3 = vec2<f32>( 0.5, 0.5) * texel;
var sum = 0.0;
sum += textureSampleCompareLevel(depth_tex, comp, uv + p0, layer,
torch_plane_ref(ref_depth, grad, p0, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + p1, layer,
torch_plane_ref(ref_depth, grad, p1, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + p2, layer,
torch_plane_ref(ref_depth, grad, p2, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + p3, layer,
torch_plane_ref(ref_depth, grad, p3, slope_lim));
var pcf = sum * 0.25;
if (radius > TORCH_PCSS_WIDE) {
let p4 = vec2<f32>(-1.0, 0.0) * texel;
let p5 = vec2<f32>( 1.0, 0.0) * texel;
let p6 = vec2<f32>( 0.0, -1.0) * texel;
let p7 = vec2<f32>( 0.0, 1.0) * texel;
sum += textureSampleCompareLevel(depth_tex, comp, uv + p4, layer,
torch_plane_ref(ref_depth, grad, p4, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + p5, layer,
torch_plane_ref(ref_depth, grad, p5, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + p6, layer,
torch_plane_ref(ref_depth, grad, p6, slope_lim));
sum += textureSampleCompareLevel(depth_tex, comp, uv + p7, layer,
torch_plane_ref(ref_depth, grad, p7, slope_lim));
pcf = sum * 0.125;
}
// Phase 5: the six cube faces tile the whole sphere, so there is no cone edge to soften — a fade
// there would punch a lit seam along every face border. Only the far plane fades (reverse-Z:
// ndc.z → 0 at far), so a shadow at the fixture's range limit ends softly.
// MONKEY (outdoor torch shadows): …unless the caller supplied its own (see `fade`). `select`
// rather than a branch so both arms cost the same and the interior arm is the identical
// expression it always was.
let ndc_fade = smoothstep(0.0, 0.06, ndc.z);
return mix(1.0, pcf, select(fade, ndc_fade, fade < 0.0));
}
// MONKEY (interior debug): the interior lane's diagnostic overlay. `mode` is decoded from the
// receiver's `wmo_fog_params.w` (`1 + debug`); the receivers call this at the END of their interior
// branch so ONLY interior-lit fragments are recoloured — a wrongly-classified outdoor entity then
// stands out against the normally-shaded world. `mode == 0` returns the real `rgb` unchanged.
// 1 CLASSIFICATION → solid green (this fragment took the interior lane)
// 2 SHADOW → the torch-shadow factor as greyscale (black shadowed .. white lit)
// 3 CASTER COUNT → red 0 / green 1 / blue 2 / white 3+ shadow proxies visible to this view
fn interior_debug_override(
mode: u32,
rgb: vec3<f32>,
sample_pos: vec4<f32>,
normal: vec3<f32>,
) -> vec3<f32> {
if (mode == 1u) {
return vec3<f32>(0.0, 1.0, 0.0);
}
if (mode == 2u) {
return vec3<f32>(torch_shadow(sample_pos, normal));
}
if (mode == 3u) {
let n = torch_caster_count();
if (n == 0u) { return vec3<f32>(1.0, 0.0, 0.0); }
if (n == 1u) { return vec3<f32>(0.0, 1.0, 0.0); }
if (n == 2u) { return vec3<f32>(0.0, 0.0, 1.0); }
return vec3<f32>(1.0, 1.0, 1.0);
}
return rgb;
}
+508 -29
View File
@@ -29,14 +29,20 @@
mesh_functions,
forward_io::Vertex,
view_transformations::position_world_to_clip,
mesh_view_bindings::view,
mesh_view_bindings::{lights, view},
shadows,
}
// MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here.
#import benilla::shadow_hook
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var layer_array: texture_2d_array<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var alpha_array: texture_2d_array<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(105) var splat_samp: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(110) var shadow_array: texture_2d_array<f32>;
// A fully covered character shadow retains 45% of the authored terrain colour.
const SHADOW_SUN_FLOOR: f32 = 0.45;
// Per-tile Vec4 uniforms packed into ONE buffer (binding 106) — the field order here MUST match the
// Rust `TerrainExtension` struct. Light + fog live in the shared global-light storage buffer (below);
// what's left is just the per-tile layer tiling factor.
@@ -64,12 +70,175 @@ 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>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
// MONKEY (outdoor torch shadows: terrain): the GROUND receiver's torch bindings - the SAME depth
// array static_gx's group 3 samples and the SAME 6416-byte table, riding this material's own group
// (`TerrainExtension` 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 text is COPIED VERBATIM from wow_model.wgsl - std430 here,
// std140 in static_gx, identical bytes because every member is 16-aligned - and a drift between the
// three copies would not be a compile error, it would be terrain sampling the wrong matrices.
struct TorchTable {
// MONKEY (static torch cache): byte-identical in ALL THREE shaders and TorchTableUniform.
// count@0 (16): x high-water slot count, y soft*100, z dynamic/live-bank mask, w flags.
// positions@16 (256), view_projs@272 (6144): total 6416 bytes. A mismatch hides buildings.
// MONKEY (live bank rank): 96 static layers + 48 live layers; matrices stay slot-addressed.
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;
@group(#{MATERIAL_BIND_GROUP}) @binding(93) var<storage, read> torch_table: TorchTable;
// Reverse-Z receiver bias - KEEP IN SYNC with static_gx.wgsl / wow_model.wgsl's TORCH_BIAS (0.001,
// the known-good value; larger detaches every shadow far from the torch).
const TORCH_BIAS: f32 = 0.001;
// NORMAL-OFFSET for the GROUND (yd): sample the map a hand's width above the surface along its own
// normal. Terrain is the one receiver whose casters REST on it - a crate, a fence post, a standing
// NPC - so the contact point is where receiver and caster depths agree to within a texel, i.e.
// exactly where a plain compare produces acne. Offsetting along the terrain normal (not straight
// up) is what keeps a SLOPE clean: on a 30 deg bank a vertical offset buys only `0.15*cos(30)` of
// separation while the depth gradient across a texel has grown by `1/cos(30)`, so the margin
// collapses on precisely the surfaces that need it most. The same 0.15 the entity lane uses, so a
// character and the ground under it are offset alike and their shadows meet without a gap.
const TORCH_NORMAL_OFFSET: f32 = 0.15;
// MONKEY (torch caster selection, MIRRORED from static_gx.wgsl): the live PCF tap-radius scale
// (count.y LOW half) and MONKEY (shadow floor)'s strength (HIGH half),
// 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 & 0xffffu) * 0.01, 0.05);
}
// MONKEY (shadow floor): the live SHADOW STRENGTH (`torchShadowStrength`, 0..1, default 0.7),
// unpacked from `count.y`'s HIGH half (`(strength x 100) << 16 | soft x 100` - see
// `TorchTableUniform::pack`; the low half is `torch_soft` above and `count.w`'s flags are
// untouched). It is applied as `mix(1, s, w * strength)` where the cross-fade weight already
// multiplies the shadow factor, which is algebraically the same thing as flooring the factor
// itself (`1 - w*strength*(1-s)` either way) for one extra multiply and no extra tap.
//
// WHY a floor at all: a torch map is the ONLY occlusion in the direct term, so a blocked fragment
// used to drop that term to exactly zero - a pitch-black, razor-edged scar of tent canvas across
// Darkmoon's grass, table legs printed on the Darkshire inn floor. Nothing in this renderer
// bounces, so the 30 % that survives at the default IS the bounce: the fill/ambient arms are
// untouched (they never saw this factor), only the DIRECT arm is floored. 1 restores the shipped
// pitch-black look exactly, 0 disables torch shadows without disturbing the lane behind them.
fn torch_strength() -> f32 {
return clamp(f32(torch_table.count.y >> 16u) * 0.01, 0.0, 1.0);
}
// MONKEY (outdoor torch shadows, MIRRORED from static_gx.wgsl - keep in sync): the EXTERIOR lane's
// world-distance fade radius (yd) and the CPU's one-bit lane gate in `count.w`. See the static_gx
// copies for the full rationale (the interior `ndc.z` fade is already down to 5 % at 10 yd, which
// erases a campfire's 15-25 yd pool).
const TORCH_EXT_FADE_YD: f32 = 44.0;
const TORCH_EXT_LANE: u32 = 1u;
// MONKEY (torch lane perf, MIRRORED from static_gx.wgsl): below THIS much unshadowed direct
// contribution a fragment skips the torch table scan and its four comparison taps entirely, because
// the shadow factor multiplies that contribution and can therefore only ever subtract less than this
// from the frame. Terrain's combine is a plain modulate rather than the interior exposure rolloff,
// so the worst possible step across the guard's boundary is 1e-4 of a fully-lit texel - two orders
// under one 8-bit code. Most of the skips are exact zeros anyway (`nl` IS 0 on ground facing away
// from the fire), so the threshold only has to be small enough to be invisible.
const TORCH_SKIP_EPS: f32 = 1e-4;
// The CPU's one-bit verdict: `exteriorShadows` AND night AND at least one promoted exterior
// fixture. EVERYTHING this lane costs terrain hangs off this single uniform read.
fn torch_ext_on() -> bool {
return (torch_table.count.w & TORCH_EXT_LANE) != 0u;
}
// MONKEY (Phase 5, COPIED VERBATIM from wow_model.wgsl - keep in sync): the cube face that contains
// direction `d` (fixture -> fragment) - the major axis, signed. Face order is the contract with
// `benilla_app::torch_shadow::cube_view_projs`: 0 +X, 1 -X, 2 +Y, 3 -Y, 4 +Z, 5 -Z.
fn torch_face(d: vec3<f32>) -> u32 {
let a = abs(d);
if (a.x >= a.y && a.x >= a.z) {
return select(1u, 0u, d.x > 0.0);
}
if (a.y >= a.z) {
return select(3u, 2u, d.y > 0.0);
}
return select(5u, 4u, d.z > 0.0);
}
// MONKEY (outdoor torch shadows: terrain): this fixture's OWN cast shadow on a GROUND fragment -
// `wow_model.wgsl`'s `torch_entity_shadow_at` with the exterior fade already chosen, because terrain
// has no interior lane to share the body with (an MCNK cell is outdoors by definition, and the
// interior half of the light table is skipped before ranking). Correlate the `wow_light` fixture at
// `light_pos` to a promoted torch (position match within 1 yd), pick the cube face facing the
// fragment, and sample that layer through the shared projector. 1.0 (unshadowed) when no map matches
// - the lane is off, or the fixture was not among the promoted slots.
fn torch_terrain_shadow(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>) -> f32 {
// MONKEY (torch lane perf): the empty table is the common case, so answer it before the normal
// offset and the loop set-up.
if (torch_table.count.x == 0u) {
return 1.0;
}
// Normal-offset the sample point off the ground (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 face = torch_face(Ps - fixture);
let layer = i * 6u + face;
// MONKEY (live bank rank): count.z is CPU-ready-filtered; holes must not consume live
// cubes. Keep the projection on the static slot while compacting depth only.
let rank = countOneBits(torch_table.count.z & ((1u << i) - 1u));
let depth_layer = select(layer, 96u + 6u * rank + face, (torch_table.count.z & (1u << i)) != 0u);
// The EXTERIOR world-distance fade, measured from the FIXTURE (not the camera): this is
// "how far does this fire's shadow carry", a property of the fire. Chosen just inside the
// cube projection's own 48 yd far plane so the frustum edge is never a hard cut.
let fade = 1.0 - smoothstep(0.8 * TORCH_EXT_FADE_YD, TORCH_EXT_FADE_YD, distance(fixture, Ps));
// MONKEY (slope bias): `N` is the ground normal the sample point was already
// offset along, so the plane the projector biases against is the one this fragment
// actually lies on.
let s = shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(depth_layer), Ps, N, torch_depth, torch_samp,
TORCH_BIAS, torch_soft(), fade);
// MONKEY (torch caster selection): `.w` is the slot's FADE WEIGHT, so a promoted fixture's
// shadow ramps in over ~1/3 s and a demoted one ramps out. The ground and the NPC standing
// on it MUST use the same weight or one shadow would pop while the other faded.
// MONKEY (shadow floor): and `torch_strength()` is the DIRECT-term floor folded
// into that same weight (see the function). `w * strength` rather than a second `mix`
// because the two are the same expression.
return mix(1.0, s, torch_table.positions[i].w * torch_strength());
}
}
return 1.0;
}
// MONKEY (torch debug, interiorDebug 2 on TERRAIN): the MIN raw cube-map shadow factor over every
// promoted fixture (ignoring the fixture match) - mirrors static_gx's `torch_debug_factor` and
// wow_model's `torch_entity_debug_factor`, so the GROUND's sampling can be SEEN as greyscale:
// all-white = the table is empty / the projection misses the ground; shaped dark = the depth map
// reaches the terrain and any remaining fault is downstream of sampling (correlation, weighting).
fn torch_terrain_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 face = torch_face(Ps - torch_table.positions[i].xyz);
let layer = i * 6u + face;
// MONKEY (live bank rank): debug samples the same compact bank as the lit path.
let rank = countOneBits(torch_table.count.z & ((1u << i) - 1u));
let depth_layer = select(layer, 96u + 6u * rank + face, (torch_table.count.z & (1u << i)) != 0u);
// MONKEY (slope bias): the overlay biases against the SAME plane the lit path does, or
// `interiorDebug 2` would keep showing acne the render no longer has.
let raw = shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(depth_layer), Ps, N, torch_depth, torch_samp,
TORCH_BIAS, torch_soft(), -1.0);
// MONKEY (torch caster selection): the WEIGHTED factor, matching the real render.
s = min(s, mix(1.0, raw, torch_table.positions[i].w));
}
return s;
}
// Custom vertex→fragment payload: the standard fields the splat fragment needs, PLUS `specular` — the
// per-vertex-clamped sun glint that must be Gouraud-interpolated (Q14). Same struct on both stages, so
// `@builtin(position)` is clip-position out / frag-coord in (`world_position` is unused until Step 5 fog).
@@ -89,6 +258,29 @@ struct TerrainVsOut {
// hard knee, the director's "hard square". Sun N·L moving per-pixel → per-vertex is the
// faithful direction (Step 3's own note: indistinguishable on the smooth term).
@location(6) primary: vec3<f32>,
// The receiver normal is carried so the real cascaded shadow lookup can apply a slope-aware
// bias. This is forward-pass data only; the terrain shadow caster is a stock Bevy proxy.
@location(7) world_normal: vec3<f32>,
// MONKEY (outdoor torch shadows: terrain; ext light k8): WHICH <=`EXT_SEL_K` exterior table
// entries `primary`'s point term was summed from, packed 8 bits each across FOUR u32s (see
// `EXT_SEL_EMPTY` / `ext_sel_get`). FLAT, because it is a CHOICE and not a quantity -
// interpolating packed indices across a triangle would produce a different, meaningless one. It
// lets the fragment stage re-shadow the VERTEX's own selection instead of re-ranking, which is
// what keeps the selection popping at chunk granularity (the authored behaviour) rather than
// drawing a hard line mid-cell. Widened from ONE u32 of three 10-bit ranks to TWO u32s of eight
// 8-bit ranks and then to FOUR u32s of twelve - three extra interstage components in all (28
// for this struct, against a 60-component limit) - see `EXT_SEL_K` for why three, and then
// eight, stopped being enough.
@location(8) @interpolate(flat) ext_sel: vec4<u32>,
// MONKEY (outdoor torch shadows: terrain): `primary` WITHOUT the point term and WITHOUT the
// clamp - just `ambient + sun*max(N.L,0)`. `primary` is clamped per VERTEX (the GL T&L locus,
// see its note above) and that clamp is lossy: once a hot torch has saturated a vertex there is
// no way to subtract the point term back out in the fragment stage. So the sun/ambient half
// rides its own slot and the night lane rebuilds `clamp(base + shadowed_points)` from it. This
// costs 3 interstage components and is read ONLY inside the night branch; the day path still
// reads `primary` and is therefore bit-identical. Unclamped is safe to interpolate: it is
// linear in `N.L` and the clamp that used to bound it is re-applied per fragment.
@location(9) base_lit: vec3<f32>,
}
// Terrain's point-light **candidacy half-width** (yd): the reference's guaranteed covered box is
@@ -124,41 +316,232 @@ const TERRAIN_REACH: f32 = 33.570166;
// The M2 lane in wow_model.wgsl still uses the packed per-light range — its own `w` is 0 (a ±10 yd
// box, a *different* byte-pinned number), a change that would move the approved interior look, so it
// is the director's to weigh. The two lanes diverge on purpose; do not "fix" them back into one.
fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
// MONKEY (outdoor torch shadows: terrain, MIRRORED from wow_model.wgsl / static_gx.wgsl - keep in
// sync): the <=`EXT_SEL_K`-nearest selection packed into FOUR u32s, twelve 8-bit indices (rank 0 in
// the low byte of `.x`) with `EXT_SEL_EMPTY` for an unfilled rank. It exists so the per-FRAGMENT
// shadowed term below can re-evaluate the VERTEX stage's choice rather than making its own -
// re-ranking per fragment would draw a hard line wherever the ranking flips, and on terrain that
// line would run straight through the middle of an MCNK cell, which is exactly the chunk-shaped
// popping the 0285 anchor removed.
//
// MONKEY (ext light k12) - **WHY K WENT 3 -> 8.** Three is the reference FFP's own commit limit (GL
// slots 1-3; wow-re `wmo-surface-dynamic-light` sections 4/6) and it was the right number for the
// reference's SPARSE, hand-authored light set: with two or three authored lights in a village the
// nearest three IS all of them, and no two draw units can disagree. benilla does not have that set
// - `fire_light.rs` SYNTHESISES a point light for every torch, lantern, brazier and campfire
// GameObject/doodad in range, so a lamp-lit set piece now puts 15+ exterior fixtures inside ONE
// chunk's candidacy box (measured at the Darkmoon Faire: 12 `Free Standing Torch 01` + 3
// `General Lantern 01` within reach of the player's chunk, plus a stall lamp and the fireworks'
// spell lights). Once the candidates outnumber the slots, adjacent draw units keep DIFFERENT
// threes; and because the boundary between draw units is a straight line - an MCNK cell edge, or
// the jump from a cell-anchored chunk to the origin-anchored bench standing on it - the
// disagreement reads as a HARD STRAIGHT EDGE across a torch's pool of light (the director's
// "scars"), and as a bench lit by a firework over ground that is not. More slots make neighbouring
// units AGREE, so a pool ends where the FALLOFF ends instead of where the cell does; the only open
// question is how many is enough, and the next paragraph answers it with the measurement.
//
// 8 bits per rank caps the LIVE table at 255 real entries, `EXT_SEL_EMPTY` = 255 being the
// sentinel; `global_light::MAX_LIVE_POINT_LIGHTS` enforces that CPU-side. The buffer still carries
// 256 slots, so `LightStd430` keeps its 8528 B and no mirror struct moves.
//
// MONKEY (ext light k12) - **AND WHY 8 -> 12, one round later.** Eight was MARGINAL the day it
// landed and the note here said so. The Darkmoon Faire's densest chunk holds THIRTEEN candidates
// and EIGHT of them stand strictly inside its own 33.33 yd cell, so a torch 2.5 yd outside the edge
// - lighting the grass right at the seam - cannot win a slot under any ranking, and that chunk goes
// on disagreeing with its neighbour about it. On that particular seam K=8 did not help, it made
// things WORSE (0.198 -> 0.356 of a falloff unit): raising K let the SPARSE neighbour take in
// lights the dense chunk still had no room for, so the two sets grew further apart, not closer.
// Twelve is the first K at which every chunk around the player keeps everything it can see bar one:
//
// worst ABSOLUTE seam jump, the four chunks meeting at the corner nearest WoW (-9557, 99)
// (`k12_table.py` in the scratchpad; falloff units, colour and N.L factored out, the byte-verified
// `1/(0.7d + 0.03d^2)`, 200 samples along each shared edge)
// seam cands K=3 K=8 K=12 K=16
// (-288,2)|(-288,3) 6 | 2 0.0418 0.1117 0.1117 0.1117
// (-288,2)|(-287,2) 6 | 13 0.6425 0.0882 0.0669 0.1281
// (-288,3)|(-287,3) 2 | 7 0.2888 0.0862 0.0862 0.0862
// (-287,2)|(-287,3) 13 | 7 0.1980 0.3556 0.1144 0.1406
// WORST 0.6425 0.3556 0.1144 0.1406
//
// Read the last two columns together. At K>=13 every chunk keeps ALL its candidates, so 0.1406 is
// the CANDIDACY BOX's own floor - the residual disagreement two chunks have because their gather
// boxes are centred 33 yd apart - and no K can touch it (widening the box is a separate change, and
// it is the byte-verified one). K=12 measures 0.1144, slightly UNDER that floor, because the dense
// chunk drops exactly one far candidate its neighbour also barely weights. So twelve ENDS this line
// of attack: 16 is not better, only wider.
//
// The own-origin case in the same table - a bench standing on the ground, ranking from its own
// position, against the chunk under it - goes 79 % / 71 % / 52 % apart at K=3, to 31 % / 27 % / 5 %
// at K=8, to 3 % / 1 % / 5 % at K=12. That is the "bench lit by a firework over dark grass" closing.
//
// RAISING K FURTHER: `ext_sel_get` and the pack tail index the selection vector dynamically
// (`sel[s >> 2u]`) and the rank arrays are zero-constructed and filled, so K is not spelled out
// anywhere but here. The vector is `vec4<u32>` now and holds SIXTEEN rank slots, so K = 13..16 is
// this constant ALONE - no type site, no interstage field, no `EXT_SEL_NONE` edit. Past 16 the next
// step is a second `vec4<u32>` (four more interstage components) and `ext_sel_get` learning which
// vector to read, which is a real change rather than a constant.
const EXT_SEL_K: u32 = 12u;
const EXT_SEL_EMPTY: u32 = 255u;
// MONKEY (ext light k12): how many of the `EXT_SEL_K` ranks pay for a CUBE-MAP OCCLUSION lookup in
// the night lane. The ranking is by distance, so ranks 0..2 are the three fixtures whose term
// dominates this fragment; ranks 3..11 are the long tail that fixes the SELECTION (the scars) and
// contribute a soft, low-amplitude wash where a hard-edged shadow would not be legible anyway.
// Holding the shadowed count at the OLD K keeps the per-fragment cost of the night lane exactly
// what it was - three table scans and their taps - while the selection itself gets twelve deep.
const EXT_SEL_SHADOWED: u32 = 3u;
// Unpack rank `s` (0..`EXT_SEL_K`-1) from the four-word selection. MIRRORED - keep in sync.
fn ext_sel_get(sel: vec4<u32>, s: u32) -> u32 {
return (sel[s >> 2u] >> (8u * (s & 3u))) & 255u;
}
// The RANKING half of the old `point_light_sum`: the same interior-lane skip, the same Chebyshev
// candidacy box, the same tie order (strictly-less inserts, so an equal distance leaves the earlier
// table index at the better rank).
//
// MONKEY (ext light k12): `box` is the draw unit's HORIZONTAL half-extent in yards, and it changes
// what "nearest" MEANS - ranking is now by the distance from the light to the unit's AABB, not to
// the unit's anchor POINT. Terrain's unit is the 33.33 yd MCNK cell, so a torch standing 2 yd
// outside a cell's edge is 2 yd from the nearest ground that cell draws, yet ~18 yd from its
// CENTRE, which is how it used to lose its slot to three torches clustered near the middle while
// lighting nothing of the grass right under it. Clamping the light into the cell box
// (`max(|d| - box, 0)` per horizontal axis) ranks it the way the ground actually sees it, and - the
// point of the exercise - makes two ADJACENT cells rank a light on their shared edge almost
// identically, so their sets agree there. The VERTICAL stays unbounded, exactly as the reference's
// hash sweep is and as `mcnk_cell_anchor` (which keeps the vertex's own y) already implies.
// `box = 0` reproduces the old point-anchored ranking BIT-FOR-BIT - that is what the own-origin
// units (props, entities) pass.
//
// CANDIDACY deliberately stays on the anchor POINT: it is the byte-verified gather box, and
// widening it is a different question from how the survivors are ordered.
fn point_light_pick(anchor: vec3<f32>, box: f32) -> vec4<u32> {
let count = u32(wow_light.point_count.x);
var sel = array<u32, 3>(0u, 0u, 0u);
var sd = array<f32, 3>(1e30, 1e30, 1e30);
var sel = array<u32, EXT_SEL_K>();
var sd = array<f32, EXT_SEL_K>();
for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
sd[s] = 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 ≤`EXT_SEL_K`
// 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);
// The hash sweep is horizontal (the grid keys on WoW x/y = Bevy z/x) and has no vertical
// bound; ranking below is the full 3-D distance, as `0x71bf90` does.
// bound; the ranking below is the full 3-D distance, as `0x71bf90` does.
if (max(abs(dv.x), abs(dv.z)) > TERRAIN_REACH) {
continue;
}
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;
// MONKEY (ext light k12): distance to the draw unit's BOX (see the header note). With
// `box = 0` both `max`es are identities and this is exactly the old `dot(dv, dv)`.
let e = max(abs(dv.xz) - vec2<f32>(box), vec2<f32>(0.0));
let d2 = dot(e, e) + dv.y * dv.y;
// MONKEY (ext light k12): an `EXT_SEL_K`-deep insertion in place of the hand-unrolled 3-deep
// cascade. Both loops are bounded by a module const, so the compiler unrolls them; the
// comparison is strictly-less, which keeps the old tie order (first-found wins).
//
// The guard on the WORST kept rank first: `sd` is sorted, so a candidate that cannot beat
// `sd[K-1]` cannot beat anything, and the scan below would walk all eight slots only to
// decide that. It makes the REJECT path - which is what nearly every table entry takes once
// the set is full - ONE comparison, i.e. cheaper than the three the old cascade spent, so
// widening K did not make the common case more expensive. Exactly equivalent to letting the
// scan run: `d2 >= sd[K-1]` is precisely the condition under which it returns `r = K`.
if (d2 >= sd[EXT_SEL_K - 1u]) {
continue;
}
var r = EXT_SEL_K;
for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
if (d2 < sd[s]) {
r = s;
break;
}
}
if (r < EXT_SEL_K) {
for (var s = EXT_SEL_K - 1u; s > r; s = s - 1u) {
sd[s] = sd[s - 1u];
sel[s] = sel[s - 1u];
}
sd[r] = d2;
sel[r] = i;
}
}
// Pack low-byte-first, `EXT_SEL_EMPTY` for a rank nothing reached. No real index can collide
// with the sentinel: the live table caps at 255 (`MAX_LIVE_POINT_LIGHTS`).
var packed = vec4<u32>(0u, 0u, 0u, 0u);
for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
let idx = select(EXT_SEL_EMPTY, sel[s], sd[s] <= 9.9e29);
packed[s >> 2u] |= idx << (8u * (s & 3u));
}
return packed;
}
// The EVALUATION half - the byte-verified falloff `1/(0.7d + 0.03d^2)` x `max(N.L, 0)` x the
// committed colour, in rank order, stopping at the first empty rank exactly as the old
// `sd[s] > 9.9e29` break did (an unfilled rank packs as `EXT_SEL_EMPTY`, and no real index can
// collide with it: the live table caps at 255). MONKEY (ext light k12): up to `EXT_SEL_K` terms now,
// still Gouraud (per vertex) and still linear in the falloff, so nothing about the day lane's FORM
// changed - a chunk simply stops dropping the fixtures its neighbour kept.
fn point_light_eval(sel: vec4<u32>, P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
var sum = vec3<f32>(0.0);
for (var s = 0u; s < 3u; s = s + 1u) {
if (sd[s] > 9.9e29) {
for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
let idx = ext_sel_get(sel, s);
if (idx == EXT_SEL_EMPTY) {
break;
}
let pos_range = wow_light.points[2u * sel[s]];
let to_light = pos_range.xyz - P;
let to_light = wow_light.points[2u * idx].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);
sum += wow_light.points[2u * idx + 1u].rgb * (atten * nl);
}
return sum;
}
// MONKEY (outdoor torch shadows: terrain): the SHADOWED evaluation - the SAME entries the
// vertex picked, each multiplied by its OWN fixture's cube-map occlusion, so a crate between the
// campfire and this patch of grass darkens the campfire's term while the brazier across the road is
// untouched. This is what step (a) already did for WMO exteriors and models; without it a
// character's shadow stopped dead at the grass.
//
// MONKEY (ext light k12): the SUM runs to `EXT_SEL_K`, but only the first `EXT_SEL_SHADOWED` ranks
// pay for an occlusion lookup - each of those costs a <=16-slot table scan and four comparison taps,
// so the per-fragment cost of this lane is pinned at exactly what it was before the widening while
// the selection itself got twelve deep. Ranks 3..11 are the distance-ordered tail: they are what
// makes a torch's pool agree across a cell edge, and they arrive unshadowed, which at their
// amplitude is not a look the eye can separate from a shadowed one. Reached only under
// `torch_ext_on()` - so it is night-only and dead with `exteriorShadows 0`. `TORCH_SKIP_EPS` drops
// the scan entirely wherever the unshadowed term is already invisible, which on terrain is most of
// the frame (ground facing away from the fire, or far enough out that the falloff has taken the
// term below a code).
fn point_light_eval_shadowed(sel: vec4<u32>, P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
var sum = vec3<f32>(0.0);
for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
let idx = ext_sel_get(sel, s);
if (idx == EXT_SEL_EMPTY) {
break;
}
let fixture = wow_light.points[2u * idx].xyz;
let to_light = fixture - 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);
// MONKEY (torch lane perf): the occlusion is a factor on a term that is already zero on any
// ground facing away from the fire.
let ext_w = atten * nl;
var occ = 1.0;
if (s < EXT_SEL_SHADOWED && ext_w > TORCH_SKIP_EPS) {
occ = torch_terrain_shadow(fixture, P, N);
}
sum += wow_light.points[2u * idx + 1u].rgb * (ext_w * occ);
}
return sum;
}
@@ -188,6 +571,11 @@ 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 (ext light k12): the MCNK cell's HORIZONTAL half-extent (yd) - the `box` a cell-anchored
// draw unit ranks by. The same grid constant as above, halved: 533.33333/16/2 = 16.666666. An
// own-origin unit passes 0 instead. MIRRORED in wow_model.wgsl / static_gx.wgsl - keep in sync.
const MCNK_CELL_HALF: f32 = 533.33333 / 32.0;
@vertex
fn vertex(in: Vertex) -> TerrainVsOut {
var out: TerrainVsOut;
@@ -196,6 +584,7 @@ fn vertex(in: Vertex) -> TerrainVsOut {
mesh_functions::mesh_position_local_to_world(world_from_local, vec4<f32>(in.position, 1.0));
out.clip_position = position_world_to_clip(out.world_position.xyz);
let world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index);
out.world_normal = world_normal;
out.uv = in.uv;
out.uv_b = in.uv_b;
out.color = in.color;
@@ -211,13 +600,23 @@ fn vertex(in: Vertex) -> TerrainVsOut {
out.specular = clamp(wow_light.light_spec.rgb * pow(ndoth, wow_light.light_spec.w), vec3<f32>(0.0), vec3<f32>(1.0));
// STEP 3 at the faithful locus — the WHOLE diffuse sum, clamped HERE per vertex (see the
// struct note). Sun: `ambient + diffuse·max(N·L,0)` on the MCNR normal; points: the ≤3-nearest
// committed lights of this vertex's MCNK chunk cell — the terrain draw unit (0285) — at the
// struct note). Sun: `ambient + diffuse·max(N·L,0)` on the MCNR normal; points: the
// ≤`EXT_SEL_K`-nearest committed lights of this vertex's MCNK chunk cell — the terrain draw
// unit (0285), ranked from the CELL BOX (`MCNK_CELL_HALF`, see `point_light_pick`) — at the
// byte-verified `1/(0.7d + 0.03d²)`, raw over-gamut colours in.
let ndotl = max(dot(n, l), 0.0);
let points = point_light_sum(out.world_position.xyz, n, mcnk_cell_anchor(out.world_position.xyz));
// MONKEY (outdoor torch shadows: terrain): the selection is PUBLISHED so the fragment stage can
// shadow this same selection at night without re-ranking. One table walk still, not two -
// the sum was always `eval(pick(..))`, it is just no longer inlined.
let sel = point_light_pick(mcnk_cell_anchor(out.world_position.xyz), MCNK_CELL_HALF);
out.ext_sel = sel;
let points = point_light_eval(sel, out.world_position.xyz, n);
let sun_lighting = wow_light.light_diffuse.rgb * ndotl;
// The sun/ambient half on its own slot, UNCLAMPED (see `base_lit`) - the night lane's only way
// back to a `primary` that does not already have the unshadowed point term baked into it.
out.base_lit = wow_light.light_ambient.rgb + sun_lighting;
out.primary = clamp(
wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl + points,
wow_light.light_ambient.rgb + sun_lighting + points,
vec3<f32>(0.0),
vec3<f32>(1.0),
);
@@ -277,11 +676,63 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
specmask = mix(specmask, s3.a, a.b);
// STEP 3 (diffuse) arrives from the vertex stage, ALREADY summed (ambient + sun N·L + the
// chunk's ≤3 committed point lights) and ALREADY clamped — Gouraud of the clamped vertex
// chunk's committed point lights) and ALREADY clamped — Gouraud of the clamped vertex
// colour, byte-faithful to GL T&L (see the `primary` struct note; the MCNR normal and the
// DayNight sun share a space per the Phase-0 validation). The MCSH ×(0.3·s + 0.7) factor below
// still scales the whole modulate, as the traced combine does.
let primary = in.primary;
// Fetch the character coverage here, but apply it as a NEUTRAL scalar after the terrain's
// authored lighting below. Subtracting the warm sun RGB left only Elwynn's green ambient and
// produced a green silhouette on yellow morning ground. The era-style projected shadow is a
// greyscale attenuation: it darkens the existing hue instead of changing it.
// Keep the raw fetch for the specular gate below. The realtime map now contains CHARACTERS
// only; MCSH contains the static world's baked blockers. They are independent occluders, so
// the character map must never replace (and thereby brighten) the authored terrain bake.
// MONKEY (shadow hook): the realtime shadow — fetch + edge fade (toward the `shadowDistance`
// range in `_wmo_fog[1].z`) + night fade (`fog_params.z`) — is computed by `benilla::shadow_hook`.
// Terrain keeps only its own MCSH suppression + spec gate below; `sun_shadow_strength` feeds the
// MCSH fade-back.
let view_z = (view.view_from_world * in.world_position).z;
let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
let sun_shadow_strength = wow_light.fog_params.z;
// Hoisted out of the `realtime_shadow` call (same expression, same bits) because the torch lane
// below needs the same normal for its normal-offset sample.
let n_lit = normalize(in.world_normal);
let world_shadow = shadow_hook::realtime_shadow(
in.world_position,
n_lit,
view_z,
cam_dist,
wow_light._wmo_fog[1].z,
sun_shadow_strength,
);
// MONKEY (outdoor torch shadows: terrain): the exterior point term, CAST-SHADOWED at night.
//
// `in.primary` is the Gouraud (per-vertex, per-vertex-CLAMPED) diffuse, and it stays the only
// thing this lane reads by day. After dark the same entries the vertex picked
// (`in.ext_sel`) are re-evaluated PER FRAGMENT with each fixture's own cube-map occlusion folded
// in, re-summed onto the sun/ambient half (`in.base_lit`) and re-clamped here - the clamp has to
// move to the fragment because the shadow is a per-fragment quantity, and clamping the sum is
// still the faithful GL order (saturate the light sum, then modulate the texture).
//
// Written as a BLEND rather than a swap, for the same two reasons the model and WMO lanes are:
// - `fog_params.z` is EXACTLY 1.0 whenever the sun is above the daylight threshold
// (`global_light::sun_shadow_strength` is a smoothstep that saturates), so `ext_night_w` is
// exactly 0, the branch is not entered, and DAYTIME IS THE SAME BITS - not "a mix that ought
// to round back to `in.primary`". Nothing here is evaluated by day at any cost.
// - at dusk the shadow, and the Gouraud->per-fragment change of the term itself, arrive on the
// same clock the sun shadows leave on instead of snapping at a threshold.
// `torch_ext_on()` is the CPU's one-bit verdict (`exteriorShadows` AND night AND at least one
// promoted exterior fixture), so with the cvar off this is dead too.
var primary = in.primary;
let ext_night_w = select(0.0, clamp(1.0 - sun_shadow_strength, 0.0, 1.0), torch_ext_on());
if (ext_night_w > 0.0) {
let primary_night = clamp(
in.base_lit + point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
primary = mix(in.primary, primary_night, ext_night_w);
}
// STEP 4: MCSH baked shadow. On the reference path (pixelShaders+specular) terrain is ONE pass
// through `terrainp_s.bls`, which carries the MCSH bit in the blend texture's alpha (1.0 lit / 0.0
@@ -294,16 +745,44 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
let mcsh = textureSample(shadow_array, splat_samp, auv, i32(round(si))).r;
shadow_lit = 1.0 - mcsh;
}
// MONKEY (world shadows): when the realtime WORLD-shadow lane is active (`worldShadows` on), the
// BAKED MCSH terrain shadows are redundant — the realtime map now shadows the static world
// (trees + buildings) too — and keeping both double-shadows. So drop MCSH then. This keys on the
// world lane flag (`sh_c16.w`, packed by `global_light::build_light_data`), NOT on the mere
// presence of a directional light: the shared shadow sun ALSO exists for CHARACTER-only shadows,
// and those must leave the world's baked MCSH intact. With the world lane off, `shadow_lit_eff`
// keeps MCSH and only the realtime `character_shadow_term` adds the dynamic character shadow.
// The world lane suppresses baked MCSH — but only by `sun_shadow_strength`, so as the realtime
// world shadow fades at night the baked MCSH fades back IN. By day (strength 1) MCSH is fully
// replaced; at night (strength 0) the authored bake returns; dusk crossfades. Character-only
// (lane flag off) never suppresses MCSH.
let world_shadow_lane = wow_light.sh_c16.w > 0.5;
let mcsh_suppress = select(0.0, sun_shadow_strength, world_shadow_lane);
let shadow_lit_eff = mix(shadow_lit, 1.0, mcsh_suppress);
let spec_gate = min(shadow_lit, world_shadow);
let character_shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, world_shadow);
// The faithful `terrainp_s` combine (Q15):
// diffuse = tex · primary · (0.3·shadow + 0.7) → a flat −30% in shadow, NO colour tint
// specular = per-vertex sheen · gloss_mask · shadow → gated to ZERO in shadow (no sheen in shade)
// (`tex·primary` is the MODULATE-1× diffuse; the sheen is added after, separate-specular.) Then
// LDR-clamp; gamma/byte throughout; raw gamma out (GAMMA LANE, 0161).
let diffuse_term = color * primary * (0.3 * shadow_lit + 0.7);
let spec_term = in.specular * specmask * shadow_lit;
let diffuse_term = color * primary * (0.3 * shadow_lit_eff + 0.7) * character_shadow_term;
let spec_term = in.specular * specmask * spec_gate;
var tuned = clamp(diffuse_term + spec_term, vec3<f32>(0.0), vec3<f32>(1.0));
// MONKEY (torch debug, interiorDebug 2 on TERRAIN): the ground's OWN cube-map sampling as
// greyscale, the same instrument static_gx and wow_model already paint on walls and bodies. The
// mode rides `wmo_fog_params.w` packed as `1 + debug` (rows 18-19 here, hence `_wmo_fog[1]`), so
// a zero - the interior lane off entirely - decodes to mode 0 and this is dead. Only mode 2 is
// answered: 1/3/4 are questions about the INTERIOR lane and the WMO group classes, and terrain
// belongs to neither, so leaving it normally shaded is what makes those three views readable.
// Placed before the fog so a distant reading still fogs like the surfaces beside it.
let idbg = u32(max(wow_light._wmo_fog[1].w - 1.0, 0.0) + 0.5);
if (idbg == 2u) {
tuned = vec3<f32>(torch_terrain_debug_factor(in.world_position.xyz, n_lit));
}
// STEP 5: gamma-space linear fog (q6). Applied AFTER tone/curve (so the diagnostic knobs see
// the unfogged lit pixel) and BEFORE the viz bypass + raw output. The fog coordinate is
// PLANAR eye-Z (view-space depth along the camera forward axis), NOT radial distance: the
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -24,7 +24,7 @@ use bevy::image::Image;
use bevy::prelude::*;
use bevy::render::render_resource::{Buffer, Face};
use crate::materials::{WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY_REF};
use crate::materials::{TorchBinds, WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY_REF};
use crate::SpatialCache;
use benilla_formats::{blp_to_rgba, read_texture_native_chain, tga_to_rgba, Chain, ModelBlend};
@@ -101,6 +101,10 @@ pub struct WorldAssets {
/// can clone it into every deduped model material's `light_buf` without threading a `Buffer` through
/// the many call sites. One buffer for the whole scene, updated in place each frame.
pub shared_light: Buffer,
/// MONKEY (torch shadows Phase 3A): the shared torch depth image + torch table buffer, held
/// beside `shared_light` for the same reason — cloned into every deduped model material's
/// `torch_depth`/`torch_buf` without threading them through the call sites.
pub torch: TorchBinds,
}
/// Identity of a deduped model material: a textured material is uniquely determined by its texture
@@ -388,7 +392,10 @@ impl WorldAssets {
/// sit under the renderer: the store's only use of it is `clone()` into every deduped model
/// material's `light_buf`, so the parameter severs what would otherwise be a dependency on the
/// lighting layout and, through it, the rig-palette regions (decision 1164).
pub fn open(chain: Chain, shared_light: Buffer) -> Self {
///
/// `torch` (MONKEY, torch shadows Phase 3A) is the same shape of seam for the torch-shadow
/// receiver bindings: the raw image handle + table buffer, cloned into every model material.
pub fn open(chain: Chain, shared_light: Buffer, torch: TorchBinds) -> Self {
Self {
chain: Arc::new(Mutex::new(chain)),
textures: SpatialCache::default(),
@@ -402,6 +409,7 @@ impl WorldAssets {
loose_root: None,
model_materials: SpatialCache::default(),
shared_light,
torch,
}
}
@@ -754,6 +762,9 @@ impl WorldAssets {
anim_slots: Vec4::ZERO,
// The shared global light (light/fog/SH come from here, updated in place each frame).
light_buf: self.shared_light.clone(),
// MONKEY (torch shadows Phase 3A): the shared torch receiver bindings.
torch_depth: self.torch.depth.clone(),
torch_buf: self.torch.table.clone(),
},
});
self.model_materials.insert(key, handle.clone());
@@ -690,6 +690,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
@@ -795,6 +804,32 @@ 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 (trans day law): also dump ONE group's per-VERTEX rows - position + the MOCV
/// alpha the shader interpolates as `trans_a` - plus every portal's vertex bounds. The
/// batch table above gives a batch's alpha RANGE and mean; a per-vertex blend law can only
/// be evaluated against the actual vertices, which is what this prints.
#[arg(long)]
verts: Option<usize>,
},
/// 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` →
@@ -1072,6 +1107,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,
@@ -1112,6 +1148,13 @@ fn main() -> Result<()> {
internal_path,
filter,
} => scan::wmodoodads(&mut chain, &internal_path, filter.as_deref())?,
Command::Wmolights {
internal_path,
verts,
} => scan::wmolights(&mut chain, &internal_path, verts)?,
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,804 @@ 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()
}
}
/// MONKEY (ext-class night law): the authored MOGN group names, indexed by absolute group index.
/// Not on [`benilla_formats::WmoRoot`] — nothing in the renderer needs them — but an audit that has
/// to say WHICH room seamed reads very differently with `kitchen`/`upstairs` beside the boxes than
/// with `g3`/`g4`. A raw chunk walk: MOGI is 32-byte records whose last i32 is a byte offset into
/// the MOGN string block (`-1` = unnamed, which vanilla uses freely).
fn group_names(bytes: &[u8]) -> Vec<Option<String>> {
let (mut mogn, mut mogi) = (None, None);
let mut o = 0usize;
while o + 8 <= bytes.len() {
let tag = [bytes[o + 3], bytes[o + 2], bytes[o + 1], bytes[o]];
let n = u32::from_le_bytes(bytes[o + 4..o + 8].try_into().unwrap()) as usize;
let body = bytes.get(o + 8..o + 8 + n);
match (&tag, body) {
(b"MOGN", Some(b)) => mogn = Some(b),
(b"MOGI", Some(b)) => mogi = Some(b),
_ => {}
}
o += 8 + n;
}
let (Some(mogn), Some(mogi)) = (mogn, mogi) else {
return Vec::new();
};
mogi.chunks_exact(32)
.map(|r| {
let off = i32::from_le_bytes(r[28..32].try_into().unwrap());
let off = usize::try_from(off).ok()?;
let rest = mogn.get(off..)?;
let end = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
Some(String::from_utf8_lossy(&rest[..end]).into_owned()).filter(|s| !s.is_empty())
})
.collect()
}
/// MONKEY (trans night floor): the group file's MOGP payload — `benilla_formats`'s own chunk walker
/// is `pub(crate)` and this instrument lives in a separate bin target, so the four lines are
/// repeated rather than the visibility widened for an audit.
fn mogp_payload(bytes: &[u8]) -> Option<&[u8]> {
let mut o = 0usize;
while o + 8 <= bytes.len() {
let tag = [bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]];
let n = u32::from_le_bytes(bytes[o + 4..o + 8].try_into().ok()?) as usize;
if &tag == b"PGOM" {
return bytes.get(o + 8..(o + 8 + n).min(bytes.len()));
}
o += 8 + n;
}
None
}
/// MONKEY (trans night floor): the per-group **MOBA batch-class** table — the instrument the
/// interior seam actually asks for.
///
/// `wmolights`'s group table above answers "which LIGHTING LANE is this group on" (INT / ext* /
/// ext). It cannot answer the next question down, which is the one a seam *inside* one interior
/// room reduces to: an interior group's batches are split TRANS / INT / EXT by the MOGP batch
/// section counts (`+0x28` / `+0x2a`), and the shader gives each class a DIFFERENT day weight
/// (`static_gx.wgsl`'s `day_w` — TRANS takes the MOCV alpha, EXT takes 1, INT takes 0). So a hard
/// line across a doorway inside one room is a BATCH-class line, invisible to every table we had.
///
/// The printed numbers are exactly the shader's inputs for that weight: the class, the batch's
/// triangle count, its world-space (model-space) bbox so it can be matched to a screenshot, and the
/// **MOCV alpha** range/mean — `trans_a` in the shader, straight off `wmo_group_fixed_colors` (the
/// buffer as the renderer uploads it, doorway fade included), i.e. the number `day_w = trans_a *
/// sun_w` is built from. `a=255` on a TRANS batch means the batch takes the exterior sky law at
/// FULL weight whenever the sun is above the horizon.
fn batch_class_table(
chain: &mut Chain,
root: &benilla_formats::WmoRoot,
root_path: &str,
names: &[Option<String>],
infos: &[benilla_formats::WmoGroupInfo],
// MONKEY (trans day law): dump this group's per-VERTEX rows as well (see the CLI doc).
verts: Option<usize>,
) {
println!("=== MOBA batch classes (per group; a = MOCV alpha = the shader's `trans_a`) ===");
let stem = root_path
.to_ascii_lowercase()
.strip_suffix(".wmo")
.unwrap_or(root_path)
.to_string();
for gi in 0..root.group_count() as usize {
let Ok(gbytes) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else {
continue;
};
let Ok(benilla_wmo::ParsedWmo::Group(group)) =
benilla_wmo::parse_wmo(&mut std::io::Cursor::new(gbytes.as_slice()))
else {
continue;
};
// The MOGP batch-section counts — the SAME two reads `build_wmo_group_submeshes` makes.
let (trans_n, int_n) = mogp_payload(&gbytes)
.filter(|m| m.len() >= 0x2c)
.map_or((0usize, 0usize), |m| {
(
u16::from_le_bytes([m[0x28], m[0x29]]) as usize,
u16::from_le_bytes([m[0x2a], m[0x2b]]) as usize,
)
});
// The colours AS UPLOADED (doorway fade applied), so the alpha printed is the alpha the
// shader interpolates. BGRA — index 3 is alpha.
let colors = benilla_formats::wmo_group_fixed_colors(&gbytes, root);
let interior = (group.flags & 0x48) == 0;
println!(
" g{gi:<3} {:<12} {:<4} batches {:<3} (trans {trans_n}, int {int_n}, ext {})",
names.get(gi).and_then(|n| n.as_deref()).unwrap_or("-"),
if interior { "INT" } else { "ext" },
group.render_batches.len(),
group.render_batches.len().saturating_sub(trans_n + int_n),
);
for (bi, batch) in group.render_batches.iter().enumerate() {
let class = if bi < trans_n {
"TRANS"
} else if bi < trans_n + int_n {
"INT"
} else {
"EXT"
};
let start = batch.start_index as usize;
let idx: Vec<usize> = group
.vertex_indices
.get(start..start + batch.count as usize)
.unwrap_or(&[])
.iter()
.map(|&i| i as usize)
.filter(|&g| g < group.vertex_positions.len())
.collect();
if idx.is_empty() {
continue;
}
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
let (mut a_lo, mut a_hi, mut a_sum, mut rgb_sum, mut n) =
(255u16, 0u16, 0u32, [0u32; 3], 0u32);
for &g in &idx {
let p = &group.vertex_positions[g];
for (k, v) in [p.x, p.y, p.z].into_iter().enumerate() {
lo[k] = lo[k].min(v);
hi[k] = hi[k].max(v);
}
if let Some(c) = colors.as_ref().and_then(|c| c.get(g)) {
let a = u16::from(c[3]);
a_lo = a_lo.min(a);
a_hi = a_hi.max(a);
a_sum += u32::from(c[3]);
// BGRA on disk — print it as RGB.
rgb_sum[0] += u32::from(c[2]);
rgb_sum[1] += u32::from(c[1]);
rgb_sum[2] += u32::from(c[0]);
n += 1;
}
}
let mean = |s: u32| if n == 0 { 0 } else { s / n };
println!(
" b{bi:<3} {class:<5} tris {:>5} a[{:>3}..{:>3}] mean {:>3} mocv rgb ({:>3},{:>3},{:>3}) box ({:>7.2},{:>7.2},{:>6.2})..({:>7.2},{:>7.2},{:>6.2})",
idx.len() / 3,
if n == 0 { 255 } else { a_lo },
if n == 0 { 255 } else { a_hi },
if n == 0 { 255 } else { mean(a_sum) as u16 },
mean(rgb_sum[0]), mean(rgb_sum[1]), mean(rgb_sum[2]),
lo[0], lo[1], lo[2], hi[0], hi[1], hi[2],
);
// MONKEY (trans day law): the per-VERTEX rows. `trans_a` is interpolated across the
// triangle from these alphas, so the BEFORE curve of the TRANS day blend is a function
// of exactly this list; the batch mean above cannot produce it.
if verts == Some(gi) {
let mut seen: Vec<usize> = Vec::new();
for &g in &idx {
if seen.contains(&g) {
continue;
}
seen.push(g);
let p = &group.vertex_positions[g];
let c = colors.as_ref().and_then(|c| c.get(g)).copied().unwrap_or([255; 4]);
let a = c[3];
println!(
" v{g:<5} ({:>7.2},{:>7.2},{:>6.2}) a {a:>3} ({:.3}) rgb ({:>3},{:>3},{:>3})",
p.x, p.y, p.z, f32::from(a) / 255.0, c[2], c[1], c[0],
);
}
print!(" tris");
for t in idx.chunks(3) {
print!(" [{}]", t.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(","));
}
println!();
}
}
}
let _ = infos;
println!();
}
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, verts: Option<usize>) -> 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.
// MONKEY (ext-class night law): the class column now says WHICH of the three lanes the group
// is on, because that is the question a seam asks. `INT` = the room lane always; `ext*` = an
// exterior-class group at BUILDING scale, which takes the room lane after dark (and which
// `interiorDebug 4` paints BLUE); `ext` = a district-scale shell, sky-lit always (RED).
println!("=== groups (name, lane, MOGP flags, MOGI box, portals, MOLR fixtures) ===");
let names = group_names(&bytes);
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} {:<12} {:<4} flags {:#010x} box ({:>8.2},{:>8.2},{:>7.2})..({:>8.2},{:>8.2},{:>7.2}) portals {pc:<3} molr {:?}",
names.get(gi).and_then(|n| n.as_deref()).unwrap_or("-"),
match (g.interior, benilla_formats::room_claim::ext_building_scale(g)) {
(true, _) => "INT",
(_, true) => "ext*",
_ => "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!();
// MONKEY (split-floor claims): the portal table. AREA is the measurement the split rule turns
// on ([`benilla_formats::room_claim::SPLIT_PORTAL_MIN_AREA`]) — a doorway is small, a room the
// artist cut in half is not — so it prints beside the groups each portal joins and a `SPLIT`
// marker for the ones a containment claim now crosses at full weight instead of fading.
println!("=== portals (polygon area vs SPLIT_PORTAL_MIN_AREA = {:.0} yd^2) ===",
benilla_formats::room_claim::SPLIT_PORTAL_MIN_AREA);
let graph = rr.graph();
for pi in 0..rr.portals.infos.len() {
let area = benilla_formats::room_claim::portal_area(&graph, pi as u16).unwrap_or(0.0);
let plane = rr.portals.infos[pi].plane;
let joins: Vec<u16> = rr
.portals
.refs
.iter()
.filter(|r| usize::from(r.portal) == pi)
.map(|r| r.group)
.collect();
// MONKEY (portal bleed): ...and the POLYGON's own box - centre + diagonal. The bleed seed
// reaches for both (`daylight_reach(diag)` is its radius, the centre its position), and the
// `BLEED` tag marks the population it seeds from: a portal whose two sides are BOTH
// interior-class groups, i.e. a doorway between two rooms rather than one to the outside.
// `DAY` is the daylight-fixture portal seed's own population, printed beside it so one
// table answers "which rule, if any, stands a light in this opening".
let pbox = rr
.portals
.infos
.get(pi)
.and_then(|i| {
let s = usize::from(i.start_vertex);
rr.portals.vertices.get(s..s + usize::from(i.count))
})
.map(|v| {
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for p in v {
for a in 0..3 {
lo[a] = lo[a].min(p[a]);
hi[a] = hi[a].max(p[a]);
}
}
(lo, hi)
});
let (diag, ctr) = pbox.map_or((0.0, [0.0; 3]), |(lo, hi)| {
(
((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt(),
[
0.5 * (lo[0] + hi[0]),
0.5 * (lo[1] + hi[1]),
0.5 * (lo[2] + hi[2]),
],
)
});
let cls: Vec<bool> = joins
.iter()
.filter_map(|g| rr.infos.get(usize::from(*g)).map(|g| g.interior))
.collect();
let kind = if cls.len() == 2 && cls.iter().all(|i| *i) {
" BLEED"
} else if cls.iter().any(|i| *i) {
" DAY"
} else {
""
};
println!(
" p{pi:<3} area {area:>7.2} yd^2 diag {diag:>6.2} ctr ({:>7.2},{:>7.2},{:>6.2}) |n.z| {:.2} joins {joins:?}{}{kind}",
ctr[0], ctr[1], ctr[2],
plane[2].abs(),
if area >= benilla_formats::room_claim::SPLIT_PORTAL_MIN_AREA { " SPLIT" } else { "" },
);
// MONKEY (trans day law): the portal's own vertex BOX under `--verts`. The daylight seed's
// calibration geometry is read off it and nothing else prints it: `seed_point` stands the
// fixture at the box centre but no higher than `SEED_MAX_HZ` above the box BOTTOM, and
// `hz = z - lo.z` is the calibration height. Without `lo[2]` neither number is recoverable
// from the centre+diagonal line above.
if verts.is_some() {
if let Some((lo, hi)) = pbox {
println!(
" box ({:>7.2},{:>7.2},{:>6.2})..({:>7.2},{:>7.2},{:>6.2})",
lo[0], lo[1], lo[2], hi[0], hi[1], hi[2],
);
}
}
}
println!();
batch_class_table(chain, &root, &root_path, &names, &infos, verts);
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,
);
}
@@ -32,7 +32,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, entityuvscan, envmapscan, fxuvscan, texmodescan, uvslotscan, uvwrapscan,
};
File diff suppressed because it is too large Load Diff
+16
View File
@@ -229,6 +229,22 @@ 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 (spell light): the THIRD route in that same module — a SPELL EFFECT's / FIREWORK's
// emitter, which the two above veto outright. Fire, holy and fel burn; frost, nature, arcane and
// shadow emit nothing at all.
pub use fire_light::{is_spell_light_path, synthesize_spell_light, SpellLightKind, SyntheticSpell};
// 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;
+948
View File
@@ -0,0 +1,948 @@
//! 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.
//! 2b. **SPLIT-FLOOR SIBLINGS** (MONKEY (split-floor claims)) — the group across a portal too
//! LARGE to be a doorway ([`SPLIT_PORTAL_MIN_AREA`]): one room the artist cut in two for
//! rendering. It joins the base at weight 1, because there is no threshold between the halves
//! to fade across — see [`ClaimHow::Split`].
//! 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,
/// MONKEY (split-floor claims): the group on the far side of a portal too LARGE to be a
/// doorway ([`SPLIT_PORTAL_MIN_AREA`]) — one half of a room the artist SPLIT for rendering.
/// Ranks and behaves as a BASE claim: hard (weight 1 everywhere), hop 0, and part of the
/// wavefront the portal walk seeds from.
Split,
}
impl ClaimHow {
pub fn tag(self) -> &'static str {
match self {
ClaimHow::Contains => "in",
ClaimHow::Molr => "molr",
ClaimHow::Portal => "portal",
ClaimHow::Split => "split",
}
}
}
/// 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))
}
/// MONKEY (split-floor claims): the AREA of one portal's polygon (yd²), fan-triangulated over the
/// MOPV vertices in authored order. The measure that separates a DOORWAY from a room the artist cut
/// in half: see [`SPLIT_PORTAL_MIN_AREA`] for the two populations it sits between.
pub fn portal_area(portals: &PortalGraph, portal: u16) -> Option<f32> {
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 sub = |a: &[f32; 3], b: &[f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
let mut area = 0.0f32;
for pair in rest.windows(2) {
let (u, v) = (sub(&pair[0], first), sub(&pair[1], first));
let c = [
u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0],
];
area += 0.5 * (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt();
}
Some(area)
}
/// 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;
/// MONKEY (split-floor claims): the polygon area (yd²) at or above which a portal is read as a
/// ROOM SPLIT rather than a doorway, so containment claims cross it at FULL weight instead of
/// fading (see [`ClaimHow::Split`]).
///
/// The soft portal fade is continuous only at the door it came THROUGH — `fade_center` is that
/// doorway, and the weight decays away from it. A room the artist cut in two (the Lion's Pride
/// Inn's gallery, split from the common room below it by a 10.5 x 9.1 yd opening; its cellar,
/// reached through a 10.2 x 3.4 yd hole in the kitchen floor) therefore reads the SAME fixture at
/// weight 1 on the near half (containment) and at whatever the fade has decayed to on the far half
/// — a straight brightness line across one continuous floor, at a plane that is not a wall. Worse,
/// the far half then re-seeds its own portal hops at that decayed weight, so every real doorway off
/// it inherits the step (the inn's upstairs rooms took the gallery's fixtures at 0.2–0.5 while the
/// gallery took them at 1.0, and the boundary between them is DIAGONAL — the reported scar).
///
/// **28 yd², from the corpus's own bimodal split.** Measured over the two buildings the seam was
/// reported in, sorted: the Goldshire inn's ten portals run 9.9, 10.8, 11.9, 11.9, 15.6, 17.3,
/// 17.3, 20.5 (every one of them a door or an arch) and then 35.1 (the cellar floor hole) and 95.9
/// (the gallery opening); Northshire abbey's fourteen run 14.3 … 16.5 for nine doorways and then
/// 40.3, 47.1, 50.7, 50.9, 252.6 for its nave splits. The union gap is 20.5 → 35.1 and 28 sits in
/// the middle of it, so no doorway in either building is merged and every split is.
///
/// AREA alone, deliberately — not "horizontal-ish", which is the shape a floor split usually has:
/// the abbey authors a HORIZONTAL portal of 14.3 yd² (g10↔g5, a hatch), and merging a real hatch
/// would light a closed room from the one below it. The two horizontal portals that ARE splits (the
/// inn's 35.1, the abbey's 252.6) both clear the area bar on their own, so the extra criterion buys
/// nothing and costs a false positive.
pub const SPLIT_PORTAL_MIN_AREA: f32 = 28.0;
/// `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`]).
pub 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)
}
/// MONKEY (ext-class night law): is this group EXTERIOR-class but at BUILDING scale — an inn's
/// shell, a basement stairwell, a covered porch, as opposed to a city district's shell?
///
/// The same cut [`claimable_by_box`] makes, read from the other side, and deliberately the SAME
/// one: a group the exterior lane will honour a candle's claim on is a group that ought to render
/// by candle-light at night, and a group whose claims that lane refuses (a district shell) must
/// keep the sky. Two thresholds here would put a group in the gap between them — lit by fixtures
/// but still sky-based, or sky-less with no fixtures allowed to reach it.
pub fn ext_building_scale(g: &WmoGroupInfo) -> bool {
!g.interior && claimable_by_box(g)
}
/// MONKEY (enclosed day floor): the slack (yd) an exterior shell's authored MOGI box is grown by
/// before it is asked whether it CONTAINS a room. An authored box is not always tight around its
/// own geometry (NSabbey g3's bottom floats 1.5 yd above its own floor), and a room whose centre
/// sits a hand's breadth outside its own building's shell is a measurement artefact, not a cave.
pub const SHELL_CONTAIN_SLACK: f32 = 0.5;
/// MONKEY (enclosed day floor): is this INTERIOR-class group a room INSIDE A BUILDING — i.e. is its
/// box centre contained in some EXTERIOR-class group of the same root at building scale
/// ([`ext_building_scale`])?
///
/// The question the daylight floor turns on, and the one thing that separates "a room whose door
/// this renderer cannot find" from "a cave that has no door". A building authors a shell: the
/// Goldshire inn's `g4 upstairs` (57.7 x 32.2 yd, exterior-class) swallows all ten of its rooms,
/// and Northshire abbey's `g5 mainlobby2` its nave. A dungeon authors none — `az_deadmines_*`,
/// Stratholme and the rest are interior groups end to end — so nothing there is ever flagged and
/// the sunless look of a cave is preserved by the DATA rather than by a special case.
///
/// A city's district shell is deliberately refused by [`ext_building_scale`]'s
/// [`CLAIM_EXT_SHELL_YD`] cut, for the same reason the claim lane refuses it: a 660 yd box is not a
/// building, and flagging every interior group in Stormwind because one shell spans the district
/// would daylight its sealed cellars.
pub fn enclosed_by_building_shell(groups: &[WmoGroupInfo], group: u16) -> bool {
let Some(g) = groups.get(usize::from(group)) else {
return false;
};
if !g.interior {
return false; // an exterior-class group is lit by the sky already
}
let c = [
0.5 * (g.bbox_min[0] + g.bbox_max[0]),
0.5 * (g.bbox_min[1] + g.bbox_max[1]),
0.5 * (g.bbox_min[2] + g.bbox_max[2]),
];
groups.iter().enumerate().any(|(i, e)| {
i != usize::from(group)
&& ext_building_scale(e)
&& (0..3).all(|a| {
c[a] >= e.bbox_min[a] - SHELL_CONTAIN_SLACK
&& c[a] <= e.bbox_max[a] + SHELL_CONTAIN_SLACK
})
})
}
/// `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);
}
// 2b. SPLIT-FLOOR SIBLINGS — MONKEY (split-floor claims). A portal too big to be a doorway
// ([`SPLIT_PORTAL_MIN_AREA`]) is the artist cutting ONE room in two for rendering, and the
// two halves must light as one: the far half takes this fixture at weight 1, exactly as the
// half it physically stands in does. Pushed HERE — after the base, before the hop — because
// it is base-equivalent in every way that matters downstream:
//
// * HARD (`fade = None`): the fade exists to make a THRESHOLD continuous, and a split has
// no threshold. Fading across it is what put the step on the continuous floor.
// * hop 0, and therefore in the wavefront the portal walk seeds from below at spend 0. That
// is the half of this rule that closes the *second-order* seam: the inn's upstairs rooms
// used to reach their gallery fixtures as hop-TWO claims (through the gallery opening
// first), entering at the 0.2–0.5 the first fade had already decayed to, while the
// gallery itself held them at 1.0 — a step at every one of those doorways, the wrong way
// round. Re-seeded from the merged half they are hop-ONE claims entering at 1.0, which is
// exactly the weight the gallery side reads there.
// * ranked above portal claims when the six slots are contested, for the same reason
// containment is: this is the room the fixture is in, spelt with two group ids.
//
// Transitive (the queue below re-walks each merged group), so a room cut into three merges
// whole. Both ends must be [`claimable_by_box`] — a district-scale shell is never one half
// of a room — and the opening must be within the fixture's own reach, so a split it cannot
// light is not merged into its set at the cost of a slot.
if reach > 0.0 && !portals.infos.is_empty() && out.len() < ROOM_CLAIM_MAX {
let mut queue: Vec<u16> = out.iter().map(|c| c.group).collect();
let mut head = 0usize;
while head < queue.len() && out.len() < ROOM_CLAIM_MAX {
let g = queue[head];
head += 1;
if groups
.get(usize::from(g))
.is_none_or(|gi| !claimable_by_box(gi))
{
continue;
}
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.len() >= ROOM_CLAIM_MAX {
break;
}
if out.iter().any(|c| c.group == r.group)
|| groups
.get(usize::from(r.group))
.is_none_or(|gi| !claimable_by_box(gi))
|| portal_area(&portals, r.portal).unwrap_or(0.0) < SPLIT_PORTAL_MIN_AREA
{
continue;
}
let Some((d, ..)) = portal_reach(&portals, r.portal, pos) else {
continue;
};
if d > reach {
continue;
}
push(&mut out, r.group, ClaimHow::Split, d, 0, None);
queue.push(r.group);
}
}
}
// 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,
}
}
/// MONKEY (enclosed day floor): a room inside a building's shell is flagged; a cave, a room
/// under a CITY-scale shell, and the shell itself are not.
#[test]
fn enclosed_is_a_room_in_a_building_and_nothing_else() {
// 0 the room, 1 the inn's own shell (57 x 32 yd, building scale), 2 a second room just
// OUTSIDE that shell, 3 a district-scale shell (200 yd) containing everything.
let groups = [
g(true, [0.0, 0.0, 0.0], [10.0, 10.0, 5.0]),
g(false, [-5.0, -5.0, -1.0], [52.0, 27.0, 10.0]),
g(true, [90.0, 90.0, 0.0], [95.0, 95.0, 5.0]),
g(false, [-100.0, -100.0, -10.0], [100.0, 100.0, 40.0]),
];
assert!(enclosed_by_building_shell(&groups, 0));
assert!(!enclosed_by_building_shell(&groups, 1), "a shell is not a room");
assert!(
!enclosed_by_building_shell(&groups, 2),
"only the DISTRICT shell holds it, and that is not a building"
);
assert!(!enclosed_by_building_shell(&groups, 3));
assert!(!enclosed_by_building_shell(&groups, 9), "out of range");
// A dungeon: interior groups end to end, no shell anywhere -> nothing is ever flagged.
let cave = [
g(true, [0.0, 0.0, 0.0], [10.0, 10.0, 5.0]),
g(true, [10.0, 0.0, 0.0], [20.0, 10.0, 5.0]),
];
assert!(!enclosed_by_building_shell(&cave, 0));
assert!(!enclosed_by_building_shell(&cave, 1));
// The slack really is applied: a room whose centre sits a hand's breadth outside its own
// shell is still in the building.
let tight = [
g(true, [0.0, 0.0, 0.0], [10.0, 10.0, 5.0]),
g(false, [-5.0, -5.0, -1.0], [52.0, 27.0, 2.3]),
];
assert!(enclosed_by_building_shell(&tight, 0), "centre z 2.5 vs shell top 2.3 + 0.5");
}
/// 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 (split-floor claims): a portal too LARGE to be a doorway merges the two halves of the
/// room it cuts — the far half takes the fixture HARD (weight 1), and the walk re-seeds from it
/// so a real doorway off that half is a FIRST hop at full entry, not a decayed second one.
#[test]
fn a_split_portal_merges_both_halves_and_reseeds_the_walk() {
// g0 holds the fixture, g1 is the other half of the same room (a 5 x 6 = 30 yd² opening,
// over SPLIT_PORTAL_MIN_AREA), g2 is a real room off g1 through a 2 x 3 = 6 yd² door.
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, -2.5, 0.0], [5.0, 2.5, 0.0], [5.0, 2.5, 6.0], [5.0, -2.5, 6.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 },
WmoPortalRef { portal: 0, group: 0, side: -1 },
WmoPortalRef { portal: 1, group: 2, side: 1 },
WmoPortalRef { portal: 1, group: 1, side: -1 },
];
let slices = [(0u16, 1u16), (1u16, 2u16), (3u16, 1u16)];
let portals = PortalGraph { vertices: &verts, infos: &infos, refs: &refs, slices: &slices };
assert!((portal_area(&portals, 0).unwrap() - 30.0).abs() < 1e-3);
assert!((portal_area(&portals, 1).unwrap() - 6.0).abs() < 1e-3);
let c = room_claims(&groups, portals, [2.0, 0.0, 1.0], 20.0, &[]);
assert_eq!(
c.iter().map(|c| (c.group, c.how, c.hops)).collect::<Vec<_>>(),
vec![
(0, ClaimHow::Contains, 0),
(1, ClaimHow::Split, 0),
(2, ClaimHow::Portal, 1),
],
);
assert_eq!(c[1].fade_radius, 0.0, "a split is HARD — weight 1 across the far half");
assert_eq!(c[2].fade_entry, 1.0, "…so the door off it is a FIRST hop at full entry");
// Under the bar the same portal is an ordinary doorway again: g1 fades, g2 is hop TWO.
let small = vec![
[5.0, -1.0, 0.0], [5.0, 1.0, 0.0], [5.0, 1.0, 3.0], [5.0, -1.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 portals = PortalGraph { vertices: &small, ..portals };
let c = room_claims(&groups, portals, [2.0, 0.0, 1.0], 20.0, &[]);
assert_eq!(
c.iter().map(|c| (c.group, c.how, c.hops)).collect::<Vec<_>>(),
vec![
(0, ClaimHow::Contains, 0),
(1, ClaimHow::Portal, 1),
(2, ClaimHow::Portal, 2),
],
);
}
/// 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]);
}
}
+20 -2
View File
@@ -21,6 +21,10 @@ impl Plugin for AssetPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, open_world_assets.in_set(AssetSet::Open));
app.add_systems(Update, (evict_world_art, scope_world_art));
// MONKEY (torch shadows Phase 3A): the shared torch depth image + table buffer's extract
// plugins and per-frame table upload — always on, like the light buffer's, because every
// model material binds them (`open_world_assets` creates them below).
crate::static_gx::register_torch_shared(app);
}
}
@@ -60,7 +64,11 @@ fn scope_world_art(mut scope: ArtScope, assets: Option<ResMut<WorldAssets>>) {
/// insert the shared [`WorldAssets`] (chain + dedup caches) + [`RenderConfig`]. If the client data
/// can't be found or opened, `WorldAssets` is simply absent and downstream startup falls back to
/// an empty free-fly scene.
fn open_world_assets(mut commands: Commands, device: Res<RenderDevice>) {
fn open_world_assets(
mut commands: Commands,
device: Res<RenderDevice>,
mut images: ResMut<Assets<Image>>,
) {
// The one shared global-light buffer, created here (RenderDevice is live by Startup) so it exists
// before any material is built. Inserted FIRST — ahead of the install lookup, so no early return
// below can skip it: it is cloned into `WorldAssets` (for model materials) and read as the
@@ -72,6 +80,16 @@ fn open_world_assets(mut commands: Commands, device: Res<RenderDevice>) {
let shared_light = crate::lighting::new_shared_light_buffer(&device);
let light_buf = shared_light.0.clone();
commands.insert_resource(shared_light);
// MONKEY (torch shadows Phase 3A): the shared torch depth image + table buffer, on the same
// always-present rule and for the same reason — every model material binds them, and a
// material built against a missing image never gets a bind group (every model blanks).
let (torch_image, torch_buffer) = crate::static_gx::new_torch_shared(&device, &mut images);
let torch = benilla_assets::materials::TorchBinds {
depth: torch_image.0.clone(),
table: torch_buffer.0.clone(),
};
commands.insert_resource(torch_image);
commands.insert_resource(torch_buffer);
let Some(data) = benilla_formats::wow_data() else {
warn!(
"no WoW install found — looked in {:?}; starting with no world",
@@ -99,7 +117,7 @@ fn open_world_assets(mut commands: Commands, device: Res<RenderDevice>) {
});
match open_chain(&data) {
Ok(chain) => commands.insert_resource(WorldAssets::open(chain, light_buf)),
Ok(chain) => commands.insert_resource(WorldAssets::open(chain, light_buf, torch)),
Err(e) => error!("failed to open client data: {e:#}"),
}
}
+209 -13
View File
@@ -66,6 +66,17 @@ use benilla_assets::materials::WowModelMaterial;
/// position compare and nothing else.
const RESAMPLE_DIST_SQ: f32 = 1e-4;
/// MONKEY (portal lane fade): how fast an anchor's crossfade weight travels between the exterior
/// lane (`0`) and the room lane (`1`), in weight-units/s. The same 2.0/s the ground-shade node
/// ramps its intensity at ([`crate::entity_shade`]'s ambient chase, the binary's `[0x810804]`) —
/// deliberately, because the two are the SAME transition seen from two sides: crossing a portal
/// re-seeds the node's ambient chase at the moment it re-lanes the part, and a half-second lane
/// blend under a quarter-second light chase (or vice versa) reads as two events, not one.
///
/// Surfaces already blend across a portal seam by geometry; entities had nothing, so an NPC
/// stepping through a doorway swapped from night-dark to torch-warm in ONE frame.
const LANE_RAMP_PER_SEC: f32 = 2.0;
/// Which WMO placements are resident — a generation counter the classifier re-evaluates entities on
/// (a building streaming in under a standing NPC must re-light it even though it didn't move).
/// Rebuilt each frame by the streamer ([`crate::terrain_stream`]) from its live placements; the
@@ -80,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
}
@@ -271,9 +286,82 @@ pub struct InteriorAnchor {
/// joining a matte-resolved anchor must force a re-resolve (the reauthor drain checks this),
/// or it would ride the matte fallback until the anchor next moves.
kind_bake: bool,
/// MONKEY (portal lane fade): the crossfade weight this anchor's parts currently render at —
/// `0` fully on the exterior result, `1` fully on the room result, chasing
/// [`Self::lane_target`] at [`LANE_RAMP_PER_SEC`]. It lives on the ANCHOR and not per part for
/// the same reason the law does: a body must never split across the blend (a half-faded head
/// on a fully-faded torso is worse than the pop it replaces), and one `f32` per light node is
/// nothing beside the `Vec3` position it already keeps.
///
/// The FIRST resolve snaps it to the target instead of ramping (see the insert site): an NPC
/// or GameObject that streams in already standing indoors has no "outside" to come from, and
/// fading it up from the exterior night result would make every indoor spawn flash dark —
/// which is the very artifact this ramp exists to remove, dressed as its cure.
lane: f32,
}
impl InteriorAnchor {
/// Where the lane weight is heading: the room result for either indoor law, the exterior one
/// outside. Both indoor laws share it because the shader gives them the SAME room lighting
/// (`wow_model.wgsl` routes `is_interior || matte_indoor` through `interior_room_light`), so a
/// `Bake`↔`Matte` flicker at a doorway must not restart the blend.
fn lane_target(law: AppliedLaw) -> f32 {
match law {
AppliedLaw::Exterior => 0.0,
AppliedLaw::Matte | AppliedLaw::Bake(_) => 1.0,
}
}
/// Step the crossfade toward this anchor's STANDING law's target, answering whether it moved —
/// which is what puts the anchor's parts back in the write path for a frame (the walk's
/// `changed` disjunct). Exact at both ends: the clamp lands the weight ON the target, so the
/// frame after a blend finishes reports `false` and the anchor is silent again.
fn step_lane(&mut self, dt: f32) -> bool {
let target = Self::lane_target(self.law);
if self.lane == target {
return false;
}
let step = dt * LANE_RAMP_PER_SEC;
self.lane = match self.lane < target {
true => (self.lane + step).min(target),
false => (self.lane - step).max(target),
};
true
}
/// The weight as the tag's 4-bit field ([`crate::mesh_tag::LANE_MAX`] steps).
fn lane_bits(&self) -> u8 {
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<WorldPointLight>, 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
@@ -288,6 +376,15 @@ impl InteriorAnchor {
}
}
/// MONKEY (portal lane fade): quantize a `0..=1` crossfade weight onto the tag's 4-bit lane field.
/// Rounds rather than truncates, so a settled weight of exactly `1.0` reaches the saturated
/// [`crate::mesh_tag::LANE_MAX`] — a truncating quantizer would leave every indoor entity one step
/// short of the room lane forever.
fn lane_bits(lane: f32) -> u8 {
let max = f32::from(crate::mesh_tag::LANE_MAX);
(lane * max).round().clamp(0.0, max) as u8
}
/// Parts whose material/tag need re-authoring from their anchor's current law — the classifier's
/// convergence queue (0734), replacing the per-part sweep's repair duty. Fed by the
/// [`InteriorLit`] `on_add` hook (a fresh part joining a settled anchor), the fade-latch observer
@@ -318,6 +415,12 @@ pub struct InteriorLit {
/// by whether that room is on the camera's chain this frame — decision 1792 §4), so a part can
/// need a rewrite for one with the other unchanged. Meaningless while `applied` is `None`.
fogged: bool,
/// MONKEY (portal lane fade): the tag's lane field as last written, the third component of the
/// write gate. It has to be recorded separately from `applied` because two writes can name the
/// same EFFECTIVE law and differ only in their blend weight — which is exactly what every
/// frame of a portal crossing looks like — and a gate that saw only the law would write the
/// first step of the blend and then freeze the entity mid-fade.
lane: u8,
}
impl InteriorLit {
@@ -372,6 +475,7 @@ impl InteriorLit {
exterior,
applied: None,
fogged: false,
lane: 0,
}
}
@@ -389,6 +493,7 @@ impl InteriorLit {
exterior,
applied: Some(AppliedLaw::Bake(0)),
fogged: true,
lane: crate::mesh_tag::LANE_MAX,
}
}
}
@@ -579,9 +684,21 @@ pub fn classify_entity_interior(
// thing a settled anchor still re-reads is this bit. A compare per anchor, and a
// part write only on the frames the verdict actually moves.
let fog = anchor_room_fogged(state.room, &instances);
if state.fog != fog {
// MONKEY (portal lane fade): the settled gate above is MOVEMENT, and the blend is
// TIME — a unit that steps through a doorway and stops dead in it (or is stopped
// by the server's next position update landing on the same spot) still owes the
// rest of its crossfade. So the lane steps here too, and a lane in motion puts the
// anchor's parts back through the write loop for exactly the frames it moves.
let lane_moved = state.step_lane(time.delta_secs());
if state.fog != fog || lane_moved {
state.fog = fog;
n_written += write_anchor_parts(state.law, fog, lit_parts, &mut parts);
n_written += write_anchor_parts(
state.law,
fog,
state.lane_bits(),
lit_parts,
&mut parts,
);
}
continue;
}
@@ -634,19 +751,31 @@ pub fn classify_entity_interior(
resolve_us += _r.elapsed().as_secs_f32() * 1e6;
let fog = anchor_room_fogged(room, &instances);
let kind_bake = bake.is_some();
let changed = match state.as_deref_mut() {
// MONKEY (portal lane fade): the law flip and the blend step are two DIFFERENT reasons to
// rewrite this anchor's parts, kept apart because only the first is an event — the second
// is the ~half-second of frames after it, and folding them into one `changed` would spam
// `WOW_INTERIOR_LOG` with 30 identical lines per doorway.
let (law_changed, lane_moved, lane) = match state.as_deref_mut() {
Some(state) => {
let changed = state.law != law || state.fog != fog;
let law_changed = state.law != law || state.fog != fog;
state.law = law;
state.room = room;
state.fog = fog;
state.last_pos = pos;
state.generation = residency.generation;
state.kind_bake = kind_bake;
changed
// Stepped AFTER the law lands, so the blend chases the verdict resolved THIS
// frame — on the flip frame it moves one step off the lane it was on, never a
// frame of the old target.
let lane_moved = state.step_lane(time.delta_secs());
(law_changed, lane_moved, state.lane_bits())
}
None => {
// `try_insert`: the anchor may carry a same-frame despawn already queued.
// The first resolve SNAPS the blend to its target rather than ramping into it
// (see [`InteriorAnchor::lane`]): an entity that streams in already indoors has
// no exterior state to come from, and fading it up would flash it dark.
let lane = InteriorAnchor::lane_target(law);
commands.entity(anchor).try_insert(InteriorAnchor {
law,
room,
@@ -654,13 +783,15 @@ pub fn classify_entity_interior(
last_pos: pos,
generation: residency.generation,
kind_bake,
lane,
});
true
(true, false, lane_bits(lane))
}
};
// Write the parts only when the law actually changed, so re-testing a moving NPC mid-room
// doesn't churn the render extraction.
if !changed {
// Write the parts only when the law actually changed (so re-testing a moving NPC mid-room
// doesn't churn the render extraction) — or while its blend is still travelling, which is
// a handful of parts on the few entities actually mid-portal.
if !law_changed && !lane_moved {
continue;
}
// `WOW_INTERIOR_LOG=1`: print interior classifications — the live-probe instrument for
@@ -675,7 +806,9 @@ pub fn classify_entity_interior(
// lighting like it": a node that resolves exterior and stays there is otherwise invisible
// to this instrument, and looks identical to a node the walk never visited.
let log = std::env::var("WOW_INTERIOR_LOG").ok();
// `law_changed` and not the write condition: the blend's frames are not classifications.
if log.is_some()
&& law_changed
&& (law != AppliedLaw::Exterior || had_state || log.as_deref() == Some("all"))
{
// The PART COUNT is load-bearing, not decoration: a lane readout says which law the
@@ -725,7 +858,7 @@ pub fn classify_entity_interior(
}
);
}
n_written += write_anchor_parts(law, fog, lit_parts, &mut parts);
n_written += write_anchor_parts(law, fog, lane, lit_parts, &mut parts);
}
// Drain the convergence queue: each entry re-authors from its anchor's standing law. Forced
// through the part's change gate — the enqueuing edges (fade latch, zoom release) mean a
@@ -751,9 +884,13 @@ pub fn classify_entity_interior(
if matches!(lit.kind, InteriorKind::Bake { .. }) && !state.kind_bake {
commands.entity(edge.0).try_remove::<InteriorAnchor>();
}
// MONKEY (portal lane fade): the anchor's CURRENT blend weight, so a part that converges
// mid-crossing lands on exactly what the walk wrote its siblings this frame — a fresh
// gear part authored at the settled weight would pop while the body it hangs off faded.
n_written += usize::from(write_part_law(
state.law,
state.fog,
state.lane_bits(),
&mut lit,
&mut material,
&mut tag,
@@ -818,6 +955,7 @@ type PartWrite<'w, 's> = Query<
fn write_anchor_parts(
law: AppliedLaw,
fog: bool,
lane: u8,
lit_parts: Option<&LitParts>,
parts: &mut PartWrite,
) -> usize {
@@ -828,6 +966,7 @@ fn write_anchor_parts(
written += usize::from(write_part_law(
law,
fog,
lane,
&mut lit,
&mut material,
&mut tag,
@@ -866,20 +1005,31 @@ fn write_anchor_parts(
/// `mesh_tag::describe` exists to catch a violation of (0355 broke exactly this way). Skipping it
/// would leave a part that classifies indoors while still *pending* carrying a probe slot on the
/// exterior material, where the shader decodes those bits as a ground-shade byte.
///
/// `lane` is the anchor's crossfade weight as tag bits (MONKEY, portal lane fade) and it is the
/// reason this function has an EFFECTIVE law distinct from its argument — see [`effective_law`].
fn write_part_law(
law: AppliedLaw,
fog: bool,
lane: u8,
lit: &mut InteriorLit,
material: &mut MeshMaterial3d<WowModelMaterial>,
tag: &mut MeshTag,
force: bool,
fade: Option<&crate::model_fade::FadeMaterials>,
) -> bool {
if lit.applied == Some(law) && lit.fogged == fog && !force {
let law = effective_law(law, lane);
// The gate is the full triple (law, fog, lane): two consecutive frames of a crossing name the
// same effective law and differ only in the weight, so a law+fog gate would write the blend's
// first step and freeze there. Exactly-gated in the other direction too — the weight reaches
// its target exactly (`InteriorAnchor::step_lane` clamps onto it), so a settled part still
// costs zero writes however long it stands there.
if lit.applied == Some(law) && lit.fogged == fog && lit.lane == lane && !force {
return false;
}
lit.applied = Some(law);
lit.fogged = fog;
lit.lane = lane;
let want = match fade {
Some(fm) => fm.material_for(Some(&*lit), true).clone(),
None => lit.steady_material().clone(),
@@ -889,12 +1039,49 @@ fn write_part_law(
}
let payload = match law {
AppliedLaw::Bake(slot) => crate::mesh_tag::with_interior_probe(tag.0, slot),
AppliedLaw::Matte | AppliedLaw::Exterior => crate::mesh_tag::with_exterior_reset(tag.0),
// MONKEY (torch shadows Phase 3A): the Matte law keeps the exterior material but flags
// the part INDOORS, so the shader's dynamic-interior lane holds across a Bake↔Matte
// flicker (the down-ray marginally hitting the baked floor at one spot). Since the portal
// lane fade it also carries the blend WEIGHT the shader mixes the two lanes by.
AppliedLaw::Matte => crate::mesh_tag::with_matte_indoor(tag.0, lane),
AppliedLaw::Exterior => crate::mesh_tag::with_exterior_reset(tag.0),
};
tag.0 = crate::mesh_tag::with_interior_fog(payload, fog);
true
}
/// MONKEY (portal lane fade): the law a part is actually AUTHORED under, given its anchor's
/// standing law and the blend weight that law is being reached at.
///
/// **The blend only ever exists in exterior material mode.** The exterior half of the crossfade is
/// the day/night result the exterior material computes, so both halves have to be available in one
/// fragment — and `Bake` is a different MATERIAL (interior mode, the probe-slot payload), which
/// cannot produce an exterior day/night result to fade from. So a part heading for `Bake` is
/// written as `Matte` — the exterior material, flagged indoors, at the current weight — for the
/// whole of the ramp, and commits the interior material and its probe slot only on the frame the
/// weight saturates. There is no visual seam at the commit: the shader routes `is_interior` and
/// the matte-indoor flag through the *same* `interior_room_light`, so the two agree at weight 1.
///
/// The mirror case is leaving: the standing law is already `Exterior` while the weight is still
/// unwinding, and until it reaches zero the part must keep the flag (and the exterior material it
/// already has) so the shader still has a room lane to fade OUT of. At zero it becomes the plain
/// exterior reset, which is what clears the flag and the field together.
///
/// The probe-slot lifecycle (`seat_probe_slot`/`BakeState`/`ParticleLight`) deliberately does NOT
/// wait on this: it turns over at the law flip, so a slot is seated (and the SH fold running)
/// slightly before the part starts reading it, and released slightly after it stops. That is the
/// harmless order — the alternative, a part on the interior material naming a slot the anchor has
/// already released, is the stuck-black-unit bug (0734).
fn effective_law(law: AppliedLaw, lane: u8) -> AppliedLaw {
match law {
// Mid-ramp toward the room: hold the exterior material and blend on the tag.
AppliedLaw::Bake(_) if lane < crate::mesh_tag::LANE_MAX => AppliedLaw::Matte,
// Mid-ramp back out: the verdict is outdoors, the blend is not there yet.
AppliedLaw::Exterior if lane > 0 => AppliedLaw::Matte,
settled => settled,
}
}
/// **Is this anchor's room on the camera's interior-fog chain this frame?** — the `[P+0x98]` half
/// of a unit's fog lane (decision 1792 §4), resolved through the same chain the room-visibility
/// leg walks: anchor record → placement instance → the flood's per-group gate.
@@ -1181,6 +1368,10 @@ mod tests {
last_pos: Vec3::ZERO, // matches the transform: the settled gate sees NO movement
generation,
kind_bake: true,
// MONKEY (portal lane fade): a SETTLED anchor — the blend already sits on its
// law's target, so `step_lane` is a no-op and the walk reads exactly as it did
// before the crossfade existed. Every fixture below is settled the same way.
lane: 1.0,
},
))
.id();
@@ -1299,6 +1490,7 @@ mod tests {
last_pos: Vec3::ZERO,
generation,
kind_bake: false,
lane: 1.0,
},
))
.id();
@@ -1345,6 +1537,7 @@ mod tests {
last_pos: Vec3::ZERO, // settled: the walk never re-rays this anchor
generation,
kind_bake: false,
lane: 1.0,
},
))
.id();
@@ -1414,6 +1607,7 @@ mod tests {
last_pos: Vec3::ZERO,
generation,
kind_bake: true,
lane: 1.0,
},
))
.id();
@@ -1484,6 +1678,7 @@ mod tests {
last_pos: Vec3::ZERO,
generation,
kind_bake: false,
lane: 0.0,
},
))
.id();
@@ -1574,6 +1769,7 @@ mod tests {
last_pos: Vec3::ZERO, // settled: the walk skips the ray, the drain does the work
generation,
kind_bake: true,
lane: 1.0,
},
))
.id();
+22 -8
View File
@@ -20,7 +20,7 @@ use bevy::render::render_resource::{Buffer, BufferDescriptor, BufferUsages};
use bevy::render::renderer::{RenderDevice, RenderQueue};
use super::global_light::{
commit_raw, light_blob_bytes, pack_model_core_rows, LIGHT_HEADER_ROWS, MAX_POINT_LIGHTS,
commit_raw, light_blob_bytes, pack_model_core_rows, LIGHT_HEADER_ROWS, MAX_LIVE_POINT_LIGHTS,
};
use super::prop_probes::prop_probe_region_offset;
use super::sh::prop_probe_coeffs;
@@ -94,11 +94,14 @@ impl LightBlob {
/// Add a point light: position, range, and its diffuse colour **before** the raw commit (the
/// over-gamut commit law is applied here — see [`commit_raw`]).
///
/// Silently capped at the table's capacity; past it a light is dropped rather than written
/// over the probe region that follows.
/// Silently capped at the table's LIVE capacity; past it a light is dropped rather than written
/// over the probe region that follows. MONKEY (ext light k8): that cap is
/// [`MAX_LIVE_POINT_LIGHTS`] (255), not the 256-slot buffer size — index 255 is the shaders'
/// `EXT_SEL_EMPTY` sentinel, so a light seated there would end every draw unit's selection list
/// early. Mirrors the truncation `build_light_data` applies to the scene table.
pub fn point(mut self, pos: Vec3, range: f32, color: [f32; 3]) -> Self {
if self.points.len() / 2 >= MAX_POINT_LIGHTS {
warn!("light blob: over {MAX_POINT_LIGHTS} point lights — dropping the rest");
if self.points.len() / 2 >= MAX_LIVE_POINT_LIGHTS {
warn!("light blob: over {MAX_LIVE_POINT_LIGHTS} point lights — dropping the rest");
return self;
}
let c = commit_raw(color);
@@ -167,6 +170,9 @@ impl LightBlob {
#[cfg(test)]
mod tests {
use super::*;
// MONKEY (ext light k8): only the cap test needs the SLOT count now — the builder caps on the
// LIVE one.
use super::super::global_light::MAX_POINT_LIGHTS;
/// The off-world defaults every producer used to hand-write: spec at the terrain convention,
/// fog off with an inert farclip wall, an empty point table.
@@ -204,16 +210,24 @@ mod tests {
);
}
/// Past the table's capacity a light is dropped, never written over the probe region that
/// Past the table's LIVE capacity a light is dropped, never written over the probe region that
/// follows it in the buffer.
///
/// MONKEY (ext light k8): that capacity is 255, ONE SHORT of the 256-slot buffer, because the
/// shaders' packed per-draw-unit selection now uses 8 bits per rank and reserves index 255 as
/// `EXT_SEL_EMPTY`. The last two assertions pin that relationship: if the cap is ever "tidied"
/// back up to the slot count, a 256th light packs as the end-of-list sentinel and every draw
/// unit that selected it silently drops the rest of its lights — a bug with no log line.
#[test]
fn the_point_table_stops_at_capacity() {
let mut b = LightBlob::model([0.0; 3], [0.0; 3], Vec3::NEG_Y);
for _ in 0..MAX_POINT_LIGHTS + 32 {
b = b.point(Vec3::ZERO, 1.0, [1.0; 3]);
}
assert_eq!(b.point_count(), MAX_POINT_LIGHTS);
assert_eq!(b.points.len(), 2 * MAX_POINT_LIGHTS);
assert_eq!(b.point_count(), MAX_LIVE_POINT_LIGHTS);
assert_eq!(b.points.len(), 2 * MAX_LIVE_POINT_LIGHTS);
assert_eq!(MAX_LIVE_POINT_LIGHTS, MAX_POINT_LIGHTS - 1);
assert!(MAX_LIVE_POINT_LIGHTS <= 255, "EXT_SEL_EMPTY is index 255");
}
/// Fog states colour and far independently of whether the shader applies it — the glue
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,447 @@
//! MONKEY (flame flicker): the per-light brightness wobble that makes a fire read as FIRE.
//!
//! Every flame in the world is a CONSTANT today. An authored M2 light block is sampled at its first
//! animation key and never again (the tracks are read once at bake), and a light SYNTHESISED from a
//! flame emitter ([`benilla_formats::fire_light`]) has no track at all — it is one number off an
//! intensity rung. So a wall torch, a candelabra and a bonfire all light their room with the
//! steadiness of a light bulb, which is the one cue that reads as "this is not fire".
//!
//! The fix is CPU-side and costs nothing, because of where it lands: `build_light_data` repacks the
//! whole point table into the storage buffer every single frame anyway (it re-sorts by camera
//! distance), so a per-light multiplier folded in at pack time is one hash and three sines per
//! packed entry — a few hundred flops for the whole table — and zero extra GPU work, zero extra
//! bandwidth, and no new `LightStd430` lane. Nothing is written back onto the `PointLight`
//! component, which is what keeps the flicker out of every OTHER consumer of that intensity:
//! `torch_shadow`'s caster ranking and hysteresis read the component and therefore still score the
//! UNMODULATED intensity, so a flame breathing at 5 Hz can never make a shadow map thrash in and
//! out of its slot.
//!
//! **What it must not be.** The failure mode has a name here — the "epileptic imp", the carried
//! light whose shadow slot toggled at frame rate (see `benilla_app::entities::carried_light`'s
//! stability note). A flicker that reads as a DISCO rather than as a flame is worse than no flicker
//! at all, so the waveform is built to be band-limited by construction rather than tuned by eye:
//!
//! * **Amplitude is small and ordered by mass.** A candle is a thumbnail of burning gas that a
//! draught can visibly bend (±12%); a bonfire is a cubic yard of it with a thermal time constant
//! (±6%). Ordering the rungs candle > torch > brazier > bonfire is the whole of the "plausible"
//! claim — the big fires are STEADIER, not just slower.
//! * **Frequency is bounded, and the fast partials carry the LEAST weight.** Three sines spread
//! across the kind's band, weighted `0.42 / 0.28 / 0.18` low→high, so the slowest partial
//! dominates the eye's impression and the peak slew is roughly half what the top of the band
//! would allow ([`FlameKind::max_rate`] states the bound the test asserts).
//! * **Weights sum to exactly 1**, so the excursion can never exceed the stated amplitude even when
//! all four terms align, and every term is zero-mean, so the MEAN multiplier is exactly 1.0 and
//! the room's tuned exposure ([`super::DynamicInteriors::exposure`]) is untouched.
//!
//! It is a function of ABSOLUTE time and a per-light seed only: frame-rate independent (the same
//! wall clock gives the same brightness at 30 fps and 240), deterministic, and — because the seed
//! hashes the light's own position — never in sync between two candles on the same table.
use bevy::prelude::*;
/// Which fire this is, and therefore how it burns. The four rungs are exactly the intensity ladder
/// the synthesiser buckets a flame emitter onto ([`benilla_formats::fire_intensity`]: candle 0.6 /
/// torch 1.5 / brazier 2.0 / bonfire 3.0) and the reach ladder the packer buckets an M2 light on
/// ([`super::m2_light_reach`]) — one ladder, so a light's flicker, its reach and its brightness can
/// never disagree about what kind of fire it is.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum FlameKind {
Candle,
Torch,
Brazier,
Bonfire,
}
impl FlameKind {
/// The rung this intensity sits on. Cut points are the MIDPOINTS of the synthesiser's own rungs
/// (0.6 / 1.5 / 2.0 / 3.0), so a synthesised light lands on its bucket exactly and an AUTHORED
/// one is filed by how bright the artist made it — the same "bucket it, don't read it" rule
/// [`super::m2_light_reach`] already applies to reach, and for the same reason (the authored
/// records carry brightness honestly and everything else as a template default).
pub fn from_intensity(i: f32) -> Self {
match i {
v if v < 1.0 => Self::Candle,
v if v < 1.75 => Self::Torch,
v if v < 2.5 => Self::Brazier,
_ => Self::Bonfire,
}
}
/// Peak excursion of the intensity multiplier at `fireFlicker 1` — `0.12` = ±12%.
///
/// Ordered candle > torch > brazier > bonfire because FLAME MASS is what damps a fire: a candle
/// tip is grams of gas and every draught in the room shows in it, a bonfire is a slow thermal
/// body whose brightness barely moves. Reading the order the other way (big fire = big flicker)
/// is the intuitive mistake, and it looks like a fault rather than like fire.
pub fn amplitude(self) -> f32 {
match self {
Self::Candle => 0.12,
Self::Torch => 0.10,
Self::Brazier => 0.08,
Self::Bonfire => 0.06,
}
}
/// The band (Hz) the three sine partials are spread across — again mass-ordered, and again for
/// the physical reason: a small flame's turbulence is fast, a big one's is slow. The top of the
/// candle band (7 Hz) is the highest rate anything here runs at, and it is the LEAST weighted
/// partial, so nothing in the feature approaches a strobe.
pub fn band(self) -> (f32, f32) {
match self {
Self::Candle => (4.0, 7.0),
Self::Torch => (3.0, 5.0),
Self::Brazier => (2.0, 4.0),
Self::Bonfire => (1.5, 3.0),
}
}
/// A hard upper bound on `|d/dt|` of the intensity multiplier, in units of multiplier per
/// second — the number that makes "band-limited" an assertion rather than an adjective.
///
/// Every partial's slew is `amp × weight × 2π × f`, bounded by the top of the band; the value
/// noise adds at most `amp × weight × 3 × f` (a smoothstep's max slope is 1.5, over a span of
/// at most 2). Loose by design — it bounds the worst possible phase alignment, which the
/// waveform will not actually reach — but that is what a bound is for.
pub fn max_rate(self, gain: f32) -> f32 {
let (_, hi) = self.band();
let sines = std::f32::consts::TAU * hi * (W_SLOW + W_MID + W_FAST);
let noise = 3.0 * hi * W_NOISE;
self.amplitude() * gain.max(0.0) * (sines + noise)
}
}
// The four term weights. They SUM TO EXACTLY 1, which is the invariant that makes
// [`FlameKind::amplitude`] a true peak bound (all four terms at +1 gives exactly `1 + amp`), and
// the ordering slow > mid > fast > noise is what keeps the eye's impression on the low frequencies
// — the difference between "the flame breathes" and "the lamp is faulty".
const W_SLOW: f32 = 0.42;
const W_MID: f32 = 0.28;
const W_FAST: f32 = 0.18;
const W_NOISE: f32 = 0.12;
/// Peak wobble on the BLUE channel alone at `fireFlicker 1` — the colour-temperature term.
///
/// A flame's colour genuinely moves with its brightness (a starved flame is redder, a fed one
/// whiter), and 3% on blue alone is the cheapest honest expression of that: it needs no colour
/// space, cannot push the light out of gamut, and at this size reads as warmth rather than as a
/// hue shift. It runs at its own slow rate and its own phase, so it never simply tracks the
/// intensity — a flame that dims and reddens in lockstep looks like a dimmer knob.
const BLUE_WOBBLE: f32 = 0.03;
/// The blue term's rate as a fraction of the kind's slowest partial — deliberately SLOWER than any
/// brightness partial. Colour temperature is a property of the whole flame body, not of the tip
/// that flickers.
const BLUE_RATE: f32 = 0.37;
/// MONKEY (flame flicker): marks a `PointLight` that burns rather than shines, with everything the
/// modulation needs — `kind` (how deep and how fast) and `seed` (whose phase).
///
/// Inserted ONCE, at spawn, and never removed or changed: the flicker is a pure function of the
/// component plus the clock, so there is no per-frame write, no `Changed` traffic and no archetype
/// churn — the entity's archetype is settled by the end of the spawn command queue exactly as it
/// was before this component existed.
#[derive(Component, Clone, Copy)]
pub struct FlameFlicker {
pub kind: FlameKind,
/// The phase seed — [`flicker_seed`] of the light's own position, so no two flames in a room
/// are ever in step. Two candles flickering together is the single most artificial thing this
/// feature could do, and it is what a shared global phase would produce.
pub seed: u32,
}
/// One frame's modulation: multipliers, not values, so the caller keeps its authored colour and
/// intensity intact and folds these in at the last moment.
#[derive(Clone, Copy, Debug)]
pub struct FlickerMod {
/// Multiplier on the light's committed intensity. Mean exactly 1.0.
pub intensity: f32,
/// Multiplier on the BLUE channel only — the colour-temperature wobble. Mean exactly 1.0.
pub blue: f32,
}
impl FlickerMod {
/// The identity — what a non-flame light gets, and what every flame gets at `fireFlicker 0`.
pub const STEADY: Self = Self {
intensity: 1.0,
blue: 1.0,
};
}
impl FlameFlicker {
pub fn new(kind: FlameKind, seed: u32) -> Self {
Self { kind, seed }
}
/// This flame's multipliers at absolute time `t` (seconds) and live gain `gain`
/// (`fireFlicker`: 0 off, 1 the authored amplitudes, 2 doubled).
///
/// `t` is ABSOLUTE elapsed time, never a delta: that is what makes the result frame-rate
/// independent (a 30 fps and a 240 fps client show the same flame at the same instant) and what
/// lets the whole thing be stateless — no per-light accumulator to keep, restore or desync.
pub fn at(&self, t: f32, gain: f32) -> FlickerMod {
if gain <= 0.0 {
return FlickerMod::STEADY;
}
let (lo, hi) = self.kind.band();
let span = hi - lo;
// The three partials at the bottom / middle / top of the band, each JITTERED by up to ±12%
// of the span from its own seed word. Without the jitter every candle in the game would
// share the same three frequencies and differ only in phase, which beats into a visible
// collective rhythm across a room full of them; with it, no two flames ever repeat.
let f = |k: u32, frac: f32| {
let jitter = (unit(self.seed ^ mix(0x9e37_79b9u32.wrapping_mul(k + 1))) - 0.5) * 0.24;
(lo + span * (frac + jitter)).clamp(lo * 0.85, hi)
};
let phase = |k: u32| unit(mix(self.seed.wrapping_add(0x85eb_ca6b).wrapping_mul(k + 3)));
let s = |k: u32, frac: f32| {
(std::f32::consts::TAU * (f(k, frac) * t + phase(k))).sin()
};
// Three sines plus one smoothed value-noise term. The noise is what stops the sum reading
// as a sum of sines — a pure harmonic stack has an audible-to-the-eye periodicity at the
// beat of its partials, and a fire has none. It runs at the SLOW edge of the band so it
// contributes shape, not speed.
let w = W_SLOW * s(0, 0.0)
+ W_MID * s(1, 0.5)
+ W_FAST * s(2, 1.0)
+ W_NOISE * value_noise(t * lo, self.seed ^ 0x1234_5678);
let amp = self.kind.amplitude() * gain;
// The blue term is independent: its own phase, its own (slower) rate. See [`BLUE_WOBBLE`].
let b = (std::f32::consts::TAU * (lo * BLUE_RATE * t + phase(7))).sin();
FlickerMod {
// `max(0)` is belt-and-braces: with `Σw == 1` and the cvar clamped to 2 the worst case
// is `1 − 0.24`, and a negative multiplier could not be committed anyway.
intensity: (1.0 + amp * w).max(0.0),
blue: (1.0 + BLUE_WOBBLE * gain * b).max(0.0),
}
}
}
/// MONKEY (flame flicker): does this light burn, and as what? The ONE rule, called by every spawn
/// lane so a candle cannot flicker as a candle in a WMO and as a bonfire on a transport.
///
/// * `flame` — the light was SYNTHESISED from a model's flame particle emitter
/// ([`benilla_assets::ModelLight::flame`]). That is a fire by construction, whatever colour it
/// burns: `OgreWallTorchpurple` and `HumanBrazierMagic` are flames with strange chemistry, not
/// lamps, and they flicker. **The route decides, not the hue** — which is why this arm is tested
/// before any colour test at all.
/// * `synthetic && !flame` — the LAMP route (`fire_light::synthesize_lamp_light`): a lamppost, a
/// hanging lantern, a sconce, a chandelier, whose "flame" is a pane of unlit glass geometry.
/// Behind glass a flame does not visibly flicker, and a street lamp that did would read as a
/// fault in the city rather than as fire. **No flicker.**
/// * An AUTHORED light block or MOLT fixture — flicker iff its colour is WARM (`r > g > b` with a
/// real saturation). The authored fixture corpus is overwhelmingly candles and wall torches
/// filed at that hue; the things it is NOT are the neutral-white fill lights artists park in
/// halls and the cold blue/green magic sources, and both are exactly what the warmth test
/// excludes. It is a heuristic, and it is why `fireFlicker 0` exists.
///
/// Returns the KIND on the same intensity ladder every lane already uses, so the caller only has to
/// hand over what it is holding anyway.
pub fn flame_kind_for(
flame: bool,
synthetic: bool,
color: [f32; 3],
intensity: f32,
) -> Option<FlameKind> {
if flame {
return Some(FlameKind::from_intensity(intensity));
}
if synthetic {
return None; // the lamp/emissive route — glass, not flame
}
is_warm(color).then(|| FlameKind::from_intensity(intensity))
}
/// The warmth test for an AUTHORED source: strictly descending `r > g > b` with a saturation
/// (`max − min`, which for a descending triple is `r − b`) over `0.2`.
///
/// Both halves earn their place. The ORDER alone admits `(0.9, 0.88, 0.86)` — the near-white fill
/// light, which must not breathe. The SATURATION alone admits a cold blue fixture. Together they
/// name the warm family and nothing else; the shipped warm fixtures clear the floor by a wide
/// margin (a candle's authored `(1.0, 0.72, 0.40)` sits at 0.60).
fn is_warm(c: [f32; 3]) -> bool {
c[0] > c[1] && c[1] > c[2] && c[0] - c[2] > 0.2
}
/// MONKEY (flame flicker): a light's phase seed from WHERE IT IS.
///
/// Position is the only identity a light has that is stable across a stream-out/stream-in (entity
/// ids are not), so a torch keeps its phase when you walk out of the room and back — a re-seeded
/// flame would visibly jump. Quantised to 1/16 yd before hashing so floating-point noise in the
/// placement transform cannot change the seed between two spawns of the same fixture.
pub fn flicker_seed(p: Vec3) -> u32 {
let q = |v: f32| (v * 16.0).round() as i32 as u32;
mix(q(p.x) ^ mix(q(p.y) ^ mix(q(p.z))))
}
/// A 32-bit integer finaliser (the `lowbias32` mix) — the whole "randomness" budget of this
/// feature. Not a PRNG: a hash, so every value it produces is reproducible from its input alone,
/// which is what makes the flicker deterministic per light rather than per session.
fn mix(mut x: u32) -> u32 {
x ^= x >> 16;
x = x.wrapping_mul(0x7feb_352d);
x ^= x >> 15;
x = x.wrapping_mul(0x846c_a68b);
x ^= x >> 16;
x
}
/// A hash word as a `[0, 1)` float — 24 bits, the f32 mantissa, so the conversion is exact.
fn unit(x: u32) -> f32 {
(mix(x) >> 8) as f32 / 16_777_216.0
}
/// Smoothed value noise in `[-1, 1]`: hash a value at each integer step of `t` and smoothstep
/// between them.
///
/// Zero-mean by construction (the per-step values are uniform on `[-1, 1]` and the interpolation is
/// a convex blend of two of them), and band-limited by construction too — its steepest slope is
/// `1.5 × span` per unit of `t`, which is what [`FlameKind::max_rate`] budgets for. That
/// combination is the reason it is value noise and not a random walk or a filtered PRNG: both of
/// those drift off the mean and neither has a slope bound.
fn value_noise(t: f32, seed: u32) -> f32 {
let i = t.floor();
let u = t - i;
let u = u * u * (3.0 - 2.0 * u); // smoothstep — C1 at the knots, so no slope discontinuity
let k = i as i64 as u32;
let a = unit(seed ^ mix(k)) * 2.0 - 1.0;
let b = unit(seed ^ mix(k.wrapping_add(1))) * 2.0 - 1.0;
a + (b - a) * u
}
#[cfg(test)]
mod tests {
use super::*;
const KINDS: [FlameKind; 4] = [
FlameKind::Candle,
FlameKind::Torch,
FlameKind::Brazier,
FlameKind::Bonfire,
];
/// The weight budget is the invariant everything else rests on: it makes
/// [`FlameKind::amplitude`] a true peak bound and the mean exactly 1.
#[test]
fn the_term_weights_sum_to_one() {
assert!((W_SLOW + W_MID + W_FAST + W_NOISE - 1.0).abs() < 1e-6);
}
/// GOLDEN — the tuned exposure must survive the feature. A flicker whose mean is not 1 is a
/// brightness change wearing a flicker's clothes, and it would silently retune every interior.
#[test]
fn the_mean_multiplier_is_one_over_a_long_window() {
for kind in KINDS {
for seed in [1u32, 0xdead_beef, 12345, 7] {
let f = FlameFlicker::new(kind, seed);
let n = 60_000;
let (mut si, mut sb) = (0.0f64, 0.0f64);
for k in 0..n {
let m = f.at(k as f32 * 0.005, 1.0); // 300 s at 200 Hz
si += f64::from(m.intensity);
sb += f64::from(m.blue);
}
let (mi, mb) = (si / f64::from(n), sb / f64::from(n));
assert!((mi - 1.0).abs() < 0.005, "{kind:?}/{seed}: mean {mi}");
assert!((mb - 1.0).abs() < 0.002, "{kind:?}/{seed}: blue mean {mb}");
}
}
}
/// GOLDEN — the anti-strobe assertion. Excursion inside the stated amplitude, and slew inside
/// the stated bound: this is the test that would fail if someone widened a band or reweighted
/// the partials toward the fast end.
#[test]
fn the_waveform_is_bounded_in_amplitude_and_in_slew() {
for kind in KINDS {
for gain in [1.0f32, 2.0] {
let f = FlameFlicker::new(kind, 0xabcd_1234);
let dt = 0.0005;
let bound = kind.max_rate(gain);
let mut prev = f.at(0.0, gain).intensity;
for k in 1..40_000 {
let m = f.at(k as f32 * dt, gain).intensity;
let amp = kind.amplitude() * gain;
assert!(
(m - 1.0).abs() <= amp + 1e-4,
"{kind:?} gain {gain}: excursion {} over amp {amp}",
m - 1.0
);
let rate = (m - prev).abs() / dt;
assert!(rate <= bound, "{kind:?} gain {gain}: {rate}/s over {bound}/s");
prev = m;
}
}
}
}
/// A steadier fire for a bigger fire — the ordering that is the whole "plausible" claim, held
/// both in the authored amplitude and in the bound the waveform actually reaches.
#[test]
fn a_bigger_fire_flickers_less() {
let amps: Vec<f32> = KINDS.iter().map(|k| k.amplitude()).collect();
assert!(amps.windows(2).all(|w| w[0] > w[1]), "{amps:?}");
let rates: Vec<f32> = KINDS.iter().map(|k| k.max_rate(1.0)).collect();
assert!(rates.windows(2).all(|w| w[0] > w[1]), "{rates:?}");
}
/// Deterministic per seed, and DIFFERENT per seed — two candles on one table must not breathe
/// together, which is the failure a shared global phase would produce.
#[test]
fn the_phase_is_deterministic_and_unshared() {
let a = FlameFlicker::new(FlameKind::Candle, flicker_seed(Vec3::new(1.0, 2.0, 3.0)));
let b = FlameFlicker::new(FlameKind::Candle, flicker_seed(Vec3::new(1.5, 2.0, 3.0)));
assert_ne!(a.seed, b.seed);
let same = FlameFlicker::new(FlameKind::Candle, flicker_seed(Vec3::new(1.0, 2.0, 3.0)));
let mut apart = 0.0f32;
for k in 0..2000 {
let t = k as f32 * 0.01;
assert_eq!(a.at(t, 1.0).intensity, same.at(t, 1.0).intensity);
apart = apart.max((a.at(t, 1.0).intensity - b.at(t, 1.0).intensity).abs());
}
assert!(apart > 0.05, "two neighbouring candles ran in step: {apart}");
}
/// `fireFlicker 0` is the off switch — a hard identity, not merely a small wobble.
#[test]
fn gain_zero_is_steady_and_gain_two_is_double() {
let f = FlameFlicker::new(FlameKind::Torch, 99);
for k in 0..500 {
let t = k as f32 * 0.02;
assert_eq!(f.at(t, 0.0).intensity, 1.0);
assert_eq!(f.at(t, 0.0).blue, 1.0);
// Every term is linear in the gain, so doubling it doubles the excursion exactly.
let (a, b) = (f.at(t, 1.0).intensity, f.at(t, 2.0).intensity);
assert!(((b - 1.0) - 2.0 * (a - 1.0)).abs() < 1e-5);
}
}
/// The route rule: a purple magic flame flickers, a lamppost does not, a white fill light does
/// not — and the intensity ladder files each on its own rung.
#[test]
fn only_flames_flicker() {
// Flame route wins over any colour test: a green magic brazier is still a fire.
assert_eq!(
flame_kind_for(true, true, [0.4, 1.0, 0.45], 2.0),
Some(FlameKind::Brazier)
);
// Lamp route: glass, never a flicker — even at a warm hue.
assert_eq!(flame_kind_for(false, true, [1.0, 0.72, 0.40], 1.5), None);
// Authored + warm ⇒ flickers, filed by intensity.
assert_eq!(
flame_kind_for(false, false, [1.0, 0.72, 0.40], 0.6),
Some(FlameKind::Candle)
);
// Authored + near-white fill, and authored + cold: neither burns.
assert_eq!(flame_kind_for(false, false, [0.9, 0.88, 0.86], 1.5), None);
assert_eq!(flame_kind_for(false, false, [0.45, 0.65, 1.0], 1.5), None);
// The ladder itself, at the synthesiser's own rungs.
for (i, k) in [
(0.6, FlameKind::Candle),
(1.5, FlameKind::Torch),
(2.0, FlameKind::Brazier),
(3.0, FlameKind::Bonfire),
] {
assert_eq!(FlameKind::from_intensity(i), k, "rung {i}");
}
}
}
File diff suppressed because it is too large Load Diff
+47 -1
View File
@@ -11,13 +11,44 @@ use benilla_assets::AssetSet;
use benilla_formats::{LightCatalog, LiquidKind};
mod blob; // the off-world light-blob builder (booth studio, body pane, glue scene)
mod daylight; // MONKEY (daylight fixtures): the sun as an interior-lane light in a doorway
mod daynight; // the two sun directions + day/night interp + the dawn/dusk warp curve
mod flicker; // MONKEY (flame flicker): the per-light fire wobble folded in at pack time
mod global_light; // the one shared global-light storage buffer (replaces the per-material push)
mod prop_probes; // the per-instance interior-prop SH probe table (slot ↔ MeshTag payload)
mod resolve; // the per-frame time-of-day sample into WowLighting + the WMO interior-fog crossfade
mod sh; // the model SH light-probe coefficient math
pub use blob::LightBlob;
pub use global_light::{new_shared_light_buffer, LightRooms, SharedLightBuffer, WorldPointLight};
// MONKEY (daylight fixtures): the marker (torch_shadow excludes it), the selection rule and the
// spawn-side helpers the placement lane calls.
// MONKEY (portal bleed): `placement_openings` is the ONE selection entry point the placement lane
// calls now -- daylight seeds and interior<->interior doorway seeds share the per-placement budget,
// so neither can be ranked without the other. `BleedFixture`/`BleedSeed` are the doorway lane's own
// two types; the fixture itself still wears `DaylightFixture`.
pub use daylight::{
daylight_claims, daylight_intensity, daylight_lane, daylight_point_light, daylight_reach,
daylight_rooms, daylight_seeds, daylight_target, bleed_seeds, placement_openings, BleedFixture,
BleedSeed,
DaylightFixture, DaylightHow, DaylightSeed, BLEED_K, MAX_DAYLIGHT_PER_PLACEMENT,
};
// MONKEY (flame flicker): the component + the one route rule, so every spawn lane files a flame
// the same way and the packer has a single function to evaluate.
pub use flicker::{flame_kind_for, flicker_seed, FlameFlicker, FlameKind, FlickerMod};
pub use global_light::{
interior_reach, m2_light_reach, new_shared_light_buffer, DynamicInteriors, FireLightGain,
ClaimFade, LightLane, LightLitRooms, LightReach, LightRooms, RoomClaimTable,
SharedLightBuffer,
ShadowDistance, ShadowFilterGaussian, ShadowProxyLight, SyntheticFireLight,
WorldShadowActive,
};
// MONKEY (merge 2026-09-17): upstream's world-light component, adopted as THE world light.
pub use global_light::WorldPointLight;
// MONKEY (spellLightGain): the spell lane's marker + its live gain — the two-word world-side
// shadow of benilla-app's own `SpellLight` lifecycle, and the dial the packer folds over it.
pub use global_light::{SpellFxLight, SpellLightGain};
pub use global_light::{
room_claim_bytes, CLAIM_EXT_OK, LIT_ROOM_EXT_DENY, ROOM_CLAIM_MAX, ROOM_CLAIM_STRIDE,
};
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
// — stays in the crate. Off-world producers state values through `LightBlob` and never a row index
@@ -166,6 +197,13 @@ pub struct WowLighting {
}
impl WowLighting {
/// The visible **celestial sun** direction (camera→sun, Bevy space) — the body that genuinely
/// rises and sets over the day. Exposed for the shadow rig, which aims its basis at this MOVING
/// sun (with an elevation clamp) rather than the near-fixed lighting `sun_dir`.
pub fn celestial_dir(&self) -> Vec3 {
self.celestial_dir
}
/// Per-kind **water swatch endpoints**: `(shallow_rgb, deep_rgb, shallow_alpha, deep_alpha)`. These
/// are the ENDPOINTS; the ramp between them is a 64-row byte-space accumulator that `liquid.wgsl`
/// reproduces (`swatch_row`), not the plain lerp this doc used to describe — it stops one row short
@@ -363,6 +401,8 @@ impl Plugin for LightingPlugin {
// The shared global-light buffer (build_light_data after the resolve above; the extract +
// render-world upload). Materials read this instead of carrying their own light copy.
global_light::register(app);
// MONKEY (daylight fixtures): the per-frame re-aim, ordered before the packer's own set.
daylight::register(app);
}
}
@@ -449,6 +489,12 @@ mod ordering_tests {
"the celestial follows: PostUpdate, BillboardPlace",
),
("weather/precip/mod.rs", "push_precip: PostUpdate"),
// MONKEY (daylight fixtures / portal bleed): both systems are PostUpdate,
// chained before `global_light::classify_light_lanes`.
(
"lighting/daylight.rs",
"update_daylight_fixtures + update_bleed_fixtures: PostUpdate",
),
];
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
+96 -2
View File
@@ -50,7 +50,16 @@
//! `255` = fully MCSH-shadowed): the per-instance mix from the batch's lit sun level toward the
//! shaded one (`wow_model.wgsl`). Entities (units/players/GameObjects) ramp it per frame
//! ([`crate::entity_shade`]); statics (doodads/props) leave it `0` — their shade is the
//! per-material selector (`sun_scale.x`). Bits 14..=18 are reserved (0).
//! per-material selector (`sun_scale.x`). Bit 14 is the **MATTE-INDOOR flag**
//! ([`MATTE_INDOOR_BIT`], MONKEY torch shadows Phase 3A): the classifier's `Matte` law — an
//! entity standing INDOORS whose material nonetheless stays in exterior mode (day/night, no
//! bake). `wow_model.wgsl` routes such a part to the dynamic-interior lane on it, so a unit
//! whose verdict flickers `Bake`↔`Matte` at one spot keeps its lane. Bits 15..=18 are the
//! **lane weight** ([`LANE_MASK`], MONKEY portal lane fade): a 4-bit crossfade from the
//! exterior result (`0`) to the room result (`15`), so an entity walking through a portal
//! blends between the two lanes over the anchor's ramp instead of popping in one frame. It is
//! meaningful only alongside bit 14 — [`with_matte_indoor`] writes the pair, and the shader
//! mixes by it.
//! - **Interior probe slot** (interior M2 props/entities — material in interior mode,
//! `model_flags.z` set, not a WMO): bits 6..=18 carry the SH-probe TABLE SLOT (see
//! [`crate::lighting::PropProbes`]; 8192 slots = 13 bits), alpha and rig ride their fixed
@@ -169,6 +178,24 @@ const ALPHA_MAX: f32 = 63.0;
/// Bits 6..=13 of the exterior payload: the ground-shade byte.
const SHADE_MASK: u32 = 0x0000_3fc0;
const SHADE_SHIFT: u32 = 6;
/// Bit 14 of the exterior payload (MONKEY, torch shadows Phase 3A): the part's anchor classified
/// INDOORS under the `Matte` law — exterior material mode, but the room's dynamic light applies.
/// Sits in the exterior payload's reserved bits, so [`with_shade`]/[`with_alpha`]/[`with_rig`]
/// carry it through and the shader's 8-bit shade decode never sees it.
pub(crate) const MATTE_INDOOR_BIT: u32 = 0x0000_4000;
/// Bits 15..=18 of the exterior payload (MONKEY, portal lane fade): the **lane weight**, a 4-bit
/// crossfade `0` (fully exterior-lit) … [`LANE_MAX`] (fully room-lit) that the shader mixes the
/// two lanes by. It rides beside [`MATTE_INDOOR_BIT`] — the bit says "this part is on the room
/// lane at all", the field says how far — and like it, sits above the shade byte so
/// [`with_shade`]/[`with_alpha`]/[`with_rig`]/[`with_interior_fog`] carry it through and
/// [`with_exterior_reset`] clears it. 16 steps is past perceptual for a half-second ramp (the
/// same reasoning as the 6-bit alpha), and the four bits are exactly what the exterior payload
/// had left over.
const LANE_MASK: u32 = 0x0007_8000;
const LANE_SHIFT: u32 = 15;
/// The saturated lane weight — the quantization step count the ramp's owner
/// (`crate::interior::InteriorAnchor`) rounds its `0..=1` fraction onto.
pub(crate) const LANE_MAX: u8 = 15;
/// Bits 6..=18 of the interior payload: the SH-probe table slot (13 bits ⇔ 8192 slots).
const PROBE_MASK: u32 = 0x0007_ffc0;
const PROBE_SHIFT: u32 = 6;
@@ -230,6 +257,26 @@ pub(crate) fn with_exterior_reset(tag: u32) -> u32 {
(tag & RIG_MASK) | carried_alpha(tag)
}
/// Rewrite a tag as a fresh exterior payload flagged MATTE-INDOOR ([`MATTE_INDOOR_BIT`]) at lane
/// weight `lane`, preserving the rig and alpha fields: the classifier's `Matte`-law write (MONKEY,
/// torch shadows Phase 3A) and — since the portal lane fade — every write of a part whose anchor's
/// crossfade is still in motion, whichever indoor law it is heading for. The shade writer
/// re-asserts its byte the same frame and carries both the bit and the field through.
///
/// The two travel together on purpose: the shader reads the field ONLY under the bit, so a lane
/// weight can never be mistaken for the reserved zeros of a plain exterior payload. `lane` is
/// clamped to [`LANE_MAX`] rather than masked — a caller's rounding overshoot must saturate at
/// "fully room-lit", not wrap to "fully exterior".
pub(crate) fn with_matte_indoor(tag: u32, lane: u8) -> u32 {
with_exterior_reset(tag) | MATTE_INDOOR_BIT | (u32::from(lane.min(LANE_MAX)) << LANE_SHIFT)
}
/// Read back the lane weight of an exterior-payload tag (`0`..=[`LANE_MAX`]) — the probe readout
/// and the tests. Meaningless on an interior payload, where these bits are part of the probe slot.
pub(crate) fn lane_of(tag: u32) -> u8 {
((tag & LANE_MASK) >> LANE_SHIFT) as u8
}
/// **A spawned part's initial `MeshTag`** — its rig slot and its starting render alpha, the two
/// fields every spawner sets and the only two it may.
///
@@ -396,10 +443,14 @@ pub fn describe(tag: u32) -> String {
// Both readings come off the RAW masks, deliberately: this is the one caller that has no
// material context and wants none — printing the ambiguity IS its job, so it is the one place
// that must not take an [`ExteriorPayload`] witness (it could not honestly produce one).
// MONKEY (portal lane fade): the lane weight is printed with the exterior reading (`shade`),
// because it shares the same law -- on an interior payload these bits are inside the `slot`
// beside it, exactly as the shade byte is.
format!(
"{tag:#010x}{flags} α {:.3} shade {} / slot {} rig {}",
"{tag:#010x}{flags} α {:.3} shade {} lane {} / slot {} rig {}",
alpha_of(tag),
(tag & SHADE_MASK) >> SHADE_SHIFT,
lane_of(tag),
(tag & PROBE_MASK) >> PROBE_SHIFT,
rig_of(tag),
)
@@ -538,6 +589,49 @@ mod tests {
assert_eq!((probe_bits(6660) & PROBE_MASK) >> PROBE_SHIFT, 6660);
}
/// MONKEY (torch shadows Phase 3A + portal lane fade): the Matte-law indoor flag rides the
/// exterior payload's reserved bit 14 and the lane weight bits 15..=18 — the per-frame
/// shade/alpha/rig/fog writers carry BOTH, the shade decode never sees either, and the
/// exterior reclaim clears both. The lane field is what makes the flag a crossfade instead of
/// a switch, so anything that carried the bit and dropped the field would silently snap an
/// entity to fully room-lit the first frame `entity_shade` re-asserted its byte.
#[test]
fn matte_indoor_bit_survives_the_field_writers_and_clears_on_exterior() {
let t = with_matte_indoor(rig_bits(9) | alpha_bits(0.5), 6);
assert_eq!(t & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(lane_of(t), 6);
assert_eq!(rig_of(t), 9);
assert_eq!(t & ALPHA_MASK, alpha_bits(0.5));
assert_eq!(with_shade(t, 255, ext()) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(lane_of(with_shade(t, 255, ext())), 6);
assert_eq!(shade_of(with_shade(t, 255, ext()), ext()), 255);
assert_eq!(with_alpha(t, 0.25) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(lane_of(with_alpha(t, 0.25)), 6);
assert_eq!(with_rig(t, 3) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(lane_of(with_rig(t, 3)), 6);
assert_eq!(with_interior_fog(t, true) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(lane_of(with_interior_fog(t, true)), 6);
assert_eq!(with_exterior_reset(t) & MATTE_INDOOR_BIT, 0);
assert_eq!(lane_of(with_exterior_reset(t)), 0);
assert_eq!(shade_of(t, ext()), 0, "the flag is outside the shade byte");
// …and the lane is outside it too, at BOTH ends of its range: a saturated lane must not
// read back as a shade byte of 240 (the failure the pair of decodes is here to catch).
let full = with_matte_indoor(alpha_bits(1.0), LANE_MAX);
assert_eq!(lane_of(full), LANE_MAX);
assert_eq!(shade_of(full, ext()), 0);
assert_eq!(shade_of(with_shade(full, 128, ext()), ext()), 128);
assert_eq!(lane_of(with_shade(full, 128, ext())), LANE_MAX);
// An overshooting caller SATURATES rather than wrapping into the probe/rig neighbourhood.
let over = with_matte_indoor(alpha_bits(1.0), 200);
assert_eq!(lane_of(over), LANE_MAX);
assert_eq!(over & (RIG_MASK | SHADE_MASK), 0, "no spill into its neighbours");
// Lane 0 under the flag is legal (the first tick of a ramp) and is NOT the exterior reset:
// the bit is what routes the shader into the mix, the field is only its weight.
let zero = with_matte_indoor(alpha_bits(1.0), 0);
assert_eq!(lane_of(zero), 0);
assert_ne!(zero & MATTE_INDOOR_BIT, 0);
}
/// Decision 0755: the classifier's whole-payload rewrites carry the tag's ALPHA field, which
/// is what lets a part change light law while a fade owns its ramp. Before this they wrote
/// `alpha_bits(1.0)`, so the classifier had to be locked out of fading parts entirely — and a
+29 -1
View File
@@ -10,7 +10,7 @@ use bevy::pbr::ExtendedMaterial;
use bevy::prelude::*;
use bevy::render::render_resource::{Buffer, Face};
use benilla_assets::materials::{WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY_REF};
use benilla_assets::materials::{TorchBinds, WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY_REF};
mod batch;
pub mod lazy;
@@ -254,6 +254,9 @@ pub fn model_material(
// and the skybox sort rung instead of the ordinary batch-order eps. See [`MatKey::sky_depth`].
sky_depth: bool,
light: &Buffer,
// MONKEY (torch shadows Phase 3A): the shared torch depth image + table buffer, cloned into the
// material beside `light` (not a key axis — one pair for the whole scene, like the light).
torch: &TorchBinds,
// The ONE placement this material belongs to, or `None` for the shared batch material every
// instance of the model reuses. `Some` only for a batch whose animated UV/tint loop depends on
// the sequence its instance is playing (decision 1408): the animated-material registries are
@@ -505,6 +508,9 @@ pub fn model_material(
// table slot is baked in exactly once.
anim_slots: Vec4::ZERO,
light_buf: light.clone(),
// MONKEY (torch shadows Phase 3A): the shared torch receiver bindings.
torch_depth: torch.depth.clone(),
torch_buf: torch.table.clone(),
},
},
);
@@ -598,6 +604,7 @@ pub fn zfill_material(
two_sided: bool,
cutout: bool,
light: &Buffer,
torch: &TorchBinds,
) -> Handle<WowModelMaterial> {
let key = MatKey {
light: light.id(),
@@ -669,6 +676,9 @@ pub fn zfill_material(
sidn: Vec4::ZERO,
anim_slots: Vec4::ZERO,
light_buf: light.clone(),
// MONKEY (torch shadows Phase 3A): the shared torch receiver bindings.
torch_depth: torch.depth.clone(),
torch_buf: torch.table.clone(),
},
},
);
@@ -1285,11 +1295,29 @@ pub enum ModelKind {
/// Tags every spawned model submesh with the metadata the panel toggles on: its subsystem and its
/// blend mode (the "layer" — opaque trunk vs alpha-cut canopy).
#[derive(Component, Clone, Copy)]
#[require(ShadowOccluder)]
pub struct ModelPart {
pub kind: ModelKind,
pub blend: ModelBlend,
}
/// The camera-INDEPENDENT half of [`visibility::apply_model_visibility`]'s verdict: does this
/// submesh exist as world content this frame (toggles, far clip, distance fade, material alpha)?
///
/// `Visibility` cannot answer that for a shadow caster: it also folds the exterior window gate,
/// the portal PVS and the exterior-scene cull — all functions of where the camera LOOKS — and a
/// wall behind the camera still blocks the sun. Gating the caster proxy on `InheritedVisibility`
/// made every entity-lane shadow swing with view direction. Defaults to occluding so a part casts
/// correctly from its spawn frame; the walk corrects a toggled-off part one frame later.
#[derive(Component, Clone, Copy)]
pub struct ShadowOccluder(pub bool);
impl Default for ShadowOccluder {
fn default() -> Self {
Self(true)
}
}
/// **Why this submesh is an entity at all** — the world streamer's answer, one static label per
/// batch, for the census (`VIS_CENSUS … why …`). A city keeps thousands of hidden model
/// submeshes resident on the entity path while the retained pass draws the world (5.6k ADT
+46 -7
View File
@@ -20,7 +20,7 @@
//! two lanes that build the same batch the same way get the same handle — which is the whole
//! reason the cache exists, and was true of four separate caches only by luck.
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::materials::{TorchBinds, WowModelMaterial};
use benilla_assets::ModelSubmesh;
use benilla_formats::ModelBlend;
use bevy::ecs::system::SystemParam;
@@ -29,6 +29,7 @@ use bevy::render::render_resource::Buffer;
use super::{model_material, zfill_material, MaterialCache, ShadeSel};
use crate::lighting::SharedLightBuffer;
use crate::static_gx::TorchShared;
/// The engine's one `WowModelMaterial` dedup cache. Swept by distance like every other art cache
/// (`art_scope`) and cleared whole on a map change; an evicted entry costs one rebuild, never a
@@ -95,12 +96,16 @@ pub struct M2BatchMaterials<'w> {
cache: ResMut<'w, ModelMaterials>,
materials: ResMut<'w, Assets<WowModelMaterial>>,
light: Option<Res<'w, SharedLightBuffer>>,
/// MONKEY (torch shadows Phase 3A): the shared torch bindings every built material takes —
/// created in the same startup system as the light buffer, so the same "retry" contract.
torch: TorchShared<'w>,
}
impl M2BatchMaterials<'_> {
/// Is the shared light buffer resident? A spawner that has other work to skip asks first.
/// Is the shared light buffer (and the torch pair) resident? A spawner that has other work to
/// skip asks first.
pub fn ready(&self) -> bool {
self.light.is_some()
self.light.is_some() && self.torch.binds().is_some()
}
/// The material store this param already holds — for a spawner that builds a batch's world
@@ -124,6 +129,7 @@ impl M2BatchMaterials<'_> {
order: u16,
) -> Option<Handle<WowModelMaterial>> {
let light = self.light.as_ref()?.0.clone();
let torch = self.torch.binds()?;
Some(self.build(
sub,
texture,
@@ -133,6 +139,7 @@ impl M2BatchMaterials<'_> {
false,
false,
&light,
&torch,
))
}
@@ -177,6 +184,7 @@ impl M2BatchMaterials<'_> {
order: u16,
) -> Option<SkyboxBatch> {
let light = self.light.as_ref()?.0.clone();
let torch = self.torch.binds()?;
let mut mk = |fade: bool| {
model_material(
&mut self.cache.0,
@@ -208,6 +216,7 @@ impl M2BatchMaterials<'_> {
false,
true, // the sky lane
&light,
&torch,
None, // one shared sky material — no per-sequence channel to key on
)
};
@@ -250,7 +259,18 @@ impl M2BatchMaterials<'_> {
rig: bool,
) -> Handle<WowModelMaterial> {
let shade = if rig { ShadeSel::Rig } else { ShadeSel::Lit };
self.build(sub, texture, order, shade, false, false, rig, light)
// MONKEY (torch shadows Phase 3A): the torch pair is global (one image, one table for the
// whole process), so an off-world lane takes it from the shared resources even though its
// LIGHT is its own. Both are inserted by `assets::open_world_assets` at `Startup`, before
// any booth or glue system can run, so this cannot fire in a real app; it is an `expect`
// rather than an `Option` return because both callers build `BoothPart`s inside `.map()`
// closures that want a handle, not a retry. (An off-world scene never lights by the
// interior room lane, so the bindings are only ever read as "no torches".)
let torch = self.torch.binds().expect(
"TorchShared: the torch depth image + table are created at Startup \
(assets::open_world_assets) before any off-world material is built",
);
self.build(sub, texture, order, shade, false, false, rig, light, &torch)
}
/// The full variant set an **entity** part needs: every M2 entity — unit, player, GameObject,
@@ -279,6 +299,7 @@ impl M2BatchMaterials<'_> {
uv: &mut EntityUvLane<'_>,
) -> Option<BatchVariants> {
let light = self.light.as_ref()?.0.clone();
let torch = self.torch.binds()?;
let steady = self.build(
sub,
texture.clone(),
@@ -288,6 +309,7 @@ impl M2BatchMaterials<'_> {
false,
true,
&light,
&torch,
);
let interior = self.build(
sub,
@@ -298,6 +320,7 @@ impl M2BatchMaterials<'_> {
false,
true,
&light,
&torch,
);
let interior_bake = self.build(
sub,
@@ -308,6 +331,7 @@ impl M2BatchMaterials<'_> {
false,
true,
&light,
&torch,
);
// A multiply batch (Mod/Mod2x) and an authored-Blend batch are their own twin: the first
// because its blend equation reads no alpha at all — the fade rides the shader's
@@ -331,6 +355,7 @@ impl M2BatchMaterials<'_> {
true,
true,
&light,
&torch,
)
};
let interior_bake_blend = if own_twin {
@@ -345,6 +370,7 @@ impl M2BatchMaterials<'_> {
true,
true,
&light,
&torch,
)
};
// On the lane, now, in the same call that built and seeded them. `register_entity_uv` is
@@ -374,7 +400,7 @@ impl M2BatchMaterials<'_> {
interior_bake,
interior_bake_blend,
fade_blend,
zfill: self.zfill_for(sub, texture, &light),
zfill: self.zfill_for(sub, texture, &light, &torch),
})
}
@@ -392,6 +418,7 @@ impl M2BatchMaterials<'_> {
two_sided: bool,
) -> Option<BatchVariants> {
let light = self.light.as_ref()?.0.clone();
let torch = self.torch.binds()?;
let mut mk = |shade: ShadeSel, probe: bool, fade: bool| {
model_material(
&mut self.cache.0,
@@ -422,6 +449,7 @@ impl M2BatchMaterials<'_> {
false, // …nor the WINDOW flag
false, // a character composite is never a skybox
&light,
&torch,
None, // a composite sheet carries no animated UV/tint channel at all
)
};
@@ -441,6 +469,7 @@ impl M2BatchMaterials<'_> {
two_sided,
blend == ModelBlend::AlphaTest,
&light,
&torch,
)),
})
}
@@ -456,9 +485,15 @@ impl M2BatchMaterials<'_> {
/// this facade to a lane it does not serve.
pub fn pieces(
&mut self,
) -> Option<(&mut MaterialCache, &mut Assets<WowModelMaterial>, Buffer)> {
) -> Option<(
&mut MaterialCache,
&mut Assets<WowModelMaterial>,
Buffer,
TorchBinds,
)> {
let light = self.light.as_ref()?.0.clone();
Some((&mut self.cache.0, &mut self.materials, light))
let torch = self.torch.binds()?;
Some((&mut self.cache.0, &mut self.materials, light, torch))
}
/// The depth-prime twin, or `None` for a batch that primes nothing: one that disables depth
@@ -469,6 +504,7 @@ impl M2BatchMaterials<'_> {
sub: &ModelSubmesh,
texture: Option<Handle<Image>>,
light: &Buffer,
torch: &TorchBinds,
) -> Option<Handle<WowModelMaterial>> {
if sub.no_depth_write || sub.no_depth_test {
return None;
@@ -482,6 +518,7 @@ impl M2BatchMaterials<'_> {
sub.two_sided,
b == ModelBlend::AlphaTest,
light,
torch,
)),
}
}
@@ -498,6 +535,7 @@ impl M2BatchMaterials<'_> {
fade: bool,
play_uv: bool,
light: &Buffer,
torch: &TorchBinds,
) -> Handle<WowModelMaterial> {
model_material(
&mut self.cache.0,
@@ -530,6 +568,7 @@ impl M2BatchMaterials<'_> {
// asks for, so it cannot be one more variant built from `sub` here.
false,
light,
torch,
// The ENTITY lane's batches (units, GameObjects, held items): shared per batch, as
// ever. The per-placement lane is the world streamer's alone (decision 1408) — every
// affected model is a placed `World\…` prop, and an entity already resolves its
@@ -76,6 +76,7 @@ pub(super) fn apply_model_visibility(
&ModelPart,
Ref<GlobalTransform>,
&mut Visibility,
&mut super::ShadowOccluder,
Option<&DoodadFade>,
Option<&mut MeshTag>,
Option<&mut MeshMaterial3d<WowModelMaterial>>,
@@ -144,6 +145,7 @@ pub(super) fn apply_model_visibility(
part,
xf,
mut vis,
mut occluder,
fade,
tag,
mat,
@@ -290,6 +292,15 @@ pub(super) fn apply_model_visibility(
*vis = desired;
}
// The shadow-caster verdict: the CONTENT terms only. `portal_visible`, `exterior_ok`
// and `owner_hidden` are all functions of where the camera looks, and geometry the
// camera cannot see still blocks the sun — folding them in made entity-lane shadows
// swing with view direction. Change-gated like every write in this walk.
let occludes = toggled_on && in_range && fade_alpha > 0.0 && mat_factor > 0.0;
if occluder.0 != occludes {
occluder.0 = occludes;
}
// Push the fade alpha to the shader (per-instance `MeshTag` alpha field — `wow_model.wgsl`
// multiplies the cutout alpha by it) and swap to the blend material variant while feathering,
// back to the cutout once opaque. Write only on change so steady doodads (`fade == 1.0`,
@@ -48,6 +48,8 @@ pub(super) fn update_model_particles(
mut mesh_assets: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
light: Res<crate::lighting::SharedLightBuffer>,
// MONKEY (torch shadows Phase 3A): the shared torch bindings every instance material takes.
torch: crate::static_gx::TorchShared,
mut emitters: Query<&mut ParticleEmitter>,
mut draws: Query<
(
@@ -59,6 +61,11 @@ pub(super) fn update_model_particles(
With<ChildDraw>,
>,
) {
// Created in the same startup system as the light buffer; absent only before it ran, when
// there is nothing to build a material against yet.
let Some(torch) = torch.binds() else {
return;
};
for mut emitter in &mut emitters {
let Some(geometry) = emitter.geometry.clone() else {
continue;
@@ -117,6 +124,7 @@ pub(super) fn update_model_particles(
false,
false, // an effect model is never a skybox
&light.0,
&torch,
None, // a shard's material is shared by the whole emitter
);
// Realized at once: the over-life ramp writes this material through
+5 -2
View File
@@ -38,6 +38,8 @@ pub(crate) fn plugin(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/ffx_glow.wgsl");
bevy::asset::embedded_asset!(app, "shaders/wow_effect.wgsl");
bevy::asset::embedded_asset!(app, "shaders/static_gx.wgsl");
// MONKEY (torch shadows Phase 1): the vertex-only depth-map shader for the interior-fixture pass.
bevy::asset::embedded_asset!(app, "shaders/torch_depth.wgsl");
}
#[cfg(test)]
@@ -81,7 +83,8 @@ mod tests {
// 6 since decision 1264 retired `skybox.wgsl` (a WMO skybox draws on the shared
// model lane, whose forced-far-depth branch lives in `benilla_assets`'
// `wow_model.wgsl`); 7 since 1429 added `static_gx.wgsl` (the B1 retained pass);
// 8 since 2016 added `sky_vertex.wgsl` (the sky's shared far-depth vertex stage).
assert_eq!(found, 8, "the engine's shader set changed size");
// 8 since 2016 added `sky_vertex.wgsl` (the sky's shared far-depth vertex stage);
// 9 since the torch-shadow Phase 1 added `torch_depth.wgsl` (the interior depth map).
assert_eq!(found, 9, "the engine's shader set changed size");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
// MONKEY (torch shadows, Phase 1): the vertex-only depth pass for one interior fixture.
//
// Renders the retained interior caster geometry (position only, stride 12) from a fixture's
// down-looking reverse-Z `view_proj` into one layer of the torch depth 2D-array texture. No
// fragment stage: only depth is written (clear 0.0, GreaterEqual — reverse-Z). See
// `static_gx/torch_depth.rs` for the pipeline and `static_gx.wgsl` for the sampling side.
struct TorchView {
view_proj: mat4x4<f32>,
}
@group(0) @binding(0) var<uniform> torch_view: TorchView;
@vertex
fn vertex(@location(0) position: vec3<f32>) -> @builtin(position) vec4<f32> {
return torch_view.view_proj * vec4<f32>(position, 1.0);
}
@@ -331,6 +331,9 @@ fn bake_cell(items: &[GxItem], meshes: &mut Assets<Mesh>) -> render::GxCellDraw
order: item.wmo.as_ref().map_or(0, |w| w.order),
sidn: item.wmo.as_ref().map_or([0; 3], |w| w.sidn),
slot: item.prop.as_ref().and_then(|p| p.slot).unwrap_or(0),
ext_night: item.wmo.as_ref().is_some_and(|w| w.ext_night),
// MONKEY (enclosed day floor): rides the bake exactly as `ext_night` does.
enclosed: item.wmo.as_ref().is_some_and(|w| w.enclosed),
});
}
// Recentre for clip-space precision (0974): the shader reconstructs world = v + origin.
@@ -622,6 +625,8 @@ mod tests {
sidn: Some([10, 20, 30]),
window: true,
batch_order: group + 1,
ext_night: false,
enclosed: false,
};
// An INT batch of group 2, pushed FIRST — Matte shade must NOT refuse it…
let mut b = batch(&g, Vec3::new(1.0, 0.0, 1.0), None, ModelBlend::Opaque);
+47 -4
View File
@@ -134,6 +134,21 @@ mod cull;
mod pick;
mod pool;
mod render;
mod shadow; // MONKEY (world shadows): CPU triangle collection for the static-world shadow caster
mod torch_depth; // MONKEY (torch shadows Phase 1): the per-fixture depth-map render + its targets
pub use shadow::CutoutBucket; // MONKEY (world shadows): per-leaf-texture alpha-cutout caster group
pub use torch_depth::TorchShadowViews; // MONKEY (torch shadows Phase 1): the app→render publication
// MONKEY (torch owner exclusion): the light→caster ownership key, written at the light spawn
// sites (`terrain_stream::spawn`, `benilla_app::entities::carried_light`) and read by both
// torch caster gathers, so a fixture never casts its own body's shadow into its own map.
pub use torch_depth::{torch_flame_inside_bounds, LightOwner};
// MONKEY (torch shadows Phase 3A): the shared depth image + table buffer every model material binds,
// their startup constructor, the one-param main-world accessor, and the always-on wiring — used by
// the asset foundation (`crate::assets`) and every material-building site; NOT gated on `enabled()`.
pub use torch_depth::{
new_torch_shared, SharedTorchBuffer, TorchDepthImage, TorchShared,
};
pub(crate) use torch_depth::register_shared as register_torch_shared;
/// The doodad spatial cell — ¼ ADT tile, the same 133⅓-yd locality key the merge lanes use
/// (`terrain_stream::merge::CELL`; 1413 round 2 proved the locality load-bearing).
@@ -296,8 +311,11 @@ struct GxItem {
owner: (i32, i32),
texture: Option<AssetId<bevy::image::Image>>,
/// Kept as a live handle so the render world's `GpuImage` can never be dropped from under
/// the baked cell (the id alone holds nothing).
_texture_handle: Option<Handle<bevy::image::Image>>,
/// the baked cell (the id alone holds nothing). Never READ (the bake keys everything off
/// [`Self::texture`]'s id) — its whole job is the strong reference held by its own
/// existence, so `dead_code` is a false positive here.
#[allow(dead_code)]
texture_handle: Option<Handle<bevy::image::Image>>,
cutout: bool,
two_sided: bool,
unlit: bool,
@@ -389,6 +407,14 @@ enum FaderState {
struct GxItemWmo {
group: u16,
interior: bool,
/// MONKEY (ext-class night law): this batch's group is EXTERIOR-class at BUILDING scale
/// ([`benilla_formats::room_claim::ext_building_scale`]) — the record table's bit 27, which
/// makes `static_gx.wgsl` blend it onto the interior light law after dark.
ext_night: bool,
/// MONKEY (enclosed day floor): this batch's group is an INTERIOR room inside a building-scale
/// shell ([`benilla_formats::room_claim::enclosed_by_building_shell`]) — the record table's
/// bit 28, which gives the room law a sun-driven ambient floor by day.
enclosed: bool,
/// The batch-class lane exactly as `model_render` packs `tint.w`: 0 = EXT law, 1 = INT,
/// 2 = TRANS — non-zero only on an interior group's batches.
class_lane: u8,
@@ -542,7 +568,13 @@ pub enum GxSite<'a> {
Doodad { owner: (i32, i32) },
/// A WMO placement's group geometry: the pre-spawned `WmoPortalInstance` entity + the
/// model's per-batch group map (`WmoModel::submesh_group`, index-parallel with batches).
Wmo { instance: Entity, groups: &'a [u16] },
/// MONKEY (ext-class night law): `bounds` is the model's MOGI group table (`group_bounds`),
/// indexed by ABSOLUTE group index — the class + box the per-batch night-law bit is read off.
Wmo {
instance: Entity,
groups: &'a [u16],
bounds: &'a [benilla_formats::WmoGroupInfo],
},
/// A WMO doodad prop (B4, decision 1433 — 1418's lane 3, absorbed): the building's
/// instance entity, the referrer set of rooms that name the prop, and the interior
/// prop's folded SH-probe slot. Only a placement WITH an instance qualifies (no
@@ -563,6 +595,15 @@ pub struct GxWmoBatch {
pub group: u16,
/// This batch's group is a true interior (`MOGI & 0x48 == 0`) — `RenderSubmesh::interior`.
pub interior: bool,
/// MONKEY (ext-class night law): EXTERIOR-class, but at BUILDING scale — an inn's shell or its
/// basement stairwell, not a city district's. Resolved at the spawn site from the group's own
/// MOGI box ([`benilla_formats::room_claim::ext_building_scale`]), because that is the last
/// place the model's group table is in hand; it rides to the shader as a record bit.
pub ext_night: bool,
/// MONKEY (enclosed day floor): the mirror question — this is an INTERIOR-class group whose
/// box centre sits inside such a shell, i.e. a ROOM IN A BUILDING rather than a cave. Resolved
/// at the same site off the same table; rides to the shader as bit 28.
pub enclosed: bool,
/// The MOBA batch class (INT/TRANS/EXT) — the lighting-lane selector on interior groups.
pub class: Option<WmoBatchClass>,
/// The MOMT SIDN night-glow colour.
@@ -696,6 +737,8 @@ impl StaticGx {
let wmo = b.wmo.map(|w| GxItemWmo {
group: w.group,
interior: w.interior,
ext_night: w.ext_night,
enclosed: w.enclosed,
class_lane: match (w.interior, w.class) {
(true, Some(WmoBatchClass::Int)) => 1,
(true, Some(WmoBatchClass::Trans)) => 2,
@@ -797,7 +840,7 @@ impl StaticGx {
local_aabb: b.aabb,
owner: b.owner,
texture: b.texture.as_ref().map(Handle::id),
_texture_handle: b.texture,
texture_handle: b.texture,
cutout: b.blend == ModelBlend::AlphaTest && !crate::model_render::alphatest_disabled(),
two_sided: b.two_sided,
unlit: b.unlit,
+401 -27
View File
@@ -16,9 +16,15 @@
//! bind groups, never a texture.
use bevy::camera::primitives::Aabb;
use bevy::core_pipeline::oit::OrderIndependentTransparencySettingsOffset;
use bevy::ecs::query::QueryItem;
use bevy::image::Image;
use bevy::mesh::VertexBufferLayout;
use bevy::pbr::{
MeshPipeline, MeshPipelineViewLayoutKey, MeshViewBindGroup, ViewEnvironmentMapUniformOffset,
ViewFogUniformOffset, ViewLightProbesUniformOffset, ViewLightsUniformOffset,
ViewScreenSpaceReflectionsUniformOffset,
};
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy::render::diagnostic::RecordDiagnostics;
@@ -31,14 +37,12 @@ use bevy::render::render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
};
use bevy::render::render_resource::binding_types::{
sampler, storage_buffer_read_only_sized, texture_2d_array, uniform_buffer, uniform_buffer_sized,
sampler, storage_buffer_read_only_sized, texture_2d_array, uniform_buffer_sized,
};
use bevy::render::render_resource::*;
use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
use bevy::render::texture::GpuImage;
use bevy::render::view::{
ExtractedView, Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms,
};
use bevy::render::view::{ExtractedView, Msaa, ViewDepthTexture, ViewTarget, ViewUniformOffset};
use bevy::render::{Render, RenderSystems};
use bevy::shader::ShaderDefVal;
use std::ops::Range;
@@ -68,6 +72,14 @@ pub(crate) struct GxItemDraw {
/// The interior prop's SH-probe slot (B4; 0 elsewhere — read only under the word's
/// INTERIOR-without-WMO lane). Rides the record table's w column, bits 1..14.
pub slot: u16,
/// MONKEY (ext-class night law): this is an EXTERIOR-class WMO batch at BUILDING scale — the
/// record table's bit 27 (see [`RECORD_EXT_NIGHT_BIT`]). False on cells, props and interior
/// batches.
pub ext_night: bool,
/// MONKEY (enclosed day floor): this is an INTERIOR-class WMO batch whose group sits inside a
/// building-scale shell — the record table's bit 28 (see [`RECORD_ENCLOSED_BIT`]). False on
/// cells, props, exterior batches, and every group of a dungeon (which authors no shell).
pub enclosed: bool,
}
/// One baked cell (or WMO region), published by the main-world flush.
@@ -165,6 +177,47 @@ 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;
/// MONKEY (ext-class night law): record column `w`, bit 27 — this batch's group is EXTERIOR-class
/// but at BUILDING scale (an inn's shell, a basement stairwell), so after dark `static_gx.wgsl`
/// blends it off the sky-lit exterior law and onto the interior room law. The room key occupies
/// bits 15..=26, so 27 is the first free bit. **Keep in sync with `static_gx.wgsl`'s
/// `RECORD_EXT_NIGHT`.**
const RECORD_EXT_NIGHT_BIT: u32 = 1 << 27;
/// MONKEY (enclosed day floor): record column `w`, bit 28 — this batch's group is an INTERIOR room
/// inside a building-scale exterior shell, so by day `static_gx.wgsl` gives its room law a
/// sun-driven ambient floor (the daylight a doorway lets in, for the doorways this renderer cannot
/// locate). Bit 27 is the night law, so 28 is the first free bit; bits 29..=31 remain free.
/// **Keep in sync with `static_gx.wgsl`'s `RECORD_ENCLOSED`.**
const RECORD_ENCLOSED_BIT: u32 = 1 << 28;
/// 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
@@ -217,21 +270,47 @@ struct GxGpuCache {
struct GxPipelines {
view_layout: BindGroupLayoutDescriptor,
cell_layout: BindGroupLayoutDescriptor,
light_layout: BindGroupLayoutDescriptor,
/// MONKEY (torch shadows Phase 1): group 3 — the torch depth array + comparison sampler + the
/// ≤16-entry `TorchTable` uniform. Present on every static_gx pipeline (`TORCH_SHADOWS` def).
torch_layout: BindGroupLayoutDescriptor,
sampler: Sampler,
sampler_clamp: Sampler,
/// Keyed `(cutout, two_sided)`; specialized for the world view's (samples, format) pair —
/// re-specialized if that pair ever changes (a window move across displays).
pipelines: HashMap<(bool, bool), CachedRenderPipelineId>,
specialized_for: Option<(u32, TextureFormat)>,
/// MONKEY (sun shadow perf): the key now carries the live `shadowFilter` too — the PCF branch
/// is a shader DEF, so a change has to re-specialize the family. Once per CHANGE, not per
/// frame: exactly the posture the (samples, format) pair beside it already has, and the same
/// caveat (a flip re-queues four pipelines rather than reviving the previous four — a cvar
/// A/B costs a shader build, a rendered frame costs nothing).
specialized_for: Option<(u32, TextureFormat, bool)>,
}
fn init_pipelines(mut commands: Commands, render_device: Res<RenderDevice>) {
let view_layout = BindGroupLayoutDescriptor::new(
"static_gx_view_layout",
fn init_pipelines(
mut commands: Commands,
mesh_pipeline: Res<MeshPipeline>,
render_device: Res<RenderDevice>,
) {
// Use Bevy's real mesh-view layout for the retained pass. Besides the view and light records,
// this carries the directional shadow textures and comparison samplers produced for the
// world camera. The old two-binding layout could render the city, but it had no legal way for
// `static_gx.wgsl` to receive the shadow map.
let view_layout = mesh_pipeline
.get_view_layout(MeshPipelineViewLayoutKey::empty())
.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,
(
uniform_buffer::<ViewUniform>(true),
storage_buffer_read_only_sized(false, None),
storage_buffer_read_only_sized(false, None),
),
),
@@ -261,6 +340,32 @@ fn init_pipelines(mut commands: Commands, render_device: Res<RenderDevice>) {
// MIXED-wrap batch (repeat one axis, clamp the other) keeps the repeat sampler plus the
// 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 ≤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(
"static_gx_torch_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(
// A depth 2D-array (= WGSL `texture_depth_2d_array`): no dedicated helper, so a
// `texture_2d_array` with the Depth sample type.
texture_2d_array(TextureSampleType::Depth),
sampler(SamplerBindingType::Comparison),
// 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"),
),
),
),
),
);
let filter = benilla_assets::tex_filter();
let make = |label: &'static str, mode: AddressMode| {
render_device.create_sampler(&SamplerDescriptor {
@@ -274,9 +379,20 @@ fn init_pipelines(mut commands: Commands, render_device: Res<RenderDevice>) {
..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,
light_layout,
torch_layout,
sampler: make("static_gx_repeat", AddressMode::Repeat),
sampler_clamp: make("static_gx_clamp", AddressMode::ClampToEdge),
pipelines: HashMap::default(),
@@ -347,9 +463,15 @@ fn prepare_static_gx(
render_device: Res<RenderDevice>,
render_queue: Res<RenderQueue>,
asset_server: Res<AssetServer>,
mesh_pipeline: Res<MeshPipeline>,
images: Res<RenderAssets<GpuImage>>,
views: Query<GxViewKey>,
// MONKEY (sun shadow perf): the live `shadowFilter`, extracted from the main world. `Option`
// because `ExtractResourcePlugin` only publishes it while the lighting plugin is in the app
// (a bare static_gx harness has no lighting) — absent reads as the shipped Gaussian.
shadow_filter: Option<Res<crate::lighting::ShadowFilterGaussian>>,
) {
let shadow_gaussian = shadow_filter.map_or(true, |f| f.0);
let _t = super::gx_perf_guard(3);
// The world view's pipeline key (the marker keeps booth views out of it).
let Some((view, msaa, _, _)) = views.iter().next() else {
@@ -360,14 +482,31 @@ fn prepare_static_gx(
} else {
TextureFormat::bevy_default()
};
let key = (msaa.samples(), format);
let key = (msaa.samples(), format, shadow_gaussian);
if pipes.specialized_for != Some(key) {
pipes.view_layout = mesh_pipeline
.get_view_layout(MeshPipelineViewLayoutKey::from(*msaa))
.main_layout
.clone();
let shader: Handle<Shader> =
asset_server.load("embedded://benilla_world/shaders/static_gx.wgsl");
pipes.pipelines.clear();
for cutout in [false, true] {
for two_sided in [false, true] {
let mut defs = vec![];
// The retained pipeline is custom, so it must opt into the same PCF branch the
// world camera's `ShadowFilteringMethod` gives every Bevy-material receiver;
// otherwise `shadows::fetch_directional_shadow` falls back to a constant 1.0.
// MONKEY (sun shadow perf): that method is now the live `shadowFilter` cvar, so the
// def follows it — the ground and the buildings standing on it must filter alike.
defs.push(ShaderDefVal::from(if shadow_gaussian {
"SHADOW_FILTER_METHOD_GAUSSIAN"
} else {
"SHADOW_FILTER_METHOD_HARDWARE_2X2"
}));
// MONKEY (torch shadows Phase 1): arm the shader's group-3 torch-map code. Set on
// EVERY static_gx pipeline so the group-3 bindings (always bound by the node) match.
defs.push(ShaderDefVal::from("TORCH_SHADOWS"));
if cutout {
defs.push(ShaderDefVal::from("GX_CUTOUT"));
}
@@ -375,7 +514,12 @@ fn prepare_static_gx(
label: Some(
format!("static_gx c{} t{}", u8::from(cutout), u8::from(two_sided)).into(),
),
layout: vec![pipes.view_layout.clone(), pipes.cell_layout.clone()],
layout: vec![
pipes.view_layout.clone(),
pipes.cell_layout.clone(),
pipes.light_layout.clone(),
pipes.torch_layout.clone(),
],
vertex: VertexState {
shader: shader.clone(),
shader_defs: defs.clone(),
@@ -450,6 +594,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,
@@ -469,6 +614,7 @@ fn prepare_static_gx(
};
if let Some(gpu) = assemble_region(
draw,
entity.index().index(),
&mut pool,
&pipes,
&pipeline_cache,
@@ -490,6 +636,7 @@ fn prepare_static_gx(
};
if let Some(gpu) = assemble_region(
draw,
entity.index().index(),
&mut pool,
&pipes,
&pipeline_cache,
@@ -520,7 +667,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));
@@ -569,6 +717,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,
@@ -581,6 +732,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 {
@@ -608,7 +762,19 @@ 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 }
// MONKEY (ext-class night law): unconditional on `gated` — the night law is a
// property of the GROUP's authored class and box, not of whether this region
// could carry a room key. An ungated region's ext-class group still must not
// read as night sky; its `interior_room_light` simply falls open, exactly as an
// ungated interior group's already does.
| if item.ext_night { RECORD_EXT_NIGHT_BIT } else { 0 }
// MONKEY (enclosed day floor): unconditional on `gated` for the same reason —
// "is this group a room in a building" is a fact about the authored group
// table, not about whether this region could carry a room key.
| if item.enclosed { RECORD_ENCLOSED_BIT } else { 0 },
]
})
.collect();
@@ -617,9 +783,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.
@@ -705,31 +889,175 @@ fn kill_bit(killed: &[u64], i: usize) -> u32 {
)
}
/// The per-frame view bind group (group 0): bevy's view uniform + the shared light buffer —
/// the SAME `wow_shared_light` storage every material binds (1429: identical lighting by
/// construction).
/// The retained pass's extra bind group (group 2): the shared light buffer — the SAME
/// `wow_shared_light` storage every material binds (1429: identical lighting by construction) —
/// Group 0 is Bevy's standard mesh-view bind group, which supplies the view matrices,
/// directional-light records, and shadow textures.
#[derive(Resource)]
struct GxViewBind(BindGroup);
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>>,
// MONKEY (torch lane perf): the words last actually uploaded, keyed by the buffer they went to.
mut last: Local<Option<(BufferId, Vec<u32>)>>,
) {
let (Some(buffer), Some(claims)) = (buffer, claims) else {
return;
};
// MONKEY (torch lane perf): skip the write when the table is bit-identical to what is already
// in the buffer. The packer does rebuild it from scratch every frame, but the RESULT is
// unchanged for as long as the player stands in one room with the same fixtures claiming it —
// which is most of the time indoors. A 32 KB `memcmp` costs a fraction of the 32 KB staging
// copy it saves, and the comparison is against what we WROTE, so it cannot disagree with the
// buffer's real contents.
let id = buffer.0.id();
if last.as_ref().is_some_and(|(b, w)| *b == id && w[..] == claims.0[..]) {
return;
}
queue.write_buffer(&buffer.0, 0, bytemuck::cast_slice(&claims.0[..]));
// Reuse the cache's allocation where there is one — this runs on a frame where the claims
// genuinely changed, and a fresh 32 KB `Vec` per such frame would be churn for nothing.
if let Some((b, w)) = last.as_mut() {
if *b == id {
w.copy_from_slice(&claims.0[..]);
return;
}
}
*last = Some((id, claims.0.to_vec()));
}
fn prepare_view_bind(
mut commands: Commands,
pipes: Res<GxPipelines>,
pipeline_cache: Res<PipelineCache>,
render_device: Res<RenderDevice>,
view_uniforms: Res<ViewUniforms>,
light: Option<Res<crate::lighting::SharedLightBuffer>>,
claims: Option<Res<GxRoomClaims>>,
) {
let (Some(view_binding), Some(light)) = (view_uniforms.uniforms.binding(), light) else {
let (Some(light), Some(claims)) = (light, claims) else {
return;
};
let layout = pipeline_cache.get_bind_group_layout(&pipes.view_layout);
commands.insert_resource(GxViewBind(render_device.create_bind_group(
"static_gx_view",
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((view_binding, light.0.as_entire_binding())),
&BindGroupEntries::sequential((
light.0.as_entire_binding(),
claims.0.as_entire_binding(),
)),
)));
}
/// The retained pass's group-3 bind group (MONKEY, torch shadows Phase 1): the interior torch depth
/// array (Phase 3A: the shared [`super::torch_depth::TorchDepthImage`]'s `GpuImage` D2Array view —
/// the same texture the entity receiver binds through its material) + static_gx's own comparison
/// sampler (from [`super::torch_depth::TorchDepthTargets`]) and the `TorchTable` uniform packed from
/// the extracted [`super::torch_depth::TorchShadowViews`] by the shared
/// [`super::torch_depth::TorchTableUniform::pack`]. ALWAYS built (with `count = 0` and a zero table
/// when the lane is off or absent) so the pipeline's group 3 is never left unbound — except while the
/// shared image is not yet resident, when static_gx skips the frame (the node early-outs on a
/// missing `GxTorchBind`).
/// MONKEY (torch lane perf): the bind group is CACHED with the exact table bytes and the depth
/// texture it was built over. Both used to be rebuilt from scratch every frame — a fresh 6416-byte
/// uniform buffer plus a fresh `BindGroup` object, for contents that are identical on every frame
/// the player stands still. Nothing else about the group can change while those two match: the
/// sampler and the layout are `RenderStartup` singletons, and the texture id covers the one case
/// where the shared image is ever re-prepared.
#[derive(Resource)]
struct GxTorchBind {
group: BindGroup,
table: super::torch_depth::TorchTableUniform,
texture: TextureId,
}
fn prepare_torch_bind(
mut commands: Commands,
pipes: Res<GxPipelines>,
pipeline_cache: Res<PipelineCache>,
render_device: Res<RenderDevice>,
targets: Option<Res<super::torch_depth::TorchDepthTargets>>,
views: Option<Res<super::torch_depth::TorchShadowViews>>,
image: Option<Res<super::torch_depth::TorchDepthImage>>,
images: Res<RenderAssets<GpuImage>>,
cached: Option<ResMut<GxTorchBind>>,
) {
// The sampler/pipeline are created in RenderStartup; the shared image is prepared by the
// render-asset pass (before this set). Without either, group 3 has nothing to bind this frame.
let Some(targets) = targets else {
return;
};
let Some(gpu_image) = image.as_ref().and_then(|h| images.get(&h.0)) else {
return;
};
let table = super::torch_depth::TorchTableUniform::pack(views.as_deref());
let texture = gpu_image.texture.id();
// The compare is on the BYTES, not on the source resource's change tick: `TorchShadowViews` is
// republished every frame by the app lane whether or not anything in it moved, so a change
// detector here would never fire negative.
if let Some(mut cached) = cached {
if cached.texture == texture
&& bytemuck::bytes_of(&cached.table) == bytemuck::bytes_of(&table)
{
return;
}
// Rebuild in place — one `Res` write instead of a deferred `insert_resource` per frame.
*cached = GxTorchBind {
group: build_torch_bind(&pipes, &pipeline_cache, &render_device, gpu_image,
targets.sampler(), &table),
table,
texture,
};
return;
}
commands.insert_resource(GxTorchBind {
group: build_torch_bind(&pipes, &pipeline_cache, &render_device, gpu_image,
targets.sampler(), &table),
table,
texture,
});
}
/// The group-3 bind group itself (see [`GxTorchBind`]) — the uniform buffer is created here because
/// its contents ARE the cache key, so a new buffer is only ever made on a frame the table moved.
fn build_torch_bind(
pipes: &GxPipelines,
pipeline_cache: &PipelineCache,
render_device: &RenderDevice,
gpu_image: &GpuImage,
sampler: &Sampler,
table: &super::torch_depth::TorchTableUniform,
) -> BindGroup {
let buffer = render_device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("static_gx_torch_table"),
contents: bytemuck::bytes_of(table),
usage: BufferUsages::UNIFORM,
});
let layout = pipeline_cache.get_bind_group_layout(&pipes.torch_layout);
render_device.create_bind_group(
"static_gx_torch",
&layout,
&BindGroupEntries::sequential((
// MONKEY (live bank rank): the image's default D2Array view spans all 144 layers:
// 96 STATIC faces addressed by matrices plus 48 LIVE copies addressed by the
// ascending set-bit rank in `count.z`. (wgpu's default view for a
// multi-layer 2D texture; `torch_depth.rs` spells out the guarantee).
&gpu_image.texture_view,
sampler,
buffer.as_entire_binding(),
)),
)
}
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct StaticGxLabel;
@@ -741,6 +1069,13 @@ impl ViewNode for StaticGxNode {
&'static ViewTarget,
&'static ViewDepthTexture,
&'static ViewUniformOffset,
&'static ViewLightsUniformOffset,
&'static ViewFogUniformOffset,
&'static ViewLightProbesUniformOffset,
&'static ViewScreenSpaceReflectionsUniformOffset,
&'static ViewEnvironmentMapUniformOffset,
Option<&'static OrderIndependentTransparencySettingsOffset>,
&'static MeshViewBindGroup,
// The world camera only — a booth bake must never receive world cells (see the marker).
&'static StaticGxView,
);
@@ -749,7 +1084,19 @@ impl ViewNode for StaticGxNode {
&self,
_graph: &mut RenderGraphContext,
render_context: &mut RenderContext<'w>,
(target, depth, view_offset, _marker): QueryItem<'w, '_, Self::ViewQuery>,
(
target,
depth,
view_offset,
view_lights,
view_fog,
view_light_probes,
view_ssr,
view_environment_map,
maybe_oit,
view_bind,
_marker,
): QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let _t = super::gx_perf_guard(4);
@@ -760,7 +1107,13 @@ impl ViewNode for StaticGxNode {
let cache = world.resource::<GxGpuCache>();
let pipes = world.resource::<GxPipelines>();
let pipeline_cache = world.resource::<PipelineCache>();
let Some(view_bind) = world.get_resource::<GxViewBind>() else {
let Some(light_bind) = world.get_resource::<GxLightBind>() else {
return Ok(());
};
// MONKEY (torch shadows Phase 1): group 3 is part of every static_gx pipeline now, so it
// must be bound before any draw. `prepare_torch_bind` always builds it (count 0 when the
// lane is off); a missing one means the depth targets weren't ready — skip the frame.
let Some(torch_bind) = world.get_resource::<GxTorchBind>() else {
return Ok(());
};
let meshes = world.resource::<RenderAssets<RenderMesh>>();
@@ -822,7 +1175,18 @@ impl ViewNode for StaticGxNode {
occlusion_query_set: None,
});
let span = diagnostics.pass_span(&mut pass, "static_gx");
pass.set_bind_group(0, &view_bind.0, &[view_offset.offset]);
let mut view_offsets = vec![
view_offset.offset,
view_lights.offset,
view_fog.offset,
**view_light_probes,
**view_ssr,
**view_environment_map,
];
if let Some(oit) = maybe_oit {
view_offsets.push(oit.offset);
}
pass.set_bind_group(0, &view_bind.main, &view_offsets);
for (gpu, draw, sel) in &resolved {
let Some(mesh) = meshes.get(draw.mesh.id()) else {
continue;
@@ -851,6 +1215,8 @@ impl ViewNode for StaticGxNode {
}
pass.set_render_pipeline(ready[&(run.cutout, run.two_sided)]);
pass.set_bind_group(1, &gpu.bind_groups[run.slot].1, &[]);
pass.set_bind_group(2, &light_bind.0, &[]);
pass.set_bind_group(3, &torch_bind.group, &[]);
pass.draw_indexed(
(islice.range.start + run.index_range.start)
..(islice.range.start + run.index_range.end),
@@ -877,6 +1243,9 @@ pub(super) fn build(app: &mut App) {
));
app.init_resource::<GxWorld>();
app.add_systems(Update, mark_world_camera);
// MONKEY (torch shadows Phase 1): the depth-map targets + node (its own RenderStartup + graph
// wiring). Built before we borrow the render app below — the two borrows are sequential.
super::torch_depth::build(app);
let Some(render_app) = app.get_sub_app_mut(bevy::render::RenderApp) else {
return;
};
@@ -888,7 +1257,10 @@ 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),
),
)
.add_render_graph_node::<ViewNodeRunner<StaticGxNode>>(Core3d, StaticGxLabel)
@@ -962,6 +1334,8 @@ mod tests {
order: 0,
sidn: [0; 3],
slot: 0,
ext_night: false,
enclosed: false,
}
}
@@ -0,0 +1,164 @@
//! MONKEY (world shadows): CPU collection of the RETAINED static world's geometry as shadow-caster
//! triangles — the same source-submesh reachback `pick.rs` does for ray hits, appended into a
//! world-space triangle list. The world-shadow lane (`benilla-app::world_shadow`) appends
//! these into a cached, layer-31 caster mesh so trees and buildings cast into the SAME Bevy
//! directional shadow map as characters, instead of only their baked shading.
//!
//! The retained pass recentres vertices per cell and bakes them into a `RENDER_WORLD`-only mesh
//! whose CPU data is gone after extract — so we go to the SOURCE the divert kept
//! ([`super::GxItem::geometry`] + [`super::GxItem::transform`]), exactly as `pick.rs` does, and
//! reconstruct world space directly: `transform.transform_point(wow_to_bevy(pos))`.
use benilla_assets::coords::wow_to_bevy;
use bevy::asset::AssetId;
use bevy::image::Image;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use super::{GxCell, StaticGx};
/// One alpha-tested foliage caster group: every resident cutout batch that shares a single leaf
/// texture, merged into one world-space triangle list carrying UVs. The character-shadow system
/// drives one layer-31 caster entity + [`super::super`]-side `CutoutShadowCasterMaterial` per
/// bucket, so the shadow pass can sample this texture and discard transparent texels — casting a
/// leaf-SHAPED silhouette instead of the solid box a positions-only proxy throws.
pub struct CutoutBucket {
/// The grouping key: the leaf sheet's asset id (stable across rebuilds, so the caller keys a
/// persistent caster entity off it rather than churning one per rebuild).
pub texture_id: AssetId<Image>,
/// A live strong handle to the same leaf image the forward pass draws — put straight on the
/// caster material so its sampler inherits the image's own clamp/repeat address mode.
pub texture: Handle<Image>,
/// World-space (Bevy) caster positions.
pub positions: Vec<[f32; 3]>,
/// Per-vertex UVs, parallel to [`Self::positions`] — what the prepass fragment samples.
pub uvs: Vec<[f32; 2]>,
pub indices: Vec<u32>,
}
impl StaticGx {
/// Append world-space (Bevy) shadow-caster triangles for resident static geometry whose
/// placement anchor is within `reach` of `center`, onto the caller's buffers (the character
/// system recycles them across rebuilds). Whole triangles referencing out-of-range indices are
/// dropped — a truncated submesh must never rewire a later triangle into garbage (mirrors
/// `character_shadow::append_triangles`).
///
/// FIRST CUT: a currently-EXILED (distance-faded) static item also draws as an entity, so it is
/// collected by both lanes and its geometry lands in the caster twice — harmless (identical
/// triangles at identical depth), a follow-up can gate on the fader state.
pub fn append_shadow_triangles(
&self,
center: Vec3,
reach: f32,
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) {
let r2 = reach * reach;
let mut push = |cell: &GxCell| {
for item in &cell.items {
// Distance gate on the placement anchor BEFORE any vertex work.
if item.transform.translation.distance_squared(center) > r2 {
continue;
}
// MONKEY (world shadows): the SOLID caster skips alpha-cutout foliage — a leaf card
// is a big flat quad, and cast solid here (this mesh has no texture) it would be an
// ugly rectangular BOX on the ground. Trunks/buildings/fences (opaque) cast their
// real silhouette here; cutout foliage is collected leaf-shaped by
// [`Self::collect_cutout_shadow_triangles`] instead. (`item.cutout` == AlphaTest.)
if item.cutout {
continue;
}
let base = positions.len() as u32;
let t = item.transform;
positions.extend(
item.geometry
.positions
.iter()
.map(|p| t.transform_point(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));
}
}
}
};
for cell in self.cells.values() {
push(cell);
}
for cell in self.wmos.values() {
push(cell);
}
for cell in self.props.values() {
push(cell);
}
}
/// Collect the resident ALPHA-CUTOUT geometry (tree canopies, bushes, cattails — the leaf
/// cards the solid caster skips) as one [`CutoutBucket`] per distinct leaf texture, each a
/// world-space triangle list with UVs. The character-shadow system drives one alpha-tested
/// layer-31 caster per bucket, so a canopy discards its transparent texels in the shadow pass
/// and casts a leaf-SHAPED silhouette. Grouping by texture is what lets a single draw carry
/// one sheet: a zone's canopies draw from only a handful of sheets, so this yields a handful
/// of buckets. Range-gates on the placement anchor exactly like [`Self::append_shadow_triangles`].
///
/// A cutout batch without a texture handle, or whose UVs don't parallel its positions, is
/// skipped (it can't be alpha-tested — better no shadow than a wrong solid box).
pub fn collect_cutout_shadow_triangles(&self, center: Vec3, reach: f32) -> Vec<CutoutBucket> {
let r2 = reach * reach;
let mut buckets: HashMap<AssetId<Image>, CutoutBucket> = HashMap::new();
let mut push = |cell: &GxCell| {
for item in &cell.items {
if !item.cutout {
continue;
}
let (Some(texture_id), Some(texture)) = (item.texture, item.texture_handle.as_ref())
else {
continue;
};
if item.transform.translation.distance_squared(center) > r2 {
continue;
}
let geometry = &item.geometry;
// UVs must parallel positions to map per-vertex — a cutout card that lost its UVs
// (should never happen) can't be sampled, so drop it rather than box it out.
if geometry.uvs.len() != geometry.positions.len() {
continue;
}
let bucket = buckets.entry(texture_id).or_insert_with(|| CutoutBucket {
texture_id,
texture: texture.clone(),
positions: Vec::new(),
uvs: Vec::new(),
indices: Vec::new(),
});
let base = bucket.positions.len() as u32;
let t = item.transform;
bucket.positions.extend(
geometry
.positions
.iter()
.map(|p| t.transform_point(wow_to_bevy(*p)).to_array()),
);
bucket.uvs.extend_from_slice(&geometry.uvs);
let added = bucket.positions.len() as u32 - base;
for tri in geometry.indices.chunks_exact(3) {
if tri.iter().all(|i| *i < added) {
bucket.indices.extend(tri.iter().map(|i| base + *i));
}
}
}
};
for cell in self.cells.values() {
push(cell);
}
for cell in self.wmos.values() {
push(cell);
}
for cell in self.props.values() {
push(cell);
}
buckets.into_values().collect()
}
}
File diff suppressed because it is too large Load Diff
+24 -3
View File
@@ -62,7 +62,10 @@ pub use spawn::prop_light::{fold_interior_probe, hex_word, interior_light_up, Pr
// 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::{
@@ -634,6 +637,11 @@ fn stream_terrain(
Res<Assets<WdtIndex>>,
Res<Time>,
ResMut<StreamActivity>,
// MONKEY (outdoor torch shadows: terrain): the shared torch depth array + table every tile
// material binds (91/92/93). Nested here for the same 16-param reason as the rest of the
// tuple - this system is already AT Bevy's ceiling - and beside the material store because
// that is what it is: an input to the one `TerrainExtension` built below.
crate::static_gx::TorchShared<'_>,
),
liquid_assets: Option<Res<LiquidAssets>>,
clutter: Option<Res<GroundClutter>>,
@@ -662,11 +670,18 @@ fn stream_terrain(
),
) {
let (mut welds, mut static_merge, mut staticgx) = batchers;
let (mut materials, mut meshes, wdts, _time, mut activity) = asset_stores;
let (mut materials, mut meshes, wdts, _time, mut activity, torch) = asset_stores;
let (current_map, map_catalog, view) = location;
// The shared light buffer + map catalog are set up by other plugins' startup; until they exist
// there's nothing to stream against, so idle.
let (Some(shared_light), Some(map_catalog)) = (shared_light, map_catalog) else {
// MONKEY (outdoor torch shadows: terrain): the torch binds join that same gate. They are born in
// the SAME Startup system as the light buffer (`assets::open_world_assets`), so in practice the
// two are never split - but a tile material built against a missing depth image would never get
// a bind group at all, so this retries next frame exactly as `shared_light` does rather than
// baking a default that would blank the ground.
let (Some(shared_light), Some(map_catalog), Some(torch)) =
(shared_light, map_catalog, torch.binds())
else {
return;
};
let t0 = Instant::now();
@@ -962,6 +977,12 @@ fn stream_terrain(
shadow_array: adt.shadow_array.clone(),
params: Vec4::new(tiling, 0.0, 0.0, 0.0),
light_buf: shared_light.0.clone(),
// MONKEY (outdoor torch shadows: terrain): cloned per tile like the light buffer -
// a handle + a `Buffer` (an Arc), so the per-tile cost is two refcount bumps and
// every tile ends up pointing at the one image and the one table the depth node
// rewrites each frame.
torch_depth: torch.depth.clone(),
torch_buf: torch.table.clone(),
},
});
// Terrain collider (decision 0009): ONE static trimesh per tile, welded from the same decoded
@@ -20,7 +20,7 @@ use crate::mesh_tag::alpha_bits;
use crate::model_fade::DoodadFade;
use crate::model_render::{model_material, MaterialCache, ShadeSel};
use crate::model_render::{ModelKind, ModelPart};
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::materials::{TorchBinds, WowModelMaterial};
/// What one placement's animation host armed, for the consumers that spawn alongside its submeshes.
/// All fields are *per placement*, not per model: the anchors are this instance's, and `arm` is
@@ -87,6 +87,8 @@ pub fn spawn_model_entities(
mat_cache: &mut MaterialCache,
materials: &mut Assets<WowModelMaterial>,
light: &Buffer,
// MONKEY (torch shadows Phase 3A): the shared torch bindings, beside the light buffer.
torch: &TorchBinds,
submeshes: &[ModelSubmesh],
// The model's app-built render forms (decision 0834), index-parallel with `submeshes`: the
// static handle + its build-time `Aabb` per batch, and the skinned twins when this model's
@@ -337,6 +339,7 @@ pub fn spawn_model_entities(
sub.window,
false, // the world streamer never spawns a skybox
light,
torch,
seq_owner,
);
// The blend twin for the distance-fade feather pass (reuse the cutout when already blend, or when
@@ -381,6 +384,7 @@ pub fn spawn_model_entities(
sub.window,
false, // the world streamer never spawns a skybox
light,
torch,
seq_owner,
)
};
@@ -495,8 +499,11 @@ pub fn spawn_model_entities(
(class.never_fade || fade_seed.is_some())
.then_some((*owner, None, None, fade_seed))
}
crate::static_gx::GxSite::Wmo { instance, groups }
if is_wmo && class.merges() =>
crate::static_gx::GxSite::Wmo {
instance,
groups,
bounds,
} if is_wmo && class.merges() =>
{
groups.get(batch_idx).map(|&g| {
(
@@ -505,6 +512,22 @@ pub fn spawn_model_entities(
instance: *instance,
group: g,
interior,
// MONKEY (ext-class night law): EXTERIOR-class at BUILDING
// scale — the same eligibility the exterior lane's strict
// claim term uses, so a group that may be lit by a room's
// fixtures is exactly the group that stops reading as sky.
ext_night: bounds
.get(usize::from(g))
.is_some_and(benilla_formats::room_claim::ext_building_scale),
// MONKEY (enclosed day floor): this batch's group is a ROOM
// INSIDE A BUILDING -- an interior-class group whose centre
// sits in a building-scale exterior shell of the same root.
// Same place, same table, same argument as `ext_night` above:
// this is the last point at which the model's group table is
// in hand, and the answer rides to the shader as a record bit.
enclosed: benilla_formats::room_claim::enclosed_by_building_shell(
bounds, g,
),
class: sub.wmo_batch,
sidn: sub.sidn,
window: sub.window,
@@ -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::{WmoBatchClass, WmoGroupInfo, WmoLight};
use bevy::prelude::*;
use crate::lighting::WorldPointLight;
@@ -24,6 +25,12 @@ const POINT_LIGHT_INTENSITY: f32 = 4.0 * std::f32::consts::PI;
/// invisible). Generous so a hall-scale WMO group whose center sits ~20 yd from its wall fixtures
/// still gathers them — erring wide only ever admits negligible candidates.
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 [`WorldPointLight`] recipe for an authored WoW light source (M2 or WMO MOLT). The light only
/// carries position + colour into the shared packed table; every lit surface — terrain, M2
@@ -54,6 +61,28 @@ 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 (flame flicker): the fire this source IS, or `None` for anything that merely shines
// (a lamp behind glass, a neutral fill light). Decided once by
// [`crate::lighting::flame_kind_for`] — one rule, every lane — and the phase SEED is taken here
// from the light's own world position, so two candles on one table are never in step and a
// fixture keeps its phase across a stream-out/stream-in.
flame: Option<crate::lighting::FlameKind>,
// 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((
@@ -63,6 +92,21 @@ 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(kind) = flame {
e.insert(crate::lighting::FlameFlicker::new(
kind,
crate::lighting::flicker_seed(world),
));
}
if let Some(reach) = reach.filter(|r| *r > 0.5) {
e.insert(crate::lighting::LightReach(reach));
}
out.push(e.id());
}
@@ -74,29 +118,342 @@ 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() {
// MONKEY (spell light): `l.spell.is_none()` keeps the PLACED lane exactly as it was. The spell
// route (`fire_light::synthesize_spell_light`) lights effect models and fireworks, whose whole
// premise — a light that exists for the length of one cast, budgeted and enveloped — belongs to
// the entity lanes that reap it with its effect. A spell model placed as scenery must not enter
// a table built for fixtures that stand still for minutes.
for l in lights
.iter()
.filter(|l| (synthetic || !l.synthetic) && l.spell.is_none())
{
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,
// MONKEY (flame flicker): the flame route burns whatever colour it is; the lamp route
// never does; an authored block burns iff it was authored warm.
crate::lighting::flame_kind_for(
l.flame,
l.synthetic,
def.diffuse_color,
def.diffuse_intensity,
),
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 [`WorldPointLight`] 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
@@ -107,12 +464,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>,
@@ -122,7 +497,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))
@@ -135,13 +510,139 @@ 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 (flame flicker): an authored fixture burns iff it was authored WARM. The MOLT
// corpus is overwhelmingly candles and wall torches at that hue; what the warmth test
// holds back is the neutral-white fill lights artists park in halls (which must stay
// dead steady) and the cold magic sources.
crate::lighting::flame_kind_for(false, false, l.color, l.intensity),
// 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,
);
}
}
/// MONKEY (daylight fixtures): spawn one interior-lane point light per EXTERIOR-FACING OPENING of
/// this placement — the doorways and windows whose sunlit threshold the room law otherwise meets
/// with a hard line (see `crate::lighting`'s `daylight` module for the whole argument, the two
/// seeds and the corpus measurement behind them).
///
/// It is deliberately the SAME entity shape a MOLT fixture gets — [`crate::lighting::LightRooms`]
/// for the visibility gate, the `room_claims` claim set through [`lit_rooms`],
/// [`crate::lighting::LightReach`] for the packer's window, an interior `LightLane` — so the
/// packer, the claim table, the shader and the std430 layout all need no knowledge that this source
/// is the sun rather than a candle. What it does NOT get is a `FlameFlicker` (daylight is steady)
/// or `SyntheticFireLight` (`fireLightGain` must not be able to dim the sun).
///
/// The `PointLight` itself is NOT spawned here: `update_daylight_fixtures` inserts it on the first
/// frame it is day, with the sun-calibrated colour and intensity. A placeholder would pack one
/// uncalibrated frame — a flash of the wrong brightness in every doorway of a building that just
/// streamed in — and a placement streaming in at night would spawn a light only to have it removed.
///
/// `instance` absent (a placement with no portal instance) means there is nothing to key a room
/// claim to, and an unclaimed interior fixture is packed UNGATED, which fails OPEN — one doorway
/// lighting every room of the building through its walls. Such a placement gets no daylight at all,
/// exactly as the MOLT lane drops its own claims there.
pub(super) fn spawn_daylight_fixtures_for(
commands: &mut Commands,
model: &WmoModel,
portals: PortalGraph<'_>,
instance: Option<Entity>,
transform: Transform,
out: &mut Vec<Entity>,
) {
let Some(instance) = instance else {
return;
};
// The APERTURE seed's input: every render batch of the root as `(group, EXT-class?, points)`,
// borrowed straight off the loaded asset's two parallel arrays. The selection rule consumes it
// lazily, so a root whose groups are all exterior walks the batches once and allocates nothing.
let batches = model
.submeshes
.iter()
.zip(model.submesh_group.iter())
.map(|(s, g)| {
(
*g,
matches!(s.wmo_batch, Some(WmoBatchClass::Ext)),
&s.geometry.positions[..],
)
});
// MONKEY (portal bleed): one selection call for both lanes -- the exterior-facing openings that
// get the SUN and the interior<->interior doorways that get the ROOM NEXT DOOR -- ranked by area
// against each other, so the eight slots go to this building's biggest openings whichever kind
// they are.
let (day, bleed) = crate::lighting::placement_openings(&model.group_bounds, portals, batches);
for seed in day {
let fixture = seed.fixture(instance);
let claims = crate::lighting::daylight_claims(&model.group_bounds, portals, &seed);
let e = commands
.spawn((
Transform::from_translation(transform.transform_point(wow_to_bevy(seed.pos))),
fixture,
crate::lighting::daylight_rooms(instance, seed.group),
lit_rooms(instance, &claims, transform),
crate::lighting::LightReach(fixture.reach),
crate::lighting::daylight_lane(),
))
.id();
out.push(e);
}
// MONKEY (portal bleed): the doorway fixtures. Deliberately the SAME bundle as above plus one
// component -- a bleed IS a daylight fixture as far as the packer, the claim table, the shader
// and `torch_shadow` are concerned, and `BleedFixture` only names the two rooms it joins and the
// plane it is calibrated at (in WORLD space: the per-frame evaluation has world positions and no
// placement matrix). Its visibility gate takes BOTH rooms, not one. The `PointLight` is again NOT
// spawned here -- a doorway between two unlit rooms never gets one at all.
for seed in bleed {
let fixture = seed.fixture(instance);
let claims = seed.claims(&model.group_bounds, portals);
let world = transform.transform_point(wow_to_bevy(seed.pos));
let e = commands
.spawn((
Transform::from_translation(world),
fixture,
crate::lighting::BleedFixture {
sides: seed.sides,
probe: world,
},
seed.rooms(instance),
lit_rooms(instance, &claims, transform),
crate::lighting::LightReach(fixture.reach),
crate::lighting::daylight_lane(),
))
.id();
out.push(e);
}
}
/// The **one draw-set gate a placed model's emitters share** — the quad clouds and the ribbon
/// trails alike, built once per placement so the two families can never be handed different
/// answers about whether their model is in the frame's scene.
@@ -284,3 +785,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,
};
@@ -69,6 +71,9 @@ type SpawnTables<'w> = (
// `None` only under `WOW_STATIC_GX=0` (the resource exists whenever the retained pass
// is armed — the default since 1434).
Option<ResMut<'w, crate::static_gx::StaticGx>>,
// MONKEY (torch shadows Phase 3A): the shared torch bindings every spawned material takes
// (nested here for the same 16-param reason as the rest of the tuple).
crate::static_gx::TorchShared<'w>,
);
pub(super) fn spawn_loaded_placements(
@@ -96,10 +101,15 @@ pub(super) fn spawn_loaded_placements(
// model-forms cache (decision 0834).
tables: SpawnTables,
) {
let (mut probes, mut activity, focus, mut forms, mut welds, mut merge, mut staticgx) = tables;
let (mut probes, mut activity, focus, mut forms, mut welds, mut merge, mut staticgx, torch) =
tables;
let Some(shared_light) = shared_light else {
return;
};
let Some(torch) = torch.binds() else {
return; // created in the same startup system as the light buffer — retry like it
};
let torch = &torch;
// Steady state is everything spawned, and the walk below still visits every placement and
// every WMO prop to find that out — the pending count (kept by the register/handoff/release
// sites) makes that frame free.
@@ -145,7 +155,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
@@ -212,6 +234,7 @@ pub(super) fn spawn_loaded_placements(
mat_cache,
materials,
light,
torch,
&m.submeshes,
FormSlices {
stat: forms.static_meshes(key).unwrap_or(&[]),
@@ -303,7 +326,28 @@ 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.
// MONKEY (torch owner exclusion): where this placement's own lights start in
// `ents`, so they — and ONLY they — can be tagged with its identity below.
let lights_from = ents.len();
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_light_owner(&mut commands, &ents[lights_from..], &object);
tag_world_object(&mut commands, &ents, &object);
if let Some((target, r)) = fade_near_target() {
let pos = p.transform.translation;
@@ -324,6 +368,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
@@ -397,6 +449,7 @@ pub(super) fn spawn_loaded_placements(
mat_cache,
materials,
light,
torch,
&m.submeshes,
FormSlices {
stat: forms.static_meshes(key).unwrap_or(&[]),
@@ -434,6 +487,9 @@ pub(super) fn spawn_loaded_placements(
crate::static_gx::GxSite::Wmo {
instance: i,
groups: &m.submesh_group,
// MONKEY (ext-class night law): the MOGI table, so the
// assembler can read each batch's group CLASS + BOX.
bounds: &m.group_bounds,
},
)
})
@@ -626,10 +682,37 @@ 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,
);
// MONKEY (daylight fixtures): …and one interior-lane light per exterior-facing
// opening, so the room law has a sun to find at the threshold instead of
// meeting the sky-lit doorway with a hard line (`lighting::daylight`).
fx::spawn_daylight_fixtures_for(
&mut commands,
m,
benilla_formats::PortalGraph {
vertices: &m.portal_vertices,
infos: &m.portal_infos,
refs: &m.portal_refs,
slices: &slices,
},
p.portal_instance,
p.transform,
&mut ents,
@@ -661,11 +744,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.
@@ -756,6 +875,7 @@ pub(super) fn spawn_loaded_placements(
mat_cache,
materials,
light,
torch,
&m.submeshes,
FormSlices {
stat: forms.static_meshes(key).unwrap_or(&[]),
@@ -893,13 +1013,34 @@ pub(super) fn spawn_loaded_placements(
ents.first().copied(),
&fade,
);
// MONKEY (torch owner exclusion): as at the ADT site — the prop's OWN lights only.
let lights_from = ents.len();
spawn_lights_for(
&mut commands,
&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_light_owner(&mut commands, &ents[lights_from..], &object);
tag_world_object(&mut commands, &ents, &object);
p.entities.extend(ents);
d.spawned = true;
@@ -1062,6 +1203,27 @@ fn tag_world_object(commands: &mut Commands, ents: &[Entity], object: &Arc<World
}
}
/// MONKEY (torch owner exclusion): stamp a placement's OWN M2 lights with its identity, so the
/// torch-shadow caster gathers can drop the fixture's own body out of its own shadow map
/// (`static_gx::LightOwner` carries the whole argument).
///
/// Narrow on purpose — this takes the slice of `ents` that [`fx::spawn_lights_for`] just appended,
/// never the placement's whole entity list, and it is NOT folded into [`tag_world_object`]. The
/// WMO lane runs `tag_world_object` over its authored MOLT fixtures too, and a MOLT fixture's
/// placement identity is the BUILDING's: tagged, an inn's hearth would name every wall, floor and
/// pillar of the inn as its own body and the interior shadow lane would go dark. A MOLT light has
/// no model of its own, so it needs no exclusion at all — only a light SYNTHESISED from (or
/// authored inside) a model does, and those are exactly the ones `spawn_lights_for` makes.
fn tag_light_owner(commands: &mut Commands, lights: &[Entity], object: &Arc<WorldObject>) {
if lights.is_empty() {
return;
}
let owner = crate::static_gx::LightOwner::placement(object);
for &e in lights {
commands.entity(e).insert(owner);
}
}
/// The **animated cull bound** for an M2 placement: the model's authored header bounding box
/// (`M2Header.bounding_box_min/max`), in Bevy model-local space. `None` when the model carries no
/// authored bounds.
@@ -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.