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}