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, ); }