MONKEY: lighting round 2 — seams, flicker, outdoor torch shadows, perf knobs
Seams - An exterior-class MOBA batch inside an interior group (the inn's cellar stair) took the night sky; its authored day weight is now scaled by the sun, so it joins the room law after dark. Exterior-class SHELL groups keep the sky law (moving them to the room law painted facades black at dusk). - Containment claims cross portals larger than 28 yd² (floor splits, gallery voids) at full weight, so a floor cut by a group boundary lights continuously. interiorDebug 4 colours WMO fragments by lane. wmolights prints group names, lane, portal areas. Fire - Flame lights flicker: band-limited sines + smoothed noise per fixture, candle fast/light to bonfire slow/deep, seeded by position, mean exactly 1; lamps never; authored fixtures only when warm. Cvar fireFlicker. Outdoor torch shadows (step a) - At night exterior fire lights become cube-map casters (half the map budget), WMO exteriors and models receive them with a world-distance fade; held torches never cast; a fire's own model is excluded from its map; daylight is bit-identical. Cvar exteriorShadows. Performance - Torch lane: static-scene fingerprint walk gated on a residency/part census (was 4 full walks per frame), entity caster rebuilt at interiorShadowEntityRate Hz (30) and gathered only within each fixture's reach, receiver shadow taps skipped at zero contribution, table and claim uploads and the torch bind group skipped when unchanged. Live bank sized to 8 dynamic slots (VRAM 192 -> 144 MiB). - Sun lanes: live cvars shadowMapSize (default 2048, was 4096), shadowFilter, characterShadowRate / worldShadowRate (30 Hz proxy rebuilds), shadowCasterReach. - Measured (inn, 1080p): debug 45 -> 61 FPS, release 83 FPS with everything on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
082f3ebe50
commit
c32d0077fc
@@ -17,7 +17,7 @@ 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, ShadowDemand, ShadowFrame, ShadowSet,
|
||||
spawn_solid_caster, take_mesh_buffers, RebuildRate, ShadowDemand, ShadowFrame, ShadowSet,
|
||||
};
|
||||
use crate::video::VideoConfig;
|
||||
|
||||
@@ -37,11 +37,15 @@ struct CharacterLane {
|
||||
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>,
|
||||
@@ -73,6 +77,8 @@ fn collect_character_shadows(
|
||||
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 {
|
||||
@@ -80,12 +86,30 @@ fn collect_character_shadows(
|
||||
};
|
||||
|
||||
// Animated rigs re-skin every frame and creatures spawn/despawn under lifecycles this lane
|
||||
// can't cheaply observe — so the caster is collected fresh every frame.
|
||||
// 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) {
|
||||
|
||||
@@ -600,6 +600,35 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
"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 —
|
||||
@@ -660,6 +689,14 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
"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",
|
||||
@@ -671,6 +708,17 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
"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.
|
||||
@@ -682,7 +730,7 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
ours(
|
||||
"interiorDebug",
|
||||
"0",
|
||||
"benilla's own: interior diagnostic overlay — 1 classification, 2 shadow, 3 caster count",
|
||||
"benilla's own: interior diagnostic overlay — 1 classification, 2 shadow, 3 caster count, 4 WMO lane map",
|
||||
),
|
||||
// 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
|
||||
@@ -694,6 +742,16 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
"1",
|
||||
"benilla's own: brightness of lights synthesised from fire props' flame emitters (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
|
||||
@@ -1179,6 +1237,34 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
knobs.video.shadow_distance =
|
||||
v.clamp(*SHADOW_DISTANCE_RANGE.start(), *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" => {
|
||||
knobs.video.shadow_map_size =
|
||||
crate::shadow_core::clamp_shadow_map_size(v.max(0.0) as u32);
|
||||
}
|
||||
"shadowfilter" => {
|
||||
knobs.video.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" => {
|
||||
knobs.video.character_shadow_rate =
|
||||
(v.max(0.0) as u32).min(crate::shadow_core::MAX_SHADOW_RATE);
|
||||
}
|
||||
"worldshadowrate" => {
|
||||
knobs.video.world_shadow_rate =
|
||||
(v.max(0.0) as u32).min(crate::shadow_core::MAX_SHADOW_RATE);
|
||||
}
|
||||
"shadowcasterreach" => {
|
||||
knobs.video.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" => knobs.video.interior_light = v != 0.0,
|
||||
@@ -1190,6 +1276,10 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
"interiorattenscale" => knobs.video.interior_atten_scale = v.clamp(0.0, 8.0),
|
||||
"interiorroomgate" => knobs.video.interior_room_gate = v != 0.0,
|
||||
"interiorshadows" => knobs.video.interior_shadows = v != 0.0,
|
||||
// MONKEY (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" => knobs.video.exterior_shadows = v != 0.0,
|
||||
// MONKEY (torch caster selection): the working-set size and the PCF radius, clamped at the
|
||||
// edge like every other numeric row. `casters` floors at 1, not 0 — `interiorShadows 0` is
|
||||
// already the off switch, and a 0 here would be a second, confusing one.
|
||||
@@ -1197,11 +1287,21 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
knobs.video.interior_shadow_casters = (v.max(1.0) as u32).clamp(1, 16);
|
||||
}
|
||||
// MONKEY (static torch cache): zero is useful for static-only rooms.
|
||||
"interiorshadowdynamic" => knobs.video.interior_shadow_dynamic = (v.max(0.0) as u32).min(16),
|
||||
"interiorshadowdynamic" => knobs.video.interior_shadow_dynamic = (v.max(1.0) as u32).clamp(1, crate::torch_shadow::MAX_TORCH_DYNAMIC as u32), // MONKEY (live bank rank): 1..8.
|
||||
// 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" => {
|
||||
knobs.video.interior_shadow_entity_rate = (v.max(0.0) as u32).min(240);
|
||||
}
|
||||
"interiorshadowsoft" => knobs.video.interior_shadow_soft = v.clamp(0.5, 3.0),
|
||||
"interiordebug" => knobs.video.interior_debug = (v.max(0.0) as u32).min(3),
|
||||
"interiordebug" => knobs.video.interior_debug = (v.max(0.0) as u32).min(4),
|
||||
// MONKEY (fire GO lights): the synthesised-fire gain, clamped at the edge like the rest.
|
||||
"firelightgain" => knobs.video.fire_light_gain = v.clamp(0.0, 4.0),
|
||||
// MONKEY (flame flicker): 0..2 — the amplitudes are authored at 1, and 2 is the deliberate
|
||||
// over-drive for judging the shape. Clamped at the edge like every knob here.
|
||||
"fireflicker" => knobs.video.fire_flicker = v.clamp(0.0, 2.0),
|
||||
// Display mode (1627) — a flag like every other checkbox here, and the reference's own
|
||||
// polarity: `1` is WINDOWED (the row is "Windowed Mode"). `video::apply_window_mode`
|
||||
// watches the value and pushes it to the window; nothing else reads it.
|
||||
@@ -1504,7 +1604,7 @@ fn sync_cvars(
|
||||
.collect(),
|
||||
);
|
||||
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
|
||||
let session: [(&str, String); 59] = [
|
||||
let session: [(&str, String); 67] = [
|
||||
("MasterVolume", sound.master.to_string()),
|
||||
("SoundVolume", sound.sfx.to_string()),
|
||||
("MusicVolume", sound.music.to_string()),
|
||||
@@ -1553,6 +1653,11 @@ fn sync_cvars(
|
||||
("worldShadows", flag(video.world_shadows)),
|
||||
("characterShadows", flag(video.character_shadows)),
|
||||
("shadowDistance", video.shadow_distance.to_string()),
|
||||
("shadowMapSize", video.shadow_map_size.to_string()),
|
||||
("shadowFilter", video.shadow_filter.to_string()),
|
||||
("characterShadowRate", video.character_shadow_rate.to_string()),
|
||||
("worldShadowRate", video.world_shadow_rate.to_string()),
|
||||
("shadowCasterReach", video.shadow_caster_reach.to_string()),
|
||||
("interiorLight", flag(video.interior_light)),
|
||||
("interiorAmbient", video.interior_ambient.to_string()),
|
||||
("interiorFill", video.interior_fill.to_string()),
|
||||
@@ -1560,14 +1665,20 @@ fn sync_cvars(
|
||||
("interiorAttenScale", video.interior_atten_scale.to_string()),
|
||||
("interiorRoomGate", flag(video.interior_room_gate)),
|
||||
("interiorShadows", flag(video.interior_shadows)),
|
||||
("exteriorShadows", flag(video.exterior_shadows)),
|
||||
(
|
||||
"interiorShadowCasters",
|
||||
video.interior_shadow_casters.to_string(),
|
||||
),
|
||||
("interiorShadowDynamic", video.interior_shadow_dynamic.to_string()),
|
||||
(
|
||||
"interiorShadowEntityRate",
|
||||
video.interior_shadow_entity_rate.to_string(),
|
||||
),
|
||||
("interiorShadowSoft", video.interior_shadow_soft.to_string()),
|
||||
("interiorDebug", video.interior_debug.to_string()),
|
||||
("fireLightGain", video.fire_light_gain.to_string()),
|
||||
("fireFlicker", video.fire_flicker.to_string()),
|
||||
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
|
||||
(
|
||||
"gxWindow",
|
||||
@@ -1985,6 +2096,15 @@ mod tests {
|
||||
d["characterShadows"] != 0.0,
|
||||
VideoConfig::default().character_shadows
|
||||
);
|
||||
// MONKEY (sun shadow perf): the five cost dials weld to the video knob's shipped defaults
|
||||
// exactly like the two flags above — a registered row that drifts from what the rig
|
||||
// actually runs is a setting that reads one way in the config and behaves another.
|
||||
let shadows = VideoConfig::default();
|
||||
assert_eq!(d["shadowMapSize"], shadows.shadow_map_size as f32);
|
||||
assert_eq!(d["shadowFilter"], shadows.shadow_filter as f32);
|
||||
assert_eq!(d["characterShadowRate"], shadows.character_shadow_rate as f32);
|
||||
assert_eq!(d["worldShadowRate"], shadows.world_shadow_rate as f32);
|
||||
assert_eq!(d["shadowCasterReach"], shadows.shadow_caster_reach);
|
||||
// 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
|
||||
|
||||
@@ -53,6 +53,10 @@ fn bridge(
|
||||
// `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,
|
||||
};
|
||||
if *out != want {
|
||||
*out = want;
|
||||
|
||||
@@ -50,6 +50,9 @@ use carried_light::spawn_carried_lights;
|
||||
// MONKEY (carried light stability): `torch_shadow` needs the settle verdict to refuse a MOVING
|
||||
// carried light a cube-shadow slot.
|
||||
pub(crate) use carried_light::CarriedLightMotion;
|
||||
// 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
|
||||
|
||||
@@ -924,7 +924,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
|
||||
|
||||
@@ -44,10 +44,32 @@ use benilla_world::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom};
|
||||
///
|
||||
/// Children, not free entities: the light's lifecycle and its frame both come from the hierarchy, so
|
||||
/// a gear change, a despawn, or a mount transition takes its lights with it.
|
||||
/// MONKEY (outdoor torch shadows): this light is carried BY A BODY — a held torch, a creature's
|
||||
/// own glow (an imp's hand flame, a fire elemental), a player's lantern. It is a marker, not a
|
||||
/// gate: the light still lights the world exactly as it did, and it is still eligible for an
|
||||
/// INTERIOR cube-shadow slot (that lane's behaviour is unchanged by this feature).
|
||||
///
|
||||
/// What it refuses is the EXTERIOR shadow lane. Three separate reasons, any one of which is enough:
|
||||
/// · the caster gather cannot exclude the owner's own body from its own map (the emitter-owner
|
||||
/// exclusion below works on retained `GxItem` bounds; a skinned creature is not one), so a
|
||||
/// torch-bearing guard would stand in his own shadow, at full outdoor strength;
|
||||
/// · a body walks, and this lane caches a depth cube per fixture and withdraws it the moment the
|
||||
/// fixture leaves the 0.1 yd it was baked at ([`super::super::torch_shadow`]'s
|
||||
/// `map_publishable`) — indoors a held torch is one of twelve resident candles and its churn is
|
||||
/// hidden, outdoors it would be one of six casters for a whole village;
|
||||
/// · the effect being built is "the CAMPFIRE throws the fence's shadow", and a shadow that walks
|
||||
/// with the light source is the one thing that reads as a bug rather than as lighting.
|
||||
/// Placed GameObject braziers and campfires are NOT held — they keep their slot (the
|
||||
/// [`CarriedLightMotion::settled`] gate is what covers a brazier riding a moving transport).
|
||||
#[derive(Component, Clone, Copy)]
|
||||
pub(crate) struct HeldLight;
|
||||
|
||||
pub(super) fn spawn_carried_lights(
|
||||
commands: &mut Commands,
|
||||
lights: &[ModelLight],
|
||||
frame: Entity,
|
||||
// MONKEY (outdoor torch shadows): does a BODY carry these lights? See [`HeldLight`].
|
||||
held: bool,
|
||||
joint: impl Fn(i16) -> Option<Entity>,
|
||||
) {
|
||||
for l in lights {
|
||||
@@ -100,6 +122,30 @@ pub(super) fn spawn_carried_lights(
|
||||
if l.synthetic {
|
||||
glow.insert(SyntheticFireLight);
|
||||
}
|
||||
// MONKEY (outdoor torch shadows): the exterior shadow lane's refusal marker (see
|
||||
// [`HeldLight`]). Tagged at the SPAWN site because that is the only place that knows who
|
||||
// owns the light — downstream all a query sees is a `PointLight` with a `ChildOf`, which a
|
||||
// placed brazier GameObject has just as much as a guard's torch does.
|
||||
if held {
|
||||
glow.insert(HeldLight);
|
||||
}
|
||||
// MONKEY (flame flicker): a carried flame breathes like any other — the imp's hand fire, a
|
||||
// held torch, a brazier GameObject. It is safe on THIS lane specifically because the
|
||||
// modulation touches neither position nor reach: the settle/claim logic
|
||||
// ([`CarriedLightMotion`], `carried_light_claims`) and the shadow slot's staleness test all
|
||||
// key on where the light IS, and none of them can see a brightness change. The seed mixes
|
||||
// the light's model-space offset with its PARENT, so two imps in one room never burn in
|
||||
// step (position alone would give every copy of a model the same phase).
|
||||
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(wow_to_bevy(local))
|
||||
^ parent.to_bits().rotate_left(11) as u32;
|
||||
glow.insert(benilla_world::lighting::FlameFlicker::new(kind, seed));
|
||||
}
|
||||
let glow = glow.id();
|
||||
commands.entity(parent).add_child(glow);
|
||||
}
|
||||
@@ -593,6 +639,7 @@ mod tests {
|
||||
},
|
||||
bone_pivot: [1.0, 0.0, 0.5],
|
||||
synthetic: false,
|
||||
flame: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +662,7 @@ mod tests {
|
||||
];
|
||||
app.world_mut().commands().queue(move |world: &mut World| {
|
||||
let mut q = world.commands();
|
||||
spawn_carried_lights(&mut q, &lights, frame, move |bone| {
|
||||
spawn_carried_lights(&mut q, &lights, frame, true, move |bone| {
|
||||
(bone == 3).then_some(joint)
|
||||
});
|
||||
});
|
||||
@@ -656,6 +703,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// MONKEY (outdoor torch shadows). The [`HeldLight`] marker is written at the SPAWN site and
|
||||
/// nowhere else, because the spawn site is the only place that knows who owns the light: after
|
||||
/// this, a guard's torch and a placed brazier are both just a `PointLight` under a `ChildOf`.
|
||||
/// It is a pure ADDITION — the light's own components (colour, intensity, flicker, synthetic
|
||||
/// tag) and its parenting are identical either way, so nothing about how it LIGHTS the world
|
||||
/// moves; only the exterior shadow lane reads it.
|
||||
#[test]
|
||||
fn a_body_carried_light_is_marked_and_a_placed_one_is_not() {
|
||||
for held in [true, false] {
|
||||
let mut app = App::new();
|
||||
let frame = app.world_mut().spawn(Transform::IDENTITY).id();
|
||||
let lights = [light(1, -1, [0.0, 0.0, 4.0], false)];
|
||||
app.world_mut().commands().queue(move |world: &mut World| {
|
||||
let mut q = world.commands();
|
||||
spawn_carried_lights(&mut q, &lights, frame, held, |_| None);
|
||||
});
|
||||
app.world_mut().flush();
|
||||
let marked = app
|
||||
.world_mut()
|
||||
.query::<(&PointLight, Has<HeldLight>)>()
|
||||
.iter(app.world())
|
||||
.map(|(_, h)| h)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(marked, vec![held], "held = {held}");
|
||||
}
|
||||
}
|
||||
|
||||
/// GOLDEN — MONKEY (carried light stability). A SYNTHESISED light does NOT ride its host
|
||||
/// bone even when the instance carries that joint: it hangs off the model FRAME at the raw
|
||||
/// model-space position, i.e. exactly where the bone ride would put it in the REST pose. The
|
||||
@@ -669,10 +743,11 @@ mod tests {
|
||||
let joint = app.world_mut().spawn(Transform::IDENTITY).id();
|
||||
let mut synth = light(1, 3, [2.0, 0.0, 1.5], false);
|
||||
synth.synthetic = true;
|
||||
synth.flame = true; // MONKEY (flame flicker): the flame route — the imp's hand fire
|
||||
let lights = [synth];
|
||||
app.world_mut().commands().queue(move |world: &mut World| {
|
||||
let mut q = world.commands();
|
||||
spawn_carried_lights(&mut q, &lights, frame, move |bone| {
|
||||
spawn_carried_lights(&mut q, &lights, frame, true, move |bone| {
|
||||
(bone == 3).then_some(joint)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -744,7 +744,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
|
||||
|
||||
@@ -423,11 +423,10 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
if !l.def.casts() {
|
||||
continue; // directional lights feed an ambient term; a static `0` visibility key is dark
|
||||
}
|
||||
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(
|
||||
prop.local.transform_point(wow_to_bevy(l.def.position)),
|
||||
),
|
||||
Transform::from_translation(local),
|
||||
Visibility::default(),
|
||||
));
|
||||
// MONKEY (fire GO lights): a transport prop takes SYNTHESISED lights like the
|
||||
@@ -436,6 +435,19 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
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;
|
||||
|
||||
@@ -2036,6 +2036,7 @@ mod tests {
|
||||
},
|
||||
bone_pivot: [0.0; 3],
|
||||
synthetic: false,
|
||||
flame: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ 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};
|
||||
@@ -44,7 +45,7 @@ use benilla_assets::materials::WowModelMaterial;
|
||||
use benilla_formats::ModelBlend;
|
||||
use benilla_world::billboard::BillboardCard;
|
||||
use benilla_world::interact::PickMesh;
|
||||
use benilla_world::lighting::{ShadowDistance, WowLighting};
|
||||
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;
|
||||
@@ -62,14 +63,114 @@ pub(crate) const PLAYER_SHADOW_LAYER: usize = 31;
|
||||
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 fixed 4096 shadow map spreads thinner (softer) over a larger area and the caster-collection
|
||||
/// cost grows with distance.
|
||||
/// 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;
|
||||
@@ -200,6 +301,10 @@ struct ShadowRigState {
|
||||
/// `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;
|
||||
@@ -230,14 +335,96 @@ impl Material for ShadowCasterMaterial {
|
||||
|
||||
impl Plugin for ShadowCorePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(DirectionalLightShadowMap { size: 4096 }) // MONKEY: crisper shadow edges
|
||||
// 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())
|
||||
.add_systems(Last, manage_rig.in_set(ShadowSet::Rig));
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,8 +560,15 @@ fn manage_rig(
|
||||
|
||||
// MONKEY (moving sun): aim at the VISIBLE celestial sun (rises/sets), clamped in elevation.
|
||||
let sun_direction = shadow_sun_travel(lighting.celestial_dir());
|
||||
let entity_reach = distance + STATIC_REBUILD_STEP;
|
||||
let tall_reach = static_collection_reach(sun_direction, distance);
|
||||
// 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).
|
||||
@@ -605,10 +799,11 @@ fn append_skinned(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
append_triangles, casts_realtime_shadow, shadow_reach_extension, shadow_sun_travel,
|
||||
static_collection_reach, sun_snap_due, DEFAULT_SHADOW_DISTANCE, MAX_CASTER_HEIGHT,
|
||||
MAX_SHADOW_SUN_ELEVATION, MIN_SHADOW_SUN_ELEVATION, SHADOW_REACH_CAP, STATIC_REBUILD_STEP,
|
||||
SUN_SNAP_RADIANS,
|
||||
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;
|
||||
@@ -685,4 +880,54 @@ mod tests {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,19 @@
|
||||
//! 1.5x hysteresis and fades choose up to twelve of sixteen resident slots; this is still a finite
|
||||
//! budget, not a promise that a room with forty eligible candles can give all forty cube maps.
|
||||
//! Rigid furniture joins the cached static geometry. Only the nearest interiorShadowDynamic
|
||||
//! promoted fixtures (default four, measured from the player) overlay creatures/animated parts.
|
||||
//! promoted fixtures (default four, maximum eight, measured from the player) overlay creatures/animated parts.
|
||||
//!
|
||||
//! MONKEY (torch lane perf): what the lane costs when nothing is happening. Three fixed per-frame
|
||||
//! charges were paid regardless of every count dial — which is why `interiorShadowCasters 8` and
|
||||
//! `interiorShadowDynamic 2` measured identically to the defaults — and each now has a gate:
|
||||
//! the static-cache FINGERPRINT walks one slot a frame instead of four AND skips even that while
|
||||
//! the scene census is unchanged ([`TORCH_SCAN_PER_FRAME`], [`PartCensus`]); the moving-caster
|
||||
//! gather runs at `interiorShadowEntityRate` Hz (default 30, `0` = every frame) over each dynamic
|
||||
//! fixture's OWN reach rather than the cube's 48 yd ([`entity_gather_due`],
|
||||
//! [`entity_gather_radius`]); and the receivers skip the table scan where the fixture's direct term
|
||||
//! is already nothing (`TORCH_SKIP_EPS`, both shaders). None of it changes what is drawn: the
|
||||
//! overlay passes still run every frame from the last mesh, so a lower rate ages a POSE and never
|
||||
//! removes a shadow. `WOW_TORCH_TRACE=1` prints the three counters as `torch-perf:` lines.
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
@@ -47,10 +59,11 @@ use benilla_world::billboard::BillboardCard;
|
||||
use benilla_world::interact::PickMesh;
|
||||
use benilla_world::lighting::{
|
||||
interior_reach, m2_light_reach, DynamicInteriors, FireLightGain, LightLane, LightLitRooms,
|
||||
LightReach, LightRooms, ShadowDistance, SyntheticFireLight,
|
||||
LightReach, LightRooms, ShadowDistance, ShadowProxyLight, SyntheticFireLight, WowLighting,
|
||||
};
|
||||
// MONKEY (carried light stability): the settle verdict a carried light earns by standing still.
|
||||
use crate::entities::CarriedLightMotion;
|
||||
// MONKEY (outdoor torch shadows): …and the marker that says a light is CARRIED BY A BODY.
|
||||
use crate::entities::{CarriedLightMotion, HeldLight};
|
||||
use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder};
|
||||
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
|
||||
use benilla_world::static_gx::{StaticGx, TorchShadowViews};
|
||||
@@ -68,6 +81,8 @@ use crate::video::VideoConfig;
|
||||
/// `torch_depth::MAX_TORCH_MAPS`; the number actually filled is the live `interiorShadowCasters`
|
||||
/// cvar (default 12), so raising the cap does not by itself cost a frame.
|
||||
const MAX_TORCH_CASTERS: usize = 16;
|
||||
/// MONKEY (live bank rank): the cvar and resource consumer share the render allocation's cap.
|
||||
pub(crate) const MAX_TORCH_DYNAMIC: usize = TorchShadowViews::MAX_TORCH_DYNAMIC;
|
||||
/// MONKEY (torch caster reach): promote out to FOUR times the fixture's effective reach. Its
|
||||
/// pool still needs shadows when viewed across the room, long before it lights the player's body.
|
||||
const TORCH_ELIGIBLE_REACH_MULT: f32 = 4.0;
|
||||
@@ -105,9 +120,28 @@ const CUBE_FACES: usize = 6;
|
||||
/// per frame. Hashing one fixture's 48 yd source set walks every resident `GxItem` and every model
|
||||
/// part; doing that for all twelve residents every frame hands the CPU back the cost the GPU cache
|
||||
/// just saved (~12 full scene scans per frame, and it is a per-frame cost even in a room where
|
||||
/// nothing ever changes). Four slots, cursor-rotated, still notices any streaming change within
|
||||
/// three frames — under a tenth of a second, and the map it then rebuilds is the same one.
|
||||
const TORCH_SCAN_PER_FRAME: usize = 4;
|
||||
/// nothing ever changes).
|
||||
///
|
||||
/// MONKEY (torch lane perf): ONE, not four. Four was already the "don't scan them all" compromise,
|
||||
/// but it is still four full walks of the resident static scene AND of every model part, every
|
||||
/// frame, forever — measured as the bulk of the ~3 ms the whole lane costs at the Lion's Pride Inn,
|
||||
/// which is exactly why neither `interiorShadowCasters` nor `interiorShadowDynamic` moved the frame
|
||||
/// time: the cost was never per map. One slot a frame is a 12-frame (~0.26 s) sweep of a full
|
||||
/// resident set, which is the same order as the cross-fade a newly promoted slot ramps in over, so
|
||||
/// nothing the eye can catch waits on it. Two further things keep that honest: a slot that has
|
||||
/// never been fingerprinted JUMPS the queue (see `scan_start`), so a promotion still builds at
|
||||
/// once, and the walk itself is skipped whenever neither the retained scene nor the entity-part
|
||||
/// census has changed since that slot last looked ([`PartCensus`]).
|
||||
const TORCH_SCAN_PER_FRAME: usize = 1;
|
||||
/// MONKEY (torch lane perf): slack (yd) added to a fixture's own reach when gathering MOVING
|
||||
/// casters for it. The receivers' direct term is `interior_window(d, reach, ..)`, which is exactly
|
||||
/// zero at `d >= reach`, so a caster further than `reach` from the fixture can only shadow
|
||||
/// fragments the fixture does not light — it is invisible by construction. The margin covers the
|
||||
/// part's own extent, because the admission test is on the part's ORIGIN and a body is a couple of
|
||||
/// yards tall: without it a unit standing just inside the pool with its origin just outside would
|
||||
/// lose its shadow. (The STATIC gather is untouched at the full [`TORCH_RANGE`]: it is cached, so
|
||||
/// its radius is not a per-frame cost, and shrinking it would invalidate every cached map.)
|
||||
const TORCH_ENTITY_REACH_MARGIN: f32 = 4.0;
|
||||
/// MONKEY (static torch cache): how far (yd²) a fixture may drift from the position its cached map
|
||||
/// was rendered at before that map is WITHDRAWN. A cached map that is merely STALE (the room
|
||||
/// streamed a chair in since it was baked) keeps being published — the shadow is a few frames out
|
||||
@@ -117,6 +151,23 @@ const TORCH_SCAN_PER_FRAME: usize = 4;
|
||||
/// blink: an unready slot's `positions[i].w` is forced to 0 with no fade — precisely the pop the
|
||||
/// whole cross-fade exists to prevent — for however many frames the rebuild budget takes to reach it.
|
||||
const TORCH_STALE_DRIFT_SQ: f32 = 0.01;
|
||||
/// MONKEY (outdoor torch shadows): how far from the PLAYER an exterior fire may still be promoted
|
||||
/// (yd). The interior lane's `4R + slack` window has no meaning out here — an exterior entry packs
|
||||
/// no reach at all (`lane == 0`, the colour row's reach lane is the interior half's), and the
|
||||
/// receivers apply the un-windowed `1/(0.7d + 0.03d²)` to it. So the window is the CUBE MAP's own
|
||||
/// range: past [`TORCH_RANGE`] a fixture's whole shadow volume is outside the projection anyway,
|
||||
/// and the world fade in the shaders has already ended by then.
|
||||
const TORCH_EXT_ELIGIBLE_YD: f32 = TORCH_RANGE;
|
||||
/// MONKEY (outdoor torch shadows): EMITTER-OWNER EXCLUSION radius for an exterior slot (yd) — any
|
||||
/// retained item whose whole bounding sphere sits inside this of the flame is the fire's OWN body
|
||||
/// and is dropped from its caster gather (`StaticGx::append_torch_triangles`). Indoors this is 0
|
||||
/// (the interior gather is untouched): an authored MOLT fixture stands clear of its bracket, so
|
||||
/// nothing was ever wrong there. Outdoors the light sits INSIDE the campfire prop's opaque logs, and
|
||||
/// left in they are the nearest occluder on all six faces — the fire blacks out its own pool.
|
||||
/// 2.5 yd covers a campfire/brazier prop whole; a crate or fence post two yards away has its centre
|
||||
/// inside that but not its sphere, so it still casts (containment, not centre distance — see
|
||||
/// `torch_item_is_emitter`).
|
||||
const TORCH_EXT_SELF_EXCLUDE: f32 = 2.5;
|
||||
/// Each cube face's FOV: 90° plus a hair, so a direction exactly on a face border still lands
|
||||
/// inside the face the shader picks for it (the projector returns "lit" outside its frustum).
|
||||
const TORCH_FACE_FOV: f32 = std::f32::consts::FRAC_PI_2 + 0.02;
|
||||
@@ -154,6 +205,24 @@ struct TorchSlot {
|
||||
pos: Vec3,
|
||||
/// Its contribution score at the player this frame (see [`fixture_score`]).
|
||||
score: f32,
|
||||
/// MONKEY (torch lane perf): the fixture's EFFECTIVE reach in yards — the same `R` the light
|
||||
/// packer writes into the receivers' colour row and the same one [`fixture_score`] ranks with.
|
||||
/// Carried on the slot so the moving-caster gather can be sized off the light that will
|
||||
/// actually be sampled, instead of the cube projection's flat 48 yd far plane. Exterior slots
|
||||
/// hold [`TORCH_RANGE`]: an exterior entry packs no reach, and its receivers apply an
|
||||
/// un-windowed falloff, so there is no smaller honest number out there.
|
||||
reach: f32,
|
||||
/// MONKEY (torch lane perf): the `(retained-scene generation, static-part census, position)`
|
||||
/// this slot last FINGERPRINTED at. While all three are unchanged the source set cannot have
|
||||
/// changed either, so the expensive walk (every resident `GxItem` in 48 yd, then every model
|
||||
/// part) is skipped outright. `None` = never fingerprinted, which also jumps the scan queue.
|
||||
checked: Option<(u64, u64, Vec3)>,
|
||||
/// MONKEY (outdoor torch shadows): which LANE promoted it. Drives the exterior half of the
|
||||
/// budget split, the emitter-owner exclusion radius its caster gather uses, and the published
|
||||
/// `exterior` bit the receivers gate on. A slot never changes lane: a fixture is interior or
|
||||
/// exterior by where it physically stands ([`LightLane`]), and if that verdict ever flipped the
|
||||
/// fixture would leave `cands` and be cross-faded out like any other loss.
|
||||
exterior: bool,
|
||||
/// The cross-fade weight, published as `positions[i].w` and applied as `mix(1, shadow, w)` by
|
||||
/// both receivers.
|
||||
w: f32,
|
||||
@@ -177,6 +246,38 @@ struct TorchLane {
|
||||
slots: Vec<TorchSlot>,
|
||||
/// When the last CONTESTED swap was started (seconds of `Time::elapsed`), for the cooldown.
|
||||
last_swap: f64,
|
||||
/// MONKEY (torch lane perf): when the moving-caster mesh was last REGATHERED, and for which
|
||||
/// dynamic set. The mesh is retained between regathers — the six overlay passes still run every
|
||||
/// frame from it — so this is a staleness dial on the POSE, never an on/off for the shadow.
|
||||
/// The mask rides along because a gather is only valid for the fixtures it was gathered
|
||||
/// around: when the dynamic set changes the cadence is overridden and the mesh rebuilt at once.
|
||||
entity_at: f64,
|
||||
entity_mask: u32,
|
||||
/// MONKEY (torch lane perf): `WOW_TORCH_TRACE` accounting for the second in progress — how many
|
||||
/// fingerprint walks actually ran, how many regathers, and the last gather's admitted-part
|
||||
/// count and radius. Counters, not timings: they are what tells the user whether a cadence or
|
||||
/// a gate is doing anything at all.
|
||||
trace_scans: u32,
|
||||
trace_gathers: u32,
|
||||
trace_parts: u32,
|
||||
trace_reach: f32,
|
||||
}
|
||||
|
||||
/// MONKEY (torch lane perf): the per-frame "has anything a fingerprint would hash MOVED?" summary,
|
||||
/// computed ONCE for the whole lane instead of once per scanned slot.
|
||||
///
|
||||
/// The fingerprint it guards is order-independent and covers (a) the retained static scene and (b)
|
||||
/// every rigid opaque model part. This pair reproduces both halves cheaply: `gx` folds the retained
|
||||
/// regions' own change stamps and populations (O(regions), not O(items) — see
|
||||
/// `StaticGx::torch_residency_generation`), and `parts` folds one branch-free mix per part over its
|
||||
/// entity id, its geometry `Arc` identity, its translation AND one basis column, so an arrival, a
|
||||
/// despawn, a geometry swap, a move and a rotation each change it. It is deliberately NOT a
|
||||
/// substitute for the fingerprint — it is scene-wide where the fingerprint is fixture-local, so it
|
||||
/// can only ever say "nothing changed anywhere, don't bother looking".
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
struct PartCensus {
|
||||
gx: u64,
|
||||
parts: u64,
|
||||
}
|
||||
|
||||
/// MONKEY (torch caster selection): the interior direct term this fixture delivers at distance `d`
|
||||
@@ -186,6 +287,11 @@ struct TorchLane {
|
||||
/// only the second one is stable as you walk, because a fixture's contribution changes smoothly
|
||||
/// with distance while its RANK by distance changes in steps.
|
||||
///
|
||||
/// MONKEY (torch caster selection): one scored candidate — `(fixture, position, score, exterior,
|
||||
/// reach)`. Named because MONKEY (torch lane perf) added the trailing REACH lane, and a `(.., x)`
|
||||
/// pattern over a bare tuple would silently have re-bound to it.
|
||||
type Candidate = (Entity, Vec3, f32, bool, f32);
|
||||
|
||||
/// MONKEY (torch caster reach): retain the intensity and soft inverse-square core at the player's
|
||||
/// distance, but stretch the selection window to 4R (including eligibility slack). This is a
|
||||
/// ranking extension, not a lighting change: at 3R the score is small, positive and still falling.
|
||||
@@ -212,6 +318,82 @@ fn candidate_score(
|
||||
fixture_score(intensity, d, r) * if synthetic { fire_gain.max(0.0) } else { 1.0 }
|
||||
}
|
||||
|
||||
/// MONKEY (outdoor torch shadows): the EXTERIOR direct term this fixture delivers at distance `d`
|
||||
/// — the exterior receivers' OWN profile (`point_light_sum` / `wmo_exterior_point_sum`:
|
||||
/// `1/(0.7d + 0.03d²)`, no soft core, no authored window), times the fixture's luminous intensity.
|
||||
/// A separate function rather than a flag on [`fixture_score`] because the two lanes genuinely
|
||||
/// light differently, and the whole point of scoring by CONTRIBUTION is that the ranking key is the
|
||||
/// number the receiver will actually compute.
|
||||
///
|
||||
/// `d` is floored at 1 yd for the ATTENUATION only: the exterior falloff has a pole at 0 and the
|
||||
/// receivers live with it (no surface is ever at the light), but a ranking key that goes to
|
||||
/// infinity would let a fixture the player is standing on outrank the entire village for one frame
|
||||
/// and then hand the slot back. The window is unfloored and reaches zero at the eligibility edge,
|
||||
/// so a candidate's score falls off smoothly instead of stepping to 0 the frame it drops out.
|
||||
fn exterior_fixture_score(intensity: f32, d: f32) -> f32 {
|
||||
let dd = d.max(1.0);
|
||||
let atten = 1.0 / (0.7 * dd + 0.03 * dd * dd);
|
||||
// `INTERIOR_DIRECT_POW` only for the WINDOW's shape — the C1 shoulder that takes a candidate's
|
||||
// score to exactly zero at the edge instead of stepping it there. It is the interior lane's
|
||||
// constant because it is the same shoulder, not because this is an interior term; the
|
||||
// attenuation above is the exterior receivers' own and shares nothing with that lane.
|
||||
let w = (1.0 - (d / TORCH_EXT_ELIGIBLE_YD).clamp(0.0, 1.0).powf(INTERIOR_DIRECT_POW))
|
||||
.clamp(0.0, 1.0);
|
||||
intensity * atten * w * w
|
||||
}
|
||||
|
||||
/// MONKEY (outdoor torch shadows): may an EXTERIOR fire be promoted at all? Same two camera tests
|
||||
/// as [`fixture_eligible`] (it is the same cube-map budget and the same `shadowDistance` dial);
|
||||
/// the player-distance window is the flat [`TORCH_EXT_ELIGIBLE_YD`] instead of `4R + slack`,
|
||||
/// because an exterior entry has no packed reach to take four of.
|
||||
fn exterior_eligible(player_d: f32, camera_d: f32, distance: f32) -> bool {
|
||||
camera_d <= TORCH_SEARCH_RADIUS.max(distance)
|
||||
&& camera_d <= distance
|
||||
&& player_d <= TORCH_EXT_ELIGIBLE_YD
|
||||
}
|
||||
|
||||
/// MONKEY (outdoor torch shadows): how many of the `interiorShadowCasters` resident slots an
|
||||
/// exterior fire may take. HALF, rounded up: a village square at night can easily author more
|
||||
/// campfires than the whole budget, and without a cap the first night in Goldshire would evict
|
||||
/// every indoor candle in the inn a step away — the interior lane's look is shipped and tuned, and
|
||||
/// this feature must not be able to take it away. Rounded UP so the smallest budgets
|
||||
/// (`interiorShadowCasters 1`) still get one outdoor shadow rather than none.
|
||||
fn exterior_budget(want: usize) -> usize {
|
||||
want.div_ceil(2)
|
||||
}
|
||||
|
||||
/// MONKEY (outdoor torch shadows): apply the budget split, by TRUNCATING the sorted candidate list
|
||||
/// rather than by teaching the slot machinery about lanes. Everything downstream — the incumbent
|
||||
/// refresh, the fade-out, the promotion, the contested swap — reads `cands`, so dropping the
|
||||
/// exterior tail here means an exterior incumbent that falls past the cap cross-fades out exactly
|
||||
/// like a fixture that walked away ("fade, not switch" holds for this eviction too), and no
|
||||
/// exterior fixture can ever be promoted into slot `cap + 1`. Interior candidates are untouched:
|
||||
/// the cap is a ceiling on the outdoor lane, never a floor under it, so a night indoors still fills
|
||||
/// all twelve slots with candles.
|
||||
///
|
||||
/// `cands` must already be sorted best-first — the survivors are then the BEST `cap` exterior
|
||||
/// fires, which is the same ranking rule the whole lane runs on.
|
||||
fn cap_exterior_candidates(cands: &mut Vec<Candidate>, cap: usize) {
|
||||
let mut seen = 0usize;
|
||||
cands.retain(|(_, _, _, exterior, _)| {
|
||||
if !*exterior {
|
||||
return true;
|
||||
}
|
||||
seen += 1;
|
||||
seen <= cap
|
||||
});
|
||||
}
|
||||
|
||||
/// MONKEY (outdoor torch shadows): the realtime-shadow day strength — a three-line mirror of
|
||||
/// `benilla_world::lighting::global_light`'s private `sun_shadow_strength` (`blob_shadow.rs` keeps
|
||||
/// the same mirror, for the same reason: it is three lines and a `pub` on it would be a wider API
|
||||
/// than the number deserves). **Exactly 1.0 above ~12° of sun elevation**, which is what makes
|
||||
/// `night_w = 1 - this` exactly 0 by day in the receivers — the daylight-is-untouched guarantee.
|
||||
fn sun_shadow_strength(sun_height: f32) -> f32 {
|
||||
let t = (sun_height / 0.208).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// MONKEY (static torch cache): camera distance uses the world-shadow resource's Bevy-yard
|
||||
/// yardstick. The player-centred 4R+3 window still limits/ranks useful pools; BOTH tests apply.
|
||||
/// The coarse camera search grows with shadowDistance so it can never silently shorten that dial.
|
||||
@@ -237,7 +419,37 @@ fn dynamic_set(fixtures: &[(usize, Vec3)], anchor: Vec3, budget: usize) -> u32 {
|
||||
let mut nearest = fixtures.to_vec();
|
||||
nearest.sort_by(|a, b| a.1.distance_squared(anchor).total_cmp(&b.1.distance_squared(anchor))
|
||||
.then(a.0.cmp(&b.0)));
|
||||
nearest.iter().take(budget.min(MAX_TORCH_CASTERS)).fold(0, |mask, (i, _)| mask | (1 << i))
|
||||
// MONKEY (live bank rank): also clamp resource values that bypass the cvar setter.
|
||||
nearest.iter().take(budget.clamp(1, MAX_TORCH_DYNAMIC)).fold(0, |mask, (i, _)| mask | (1 << i))
|
||||
}
|
||||
|
||||
/// MONKEY (torch lane perf): the radius the MOVING-caster gather admits parts inside, for a
|
||||
/// fixture of effective reach `reach`. See [`TORCH_ENTITY_REACH_MARGIN`] for why `reach + margin`
|
||||
/// is the honest bound and not a tightening of the effect; clamped to [`TORCH_RANGE`] because
|
||||
/// nothing outside the cube projection can be recorded anyway.
|
||||
fn entity_gather_radius(reach: f32) -> f32 {
|
||||
if !reach.is_finite() {
|
||||
return TORCH_RANGE;
|
||||
}
|
||||
(reach + TORCH_ENTITY_REACH_MARGIN).clamp(TORCH_ENTITY_REACH_MARGIN, TORCH_RANGE)
|
||||
}
|
||||
|
||||
/// MONKEY (torch lane perf): is `origin` inside ANY of the gather's fixtures' own radii — the
|
||||
/// UNION that replaced "48 yd of every dynamic fixture". Split out as a pure function because it is
|
||||
/// the whole admission rule of the gather and the one part of it worth a test.
|
||||
fn within_any_reach(fixtures: &[(usize, Vec3, f32)], origin: Vec3) -> bool {
|
||||
fixtures.iter().any(|(_, p, r)| p.distance_squared(origin) <= r * r)
|
||||
}
|
||||
|
||||
/// MONKEY (torch lane perf): is a REGATHER of the moving-caster mesh due this frame?
|
||||
///
|
||||
/// `rate == 0` is the pre-feature every-frame behaviour, kept as the live A/B. A changed dynamic
|
||||
/// SET always forces one — the mesh is only valid for the fixtures it was gathered around, and
|
||||
/// keeping a stale one there would put a shadow in a room the overlay no longer serves. `elapsed`
|
||||
/// is compared against `1/rate` with a small tolerance so a 30 Hz cadence on a 60 fps frame lands
|
||||
/// on every other frame rather than alternating 1 and 3 frames as the two clocks beat.
|
||||
fn entity_gather_due(rate: u32, elapsed: f64, mask: u32, last_mask: u32) -> bool {
|
||||
rate == 0 || mask != last_mask || elapsed >= 1.0 / f64::from(rate) - 1e-4
|
||||
}
|
||||
|
||||
/// MONKEY (torch caster selection): the entity-caster query trio, bundled as ONE `SystemParam`.
|
||||
@@ -264,13 +476,33 @@ struct EntityCasters<'w, 's> {
|
||||
rigs: Query<'w, 's, &'static RigSkin>,
|
||||
palettes: Res<'w, RigPalettes>,
|
||||
distance: Res<'w, ShadowDistance>,
|
||||
/// MONKEY (outdoor torch shadows): the live celestial sun, for the night gate. It rides THIS
|
||||
/// bundle rather than being a 17th system param because `update_torch_shadows` already sits at
|
||||
/// Bevy's 16-param ceiling — the same reason `distance` is here.
|
||||
lighting: Res<'w, WowLighting>,
|
||||
}
|
||||
|
||||
/// The lane is active when the dynamic-interior lane is on AND its shadow toggle is on.
|
||||
/// The INTERIOR lane is active when the dynamic-interior lane is on AND its shadow toggle is on.
|
||||
fn interior_shadows_on(video: &VideoConfig) -> bool {
|
||||
video.interior_light && video.interior_shadows
|
||||
}
|
||||
|
||||
/// MONKEY (outdoor torch shadows): the EXTERIOR lane's gate — its own cvar AND night.
|
||||
///
|
||||
/// Deliberately independent of `interiorLight`: the exterior receivers (`wmo_exterior_point_sum`
|
||||
/// on a WMO's outdoor-class surfaces, `point_light_sum` on doodads/entities) have never been part
|
||||
/// of the dynamic-interior feature and are drawn with `interiorLight 0` exactly as with it, so
|
||||
/// tying their shadows to that dial would be surprising in both directions.
|
||||
///
|
||||
/// Night is a HARD gate, not a fade to zero, because it is what buys the "daytime is bit-identical"
|
||||
/// claim on the CPU side as well as in the shaders: with the sun up there are no exterior
|
||||
/// candidates, so no exterior slot, so no exterior caster mesh, no depth render, and the published
|
||||
/// `exterior` bit is 0. `sun_shadow_strength` saturates at exactly 1.0 above ~12° of elevation, so
|
||||
/// the test is the same threshold the receivers' own `night_w` crosses — the two cannot disagree.
|
||||
fn exterior_shadows_on(video: &VideoConfig, sun_strength: f32) -> bool {
|
||||
video.exterior_shadows && sun_strength < 1.0
|
||||
}
|
||||
|
||||
/// A reverse-Z perspective (near → 1, far → 0; wgpu clip, RH, looking down −Z) — matches the whole
|
||||
/// engine's reverse-Z convention and the torch depth pipeline's `GreaterEqual` compare. Built in f64.
|
||||
fn reverse_z_perspective(fov_y: f64, aspect: f64, near: f64, far: f64) -> DMat4 {
|
||||
@@ -331,11 +563,19 @@ fn update_torch_shadows(
|
||||
// MONKEY (carried light stability): `Has<ChildOf>` + `Option<&CarriedLightMotion>` ride the
|
||||
// tuple so the filter below can refuse a MOVING carried light a slot. Both are cheap table
|
||||
// reads on a query that already walks these entities.
|
||||
// MONKEY (outdoor torch shadows): the FILTER moved into the closure. It used to be
|
||||
// `Or<(With<LightRooms>, With<LightLitRooms>)>` — "has a room claim" — which is exactly the set
|
||||
// of fixtures that can be interior, and therefore excluded every outdoor campfire from the
|
||||
// query outright. The filter is now the packer's own (`Without<ShadowProxyLight>`: a proxy is a
|
||||
// `PointLight` that exists only to cast, never to light), and the room-claim test is applied
|
||||
// per-candidate on the INTERIOR arm below, so the interior candidate set is unchanged
|
||||
// member-for-member. `Has<LightLitRooms>` rides the tuple to reproduce the other half of the
|
||||
// retired `Or`, and `Has<HeldLight>` to refuse a body-carried torch an EXTERIOR slot.
|
||||
torches: Query<
|
||||
(Entity, &GlobalTransform, &PointLight, Option<&LightReach>, Option<&LightLane>,
|
||||
Has<LightRooms>, Has<SyntheticFireLight>, Has<ChildOf>,
|
||||
Option<&crate::entities::CarriedLightMotion>),
|
||||
Or<(With<LightRooms>, With<LightLitRooms>)>,
|
||||
Has<LightRooms>, Has<LightLitRooms>, Has<SyntheticFireLight>, Has<ChildOf>,
|
||||
Has<HeldLight>, Option<&crate::entities::CarriedLightMotion>),
|
||||
Without<ShadowProxyLight>,
|
||||
>,
|
||||
// MONKEY (torch caster selection): `atten_scale` — a fixture's EFFECTIVE radius is its authored
|
||||
// end times this live cvar, and the score's window is a fraction of that radius. Reading the
|
||||
@@ -349,7 +589,12 @@ fn update_torch_shadows(
|
||||
mut last_trace: Local<f64>,
|
||||
) {
|
||||
// Declare demand so the shared rig stays up while on.
|
||||
let on = interior_shadows_on(&video) && *state.get() == ClientState::InWorld;
|
||||
// MONKEY (outdoor torch shadows): EITHER lane keeps the lane alive — they share the sixteen
|
||||
// cube slots, the rebuild budget and the depth bank, so there is one system, not two.
|
||||
let sun_w = sun_shadow_strength(ents.lighting.celestial_dir().y);
|
||||
let interior_on = interior_shadows_on(&video);
|
||||
let exterior_on = exterior_shadows_on(&video, sun_w);
|
||||
let on = (interior_on || exterior_on) && *state.get() == ClientState::InWorld;
|
||||
demand.0 = demand.0 || on;
|
||||
|
||||
if !(frame.active && on) {
|
||||
@@ -389,9 +634,11 @@ fn update_torch_shadows(
|
||||
|
||||
// (1a) MONKEY (torch caster reach): each pool is eligible from 4R + slack away. Rank with the
|
||||
// same soft core/intensity so nearby contributors still win over pools seen across the room.
|
||||
let mut cands: Vec<(Entity, Vec3, f32)> = torches
|
||||
// MONKEY (outdoor torch shadows): the candidate tuple carries its LANE (`true` = exterior).
|
||||
let mut cands: Vec<Candidate> = torches
|
||||
.iter()
|
||||
.filter_map(|(e, gt, pl, reach, light_lane, has_rooms, synthetic, carried, motion)| {
|
||||
.filter_map(|(e, gt, pl, reach, light_lane, has_rooms, has_lit, synthetic, carried,
|
||||
held, motion)| {
|
||||
// MONKEY (carried light stability): a CARRIED light (`ChildOf` — a pet's hand flame,
|
||||
// an NPC's torch, a transport's deck brazier) casts only while it is STANDING STILL.
|
||||
//
|
||||
@@ -426,28 +673,68 @@ fn update_torch_shadows(
|
||||
.filter(|r| *r > 0.5)
|
||||
.unwrap_or_else(|| m2_light_reach(base));
|
||||
let r = interior_reach(authored, interiors.atten_scale);
|
||||
if !fixture_eligible(d, p.distance(cam), r, ents.distance.0) {
|
||||
return None;
|
||||
}
|
||||
// MONKEY (outdoor torch shadows): the `4R + slack` eligibility test moved DOWN into the
|
||||
// interior arm (unchanged there). It cannot be shared: `r` is an INTERIOR reach — the
|
||||
// authored MOLT end or the M2 intensity bucket times `interiorAttenScale` — and an
|
||||
// exterior entry packs no reach at all, so applying it out here would size an outdoor
|
||||
// campfire's promotion window off a number no exterior receiver ever reads.
|
||||
let c = pl.color.to_linear();
|
||||
// Rec.709 luminance of the committed colour: ONE scalar for "how much light is this",
|
||||
// so a dim blue magic brazier cannot outrank a bright hearth on channel count.
|
||||
let lum = (0.2126 * c.red + 0.7152 * c.green + 0.0722 * c.blue).max(0.0) * base.max(0.0);
|
||||
let score = candidate_score(lum, d, r, light_lane, has_rooms, synthetic, fire_gain.0);
|
||||
(score > 0.0).then_some((e, p, score))
|
||||
// MONKEY (outdoor torch shadows): the lane split. It is the SAME verdict the light
|
||||
// packer writes into the colour row's `.w` (`LightLane` — a light is interior iff it
|
||||
// physically stands in an interior-class WMO group, with the old `LightRooms` rule as
|
||||
// the pre-classification fallback), because that is what decides which receiver family
|
||||
// reads it and therefore which shadow lane can ever be sampled for it.
|
||||
let interior = light_lane.map_or(has_rooms, |l| l.interior);
|
||||
if interior {
|
||||
// The interior arm, unchanged — including the retired query filter's own
|
||||
// membership test, so this candidate set is identical member-for-member to the one
|
||||
// `Or<(With<LightRooms>, With<LightLitRooms>)>` produced.
|
||||
if !interior_on || !(has_rooms || has_lit) {
|
||||
return None;
|
||||
}
|
||||
if !fixture_eligible(d, p.distance(cam), r, ents.distance.0) {
|
||||
return None;
|
||||
}
|
||||
let score =
|
||||
candidate_score(lum, d, r, light_lane, has_rooms, synthetic, fire_gain.0);
|
||||
// MONKEY (torch lane perf): `r` rides along — the moving-caster gather sizes its
|
||||
// radius off the light that will be sampled, not off the cube's 48 yd far plane.
|
||||
return (score > 0.0).then_some((e, p, score, false, r));
|
||||
}
|
||||
// The EXTERIOR arm — a campfire, a brazier, a lamppost, a bonfire.
|
||||
if !exterior_on || held {
|
||||
return None;
|
||||
}
|
||||
if !exterior_eligible(d, p.distance(cam), ents.distance.0) {
|
||||
return None;
|
||||
}
|
||||
// Same synthetic-gain fold as the interior arm (and as the packer): dimming the
|
||||
// invented lights with `fireLightGain 0` must take their shadows with them, or a fire
|
||||
// that lights nothing would still carve a black wedge across the ground.
|
||||
let gain = if synthetic { fire_gain.0.max(0.0) } else { 1.0 };
|
||||
let score = exterior_fixture_score(lum * gain, d);
|
||||
// MONKEY (torch lane perf): an exterior entry packs no reach and its receivers apply an
|
||||
// un-windowed falloff, so the cube's own range is the only honest gather radius here.
|
||||
(score > 0.0).then_some((e, p, score, true, TORCH_RANGE))
|
||||
})
|
||||
.collect();
|
||||
cands.sort_by(|a, b| b.2.total_cmp(&a.2));
|
||||
let ext_cap = exterior_budget(want);
|
||||
cap_exterior_candidates(&mut cands, ext_cap);
|
||||
|
||||
// (1b) Refresh the incumbents against this frame's candidates. A fixture that dropped out of
|
||||
// the set (it moved away, despawned, its room streamed out) starts fading rather than
|
||||
// vanishing. A slot past the live budget does the same, so lowering `interiorShadowCasters`
|
||||
// fades its extra shadows out instead of cutting them.
|
||||
for (i, slot) in lane.slots.iter_mut().enumerate() {
|
||||
match cands.iter().find(|(e, _, _)| *e == slot.fixture) {
|
||||
Some((_, p, sc)) => {
|
||||
match cands.iter().find(|(e, ..)| *e == slot.fixture) {
|
||||
Some((_, p, sc, _, r)) => {
|
||||
slot.pos = *p;
|
||||
slot.score = *sc;
|
||||
slot.reach = *r;
|
||||
}
|
||||
None => {
|
||||
slot.score = 0.0;
|
||||
@@ -482,9 +769,9 @@ fn update_torch_shadows(
|
||||
// MONKEY (static torch cache): at most two new residents per frame, including startup.
|
||||
let mut promotions = 0;
|
||||
while lane.slots.len() < want && promotions < 2 {
|
||||
let Some(&(e, p, sc)) = cands
|
||||
let Some(&(e, p, sc, exterior, reach)) = cands
|
||||
.iter()
|
||||
.find(|(e, _, _)| !lane.slots.iter().any(|s| s.fixture == *e))
|
||||
.find(|(e, ..)| !lane.slots.iter().any(|s| s.fixture == *e))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
@@ -499,6 +786,9 @@ fn update_torch_shadows(
|
||||
fixture: e,
|
||||
pos: p,
|
||||
score: sc,
|
||||
reach,
|
||||
checked: None,
|
||||
exterior,
|
||||
w: 0.0,
|
||||
evicting: false,
|
||||
});
|
||||
@@ -518,8 +808,8 @@ fn update_torch_shadows(
|
||||
.map(|(i, s)| (i, s.score));
|
||||
let challenger = cands
|
||||
.iter()
|
||||
.find(|(e, _, _)| !lane.slots.iter().any(|s| s.fixture == *e))
|
||||
.map(|(_, _, sc)| *sc);
|
||||
.find(|(e, ..)| !lane.slots.iter().any(|s| s.fixture == *e))
|
||||
.map(|(_, _, sc, ..)| *sc);
|
||||
if let (Some((idx, weak)), Some(best)) = (weakest, challenger) {
|
||||
if best > weak * TORCH_SWAP_RATIO {
|
||||
lane.slots[idx].evicting = true;
|
||||
@@ -530,25 +820,36 @@ fn update_torch_shadows(
|
||||
|
||||
// The published set, in slot order: fixture, position, score (trace only) and fade weight,
|
||||
// capped at the table's allocation.
|
||||
let promoted: Vec<(usize, Entity, Vec3, f32, f32)> = lane
|
||||
// MONKEY (outdoor torch shadows): …and each slot's LANE, for the caster gather's self-exclusion
|
||||
// radius, the published `exterior` bit and the trace.
|
||||
let promoted: Vec<(usize, Entity, Vec3, f32, f32, bool)> = lane
|
||||
.slots
|
||||
.iter()
|
||||
.take(MAX_TORCH_CASTERS)
|
||||
.map(|s| (s.cache_slot, s.fixture, s.pos, s.score, s.w))
|
||||
.map(|s| (s.cache_slot, s.fixture, s.pos, s.score, s.w, s.exterior))
|
||||
.collect();
|
||||
|
||||
// (2) MONKEY (static torch cache): fingerprint resident SOURCE geometry per fixture, not
|
||||
// StaticGx's change tick (frame bookkeeping changes it continuously). This catches arrivals,
|
||||
// removals and replacements even with a stationary camera. Entity-resident rigid furniture
|
||||
// joins the static mesh; creatures and skinned/animated parts belong to the dynamic overlay.
|
||||
let fixture_positions: Vec<_> = promoted.iter().map(|(i, _, p, _, _)| (*i, *p)).collect();
|
||||
let fixture_positions: Vec<_> = promoted.iter().map(|(i, _, p, ..)| (*i, *p)).collect();
|
||||
let dynamic = dynamic_set(&fixture_positions, anchor, video.interior_shadow_dynamic as usize);
|
||||
let mut rebuilt = 0;
|
||||
let mut static_rebuilt = 0u32;
|
||||
// MONKEY (torch lane perf): how many fingerprint walks the gate actually let through this
|
||||
// frame, folded into the lane's per-second trace below (`lane` is mutably borrowed inside the
|
||||
// loop, so the counter cannot live on it until the loop has finished).
|
||||
let mut lane_scans = 0u32;
|
||||
let mut published = TorchShadowViews {
|
||||
count: promoted.iter().map(|(i, ..)| *i as u32 + 1).max().unwrap_or(0),
|
||||
soft: video.interior_shadow_soft,
|
||||
dynamic_mask: dynamic,
|
||||
// MONKEY (outdoor torch shadows): the receivers' one-bit gate — the cvar, the sun AND an
|
||||
// actual exterior slot to sample. Keyed on the SLOTS rather than on `exterior_on` alone so
|
||||
// that a night with the cvar on but nothing promoted (no fires in range) costs the
|
||||
// receivers nothing at all, which is most of the outdoor world.
|
||||
exterior: exterior_on && promoted.iter().any(|(.., ext)| *ext),
|
||||
..Default::default()
|
||||
};
|
||||
// MONKEY (static torch cache): rotate service order so two moving/carried fixtures cannot
|
||||
@@ -558,16 +859,51 @@ fn update_torch_shadows(
|
||||
// for every slot every frame would be the new cost ceiling — and at most two of those slots
|
||||
// could act on the answer anyway.
|
||||
let slot_count = lane.slots.len();
|
||||
let start = lane.rebuild_cursor;
|
||||
// MONKEY (torch lane perf): the scene-wide census, computed ONCE for the whole lane rather
|
||||
// than implicitly once per scanned slot. Everything a fingerprint hashes lives in exactly two
|
||||
// places, and both are summarised here far more cheaply than they are hashed: the retained
|
||||
// regions' change stamps and populations, and one branch-free mix per rigid opaque model part.
|
||||
// While this pair is unchanged the fixture-local source set cannot have changed either, so the
|
||||
// slot's walk is skipped outright — which is what turns "a full scene scan every frame,
|
||||
// forever, in a room where nothing ever moves" into "a scan the frame something does".
|
||||
let census = (slot_count > 0)
|
||||
.then(|| PartCensus {
|
||||
gx: gx.as_ref().map_or(0, |gx| gx.torch_residency_generation()),
|
||||
parts: static_part_census(&ents),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// A slot that has NEVER been fingerprinted jumps the queue: with one scan a frame a fresh
|
||||
// promotion would otherwise wait a whole sweep for its first map, and a slot with no map
|
||||
// publishes none (`map_publishable`), so that wait is a shadow that is simply missing. There
|
||||
// are at most two promotions a frame, so this can never crowd the rotation out for long.
|
||||
let start = lane
|
||||
.slots
|
||||
.iter()
|
||||
.position(|s| s.checked.is_none())
|
||||
.unwrap_or_else(|| if slot_count == 0 { 0 } else { lane.rebuild_cursor % slot_count });
|
||||
let scan = slot_count.min(TORCH_SCAN_PER_FRAME);
|
||||
for offset in 0..scan {
|
||||
let index = (start + offset) % slot_count;
|
||||
let slot = &mut lane.slots[index];
|
||||
let i = slot.cache_slot;
|
||||
// MONKEY (torch lane perf): the skip. The fixture's own position joins the census because
|
||||
// it is hashed INTO the fingerprint (a carried torch moves without the world changing),
|
||||
// and a slot is only ever stamped once its key is actually committed below — a rebuild
|
||||
// deferred by the budget must come back and try again, not be certified as looked at.
|
||||
if slot.checked == Some((census.gx, census.parts, slot.pos)) {
|
||||
continue;
|
||||
}
|
||||
lane_scans += 1;
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
slot.fixture.hash(&mut hash);
|
||||
slot.pos.to_array().map(f32::to_bits).hash(&mut hash);
|
||||
gx.as_ref().map(|gx| gx.torch_geometry_key(slot.pos, TORCH_RANGE)).hash(&mut hash);
|
||||
// MONKEY (outdoor torch shadows): the exclusion radius is part of the ADMISSION, so it
|
||||
// must be part of the FINGERPRINT — a key computed over a different source set than the
|
||||
// mesh would certify the wrong geometry as current.
|
||||
let self_exclude = if slot.exterior { TORCH_EXT_SELF_EXCLUDE } else { 0.0 };
|
||||
gx.as_ref()
|
||||
.map(|gx| gx.torch_geometry_key(slot.pos, TORCH_RANGE, self_exclude))
|
||||
.hash(&mut hash);
|
||||
let mut entity_key = 0u64;
|
||||
for (entity, pick, part, global, rig, occluder, _) in &ents.parts {
|
||||
if !static_part(part, rig.is_some()) || !occluder.0 { continue; }
|
||||
@@ -583,18 +919,24 @@ fn update_torch_shadows(
|
||||
entity_key.hash(&mut hash);
|
||||
let key = hash.finish();
|
||||
let dirty = slot.geometry_key != Some(key);
|
||||
if !dirty {
|
||||
slot.checked = Some((census.gx, census.parts, slot.pos));
|
||||
}
|
||||
if dirty && rebuilt < 2 {
|
||||
let mut positions = Vec::new();
|
||||
let mut indices = Vec::new();
|
||||
if let Some(gx) = &gx {
|
||||
gx.append_torch_triangles(slot.pos, TORCH_RANGE, &mut positions, &mut indices);
|
||||
gx.append_torch_triangles(
|
||||
slot.pos, TORCH_RANGE, self_exclude, &mut positions, &mut indices);
|
||||
}
|
||||
collect_torch_entities(&ents, true, &[(i, slot.pos)], &mut positions, &mut indices);
|
||||
collect_torch_entities(
|
||||
&ents, true, &[(i, slot.pos, TORCH_RANGE)], &mut positions, &mut indices);
|
||||
let mut mesh = empty_shadow_mesh();
|
||||
restore_mesh_buffers(&mut mesh, positions, indices);
|
||||
if let Some(old) = slot.mesh.replace(meshes.add(mesh)) { meshes.remove(old.id()); }
|
||||
slot.geometry_key = Some(key);
|
||||
slot.built_at = Some(slot.pos);
|
||||
slot.checked = Some((census.gx, census.parts, slot.pos));
|
||||
rebuilt += 1;
|
||||
static_rebuilt |= 1 << i;
|
||||
}
|
||||
@@ -636,18 +978,51 @@ fn update_torch_shadows(
|
||||
}
|
||||
// MONKEY (static torch cache): gather only around the dynamic subset, not every promoted
|
||||
// fixture. A single mesh serves those N maps and contains no rigid furniture already cached.
|
||||
let dynamic_positions: Vec<_> = fixture_positions.iter().copied()
|
||||
.filter(|(i, _)| dynamic & (1 << i) != 0).collect();
|
||||
let mut ent_admitted = 0;
|
||||
//
|
||||
// MONKEY (torch lane perf): …and only within each of those fixtures' OWN reach, not within the
|
||||
// cube projection's flat 48 yd. The receivers' direct term is exactly zero past `reach`
|
||||
// (`interior_window`), so a caster admitted beyond it could only ever shadow fragments this
|
||||
// fixture does not light — it was pure cost. A candle's ~12 yd pool is a twentieth of the
|
||||
// volume the 48 yd radius swept, and this gather CPU-SKINS every part it admits.
|
||||
let dynamic_positions: Vec<(usize, Vec3, f32)> = lane
|
||||
.slots
|
||||
.iter()
|
||||
.take(MAX_TORCH_CASTERS)
|
||||
.filter(|s| dynamic & (1 << s.cache_slot) != 0)
|
||||
.map(|s| (s.cache_slot, s.pos, entity_gather_radius(s.reach)))
|
||||
.collect();
|
||||
// MONKEY (torch lane perf): and only at `interiorShadowEntityRate` Hz. The gather + the
|
||||
// `Mesh` mutation it feeds (a full vertex/index re-extraction, a GPU re-upload and an
|
||||
// `AssetChanged<Mesh3d>` fan-out through material specialisation) were paid EVERY frame
|
||||
// regardless of every count dial — the fixed charge that made `interiorShadowCasters 8` and
|
||||
// `interiorShadowDynamic 2` measure identically to the defaults. The mesh is RETAINED between
|
||||
// regathers and the six overlay passes still draw it every frame, so this ages the pose the
|
||||
// casters were skinned at; it never removes a shadow.
|
||||
if dynamic != 0 {
|
||||
if lane.entity_mesh.is_none() { lane.entity_mesh = Some(meshes.add(empty_shadow_mesh())); }
|
||||
if let Some(mesh) = lane.entity_mesh.as_ref().and_then(|h| meshes.get_mut(h)) {
|
||||
let (mut positions, mut indices) = take_mesh_buffers(mesh);
|
||||
ent_admitted = collect_torch_entities(&ents, false, &dynamic_positions, &mut positions, &mut indices);
|
||||
restore_mesh_buffers(mesh, positions, indices);
|
||||
let fresh = lane.entity_mesh.is_none();
|
||||
if fresh { lane.entity_mesh = Some(meshes.add(empty_shadow_mesh())); }
|
||||
let due = fresh
|
||||
|| entity_gather_due(
|
||||
video.interior_shadow_entity_rate,
|
||||
now - lane.entity_at,
|
||||
dynamic,
|
||||
lane.entity_mask,
|
||||
);
|
||||
if due {
|
||||
if let Some(mesh) = lane.entity_mesh.as_ref().and_then(|h| meshes.get_mut(h)) {
|
||||
let (mut positions, mut indices) = take_mesh_buffers(mesh);
|
||||
lane.trace_parts = collect_torch_entities(
|
||||
&ents, false, &dynamic_positions, &mut positions, &mut indices);
|
||||
restore_mesh_buffers(mesh, positions, indices);
|
||||
}
|
||||
lane.trace_gathers += 1;
|
||||
lane.entity_at = now;
|
||||
lane.entity_mask = dynamic;
|
||||
}
|
||||
published.entity_mesh = lane.entity_mesh.as_ref().map(Handle::id);
|
||||
}
|
||||
lane.trace_scans += lane_scans;
|
||||
lane.trace_reach = dynamic_positions.iter().map(|(.., r)| *r).fold(0.0, f32::max);
|
||||
*views = published;
|
||||
|
||||
// `WOW_TORCH_TRACE=1` — once a second, what the lane published: fixture distances + whether a
|
||||
@@ -655,28 +1030,54 @@ fn update_torch_shadows(
|
||||
if std::env::var_os("WOW_TORCH_TRACE").is_some() {
|
||||
if now - *last_trace >= 1.0 {
|
||||
*last_trace = now;
|
||||
// MONKEY (outdoor torch shadows): the header carries the lane budget too — "6 of the
|
||||
// 12 slots may be exterior, 3 currently are, the sun is at 0.00" is the whole state of
|
||||
// the split in one line, and it is the first thing to read when an outdoor shadow is
|
||||
// missing (no EXT slots at sun 1.00 is correct; no EXT slots at sun 0.00 is a bug).
|
||||
info!(
|
||||
"torch-trace: {}/{} slots of {} ({} candidates, caster {}, {} entity casters)",
|
||||
"torch-trace: {}/{} slots of {} (ext {}/{}, sun {:.2}, {} candidates, caster {}, {} entity casters)",
|
||||
promoted.len(),
|
||||
want,
|
||||
MAX_TORCH_CASTERS,
|
||||
promoted.iter().filter(|(.., ext)| *ext).count(),
|
||||
ext_cap,
|
||||
sun_w,
|
||||
cands.len(),
|
||||
if lane.slots.iter().any(|s| s.mesh.is_some()) {
|
||||
"built"
|
||||
} else {
|
||||
"none"
|
||||
},
|
||||
ent_admitted,
|
||||
lane.trace_parts,
|
||||
);
|
||||
// MONKEY (torch lane perf): the FIXED per-frame cost, in the two counters that bound
|
||||
// it. `gather parts N (rate R Hz, reach X yd)` is the CPU-skinned moving-caster set and
|
||||
// the cadence + union radius it was taken at; `fingerprint scans S` is how many full
|
||||
// resident-scene walks the census gate actually let through in the last second (a
|
||||
// still room should read 0, a streaming one a handful). Both are per SECOND, so they
|
||||
// are directly comparable to the frame rate beside them: `gathers` equal to the fps is
|
||||
// `interiorShadowEntityRate 0`, and `scans` equal to the fps is the old behaviour with
|
||||
// the gate defeated.
|
||||
info!(
|
||||
"torch-perf: gather parts {} (rate {} Hz, {} gathers/s, reach {:.0} yd), fingerprint scans {}/s",
|
||||
lane.trace_parts,
|
||||
video.interior_shadow_entity_rate,
|
||||
lane.trace_gathers,
|
||||
lane.trace_reach,
|
||||
lane.trace_scans,
|
||||
);
|
||||
lane.trace_gathers = 0;
|
||||
lane.trace_scans = 0;
|
||||
// MONKEY (torch caster selection): per SLOT, the three numbers the selection is made
|
||||
// of — which fixture, what it scores at the player, and how far its cross-fade has
|
||||
// ramped. A shadow that pops is a `w` that jumped; a shadow that should be there and
|
||||
// is not is a fixture missing from this list (then read `cands`, above).
|
||||
// MONKEY (torch caster reach): scientific scores expose the small distant tail;
|
||||
// distance is still from the player, and eligibility now extends to 4R + 3 yd.
|
||||
for (i, e, p, sc, w) in &promoted {
|
||||
for (i, e, p, sc, w, ext) in &promoted {
|
||||
info!(
|
||||
" slot {i}: {e} at [{:.1},{:.1},{:.1}] d(player) {:.1} score {:.4e} w {:.2} (camera <= shadowDistance AND player <= 4R+3) static {} dynamic {}",
|
||||
" slot {i}: lane {} {e} at [{:.1},{:.1},{:.1}] d(player) {:.1} score {:.4e} w {:.2} (camera <= shadowDistance AND player <= 4R+3) static {} dynamic {} requested_live_rank {:?}",
|
||||
if *ext { "EXT" } else { "INT" },
|
||||
p.x,
|
||||
p.y,
|
||||
p.z,
|
||||
@@ -685,6 +1086,7 @@ fn update_torch_shadows(
|
||||
w,
|
||||
if static_rebuilt & (1 << i) != 0 { "rebuilt (GPU pending)" } else { "cached/requested" },
|
||||
if dynamic & (1 << i) != 0 { "yes" } else { "no" },
|
||||
(dynamic & (1 << i) != 0).then(|| TorchShadowViews::live_rank(dynamic, *i)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -697,8 +1099,12 @@ fn static_part(part: &ModelPart, rigged: bool) -> bool {
|
||||
part.kind != ModelKind::Creature && !rigged && part.blend == ModelBlend::Opaque
|
||||
}
|
||||
|
||||
/// MONKEY (torch lane perf): `fixtures` is `(slot, position, RADIUS)` — each fixture carries the
|
||||
/// radius it admits casters inside, instead of the whole gather sharing [`TORCH_RANGE`]. The static
|
||||
/// (cached) caller passes `TORCH_RANGE` and is therefore byte-for-byte the gather it always was;
|
||||
/// only the per-frame MOVING gather narrows, to `entity_gather_radius(reach)`.
|
||||
fn collect_torch_entities(
|
||||
ents: &EntityCasters, want_static: bool, fixtures: &[(usize, Vec3)],
|
||||
ents: &EntityCasters, want_static: bool, fixtures: &[(usize, Vec3, f32)],
|
||||
positions: &mut Vec<[f32; 3]>, indices: &mut Vec<u32>,
|
||||
) -> u32 {
|
||||
let mut admitted = 0;
|
||||
@@ -711,7 +1117,7 @@ fn collect_torch_entities(
|
||||
let origin = global.map(GlobalTransform::translation)
|
||||
.or_else(|| rig.and_then(|r| ents.palettes.slot_origin(r.slot)));
|
||||
let Some(origin) = origin else { continue };
|
||||
if !fixtures.iter().any(|(_, p)| p.distance_squared(origin) <= TORCH_RANGE * TORCH_RANGE) { continue; }
|
||||
if !within_any_reach(fixtures, origin) { continue; }
|
||||
let base = positions.len() as u32;
|
||||
if let Some(rig) = rig {
|
||||
let Some(palette) = ents.palettes.world_palette(rig.slot, rig.bones() as usize) else { continue };
|
||||
@@ -746,6 +1152,34 @@ fn collect_torch_entities(
|
||||
admitted
|
||||
}
|
||||
|
||||
/// MONKEY (torch lane perf): the entity half of [`PartCensus`] — one branch-free mix per RIGID
|
||||
/// OPAQUE model part (the exact set the fingerprint's own entity walk hashes), folded
|
||||
/// order-independently so archetype reordering is invisible.
|
||||
///
|
||||
/// Deliberately much cheaper than the thing it guards: no `DefaultHasher` per part and no
|
||||
/// `to_matrix()`, just the entity id, the geometry `Arc`'s identity, the affine's translation and
|
||||
/// ONE basis column. Translation catches a moved prop, the basis column catches a rotated one (a
|
||||
/// door on its hinge barely moves its origin), the `Arc` catches a geometry swap, and the id sum
|
||||
/// catches an arrival or a despawn. What it cannot see — a part rotating exactly about the axis of
|
||||
/// the column sampled — has no bearing on a shadow's silhouette to the precision this lane renders.
|
||||
fn static_part_census(ents: &EntityCasters) -> u64 {
|
||||
let mut census = 0u64;
|
||||
for (entity, pick, part, global, rig, occluder, _) in &ents.parts {
|
||||
if !occluder.0 || !static_part(part, rig.is_some()) { continue; }
|
||||
let Some(global) = global else { continue };
|
||||
let a = global.affine();
|
||||
let mut mix = entity.to_bits() ^ (std::sync::Arc::as_ptr(&pick.0) as usize as u64);
|
||||
for v in [
|
||||
a.translation.x, a.translation.y, a.translation.z,
|
||||
a.matrix3.x_axis.x, a.matrix3.x_axis.y, a.matrix3.x_axis.z,
|
||||
] {
|
||||
mix = mix.rotate_left(11) ^ u64::from(v.to_bits());
|
||||
}
|
||||
census = census.wrapping_add(mix);
|
||||
}
|
||||
census
|
||||
}
|
||||
|
||||
/// Tear down the caster mesh and clear the published views (the lane went off, or we left the world).
|
||||
fn teardown(lane: &mut TorchLane, meshes: &mut Assets<Mesh>, views: &mut TorchShadowViews) {
|
||||
for slot in &mut lane.slots {
|
||||
@@ -755,6 +1189,11 @@ fn teardown(lane: &mut TorchLane, meshes: &mut Assets<Mesh>, views: &mut TorchSh
|
||||
// MONKEY (torch caster selection): the slot table goes with them — an incumbent kept across a
|
||||
// teardown would come back at full weight in a room it is no longer in.
|
||||
lane.slots.clear();
|
||||
// MONKEY (torch lane perf): the gather bookkeeping goes with them. A lane that comes back up in
|
||||
// a different room must regather on its first frame, not wait out a cadence tick against a
|
||||
// timestamp from before the teardown.
|
||||
lane.entity_at = 0.0;
|
||||
lane.entity_mask = 0;
|
||||
*views = TorchShadowViews::default();
|
||||
}
|
||||
|
||||
@@ -796,14 +1235,17 @@ mod tests {
|
||||
assert!(!fixture_eligible(1.0, f32::NAN, 48.0, 80.0));
|
||||
}
|
||||
|
||||
// MONKEY (static torch cache): sparse physical slots, tie stability and live shrink/off.
|
||||
// MONKEY (live bank rank): sparse slots, tie stability and both ends of the live budget.
|
||||
#[test]
|
||||
fn dynamic_subset_is_nearest_and_deterministic() {
|
||||
let fixtures = [(15, Vec3::X), (8, -Vec3::X), (2, Vec3::X * 20.0), (4, Vec3::ZERO)];
|
||||
assert_eq!(dynamic_set(&fixtures, Vec3::ZERO, 2), (1 << 4) | (1 << 8));
|
||||
assert_eq!(dynamic_set(&fixtures, Vec3::X * 20.0, 1), 1 << 2);
|
||||
assert_eq!(dynamic_set(&fixtures, Vec3::ZERO, 0), 0);
|
||||
assert_eq!(dynamic_set(&fixtures, Vec3::ZERO, 0), 1 << 4);
|
||||
assert_eq!(dynamic_set(&fixtures, Vec3::ZERO, 16).count_ones(), 4);
|
||||
let all: Vec<_> = (0..MAX_TORCH_CASTERS).map(|i| (i, Vec3::X * i as f32)).collect();
|
||||
assert_eq!(dynamic_set(&all, Vec3::ZERO, 16), 0xff);
|
||||
assert_eq!(dynamic_set(&[], Vec3::ZERO, 1), 0);
|
||||
}
|
||||
|
||||
// MONKEY (static torch cache): a never-built slot publishes nothing; a merely STALE map keeps
|
||||
@@ -818,6 +1260,138 @@ mod tests {
|
||||
assert!(!map_publishable(Some(Vec3::ZERO), Vec3::splat(10.0)));
|
||||
}
|
||||
|
||||
// MONKEY (outdoor torch shadows): the exterior ranking key is the EXTERIOR receivers' own
|
||||
// profile, not the interior soft core — monotone in distance, linear in intensity, floored off
|
||||
// the falloff's pole at 0, and reaching exactly zero at the eligibility edge so a candidate
|
||||
// never steps out of the list at a finite score.
|
||||
#[test]
|
||||
fn exterior_scores_follow_the_outdoor_falloff() {
|
||||
let near = exterior_fixture_score(1.0, 5.0);
|
||||
let mid = exterior_fixture_score(1.0, 15.0);
|
||||
let far = exterior_fixture_score(1.0, 40.0);
|
||||
assert!(near > mid && mid > far && far > 0.0);
|
||||
assert_eq!(exterior_fixture_score(1.0, TORCH_EXT_ELIGIBLE_YD), 0.0);
|
||||
assert_eq!(exterior_fixture_score(0.0, 5.0), 0.0);
|
||||
assert!(exterior_fixture_score(2.0, 15.0) > mid);
|
||||
// The falloff's pole at d = 0 is floored, so a fixture the player is standing on cannot
|
||||
// score infinity, take a slot for one frame and hand it straight back.
|
||||
let at_zero = exterior_fixture_score(1.0, 0.0);
|
||||
assert!(at_zero.is_finite() && at_zero <= 1.0 / 0.73 + 1e-3, "{at_zero}");
|
||||
// …and inside the window it IS the receivers' falloff: 1/(0.7d + 0.03d^2) at 1 yd.
|
||||
assert!((exterior_fixture_score(1.0, 1.0) - 1.0 / 0.73).abs() < 1e-2);
|
||||
}
|
||||
|
||||
// MONKEY (outdoor torch shadows): the camera tests are shared with the interior lane (same
|
||||
// budget, same `shadowDistance` dial); the player window is flat, because an exterior entry
|
||||
// packs no reach for a `4R` window to be four of.
|
||||
#[test]
|
||||
fn exterior_eligibility_is_the_cube_range_at_the_player() {
|
||||
assert!(exterior_eligible(0.0, 0.0, 80.0));
|
||||
assert!(exterior_eligible(TORCH_EXT_ELIGIBLE_YD, 10.0, 80.0));
|
||||
assert!(!exterior_eligible(TORCH_EXT_ELIGIBLE_YD + 0.1, 10.0, 80.0));
|
||||
assert!(!exterior_eligible(1.0, 80.01, 80.0));
|
||||
assert!(exterior_eligible(1.0, 250.0, 300.0));
|
||||
assert!(!exterior_eligible(1.0, f32::NAN, 80.0));
|
||||
}
|
||||
|
||||
// MONKEY (outdoor torch shadows): the lane gate is the cvar AND night, and "night" is the
|
||||
// receivers' own threshold — `sun_shadow_strength` saturates at exactly 1.0 in daylight, so the
|
||||
// CPU lane and the shaders' `night_w` can never disagree about which it is.
|
||||
#[test]
|
||||
fn the_exterior_lane_is_night_only_and_cvar_gated() {
|
||||
let mut video = VideoConfig::default();
|
||||
assert!(video.exterior_shadows, "shipped on");
|
||||
assert!(exterior_shadows_on(&video, 0.0), "midnight");
|
||||
assert!(exterior_shadows_on(&video, 0.99), "dusk still counts");
|
||||
assert!(!exterior_shadows_on(&video, 1.0), "full daylight never");
|
||||
video.exterior_shadows = false;
|
||||
assert!(!exterior_shadows_on(&video, 0.0));
|
||||
// The receivers' threshold, verbatim: 1.0 the moment the sun clears ~12 degrees.
|
||||
assert_eq!(sun_shadow_strength(0.208), 1.0);
|
||||
assert_eq!(sun_shadow_strength(0.9), 1.0);
|
||||
assert_eq!(sun_shadow_strength(-0.3), 0.0);
|
||||
assert!(sun_shadow_strength(0.1) > 0.0 && sun_shadow_strength(0.1) < 1.0);
|
||||
}
|
||||
|
||||
// MONKEY (outdoor torch shadows): half the budget, rounded up — and the cap only ever removes
|
||||
// EXTERIOR candidates, keeping the best ones (the list is score-sorted).
|
||||
#[test]
|
||||
fn the_exterior_budget_is_half_and_caps_only_its_own_lane() {
|
||||
assert_eq!(exterior_budget(12), 6);
|
||||
assert_eq!(exterior_budget(16), 8);
|
||||
assert_eq!(exterior_budget(1), 1, "the smallest budget still gets one outdoor shadow");
|
||||
let mut cands: Vec<Candidate> = (0..8)
|
||||
.map(|i| {
|
||||
(Entity::from_raw_u32(i).unwrap(), Vec3::ZERO, 8.0 - i as f32, i % 2 == 0, 10.0)
|
||||
})
|
||||
.collect();
|
||||
cap_exterior_candidates(&mut cands, 2);
|
||||
// Identified by score, which is also their rank: 8,7,6,5,4,3,2,1 with the even ranks
|
||||
// exterior. Capped at two, ranks 8 and 6 (the best two fires) survive; 4 and 2 do not.
|
||||
let kept: Vec<(u32, bool)> =
|
||||
cands.iter().map(|(_, _, sc, x, _)| (*sc as u32, *x)).collect();
|
||||
assert_eq!(
|
||||
kept,
|
||||
vec![(8, true), (7, false), (6, true), (5, false), (3, false), (1, false)],
|
||||
"the two best exterior fires survive; every interior candidate does"
|
||||
);
|
||||
// A cap of zero is a lane switched off, and it takes nothing else with it.
|
||||
let mut all_ext: Vec<Candidate> =
|
||||
vec![(Entity::from_raw_u32(0).unwrap(), Vec3::ZERO, 1.0, true, 10.0)];
|
||||
cap_exterior_candidates(&mut all_ext, 0);
|
||||
assert!(all_ext.is_empty());
|
||||
}
|
||||
|
||||
// MONKEY (torch lane perf): the moving-caster gather is sized off each fixture's OWN reach.
|
||||
// The margin covers the part's extent (the test is on its ORIGIN), the clamp keeps the radius
|
||||
// inside the cube projection that has to record it, and a degenerate reach can only ever widen
|
||||
// the window — losing a caster must never be the failure direction here.
|
||||
#[test]
|
||||
fn the_entity_gather_radius_is_the_fixtures_own_reach() {
|
||||
assert_eq!(entity_gather_radius(12.0), 12.0 + TORCH_ENTITY_REACH_MARGIN);
|
||||
assert!(entity_gather_radius(12.0) < TORCH_RANGE, "a candle sweeps far less than 48 yd");
|
||||
assert_eq!(entity_gather_radius(TORCH_RANGE), TORCH_RANGE, "clamped to the cube range");
|
||||
assert_eq!(entity_gather_radius(1e9), TORCH_RANGE);
|
||||
assert_eq!(entity_gather_radius(0.0), TORCH_ENTITY_REACH_MARGIN);
|
||||
assert_eq!(entity_gather_radius(-5.0), TORCH_ENTITY_REACH_MARGIN);
|
||||
assert_eq!(entity_gather_radius(f32::NAN), TORCH_RANGE, "never a silent zero radius");
|
||||
}
|
||||
|
||||
// …and the gather admits the UNION of those radii, not one shared radius: a tight candle and a
|
||||
// wide hearth in the same dynamic set each keep their own window, and a part inside either is
|
||||
// in. The reach that matters is the fixture's, so a body 20 yd from the candle and 20 yd from
|
||||
// the hearth is a caster for the hearth alone.
|
||||
#[test]
|
||||
fn the_gather_admits_the_union_of_the_fixture_reaches() {
|
||||
let candle = entity_gather_radius(8.0);
|
||||
let hearth = entity_gather_radius(30.0);
|
||||
let set = [(0usize, Vec3::ZERO, candle), (1usize, Vec3::X * 60.0, hearth)];
|
||||
assert!(within_any_reach(&set, Vec3::X * 5.0), "inside the candle");
|
||||
assert!(within_any_reach(&set, Vec3::X * 40.0), "inside the hearth, not the candle");
|
||||
assert!(!within_any_reach(&set, Vec3::X * 20.0), "between the two, lit by neither");
|
||||
assert!(!within_any_reach(&[], Vec3::ZERO), "an empty dynamic set admits nothing");
|
||||
// The margin is what keeps a body whose ORIGIN sits just outside the pool casting.
|
||||
assert!(within_any_reach(&set, Vec3::X * (8.0 + 1.0)));
|
||||
}
|
||||
|
||||
// MONKEY (torch lane perf): the regather cadence. `0` is the every-frame escape hatch, a
|
||||
// changed dynamic set always overrides the clock (the mesh is only valid for the fixtures it
|
||||
// was gathered around), and the tolerance stops a 30 Hz cadence from beating against a 60 fps
|
||||
// frame into an alternating 1/3-frame stutter.
|
||||
#[test]
|
||||
fn the_entity_gather_cadence_is_a_rate_not_a_gate() {
|
||||
assert!(entity_gather_due(0, 0.0, 1, 1), "rate 0 = every frame");
|
||||
assert!(!entity_gather_due(30, 0.0, 1, 1), "same set, no time passed");
|
||||
assert!(!entity_gather_due(30, 0.02, 1, 1), "still inside 1/30 s");
|
||||
assert!(entity_gather_due(30, 1.0 / 30.0, 1, 1), "exactly on the tick");
|
||||
assert!(entity_gather_due(30, 1.0 / 60.0 * 2.0, 1, 1), "two 60 fps frames make the tick");
|
||||
assert!(entity_gather_due(30, 0.0, 0b11, 0b01), "a changed dynamic set overrides the clock");
|
||||
assert!(entity_gather_due(15, 0.07, 1, 1));
|
||||
assert!(!entity_gather_due(15, 0.05, 1, 1));
|
||||
// A rate at or above the frame rate is "every frame" without being a special case.
|
||||
assert!(entity_gather_due(240, 1.0 / 46.0, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distant_scores_stay_positive_and_ordered() {
|
||||
let r = 10.0;
|
||||
|
||||
@@ -226,6 +226,46 @@ pub(crate) struct VideoConfig {
|
||||
/// 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`
|
||||
@@ -247,12 +287,34 @@ pub(crate) struct VideoConfig {
|
||||
/// 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). A candle cluster casts many hard-edged
|
||||
/// overlapping shadows; widening the 4-tap kernel is the cheap softening. Rides the torch
|
||||
@@ -267,7 +329,7 @@ pub(crate) struct VideoConfig {
|
||||
/// packer applies at PACK time (an ungated pack is one `count = 0` head per light), so it moves
|
||||
/// the frame it changes and costs the shader nothing.
|
||||
pub(crate) interior_room_gate: bool,
|
||||
/// MONKEY (interior debug): the interior-lane diagnostic overlay (`interiorDebug`, 0..3). See
|
||||
/// MONKEY (interior debug): the interior-lane diagnostic overlay (`interiorDebug`, 0..4). See
|
||||
/// [`benilla_world::lighting::DynamicInteriors::debug`].
|
||||
pub(crate) interior_debug: u32,
|
||||
/// MONKEY (fire GO lights): gain on every light SYNTHESISED from a fire prop's flame emitter
|
||||
@@ -275,6 +337,12 @@ pub(crate) struct VideoConfig {
|
||||
/// [`benilla_world::lighting::FireLightGain`] by `dynamic_interior`, and applied at PACK time
|
||||
/// so it is live.
|
||||
pub(crate) fire_light_gain: f32,
|
||||
/// MONKEY (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,
|
||||
@@ -287,6 +355,12 @@ impl Default for VideoConfig {
|
||||
world_shadows: false,
|
||||
character_shadows: false,
|
||||
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.15,
|
||||
@@ -295,15 +369,21 @@ impl Default for VideoConfig {
|
||||
// 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,
|
||||
interior_shadow_soft: 1.0,
|
||||
// MONKEY (room gate): on — without it a building's fixtures light through its own
|
||||
// floors and walls.
|
||||
interior_room_gate: true,
|
||||
interior_debug: 0,
|
||||
fire_light_gain: 1.0,
|
||||
fire_flicker: 1.0,
|
||||
display: if windowed_env() {
|
||||
DisplayMode::Windowed
|
||||
} else {
|
||||
|
||||
@@ -38,8 +38,8 @@ 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, ShadowCaster, ShadowDemand, ShadowFrame,
|
||||
ShadowSet, PLAYER_SHADOW_LAYER, STATIC_REBUILD_STEP,
|
||||
shadow_trace, spawn_solid_caster, take_mesh_buffers, RebuildRate, ShadowCaster, ShadowDemand,
|
||||
ShadowFrame, ShadowSet, PLAYER_SHADOW_LAYER, STATIC_REBUILD_STEP,
|
||||
};
|
||||
use crate::video::VideoConfig;
|
||||
|
||||
@@ -117,11 +117,15 @@ struct WorldLane {
|
||||
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>,
|
||||
@@ -169,8 +173,20 @@ fn update_world_shadows(
|
||||
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();
|
||||
}
|
||||
if let Some(handle) = lane.env_mesh.clone() {
|
||||
// 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(
|
||||
@@ -323,4 +339,6 @@ fn teardown(
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -301,6 +301,18 @@ pub struct ModelLight {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Bevy [`AssetLoader`] decoding `*.m2` → [`M2Model`].
|
||||
@@ -489,6 +501,7 @@ impl AssetLoader for M2ModelLoader {
|
||||
.map_or([0.0; 3], |b| b.pivot),
|
||||
def,
|
||||
synthetic: false,
|
||||
flame: false,
|
||||
})
|
||||
.collect();
|
||||
// MONKEY (fire GO lights) / MONKEY (lamp lights): a prop that authors NO casting light gets
|
||||
@@ -508,14 +521,16 @@ impl AssetLoader for M2ModelLoader {
|
||||
// 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.
|
||||
// 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`]).
|
||||
let synth = benilla_formats::synthesize_fire_light(
|
||||
&path,
|
||||
emitters.iter().map(|e| &e.def),
|
||||
)
|
||||
.map(|fire| {
|
||||
let src = &emitters[fire.emitter].def;
|
||||
(src.position, src.bone, fire.color, fire.intensity)
|
||||
(src.position, src.bone, fire.color, fire.intensity, true)
|
||||
})
|
||||
.or_else(|| {
|
||||
// MONKEY (lamp lights): a lamppost/lantern/chandelier authors NO particle emitter
|
||||
@@ -530,9 +545,9 @@ impl AssetLoader for M2ModelLoader {
|
||||
.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))
|
||||
.map(|l| (l.position, l.bone, l.color, l.intensity, false))
|
||||
});
|
||||
if let Some((position, src_bone, color, intensity)) = synth {
|
||||
if let Some((position, src_bone, color, intensity, flame)) = 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
|
||||
@@ -566,6 +581,7 @@ impl AssetLoader for M2ModelLoader {
|
||||
.get(src_bone as usize)
|
||||
.map_or([0.0; 3], |b| b.pivot),
|
||||
synthetic: true,
|
||||
flame,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,20 @@ fn torch_caster_count() -> u32 {
|
||||
// many overlapping penumbra-less edges, and widening the kernel is the cheapest
|
||||
// softening available here (a real penumbra would need the blocker distance and a
|
||||
// variable kernel — not worth a second sampling pass on a 512² face).
|
||||
// - `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 4-tap PCF factor otherwise (0 = shadowed).
|
||||
fn torch_map_shadow(
|
||||
view_proj: mat4x4<f32>,
|
||||
@@ -101,6 +115,7 @@ fn torch_map_shadow(
|
||||
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) {
|
||||
@@ -126,8 +141,11 @@ fn torch_map_shadow(
|
||||
// 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.
|
||||
let far_fade = smoothstep(0.0, 0.06, ndc.z);
|
||||
return mix(1.0, sum * 0.25, far_fade);
|
||||
// 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, sum * 0.25, select(fade, ndc_fade, fade < 0.0));
|
||||
}
|
||||
|
||||
// MONKEY (interior debug): the interior lane's diagnostic overlay. `mode` is decoded from the
|
||||
|
||||
@@ -220,6 +220,7 @@ struct TorchTable {
|
||||
// MONKEY (static torch cache): byte-identical in BOTH shaders and TorchTableUniform.
|
||||
// count@0 (16): x high-water slot count, y soft*100, z dynamic/live-bank mask, w reserved.
|
||||
// positions@16 (256), view_projs@272 (6144): total 6416 bytes. A mismatch hides buildings.
|
||||
// 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>,
|
||||
@@ -242,6 +243,27 @@ const TORCH_NORMAL_OFFSET: f32 = 0.15;
|
||||
fn torch_soft() -> f32 {
|
||||
return max(f32(torch_table.count.y) * 0.01, 0.05);
|
||||
}
|
||||
// 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): below THIS much unshadowed direct contribution a fragment skips the
|
||||
// torch table scan and its four comparison taps entirely, because the shadow factor is multiplied
|
||||
// into that contribution and can therefore only ever subtract less than this from the frame.
|
||||
//
|
||||
// Sized off what the eye can see, not off "small": the interior budget goes through the
|
||||
// `1 - exp(-x * exposure)` rolloff, which for a term this small is just `x * exposure`, so at the
|
||||
// shipped `interiorExposure 2.5` the worst possible step across the guard's boundary is
|
||||
// 2.5e-4 linear = well under one 8-bit code. A tenth of this (1e-3) would have been a visible
|
||||
// ~8/255 contour along the iso-surface where the guard flips. Most of the skips it buys are exact
|
||||
// zeros anyway -- `window` IS 0 past the fixture's reach and `nl` IS 0 on a surface facing away --
|
||||
// so the threshold only has to be small enough to be invisible, never large enough to be useful.
|
||||
const TORCH_SKIP_EPS: f32 = 1e-4;
|
||||
fn torch_ext_on() -> bool {
|
||||
return (torch_table.count.w & TORCH_EXT_LANE) != 0u;
|
||||
}
|
||||
|
||||
// MONKEY (Phase 5, MIRRORED from static_gx.wgsl — keep in sync): the cube face that contains
|
||||
// direction `d` (fixture → fragment) — the major axis, signed. Face order is the contract with
|
||||
@@ -262,7 +284,16 @@ fn torch_face(d: vec3<f32>) -> u32 {
|
||||
// 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 ≤4 promoted.
|
||||
fn torch_entity_shadow(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>) -> f32 {
|
||||
//
|
||||
// MONKEY (outdoor torch shadows): `fade_radius` picks the far fade and is the ONLY lane difference
|
||||
// (`0` = the interior/legacy `ndc.z` fade, bit-for-bit; `> 0` = the exterior world-distance fade
|
||||
// measured from the fixture). MIRRORS static_gx.wgsl's `torch_map_at`.
|
||||
fn torch_entity_shadow_at(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>, fade_radius: f32) -> f32 {
|
||||
// MONKEY (torch lane perf): MIRRORS static_gx.wgsl's `torch_map_at` — 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 out of the body (see TORCH_NORMAL_OFFSET).
|
||||
let Ps = P + N * TORCH_NORMAL_OFFSET;
|
||||
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
|
||||
@@ -270,10 +301,22 @@ fn torch_entity_shadow(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>) -> f32
|
||||
if (torch_table.positions[i].w <= 0.0) { continue; }
|
||||
let fixture = torch_table.positions[i].xyz;
|
||||
if (distance(fixture, light_pos) < 1.0) {
|
||||
let layer = i * 6u + torch_face(Ps - fixture);
|
||||
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);
|
||||
// MONKEY (outdoor torch shadows): negative = the interior contract (let the projector
|
||||
// use its own reverse-Z far fade); otherwise this lane's world-distance weight.
|
||||
let fade = select(
|
||||
-1.0,
|
||||
1.0 - smoothstep(0.8 * fade_radius, fade_radius, distance(fixture, Ps)),
|
||||
fade_radius > 0.0,
|
||||
);
|
||||
let s = shadow_hook::torch_map_shadow(
|
||||
torch_table.view_projs[layer], i32(layer + select(0u, 96u, (torch_table.count.z & (1u << i)) != 0u)), Ps, torch_depth, torch_samp, TORCH_BIAS,
|
||||
torch_soft());
|
||||
torch_table.view_projs[layer], i32(depth_layer), Ps, torch_depth, torch_samp, TORCH_BIAS,
|
||||
torch_soft(), fade);
|
||||
// MONKEY (torch caster selection, MIRRORED from static_gx.wgsl): `.w` is the slot's
|
||||
// FADE WEIGHT, so a promoted fixture's shadow ramps in over ~1/3 s and a demoted one
|
||||
// ramps out. An entity and the floor under it MUST use the same weight or the NPC's
|
||||
@@ -284,6 +327,17 @@ fn torch_entity_shadow(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>) -> f32
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// The INTERIOR lane's call - unchanged behaviour (`fade_radius 0` => the reverse-Z `ndc.z` fade).
|
||||
fn torch_entity_shadow(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>) -> f32 {
|
||||
return torch_entity_shadow_at(light_pos, P, N, 0.0);
|
||||
}
|
||||
|
||||
// MONKEY (outdoor torch shadows): the EXTERIOR lane's call - a campfire/brazier/lamppost throwing
|
||||
// the player, an NPC or a doodad's shadow across the ground and the wall behind them.
|
||||
fn torch_entity_exterior_shadow(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>) -> f32 {
|
||||
return torch_entity_shadow_at(light_pos, P, N, TORCH_EXT_FADE_YD);
|
||||
}
|
||||
|
||||
// MONKEY (torch debug, interiorDebug 2 on ENTITIES): the MIN raw cube-map shadow factor over every
|
||||
// promoted fixture (ignoring the fixture match) — mirrors static_gx's `torch_debug_factor`, so an
|
||||
// entity's sampling can be SEEN as greyscale: all-white = the table is empty / the projection misses;
|
||||
@@ -293,10 +347,14 @@ fn torch_entity_debug_factor(P: vec3<f32>, N: vec3<f32>) -> f32 {
|
||||
var s = 1.0;
|
||||
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
|
||||
if (torch_table.positions[i].w <= 0.0) { continue; }
|
||||
let layer = i * 6u + torch_face(Ps - torch_table.positions[i].xyz);
|
||||
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);
|
||||
let raw = shadow_hook::torch_map_shadow(
|
||||
torch_table.view_projs[layer], i32(layer + select(0u, 96u, (torch_table.count.z & (1u << i)) != 0u)), Ps, torch_depth, torch_samp, TORCH_BIAS,
|
||||
torch_soft());
|
||||
torch_table.view_projs[layer], i32(depth_layer), Ps, 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));
|
||||
}
|
||||
@@ -341,6 +399,12 @@ struct WowVsOut {
|
||||
#ifdef WOW_MERGED_SLOT
|
||||
@location(10) @interpolate(flat) merged_slot: u32,
|
||||
#endif
|
||||
// MONKEY (outdoor torch shadows): WHICH <=3 exterior table entries `point_lit` was summed from,
|
||||
// packed 10 bits each (see `EXT_SEL_NONE`). FLAT, because it is a choice and not a quantity -
|
||||
// interpolating three packed indices would produce a fourth, meaningless one. `EXT_SEL_NONE` on
|
||||
// every lane that takes no exterior point term (interior WMO surfaces, interior props), which
|
||||
// makes the night lane a no-op there by construction.
|
||||
@location(11) @interpolate(flat) ext_sel: u32,
|
||||
}
|
||||
|
||||
// The dynamic point-light term at a world-space point (decisions 0016/0273/0278, selection 0285) —
|
||||
@@ -380,7 +444,17 @@ fn wow_normalize(v: vec3<f32>) -> vec3<f32> {
|
||||
return select(vec3<f32>(0.0), normalize(v), l2 > 1e-12);
|
||||
}
|
||||
|
||||
fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
|
||||
// MONKEY (outdoor torch shadows, MIRRORED from static_gx.wgsl — keep in sync): the ≤3-nearest
|
||||
// EXTERIOR selection packed into ONE u32, three 10-bit indices (rank 0 in the low bits) with
|
||||
// `EXT_SEL_EMPTY` for an unfilled rank. The point table is capped at 256 entries, so 10 bits leaves
|
||||
// two bits of headroom. It exists so the per-FRAGMENT shadowed term below can re-evaluate the
|
||||
// VERTEX stage's choice rather than making its own — re-ranking per fragment would draw a hard
|
||||
// line wherever the ranking flips, which is precisely what the Gouraud term never does.
|
||||
const EXT_SEL_EMPTY: u32 = 1023u;
|
||||
const EXT_SEL_NONE: u32 = 1073741823u; // three empty ranks: 1023 | 1023<<10 | 1023<<20
|
||||
|
||||
// The ranking half of `point_light_sum`, split out verbatim (same tests, same order, same ties).
|
||||
fn point_light_pick(anchor: vec3<f32>) -> 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);
|
||||
@@ -409,21 +483,63 @@ fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
|
||||
sd[2] = d2; sel[2] = i;
|
||||
}
|
||||
}
|
||||
return select(EXT_SEL_EMPTY, sel[0], sd[0] <= 9.9e29)
|
||||
| (select(EXT_SEL_EMPTY, sel[1], sd[1] <= 9.9e29) << 10u)
|
||||
| (select(EXT_SEL_EMPTY, sel[2], sd[2] <= 9.9e29) << 20u);
|
||||
}
|
||||
|
||||
// The evaluation half — the byte-verified falloff `1/(0.7d + 0.03d²)` × `max(N·L, 0)` × the
|
||||
// committed colour, in rank order, stopping at the first empty rank exactly as the old
|
||||
// `sd[s] > 9.9e29` break did. Shared by both pickers (their sum loops were already identical).
|
||||
fn point_light_eval(sel: 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) {
|
||||
let idx = (sel >> (10u * s)) & 1023u;
|
||||
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): the SHADOWED evaluation — the same three entries, each
|
||||
// multiplied by its OWN fixture's cube-map occlusion (normal-offset, like every entity sample on
|
||||
// this lane), so a fence between the player and campfire A darkens A's term while lamppost B's is
|
||||
// untouched. Bounded at three iterations, and reached only under `torch_ext_on()`, so nothing here
|
||||
// runs in daylight or with `exteriorShadows 0`.
|
||||
fn point_light_eval_shadowed(sel: u32, P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
|
||||
var sum = vec3<f32>(0.0);
|
||||
for (var s = 0u; s < 3u; s = s + 1u) {
|
||||
let idx = (sel >> (10u * s)) & 1023u;
|
||||
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, MIRRORED from static_gx.wgsl): the occlusion is a factor on a
|
||||
// term that is already zero on any surface facing away from the fire.
|
||||
let ext_w = atten * nl;
|
||||
var s = 1.0;
|
||||
if (ext_w > TORCH_SKIP_EPS) {
|
||||
s = torch_entity_exterior_shadow(fixture, P, N);
|
||||
}
|
||||
sum += wow_light.points[2u * idx + 1u].rgb * (ext_w * s);
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
|
||||
return point_light_eval(point_light_pick(anchor), P, N);
|
||||
}
|
||||
|
||||
// MONKEY (dynamic interiors): the per-FRAGMENT room light — MIRRORED from `static_gx.wgsl`
|
||||
// (`interior_room_light`; keep in sync). MONKEY (light lanes / interior attenuation): the loop is
|
||||
// over the INTERIOR half of the table only (colour row `.w > 0.5`), and that same `.w` is each
|
||||
@@ -568,8 +684,17 @@ fn interior_room_light(P: vec3<f32>, N: vec3<f32>, room_inst: u32, room_group: u
|
||||
// `torch_surface_shadow` call): this fixture's OWN cube map, sampled through the
|
||||
// material's group-2 torch bindings — a pillar between an NPC and torch A darkens A's
|
||||
// term without touching torch B's, exactly as on the surfaces around it.
|
||||
let s = torch_entity_shadow(pos_range.xyz, P, N);
|
||||
direct += c_norm * (atten * nl * window) * s;
|
||||
// MONKEY (torch lane perf, MIRRORED from static_gx.wgsl): skip the table scan and its four
|
||||
// comparison taps where the direct term this occlusion multiplies is already nothing —
|
||||
// past the fixture's reach (`window` is exactly 0 there) or on a surface facing away from
|
||||
// it. An entity and the floor under it must agree about the shadow they show, so the
|
||||
// threshold and the shape of the guard are the same on both sides.
|
||||
let direct_w = atten * nl * window;
|
||||
var s = 1.0;
|
||||
if (direct_w > TORCH_SKIP_EPS) {
|
||||
s = torch_entity_shadow(pos_range.xyz, P, N);
|
||||
}
|
||||
direct += c_norm * direct_w * s;
|
||||
// MONKEY (soft falloff): the fill's profile is UNCHANGED in FORM — `(1 − d/r)²`, which is
|
||||
// exactly `interior_window(d, r, 1.0)` — and only its radius moved, from R to
|
||||
// `INTERIOR_FILL_SPAN·R`. That is the whole "gentle wash between the pools": at the fixture
|
||||
@@ -609,7 +734,9 @@ fn mcnk_cell_anchor(P: vec3<f32>) -> vec3<f32> {
|
||||
// the SAME candidates and agree at the seam. Candidacy is terrain's Chebyshev box, not this file's
|
||||
// 48 yd sphere, for that same agreement reason.
|
||||
const WMO_EXT_REACH: f32 = 33.570166;
|
||||
fn wmo_exterior_point_sum(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
|
||||
// MONKEY (outdoor torch shadows): the ranking half, split out for the same reason as
|
||||
// `point_light_pick` — the fragment stage re-evaluates the VERTEX's choice, never its own.
|
||||
fn wmo_exterior_pick(P: vec3<f32>) -> u32 {
|
||||
let anchor = mcnk_cell_anchor(P);
|
||||
let count = u32(wow_light.point_count.x);
|
||||
var sel = array<u32, 3>(0u, 0u, 0u);
|
||||
@@ -638,18 +765,12 @@ fn wmo_exterior_point_sum(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
|
||||
sd[2] = d2; sel[2] = i;
|
||||
}
|
||||
}
|
||||
var sum = vec3<f32>(0.0);
|
||||
for (var s = 0u; s < 3u; s = s + 1u) {
|
||||
if (sd[s] > 9.9e29) {
|
||||
break;
|
||||
}
|
||||
let to_light = wow_light.points[2u * sel[s]].xyz - P;
|
||||
let d = length(to_light);
|
||||
let atten = 1.0 / (0.7 * d + 0.03 * d * d);
|
||||
let nl = max(dot(N, to_light / max(d, 1e-4)), 0.0);
|
||||
sum += wow_light.points[2u * sel[s] + 1u].rgb * (atten * nl);
|
||||
}
|
||||
return sum;
|
||||
return select(EXT_SEL_EMPTY, sel[0], sd[0] <= 9.9e29)
|
||||
| (select(EXT_SEL_EMPTY, sel[1], sd[1] <= 9.9e29) << 10u)
|
||||
| (select(EXT_SEL_EMPTY, sel[2], sd[2] <= 9.9e29) << 20u);
|
||||
}
|
||||
fn wmo_exterior_point_sum(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
|
||||
return point_light_eval(wmo_exterior_pick(P), P, N);
|
||||
}
|
||||
|
||||
// The vertex input — bevy 0.18's `forward_io::Vertex` fields at bevy's shader locations (the
|
||||
@@ -922,15 +1043,23 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
|
||||
// it as a blanket zero is what left Stormwind's Trade District torches lighting nothing but
|
||||
// the NPC beside them. Mirrors `static_gx.wgsl`'s branch (which draws the vast majority of
|
||||
// these batches) — keep the two in step.
|
||||
out.point_lit = wmo_exterior_point_sum(out.world_position.xyz, out.world_normal);
|
||||
// MONKEY (outdoor torch shadows): the pick is PUBLISHED so the fragment stage can shadow
|
||||
// these same three entries 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 = wmo_exterior_pick(out.world_position.xyz);
|
||||
out.ext_sel = sel;
|
||||
out.point_lit = point_light_eval(sel, out.world_position.xyz, out.world_normal);
|
||||
} else if (m.model_flags.x > 0.5 || m.model_flags.z > 0.5) {
|
||||
out.point_lit = vec3<f32>(0.0);
|
||||
out.ext_sel = EXT_SEL_NONE;
|
||||
} else {
|
||||
var anchor = mesh_world_from_local[3].xyz;
|
||||
if (m.clutter_fade.w > 0.5) {
|
||||
anchor = mcnk_cell_anchor(out.world_position.xyz);
|
||||
}
|
||||
out.point_lit = point_light_sum(out.world_position.xyz, out.world_normal, anchor);
|
||||
let sel = point_light_pick(anchor);
|
||||
out.ext_sel = sel;
|
||||
out.point_lit = point_light_eval(sel, out.world_position.xyz, out.world_normal);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1399,7 +1528,30 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
// like the terrain it adjoins, which is what puts a street torch on the cobbles. The term
|
||||
// arrives GOURAUD-INTERPOLATED — per-vertex like the reference FFP, whose tessellation-scale
|
||||
// smoothing is the authored look. Diffuse-only (committed ambient/specular are zero).
|
||||
let point_diffuse = in.point_lit;
|
||||
// MONKEY (outdoor torch shadows): ...and after dark that Gouraud term is CAST-SHADOWED.
|
||||
//
|
||||
// The same three entries the vertex stage picked (`in.ext_sel`) are re-evaluated PER FRAGMENT
|
||||
// with each fixture's own cube-map occlusion folded in, and the two results are blended by
|
||||
// `night_w = 1 - sun_shadow_strength`. Written as a blend rather than a swap for two reasons:
|
||||
// - `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 `night_w` is
|
||||
// exactly 0 and the branch is not entered - DAYLIGHT IS THE SAME BITS, not "a mix that
|
||||
// ought to round back to b".
|
||||
// - 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 some 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. `n_lit` is the same
|
||||
// normal every other lit term here uses, so the normal-offset sample leaves the skin the way
|
||||
// the interior entity lane's does.
|
||||
var point_diffuse = in.point_lit;
|
||||
let ext_night_w = select(0.0, clamp(1.0 - wow_light.fog_params.z, 0.0, 1.0), torch_ext_on());
|
||||
if (ext_night_w > 0.0) {
|
||||
point_diffuse = mix(
|
||||
in.point_lit,
|
||||
point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit),
|
||||
ext_night_w,
|
||||
);
|
||||
}
|
||||
|
||||
// Hover/target model brighten (tag bit 31): the client's per-model highlight emissive —
|
||||
// `glMaterialfv(GL_EMISSION, +64/255)` per channel (shipped config default 0xff404040), verified
|
||||
|
||||
@@ -565,6 +565,39 @@ impl RootRooms {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
fn root_rooms(
|
||||
chain: &mut Chain,
|
||||
root: &benilla_formats::WmoRoot,
|
||||
@@ -675,13 +708,23 @@ pub fn wmolights(chain: &mut Chain, raw_path: &str) -> Result<()> {
|
||||
// (an inn's basement stairwell, a covered porch) is drawn by the EXTERIOR law and takes the
|
||||
// night sky unless an interior fixture claims it — that is bug B's second cause, and this is
|
||||
// where you read which groups are in that population.
|
||||
println!("=== groups (class, MOGP flags, MOGI box, portals, MOLR fixtures) ===");
|
||||
// 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} {} flags {:#010x} box ({:>8.2},{:>8.2},{:>7.2})..({:>8.2},{:>8.2},{:>7.2}) portals {pc:<3} molr {:?}",
|
||||
if g.interior { "INT" } else { "ext" },
|
||||
" 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],
|
||||
@@ -689,6 +732,30 @@ pub fn wmolights(chain: &mut Chain, raw_path: &str) -> Result<()> {
|
||||
);
|
||||
}
|
||||
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();
|
||||
println!(
|
||||
" p{pi:<3} area {area:>7.2} yd^2 |n.z| {:.2} joins {joins:?}{}",
|
||||
plane[2].abs(),
|
||||
if area >= benilla_formats::room_claim::SPLIT_PORTAL_MIN_AREA { " SPLIT" } else { "" },
|
||||
);
|
||||
}
|
||||
println!();
|
||||
for (i, l) in lights.iter().enumerate() {
|
||||
let prod = [
|
||||
l.color[0] * l.intensity,
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
//! 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
|
||||
@@ -65,6 +69,11 @@ pub enum ClaimHow {
|
||||
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 {
|
||||
@@ -73,6 +82,7 @@ impl ClaimHow {
|
||||
ClaimHow::Contains => "in",
|
||||
ClaimHow::Molr => "molr",
|
||||
ClaimHow::Portal => "portal",
|
||||
ClaimHow::Split => "split",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,6 +198,29 @@ fn portal_box(portals: &PortalGraph, portal: u16) -> Option<([f32; 3], [f32; 3])
|
||||
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
|
||||
@@ -242,6 +275,34 @@ pub const CLAIM_FADE_MIN_YD: f32 = 2.0;
|
||||
/// 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.)
|
||||
@@ -271,11 +332,23 @@ pub const CLAIM_EXT_SHELL_YD: f32 = 96.0;
|
||||
/// Is a BOX claim (containment or MOLR) on this group eligible for the exterior lane? Interior
|
||||
/// groups trivially are (the exterior lane never draws them); an exterior group only at building
|
||||
/// scale (see [`CLAIM_EXT_SHELL_YD`]).
|
||||
fn claimable_by_box(g: &WmoGroupInfo) -> bool {
|
||||
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)
|
||||
}
|
||||
|
||||
/// `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])
|
||||
@@ -351,6 +424,68 @@ pub fn room_claims(
|
||||
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.
|
||||
//
|
||||
@@ -611,6 +746,64 @@ mod tests {
|
||||
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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ use bevy::render::render_resource::{Buffer, BufferDescriptor, BufferUsages};
|
||||
use bevy::render::renderer::{RenderDevice, RenderQueue};
|
||||
use bevy::render::{Render, RenderApp, RenderSystems};
|
||||
|
||||
// MONKEY (flame flicker): the wobble's component + evaluator, folded into the packed colour below.
|
||||
use super::flicker::{FlameFlicker, FlickerMod};
|
||||
use super::prop_probes::MAX_PROP_PROBES;
|
||||
use super::{sh, WowLighting};
|
||||
use crate::dev_state::DebugState;
|
||||
@@ -538,6 +540,26 @@ impl Default for ShadowDistance {
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (sun shadow perf): the live `shadowFilter` choice, bridged from benilla-app's shadow rig
|
||||
/// and extracted into the RENDER world.
|
||||
///
|
||||
/// Every other realtime-shadow receiver benilla has is a Bevy `Material` (terrain, `wow_model`), so
|
||||
/// its pipeline picks the PCF branch up automatically from the view's `ShadowFilteringMethod`. The
|
||||
/// retained `static_gx` pass is specialized BY HAND, so it has to be told: `static_gx::render`
|
||||
/// folds this into its pipeline key and pushes the matching `SHADOW_FILTER_METHOD_*` shader def.
|
||||
/// Without it the two halves of the frame would disagree — the ground filtered one way and the
|
||||
/// buildings standing on it the other.
|
||||
///
|
||||
/// `true` = Gaussian (9 taps, the default look); `false` = Hardware2x2 (1 tap).
|
||||
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, ExtractResource)]
|
||||
pub struct ShadowFilterGaussian(pub bool);
|
||||
|
||||
impl Default for ShadowFilterGaussian {
|
||||
fn default() -> Self {
|
||||
Self(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Main-world resource holding the packed light for this frame; extracted into the render world where
|
||||
/// [`upload_light`] writes it. Rebuilt every frame by [`build_light_data`] (cheap — one std430 pack).
|
||||
#[derive(Resource, Clone, Copy, ExtractResource)]
|
||||
@@ -568,6 +590,10 @@ pub(super) fn register(app: &mut App) {
|
||||
.init_resource::<RoomClaimTable>()
|
||||
.init_resource::<WorldShadowActive>()
|
||||
.init_resource::<ShadowDistance>()
|
||||
// MONKEY (sun shadow perf): the live PCF choice, extracted for `static_gx`'s hand-rolled
|
||||
// pipeline (every Bevy-material receiver keys off the view component instead).
|
||||
.init_resource::<ShadowFilterGaussian>()
|
||||
.add_plugins(ExtractResourcePlugin::<ShadowFilterGaussian>::default())
|
||||
.init_resource::<DynamicInteriors>()
|
||||
// MONKEY (fire GO lights): the live gain on synthesised fire lights.
|
||||
.init_resource::<FireLightGain>()
|
||||
@@ -683,9 +709,24 @@ pub struct DynamicInteriors {
|
||||
/// interior-classified OUTDOOR entity shows up. 2 = SHADOW: the torch-shadow factor as greyscale
|
||||
/// (black = shadowed, white = lit) — shows whether cast shadows are computed at all. 3 = CASTER
|
||||
/// COUNT: how many shadow-casting proxies the fragment sees in `clusterable_objects` (red = 0 —
|
||||
/// the proxies never reached the shader; green = 1; blue = 2; white = 3+). Packed with the on/off
|
||||
/// flag into `wmo_fog_params.w` as `1 + debug` (so `>0.5` still means "on").
|
||||
/// the proxies never reached the shader; green = 1; blue = 2; white = 3+).
|
||||
/// MONKEY (ext-class night law): 4 = WMO LANE MAP — every WMO fragment painted by the lighting
|
||||
/// law its GROUP falls under (green interior-class, blue exterior-class at building scale, red
|
||||
/// exterior-class shell/district), terrain and models untouched. The instrument for "is this
|
||||
/// seam a LAW mismatch (two colours) or a CLAIM mismatch (one colour)?".
|
||||
/// Packed with the on/off flag into `wmo_fog_params.w` as `1 + debug` (so `>0.5` still means
|
||||
/// "on").
|
||||
pub debug: u32,
|
||||
/// MONKEY (flame flicker): live gain on every flame's brightness wobble (`fireFlicker`, 0..2).
|
||||
/// `1` = the authored per-kind amplitudes ([`FlameKind::amplitude`]), `0` = steady constants
|
||||
/// (the pre-feature look, and the escape hatch if a flicker ever reads wrong), `2` = doubled.
|
||||
///
|
||||
/// It rides THIS resource rather than a header lane because it is consumed entirely on the CPU:
|
||||
/// [`build_light_data`] folds the multiplier into the packed colour, so the shader never learns
|
||||
/// the feature exists and `LightStd430` gains no lane. Also why it is not on
|
||||
/// [`FireLightGain`]: that one scales the SYNTHESISED lane only, while a flicker belongs to
|
||||
/// authored wall torches just as much.
|
||||
pub flicker: f32,
|
||||
}
|
||||
|
||||
impl Default for DynamicInteriors {
|
||||
@@ -701,6 +742,8 @@ impl Default for DynamicInteriors {
|
||||
// its own floors and walls.
|
||||
room_gate: true,
|
||||
debug: 0,
|
||||
// MONKEY (flame flicker): on at the authored amplitudes.
|
||||
flicker: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -880,6 +923,10 @@ fn build_light_data(
|
||||
Option<&LightLane>,
|
||||
// MONKEY (room gate): the bbox-derived supplement to the MOLR claim set.
|
||||
Option<&LightLitRooms>,
|
||||
// MONKEY (flame flicker): present iff this light BURNS ([`flame_kind_for`] decided so
|
||||
// at spawn). Read-only, never written — the wobble is a pure function of it plus the
|
||||
// clock, so it adds no `Changed` traffic and no archetype churn to the frame.
|
||||
Option<&FlameFlicker>,
|
||||
),
|
||||
Without<ShadowProxyLight>,
|
||||
>,
|
||||
@@ -965,6 +1012,10 @@ fn build_light_data(
|
||||
// identity, over-gamut preserved). Entries past `count` stay stale in the blob — the count row
|
||||
// guards every reader.
|
||||
let cam_pos = cam.single().map(|t| t.translation()).unwrap_or(Vec3::ZERO);
|
||||
// MONKEY (flame flicker): the one clock read for the whole table — every flame's phase is a
|
||||
// function of this absolute second and its own seed, so the frame is internally consistent and
|
||||
// the result does not depend on how long the frame took.
|
||||
let now_secs = time.elapsed_secs();
|
||||
// MONKEY (fire GO lights): `(…, synthetic)` rides the tuple so the census below can count the
|
||||
// invented entries without a second query.
|
||||
// MONKEY (light lanes): the tuple's last member is the packed COLOUR-ROW `.w` — 0 for an
|
||||
@@ -978,7 +1029,7 @@ fn build_light_data(
|
||||
// the interior lane, so `claims 4` beside `EXT` is the readout of exactly that trade.
|
||||
let mut pts: Vec<(f32, Vec3, f32, [f32; 3], bool, f32, RoomClaim, usize)> = lights_q
|
||||
.iter()
|
||||
.filter(|(_, gt, rooms, _, _, _, _)| {
|
||||
.filter(|(_, gt, rooms, _, _, _, _, _)| {
|
||||
// The ROOM term (decision 0689's law, fourth lane — see [`LightRooms`]). Not a
|
||||
// visibility test bolted onto a faithful gather: the reference's register walk has no
|
||||
// such term either, it simply never has a culled room's torch to register. Ungated for
|
||||
@@ -996,7 +1047,7 @@ fn build_light_data(
|
||||
&& gt.translation().distance_squared(cam_pos)
|
||||
< INTERIOR_NEAR_ADMIT * INTERIOR_NEAR_ADMIT)
|
||||
})
|
||||
.filter_map(|(pl, gt, rooms, synthetic, reach, lane_of, lit_rooms)| {
|
||||
.filter_map(|(pl, gt, rooms, synthetic, reach, lane_of, lit_rooms, flicker)| {
|
||||
let p = gt.translation();
|
||||
let d2 = p.distance_squared(cam_pos);
|
||||
(d2 < POINT_PACK_RADIUS * POINT_PACK_RADIUS).then(|| {
|
||||
@@ -1008,7 +1059,20 @@ fn build_light_data(
|
||||
// recovered colour×intensity, and only for a synthesised source. At spawn it
|
||||
// would need a world respawn to retune; here the dial moves the frame it changes.
|
||||
let s = base * if synthetic { fire_gain.0.max(0.0) } else { 1.0 };
|
||||
let rgb = commit_raw([c.red * s, c.green * s, c.blue * s]);
|
||||
// MONKEY (flame flicker): the fire wobble, folded in at the very last moment — over
|
||||
// the committed colour and NOWHERE else. Deliberately downstream of `base`, which
|
||||
// still feeds the reach/lane below unmodulated: a breathing REACH would move the
|
||||
// interior window, re-decide the room gate's portal hop and re-rank the torch
|
||||
// shadow casters every frame, i.e. exactly the frame-rate thrash this feature
|
||||
// exists to avoid. Only the brightness moves; the pool's geometry is frozen.
|
||||
//
|
||||
// `elapsed_secs` (absolute, never a delta) is what makes it frame-rate independent:
|
||||
// the same instant gives the same brightness whatever the frame took.
|
||||
let fm = flicker.map_or(FlickerMod::STEADY, |f| {
|
||||
f.at(now_secs, dynamic_interiors.flicker)
|
||||
});
|
||||
let s = s * fm.intensity;
|
||||
let rgb = commit_raw([c.red * s, c.green * s, c.blue * s * fm.blue]);
|
||||
// MONKEY (light lane by position): a light is INTERIOR iff it PHYSICALLY STANDS in
|
||||
// an interior-class WMO group — the verdict [`classify_light_lanes`] (static) or
|
||||
// the carried-light claim (entities) wrote onto it. Claiming a room is no longer
|
||||
@@ -1103,16 +1167,29 @@ fn build_light_data(
|
||||
// feature is a heuristic over content, so "the inn is too bright" has to be separable
|
||||
// into "too many synthetic sources" and "the authored ones changed" without a rebuild.
|
||||
let synth = pts.iter().filter(|(.., s, _, _, _)| *s).count();
|
||||
// MONKEY (flame flicker): how many of the nearby sources BURN — the census that answers
|
||||
// "did the route rule file this room's fixtures as flames at all?" without a rebuild.
|
||||
// Counted over the same radius the pack gathers from rather than off `pts`, so it does
|
||||
// not have to ride the packing tuple through five destructurings for a diagnostic.
|
||||
let flames = lights_q
|
||||
.iter()
|
||||
.filter(|(_, gt, ..)| {
|
||||
gt.translation().distance_squared(cam_pos)
|
||||
< POINT_PACK_RADIUS * POINT_PACK_RADIUS
|
||||
})
|
||||
.filter(|t| t.7.is_some())
|
||||
.count();
|
||||
// MONKEY (light lanes): how the table splits between the two now-disjoint consumer
|
||||
// families. "The inn's candles are lighting the lawn" is an INT count on a row the
|
||||
// exterior lane should never have seen — a number, printed per row below as INT/EXT.
|
||||
let interior = pts.iter().filter(|(.., lane, _, _)| *lane > 0.5).count();
|
||||
eprintln!(
|
||||
"[points] {} packed ({interior} INT / {} EXT, {synth} synthetic, gain {:.2}, atten x{:.2}), cam {cam_pos:.1?} — this chunk's candidates: {boxed} (was {sphere} at the 48 yd sphere), 3 slots",
|
||||
"[points] {} packed ({interior} INT / {} EXT, {synth} synthetic, {flames} flickering, gain {:.2}, atten x{:.2}, flicker x{:.2}), cam {cam_pos:.1?} — this chunk's candidates: {boxed} (was {sphere} at the 48 yd sphere), 3 slots",
|
||||
pts.len(),
|
||||
pts.len() - interior,
|
||||
fire_gain.0,
|
||||
dynamic_interiors.atten_scale,
|
||||
dynamic_interiors.flicker,
|
||||
);
|
||||
for (d2, p, _, rgb, synthetic, lane, claim, lit_n) in pts.iter().take(8) {
|
||||
eprintln!(
|
||||
@@ -1685,6 +1762,68 @@ mod tests {
|
||||
assert!((data.points[1][0] - 2.0).abs() < 1e-4, "authored unaffected");
|
||||
}
|
||||
|
||||
/// GOLDEN — MONKEY (flame flicker): the wobble reaches the packed COLOUR and nothing else.
|
||||
///
|
||||
/// Three properties, and each one is a bug if it inverts. (1) A flame's committed colour MOVES
|
||||
/// with the clock while an identical light without the component beside it does not — the
|
||||
/// feature exists at all. (2) The move stays inside the kind's authored amplitude, which is the
|
||||
/// anti-strobe guarantee measured where it actually lands (after the `4π` round trip and
|
||||
/// `commit_raw`), not just in the waveform's own unit test. (3) The colour row's `.w` — the
|
||||
/// interior REACH — is byte-identical across those frames. That is the one thing that must not
|
||||
/// breathe: a moving reach would re-window the interior pool, re-decide the room gate's portal
|
||||
/// hop and re-rank the torch-shadow casters every single frame, which is the frame-rate thrash
|
||||
/// ("the epileptic imp") this feature is built to stay clear of.
|
||||
#[test]
|
||||
fn the_flicker_moves_the_packed_colour_and_never_the_reach() {
|
||||
use super::super::flicker::FlameKind;
|
||||
let at = |app: &mut App, ms: u64| {
|
||||
let mut t = Time::<()>::default();
|
||||
t.advance_by(std::time::Duration::from_millis(ms));
|
||||
app.world_mut().insert_resource(t);
|
||||
app.update();
|
||||
app.world().resource::<WowLightData>().0
|
||||
};
|
||||
let mut app = packer_app();
|
||||
// Same recipe, same lane, same intensity: only the component can separate them.
|
||||
let recipe = || crate::terrain_stream::point_light([1.0, 0.5, 0.25], 1.5);
|
||||
let lane = LightLane { interior: true, generation: LightLane::SETTLED };
|
||||
app.world_mut()
|
||||
.spawn((recipe(), GlobalTransform::from_translation(Vec3::X), lane));
|
||||
app.world_mut().spawn((
|
||||
recipe(),
|
||||
GlobalTransform::from_translation(Vec3::new(2.0, 0.0, 0.0)),
|
||||
lane,
|
||||
FlameFlicker::new(FlameKind::Torch, 0x51ee_d105),
|
||||
));
|
||||
|
||||
let (mut moved, mut steady_moved) = (0.0f32, 0.0f32);
|
||||
let first = at(&mut app, 0);
|
||||
let (base_steady, base_flame, reach) = (first.points[1][0], first.points[3][0], first.points[3][3]);
|
||||
assert!(reach > 0.5, "the flame is on the interior lane, so `.w` carries its reach");
|
||||
for ms in (40..4000).step_by(37) {
|
||||
let d = at(&mut app, ms);
|
||||
steady_moved = steady_moved.max((d.points[1][0] - base_steady).abs());
|
||||
moved = moved.max((d.points[3][0] - base_flame).abs() / base_flame.max(1e-6));
|
||||
assert_eq!(d.points[3][3], reach, "the packed reach never moves with the flicker");
|
||||
assert_eq!(d.points[2], first.points[2], "nor does the position/range row");
|
||||
}
|
||||
assert_eq!(steady_moved, 0.0, "a light with no FlameFlicker is a constant, as before");
|
||||
assert!(moved > 0.02, "the flame barely moved at all: {moved}");
|
||||
// Both endpoints of the excursion are inside the torch rung's authored +-10%, doubled by
|
||||
// the two-sided base sample (the reference frame is t=0, itself off the mean).
|
||||
assert!(moved < 2.0 * FlameKind::Torch.amplitude(), "over amplitude: {moved}");
|
||||
|
||||
// `fireFlicker 0` is the off switch: the flame commits the same bytes on every frame.
|
||||
app.world_mut().insert_resource(DynamicInteriors {
|
||||
flicker: 0.0,
|
||||
..DynamicInteriors::default()
|
||||
});
|
||||
let off = at(&mut app, 5000).points[3];
|
||||
for ms in [5100u64, 5250, 5600] {
|
||||
assert_eq!(at(&mut app, ms).points[3], off, "gain 0 = the pre-feature constant");
|
||||
}
|
||||
}
|
||||
|
||||
/// GOLDEN — MONKEY (light lanes / interior attenuation): the colour row's `.w` separates the
|
||||
/// two consumer families AND carries the interior reach, and `[pos.xyz, range]` is untouched.
|
||||
///
|
||||
|
||||
@@ -12,16 +12,21 @@ use benilla_formats::{LightCatalog, LiquidKind};
|
||||
|
||||
mod blob; // the off-world light-blob builder (booth studio, body pane, glue scene)
|
||||
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;
|
||||
// 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, ShadowProxyLight, SyntheticFireLight, WorldShadowActive,
|
||||
ShadowDistance, ShadowFilterGaussian, ShadowProxyLight, SyntheticFireLight,
|
||||
WorldShadowActive,
|
||||
};
|
||||
pub use global_light::{
|
||||
room_claim_bytes, CLAIM_EXT_OK, LIT_ROOM_EXT_DENY, ROOM_CLAIM_MAX, ROOM_CLAIM_STRIDE,
|
||||
|
||||
@@ -138,6 +138,12 @@ const WORD_MATTE: u32 = 268435456u; // 1 << 28 — exterior MODD prop: intens
|
||||
const RECORD_ROOM_SHIFT: u32 = 15u;
|
||||
const RECORD_ROOM_MASK: u32 = 4095u;
|
||||
|
||||
// MONKEY (ext-class night law): record column `w`, bit 27 — this batch's group is EXTERIOR-class
|
||||
// (MOGP `& 0x48`) but at BUILDING scale, `benilla_formats::room_claim::ext_building_scale`. The
|
||||
// room key ends at bit 26, so 27 is the first free one. Keep in sync with `static_gx/render.rs`'s
|
||||
// `RECORD_EXT_NIGHT_BIT`.
|
||||
const RECORD_EXT_NIGHT: u32 = 134217728u; // 1 << 27
|
||||
|
||||
// Vanilla cutout ref (224/255) — wow_model.wgsl's VANILLA_ALPHA_KEY.
|
||||
const VANILLA_ALPHA_KEY: f32 = 0.8784314;
|
||||
|
||||
@@ -149,6 +155,7 @@ struct TorchTable {
|
||||
// MONKEY (static torch cache): byte-identical in BOTH shaders and TorchTableUniform.
|
||||
// count@0 (16): x high-water slot count, y soft*100, z dynamic/live-bank mask, w reserved.
|
||||
// positions@16 (256), view_projs@272 (6144): total 6416 bytes. A mismatch hides buildings.
|
||||
// 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>,
|
||||
@@ -170,6 +177,38 @@ fn torch_soft() -> f32 {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MONKEY (outdoor torch shadows): the EXTERIOR lane's world-distance fade radius (yd). See
|
||||
// `shadow_hook::torch_map_shadow`'s `fade` note for why the interior's reverse-Z `ndc.z` fade is
|
||||
// useless out here (it is already down to 5 % at 10 yd, and a campfire's pool is 15-25). Chosen
|
||||
// just inside the cube projection's own 48 yd far plane, so a shadow is at full strength across
|
||||
// the whole pool and has finished fading by the time the fragment leaves the frustum — which is
|
||||
// what stops the frustum edge from being a visible hard cut.
|
||||
const TORCH_EXT_FADE_YD: f32 = 44.0;
|
||||
// MONKEY (outdoor torch shadows): `count.w` bit 0 — the EXTERIOR receiver lane's live gate,
|
||||
// published by `benilla_app::torch_shadow` (the `exteriorShadows` cvar AND night AND at least one
|
||||
// promoted exterior fixture). Everything the exterior lane costs hangs off this ONE uniform read,
|
||||
// so by day / with the cvar off the receivers below are exactly the code they were before.
|
||||
const TORCH_EXT_LANE: u32 = 1u;
|
||||
// MONKEY (torch lane perf): below THIS much unshadowed direct contribution a fragment skips the
|
||||
// torch table scan and its four comparison taps entirely, because the shadow factor is multiplied
|
||||
// into that contribution and can therefore only ever subtract less than this from the frame.
|
||||
//
|
||||
// Sized off what the eye can see, not off "small": the interior budget goes through the
|
||||
// `1 - exp(-x * exposure)` rolloff, which for a term this small is just `x * exposure`, so at the
|
||||
// shipped `interiorExposure 2.5` the worst possible step across the guard's boundary is
|
||||
// 2.5e-4 linear = well under one 8-bit code. A tenth of this (1e-3) would have been a visible
|
||||
// ~8/255 contour along the iso-surface where the guard flips. Most of the skips it buys are exact
|
||||
// zeros anyway -- `window` IS 0 past the fixture's reach and `nl` IS 0 on a surface facing away --
|
||||
// so the threshold only has to be small enough to be invisible, never large enough to be useful.
|
||||
const TORCH_SKIP_EPS: f32 = 1e-4;
|
||||
fn torch_ext_on() -> bool {
|
||||
#ifdef TORCH_SHADOWS
|
||||
return (torch_table.count.w & TORCH_EXT_LANE) != 0u;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
// MONKEY (Phase 5): the cube face that contains direction `d` (fixture → fragment) — the major
|
||||
// axis, signed. The 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. Each face is a 90°(+ε) frustum down that axis, so the face
|
||||
@@ -185,21 +224,47 @@ fn torch_face(d: vec3<f32>) -> u32 {
|
||||
return select(5u, 4u, d.z > 0.0);
|
||||
}
|
||||
|
||||
// This interior fixture's OWN cast shadow: correlate the `wow_light` fixture at `light_pos` to a
|
||||
// This fixture's OWN cast shadow: 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. 1.0 (unshadowed) when no map matches, or when TORCH_SHADOWS is off (wow_model's copy
|
||||
// of the caller never sets it).
|
||||
fn torch_surface_shadow(light_pos: vec3<f32>, P: vec3<f32>) -> f32 {
|
||||
//
|
||||
// MONKEY (outdoor torch shadows): `fade_radius` selects the FAR FADE, and it is the ONLY difference
|
||||
// between the interior and exterior lanes here — the correlation, the face pick, the live-bank
|
||||
// compaction and the slot cross-fade are one body because a shadow map does not know which lane
|
||||
// promoted it. `0` = the interior/legacy `ndc.z` fade, unchanged bit-for-bit; `> 0` = the exterior
|
||||
// world-distance fade `1 − smoothstep(0.8R, R, d)` measured from the FIXTURE (not from the camera:
|
||||
// this is "how far does this fire's shadow carry", a property of the fire).
|
||||
fn torch_map_at(light_pos: vec3<f32>, P: vec3<f32>, fade_radius: f32) -> f32 {
|
||||
#ifdef TORCH_SHADOWS
|
||||
// MONKEY (torch lane perf): the whole table is empty far more often than not (no promoted
|
||||
// fixture in range, or the lane switched off while the shader-def is still compiled in). Say so
|
||||
// once, up front, so the caller pays one uniform read rather than a loop set-up per fixture.
|
||||
if (torch_table.count.x == 0u) {
|
||||
return 1.0;
|
||||
}
|
||||
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
|
||||
// MONKEY (static torch cache): holes and pending uploads never sample stale layers.
|
||||
if (torch_table.positions[i].w <= 0.0) { continue; }
|
||||
let fixture = torch_table.positions[i].xyz;
|
||||
if (distance(fixture, light_pos) < 1.0) {
|
||||
let layer = i * 6u + torch_face(P - fixture);
|
||||
let face = torch_face(P - 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);
|
||||
// MONKEY (outdoor torch shadows): negative = "use your own ndc fade" (the interior
|
||||
// contract). The exterior weight is computed HERE because only this scope knows both
|
||||
// the fixture and the fragment.
|
||||
let fade = select(
|
||||
-1.0,
|
||||
1.0 - smoothstep(0.8 * fade_radius, fade_radius, distance(fixture, P)),
|
||||
fade_radius > 0.0,
|
||||
);
|
||||
let s = shadow_hook::torch_map_shadow(
|
||||
torch_table.view_projs[layer], i32(layer + select(0u, 96u, (torch_table.count.z & (1u << i)) != 0u)), P, torch_depth, torch_samp, TORCH_BIAS,
|
||||
torch_soft());
|
||||
torch_table.view_projs[layer], i32(depth_layer), P, torch_depth, torch_samp, TORCH_BIAS,
|
||||
torch_soft(), fade);
|
||||
// MONKEY (torch caster selection): `.w` is the slot's FADE WEIGHT. A slot ramps 0 -> 1
|
||||
// over ~1/3 s when it is promoted and 1 -> 0 before it is reused, and `mix` turns that
|
||||
// into the shadow appearing/dissolving instead of switching. Without it the selection
|
||||
@@ -212,6 +277,17 @@ fn torch_surface_shadow(light_pos: vec3<f32>, P: vec3<f32>) -> f32 {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// The INTERIOR lane's call — unchanged behaviour (`fade_radius 0` ⇒ the reverse-Z `ndc.z` fade).
|
||||
fn torch_surface_shadow(light_pos: vec3<f32>, P: vec3<f32>) -> f32 {
|
||||
return torch_map_at(light_pos, P, 0.0);
|
||||
}
|
||||
|
||||
// MONKEY (outdoor torch shadows): the EXTERIOR lane's call — the campfire/brazier/lamppost pool on
|
||||
// a WMO's outdoor-class surfaces and on exterior doodads.
|
||||
fn torch_exterior_shadow(light_pos: vec3<f32>, P: vec3<f32>) -> f32 {
|
||||
return torch_map_at(light_pos, P, TORCH_EXT_FADE_YD);
|
||||
}
|
||||
|
||||
// MONKEY (torch debug, interiorDebug 2): the MIN raw depth-map shadow factor over EVERY promoted map
|
||||
// (ignores the fixture-position match), so the map's actual content is visible as greyscale on the
|
||||
// floor: all-WHITE = maps empty / projection misses the fragment; uniform GREY = self-shadow/bias;
|
||||
@@ -221,10 +297,14 @@ fn torch_debug_factor(P: vec3<f32>) -> f32 {
|
||||
var s = 1.0;
|
||||
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
|
||||
if (torch_table.positions[i].w <= 0.0) { continue; }
|
||||
let layer = i * 6u + torch_face(P - torch_table.positions[i].xyz);
|
||||
let face = torch_face(P - 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);
|
||||
let raw = shadow_hook::torch_map_shadow(
|
||||
torch_table.view_projs[layer], i32(layer + select(0u, 96u, (torch_table.count.z & (1u << i)) != 0u)), P, torch_depth, torch_samp, TORCH_BIAS,
|
||||
torch_soft());
|
||||
torch_table.view_projs[layer], i32(depth_layer), P, torch_depth, torch_samp, TORCH_BIAS,
|
||||
torch_soft(), -1.0);
|
||||
// MONKEY (torch caster selection): the WEIGHTED factor, so the debug view shows what the
|
||||
// real render shows - a fading slot greys out here too, and a slot stuck at w = 0 (never
|
||||
// ramping in) is visible as a map that contributes nothing.
|
||||
@@ -243,7 +323,24 @@ fn wow_normalize(v: vec3<f32>) -> vec3<f32> {
|
||||
return select(vec3<f32>(0.0), normalize(v), l2 > 1e-12);
|
||||
}
|
||||
|
||||
fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
|
||||
// MONKEY (outdoor torch shadows): the ≤3-nearest EXTERIOR selection, packed into ONE u32 as three
|
||||
// 10-bit indices (rank 0 in the low bits), `EXT_SEL_EMPTY` for an unfilled rank. The packed light
|
||||
// table is capped at 256 entries, so 10 bits carries two bits of headroom.
|
||||
//
|
||||
// WHY the selection is packed and TRAVELS from the vertex stage: the exterior point term is chosen
|
||||
// per VERTEX (the reference FFP's own granularity, and — on a WMO's outdoor-class surfaces —
|
||||
// anchored at the MCNK CELL so a street and the road it runs into rank the same three lights and
|
||||
// agree at the seam, see `wmo_exterior_point_sum`). The per-FRAGMENT shadowed term must use exactly
|
||||
// THAT selection: re-ranking per fragment would put a hard line across the cobbles at every cell
|
||||
// boundary, where the interpolated vertex term has none. So the vertex stage publishes its choice
|
||||
// and the fragment stage only re-EVALUATES it (and shadows it).
|
||||
const EXT_SEL_EMPTY: u32 = 1023u;
|
||||
const EXT_SEL_NONE: u32 = 1073741823u; // three empty ranks: 1023 | 1023<<10 | 1023<<20
|
||||
|
||||
// The ranking half of the old `point_light_sum` — the EXTERIOR doodad/MODD-prop family, anchored at
|
||||
// the receiving unit's own origin. Split out verbatim (same tests, same order, same tie handling)
|
||||
// so the sum can be re-evaluated later against the identical choice.
|
||||
fn point_light_pick(anchor: vec3<f32>) -> 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);
|
||||
@@ -273,21 +370,67 @@ fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
|
||||
sd[2] = d2; sel[2] = i;
|
||||
}
|
||||
}
|
||||
return select(EXT_SEL_EMPTY, sel[0], sd[0] <= 9.9e29)
|
||||
| (select(EXT_SEL_EMPTY, sel[1], sd[1] <= 9.9e29) << 10u)
|
||||
| (select(EXT_SEL_EMPTY, sel[2], sd[2] <= 9.9e29) << 20u);
|
||||
}
|
||||
|
||||
// The evaluation half — the byte-verified falloff `1/(0.7d + 0.03d²)` × `max(N·L, 0)` × the
|
||||
// committed colour, in rank order, stopping at the first empty rank exactly as the old
|
||||
// `sd[s] > 9.9e29` break did. Shared by both pickers (their sum loops were already identical).
|
||||
fn point_light_eval(sel: 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) {
|
||||
let idx = (sel >> (10u * s)) & 1023u;
|
||||
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): the SHADOWED evaluation — the same three entries, each multiplied
|
||||
// by its OWN fixture's cube-map occlusion, so a crate between the fragment and campfire A darkens
|
||||
// A's term while lamppost B's is untouched. Per fixture, exactly as the interior lane is (this is
|
||||
// the same argument one lane over: a single scene-wide shadow factor cannot express "shadowed from
|
||||
// one fire, lit by another", which is what a village square at night actually looks like).
|
||||
//
|
||||
// Bounded at three iterations by construction, and reached only under `torch_ext_on()`, so nothing
|
||||
// here executes in daylight or with `exteriorShadows 0`.
|
||||
fn point_light_eval_shadowed(sel: u32, P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
|
||||
var sum = vec3<f32>(0.0);
|
||||
for (var s = 0u; s < 3u; s = s + 1u) {
|
||||
let idx = (sel >> (10u * s)) & 1023u;
|
||||
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): same argument as the interior lane's guard one screen up — the
|
||||
// occlusion is a factor on a term that is already zero on any surface facing away from the
|
||||
// fire, and the scan + four taps are the expensive half of this loop body.
|
||||
let ext_w = atten * nl;
|
||||
var s = 1.0;
|
||||
if (ext_w > TORCH_SKIP_EPS) {
|
||||
s = torch_exterior_shadow(fixture, P);
|
||||
}
|
||||
sum += wow_light.points[2u * idx + 1u].rgb * (ext_w * s);
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
|
||||
return point_light_eval(point_light_pick(anchor), P, N);
|
||||
}
|
||||
|
||||
// MONKEY (wmo exterior points): the EXTERIOR-lane point term for a WMO's OUTDOOR-class surfaces —
|
||||
// Stormwind's cobbles, its city walls, an inn's porch: every group whose MOGP flags carry `0x48`
|
||||
// (`WORD_INTERIOR` clear). Until now those took NO point term at all: the vertex stage zeroed
|
||||
@@ -325,7 +468,9 @@ fn mcnk_cell_anchor(P: vec3<f32>) -> vec3<f32> {
|
||||
let iz = floor((half + P.z) / cell);
|
||||
return vec3<f32>((ix + 0.5) * cell - half, P.y, (iz + 0.5) * cell - half);
|
||||
}
|
||||
fn wmo_exterior_point_sum(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
|
||||
// MONKEY (outdoor torch shadows): the ranking half, split out for the same reason as
|
||||
// `point_light_pick` — the fragment stage must re-evaluate the VERTEX's choice, not make its own.
|
||||
fn wmo_exterior_pick(P: vec3<f32>) -> u32 {
|
||||
let anchor = mcnk_cell_anchor(P);
|
||||
let count = u32(wow_light.point_count.x);
|
||||
var sel = array<u32, 3>(0u, 0u, 0u);
|
||||
@@ -354,18 +499,12 @@ fn wmo_exterior_point_sum(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
|
||||
sd[2] = d2; sel[2] = i;
|
||||
}
|
||||
}
|
||||
var sum = vec3<f32>(0.0);
|
||||
for (var s = 0u; s < 3u; s = s + 1u) {
|
||||
if (sd[s] > 9.9e29) {
|
||||
break;
|
||||
}
|
||||
let to_light = wow_light.points[2u * sel[s]].xyz - P;
|
||||
let d = length(to_light);
|
||||
let atten = 1.0 / (0.7 * d + 0.03 * d * d);
|
||||
let nl = max(dot(N, to_light / max(d, 1e-4)), 0.0);
|
||||
sum += wow_light.points[2u * sel[s] + 1u].rgb * (atten * nl);
|
||||
}
|
||||
return sum;
|
||||
return select(EXT_SEL_EMPTY, sel[0], sd[0] <= 9.9e29)
|
||||
| (select(EXT_SEL_EMPTY, sel[1], sd[1] <= 9.9e29) << 10u)
|
||||
| (select(EXT_SEL_EMPTY, sel[2], sd[2] <= 9.9e29) << 20u);
|
||||
}
|
||||
fn wmo_exterior_point_sum(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
|
||||
return point_light_eval(wmo_exterior_pick(P), P, N);
|
||||
}
|
||||
|
||||
// MONKEY (dynamic interiors): the per-FRAGMENT room light for interior WMO surfaces AND interior
|
||||
@@ -635,8 +774,20 @@ fn interior_room_light(
|
||||
// term without touching torch B's — the occlusion that makes an interior read
|
||||
// lit-and-shadowed instead of flat. (`static_gx.wgsl` only; the wow_model copy of this
|
||||
// function keeps the 1.0 `torch_shadow_for` stub — it has no group 3.)
|
||||
let s = torch_surface_shadow(pos_range.xyz, P);
|
||||
direct += c_norm * (atten * nl * window) * s * w;
|
||||
// MONKEY (torch lane perf): the shadow sample is MULTIPLIED into the direct term, so
|
||||
// where that term is already nothing the table scan and its four comparison taps buy
|
||||
// nothing. And it very often is: `window` is exactly 0 past the fixture's reach, `nl` is 0
|
||||
// on a surface facing away even under the wrap, and `w` is the claim weight a portal fade
|
||||
// has taken to 0. The scan is by POSITION over up to sixteen slots, so this is the
|
||||
// difference between "every interior fixture in range pays a scan on every fragment it
|
||||
// touches" and "only the ones actually lighting it do". A branch rather than a `select`
|
||||
// deliberately: the whole point is to NOT execute the taps.
|
||||
let direct_w = atten * nl * window;
|
||||
var s = 1.0;
|
||||
if (direct_w * w > TORCH_SKIP_EPS) {
|
||||
s = torch_surface_shadow(pos_range.xyz, P);
|
||||
}
|
||||
direct += c_norm * direct_w * s * w;
|
||||
// MONKEY (soft falloff): the fill's profile is UNCHANGED in FORM — `(1 − d/r)²`, which is
|
||||
// exactly `interior_window(d, r, 1.0)` — and only its radius moved, from R to
|
||||
// `INTERIOR_FILL_SPAN·R`. That is the whole "gentle wash between the pools": at the fixture
|
||||
@@ -674,6 +825,12 @@ struct GxVsOut {
|
||||
@location(3) @interpolate(flat) word: u32,
|
||||
@location(4) point_lit: vec3<f32>,
|
||||
@location(5) color: vec4<f32>,
|
||||
// MONKEY (outdoor torch shadows): WHICH ≤3 exterior table entries `point_lit` was summed from,
|
||||
// packed 10 bits each (see `EXT_SEL_NONE`). FLAT, because it is a choice, not a quantity —
|
||||
// interpolating three packed indices would produce a fourth, meaningless one. `EXT_SEL_NONE` on
|
||||
// every lane that takes no exterior point term (interior WMO surfaces, interior props, the
|
||||
// collapsed exile vertex), which makes the fragment lane a no-op there by construction.
|
||||
@location(6) @interpolate(flat) ext_sel: u32,
|
||||
}
|
||||
|
||||
@vertex
|
||||
@@ -691,6 +848,7 @@ fn vertex(v: GxVertex) -> GxVsOut {
|
||||
out.word = v.word;
|
||||
out.point_lit = vec3<f32>(0.0);
|
||||
out.color = vec4<f32>(1.0);
|
||||
out.ext_sel = EXT_SEL_NONE;
|
||||
return out;
|
||||
}
|
||||
// Camera-relative clip (0974/wow_model.wgsl): both addends are small — the recentred
|
||||
@@ -725,6 +883,7 @@ fn vertex(v: GxVertex) -> GxVsOut {
|
||||
// stage (`interior_room_light`) — a per-vertex sum on a WMO's huge floor triangles is
|
||||
// Gouraud: straight-edged wedges, and a torch mid-triangle lights nothing.
|
||||
out.point_lit = vec3<f32>(0.0);
|
||||
out.ext_sel = EXT_SEL_NONE;
|
||||
} else if ((v.word & WORD_WMO) != 0u) {
|
||||
// MONKEY (wmo exterior points): an EXTERIOR-class group (MOGP `& 0x48`) is a street, a
|
||||
// courtyard, a porch — drawn by the exterior law, so it takes the exterior point term the
|
||||
@@ -732,9 +891,16 @@ fn vertex(v: GxVertex) -> GxVsOut {
|
||||
// Stormwind), so `wmo_exterior_point_sum` re-anchors on the MCNK cell; see its comment.
|
||||
// Per-vertex like terrain's, not per-fragment: WMO surfaces are MOCV-baked per vertex, so
|
||||
// they carry the tessellation a Gouraud term needs, and this stays one bounded walk.
|
||||
out.point_lit = wmo_exterior_point_sum(world, v.normal);
|
||||
// MONKEY (outdoor torch shadows): the pick is published so the fragment stage can shadow
|
||||
// these same three entries at night without re-ranking (see `EXT_SEL_NONE`). One table walk
|
||||
// still, not two — the sum was always `eval(pick(...))`, it is just no longer inlined.
|
||||
let sel = wmo_exterior_pick(world);
|
||||
out.ext_sel = sel;
|
||||
out.point_lit = point_light_eval(sel, world, v.normal);
|
||||
} else {
|
||||
out.point_lit = point_light_sum(world, v.normal, v.anchor);
|
||||
let sel = point_light_pick(v.anchor);
|
||||
out.ext_sel = sel;
|
||||
out.point_lit = point_light_eval(sel, world, v.normal);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -794,6 +960,30 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
// entity path removes (the first capture A/B lit every back-facing canopy card from the
|
||||
// wrong side — the felwood heatmap's bright clusters).
|
||||
let n_lit = wow_normalize(in.world_normal);
|
||||
// MONKEY (outdoor torch shadows): the EXTERIOR point term, cast-shadowed at night.
|
||||
//
|
||||
// `in.point_lit` is the Gouraud (per-vertex) exterior sum, and it stays the ONLY thing this
|
||||
// lane reads by day. After dark the same three entries are re-evaluated PER FRAGMENT with each
|
||||
// one's own cube-map occlusion folded in (`point_light_eval_shadowed`), and the two are blended
|
||||
// by `night_w = 1 − sun_shadow_strength`. Two things fall out of writing it as a blend rather
|
||||
// than a swap:
|
||||
// · `fog_params.z` is EXACTLY 1.0 whenever the sun is above the daylight threshold
|
||||
// (`global_light::sun_shadow_strength`, a smoothstep that saturates), so `night_w` is
|
||||
// exactly 0 and the `if` is not entered at all — daylight is the same instructions and the
|
||||
// same bits it was, not "a mix that ought to round back".
|
||||
// · at dusk the shadow arrives on the same clock the sun shadows leave on, and the
|
||||
// Gouraud→per-fragment change of the term itself arrives with it instead of snapping.
|
||||
// `torch_ext_on()` is the CPU's one-bit verdict (`exteriorShadows` && night && ≥1 promoted
|
||||
// exterior fixture), so with the cvar off the branch is dead too.
|
||||
var point_lit = in.point_lit;
|
||||
let ext_night_w = select(0.0, clamp(1.0 - wow_light.fog_params.z, 0.0, 1.0), torch_ext_on());
|
||||
if (ext_night_w > 0.0) {
|
||||
point_lit = mix(
|
||||
in.point_lit,
|
||||
point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit),
|
||||
ext_night_w,
|
||||
);
|
||||
}
|
||||
let L = -normalize(wow_light.light_sun.xyz);
|
||||
let ndotl = max(dot(n_lit, L), 0.0);
|
||||
let lit_nl = clamp(
|
||||
@@ -905,6 +1095,40 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
}
|
||||
let sidn_e = sidn_rgb * (wow_light.grade.x * sidn_w);
|
||||
// MONKEY (ext-class night law): plenty of geometry INSIDE a building is authored
|
||||
// EXTERIOR-class — the Goldshire inn's whole 57.7 yd shell is one group (`upstairs`,
|
||||
// MOGP 0x0a09, and it holds the stair down to the cellar), its east stairwell annex is
|
||||
// another — and the exterior law is the DAY/NIGHT SKY law: after dark that is `light_ambient`
|
||||
// + `light_diffuse`, which the night sky makes GREEN. A fragment of one of those groups
|
||||
// therefore renders sky-green one plank away from a candle-lit interior group, which is the
|
||||
// reported hard break at the cellar stair and (with the shell's outer face) on the floors
|
||||
// above it. The claim-gated `ext_room` term below already reaches those surfaces, but it
|
||||
// ADDS to the sky rather than replacing it, so the green survived underneath the candles.
|
||||
//
|
||||
// The fix is a per-batch LAW BLEND rather than another additive term: a building-scale
|
||||
// exterior group is one the room gate is willing to light (the same
|
||||
// `ext_building_scale`/`CLAIM_EXT_OK` eligibility, so no group can land between the two
|
||||
// rules), so after dark it should be lit the way the room next to it is — the interior
|
||||
// lane's ambient floor, its claim-gated fixtures, its exposure rolloff, and NO sky term.
|
||||
// By day nothing moves: `night_w` is 0 whenever the sun is up, so the authored sunlit look
|
||||
// of every porch, courtyard and city street is bit-for-bit what it was.
|
||||
//
|
||||
// `fog_params.z` is `sun_shadow_strength(celestial_dir.y)` — 1 in daylight, ramping to 0
|
||||
// as the sun reaches the horizon (see `global_light.rs`), i.e. exactly the "how much of the
|
||||
// sky law is real right now" number the sun shadows already fade on. Reusing it keeps the
|
||||
// law swap on the same dusk/dawn clock as everything else that fades with the sun.
|
||||
let interiors_on = wow_light.wmo_fog_params.w > 0.5;
|
||||
let ext_night = !interior && interiors_on && (rec.w & RECORD_EXT_NIGHT) != 0u;
|
||||
// MONKEY (ext-class night law, REVERTED for GROUPS 2026-09-09): swapping a whole
|
||||
// exterior-class SHELL group onto the room law after dark painted every building FACADE
|
||||
// black at dusk — the inn's and the smithy's outer walls are that shell, no candle claims
|
||||
// them, and `interiorAmbient` (0.02) is the whole of what the room law had for them while
|
||||
// the ground beside them was still sunset-lit (user screenshots, 20:40 server time). The
|
||||
// shell keeps the reference sky law day and night; its claimed fixtures still arrive
|
||||
// through the strict `ext_room` term below. The BATCH-level half of the fix (an EXT-class
|
||||
// batch inside an INTERIOR group — the cellar stair) lives in `day_w` and is untouched.
|
||||
// `ext_night` itself is kept for the `interiorDebug 4` overlay only.
|
||||
let night_w = 0.0;
|
||||
// MONKEY (portal claims): the EXTERIOR-class group's own room light. Bug B's second
|
||||
// cause: plenty of groups INSIDE a building are authored EXTERIOR-class (MOGP `& 0x48`) —
|
||||
// the Goldshire inn's whole shell is one, and its basement stairwell reads night-sky GREEN
|
||||
@@ -938,7 +1162,7 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
// the whole skyline.
|
||||
var ext_room = vec3<f32>(0.0);
|
||||
let ext_room_d = distance(in.world_position.xyz, view.world_position.xyz);
|
||||
if (!interior && wow_light.wmo_fog_params.w > 0.5 && ext_room_d < EXT_ROOM_FADE_END) {
|
||||
if (!interior && interiors_on && ext_room_d < EXT_ROOM_FADE_END) {
|
||||
let lit = interior_room_light(
|
||||
in.world_position.xyz,
|
||||
n_lit,
|
||||
@@ -947,7 +1171,16 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
true, // fail CLOSED: an unclaimed exterior surface renders exactly as before
|
||||
);
|
||||
let far = 1.0 - smoothstep(EXT_ROOM_FADE_START, EXT_ROOM_FADE_END, ext_room_d);
|
||||
ext_room = max(lit - vec3<f32>(wow_light.point_count.y), vec3<f32>(0.0)) * far;
|
||||
// MONKEY (ext-class night law): the ambient floor is subtracted by `1 − night_w`, not
|
||||
// flat. The floor is removed here because an exterior surface has the SKY for its
|
||||
// floor — but that premise is exactly what `night_w` retires, so the two must move
|
||||
// together or the dusk cross-fade dips (the sky half already faded out, the interior
|
||||
// half's floor still subtracted away). At `night_w = 1` this term is weighted to zero
|
||||
// by the blend below anyway; the scaling is what keeps the ramp between monotone.
|
||||
ext_room = max(
|
||||
lit - vec3<f32>(wow_light.point_count.y * (1.0 - night_w)),
|
||||
vec3<f32>(0.0),
|
||||
) * far;
|
||||
}
|
||||
// GL_COLOR_MATERIAL: MOCV multiplies the lit terms INSIDE the clamp, emission adds
|
||||
// beside. MONKEY (wmo exterior points): `in.point_lit` is zero on INTERIOR groups (the
|
||||
@@ -955,7 +1188,7 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
// exterior nearest-3 point term on EXTERIOR-class ones, which is what makes a street
|
||||
// torch reach the cobbles. The clamp is why it costs nothing in daylight.
|
||||
let primary = clamp(
|
||||
vc.rgb * (lit_wmo + in.point_lit + ext_room) + sidn_e,
|
||||
vc.rgb * (lit_wmo + point_lit + ext_room) + sidn_e,
|
||||
vec3<f32>(0.0),
|
||||
vec3<f32>(1.0),
|
||||
);
|
||||
@@ -964,7 +1197,14 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
// break the pixel-identity bar.
|
||||
let tex_rgb = folded / max(vc.rgb, vec3<f32>(1.0 / 255.0));
|
||||
rgb = tex_rgb * primary;
|
||||
if (interior && wow_light.wmo_fog_params.w > 0.5) {
|
||||
// MONKEY (ext-class night law): `night_w > 0` is a HARD gate, not a redundant one. With the
|
||||
// sun up the blend below would be `mix(interior_result, rgb, 1.0)` = `a + (b − a)`, which
|
||||
// in floats does not always round back to `b` — the ±1/255 film this renderer has chased
|
||||
// before, and it would land on every sunlit street, wall and courtyard in the world, which
|
||||
// is most of a city's screen. Skipping the branch outright makes DAYLIGHT bit-identical to
|
||||
// the reference path, and costs nothing but the per-fragment point walk we also do not want
|
||||
// to pay in daylight. Interior groups are unaffected: they enter on `interior` as before.
|
||||
if (interior && interiors_on) {
|
||||
// MONKEY (dynamic interiors #A, `interiorLight` on): the WHOLE interior group — INT, TRANS and EXT batch
|
||||
// classes alike — lights from the LIVE room torches instead of the MOCV bake. Gating
|
||||
// this on `class_int` left every TRANS/EXT batch (most of a floor near a doorway) on
|
||||
@@ -990,7 +1230,14 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
n_lit,
|
||||
gx_room_inst(),
|
||||
gx_room_group(in.word),
|
||||
false, // the interior lane's gate fails OPEN (see `interior_room_admits`)
|
||||
// The interior lane's gate fails OPEN (see `interior_room_admits`).
|
||||
// MONKEY (ext-class night law): the EXTERIOR-class lane keeps the STRICT gate it
|
||||
// has always used here. Failing open on this lane would hand every un-keyed
|
||||
// exterior group in the frame the whole fixture table the moment the sun set — a
|
||||
// barn lit by a city's candles. Failing closed leaves an unclaimed one at exactly
|
||||
// the ambient floor, which is what an unlit interior nook reads as, and is the
|
||||
// "no sky" half of the fix on its own.
|
||||
ext_night,
|
||||
);
|
||||
var day_w = 0.0;
|
||||
if (class_trans) {
|
||||
@@ -998,7 +1245,32 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
} else if (!class_int) {
|
||||
day_w = 1.0;
|
||||
}
|
||||
let illum = vec3<f32>(1.0) - exp(-(room + sidn_e) * wow_light.point_count.w);
|
||||
// MONKEY (ext-class night law): …and the authored weight is scaled by the SUN. This is
|
||||
// the second half of the same bug, one lane over, and it is what actually paints the
|
||||
// Lion's Pride Inn's cellar stair sky-green: the stair is NOT an exterior group (its
|
||||
// 700 vertices are group 10, `room03`, MOGP 0x2805 — interior), but its MOBA batches
|
||||
// are EXT-CLASS, and an EXT-class batch of an interior group took `day_w = 1`
|
||||
// unconditionally, i.e. it rendered on the reference path with `lit_int_base` = the
|
||||
// sun/sky ambient+diffuse. That is the right law at noon (a doorway takes the sun) and
|
||||
// it is the NIGHT SKY after dark — green, next to the INT-class wall beside it on the
|
||||
// candle lane, with the break exactly where the batch class changes.
|
||||
//
|
||||
// The weight's own rationale is what fixes it: it exists so the interior side of a
|
||||
// threshold matches the EXTERIOR group a step away. That group is now on the room law
|
||||
// after dark too (`ext_night` above), so matching it at night means the room law here
|
||||
// as well — the same argument, evaluated at the right time of day. `fog_params.z` is
|
||||
// exactly 1.0 while the sun is up, so `day_w` and therefore every daylight fragment of
|
||||
// every interior group is bit-for-bit unchanged.
|
||||
let sun_w = clamp(wow_light.fog_params.z, 0.0, 1.0);
|
||||
day_w = day_w * sun_w;
|
||||
// MONKEY (ext-class night law): `in.point_lit` joins the rolloff. It is a HARD ZERO on
|
||||
// every interior batch (the vertex stage zeroes it for `WORD_INTERIOR`), so this is an
|
||||
// exact no-op for the lane that already ran here — and on an ext-class batch it is the
|
||||
// exterior point term, i.e. the street torch hanging on the wall being lit. Without it
|
||||
// the blend would take that torch away from every building facade the moment it swapped
|
||||
// laws, which is a new seam where the wall meets the lit cobbles.
|
||||
let illum = vec3<f32>(1.0)
|
||||
- exp(-(room + point_lit + sidn_e) * wow_light.point_count.w);
|
||||
// BLEND toward the reference's own lit result (`rgb` above — MOCV × the exterior law)
|
||||
// by the authored batch-class weight; never ADD daylight on top of the room light,
|
||||
// which left the interior side of every threshold brighter than the exterior floor a
|
||||
@@ -1013,6 +1285,41 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
rgb = shadow_hook::interior_debug_override(idbg, rgb, in.world_position, n_lit);
|
||||
}
|
||||
}
|
||||
// MONKEY (ext-class night law): `interiorDebug 4` — the LANE MAP. Every WMO fragment is
|
||||
// painted by which lighting law its GROUP falls under, and nothing else is touched
|
||||
// (terrain, M2 props and entities keep their real shading), so one screenshot of any
|
||||
// building answers "which of these three populations is this surface in?" — the question
|
||||
// every seam in this system reduces to, and the one the offline `wmolights` group table can
|
||||
// only answer by bbox arithmetic.
|
||||
// GREEN family interior-class group (MOGP & 0x48 == 0) — always on the room lane, shaded
|
||||
// by MOBA BATCH class because that is its own seam: pure green = INT
|
||||
// (never took the sky), yellow-green = TRANS (the MOCV-alpha lerp),
|
||||
// MINT = an EXT-class batch of an interior group — the population that
|
||||
// used to render by the night SKY inside a lit room (the cellar stair).
|
||||
// BLUE exterior-class at BUILDING scale — the sky lane by day, the room lane
|
||||
// after dark (this fix).
|
||||
// RED exterior-class shell/district — the sky lane, always; the room gate
|
||||
// refuses its claims (`LIT_ROOM_EXT_DENY`).
|
||||
// A seam between two like-coloured fragments is a CLAIM problem (which fixtures each side
|
||||
// admits); a seam between two different colours is a LAW problem. Read off the record bit
|
||||
// rather than `ext_night`, so the map is the same with the sun up and with `interiorLight`
|
||||
// toggling only whether the overlay runs at all.
|
||||
let lane_dbg = u32(max(wow_light.wmo_fog_params.w - 1.0, 0.0) + 0.5);
|
||||
if (lane_dbg == 4u) {
|
||||
if (interior) {
|
||||
if (class_int) {
|
||||
rgb = vec3<f32>(0.0, 1.0, 0.0);
|
||||
} else if (class_trans) {
|
||||
rgb = vec3<f32>(0.55, 1.0, 0.0);
|
||||
} else {
|
||||
rgb = vec3<f32>(0.0, 1.0, 0.75);
|
||||
}
|
||||
} else if ((rec.w & RECORD_EXT_NIGHT) != 0u) {
|
||||
rgb = vec3<f32>(0.1, 0.35, 1.0);
|
||||
} else {
|
||||
rgb = vec3<f32>(1.0, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
} else if ((in.word & WORD_INTERIOR) != 0u) {
|
||||
// ---- the interior M2-PROP lane (B4, decision 1433) — wow_model.wgsl's
|
||||
// interior-prop branch verbatim: the folded per-instance SH probe (MODD ambient +
|
||||
@@ -1039,7 +1346,7 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
vec3<f32>(0.0),
|
||||
vec3<f32>(1.0),
|
||||
);
|
||||
let primary = clamp(lit_prop + in.point_lit, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
let primary = clamp(lit_prop + point_lit, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
rgb = folded * primary;
|
||||
// MONKEY (dynamic interiors #A): interior props take the SAME live room light as the
|
||||
// surfaces around them (`interior_room_light`) instead of the baked probe — on the probe a
|
||||
@@ -1116,7 +1423,7 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
// FFP combine: the light sum saturates FIRST, the texture (× the baked constant
|
||||
// tint, when authored) modulates the clamped result. Statics: inst_tint identity,
|
||||
// no highlight, no SIDN.
|
||||
let primary = clamp(lit + in.point_lit, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
let primary = clamp(lit + point_lit, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
rgb = folded * primary;
|
||||
}
|
||||
// Unlit fullbright (M2 UNLIT 0x01 / WMO UNLIT on an exterior-group batch): the albedo —
|
||||
|
||||
@@ -296,6 +296,7 @@ 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),
|
||||
});
|
||||
}
|
||||
// Recentre for clip-space precision (0974): the shader reconstructs world = v + origin.
|
||||
@@ -534,6 +535,7 @@ mod tests {
|
||||
sidn: Some([10, 20, 30]),
|
||||
window: true,
|
||||
batch_order: group + 1,
|
||||
ext_night: 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);
|
||||
|
||||
@@ -401,6 +401,10 @@ 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,
|
||||
/// 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,
|
||||
@@ -540,7 +544,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
|
||||
@@ -561,6 +571,11 @@ 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,
|
||||
/// The MOBA batch class (INT/TRANS/EXT) — the lighting-lane selector on interior groups.
|
||||
pub class: Option<WmoBatchClass>,
|
||||
/// The MOMT SIDN night-glow colour.
|
||||
@@ -683,6 +698,7 @@ impl StaticGx {
|
||||
let wmo = b.wmo.map(|w| GxItemWmo {
|
||||
group: w.group,
|
||||
interior: w.interior,
|
||||
ext_night: w.ext_night,
|
||||
class_lane: match (w.interior, w.class) {
|
||||
(true, Some(WmoBatchClass::Int)) => 1,
|
||||
(true, Some(WmoBatchClass::Trans)) => 2,
|
||||
|
||||
@@ -71,6 +71,10 @@ 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,
|
||||
}
|
||||
|
||||
/// One baked cell (or WMO region), published by the main-world flush.
|
||||
@@ -177,6 +181,13 @@ const RECORD_FOG_BIT: u32 = 1 << 14;
|
||||
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;
|
||||
|
||||
/// 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.
|
||||
@@ -256,7 +267,12 @@ struct GxPipelines {
|
||||
/// 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(
|
||||
@@ -439,7 +455,12 @@ fn prepare_static_gx(
|
||||
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 {
|
||||
@@ -450,7 +471,7 @@ 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))
|
||||
@@ -462,10 +483,16 @@ fn prepare_static_gx(
|
||||
for cutout in [false, true] {
|
||||
for two_sided in [false, true] {
|
||||
let mut defs = vec![];
|
||||
// The world camera uses Bevy's default Gaussian shadow filtering. The retained
|
||||
// pipeline is custom, so it must opt into the same shader branch explicitly;
|
||||
// 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.
|
||||
defs.push(ShaderDefVal::from("SHADOW_FILTER_METHOD_GAUSSIAN"));
|
||||
// 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"));
|
||||
@@ -726,7 +753,13 @@ fn assemble_region(
|
||||
| (u32::from(item.sidn[2]) << 16),
|
||||
kill_bit(&draw.killed, i)
|
||||
| (u32::from(item.slot) << 1)
|
||||
| if gated { room_key(item, &draw.sets) } else { 0 },
|
||||
| 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 },
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
@@ -860,11 +893,32 @@ 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(
|
||||
@@ -898,8 +952,18 @@ fn prepare_view_bind(
|
||||
/// 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(BindGroup);
|
||||
struct GxTorchBind {
|
||||
group: BindGroup,
|
||||
table: super::torch_depth::TorchTableUniform,
|
||||
texture: TextureId,
|
||||
}
|
||||
|
||||
fn prepare_torch_bind(
|
||||
mut commands: Commands,
|
||||
@@ -910,6 +974,7 @@ fn prepare_torch_bind(
|
||||
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.
|
||||
@@ -920,25 +985,62 @@ fn prepare_torch_bind(
|
||||
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),
|
||||
contents: bytemuck::bytes_of(table),
|
||||
usage: BufferUsages::UNIFORM,
|
||||
});
|
||||
let layout = pipeline_cache.get_bind_group_layout(&pipes.torch_layout);
|
||||
commands.insert_resource(GxTorchBind(render_device.create_bind_group(
|
||||
render_device.create_bind_group(
|
||||
"static_gx_torch",
|
||||
&layout,
|
||||
&BindGroupEntries::sequential((
|
||||
// The image's default view: D2Array over all 192 layers — the 96 STATIC faces the
|
||||
// table's matrices address plus the 96 LIVE copies (MONKEY, static torch cache); the
|
||||
// shader picks the bank per slot from `count.z`. (wgpu's default view for a
|
||||
// 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,
|
||||
targets.sampler(),
|
||||
sampler,
|
||||
buffer.as_entire_binding(),
|
||||
)),
|
||||
)));
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
|
||||
@@ -1095,7 +1197,7 @@ 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.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),
|
||||
@@ -1177,6 +1279,7 @@ mod tests {
|
||||
order: 0,
|
||||
sidn: [0; 3],
|
||||
slot: 0,
|
||||
ext_night: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,20 +52,23 @@ use bytemuck::Zeroable;
|
||||
use super::render::StaticGxView;
|
||||
use benilla_assets::materials::TorchBinds;
|
||||
|
||||
/// MONKEY (static torch cache): each 512-square Depth32 face is 1 MiB. Two banks support
|
||||
/// sixteen resident static cubes and up to sixteen live entity overlays: 192 MiB total. Keep
|
||||
/// the edge here so a memory-constrained port can choose 384 (108 MiB, 25% less linear detail).
|
||||
/// MONKEY (live bank rank): each 512-square Depth32 face is 1 MiB. Sixteen static cubes plus
|
||||
/// eight compact live overlays cost 144 MiB; 384-square faces would cost 81 MiB.
|
||||
const TORCH_MAP_EDGE: u32 = 512;
|
||||
pub(crate) const MAX_TORCH_MAPS: usize = 16;
|
||||
const MAX_TORCH_DYNAMIC: usize = 8;
|
||||
pub(crate) const CUBE_FACES: usize = 6;
|
||||
/// MONKEY (static torch cache): matrices address the 96 STATIC faces. Physical texture layers
|
||||
/// 0..96 hold those faces permanently; 96..192 are live copies used only by dynamic-mask bits.
|
||||
/// MONKEY (live bank rank): matrix count stays 96, independent of texture capacity. Static
|
||||
/// layers 0..96 stay slot-addressed; live layers 96..144 follow ascending dynamic-mask rank.
|
||||
pub(crate) const MAX_TORCH_LAYERS: usize = MAX_TORCH_MAPS * CUBE_FACES;
|
||||
const TORCH_TEXTURE_LAYERS: usize = MAX_TORCH_LAYERS * 2;
|
||||
const TORCH_TEXTURE_LAYERS: usize = MAX_TORCH_LAYERS + MAX_TORCH_DYNAMIC * CUBE_FACES;
|
||||
/// MONKEY (static torch cache): count@0 (16), positions@16 (256), view_projs@272 (6144).
|
||||
/// Total 6416 bytes, identical under WGSL uniform/storage alignment. count.z is the live-bank
|
||||
/// bit mask, count.w reserved. Both WGSL TorchTable copies and render.rs MUST agree with this.
|
||||
pub(crate) const TORCH_TABLE_BYTES: u64 = 6416;
|
||||
/// MONKEY (outdoor torch shadows): `count.w` bit 0 — the exterior receiver lane's live gate. Keep
|
||||
/// in sync with `TORCH_EXT_LANE` in BOTH `static_gx.wgsl` and `wow_model.wgsl`.
|
||||
const TORCH_EXT_LANE: u32 = 1;
|
||||
|
||||
/// **The app→render publication** (`benilla_app::torch_shadow` writes it each frame, extracted here):
|
||||
/// the promoted fixtures' cube-face matrices, world positions and static mesh revisions.
|
||||
@@ -81,6 +84,13 @@ pub struct TorchShadowViews {
|
||||
/// 0.5..3). Carried here rather than in `DynamicInteriors` because it belongs to the shadow
|
||||
/// table's own bytes — [`TorchTableUniform::pack`] puts it in the otherwise-padding `count.y`.
|
||||
pub soft: f32,
|
||||
/// MONKEY (outdoor torch shadows): does the EXTERIOR receiver lane run this frame? The app lane
|
||||
/// sets it when `exteriorShadows` is on, the sun is below the daylight threshold, AND at least
|
||||
/// one promoted slot is an exterior fixture. It rides to the receivers in the table's
|
||||
/// `count.w` bit 0 (the word that has been reserved padding since the table was written), and
|
||||
/// it is the ONE uniform read every exterior-lane cost in `static_gx.wgsl`/`wow_model.wgsl`
|
||||
/// hangs off — by day, or with the cvar off, the receivers are the code they always were.
|
||||
pub exterior: bool,
|
||||
/// `positions[i].xyz` = fixture world position (absolute Bevy), `.w` = the slot's FADE WEIGHT
|
||||
/// `0..1` (MONKEY, torch caster selection). The `.w` lane used to carry the fixture's range,
|
||||
/// which no shader ever read; it now carries the cross-fade, and both receivers return
|
||||
@@ -98,6 +108,18 @@ pub struct TorchShadowViews {
|
||||
pub entity_mesh: Option<AssetId<Mesh>>,
|
||||
}
|
||||
|
||||
impl TorchShadowViews {
|
||||
/// MONKEY (live bank rank): expose the allocation cap through the existing app resource.
|
||||
pub const MAX_TORCH_DYNAMIC: usize = MAX_TORCH_DYNAMIC;
|
||||
|
||||
/// MONKEY (live bank rank): WGSL countOneBits(mask & ((1u << slot) - 1u)). Use the
|
||||
/// FINAL ready-filtered mask for rendering; app traces describe only requested ranks.
|
||||
pub fn live_rank(dynamic_mask: u32, slot: usize) -> usize {
|
||||
debug_assert!(slot < MAX_TORCH_MAPS);
|
||||
(dynamic_mask & ((1u32 << slot) - 1)).count_ones() as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand-written because `[Mat4; 96]` has no `Default` — std only derives array `Default` up to
|
||||
/// `N = 32`, so the derive that worked at 24 layers stops compiling at 96. (It also lets `soft`
|
||||
/// default to a sane `1.0` rather than a zero tap radius.)
|
||||
@@ -108,6 +130,7 @@ impl Default for TorchShadowViews {
|
||||
dynamic_mask: 0,
|
||||
ready_mask: 0,
|
||||
soft: 1.0,
|
||||
exterior: false,
|
||||
positions: [Vec4::ZERO; MAX_TORCH_MAPS],
|
||||
view_projs: [Mat4::ZERO; MAX_TORCH_LAYERS],
|
||||
caster_meshes: [None; MAX_TORCH_MAPS],
|
||||
@@ -117,9 +140,9 @@ impl Default for TorchShadowViews {
|
||||
}
|
||||
|
||||
/// MONKEY (torch shadows Phase 3A): the ONE shared torch depth array — a `Depth32Float`
|
||||
/// MONKEY (static torch cache): 512×512×192-layer `Image` asset (render, sample, copy-src/dst),
|
||||
/// MONKEY (live bank rank): 512×512×144-layer `Image` asset (render, sample, copy-src/dst),
|
||||
/// no CPU data) whose sampler descriptor carries `compare: Some(GreaterEqual)`, so its `GpuImage`
|
||||
/// sampler is a real comparison sampler and its default view is a `D2Array` over all 192 layers
|
||||
/// sampler is a real comparison sampler and its default view is a `D2Array` over all 144 layers
|
||||
/// (wgpu's default view dimension for a multi-layer 2D texture). Created ONCE at startup by
|
||||
/// [`new_torch_shared`] (always — regardless of the interior-shadow cvars — because every model
|
||||
/// material binds it; a missing image would stall every material's bind group), inserted in the
|
||||
@@ -212,7 +235,7 @@ pub fn new_torch_shared(
|
||||
}
|
||||
|
||||
/// MONKEY (static torch cache): byte-identical std140/std430 table in BOTH receiver shaders.
|
||||
/// count@0: live high-water mark, soft*100, dynamic mask, reserved (16 bytes).
|
||||
/// count@0: live high-water mark, soft*100, dynamic mask, lane flags (16 bytes).
|
||||
/// positions[16]@16: world xyz + fade weight (256 bytes).
|
||||
/// view_projs[96]@272: six static-bank face matrices per physical slot (6144 bytes).
|
||||
/// Total 6416. Unready/hole slots have zero weight; no stale owner's depth can be sampled.
|
||||
@@ -225,7 +248,11 @@ pub(crate) struct TorchTableUniform {
|
||||
/// alternative was a whole new 16-byte row for one dial.
|
||||
soft_x100: u32,
|
||||
dynamic_mask: u32,
|
||||
_pad: u32,
|
||||
/// MONKEY (outdoor torch shadows): lane flags, the word that was reserved padding.
|
||||
/// Bit 0 ([`TORCH_EXT_LANE`]) = the EXTERIOR receiver lane is live this frame. Both WGSL
|
||||
/// copies read it as `count.w`; the table's SIZE is untouched, which is what keeps the
|
||||
/// 6416-byte contract (and therefore every building and model on screen) intact.
|
||||
flags: u32,
|
||||
positions: [[f32; 4]; MAX_TORCH_MAPS],
|
||||
view_projs: [[[f32; 4]; 4]; MAX_TORCH_LAYERS],
|
||||
}
|
||||
@@ -247,6 +274,9 @@ impl TorchTableUniform {
|
||||
// a hard edge), so a table published without a scale reads as the neutral 1.0.
|
||||
let soft = if views.soft > 0.01 { views.soft } else { 1.0 };
|
||||
table.soft_x100 = (soft * 100.0).round().max(1.0) as u32;
|
||||
// MONKEY (outdoor torch shadows): the exterior receiver lane's live gate. Packed even when
|
||||
// `count` is 0 costs nothing and is simpler to reason about than a conditional flag.
|
||||
table.flags = if views.exterior { TORCH_EXT_LANE } else { 0 };
|
||||
for i in 0..count {
|
||||
table.positions[i] = views.positions[i].to_array();
|
||||
if views.ready_mask & (1 << i) == 0 { table.positions[i][3] = 0.0; }
|
||||
@@ -270,12 +300,60 @@ fn upload_torch_table(
|
||||
queue: Res<RenderQueue>,
|
||||
buffer: Option<Res<SharedTorchBuffer>>,
|
||||
views: Option<Res<TorchShadowViews>>,
|
||||
// MONKEY (torch lane perf): the last bytes actually uploaded, keyed by the buffer they went to
|
||||
// (so a recreated buffer can never inherit another one's "already current" verdict), plus the
|
||||
// trace counters for the second in progress.
|
||||
mut last: Local<Option<(BufferId, TorchTableUniform)>>,
|
||||
mut trace: Local<TorchUploadTrace>,
|
||||
) {
|
||||
let Some(buffer) = buffer else {
|
||||
return;
|
||||
};
|
||||
let table = TorchTableUniform::pack(views.as_deref());
|
||||
queue.write_buffer(&buffer.0, 0, bytemuck::bytes_of(&table));
|
||||
// MONKEY (torch lane perf): skip the upload when the packed bytes are identical to the ones
|
||||
// already in the buffer. A standing player in a lit room republishes the SAME 6416 bytes every
|
||||
// frame — the fixtures have not moved, the cross-fades have saturated at 1 and the projections
|
||||
// are rebuilt from unchanged positions — so this is a staging-belt allocation and a copy with
|
||||
// nothing to say. A `memcmp` of 6 KB is far cheaper than the copy it replaces, and the compare
|
||||
// is against what we WROTE, so it can never disagree with the buffer's real contents.
|
||||
let id = buffer.0.id();
|
||||
let current = last.as_ref().is_some_and(|(b, t)| {
|
||||
*b == id && bytemuck::bytes_of(t) == bytemuck::bytes_of(&table)
|
||||
});
|
||||
if !current {
|
||||
queue.write_buffer(&buffer.0, 0, bytemuck::bytes_of(&table));
|
||||
*last = Some((id, table));
|
||||
trace.uploads += 1;
|
||||
}
|
||||
trace.frames += 1;
|
||||
trace.report();
|
||||
}
|
||||
|
||||
/// MONKEY (torch lane perf): `WOW_TORCH_TRACE` accounting for [`upload_torch_table`] — the render
|
||||
/// world's half of the lane's per-second perf line (the app's half is `torch-perf:` in
|
||||
/// `benilla_app::torch_shadow`). `uploads` well below `frames` is the dedup working.
|
||||
struct TorchUploadTrace {
|
||||
uploads: u32,
|
||||
frames: u32,
|
||||
since: std::time::Instant,
|
||||
}
|
||||
|
||||
impl Default for TorchUploadTrace {
|
||||
fn default() -> Self {
|
||||
Self { uploads: 0, frames: 0, since: std::time::Instant::now() }
|
||||
}
|
||||
}
|
||||
|
||||
impl TorchUploadTrace {
|
||||
fn report(&mut self) {
|
||||
if self.since.elapsed().as_secs_f32() < 1.0 {
|
||||
return;
|
||||
}
|
||||
if std::env::var_os("WOW_TORCH_TRACE").is_some() {
|
||||
info!("torch-perf: table uploads {}/{} frames", self.uploads, self.frames);
|
||||
}
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// The persistent render-world state that does NOT depend on the image: the comparison sampler
|
||||
@@ -302,7 +380,7 @@ impl TorchDepthTargets {
|
||||
#[derive(Resource, Default)]
|
||||
struct TorchLayerViews {
|
||||
texture: Option<TextureId>,
|
||||
/// `views[i]` = the depth attachment for array layer `i` (fixture `i / 6`, face `i % 6`).
|
||||
/// MONKEY (live bank rank): static slot `i / 6`; live rank `(i - 96) / 6`; face `i % 6`.
|
||||
views: Vec<TextureView>,
|
||||
}
|
||||
|
||||
@@ -457,9 +535,10 @@ fn prepare_torch_depth(
|
||||
return;
|
||||
}
|
||||
let cached = cache.0.lock().unwrap();
|
||||
let (ready_mask, rebuild_mask) = torch_cache_plan(&*cached, &views.caster_meshes[..count],
|
||||
let (ready_mask, rebuild_mask, dynamic_mask) = torch_cache_plan(&*cached, &views.caster_meshes[..count], views.dynamic_mask,
|
||||
|id| torch_mesh_ready(id, &meshes, &allocator));
|
||||
views.ready_mask = ready_mask;
|
||||
views.dynamic_mask = dynamic_mask;
|
||||
// If entity upload is late, bind the static bank for this frame, never last frame's overlay.
|
||||
if !views.entity_mesh.is_some_and(|id| torch_mesh_ready(id, &meshes, &allocator)) {
|
||||
views.dynamic_mask = 0;
|
||||
@@ -556,6 +635,7 @@ impl ViewNode for TorchDepthNode {
|
||||
let rebuild = draw.rebuild_mask & (1 << slot) != 0
|
||||
&& cached[slot] != views.caster_meshes[slot];
|
||||
let dynamic = views.dynamic_mask & (1 << slot) != 0;
|
||||
let live_rank = dynamic.then(|| TorchShadowViews::live_rank(views.dynamic_mask, slot));
|
||||
// MONKEY (static torch cache): render static once, then copy to the disjoint live
|
||||
// bank BEFORE each entity pass. GreaterEqual + Load preserves the nearest surface.
|
||||
for face in 0..CUBE_FACES {
|
||||
@@ -576,8 +656,10 @@ impl ViewNode for TorchDepthNode {
|
||||
pass.set_bind_group(0, &draw.bind_groups[layer], &[]);
|
||||
draw_torch_mesh(&mut pass, views.caster_meshes[slot], meshes, allocator);
|
||||
}
|
||||
if dynamic {
|
||||
let live = layer + MAX_TORCH_LAYERS;
|
||||
if let Some(rank) = live_rank {
|
||||
// MONKEY (live bank rank): matrices remain slot-addressed; only the copy
|
||||
// destination/overlay attachment follows count.z's compact ready-set rank.
|
||||
let live = MAX_TORCH_LAYERS + rank * CUBE_FACES + face;
|
||||
render_context.command_encoder().copy_texture_to_texture(
|
||||
TexelCopyTextureInfo {
|
||||
texture: &gpu.texture, mip_level: 0,
|
||||
@@ -609,9 +691,10 @@ impl ViewNode for TorchDepthNode {
|
||||
}
|
||||
if rebuild { cached[slot] = views.caster_meshes[slot]; }
|
||||
if trace_on && (trace_due || rebuild) {
|
||||
info!("torch-cache: slot {slot} static {} dynamic {}",
|
||||
info!("torch-cache: slot {slot} static {} dynamic {} live_rank {:?} live_base {:?}",
|
||||
if rebuild { "rebuilt" } else { "cached" },
|
||||
if dynamic { "yes" } else { "no" });
|
||||
if dynamic { "yes" } else { "no" }, live_rank,
|
||||
live_rank.map(|rank| MAX_TORCH_LAYERS + rank * CUBE_FACES));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -670,8 +753,8 @@ pub(super) fn build(app: &mut App) {
|
||||
|
||||
// MONKEY (static torch cache): planning does not commit residency. A missing upload cannot
|
||||
// certify an empty map, and the two-rebuild budget also covers GPU texture recreation.
|
||||
fn torch_cache_plan<T: Copy + Eq>(cached: &[Option<T>], requested: &[Option<T>],
|
||||
mut mesh_ready: impl FnMut(T) -> bool) -> (u32, u32) {
|
||||
fn torch_cache_plan<T: Copy + Eq>(cached: &[Option<T>], requested: &[Option<T>], dynamic_mask: u32,
|
||||
mut mesh_ready: impl FnMut(T) -> bool) -> (u32, u32, u32) {
|
||||
let (mut ready, mut rebuild) = (0u32, 0u32);
|
||||
for (i, requested) in requested.iter().take(MAX_TORCH_MAPS).enumerate() {
|
||||
let Some(id) = *requested else { continue };
|
||||
@@ -682,7 +765,15 @@ fn torch_cache_plan<T: Copy + Eq>(cached: &[Option<T>], requested: &[Option<T>],
|
||||
ready |= 1 << i;
|
||||
}
|
||||
}
|
||||
(ready, rebuild)
|
||||
// MONKEY (live bank rank): remove unready holes BEFORE ranking, exactly as table.count.z
|
||||
// does. Bound even malformed resource masks to eight cubes so no copy can escape the bank.
|
||||
let eligible = dynamic_mask & ready;
|
||||
let dynamic = (0..MAX_TORCH_MAPS).fold(0, |mask, slot| {
|
||||
if eligible & (1 << slot) != 0 && TorchShadowViews::live_rank(eligible, slot) < MAX_TORCH_DYNAMIC {
|
||||
mask | (1 << slot)
|
||||
} else { mask }
|
||||
});
|
||||
(ready, rebuild, dynamic)
|
||||
}
|
||||
|
||||
// MONKEY (static torch cache): an empty mesh is a valid all-clear cube. A nonempty mesh without
|
||||
@@ -714,11 +805,12 @@ impl super::StaticGx {
|
||||
/// MONKEY (static torch cache): fixture-local static collection uses batch bounds, not the
|
||||
/// building's placement origin. A distant Abbey room is still part of a WMO anchored more
|
||||
/// than 48 yards away. The key and the mesh MUST use this identical admission predicate.
|
||||
pub fn append_torch_triangles(&self, center: Vec3, reach: f32,
|
||||
pub fn append_torch_triangles(&self, center: Vec3, reach: f32, self_exclude: f32,
|
||||
positions: &mut Vec<[f32; 3]>, indices: &mut Vec<u32>) {
|
||||
for cell in self.cells.values().chain(self.wmos.values()).chain(self.props.values()) {
|
||||
for item in &cell.items {
|
||||
if !torch_item_in_range(item, center, reach) { continue; }
|
||||
if torch_item_is_emitter(item, center, self_exclude) { continue; }
|
||||
let base = positions.len() as u32;
|
||||
positions.extend(item.geometry.positions.iter().map(|p|
|
||||
item.transform.transform_point(benilla_assets::coords::wow_to_bevy(*p)).to_array()));
|
||||
@@ -730,15 +822,41 @@ impl super::StaticGx {
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (torch lane perf): a CHEAP stamp of "has the retained scene changed at all" —
|
||||
/// O(regions), where [`Self::torch_geometry_key`] is O(items) and is run per fixture.
|
||||
///
|
||||
/// It folds each region's own `last_change` frame together with its population, which between
|
||||
/// them cover every way the source set of ANY fixture can move: an item is only ever pushed
|
||||
/// (`push_item` stamps `last_change`) or dropped (`release_owner` stamps it and changes the
|
||||
/// count), and a whole region arriving or being culled changes the fold because the region set
|
||||
/// itself is folded. Order-independent, so `HashMap` iteration order is invisible. A baker's
|
||||
/// in-place SORT of a region's items is deliberately not covered and does not need to be: the
|
||||
/// geometry key it guards is an order-independent sum too.
|
||||
///
|
||||
/// This is a GATE, not a key: the app lane skips a fixture's expensive walk while this and the
|
||||
/// entity-part census are both unchanged, and still computes the real per-fixture key whenever
|
||||
/// either of them moves.
|
||||
pub fn torch_residency_generation(&self) -> u64 {
|
||||
let mut sum = 0u64;
|
||||
for cell in self.cells.values().chain(self.wmos.values()).chain(self.props.values()) {
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
cell.last_change.hash(&mut hash);
|
||||
cell.items.len().hash(&mut hash);
|
||||
sum = sum.wrapping_add(hash.finish());
|
||||
}
|
||||
sum
|
||||
}
|
||||
|
||||
/// MONKEY (static torch cache): residency fingerprint of the exact opaque source set used
|
||||
/// by append_torch_triangles. Order-independent per-item hashing ignores HashMap reordering
|
||||
/// and camera/portal/fader bookkeeping. Arrival, unload, replacement or transform edits in
|
||||
/// THIS fixture's sphere invalidate it even when neither camera nor fixture moves.
|
||||
pub fn torch_geometry_key(&self, center: Vec3, reach: f32) -> u64 {
|
||||
pub fn torch_geometry_key(&self, center: Vec3, reach: f32, self_exclude: f32) -> u64 {
|
||||
let mut sum = 0u64;
|
||||
for cell in self.cells.values().chain(self.wmos.values()).chain(self.props.values()) {
|
||||
for item in &cell.items {
|
||||
if !torch_item_in_range(item, center, reach) { continue; }
|
||||
if torch_item_is_emitter(item, center, self_exclude) { continue; }
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
// MONKEY (static torch cache): source residency stamp prevents allocator address
|
||||
// reuse after unload/reload from impersonating the previous geometry Arc.
|
||||
@@ -752,6 +870,40 @@ impl super::StaticGx {
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (outdoor torch shadows): EMITTER-OWNER EXCLUSION — is this item the fixture's own body?
|
||||
///
|
||||
/// A campfire's light sits AT the flame, inside the campfire prop's own opaque logs, and nothing in
|
||||
/// this lane knows the two belong together (the light is an ECS entity, the logs are a retained
|
||||
/// `GxItem`; the placement that spawned both is not carried on either side). Left in, the logs are
|
||||
/// the nearest occluder in every one of the six cube faces and the fire blacks out its OWN pool —
|
||||
/// which indoors never mattered, because an authored MOLT fixture stands clear of its bracket,
|
||||
/// and outdoors is the whole effect.
|
||||
///
|
||||
/// So identity is approximated by CONTAINMENT: an item whose entire bounding sphere lies inside
|
||||
/// `radius` of the fixture is the fixture's own body (or is so close to it that it can cast
|
||||
/// nothing but self-shadow). Containment, not centre distance, is what keeps a wall or a fence
|
||||
/// that merely PASSES near the fire — a large sphere with a near centre — casting normally.
|
||||
/// `radius <= 0` (the interior lane, and any caller that wants the old set) excludes nothing, so
|
||||
/// the interior gather is byte-for-byte the set it always was. An item with no bounds is never
|
||||
/// excluded: the failure direction of this test must be "keeps a caster", not "loses a wall".
|
||||
fn torch_item_is_emitter(item: &super::GxItem, center: Vec3, radius: f32) -> bool {
|
||||
item.local_aabb.is_some_and(|aabb| torch_bound_contained(
|
||||
&item.transform, aabb.center.into(), aabb.half_extents.into(), center, radius))
|
||||
}
|
||||
|
||||
/// The containment half of [`torch_item_is_emitter`], split out exactly as `torch_bound_in_range`
|
||||
/// is: the transformed bounding sphere lies WHOLLY inside `radius` of the fixture. `radius <= 0`
|
||||
/// (the interior lane) contains nothing.
|
||||
fn torch_bound_contained(transform: &Transform, local_center: Vec3, half_extents: Vec3,
|
||||
center: Vec3, radius: f32) -> bool {
|
||||
if radius <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let origin = transform.transform_point(local_center);
|
||||
let r = (half_extents * transform.scale.abs()).length();
|
||||
origin.distance(center) + r <= radius
|
||||
}
|
||||
|
||||
// MONKEY (static torch cache): a conservative transformed bounding sphere, independent of
|
||||
// portal visibility. Missing bounds admit the source rather than silently losing a wall.
|
||||
fn torch_item_in_range(item: &super::GxItem, center: Vec3, reach: f32) -> bool {
|
||||
@@ -775,12 +927,39 @@ mod tests {
|
||||
#[test]
|
||||
fn cache_plan_reuses_and_limits_rebuilds() {
|
||||
let cached = [Some(10), Some(11), None, Some(13)];
|
||||
assert_eq!(torch_cache_plan(&cached, &cached, |_| false), (0b1011, 0));
|
||||
assert_eq!(torch_cache_plan(&cached, &cached, 0xf, |_| false), (0b1011, 0, 0b1011));
|
||||
let requested = [Some(20), Some(11), Some(12), Some(23)];
|
||||
assert_eq!(torch_cache_plan(&cached, &requested, |_| true), (0b0111, 0b0101));
|
||||
assert_eq!(torch_cache_plan(&cached, &requested, |id| id == 23), (0b1010, 0b1000));
|
||||
assert_eq!(torch_cache_plan(&cached, &[None, Some(11)], |_| true), (0b10, 0));
|
||||
assert_eq!(torch_cache_plan(&[None; 4], &requested, |_| true), (0b11, 0b11));
|
||||
assert_eq!(torch_cache_plan(&cached, &requested, 0xf, |_| true), (0b0111, 0b0101, 0b0111));
|
||||
assert_eq!(torch_cache_plan(&cached, &requested, 0xf, |id| id == 23), (0b1010, 0b1000, 0b1010));
|
||||
assert_eq!(torch_cache_plan(&cached, &[None, Some(11)], 0xf, |_| true), (0b10, 0, 0b10));
|
||||
assert_eq!(torch_cache_plan(&[None; 4], &requested, 0, |_| true), (0b11, 0b11, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_bank_rank_matches_count_one_bits() {
|
||||
// MONKEY (live bank rank): independently enumerate lower set bits, including holes,
|
||||
// slot 15 and eight sparse overlays; every face must fit the compact allocation.
|
||||
for mask in [0u32, 1, 1 << 15, 0x8085, 0xaaaa, 0xff] {
|
||||
for slot in 0..MAX_TORCH_MAPS {
|
||||
let expected = (0..slot).filter(|bit| mask & (1 << bit) != 0).count();
|
||||
let rank = TorchShadowViews::live_rank(mask, slot);
|
||||
assert_eq!(rank, expected, "mask {mask:#x}, slot {slot}");
|
||||
if mask & (1 << slot) != 0 {
|
||||
assert!(MAX_TORCH_LAYERS + rank * CUBE_FACES + 5 < TORCH_TEXTURE_LAYERS);
|
||||
}
|
||||
}
|
||||
}
|
||||
let cached = [Some(1); MAX_TORCH_MAPS];
|
||||
let (_, _, capped) = torch_cache_plan(&cached, &cached, 0xffff, |_| false);
|
||||
assert_eq!(capped, 0xff);
|
||||
let mut requested = cached;
|
||||
requested[0] = None;
|
||||
let (ready, _, dynamic) = torch_cache_plan(&cached, &requested, 0x8001, |_| false);
|
||||
let views = TorchShadowViews { count: 16, ready_mask: ready, dynamic_mask: dynamic, ..Default::default() };
|
||||
let table = TorchTableUniform::pack(Some(&views));
|
||||
assert_eq!(dynamic, 0x8000);
|
||||
assert_eq!(table.dynamic_mask, dynamic);
|
||||
assert_eq!(TorchShadowViews::live_rank(table.dynamic_mask, 15), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -808,6 +987,37 @@ mod tests {
|
||||
assert_eq!(table.positions[0][3], 0.0);
|
||||
assert_eq!(table.positions[15][3], 1.0);
|
||||
assert_eq!(MAX_TORCH_LAYERS, 96);
|
||||
assert_eq!(TORCH_TEXTURE_LAYERS, 192);
|
||||
assert_eq!(MAX_TORCH_DYNAMIC, 8);
|
||||
assert_eq!(TORCH_TEXTURE_LAYERS, 144);
|
||||
// MONKEY (outdoor torch shadows): the exterior lane rides `count.w`, the word that was
|
||||
// reserved padding. The SIZE must not move with it — a table that disagrees with either
|
||||
// WGSL copy blanks every building and model on screen.
|
||||
assert_eq!(std::mem::offset_of!(TorchTableUniform, flags), 12);
|
||||
assert_eq!(table.flags, 0, "not exterior by default");
|
||||
views.exterior = true;
|
||||
assert_eq!(TorchTableUniform::pack(Some(&views)).flags, TORCH_EXT_LANE);
|
||||
assert_eq!(TorchTableUniform::pack(None).flags, 0, "no resource = no lane");
|
||||
}
|
||||
|
||||
// MONKEY (outdoor torch shadows): EMITTER-OWNER EXCLUSION. A campfire's own logs must leave
|
||||
// its caster gather (they are the nearest occluder on all six faces and would black out the
|
||||
// fire's own pool); a crate beside it must not. The test is CONTAINMENT of the whole bounding
|
||||
// sphere, so "near the fire" is not enough to be excluded — which is what keeps a long wall or
|
||||
// a fence that merely passes the fire casting normally.
|
||||
#[test]
|
||||
fn only_the_fire_s_own_body_leaves_its_caster_gather() {
|
||||
let excluded = |x: f32, half: f32, radius: f32| torch_bound_contained(
|
||||
&Transform::from_xyz(x, 0.0, 0.0), Vec3::ZERO, Vec3::splat(half), Vec3::ZERO, radius);
|
||||
// The campfire prop itself: centred on the flame, ~1 yd across.
|
||||
assert!(excluded(0.2, 0.5, 2.5));
|
||||
// A crate two yards off: its centre is inside 2.5, its sphere is not.
|
||||
assert!(!excluded(2.0, 0.6, 2.5));
|
||||
// A wall passing right by the fire: a huge sphere, never contained.
|
||||
assert!(!excluded(0.0, 30.0, 2.5));
|
||||
// The INTERIOR lane passes 0 and excludes nothing at all - its gather is untouched.
|
||||
assert!(!excluded(0.0, 0.1, 0.0));
|
||||
// Scale is applied to the extents, exactly as `torch_bound_in_range` applies it.
|
||||
let scaled = Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::splat(4.0));
|
||||
assert!(!torch_bound_contained(&scaled, Vec3::ZERO, Vec3::splat(0.5), Vec3::ZERO, 2.5));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,8 +428,11 @@ pub fn spawn_model_entities(
|
||||
// entity path).
|
||||
(class.never_fade || fade.is_some()).then_some((*owner, None, None, fade))
|
||||
}
|
||||
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| {
|
||||
(
|
||||
@@ -438,6 +441,13 @@ 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),
|
||||
class: sub.wmo_batch,
|
||||
sidn: sub.sidn,
|
||||
window: sub.window,
|
||||
|
||||
@@ -67,6 +67,12 @@ fn spawn_point_light(
|
||||
// `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
|
||||
@@ -94,6 +100,12 @@ fn spawn_point_light(
|
||||
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));
|
||||
}
|
||||
@@ -166,6 +178,14 @@ pub(super) fn spawn_lights_for(
|
||||
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`,
|
||||
@@ -490,6 +510,11 @@ pub(super) fn spawn_wmo_lights_for(
|
||||
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
|
||||
|
||||
@@ -456,6 +456,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,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user