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