Skip to main content

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}