Skip to main content

roxlap_gpu/
fow.rs

1//! FW.3 — the fog-of-war `fog_mask` storage-buffer packer, owned here
2//! (the crate that owns the WGSL `FOG_*` constants) so the header layout
3//! has ONE source. `roxlap-render` and the headless tests both call
4//! [`pack_fog_mask`] instead of hand-rolling the header — a layout
5//! change lands in one place, not three.
6//!
7//! Buffer layout (mirrors `scene_dda.wgsl`'s `FOG_*` consts):
8//! word 0 `FOG_ENABLED` (0 = off) · 1 `FOG_GRID` (per-grid slot) · 2
9//! `FOG_DECKS_N` · 3–4 origin cell (i32) · 5 width · 6 height · 7–8
10//! `memory_dim`/`memory_desaturate` (f32 bits) · 9.. up to
11//! [`FOG_MAX_DECKS`] `(z_top, z_bottom)` i32 pairs · then
12//! `FOG_ACTIVE_DECK` (the observer's deck — every voxel is classified
13//! against it, not its own z) · [`FOG_HEADER_WORDS`].. deck-major,
14//! row-major mask bytes packed 4 cells / u32.
15
16/// Header word count before the packed cells (`FOG_CELLS_BASE` in WGSL).
17pub const FOG_HEADER_WORDS: usize = 18;
18/// Max decks the shader's `fog_mask` deck loop scans (`FOG_MAX_DECKS`).
19pub const FOG_MAX_DECKS: usize = 4;
20/// Word index of the observer's active deck (`FOG_ACTIVE_DECK` in WGSL),
21/// just past the [`FOG_MAX_DECKS`] band pairs (`9 + 2*4`).
22pub const FOG_ACTIVE_DECK: usize = 17;
23
24/// FW.3 — pack a fog mask into `fog_mask` storage-buffer words.
25///
26/// - `grid_index` — the per-grid camera slot the shader gates on
27///   (`FOG_GRID`).
28/// - `origin_cell` — grid-local cell of the buffer's `(0, 0)`.
29/// - `width` / `height` — buffer size in cells.
30/// - `decks` — `(z_top, z_bottom)` inclusive bands. **Truncated to
31///   [`FOG_MAX_DECKS`]** (with a `debug_assert` + one-time warn): the
32///   shader only scans that many, so a taller deck stack would silently
33///   drop its top floors — the truncation is now loud.
34/// - `active_deck` — the observer's deck; every rendered voxel is
35///   classified against this layer regardless of its own z (so stairs
36///   and sub-floor seen through a hole read on the deck you stand on).
37///   Clamped to the deck count.
38/// - `cells` — deck-major, row-major mask bytes (`d*w*h + y*w + x`).
39///
40/// Returns `FOG_HEADER_WORDS + ceil(cells.len()/4)` words.
41#[must_use]
42pub fn pack_fog_mask(
43    grid_index: u32,
44    origin_cell: [i32; 2],
45    width: u32,
46    height: u32,
47    decks: &[[i32; 2]],
48    active_deck: usize,
49    memory_dim: f32,
50    memory_desaturate: f32,
51    cells: &[u8],
52) -> Vec<u32> {
53    if decks.len() > FOG_MAX_DECKS {
54        // Loud (not a silent drop): the shader scans only FOG_MAX_DECKS,
55        // so a taller stack would lose its top floors on GPU with no log.
56        use std::sync::atomic::{AtomicBool, Ordering};
57        static WARNED: AtomicBool = AtomicBool::new(false);
58        if !WARNED.swap(true, Ordering::Relaxed) {
59            log::warn!(
60                "fog-of-war mask has {} decks; the GPU shader scans only {FOG_MAX_DECKS} — \
61                 decks {FOG_MAX_DECKS}+ render Hidden on GPU (split the ship or raise \
62                 FOG_MAX_DECKS)",
63                decks.len(),
64            );
65        }
66    }
67    let deck_count = decks.len().min(FOG_MAX_DECKS);
68    let cell_words = cells.len().div_ceil(4);
69    let mut w = vec![0u32; FOG_HEADER_WORDS + cell_words];
70    w[0] = 1; // FOG_ENABLED
71    w[1] = grid_index; // FOG_GRID
72    w[2] = deck_count as u32; // FOG_DECKS_N
73    w[3] = origin_cell[0] as u32; // FOG_ORIGIN_X (bitcast i32)
74    w[4] = origin_cell[1] as u32; // FOG_ORIGIN_Y
75    w[5] = width; // FOG_WIDTH
76    w[6] = height; // FOG_HEIGHT
77    w[7] = memory_dim.to_bits(); // FOG_MEM_DIM
78    w[8] = memory_desaturate.to_bits(); // FOG_MEM_DESAT
79    for (d, band) in decks.iter().take(FOG_MAX_DECKS).enumerate() {
80        w[9 + d * 2] = band[0] as u32; // z_top
81        w[9 + d * 2 + 1] = band[1] as u32; // z_bottom
82    }
83    // Clamp the active deck to a valid layer so the shader index is
84    // always in-bounds even if a caller passes a stale deck.
85    w[FOG_ACTIVE_DECK] = active_deck.min(deck_count.saturating_sub(1)) as u32;
86    for (i, chunk) in cells.chunks(4).enumerate() {
87        let mut word = 0u32;
88        for (j, &b) in chunk.iter().enumerate() {
89            word |= u32::from(b) << (j * 8);
90        }
91        w[FOG_HEADER_WORDS + i] = word;
92    }
93    w
94}
95
96/// FW.3 — the disabled fog buffer (`FOG_ENABLED == 0`): the shader reads
97/// one word and skips, so a scene with no fog is byte-identical. Bound as
98/// the dummy at build and written when `FrameParams::fow` clears.
99#[must_use]
100pub fn disabled_fog_mask() -> Vec<u32> {
101    vec![0u32; 4]
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn header_layout() {
110        let w = pack_fog_mask(
111            3,
112            [-128, 256],
113            2,
114            1,
115            &[[10, 20], [30, 40]],
116            1,
117            0.5,
118            0.25,
119            &[0xC1, 0x02, 0x83, 0x00],
120        );
121        assert_eq!(w[0], 1);
122        assert_eq!(w[1], 3);
123        assert_eq!(w[2], 2);
124        assert_eq!(w[3] as i32, -128);
125        assert_eq!(w[4], 256);
126        assert_eq!(w[5], 2);
127        assert_eq!(w[6], 1);
128        assert_eq!(f32::from_bits(w[7]), 0.5);
129        assert_eq!(f32::from_bits(w[8]), 0.25);
130        assert_eq!((w[9] as i32, w[10] as i32), (10, 20));
131        assert_eq!((w[11] as i32, w[12] as i32), (30, 40));
132        assert_eq!(w[FOG_ACTIVE_DECK], 1, "active deck");
133        assert_eq!(w[18], 0x0083_02C1);
134        assert_eq!(w.len(), 19);
135    }
136
137    #[test]
138    fn truncates_excess_decks() {
139        let decks = [[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]]; // 5 > MAX 4
140        let w = pack_fog_mask(0, [0, 0], 1, 1, &decks, 0, 1.0, 0.0, &[0]);
141        assert_eq!(w[2], FOG_MAX_DECKS as u32, "deck count clamps to MAX");
142    }
143
144    #[test]
145    fn active_deck_clamps_to_deck_count() {
146        // A stale/over-range active deck must not index past the layers.
147        let w = pack_fog_mask(0, [0, 0], 1, 1, &[[0, 1], [2, 3]], 9, 1.0, 0.0, &[0]);
148        assert_eq!(w[FOG_ACTIVE_DECK], 1, "clamped to last valid deck");
149    }
150}