Skip to main content

concinnity_render/render_graph/
validate.rs

1// src/render_graph/validate.rs
2//
3// Barrier-coverage check over a `CompiledGraph`. The compile pass derives
4// `barriers_before` by walking each resource's timeline; this module replays
5// those barriers in execution order and checks the resulting state against what
6// each pass's read / write declarations require. The two directions are
7// structurally independent -- a per-resource timeline versus a per-pass replay --
8// so a deriver bug (a dropped transition, a mis-ordered run, a read-run stage
9// union that misses a consumer) shows up as a gap here.
10//
11// The check is pure and GPU-free, so it runs both as a headless sweep over the
12// `FrameGraphInputs` space and as a per-frame `debug_assertions` assertion inside
13// each backend executor, where it covers the graphs a test sweep never builds.
14
15use super::compile::CompiledGraph;
16use super::passes::PassId;
17use super::types::{ReadStages, ResourceState};
18use alloc::vec;
19use alloc::vec::Vec;
20
21// What kind of coverage a pass is missing for one resource.
22#[derive(Copy, Clone, Debug, Eq, PartialEq)]
23pub enum GapKind {
24    // The pass reads the resource, but the replayed barrier state is not `Read`:
25    // no transition made the producing write visible to this consumer.
26    UncoveredRead,
27    // The pass writes the resource, but the replayed barrier state is not `Write`:
28    // no transition opened it for writing.
29    UncoveredWrite,
30    // The resource is in `Read`, but the barrier that opened the read run does not
31    // name this consumer's shader stage, so the producing write was never made
32    // visible to it.
33    MissingReadStage,
34}
35
36// One pass / resource pair whose declared access is not covered by a barrier.
37#[derive(Copy, Clone, Debug, Eq, PartialEq)]
38pub struct BarrierGap {
39    pub pass: PassId,
40    pub resource_label: &'static str,
41    pub kind: GapKind,
42}
43
44impl core::fmt::Display for BarrierGap {
45    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
46        let what = match self.kind {
47            GapKind::UncoveredRead => "reads",
48            GapKind::UncoveredWrite => "writes",
49            GapKind::MissingReadStage => "reads (stage not in the run union)",
50        };
51        write!(f, "pass {:?} {} {}", self.pass, what, self.resource_label)
52    }
53}
54
55// Every declared access in `graph` that no barrier covers, in execution order.
56// Empty for a correctly compiled graph.
57#[cfg(test)]
58pub(crate) fn barrier_coverage_gaps(graph: &CompiledGraph) -> Vec<BarrierGap> {
59    gaps_over(graph, &|_| true)
60}
61
62/// The same check restricted to the resources `driven[resource_index]` marks, i.e.
63/// the ones a backend executor resolves to a native target and emits transitions
64/// for. A backend calls this on the graphs a real session builds, so it covers the
65/// input combinations a headless sweep does not reach, and it asserts specifically
66/// that everything the backend claims to drive is fully covered. Resources outside
67/// the driven set keep whatever synchronisation their encoder owns and are skipped.
68pub fn barrier_coverage_gaps_for_driven(graph: &CompiledGraph, driven: &[bool]) -> Vec<BarrierGap> {
69    gaps_over(graph, &|i| driven.get(i).copied().unwrap_or(false))
70}
71
72/// The access each resource is left in once the graph's last barrier for it has
73/// run, indexed by resource id: the state plus the stage union that barrier
74/// carried, since a `Read`'s native state can depend on its consuming stages.
75/// `(Undefined, empty)` for a resource no barrier touches.
76///
77/// A backend pairs this with its own state translation to check the cross-frame
78/// contract: a resource whose first-use transition names a resting state must
79/// actually end the frame in it, or the next frame's producer barrier declares a
80/// source state the resource is not in.
81pub fn final_states(graph: &CompiledGraph) -> Vec<(ResourceState, ReadStages)> {
82    let mut state = vec![(ResourceState::Undefined, ReadStages::empty()); graph.resources.len()];
83    for pass in &graph.passes {
84        for op in &pass.barriers_before {
85            state[op.resource_index()] = (op.to_state(), op.read_stages());
86        }
87    }
88    state
89}
90
91fn gaps_over(graph: &CompiledGraph, driven: &dyn Fn(usize) -> bool) -> Vec<BarrierGap> {
92    let mut state = vec![ResourceState::Undefined; graph.resources.len()];
93    // Stage union carried by the barrier that opened each resource's current read
94    // run; meaningless unless that resource is in `Read`.
95    let mut run_stages = vec![ReadStages::empty(); graph.resources.len()];
96    let mut gaps = Vec::new();
97
98    for pass in &graph.passes {
99        for op in &pass.barriers_before {
100            let i = op.resource_index();
101            state[i] = op.to_state();
102            if op.to_state() == ResourceState::Read {
103                run_stages[i] = op.read_stages();
104            }
105        }
106
107        let stage = ReadStages::for_pass_kind(pass.kind);
108        // A pass that writes a resource leaves it in `Write` whether or not it also
109        // reads it, so writes are checked first and shadow the read check.
110        for w in &pass.writes {
111            let i = w.resource_index();
112            if !driven(i) {
113                continue;
114            }
115            if state[i] != ResourceState::Write {
116                gaps.push(BarrierGap {
117                    pass: pass.id,
118                    resource_label: graph.resources[i].label,
119                    kind: GapKind::UncoveredWrite,
120                });
121            }
122        }
123        for r in &pass.reads {
124            let i = r.resource_index();
125            if !driven(i) || pass.writes.iter().any(|w| w.resource_index() == i) {
126                continue;
127            }
128            if state[i] != ResourceState::Read {
129                gaps.push(BarrierGap {
130                    pass: pass.id,
131                    resource_label: graph.resources[i].label,
132                    kind: GapKind::UncoveredRead,
133                });
134            } else if !run_stages[i].contains(stage) {
135                gaps.push(BarrierGap {
136                    pass: pass.id,
137                    resource_label: graph.resources[i].label,
138                    kind: GapKind::MissingReadStage,
139                });
140            }
141        }
142    }
143
144    gaps
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::render_graph::builder::GraphBuilder;
151    use crate::render_graph::frame::{FrameGraphInputs, build_frame_graph};
152    use crate::render_graph::types::{
153        PassKind, PixelFormat, TextureDesc, TextureSize, TextureUsage,
154    };
155    use alloc::string::ToString;
156
157    // Every gated flag on `FrameGraphInputs`, so the sweep below can name the
158    // combination that failed rather than reporting an opaque struct. Extend when a
159    // gated pass is added; the sweep is only as wide as this table.
160    use super::super::frame::GATED_FLAGS as FLAGS;
161
162    // Compile the graph for `combo` and assert it has no barrier gaps.
163    fn assert_covered(combo: &[usize]) {
164        let mut inputs = FrameGraphInputs::all_off();
165        for &f in combo {
166            FLAGS[f].1(&mut inputs);
167        }
168        let names: Vec<&str> = combo.iter().map(|&f| FLAGS[f].0).collect();
169        let graph = build_frame_graph(&inputs)
170            .unwrap_or_else(|e| panic!("graph failed to compile for {names:?}: {e}"));
171        let gaps = barrier_coverage_gaps(&graph);
172        assert!(
173            gaps.is_empty(),
174            "barrier gaps for {names:?}: {}",
175            gaps.iter()
176                .map(|g| g.to_string())
177                .collect::<Vec<_>>()
178                .join(", ")
179        );
180    }
181
182    #[test]
183    fn every_single_and_paired_flag_graph_is_fully_covered() {
184        // Exhaustive over the whole flag space is 2^24 graphs; singles + pairs is
185        // ~300 and catches the interaction bugs that matter (a pass inserted between
186        // a producer and its consumer, a substituted pass -- unified G-buffer for
187        // the split pre-passes, RT for SSR -- rerouting a read). The all-off and
188        // all-on ends are covered separately below.
189        assert_covered(&[]);
190        for a in 0..FLAGS.len() {
191            assert_covered(&[a]);
192            for b in (a + 1)..FLAGS.len() {
193                assert_covered(&[a, b]);
194            }
195        }
196    }
197
198    #[test]
199    fn the_driven_subset_check_ignores_resources_outside_it() {
200        // A backend drives only the resources its registry resolves; the rest keep
201        // whatever synchronisation their encoder owns. Stripping a barrier for an
202        // undriven resource must stay silent, and the same strip on a driven one
203        // must report -- otherwise the subset check would either alarm on every
204        // partially-migrated frame or never alarm at all.
205        let mut g = GraphBuilder::new();
206        let a = g.create_texture("a", tex());
207        let b = g.create_texture("b", tex());
208        let (a1, b1) = {
209            let mut p = g.add_pass(PassId::Main, PassKind::Render);
210            (p.write_texture(a), p.write_texture(b))
211        };
212        g.add_pass(PassId::Composite, PassKind::Render)
213            .read_texture(a1)
214            .read_texture(b1)
215            .presents();
216        let mut g = g.compile().expect("compiles");
217        let composite = g.passes.len() - 1;
218        g.passes[composite].barriers_before.clear();
219
220        let only_a = {
221            let mut d = vec![false; g.resources.len()];
222            d[a.resource.index()] = true;
223            d
224        };
225        let gaps = barrier_coverage_gaps_for_driven(&g, &only_a);
226        assert_eq!(gaps.len(), 1, "{gaps:?}");
227        assert_eq!(gaps[0].resource_label, "a");
228
229        let none = vec![false; g.resources.len()];
230        assert_eq!(barrier_coverage_gaps_for_driven(&g, &none), vec![]);
231    }
232
233    #[test]
234    fn every_frame_graph_resource_classifies_as_its_backend_expects() {
235        // Both explicit backends now take a resource's barrier class from the
236        // graph instead of restating it, so a desc edit here silently changes what
237        // transitions they emit. These are the classes the executors' translators
238        // are written against; changing one means changing the translator too.
239        use crate::render_graph::types::GraphResourceClass as C;
240
241        let mut inputs = FrameGraphInputs::all_off();
242        for (name, set) in FLAGS {
243            if *name != "world_hidden" {
244                set(&mut inputs);
245            }
246        }
247        // Multisampled, so the separate `hdr_color` attachment is in the graph;
248        // without MSAA the single colour target is the spine and only
249        // `hdr_resolve` is declared.
250        inputs.hdr_sample_count = 4;
251        let graph = build_frame_graph(&inputs).expect("compiles");
252
253        let expected = [
254            ("draw_args", C::IndirectBuffer),
255            ("draw_args2", C::IndirectBuffer),
256            ("cull_status", C::UnorderedBuffer),
257            ("cluster_light_list", C::StorageBuffer),
258            ("ao_output", C::ColorTarget),
259            ("shadow_map", C::DepthTarget),
260            ("spot_shadow_map", C::DepthTarget),
261            ("fog_froxel_volume", C::StorageImage),
262            ("hdr_depth", C::DepthTarget),
263            ("hdr_color", C::ColorTarget),
264            ("hiz_pyramid", C::StorageImage),
265            // The unified pre-pass's four attachments are four resources, and
266            // the depth one is why: it is a different class from its three
267            // colour siblings, so one handle could not have carried it.
268            ("gbuffer_normal_depth", C::ColorTarget),
269            ("gbuffer_roughness", C::ColorTarget),
270            ("gbuffer_velocity", C::ColorTarget),
271            ("gbuffer_depth", C::DepthTarget),
272        ];
273        for (label, want) in expected {
274            let res = graph
275                .resources
276                .iter()
277                .find(|r| r.label == label)
278                .unwrap_or_else(|| panic!("{label} missing from the fully-loaded graph"));
279            assert_eq!(res.class(), Some(want), "{label}");
280        }
281
282        // Nothing in the graph may be unclassifiable: a resource with no class
283        // gets no registry entry and so silently loses its barriers.
284        for res in &graph.resources {
285            assert!(res.class().is_some(), "{} has no class", res.label);
286        }
287    }
288
289    #[test]
290    fn the_fully_loaded_graph_is_covered() {
291        // Every gated pass at once except `world_hidden`, which masks them all off
292        // (its collapsed graph is covered as a single above).
293        let all: Vec<usize> = (0..FLAGS.len())
294            .filter(|&f| FLAGS[f].0 != "world_hidden")
295            .collect();
296        assert_covered(&all);
297    }
298
299    fn tex() -> TextureDesc {
300        TextureDesc::texture_2d(
301            TextureSize::Drawable,
302            TextureSize::Drawable,
303            PixelFormat::Rgba16Float,
304            TextureUsage::SHADER_READ | TextureUsage::RENDER_TARGET,
305        )
306    }
307
308    #[test]
309    fn a_well_formed_graph_has_no_gaps() {
310        let mut g = GraphBuilder::new();
311        let t = g.create_texture("t", tex());
312        let t1 = g.add_pass(PassId::Main, PassKind::Render).write_texture(t);
313        g.add_pass(PassId::Composite, PassKind::Render)
314            .read_texture(t1)
315            .presents();
316        let g = g.compile().expect("compiles");
317        assert_eq!(barrier_coverage_gaps(&g), vec![]);
318    }
319
320    #[test]
321    fn a_mixed_stage_read_run_is_covered_for_both_consumers() {
322        // The case the read-stage union exists for: one write consumed by a compute
323        // pass and a render pass. A single producer barrier must name both stages,
324        // or the second consumer races the write.
325        let mut g = GraphBuilder::new();
326        let t = g.create_texture("t", tex());
327        let t1 = g.add_pass(PassId::Main, PassKind::Render).write_texture(t);
328        g.add_pass(PassId::AutoExposure, PassKind::Compute)
329            .read_texture(t1);
330        g.add_pass(PassId::Composite, PassKind::Render)
331            .read_texture(t1)
332            .presents();
333        let g = g.compile().expect("compiles");
334        assert_eq!(barrier_coverage_gaps(&g), vec![]);
335    }
336
337    #[test]
338    fn a_dropped_barrier_is_reported() {
339        // Negative control: strip the consumer's barrier and the replay must flag
340        // the read it no longer covers. Without this the test above could pass on a
341        // validator that never reports anything.
342        let mut g = GraphBuilder::new();
343        let t = g.create_texture("t", tex());
344        let t1 = g.add_pass(PassId::Main, PassKind::Render).write_texture(t);
345        g.add_pass(PassId::Composite, PassKind::Render)
346            .read_texture(t1)
347            .presents();
348        let mut g = g.compile().expect("compiles");
349        let composite = g.passes.len() - 1;
350        g.passes[composite].barriers_before.clear();
351
352        let gaps = barrier_coverage_gaps(&g);
353        assert_eq!(gaps.len(), 1, "{gaps:?}");
354        assert_eq!(gaps[0].kind, GapKind::UncoveredRead);
355        assert_eq!(gaps[0].pass, PassId::Composite);
356        assert_eq!(gaps[0].resource_label, "t");
357    }
358
359    #[test]
360    fn a_read_run_missing_a_consumer_stage_is_reported() {
361        // Negative control for the stage union: narrow the producer barrier to the
362        // fragment stage only and the compute consumer must be flagged, even though
363        // the resource is correctly in `Read`.
364        let mut g = GraphBuilder::new();
365        let t = g.create_texture("t", tex());
366        let t1 = g.add_pass(PassId::Main, PassKind::Render).write_texture(t);
367        g.add_pass(PassId::AutoExposure, PassKind::Compute)
368            .read_texture(t1);
369        g.add_pass(PassId::Composite, PassKind::Render)
370            .read_texture(t1)
371            .presents();
372        let mut g = g.compile().expect("compiles");
373        for pass in &mut g.passes {
374            for op in &mut pass.barriers_before {
375                if op.to_state() == ResourceState::Read {
376                    op.read_stages = ReadStages::FRAGMENT;
377                }
378            }
379        }
380
381        let gaps = barrier_coverage_gaps(&g);
382        assert_eq!(gaps.len(), 1, "{gaps:?}");
383        assert_eq!(gaps[0].kind, GapKind::MissingReadStage);
384        assert_eq!(gaps[0].pass, PassId::AutoExposure);
385    }
386}