concinnity_core/gfx/cull_status.rs
1//! The per-object outcome vocabulary the GPU cull writes into its status
2//! buffer, and the host-side histogram a readback of that buffer reduces to.
3//!
4//! The status buffer is the only record of what the GPU-driven cull actually
5//! decided: the submitted draw-call count is a CPU-side number that does not
6//! move when an object is rejected on the GPU, and the Hi-Z pyramid leaves no
7//! trace in the presented pixels of an object it correctly occluded. Reading
8//! the buffer back and tallying it here is what gives a Hi-Z change a
9//! behavioural oracle.
10//!
11//! Values mirror the `STATUS_*` constants in `cull.slang`; a test below reads
12//! that shader source and asserts the two agree.
13
14use alloc::vec::Vec;
15
16/// The per-object outcomes the GPU cull records in its status buffer. Metal's
17/// ICB encode kernel is told which one to draw rather than declaring them
18/// itself.
19pub struct CullStatus;
20
21impl CullStatus {
22 /// Visible in phase 1, drawn by the main pass.
23 pub const DRAWN: u32 = 0;
24 /// Hi-Z-occluded in phase 1; the only outcome phase 2 re-tests. Under
25 /// single-pass occlusion no phase 2 runs, so this is the settled outcome
26 /// of a Hi-Z rejection.
27 pub const HIZ_CANDIDATE: u32 = 1;
28 /// Frustum-, distance- or disabled-culled; settled.
29 pub const CULLED: u32 = 2;
30 /// A candidate phase 2 found visible, drawn by the disocclusion pass.
31 pub const REDRAW: u32 = 3;
32 /// A candidate phase 2 found still occluded by this frame's own depth.
33 pub const HIZ_CULLED: u32 = 4;
34}
35
36/// A tally of one frame's cull-status buffer: how many objects landed in each
37/// outcome. Produced by [`tally`] from a raw readback.
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
39pub struct CullStatusCounts {
40 /// Objects [`CullStatus::DRAWN`]: passed every phase-1 test.
41 pub drawn: u32,
42 /// Objects [`CullStatus::CULLED`]: rejected by the frustum, the per-object
43 /// cull distance, or a clear enable bit. Never Hi-Z.
44 pub frustum_culled: u32,
45 /// Objects left at [`CullStatus::HIZ_CANDIDATE`]: Hi-Z-occluded in phase 1
46 /// and not re-tested. Under two-pass occlusion a non-zero count here means
47 /// phase 2 did not run over those objects.
48 pub hiz_candidate: u32,
49 /// Objects [`CullStatus::REDRAW`]: Hi-Z-occluded in phase 1, found visible
50 /// against the rebuilt pyramid, drawn by the disocclusion pass.
51 pub redrawn: u32,
52 /// Objects [`CullStatus::HIZ_CULLED`]: Hi-Z-occluded in phase 1 and still
53 /// occluded in phase 2.
54 pub hiz_culled: u32,
55 /// Entries carrying a value no `STATUS_*` constant names. Non-zero means
56 /// the buffer was read past the live object count, or was never written.
57 pub unknown: u32,
58}
59
60impl CullStatusCounts {
61 /// Objects the cull let through to a draw, over both phases.
62 pub fn visible(self) -> u32 {
63 self.drawn + self.redrawn
64 }
65
66 /// Objects the Hi-Z test rejected, whether or not phase 2 re-tested them.
67 /// The number a Hi-Z A/B compares.
68 pub fn hiz_rejected(self) -> u32 {
69 self.hiz_candidate + self.hiz_culled
70 }
71
72 /// Every entry tallied, across all outcomes.
73 pub fn total(self) -> u32 {
74 self.drawn
75 + self.frustum_culled
76 + self.hiz_candidate
77 + self.redrawn
78 + self.hiz_culled
79 + self.unknown
80 }
81}
82
83/// Reduce a raw cull-status readback to per-outcome counts.
84pub fn tally(raw: &[u32]) -> CullStatusCounts {
85 let mut c = CullStatusCounts::default();
86 for &status in raw {
87 let slot = match status {
88 CullStatus::DRAWN => &mut c.drawn,
89 CullStatus::CULLED => &mut c.frustum_culled,
90 CullStatus::HIZ_CANDIDATE => &mut c.hiz_candidate,
91 CullStatus::REDRAW => &mut c.redrawn,
92 CullStatus::HIZ_CULLED => &mut c.hiz_culled,
93 _ => &mut c.unknown,
94 };
95 *slot += 1;
96 }
97 c
98}
99
100/// Decode a byte-oriented readback into the `u32` statuses [`tally`] consumes,
101/// truncating to `count` objects. The backends map their status buffer as raw
102/// bytes; the trailing capacity past the live object count holds whatever the
103/// last resize left there, so the caller's live cull count is the length that
104/// matters. Returns `Err` when the mapping is too short for `count`.
105pub fn decode(bytes: &[u8], count: usize) -> Result<Vec<u32>, &'static str> {
106 if bytes.len() < count * core::mem::size_of::<u32>() {
107 return Err("cull-status readback is shorter than the live object count");
108 }
109 Ok(bytes[..count * core::mem::size_of::<u32>()]
110 .chunks_exact(4)
111 .map(|c| u32::from_ne_bytes([c[0], c[1], c[2], c[3]]))
112 .collect())
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn tally_counts_every_outcome() {
121 let raw = [
122 CullStatus::DRAWN,
123 CullStatus::DRAWN,
124 CullStatus::CULLED,
125 CullStatus::HIZ_CANDIDATE,
126 CullStatus::REDRAW,
127 CullStatus::HIZ_CULLED,
128 CullStatus::HIZ_CULLED,
129 ];
130 let c = tally(&raw);
131 assert_eq!(c.drawn, 2);
132 assert_eq!(c.frustum_culled, 1);
133 assert_eq!(c.hiz_candidate, 1);
134 assert_eq!(c.redrawn, 1);
135 assert_eq!(c.hiz_culled, 2);
136 assert_eq!(c.unknown, 0);
137 assert_eq!(c.total(), raw.len() as u32);
138 assert_eq!(c.visible(), 3);
139 assert_eq!(c.hiz_rejected(), 3);
140 }
141
142 #[test]
143 fn tally_of_nothing_is_all_zero() {
144 assert_eq!(tally(&[]), CullStatusCounts::default());
145 assert_eq!(tally(&[]).total(), 0);
146 }
147
148 #[test]
149 fn unnamed_status_values_land_in_unknown() {
150 // An unwritten buffer is the case this guards: a probe reading a
151 // status region the cull never dispatched over must not silently
152 // report those objects as DRAWN-adjacent.
153 let c = tally(&[5, 7, u32::MAX]);
154 assert_eq!(c.unknown, 3);
155 assert_eq!(c.total(), 3);
156 assert_eq!(c.visible(), 0);
157 assert_eq!(c.hiz_rejected(), 0);
158 }
159
160 #[test]
161 fn decode_truncates_to_the_live_object_count() {
162 let mut bytes = Vec::new();
163 for v in [CullStatus::DRAWN, CullStatus::CULLED, 9u32] {
164 bytes.extend_from_slice(&v.to_ne_bytes());
165 }
166 let decoded = decode(&bytes, 2).expect("two objects fit");
167 assert_eq!(decoded, [CullStatus::DRAWN, CullStatus::CULLED]);
168 assert_eq!(tally(&decoded).unknown, 0);
169 }
170
171 #[test]
172 fn decode_rejects_a_short_mapping() {
173 let bytes = [0u8; 4];
174 assert!(decode(&bytes, 2).is_err());
175 assert!(decode(&bytes, 1).is_ok());
176 assert!(decode(&[], 0).is_ok());
177 }
178
179 // The shader is the authority on these values: the Rust constants exist so
180 // the host can name them, and Metal's encode kernel is handed one of them
181 // as a uniform. Read `cull.slang` and assert every `STATUS_*` it declares
182 // matches, so a shader edit that renumbers one fails here rather than
183 // silently mis-tallying a readback.
184 #[test]
185 fn constants_match_cull_slang() {
186 let declared = parse_shader_statuses(crate::render::shaders::CULL);
187 let expected = [
188 ("STATUS_DRAWN", CullStatus::DRAWN),
189 ("STATUS_HIZ_CANDIDATE", CullStatus::HIZ_CANDIDATE),
190 ("STATUS_CULLED", CullStatus::CULLED),
191 ("STATUS_REDRAW", CullStatus::REDRAW),
192 ("STATUS_HIZ_CULLED", CullStatus::HIZ_CULLED),
193 ];
194 assert_eq!(
195 declared.len(),
196 expected.len(),
197 "cull.slang declares {} STATUS_* constants, the host names {}: {declared:?}",
198 declared.len(),
199 expected.len(),
200 );
201 for (name, value) in expected {
202 let found = declared
203 .iter()
204 .find(|(n, _)| n == name)
205 .unwrap_or_else(|| panic!("cull.slang declares no {name}"));
206 assert_eq!(found.1, value, "{name} disagrees with cull.slang");
207 }
208 }
209
210 // Scrape `static const uint STATUS_<NAME> = <N>u;` declarations.
211 fn parse_shader_statuses(src: &str) -> Vec<(alloc::string::String, u32)> {
212 src.lines()
213 .filter_map(|line| {
214 let rest = line.trim().strip_prefix("static const uint STATUS_")?;
215 let (name, rest) = rest.split_once('=')?;
216 let value = rest.split_once(';')?.0.trim().trim_end_matches('u');
217 Some((
218 alloc::format!("STATUS_{}", name.trim()),
219 value.parse().ok()?,
220 ))
221 })
222 .collect()
223 }
224}