From 1a502177d004ac02cdcaad68204b9e325bb88b66 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 9 Oct 2026 17:28:38 +0100
Subject: [PATCH] MONKEY (wmo lamp pick): enhanced exterior WMO lamps are
ranked at the fragment, not the provoking vertex
The enhanced exterior WMO point-light term evaluated falloff per fragment but used the
flat lamp selection of the provoking vertex (snapped to its 33 yd MCNK cell), so a large
wall or paving triangle took one vertex's whole lamp pool and neighbours differed: long
straight-edged dark wedges on Stormwind's gate ramp and yard walls at dusk/night. Ranks
the closest exterior lamps at the fragment (static_gx + fallback wow_model), shared by
the shadowed and unshadowed evaluations. Classic (all light gains 0) keeps the vertex
pick and its packed frame bits; capture scenarios stormwind-training-yard-*/gate-ramp-*.
---
crates/benilla-app/src/capture/scenarios.rs | 49 ++++++++++++++++
.../src/shaders/monkey_frame.wgsl | 4 +-
.../benilla-assets/src/shaders/wow_model.wgsl | 35 ++++++------
.../src/lighting/global_light.rs | 57 ++++++++++++++++++-
.../benilla-world/src/shaders/static_gx.wgsl | 33 ++++++-----
5 files changed, 140 insertions(+), 38 deletions(-)
diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs
index 9d254c9c..c51e45e9 100644
--- a/crates/benilla-app/src/capture/scenarios.rs
+++ b/crates/benilla-app/src/capture/scenarios.rs
@@ -287,6 +287,55 @@ pub(super) const SUBJECT_INDOOR: [f32; 3] = [-9469.4, 31.9, 57.9];
/// Every other named viewpoint, capturable by name (`WOW_CAPTURE=`) but not in the sweep.
pub(super) const ON_DEMAND: &[Scenario] = &[
+ // MONKEY (wmo surface lights): the portcullis/archery yard and lamp-lined gate ramp.
+ Scenario {
+ name: "stormwind-training-yard-day",
+ map: Some(MAP_AZEROTH),
+ eye: [-8728.0, 341.0, 104.0],
+ look: [-8517.4951, 792.4295, 147.57787],
+ minute: 720,
+ ui: None,
+ },
+ Scenario {
+ name: "stormwind-training-yard-dusk",
+ map: Some(MAP_AZEROTH),
+ eye: [-8728.0, 341.0, 104.0],
+ look: [-8517.4951, 792.4295, 147.57787],
+ minute: 1110,
+ ui: None,
+ },
+ Scenario {
+ name: "stormwind-training-yard-night",
+ map: Some(MAP_AZEROTH),
+ eye: [-8728.0, 341.0, 104.0],
+ look: [-8517.4951, 792.4295, 147.57787],
+ minute: 0,
+ ui: None,
+ },
+ Scenario {
+ name: "stormwind-gate-ramp-day",
+ map: Some(MAP_AZEROTH),
+ eye: [-9095.0, 412.0, 124.0],
+ look: [-8818.3066, 632.09216, -229.55338],
+ minute: 720,
+ ui: None,
+ },
+ Scenario {
+ name: "stormwind-gate-ramp-dusk",
+ map: Some(MAP_AZEROTH),
+ eye: [-9095.0, 412.0, 124.0],
+ look: [-8818.3066, 632.09216, -229.55338],
+ minute: 1110,
+ ui: None,
+ },
+ Scenario {
+ name: "stormwind-gate-ramp-night",
+ map: Some(MAP_AZEROTH),
+ eye: [-9095.0, 412.0, 124.0],
+ look: [-8818.3066, 632.09216, -229.55338],
+ minute: 0,
+ ui: None,
+ },
// MONKEY (volumetric fog): UI proof uses the same live page selection as a player.
Scenario {
name: "ui-options-advanced", map: Some(MAP_AZEROTH),
diff --git a/crates/benilla-assets/src/shaders/monkey_frame.wgsl b/crates/benilla-assets/src/shaders/monkey_frame.wgsl
index c6dc31fe..015dd47c 100644
--- a/crates/benilla-assets/src/shaders/monkey_frame.wgsl
+++ b/crates/benilla-assets/src/shaders/monkey_frame.wgsl
@@ -18,7 +18,9 @@ struct MonkeyFrame {
wind_a: vec4, // dir_x, dir_y (unit, world XZ: .x = world x, .y = world z), base_heading (rad, fixed profile heading), gust 0..1
wind_b: vec4, // travel (yd, integrated speed, wrapped at 4096), sway_strength, grass_strength, tree_strength
wet_a: vec4, // rain_rate 0..1, wetness 0..1, ripple_time_s, snow 0..1
- misc: vec4, // bender_count, time_of_day 0..1 (packer), night 0..1 (packer), moon shadow confidence 0..1
+ // MONKEY (wmo surface lights): misc.x's integer is the bender count; its fraction is 0.5
+ // when enhanced exterior points are enabled, zero in Classic. Wind truncates to u32.
+ misc: vec4, // bender_count + exterior gate, time_of_day 0..1, night 0..1, moon shadow confidence
benders: array, 8>, // world xyz (Bevy space) + radius (yd); the first bender_count are live
// GFX (moonlight): unit direction toward the moon (Bevy space) + intensity (gamma; 0 = off).
moon: vec4,
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 815fa8bf..f903abcb 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -1195,19 +1195,12 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
// at its INSTANCE origin (wow-re wmo-surface-dynamic-light §6 — the receiving unit's own
// position, deliberately not the skinned per-vertex matrix).
if (m.model_flags.x > 0.5 && m.model_flags.z < 0.5) {
- // MONKEY (wmo exterior points): an EXTERIOR-class WMO group (`model_flags.z` clear = MOGP
- // `& 0x48` set) is a street, a courtyard, a porch — drawn by the exterior law, the same law
- // the terrain beside it is drawn by, so it takes the exterior point term terrain takes. The
- // §2 "zero on every WMO surface" finding was measured on abbey INTERIOR rooms and reading
- // 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.
- // MONKEY (outdoor torch shadows): the pick is PUBLISHED so the fragment stage can shadow
- // this same selection at night without re-ranking. One table walk still, not two -
- // the sum was always `eval(pick(..))`, it is just no longer inlined.
- let sel = 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`.
+ // MONKEY (wmo surface lights): Classic retains its MCNK vertex pick. Enhanced WMO
+ // picks once at the fragment instead; no per-vertex table walk on that lane.
+ out.ext_sel = EXT_SEL_NONE;
+ if (fract(wow_light.monkey.misc.x) == 0.0) {
+ out.ext_sel = wmo_exterior_pick(out.world_position.xyz);
+ }
} else if (m.model_flags.x > 0.5 || m.model_flags.z > 0.5) {
out.ext_sel = EXT_SEL_NONE;
} else {
@@ -1607,16 +1600,20 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
}
}
let sidn_e = m.sidn.rgb * (wow_light.grade.x * sidn_w);
- // 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);
+ // MONKEY (wmo surface lights): a city WMO spans many terrain cells. A flat vertex pick
+ // draws its lamp pool on whole triangles; a snapped cell pick moves the seam to the grid.
+ // Enhanced exterior WMO ranks at the receiving fragment, within the existing 48 yd reach.
+ // Props keep their placement pick, interior receivers their room lane, Classic its old pick.
+ var exterior_selection = in.ext_sel;
+ if (is_wmo && !is_interior && fract(wow_light.monkey.misc.x) > 0.0) {
+ exterior_selection = point_light_pick(in.world_position.xyz, 0.0);
+ }
+ var point_diffuse = point_light_eval(exterior_selection, 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(
point_diffuse,
- point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit),
+ point_light_eval_shadowed(exterior_selection, in.world_position.xyz, n_lit),
ext_night_w,
);
}
diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs
index f5ee39d6..16936926 100644
--- a/crates/benilla-world/src/lighting/global_light.rs
+++ b/crates/benilla-world/src/lighting/global_light.rs
@@ -756,10 +756,18 @@ fn pack_monkey_frame(
frame: Res,
clock: Res,
light: Res,
+ fire_gain: Res,
+ spell_gain: Res,
+ lava_gain: Option>,
mut data: ResMut,
) {
let night = 1.0 - sun_shadow_strength(light.celestial_dir.y);
- let packed = frame.pack(clock.minute as f32 / 1440.0, night);
+ let mut packed = frame.pack(clock.minute as f32 / 1440.0, night);
+ // MONKEY (wmo surface lights): wind truncates misc.x to u32, leaving its fraction free.
+ // All enhanced point gains zero (Classic) preserves the old pick and frame bits.
+ if fire_gain.0 > 0.0 || spell_gain.0 > 0.0 || lava_gain.is_some_and(|gain| gain.0 > 0.0) {
+ packed[7][0] += 0.5;
+ }
if data.0.monkey != packed {
data.0.monkey = packed;
}
@@ -2381,6 +2389,53 @@ mod tests {
assert_eq!(gpu.len(), ROOM_CLAIM_STRIDE, "2 head + 6 ids + 6 x 4 fade");
}
+ /// MONKEY (wmo surface lights): Classic keeps all frame bits, and the enhanced gate must
+ /// never alter the wind shader's integer bender count or any other programme row.
+ #[test]
+ fn the_wmo_surface_gate_preserves_classic_and_wind_counts() {
+ let mut app = App::new();
+ app.init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .insert_resource(FireLightGain(0.0))
+ .insert_resource(SpellLightGain(0.0))
+ .insert_resource(super::super::LavaLightGain(0.0))
+ .add_systems(Update, pack_monkey_frame);
+ for count in 0..=8 {
+ app.world_mut()
+ .resource_mut::()
+ .bender_count = count;
+ let frame = app.world().resource::();
+ let classic = frame.pack(0.0, 1.0);
+ for gains in [
+ [0.0, 0.0, 0.0],
+ [0.25, 0.0, 0.0],
+ [0.0, 0.25, 0.0],
+ [0.0, 0.0, 0.25],
+ [1.0, 1.0, 1.0],
+ [0.0, 0.0, 0.0],
+ ] {
+ app.world_mut().resource_mut::().0 = gains[0];
+ app.world_mut().resource_mut::().0 = gains[1];
+ app.world_mut()
+ .resource_mut::()
+ .0 = gains[2];
+ app.update();
+ let packed = app.world().resource::().0.monkey;
+ assert_eq!(
+ packed[7][0] as u32, count,
+ "wind count must truncate unchanged"
+ );
+ let mut expected = classic;
+ if gains.iter().any(|gain| *gain > 0.0) {
+ expected[7][0] += 0.5;
+ }
+ assert_eq!(packed, expected, "only the enhanced gate may move");
+ }
+ }
+ }
+
/// GOLDEN — MONKEY (fire GO lights): `fireLightGain` scales SYNTHESISED sources and **only**
/// them, at pack time.
///
diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl
index e1c2c332..78459839 100644
--- a/crates/benilla-world/src/shaders/static_gx.wgsl
+++ b/crates/benilla-world/src/shaders/static_gx.wgsl
@@ -1424,18 +1424,12 @@ fn vertex(v: GxVertex) -> GxVsOut {
// Gouraud: straight-edged wedges, and a torch mid-triangle lights nothing.
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.
- // 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.
- let sel = wmo_exterior_pick(world);
- 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.
+ // MONKEY (wmo surface lights): Classic retains its MCNK vertex pick. Enhanced WMO
+ // picks once at the fragment instead; no per-vertex table walk on that lane.
+ out.ext_sel = EXT_SEL_NONE;
+ if (fract(wow_light.monkey.misc.x) == 0.0) {
+ out.ext_sel = wmo_exterior_pick(world);
+ }
} 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.
@@ -1489,15 +1483,20 @@ 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 (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);
+ // MONKEY (wmo surface lights): a city WMO spans many terrain cells. A flat vertex pick
+ // draws its lamp pool on whole triangles; a snapped cell pick moves the seam to the grid.
+ // Enhanced exterior WMO ranks at the receiving fragment, within the existing 48 yd reach.
+ // Props keep their placement pick, interior receivers their room lane, Classic its old pick.
+ var exterior_selection = in.ext_sel;
+ if ((in.word & WORD_WMO) != 0u && (in.word & WORD_INTERIOR) == 0u && fract(wow_light.monkey.misc.x) > 0.0) {
+ exterior_selection = point_light_pick(in.world_position.xyz, 0.0);
+ }
+ var point_lit = point_light_eval(exterior_selection, 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(
point_lit,
- point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit),
+ point_light_eval_shadowed(exterior_selection, in.world_position.xyz, n_lit),
ext_night_w,
);
}