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