+69
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user