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)
This commit is contained in:
pkuzic
2026-10-09 12:44:04 +01:00
parent 78a23dc446
commit fcd4d37a15
3 changed files with 262 additions and 5 deletions
@@ -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<f32>` 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::<u32>()) as u64
(MONKEY_TABLE_WORDS * std::mem::size_of::<u32>()) 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<bool> = 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.
@@ -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<usize> {
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<_>>(),
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());
}
}
@@ -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<f32>(0.0);
var fill = vec3<f32>(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