From ef6b5e673e388a5ba7c73a43f01312d6c3b14a4a Mon Sep 17 00:00:00 2001 From: pkuzic Date: Thu, 8 Oct 2026 23:30:16 +0100 Subject: [PATCH] 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 (cherry picked from commit 801087822f0925a43352bb925e8c1154383c4377) --- .../benilla-assets/src/shaders/terrain.wgsl | 110 +++++++----------- .../benilla-assets/src/shaders/wow_model.wgsl | 57 +++------ .../src/lighting/global_light.rs | 55 ++++++++- .../benilla-world/src/shaders/static_gx.wgsl | 66 ++++------- crates/benilla-world/src/static_gx/mod.rs | 18 +-- 5 files changed, 136 insertions(+), 170 deletions(-) diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl index f9c6f4d0..16f3df2c 100644 --- a/crates/benilla-assets/src/shaders/terrain.wgsl +++ b/crates/benilla-assets/src/shaders/terrain.wgsl @@ -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) -> 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, P: vec3, N: vec3) -> f32 { @@ -176,11 +177,23 @@ fn torch_terrain_shadow(light_pos: vec3, P: vec3, N: vec3) -> 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, P: vec3, N: vec3) -> 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, @location(4) color: vec4, @location(5) specular: vec3, - // 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, // 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, // 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, - // 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, - // MONKEY (moon shadows): preserve the point sum BEFORE saturation; primary cannot recover it. - @location(10) point_lit: vec3, } // 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, box: f32) -> vec4 { // 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, P: vec3, N: vec3) -> vec3 { var sum = vec3(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(0.0), - vec3(1.0), - ); + out.primary = clamp(out.base_lit, vec3(0.0), vec3(1.0)); return out; } @@ -646,11 +640,8 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 { 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 { 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(0.0), - vec3(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(0.0), vec3(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(0.0), vec3(1.0)); } // GFX (moonlight): ADD the moon (cool, N·L, shadowed by the moon's own map) onto the stock diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index 8c81182a..815fa8bf 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -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) -> 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, P: vec3, N: vec3, 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, #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, box: f32) -> vec4 { // 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, P: vec3, N: vec3) -> vec3 { var sum = vec3(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(0.0); } else if (m.model_flags.x > 0.5 || m.model_flags.z > 0.5) { - out.point_lit = vec3(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( diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index 059db127..d1a5dd3d 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -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> = 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 = 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); } } diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index ad42eca3..caf191bf 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -405,7 +405,7 @@ fn torch_face(d: vec3) -> 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, P: vec3, N: vec3, 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, box: f32) -> vec4 { // 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, P: vec3, N: vec3) -> vec3 { var sum = vec3(0.0); for (var s = 0u; s < EXT_SEL_K; s = s + 1u) { @@ -1283,10 +1283,9 @@ struct GxVsOut { @location(1) world_normal: vec3, @location(2) uv: vec2, @location(3) @interpolate(flat) word: u32, - @location(4) point_lit: vec3, @location(5) color: vec4, // 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(0.0, 1.0, 0.0); out.uv = vec2(0.0); out.word = v.word; - out.point_lit = vec3(0.0); out.color = vec4(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(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(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 { // 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 { 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 { // 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 diff --git a/crates/benilla-world/src/static_gx/mod.rs b/crates/benilla-world/src/static_gx/mod.rs index 77b30e7c..af7bfc1d 100644 --- a/crates/benilla-world/src/static_gx/mod.rs +++ b/crates/benilla-world/src/static_gx/mod.rs @@ -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()