Skip to main content

awsm_renderer/dynamic_materials/
widths.rs

1//! Per-frame encoding widths derived from the **live** bucket count.
2//!
3//! See `docs/plans/increase-materials.md` §0 + §3. The central insight:
4//! every GPU encoding width below is a pure function of the *live*
5//! `bucket_count`, NOT the configured registration cap — so the typical
6//! (<16 material) scene pays exactly what it pays today, and a width only
7//! grows once the live count crosses its boundary.
8//!
9//! These helpers are the single source of truth the classify shader, the
10//! edge shaders, and the edge-buffer sizing all agree on, so the two GPU
11//! encodings (classify `tile_mask` words ↔ edge `edge_slot_map` bits) can
12//! never diverge for a given live count.
13
14/// Largest registration cap [`BucketConfig`] accepts (`0xFFFE`). The top
15/// two 16-bit values (`0xFFFE` skybox / `0xFFFF` empty) are reserved as the
16/// widened edge-slot sentinels (§5), so the highest usable bucket index is
17/// `0xFFFD` and the cap is `0xFFFE` buckets.
18pub const MAX_BUCKET_ENTRIES_CEILING: u32 = 0xFFFE;
19
20/// Default registration cap — identical to today's `MAX_BUCKET_WORDS * 32`,
21/// so behavior is unchanged unless [`BucketConfig`] is set on the builder.
22pub const DEFAULT_MAX_BUCKET_ENTRIES: u32 = 32;
23
24/// Runtime tunable for the **registration ceiling** — how many co-resident
25/// material buckets the registry will accept (`docs/plans/increase-materials.md`
26/// §2, Option B). It sizes NOTHING per-frame: every GPU encoding width is a
27/// pure function of the *live* bucket count (see [`classify_mask_words`] and
28/// the edge-slot helpers), so raising the cap costs nothing until the live
29/// count actually grows. Set via `AwsmRendererBuilder::with_bucket_config`.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct BucketConfig {
32    /// Max co-resident buckets the registry will accept. Default 32
33    /// (== today). Valid range `1..=65534`. Values >254 require — and (§5)
34    /// automatically enable — the 16-bit edge packing at runtime.
35    pub max_bucket_entries: u32,
36}
37
38impl Default for BucketConfig {
39    fn default() -> Self {
40        Self {
41            max_bucket_entries: DEFAULT_MAX_BUCKET_ENTRIES,
42        }
43    }
44}
45
46impl BucketConfig {
47    /// Validates the cap is in `1..=65534`. Called by the builder so a bad
48    /// config fails fast rather than producing a registry that can mint a
49    /// bucket index the edge encoding can't represent.
50    pub fn validate(&self) -> Result<(), String> {
51        if self.max_bucket_entries < 1 || self.max_bucket_entries > MAX_BUCKET_ENTRIES_CEILING {
52            return Err(format!(
53                "BucketConfig.max_bucket_entries = {} is out of range (must be 1..={})",
54                self.max_bucket_entries, MAX_BUCKET_ENTRIES_CEILING
55            ));
56        }
57        Ok(())
58    }
59}
60
61/// Number of `atomic<u32>` words the classify `tile_mask` workgroup array
62/// needs to hold one bit per live bucket (32 bits per word).
63///
64/// `1` at `live_bucket_count <= 32` → identical to today's single-word
65/// form. A workgroup-array size must be a compile-time constant in WGSL,
66/// so this value is templated into the shader and is therefore part of the
67/// classify cache key (via `bucket_entries`, whose length determines it).
68pub fn classify_mask_words(live_bucket_count: u32) -> u32 {
69    live_bucket_count.div_ceil(32).max(1)
70}
71
72/// Width (in bits) of each per-sample bucket-index field packed into the
73/// edge `edge_slot_map` (§5). `8` while the live count fits the 8-bit
74/// sentinels (`0xFE` skybox / `0xFF` empty → 254 usable) — byte-identical
75/// to today; `16` once the count exceeds 254 (`0xFFFE`/`0xFFFF` sentinels →
76/// up to 65534). A pure function of the live count, so the 8-bit path costs
77/// nothing until the count actually crosses 254.
78pub fn edge_slot_bits(live_bucket_count: u32) -> u8 {
79    if live_bucket_count <= 254 {
80        8
81    } else {
82        16
83    }
84}
85
86/// u32 words per edge pixel in the `edge_slot_map` region: 4 samples ×
87/// `edge_slot_bits` / 32. 8-bit → 1 word, 16-bit → 2 words.
88pub fn edge_slot_words_per_edge(live_bucket_count: u32) -> u32 {
89    match edge_slot_bits(live_bucket_count) {
90        8 => 1,
91        _ => 2,
92    }
93}
94
95// Packed edge-slot sentinels. The 8-bit pair is unchanged from today; the
96// 16-bit pair is used once the live count exceeds 254. Classify packs the
97// truncated form (`full_u32_sentinel & mask`); the resolve shaders compare
98// the unpacked field against these.
99pub const EDGE_SENTINEL_SKYBOX_8: u32 = 0xFE;
100pub const EDGE_SENTINEL_EMPTY_8: u32 = 0xFF;
101pub const EDGE_SENTINEL_SKYBOX_16: u32 = 0xFFFE;
102pub const EDGE_SENTINEL_EMPTY_16: u32 = 0xFFFF;
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn edge_slot_bits_flips_at_254() {
110        assert_eq!(edge_slot_bits(0), 8);
111        assert_eq!(edge_slot_bits(16), 8);
112        assert_eq!(edge_slot_bits(254), 8); // 254 usable in 8-bit
113        assert_eq!(edge_slot_bits(255), 16);
114        assert_eq!(edge_slot_bits(1024), 16);
115        assert_eq!(edge_slot_bits(65534), 16);
116        assert_eq!(edge_slot_words_per_edge(254), 1);
117        assert_eq!(edge_slot_words_per_edge(255), 2);
118        assert_eq!(edge_slot_words_per_edge(1024), 2);
119    }
120
121    #[test]
122    fn edge_slot_bits_can_represent_every_live_index() {
123        // Lockstep invariant (§5/§8): the chosen width must encode every
124        // bucket index 0..live-1 plus leave room for the two sentinels.
125        for &live in &[1u32, 16, 254, 255, 1024, 65534] {
126            let max_index = live - 1;
127            let bits = edge_slot_bits(live);
128            let sentinel_floor = if bits == 8 { 0xFEu32 } else { 0xFFFEu32 };
129            assert!(
130                max_index < sentinel_floor,
131                "live={live}: max index {max_index} collides with the {bits}-bit sentinel floor {sentinel_floor}"
132            );
133        }
134    }
135
136    #[test]
137    fn classify_mask_words_matches_today_at_small_counts() {
138        // The historical default cap is 32 → exactly one mask word, so the
139        // generated WGSL stays byte-identical to today for every typical
140        // scene. This is the §9.1 parity baseline guarantee.
141        assert_eq!(classify_mask_words(0), 1);
142        assert_eq!(classify_mask_words(1), 1);
143        assert_eq!(classify_mask_words(16), 1);
144        assert_eq!(classify_mask_words(32), 1);
145    }
146
147    #[test]
148    fn bucket_config_default_is_32_and_validates() {
149        let cfg = BucketConfig::default();
150        assert_eq!(cfg.max_bucket_entries, 32);
151        assert!(cfg.validate().is_ok());
152    }
153
154    #[test]
155    fn bucket_config_validation_bounds() {
156        assert!(BucketConfig {
157            max_bucket_entries: 1
158        }
159        .validate()
160        .is_ok());
161        assert!(BucketConfig {
162            max_bucket_entries: 254
163        }
164        .validate()
165        .is_ok());
166        assert!(BucketConfig {
167            max_bucket_entries: 1024
168        }
169        .validate()
170        .is_ok());
171        assert!(BucketConfig {
172            max_bucket_entries: 65534
173        }
174        .validate()
175        .is_ok());
176        // Out of range.
177        assert!(BucketConfig {
178            max_bucket_entries: 0
179        }
180        .validate()
181        .is_err());
182        assert!(BucketConfig {
183            max_bucket_entries: 65535
184        }
185        .validate()
186        .is_err());
187    }
188
189    #[test]
190    fn classify_mask_words_grows_one_word_per_32_buckets() {
191        assert_eq!(classify_mask_words(33), 2);
192        assert_eq!(classify_mask_words(64), 2);
193        assert_eq!(classify_mask_words(65), 3);
194        assert_eq!(classify_mask_words(254), 8); // ceil(254/32) = 8
195        assert_eq!(classify_mask_words(255), 8);
196        assert_eq!(classify_mask_words(256), 8);
197        assert_eq!(classify_mask_words(1024), 32); // ceil(1024/32) = 32
198        assert_eq!(classify_mask_words(65534), 2048); // ceil(65534/32)
199    }
200}