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}