MONKEY (r18 A1 B002/60056): per-fragment point-light falloff on terrain + exterior props, interior retained props drop the unrelated exterior light sum, shadow-map identity = nearest valid within 0.1 yd, WOW_POINTS_DUMP=all room/light trace

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 801087822f0925a43352bb925e8c1154383c4377)
This commit is contained in:
pkuzic
2026-10-09 12:38:43 +01:00
parent cbfcc3cada
commit ef6b5e673e
5 changed files with 136 additions and 170 deletions
+40 -70
View File
@@ -1,7 +1,8 @@
// Terrain splat shader: a custom vertex and fragment stage on Bevy's StandardMaterial. Blends up to
// four tiled layers by a per-chunk alpha map, lit as the reference's fixed-function terrain:
// diffuse = clamp(ambient (row 1) + diffuse (row 0)·max(N·L, 0) + Σ point·att·N·L), evaluated
// and clamped per vertex (GL T&L), Gouraud-interpolated, modulated 1× into the texture;
// sun/ambient interpolates from the vertices; local points evaluate per pixel, then the sum
// clamps and modulates the texture. Deviation: sparse MCNK geometry must not miss candle pools;
// specular = clamp(row 9 · max(N·H, 0)^20) per vertex (local viewer), times the sheen mask (the
// `_s` texture's per-texel alpha, blended like the colour), added after the modulate.
// Evaluated per pixel, or unmasked, the near-white row 9 would wash lit ground to cream.
@@ -165,7 +166,7 @@ fn torch_face(d: vec3<f32>) -> u32 {
// `wow_model.wgsl`'s `torch_entity_shadow_at` with the exterior fade already chosen, because terrain
// has no interior lane to share the body with (an MCNK cell is outdoors by definition, and the
// interior half of the light table is skipped before ranking). Correlate the `wow_light` fixture at
// `light_pos` to a promoted torch (position match within 1 yd), pick the cube face facing the
// `light_pos` to its promoted torch (nearest position within 0.1 yd), pick the cube face facing the
// fragment, and sample that layer through the shared projector. 1.0 (unshadowed) when no map matches
// - the lane is off, or the fixture was not among the promoted slots.
fn torch_terrain_shadow(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>) -> f32 {
@@ -176,11 +177,23 @@ fn torch_terrain_shadow(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>) -> f32
}
// Normal-offset the sample point off the ground (see TORCH_NORMAL_OFFSET).
let Ps = P + N * TORCH_NORMAL_OFFSET;
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
// MONKEY (light identity): nearby spell lights must not borrow a candle's map. The small
// tolerance matches torch_shadow::TORCH_STALE_DRIFT_SQ; nearest agrees with both model paths.
var nearest = torch_table.count.x;
var nearest_d2 = 0.010001;
for (var candidate = 0u; candidate < torch_table.count.x; candidate += 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) {
if (torch_table.positions[candidate].w <= 0.0) { continue; }
let delta = torch_table.positions[candidate].xyz - light_pos;
let d2 = dot(delta, delta);
if (d2 < nearest_d2) {
nearest = candidate;
nearest_d2 = d2;
}
}
if (nearest < torch_table.count.x) {
let i = nearest;
let fixture = torch_table.positions[i].xyz;
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
@@ -204,7 +217,6 @@ fn torch_terrain_shadow(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>) -> f32
// into that same weight (see the function). `w * strength` rather than a second `mix`
// because the two are the same expression.
return mix(1.0, s, torch_table.positions[i].w * torch_strength());
}
}
return 1.0;
}
@@ -245,15 +257,13 @@ struct TerrainVsOut {
@location(3) uv_b: vec2<f32>,
@location(4) color: vec4<f32>,
@location(5) specular: vec3<f32>,
// The Gouraud diffuse, summed and clamped per vertex as GL T&L does: an over-gamut light (a
// carried torch is (1.4, 0.87, 0.40)) saturates one vertex and falls off linearly from it,
// where clamping per pixel would pin a wide plateau at white.
// Preserve the reference's clamped sun/ambient interpolation when no point was selected.
@location(6) primary: vec3<f32>,
// The receiver normal is carried so the real cascaded shadow lookup can apply a slope-aware
// bias. This is forward-pass data only; the terrain shadow caster is a stock Bevy proxy.
@location(7) world_normal: vec3<f32>,
// MONKEY (outdoor torch shadows: terrain; ext light k8): WHICH <=`EXT_SEL_K` exterior table
// entries `primary`'s point term was summed from, packed 8 bits each across FOUR u32s (see
// entries the fragment evaluates, packed 8 bits each across FOUR u32s (see
// `EXT_SEL_EMPTY` / `ext_sel_get`). FLAT, because it is a CHOICE and not a quantity -
// interpolating packed indices across a triangle would produce a different, meaningless one. It
// lets the fragment stage re-shadow the VERTEX's own selection instead of re-ranking, which is
@@ -263,17 +273,8 @@ struct TerrainVsOut {
// for this struct, against a 60-component limit) - see `EXT_SEL_K` for why three, and then
// eight, stopped being enough.
@location(8) @interpolate(flat) ext_sel: vec4<u32>,
// MONKEY (outdoor torch shadows: terrain): `primary` WITHOUT the point term and WITHOUT the
// clamp - just `ambient + sun*max(N.L,0)`. `primary` is clamped per VERTEX (the GL T&L locus,
// see its note above) and that clamp is lossy: once a hot torch has saturated a vertex there is
// no way to subtract the point term back out in the fragment stage. So the sun/ambient half
// rides its own slot and the night lane rebuilds `clamp(base + shadowed_points)` from it. This
// costs 3 interstage components and is read ONLY inside the night branch; the day path still
// reads `primary` and is therefore bit-identical. Unclamped is safe to interpolate: it is
// linear in `N.L` and the clamp that used to bound it is re-applied per fragment.
// Unclamped sun/ambient interpolates separately; local lights are evaluated per pixel.
@location(9) base_lit: vec3<f32>,
// MONKEY (moon shadows): preserve the point sum BEFORE saturation; primary cannot recover it.
@location(10) point_lit: vec3<f32>,
}
// Terrain's point-light candidacy half-width (yd): `w + 10`, `w` being the chunk's bounding-sphere
@@ -479,9 +480,8 @@ fn point_light_pick(anchor: vec3<f32>, box: f32) -> vec4<u32> {
// The EVALUATION half - the byte-verified falloff `1/(0.7d + 0.03d^2)` x `max(N.L, 0)` x the
// committed colour, in rank order, stopping at the first empty rank exactly as the old
// `sd[s] > 9.9e29` break did (an unfilled rank packs as `EXT_SEL_EMPTY`, and no real index can
// collide with it: the live table caps at 255). MONKEY (ext light k12): up to `EXT_SEL_K` terms now,
// still Gouraud (per vertex) and still linear in the falloff, so nothing about the day lane's FORM
// changed - a chunk simply stops dropping the fixtures its neighbour kept.
// collide with it: the live table caps at 255). MONKEY (local-light consistency): evaluate the
// selected terms per fragment, without re-ranking.
fn point_light_eval(sel: vec4<u32>, P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
var sum = vec3<f32>(0.0);
for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
@@ -591,17 +591,11 @@ fn vertex(in: Vertex) -> TerrainVsOut {
// the sum was always `eval(pick(..))`, it is just no longer inlined.
let sel = point_light_pick(mcnk_cell_anchor(out.world_position.xyz), MCNK_CELL_HALF);
out.ext_sel = sel;
let points = point_light_eval(sel, out.world_position.xyz, n);
out.point_lit = points;
// MONKEY (local-light consistency): pick once per draw unit; sum per fragment.
// Sparse MCNK vertices otherwise miss a candle between them.
let sun_lighting = wow_light.light_diffuse.rgb * ndotl;
// The sun/ambient half on its own slot, UNCLAMPED (see `base_lit`) - the night lane's only way
// back to a `primary` that does not already have the unshadowed point term baked into it.
out.base_lit = wow_light.light_ambient.rgb + sun_lighting;
out.primary = clamp(
wow_light.light_ambient.rgb + sun_lighting + points,
vec3<f32>(0.0),
vec3<f32>(1.0),
);
out.primary = clamp(out.base_lit, vec3<f32>(0.0), vec3<f32>(1.0));
return out;
}
@@ -646,11 +640,8 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
specmask = mix(specmask, s2.a, a.g);
specmask = mix(specmask, s3.a, a.b);
// STEP 3 (diffuse) arrives from the vertex stage, ALREADY summed (ambient + sun N·L + the
// chunk's committed point lights) and ALREADY clamped — Gouraud of the clamped vertex
// colour, byte-faithful to GL T&L (see the `primary` struct note; the MCNR normal and the
// DayNight sun share a space per the Phase-0 validation). The MCSH ×(0.3·s + 0.7) factor below
// still scales the whole modulate, as the traced combine does.
// Sun/ambient arrives unclamped from the vertex stage; selected local lights join per
// fragment before the light sum clamps and modulates the texture.
// Fetch the character coverage here, but apply it as a NEUTRAL scalar after the terrain's
// authored lighting below. Subtracting the warm sun RGB left only Elwynn's green ambient and
// produced a green silhouette on yellow morning ground. The era-style projected shadow is a
@@ -684,45 +675,24 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
wow_light.monkey.misc.w,
);
let world_shadow = shadow_terms.x;
// MONKEY (outdoor torch shadows: terrain): the exterior point term, CAST-SHADOWED at night.
//
// `in.primary` is the Gouraud (per-vertex, per-vertex-CLAMPED) diffuse, and it stays the only
// thing this lane reads by day. After dark the same entries the vertex picked
// (`in.ext_sel`) are re-evaluated PER FRAGMENT with each fixture's own cube-map occlusion folded
// in, re-summed onto the sun/ambient half (`in.base_lit`) and re-clamped here - the clamp has to
// move to the fragment because the shadow is a per-fragment quantity, and clamping the sum is
// still the faithful GL order (saturate the light sum, then modulate the texture).
//
// Written as a BLEND rather than a swap, for the same two reasons the model and WMO lanes are:
// - `fog_params.z` is EXACTLY 1.0 whenever the sun is above the daylight threshold
// (`global_light::sun_shadow_strength` is a smoothstep that saturates), so `ext_night_w` is
// exactly 0, the branch is not entered, and DAYTIME IS THE SAME BITS - not "a mix that ought
// to round back to `in.primary`". Nothing here is evaluated by day at any cost.
// - at dusk the shadow, and the Gouraud->per-fragment change of the term itself, arrive on the
// same clock the sun shadows leave on instead of snapping at a threshold.
// `torch_ext_on()` is the CPU's one-bit verdict (`exteriorShadows` AND night AND at least one
// promoted exterior fixture), so with the cvar off this is dead too.
var primary = in.primary;
// MONKEY (local-light consistency): per-pixel falloff on terrain, as on WMO and props.
// Night adds each selected fixture's own occlusion on the existing smooth dusk clock.
var points = point_light_eval(in.ext_sel, in.world_position.xyz, n_lit);
let ext_night_w = select(0.0, clamp(1.0 - sun_shadow_strength, 0.0, 1.0), torch_ext_on());
if (ext_night_w > 0.0) {
let primary_night = clamp(
in.base_lit + point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
primary = mix(in.primary, primary_night, ext_night_w);
points = mix(points,
point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit), ext_night_w);
}
var primary = in.primary;
if (ext_sel_get(in.ext_sel, 0u) != EXT_SEL_EMPTY) {
primary = clamp(in.base_lit + points, vec3<f32>(0.0), vec3<f32>(1.0));
}
// MONKEY (moon shadows): attenuate the SKY (ambient AND directional) BEFORE adding points
// and saturating. Subtracting from primary loses over-gamut torch energy: sky .2 + point 1
// must remain 1 under a .35 moon shadow, not .93. Keep the entire off arm above untouched;
// neither a zero weight nor a fully lit fragment earns a different Gouraud/clamp path.
// must remain 1 under a .35 moon shadow, not .93. Point lights keep their energy;
// only the sky is attenuated before the final light-sum clamp.
if (shadow_terms.y < 1.0) {
var points = in.point_lit;
if (ext_night_w > 0.0) {
points = mix(points,
point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit), ext_night_w);
}
primary = clamp(in.base_lit * shadow_terms.y + points, vec3<f32>(0.0), vec3<f32>(1.0));
}
// GFX (moonlight): ADD the moon (cool, N·L, shadowed by the moon's own map) onto the stock
@@ -122,7 +122,7 @@ struct WowLight {
// The dynamic point-light table (decision 0278), packed by `global_light::build_light_data`:
// row 20 `.x` = live entry count; then TWO rows per light — `[pos.xyz, range]`, `[rgb, lane]`.
// Rides this buffer (not bevy's clusterables) because the view layout exposes those to the
// fragment stage only, and the Gouraud term is evaluated in the VERTEX stage.
// fragment stage only; the vertex publishes a flat selection for fragment evaluation.
//
// MONKEY (light lanes): the colour row's `.w` splits the table into two DISJOINT halves.
// `0` = an EXTERIOR light (nothing claims a room) — read only by `point_light_sum`. Anything
@@ -248,7 +248,7 @@ fn torch_face(d: vec3<f32>) -> u32 {
// MONKEY (torch shadows Phase 3A): this interior fixture's OWN cast shadow on an ENTITY fragment —
// exactly static_gx.wgsl's `torch_surface_shadow` (keep in sync): correlate the `wow_light`
// fixture at `light_pos` to a promoted torch (nearest position within 1 yd), pick the cube face
// fixture at `light_pos` to a promoted torch (nearest position within 0.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.
//
@@ -265,7 +265,9 @@ fn torch_entity_shadow_at(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>, fade
let Ps = P + N * TORCH_NORMAL_OFFSET;
// MONKEY (gfx): mirror the retained receiver's nearest-fixture correlation.
var nearest = torch_table.count.x;
var nearest_d2 = 1.0;
// MONKEY (light identity): only the light's own map, not a nearby candle's. The 0.1 yd
// tolerance matches torch_shadow::TORCH_STALE_DRIFT_SQ's publishable-map limit.
var nearest_d2 = 0.010001;
for (var candidate = 0u; candidate < torch_table.count.x; candidate += 1u) {
// MONKEY (static torch cache): holes and pending uploads never sample stale layers.
if (torch_table.positions[candidate].w <= 0.0) { continue; }
@@ -369,8 +371,6 @@ struct WowVsOut {
#ifdef VERTEX_OUTPUT_INSTANCE_INDEX
@location(6) @interpolate(flat) instance_index: u32,
#endif
// The point-light sum `Σ att·sat(N·L)·colour`, per vertex like the reference FFP.
@location(8) point_lit: vec3<f32>,
#ifdef WOW_MERGED_FADE
// A merged blob's per-placement fade alpha, constant over the placement.
@location(9) merged_fade: f32,
@@ -379,7 +379,7 @@ struct WowVsOut {
@location(10) @interpolate(flat) merged_slot: u32,
#endif
// MONKEY (outdoor torch shadows; ext light k8): WHICH <=`EXT_SEL_K` exterior table entries
// `point_lit` was summed from, packed 8 bits each across FOUR u32s (see `EXT_SEL_NONE` /
// the fragment evaluates, packed 8 bits each across FOUR u32s (see `EXT_SEL_NONE` /
// `ext_sel_get`). FLAT, because it is a choice and not a quantity - interpolating packed
// indices would produce a different, 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
@@ -599,9 +599,7 @@ fn point_light_pick(anchor: vec3<f32>, box: f32) -> vec4<u32> {
// 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).
// MONKEY (ext light k12): up to `EXT_SEL_K` terms now, still Gouraud (per vertex) and still linear
// in the falloff, so nothing about the day lane's FORM changed — a unit simply stops dropping the
// fixtures its neighbour kept.
// MONKEY (local-light consistency): evaluate the selected terms per fragment, without re-ranking.
fn point_light_eval(sel: vec4<u32>, P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
var sum = vec3<f32>(0.0);
for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
@@ -1210,9 +1208,7 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
let sel = wmo_exterior_pick(out.world_position.xyz);
out.ext_sel = sel;
// MONKEY (B035 per-fragment wmo points): summed per FRAGMENT now, as in `static_gx.wgsl`.
out.point_lit = vec3<f32>(0.0);
} 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;
@@ -1226,7 +1222,8 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
}
let sel = point_light_pick(anchor, box);
out.ext_sel = sel;
out.point_lit = point_light_eval(sel, out.world_position.xyz, out.world_normal);
// MONKEY (local-light consistency): match terrain/WMO fragment evaluation. Sparse prop
// vertices otherwise spread a candle's near-field peak over the whole piece of furniture.
}
return out;
}
@@ -1610,37 +1607,11 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
}
}
let sidn_e = m.sidn.rgb * (wow_light.grade.x * sidn_w);
// WoW dynamic point lights (decisions 0016/0273/0278, selection 0285) — exterior doodads,
// entities, clutter, and terrain receive their unit's committed lights: the ≤`EXT_SEL_K` NEAREST to the
// receiving unit's own position or AABB, never the whole scene (`point_light_sum`). INTERIOR WMO
// surfaces take ZERO (observed on every WMO surface batch in the abbey capture — an interior
// capture, hence the MONKEY split above) and interior props fold their group-MOLR lobes into
// the SH probe instead — both zeroed in the VERTEX stage; an EXTERIOR-class WMO group takes
// the exterior-lane term (`wmo_exterior_point_sum`) there instead, ranked from its MCNK cell
// like the terrain it adjoins, which is what puts a street torch on the cobbles. The term
// arrives GOURAUD-INTERPOLATED — per-vertex like the reference FFP, whose tessellation-scale
// smoothing is the authored look. Diffuse-only (committed ambient/specular are zero).
// MONKEY (outdoor torch shadows): ...and after dark that Gouraud term is CAST-SHADOWED.
//
// The same 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;
// MONKEY (B035 per-fragment wmo points): an EXTERIOR-class WMO batch sums its picked lights
// per fragment, day and night — MIRRORED from `static_gx.wgsl` (that file has the why).
if (m.model_flags.x > 0.5 && m.model_flags.z < 0.5) {
point_diffuse = point_light_eval(in.ext_sel, in.world_position.xyz, n_lit);
}
// MONKEY (local-light consistency): all exterior receivers sum their committed lights
// per fragment. Sparse prop vertices must not spread the near-field peak across
// furniture while the adjacent terrain and WMO evaluate a different pool.
// Interior receivers publish EXT_SEL_NONE and use the separate room-light lane.
var point_diffuse = point_light_eval(in.ext_sel, in.world_position.xyz, n_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(
@@ -1662,8 +1662,9 @@ fn build_light_data(
for slot in claims.0[pts.len() * ROOM_CLAIM_STRIDE..].iter_mut() {
*slot = 0;
}
// `WOW_POINTS_DUMP=1` prints the nearest 8 packed lights once a second; `=frame` every frame,
// which a pool that changes frame to frame needs.
// `WOW_POINTS_DUMP=1` prints the nearest 8 packed lights once a second; `=frame` every frame.
// MONKEY (room diagnostics): `=all` prints every committed row once a second, including
// lane/claim/fade data. Eight nearest-to-camera rows can omit the hearth under investigation.
static POINTS_DUMP: std::sync::OnceLock<Option<std::ffi::OsString>> =
std::sync::OnceLock::new();
if let Some(mode) = POINTS_DUMP.get_or_init(|| std::env::var_os("WOW_POINTS_DUMP")) {
@@ -1724,9 +1725,22 @@ fn build_light_data(
// one number away, and the two causes look identical on screen.
spell_gain.0,
);
for (d2, p, _, rgb, synthetic, lane, claim, lit_n) in pts.iter().take(8) {
eprintln!(
"[MonkeyLightState] interior={} gate={} ambient={:.4} fill={:.4} exposure={:.3} bake={:.3} daylight={:.3}",
dynamic_interiors.enabled,
dynamic_interiors.room_gate,
fresh.rows[20][1],
fresh.rows[20][2],
fresh.rows[20][3],
dynamic_interiors.bake_floor,
dynamic_interiors.daylight,
);
let dump_count = if mode.as_os_str() == "all" { pts.len() } else { 8 };
for (row, (d2, p, _, rgb, synthetic, lane, claim, lit_n)) in
pts.iter().take(dump_count).enumerate()
{
eprintln!(
" d {:6.2} at [{:8.2},{:7.2},{:8.2}] rgb [{:.3},{:.3},{:.3}] {}{}",
" row {row:3} d {:6.2} at [{:8.2},{:7.2},{:8.2}] rgb [{:.3},{:.3},{:.3}] {}{}",
d2.sqrt(),
p.x,
p.y,
@@ -1753,6 +1767,21 @@ fn build_light_data(
// behind "why is this room dark" / "why does that wall still glow".
if *lane > 0.5 {
eprintln!(" rooms {claim}");
if mode.as_os_str() == "all" {
for (group, fade) in claim.groups[..usize::from(claim.n)]
.iter().zip(&claim.fades[..usize::from(claim.n)])
{
eprintln!(
" group={} ext={} center={:.2?} radius={:.3} slack={:.3} entry={:.3}",
group & !LIT_ROOM_EXT_DENY,
group & LIT_ROOM_EXT_DENY == 0,
fade.center,
fade.radius,
fade.slack,
fade.entry,
);
}
}
}
}
}
@@ -1873,6 +1902,24 @@ pub fn classify_light_lanes(
// Always re-stamped when the generation moved, even if the verdict didn't — the stamp is
// what stops the next frame re-raying the same light.
if lane != Some(&want) {
// MONKEY (room diagnostics): expose the physical lane decision behind fireplace
// bleed. Runs only on classification changes, not another ray or an idle-frame log.
static MONKEY_LIGHT_TRACE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
if *MONKEY_LIGHT_TRACE.get_or_init(|| {
std::env::var("WOW_POINTS_DUMP").is_ok_and(|mode| mode == "all")
}) {
let verdict_name = match verdict {
crate::wmo_portal::IndoorVerdict::Outdoors => "outdoors",
crate::wmo_portal::IndoorVerdict::OutdoorsOnWmo => "outdoors-wmo",
crate::wmo_portal::IndoorVerdict::DayNight => "interior-matte",
crate::wmo_portal::IndoorVerdict::Baked { .. } => "interior-baked",
};
eprintln!(
"[MonkeyLightLane] light={light:?} wow={:.3?} verdict={verdict_name} terrain={terrain_hit} room_refs={} interior={interior} generation={generation}",
benilla_assets::coords::bevy_to_wow(gt.translation()),
rooms.is_some(),
);
}
commands.entity(light).insert(want);
}
}
+21 -45
View File
@@ -405,7 +405,7 @@ fn torch_face(d: vec3<f32>) -> u32 {
}
// This fixture's OWN cast shadow: correlate the `wow_light` fixture at `light_pos` to a
// promoted torch (nearest position within 1 yd), pick the cube face facing the fragment, and sample
// promoted torch (nearest position within 0.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).
//
@@ -428,7 +428,9 @@ fn torch_map_at(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>, fade_radius: f
}
// MONKEY (gfx): a nearby spell light must not steal an earlier fixture's map.
var nearest = torch_table.count.x;
var nearest_d2 = select(1.0, 0.010001, strict);
// MONKEY (light identity): an unrelated spell light within a yard must not steal a map.
// 0.1 yd also matches torch_shadow::TORCH_STALE_DRIFT_SQ's publishable-map limit.
var nearest_d2 = 0.010001;
for (var candidate = 0u; candidate < torch_table.count.x; candidate += 1u) {
// MONKEY (static torch cache): holes and pending uploads never sample stale layers.
if (torch_table.positions[candidate].w <= 0.0) { continue; }
@@ -713,9 +715,7 @@ fn point_light_pick(anchor: vec3<f32>, box: f32) -> vec4<u32> {
// 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).
// MONKEY (ext light k12): up to `EXT_SEL_K` terms now, still Gouraud (per vertex) and still linear
// in the falloff, so nothing about the day lane's FORM changed — a unit simply stops dropping the
// fixtures its neighbour kept.
// MONKEY (local-light consistency): evaluate the selected terms per fragment, without re-ranking.
fn point_light_eval(sel: vec4<u32>, P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
var sum = vec3<f32>(0.0);
for (var s = 0u; s < EXT_SEL_K; s = s + 1u) {
@@ -1283,10 +1283,9 @@ struct GxVsOut {
@location(1) world_normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) @interpolate(flat) word: u32,
@location(4) point_lit: vec3<f32>,
@location(5) color: vec4<f32>,
// MONKEY (outdoor torch shadows; ext light k8): WHICH ≤`EXT_SEL_K` exterior table entries
// `point_lit` was summed from, packed 8 bits each across FOUR u32s (see `EXT_SEL_NONE` /
// the fragment evaluates, packed 8 bits each across FOUR u32s (see `EXT_SEL_NONE` /
// `ext_sel_get`). FLAT, because it is a choice, not a quantity — interpolating packed indices
// would produce a different, meaningless one. `EXT_SEL_NONE` on every lane that takes no
// exterior point term (interior WMO surfaces, interior props, the collapsed exile vertex),
@@ -1308,7 +1307,6 @@ fn vertex(v: GxVertex) -> GxVsOut {
out.world_normal = vec3<f32>(0.0, 1.0, 0.0);
out.uv = vec2<f32>(0.0);
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;
@@ -1343,20 +1341,21 @@ fn vertex(v: GxVertex) -> GxVsOut {
// (wow-re trace-forensics-abbey-interior-d3d §2: zero on every observed WMO surface) —
// and so do interior M2 props (B4): their group-MOLR point lobes are folded into the
// per-item SH probe at spawn, the entity path's own vertex-stage zeroing.
if ((v.word & WORD_WMO) != 0u && (v.word & WORD_INTERIOR) != 0u) {
// INTERIOR WMO surfaces stay zero here, the reference's own vertex-stage zeroing. MONKEY
if ((v.word & WORD_INTERIOR) != 0u) {
// MONKEY (interior prop parity): interior props also publish no exterior selection,
// matching wow_model.wgsl. A prop's baked probe already contains its fixture lighting;
// adding the outside table only to retained furniture made it brighter than its walls.
// INTERIOR receivers stay zero here, the reference's own vertex-stage zeroing. MONKEY
// (dynamic interiors): they light from the live torches PER-FRAGMENT in the fragment
// stage (`interior_room_light`) — a per-vertex sum on a WMO's huge floor triangles is
// Gouraud: straight-edged wedges, and a torch mid-triangle lights nothing.
out.point_lit = vec3<f32>(0.0);
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
// terrain beside it takes. Its own anchor is the placement origin (one point for all of
// Stormwind), so `wmo_exterior_point_sum` re-anchors on the MCNK cell; see its comment.
// Per-vertex like terrain's, not per-fragment: WMO surfaces are MOCV-baked per vertex, so
// they carry the tessellation a Gouraud term needs, and this stays one bounded walk.
// Selection stays per vertex; light falloff is evaluated per fragment.
// MONKEY (outdoor torch shadows): the pick is published so the fragment stage can shadow
// this same selection 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.
@@ -1364,13 +1363,12 @@ fn vertex(v: GxVertex) -> GxVsOut {
out.ext_sel = sel;
// MONKEY (B035 per-fragment wmo points): the SUM moved to the fragment stage (see there);
// only the pick stays per vertex, so this lane still walks the table once per vertex.
out.point_lit = vec3<f32>(0.0);
} else {
// MONKEY (ext light k12): `box = 0` — an exterior doodad/MODD prop is its own draw unit and
// ranks from its baked PLACEMENT origin, exactly as it did before the widening.
let sel = point_light_pick(v.anchor, 0.0);
out.ext_sel = sel;
out.point_lit = point_light_eval(sel, world, v.normal);
// MONKEY (local-light consistency): sum at the fragment, matching terrain and entities.
}
return out;
}
@@ -1418,32 +1416,10 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
// Both faces light from the submitted normal (no GL_LIGHT_MODEL_TWO_SIDE in the reference);
// this pipeline never negates back faces, so it has no front-face select like wow_model.wgsl.
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 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;
// MONKEY (B035 per-fragment wmo points): an EXTERIOR WMO surface evaluates its picked lights
// PER FRAGMENT, day and night. Its triangles are paving-slab sized (the Valley of Heroes
// bridge: a few yards a side), and the `1/(0.7d + 0.03d^2)` falloff of a light hanging a
// fraction of a yard over them — a glowing mount's hooves, a spell — is all peak and no
// shoulder at that scale, so the Gouraud sum lit whichever slab corners happened to sit under
// the light: a pool cut along the tile edges that jumped as the light moved. The vertex pick
// (`in.ext_sel`, flat) is unchanged, so the SET of lights and its cell agreement are too.
if ((in.word & WORD_WMO) != 0u && (in.word & WORD_INTERIOR) == 0u) {
point_lit = point_light_eval(in.ext_sel, in.world_position.xyz, n_lit);
}
// MONKEY (local-light consistency): the same per-fragment falloff for WMO and props.
// The vertex still ranks once and publishes its flat selection. Interior receivers
// publish EXT_SEL_NONE and keep their room-light lane.
var point_lit = point_light_eval(in.ext_sel, in.world_position.xyz, n_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(
@@ -1660,8 +1636,8 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
ext_room_far = 1.0 - smoothstep(EXT_ROOM_FADE_START, EXT_ROOM_FADE_END, ext_room_d);
}
// GL_COLOR_MATERIAL: MOCV multiplies the lit terms INSIDE the clamp, emission adds
// beside. MONKEY (wmo exterior points): `in.point_lit` is zero on INTERIOR groups (the
// reference's own vertex-stage zeroing — they light per fragment below) and carries the
// beside. MONKEY (wmo exterior points): `point_lit` is zero on INTERIOR groups (their
// EXT_SEL_NONE — they light from the room lane below) and carries the
// exterior nearest-3 point term on EXTERIOR-class ones, which is what makes a street
// torch reach the cobbles. The clamp is why it costs nothing in daylight.
let primary = clamp(
@@ -1735,8 +1711,8 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
// 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
// MONKEY (ext-class night law): `point_lit` joins the rolloff. It is a HARD ZERO on
// every interior batch (EXT_SEL_NONE 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
+10 -8
View File
@@ -24,20 +24,18 @@ mod pool;
mod render;
mod shadow; // MONKEY (world shadows): CPU triangle collection for the static-world shadow caster
mod torch_depth; // MONKEY (torch shadows Phase 1): the per-fixture depth-map render + its targets
pub use shadow::CutoutBucket; // MONKEY (world shadows): per-leaf-texture alpha-cutout caster group
pub use shadow::caster_in_reach; // MONKEY (B035): bounds-based caster reach, shared with the entity lane
pub use shadow::CutoutBucket; // MONKEY (world shadows): per-leaf-texture alpha-cutout caster group
pub use torch_depth::TorchShadowViews; // MONKEY (torch shadows Phase 1): the app→render publication
// MONKEY (torch owner exclusion): the light→caster ownership key, written at the light spawn
// sites (`terrain_stream::spawn`, `benilla_app::entities::carried_light`) and read by both
// torch caster gathers, so a fixture never casts its own body's shadow into its own map.
// MONKEY (torch owner exclusion): the light→caster ownership key, written at the light spawn
// sites (`terrain_stream::spawn`, `benilla_app::entities::carried_light`) and read by both
// torch caster gathers, so a fixture never casts its own body's shadow into its own map.
pub use torch_depth::{torch_flame_inside_bounds, LightOwner};
// MONKEY (torch shadows Phase 3A): the shared depth image + table buffer every model material binds,
// their startup constructor, the one-param main-world accessor, and the always-on wiring — used by
// the asset foundation (`crate::assets`) and every material-building site; NOT gated on `enabled()`.
pub use torch_depth::{
new_torch_shared, SharedTorchBuffer, TorchDepthImage, TorchShared,
};
pub(crate) use torch_depth::register_shared as register_torch_shared;
pub use torch_depth::{new_torch_shared, SharedTorchBuffer, TorchDepthImage, TorchShared};
/// The doodad spatial cell, a quarter ADT tile (133⅓ yd), as `terrain_stream::merge::CELL`.
const CELL: f32 = 533.333_3 / 4.0;
@@ -741,6 +739,10 @@ fn log_foliage_classification(path: &str) {
pub struct StaticGxPlugin;
/// Residency diagnostics read admission after this frame's retained scene walk.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct MonkeyStaticGxCullSet;
impl Plugin for StaticGxPlugin {
fn build(&self, app: &mut App) {
if !enabled() {
@@ -755,7 +757,7 @@ impl Plugin for StaticGxPlugin {
// exile shows the next frame, ticked. `chain_ignore_deferred` adds no sync points.
(
bake::flush_static_gx,
cull::cull_cells,
cull::cull_cells.in_set(MonkeyStaticGxCullSet),
render::publish_gx_world,
)
.chain_ignore_deferred()