From fcd4d37a150d5ab27f8a7fb8edf28797520dddc4 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 9 Oct 2026 12:44:04 +0100
Subject: [PATCH] MONKEY (r19 F4, lighting): per-frame light scan replaced by a
room light index (Naxxramas stutter)
Lighting part of benilla-everwood 736e6a27 (monkey_room_index, global_light, static_gx shortlist).
(cherry picked from commit 736e6a27, capture + physics hunks omitted)
---
.../src/lighting/global_light.rs | 23 ++-
.../src/lighting/monkey_room_index.rs | 181 ++++++++++++++++++
.../benilla-world/src/shaders/static_gx.wgsl | 63 +++++-
3 files changed, 262 insertions(+), 5 deletions(-)
create mode 100644 crates/benilla-world/src/lighting/monkey_room_index.rs
diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs
index d1a5dd3d..f5ee39d6 100644
--- a/crates/benilla-world/src/lighting/global_light.rs
+++ b/crates/benilla-world/src/lighting/global_light.rs
@@ -20,6 +20,10 @@ use crate::dev_state::DebugState;
use crate::view::ViewDistance;
use crate::view::WorldCamera;
+#[path = "monkey_room_index.rs"]
+mod monkey_room_index;
+use monkey_room_index::{monkey_build_index, MONKEY_CLAIM_WORDS, MONKEY_TABLE_WORDS};
+
/// The shared light, std430-packed as `vec4` rows (all `vec4`, so std430 equals std140). Every
/// shader that binds the buffer (`wow_model`, `terrain`, `liquid`, `wdl`, `wow_effect`,
/// `static_gx`) mirrors this row order as a prefix; keep them in sync.
@@ -322,19 +326,19 @@ pub const CLAIM_ENTRY_SHIFT: u32 = 17;
/// three shaders plus the portrait booth and must never be resized, and only `static_gx` reads
/// this. `static_gx::render` owns the buffer and the binding.
#[derive(Resource, Clone, ExtractResource)]
-pub struct RoomClaimTable(pub Box<[u32; ROOM_CLAIM_STRIDE * MAX_POINT_LIGHTS]>);
+pub struct RoomClaimTable(pub Box<[u32; MONKEY_TABLE_WORDS]>);
/// MONKEY (room gate): the claim table's byte size — the one place `static_gx::render` sizes its
/// GPU buffer from, so the table cannot grow here and leave the binding short (a bound storage
/// buffer smaller than the shader's runtime-sized array fails validation at draw time, which
/// vanishes every building).
pub fn room_claim_bytes() -> u64 {
- (ROOM_CLAIM_STRIDE * MAX_POINT_LIGHTS * std::mem::size_of::()) as u64
+ (MONKEY_TABLE_WORDS * std::mem::size_of::()) as u64
}
impl Default for RoomClaimTable {
fn default() -> Self {
- Self(Box::new([0; ROOM_CLAIM_STRIDE * MAX_POINT_LIGHTS]))
+ Self(Box::new([0; MONKEY_TABLE_WORDS]))
}
}
@@ -1659,9 +1663,20 @@ fn build_light_data(
// Entries past the count are stale in the point table by design (the count row guards every
// reader) — but the claim table is read at the SAME index, so a stale head there would gate a
// live light with a dead building's identity. Clear the tail instead of trusting the count.
- for slot in claims.0[pts.len() * ROOM_CLAIM_STRIDE..].iter_mut() {
+ for slot in claims.0[pts.len() * ROOM_CLAIM_STRIDE..MONKEY_CLAIM_WORDS].iter_mut() {
*slot = 0;
}
+ // The appended room bitmap is a conservative shortlist; the shader still applies every
+ // selected light's exact claim/fade predicate, in the original ascending light order.
+ static MONKEY_ROOM_INDEX_ENABLED: std::sync::OnceLock = std::sync::OnceLock::new();
+ if *MONKEY_ROOM_INDEX_ENABLED
+ .get_or_init(|| std::env::var("WOW_MONKEY_ROOM_INDEX").as_deref() != Ok("0"))
+ {
+ monkey_build_index(&mut claims.0[..], &fresh.points, pts.len());
+ } else {
+ // Zero header selects the unchanged full-scan shader path for same-build measurement.
+ claims.0[MONKEY_CLAIM_WORDS..].fill(0);
+ }
// `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.
diff --git a/crates/benilla-world/src/lighting/monkey_room_index.rs b/crates/benilla-world/src/lighting/monkey_room_index.rs
new file mode 100644
index 00000000..ec69bc12
--- /dev/null
+++ b/crates/benilla-world/src/lighting/monkey_room_index.rs
@@ -0,0 +1,181 @@
+//! MONKEY: invert packed room claims once per frame, preserving light order and exact gate weights.
+use super::{MAX_POINT_LIGHTS, ROOM_CLAIM_MAX, ROOM_CLAIM_STRIDE};
+
+pub(super) const MONKEY_CLAIM_WORDS: usize = ROOM_CLAIM_STRIDE * MAX_POINT_LIGHTS;
+pub(super) const MONKEY_INDEX_SLOTS: usize = 4096;
+pub(super) const MONKEY_MASK_WORDS: usize = MAX_POINT_LIGHTS / 32;
+pub(super) const MONKEY_INDEX_STRIDE: usize = 2 + MONKEY_MASK_WORDS;
+pub(super) const MONKEY_UNGATED: usize = MONKEY_CLAIM_WORDS + 1;
+pub(super) const MONKEY_INDEX_START: usize = MONKEY_UNGATED + MONKEY_MASK_WORDS;
+pub(super) const MONKEY_TABLE_WORDS: usize =
+ MONKEY_INDEX_START + MONKEY_INDEX_SLOTS * MONKEY_INDEX_STRIDE;
+
+fn monkey_hash(instance: u32, group: u32) -> usize {
+ ((instance.wrapping_mul(0x9e37_79b9) ^ group.wrapping_mul(0x85eb_ca6b)) as usize)
+ & (MONKEY_INDEX_SLOTS - 1)
+}
+
+pub(super) fn monkey_build_index(table: &mut [u32], points: &[[f32; 4]], count: usize) {
+ table[MONKEY_CLAIM_WORDS..].fill(0);
+ table[MONKEY_CLAIM_WORDS] = 1;
+ for i in 0..count {
+ if points[2 * i + 1][3] < 0.5 {
+ continue;
+ }
+ let head = i * ROOM_CLAIM_STRIDE;
+ let instance = table[head];
+ let n = (table[head + 1] as usize).min(ROOM_CLAIM_MAX);
+ let bit = 1u32 << (i % 32);
+ let word = i / 32;
+ if n == 0 {
+ table[MONKEY_UNGATED + word] |= bit;
+ continue;
+ }
+ for claim in 0..n {
+ let group = table[head + 2 + claim] & 0xffff;
+ if group == 0 {
+ continue;
+ }
+ let mut slot = monkey_hash(instance, group);
+ loop {
+ let at = MONKEY_INDEX_START + slot * MONKEY_INDEX_STRIDE;
+ if table[at + 1] == 0 {
+ table[at] = instance;
+ table[at + 1] = group;
+ }
+ if table[at] == instance && table[at + 1] == group {
+ table[at + 2 + word] |= bit;
+ break;
+ }
+ slot = (slot + 1) & (MONKEY_INDEX_SLOTS - 1);
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ fn candidates(table: &[u32], instance: u32, group: u32, strict: bool) -> Vec {
+ let mut slot = monkey_hash(instance, group);
+ let at = loop {
+ let at = MONKEY_INDEX_START + slot * MONKEY_INDEX_STRIDE;
+ if table[at + 1] == 0 {
+ break None;
+ }
+ if table[at] == instance && table[at + 1] == group {
+ break Some(at);
+ }
+ slot = (slot + 1) & (MONKEY_INDEX_SLOTS - 1);
+ };
+ (0..MAX_POINT_LIGHTS)
+ .filter(|i| {
+ if group == 0 {
+ return !strict;
+ }
+ let mask = at.map_or(0, |a| table[a + 2 + i / 32])
+ | if strict {
+ 0
+ } else {
+ table[MONKEY_UNGATED + i / 32]
+ };
+ mask & (1 << (i % 32)) != 0
+ })
+ .collect()
+ }
+ #[test]
+ fn monkey_index_preserves_all_255_lights_gate_off_groups_and_sort_order() {
+ let mut table = vec![0; MONKEY_TABLE_WORDS];
+ let mut points = [[0.; 4]; 2 * MAX_POINT_LIGHTS];
+ for i in 0..255 {
+ points[2 * i + 1][3] = if i % 7 == 0 { 0. } else { 8. };
+ let head = i * ROOM_CLAIM_STRIDE;
+ if i % 9 != 0 {
+ table[head] = 3 + (i % 4) as u32;
+ table[head + 1] = ROOM_CLAIM_MAX as u32;
+ for k in 0..ROOM_CLAIM_MAX {
+ // Packed flags/portal entry weights must not affect the shortlist.
+ table[head + 2 + k] = (1 + ((i + k) % 31) as u32) | (0xff << 17) | (1 << 16);
+ }
+ }
+ }
+ monkey_build_index(&mut table, &points, 255);
+ for instance in 2..8 {
+ for group in 1..34 {
+ for strict in [false, true] {
+ let old: Vec<_> = (0..255)
+ .filter(|i| {
+ let head = i * ROOM_CLAIM_STRIDE;
+ points[2 * i + 1][3] >= 0.5
+ && ((table[head + 1] == 0 && !strict)
+ || (table[head] == instance
+ && (0..table[head + 1] as usize)
+ .any(|k| table[head + 2 + k] & 0xffff == group)))
+ })
+ .collect();
+ assert_eq!(candidates(&table, instance, group, strict), old);
+ }
+ }
+ }
+ // A second frame with fewer lights retires every old rank and room.
+ monkey_build_index(&mut table, &points, 1);
+ assert!(candidates(&table, 3, 1, false).is_empty());
+ }
+ #[test]
+ fn monkey_room_zero_ungated_and_soft_claims_keep_the_original_gate_authoritative() {
+ let mut table = vec![0; MONKEY_TABLE_WORDS];
+ let mut points = [[0.; 4]; 2 * MAX_POINT_LIGHTS];
+ for i in 0..4 {
+ points[2 * i + 1][3] = 8.;
+ }
+ // Rank 0 is ungated. Ranks 1/2 claim room 1, with different strict/fade flags.
+ for i in 1..3 {
+ let head = i * ROOM_CLAIM_STRIDE;
+ table[head] = 3;
+ table[head + 1] = 1;
+ table[head + 2] = 1 | if i == 1 { (1 << 16) | (128 << 17) } else { 0 };
+ table[head + 8 + 3] = 1024; // a four-yard portal fade
+ }
+ // Rank 3 belongs to a different building and must never enter room (3,1).
+ table[3 * ROOM_CLAIM_STRIDE] = 4;
+ table[3 * ROOM_CLAIM_STRIDE + 1] = 1;
+ table[3 * ROOM_CLAIM_STRIDE + 2] = 1;
+ monkey_build_index(&mut table, &points, 4);
+ assert_eq!(candidates(&table, 3, 1, false), vec![0, 1, 2]);
+ // Both claims remain conservative candidates even though strict rejects rank 2.
+ assert_eq!(candidates(&table, 3, 1, true), vec![1, 2]);
+ assert_eq!(candidates(&table, 3, 99, false), vec![0]);
+ assert!(candidates(&table, 3, 99, true).is_empty());
+ assert!(candidates(&table, 3, 0, true).is_empty());
+ assert_eq!(
+ candidates(&table, 3, 0, false)
+ .into_iter()
+ .filter(|i| *i < 4)
+ .collect::>(),
+ vec![0, 1, 2, 3]
+ );
+ // The index has neither changed strict eligibility nor the quantized portal fade.
+ assert_eq!(table[ROOM_CLAIM_STRIDE + 2], 1 | (1 << 16) | (128 << 17));
+ assert_eq!(table[ROOM_CLAIM_STRIDE + 8 + 3], 1024);
+ }
+ #[test]
+ fn monkey_index_handles_maximum_claim_population_and_hash_collisions() {
+ let mut table = vec![0; MONKEY_TABLE_WORDS];
+ let mut points = [[0.; 4]; 2 * MAX_POINT_LIGHTS];
+ for i in 0..255 {
+ points[2 * i + 1][3] = 4.;
+ table[i * ROOM_CLAIM_STRIDE] = 100 + i as u32;
+ table[i * ROOM_CLAIM_STRIDE + 1] = 6;
+ for k in 0..6 {
+ table[i * ROOM_CLAIM_STRIDE + 2 + k] = 1 + k as u32;
+ }
+ }
+ monkey_build_index(&mut table, &points, 255);
+ for i in 0..255 {
+ for group in 1..=6 {
+ assert_eq!(candidates(&table, 100 + i as u32, group, true), vec![i]);
+ }
+ }
+ assert!(candidates(&table, 999, 1, true).is_empty());
+ }
+}
diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl
index 5a69f584..e1c2c332 100644
--- a/crates/benilla-world/src/shaders/static_gx.wgsl
+++ b/crates/benilla-world/src/shaders/static_gx.wgsl
@@ -1085,6 +1085,50 @@ fn gx_room_inst() -> u32 {
fn gx_room_group(word: u32) -> u32 {
return (recs[word & 0xffffu].w >> RECORD_ROOM_SHIFT) & RECORD_ROOM_MASK;
}
+// MONKEY: the claim prefix is unchanged. The appended hash table shortlists lights by room;
+// flags and portal weights remain authoritative in interior_room_admits. Mirror monkey_room_index.rs.
+const MONKEY_CLAIM_WORDS: u32 = ROOM_CLAIM_STRIDE * 256u;
+const MONKEY_MASK_WORDS: u32 = 8u;
+const MONKEY_INDEX_SLOTS: u32 = 4096u;
+const MONKEY_INDEX_STRIDE: u32 = 2u + MONKEY_MASK_WORDS;
+const MONKEY_UNGATED: u32 = MONKEY_CLAIM_WORDS + 1u;
+const MONKEY_INDEX_START: u32 = MONKEY_UNGATED + MONKEY_MASK_WORDS;
+
+fn monkey_room_index(room_inst: u32, room_group: u32) -> u32 {
+ if (room_claims[MONKEY_CLAIM_WORDS] == 0u || room_group == 0u) {
+ return 0u;
+ }
+ var slot = ((room_inst * 0x9e3779b9u) ^ (room_group * 0x85ebca6bu)) & (MONKEY_INDEX_SLOTS - 1u);
+ for (var probe = 0u; probe < MONKEY_INDEX_SLOTS; probe = probe + 1u) {
+ let at = MONKEY_INDEX_START + slot * MONKEY_INDEX_STRIDE;
+ if (room_claims[at + 1u] == 0u) {
+ return 0u;
+ }
+ if (room_claims[at] == room_inst && room_claims[at + 1u] == room_group) {
+ return at;
+ }
+ slot = (slot + 1u) & (MONKEY_INDEX_SLOTS - 1u);
+ }
+ return 0u;
+}
+
+fn monkey_room_mask(at: u32, room_group: u32, strict: bool, word: u32) -> u32 {
+ // A zero header falls back to the original full table walk, including its exact lane gate.
+ if (room_claims[MONKEY_CLAIM_WORDS] == 0u) {
+ return 0xffffffffu;
+ }
+ if (room_group == 0u) {
+ return select(0xffffffffu, 0u, strict);
+ }
+ var bits = 0u;
+ if (!strict) {
+ bits = room_claims[MONKEY_UNGATED + word];
+ }
+ if (at != 0u) {
+ bits = bits | room_claims[at + 2u + word];
+ }
+ return bits;
+}
// MONKEY (room gate): `room_inst`/`room_group` are the RECEIVING surface's room (see
// `interior_room_admits`). `wow_model.wgsl`'s copy takes the same room arguments and stubs the
// predicate to `true` — it has neither the claim binding nor a per-fragment room key, which is
@@ -1101,7 +1145,24 @@ fn interior_room_light(
let k_fill = wow_light.point_count.z;
var direct = vec3(0.0);
var fill = vec3(0.0);
- for (var i = 0u; i < count; i = i + 1u) {
+ let monkey_at = monkey_room_index(room_inst, room_group);
+ // Ascending words and least-significant bits preserve the original floating-point sum order.
+ var monkey_word = 0u;
+ var monkey_bits = monkey_room_mask(monkey_at, room_group, strict, monkey_word);
+ loop {
+ if (monkey_bits == 0u) {
+ monkey_word = monkey_word + 1u;
+ if (monkey_word >= MONKEY_MASK_WORDS || monkey_word * 32u >= count) {
+ break;
+ }
+ monkey_bits = monkey_room_mask(monkey_at, room_group, strict, monkey_word);
+ continue;
+ }
+ let i = monkey_word * 32u + firstTrailingBit(monkey_bits);
+ monkey_bits = monkey_bits & (monkey_bits - 1u);
+ if (i >= count) {
+ break;
+ }
let color_lane = wow_light.points[2u * i + 1u];
// MONKEY (light lanes): only a fixture that CLAIMS a room lights this room (keep in sync
// with wow_model.wgsl). `.w` is 0 on every exterior source, so a campfire burning just