Skip to main content

concinnity_render/render_graph/
compile.rs

1// src/render_graph/compile.rs
2//
3// Frozen `CompiledGraph` produced by `GraphBuilder::compile`. The compile
4// pass:
5//
6//   1. Validates exactly one pass declares `presents()`.
7//   2. Validates every declared read has a producer (write) in the graph.
8//   3. Topologically sorts passes by read-after-write + write-after-write
9//      edges. Cycles are an error.
10//   4. Derives a `barriers_before` list per pass from the per-resource
11//      state machine (Undefined → Read → Write transitions).
12//   5. Computes a `[first, last]` pass-index lifetime per resource so a
13//      future aliaser can overlap non-overlapping lifetimes in
14//      physical memory.
15//
16// The graph does not yet allocate transient resources or interpret
17// barriers per-backend. This module only produces the data the backend
18// executor consumes.
19
20use alloc::collections::BinaryHeap;
21use alloc::vec;
22use alloc::vec::Vec;
23use core::cmp::Reverse;
24use hashbrown::HashMap;
25
26use super::builder::{GraphBuilder, ResourceVersion};
27use super::passes::PassId;
28use super::types::{
29    BarrierOp, BufferDesc, GraphResourceClass, PassKind, PassRange, ReadStages, ResourceId,
30    ResourceOrigin, ResourceState, TextureDesc,
31};
32
33// Compiler error. Returned by [`GraphBuilder::compile`]; the call site
34// typically panics (these are programmer errors, not runtime conditions).
35#[derive(Debug, Clone, PartialEq)]
36pub enum GraphError {
37    // No pass declared `presents()`. Every graph must terminate at the
38    // swapchain write.
39    MissingPresenter,
40    // More than one pass declared `presents()`. The graph compiler does
41    // not pick one for you.
42    MultiplePresenters(usize),
43    // A pass declared `read_*(handle)` but no pass writes that exact
44    // `(resource, version)` pair. Most commonly this happens when a
45    // caller forgets to thread the post-write handle from a prior pass
46    // (used the pre-write `h` instead of the `h1` returned by
47    // `write_texture`).
48    MissingProducer {
49        pass: PassId,
50        resource_label: &'static str,
51        version: u32,
52    },
53    // The read / write graph has a cycle: toposort visited fewer
54    // passes than declared.
55    Cycle,
56}
57
58impl core::fmt::Display for GraphError {
59    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
60        match self {
61            GraphError::MissingPresenter => {
62                write!(
63                    f,
64                    "no pass declared presents(); the graph has no terminal node"
65                )
66            }
67            GraphError::MultiplePresenters(n) => {
68                write!(f, "{} passes declared presents(); only one is allowed", n)
69            }
70            GraphError::MissingProducer {
71                pass,
72                resource_label,
73                version,
74            } => write!(
75                f,
76                "pass {:?} reads {} v{} but no pass writes that version",
77                pass, resource_label, version
78            ),
79            GraphError::Cycle => write!(f, "cycle in render-graph read/write edges"),
80        }
81    }
82}
83
84impl core::error::Error for GraphError {}
85
86/// One pass in execution order. Carries the declared reads / writes, the
87/// barriers the executor must emit before running this pass, and the
88/// `PassKind` + `PassId` the backend dispatches on.
89#[derive(Debug, Clone)]
90pub struct CompiledPass {
91    /// The pass's stable identity.
92    pub id: PassId,
93    /// Whether the executor encodes a render or a compute pass.
94    pub kind: PassKind,
95    /// Resource versions this pass reads.
96    pub reads: Vec<ResourceVersion>,
97    /// Resource versions this pass writes.
98    pub writes: Vec<ResourceVersion>,
99    /// Whether this pass writes the swapchain image.
100    pub presents: bool,
101    /// Barriers the executor emits before the pass.
102    pub barriers_before: Vec<BarrierOp>,
103}
104
105/// One resource in the compiled graph. Carries the lifetime interval
106/// (in compiled-pass-index space) and the origin distinction the
107/// aliaser will care about.
108#[derive(Debug, Clone)]
109pub struct CompiledResource {
110    /// The resource's stable label, the executor's join key.
111    pub label: &'static str,
112    /// Whether the resource is imported or graph-declared.
113    pub origin: ResourceOrigin,
114    /// The pass range over which the resource must stay live.
115    pub lifetime: PassRange,
116    /// Texture shape (format / size / sample count / layers), `None` for a
117    /// buffer. The aliasing planner uses it to size each transient resource;
118    /// the backend will use it to allocate the realised resource.
119    pub tex_desc: Option<TextureDesc>,
120    // Buffer shape (size / usage), `None` for a texture. Carried so a backend can
121    // derive the resource's barrier class from its declared usage.
122    pub(crate) buf_desc: Option<BufferDesc>,
123}
124
125impl CompiledResource {
126    /// The barrier class this resource's declared usage puts it in. The graph is
127    /// the single source of truth for it: a backend executor resolves a label to
128    /// its GPU object but never restates what kind of resource it is, so the
129    /// executors cannot disagree. `None` for a resource carrying neither desc.
130    pub fn class(&self) -> Option<GraphResourceClass> {
131        if let Some(desc) = self.tex_desc {
132            return Some(GraphResourceClass::for_texture_usage(desc.usage));
133        }
134        self.buf_desc
135            .map(|desc| GraphResourceClass::for_buffer_usage(desc.usage))
136    }
137}
138
139/// Frozen graph the per-backend executor consumes. Passes are in
140/// execution order; barriers are pre-derived; resource lifetimes are
141/// ready for a future aliaser.
142#[derive(Debug, Clone)]
143pub struct CompiledGraph {
144    /// Passes in execution order.
145    pub passes: Vec<CompiledPass>,
146    /// Resources, indexed by [`ResourceId`].
147    pub resources: Vec<CompiledResource>,
148}
149
150impl CompiledGraph {
151    // Restrict a pass's `barriers_before` to the resources whose label is in
152    // `allow`, pairing each kept barrier with that label. A backend executor
153    // uses this to drive native transitions for the subset of resources that
154    // have moved off hand-written inline barriers, while every other resource
155    // keeps its existing inline / render-pass-driven path. The label is the
156    // stable join key the backend resolves to its GPU object.
157    #[cfg(test)]
158    pub(crate) fn pass_barriers_for(
159        &self,
160        pass: &CompiledPass,
161        allow: &[&str],
162    ) -> Vec<(&'static str, BarrierOp)> {
163        pass.barriers_before
164            .iter()
165            .filter_map(|op| {
166                let label = self.resources[op.resource_index()].label;
167                allow.contains(&label).then_some((label, *op))
168            })
169            .collect()
170    }
171}
172
173impl GraphBuilder {
174    // Topologically sort, validate, and derive per-pass barriers.
175    // Returns the frozen graph the executor consumes. The graph must
176    // declare exactly one `presents()` pass: the terminal swapchain
177    // write that ends the frame.
178    pub(crate) fn compile(self) -> Result<CompiledGraph, GraphError> {
179        let GraphBuilder { resources, passes } = self;
180        let n_passes = passes.len();
181        let n_resources = resources.len();
182
183        // Step 1: presenter validation
184        let presenters: Vec<usize> = passes
185            .iter()
186            .enumerate()
187            .filter(|(_, p)| p.presents)
188            .map(|(i, _)| i)
189            .collect();
190        match presenters.len() {
191            0 => return Err(GraphError::MissingPresenter),
192            1 => {}
193            n => return Err(GraphError::MultiplePresenters(n)),
194        }
195
196        // Step 2: build (resource, version) -> writer-pass lookup
197        let mut writer_of: HashMap<(ResourceId, u32), usize> = HashMap::new();
198        for (i, pass) in passes.iter().enumerate() {
199            for w in &pass.writes {
200                // Each (resource, version) pair has exactly one writer
201                // by construction (write_texture bumps the version, so
202                // two passes can't claim the same one).
203                writer_of.insert((w.resource, w.version), i);
204            }
205        }
206
207        // Step 3: validate every read has a producer
208        // An imported resource at version 0 has an *implicit* producer
209        // (the engine that owns the GPU object). Reads of (imported, v0)
210        // are therefore always legal even though no graph pass writes
211        // them. Reads of (transient, v0) and reads at any version > 0
212        // still need a real producer in the same graph.
213        for pass in passes.iter() {
214            for r in &pass.reads {
215                if writer_of.contains_key(&(r.resource, r.version)) {
216                    continue;
217                }
218                let decl = &resources[r.resource.index()];
219                let implicit_producer = r.version == 0 && decl.origin() == ResourceOrigin::Imported;
220                if !implicit_producer {
221                    return Err(GraphError::MissingProducer {
222                        pass: pass.id,
223                        resource_label: decl.label(),
224                        version: r.version,
225                    });
226                }
227            }
228        }
229
230        // Step 4: build dependency edges and run Kahn's toposort
231        // edges[i] = list of passes that depend on i (must run after i).
232        let mut edges: Vec<Vec<usize>> = vec![Vec::new(); n_passes];
233        let mut in_degree: Vec<usize> = vec![0; n_passes];
234        let add_edge =
235            |from: usize, to: usize, edges: &mut [Vec<usize>], in_degree: &mut [usize]| {
236                if from != to {
237                    edges[from].push(to);
238                    in_degree[to] += 1;
239                }
240            };
241
242        // Build a (resource, version) → [reader pass_idxs] lookup so the
243        // WAR step below can find prior readers without rescanning every
244        // pass per write.
245        let mut readers_of: HashMap<(ResourceId, u32), Vec<usize>> = HashMap::new();
246        for (i, pass) in passes.iter().enumerate() {
247            for r in &pass.reads {
248                readers_of
249                    .entry((r.resource, r.version))
250                    .or_default()
251                    .push(i);
252            }
253        }
254
255        for (pass_idx, pass) in passes.iter().enumerate() {
256            // Read-after-write: each read of (resource, version) requires
257            // the pass that wrote (resource, version) to precede us.
258            for r in &pass.reads {
259                if let Some(&w) = writer_of.get(&(r.resource, r.version)) {
260                    add_edge(w, pass_idx, &mut edges, &mut in_degree);
261                }
262            }
263            // Write-after-write: a write that produced version V depends
264            // on the pass that produced version V-1 (the prior content
265            // matters for blend-style writes; for plain overwrites it
266            // doesn't but the ordering is still correct).
267            for w in &pass.writes {
268                if w.version > 1
269                    && let Some(&prev_writer) = writer_of.get(&(w.resource, w.version - 1))
270                {
271                    add_edge(prev_writer, pass_idx, &mut edges, &mut in_degree);
272                }
273            }
274            // Write-after-read: a write that produces version V requires
275            // every reader of version V-1 to precede us. Without this,
276            // a reader of the pre-write version could otherwise be
277            // re-ordered after the writer, sampling stale-from-the-other-
278            // direction data. In the Metal frame this fires on
279            // AutoExposure (reads `hdr_resolve_v1` produced by Main)
280            // pinning before Decals / Fog / ParticlesDraw (which bump
281            // hdr_resolve to v2+). Self-edges are filtered by add_edge.
282            for w in &pass.writes {
283                if w.version > 0
284                    && let Some(readers) = readers_of.get(&(w.resource, w.version - 1))
285                {
286                    for &reader in readers {
287                        add_edge(reader, pass_idx, &mut edges, &mut in_degree);
288                    }
289                }
290            }
291        }
292
293        // Stable order: when two passes are both ready, pick the one
294        // declared first. A min-heap on the original pass index achieves
295        // this without rescanning the ready set.
296        let mut ready: BinaryHeap<Reverse<usize>> = (0..n_passes)
297            .filter(|&i| in_degree[i] == 0)
298            .map(Reverse)
299            .collect();
300        let mut order: Vec<usize> = Vec::with_capacity(n_passes);
301        while let Some(Reverse(idx)) = ready.pop() {
302            order.push(idx);
303            for &neighbor in &edges[idx] {
304                in_degree[neighbor] -= 1;
305                if in_degree[neighbor] == 0 {
306                    ready.push(Reverse(neighbor));
307                }
308            }
309        }
310        if order.len() != n_passes {
311            return Err(GraphError::Cycle);
312        }
313
314        // Step 5: realise compiled passes in execution order
315        let mut compiled_passes: Vec<CompiledPass> = order
316            .iter()
317            .map(|&orig_idx| {
318                let decl = &passes[orig_idx];
319                CompiledPass {
320                    id: decl.id,
321                    kind: decl.kind,
322                    reads: decl.reads.clone(),
323                    writes: decl.writes.clone(),
324                    presents: decl.presents,
325                    barriers_before: Vec::new(),
326                }
327            })
328            .collect();
329
330        // Step 6: derive per-pass barriers
331        derive_barriers(&mut compiled_passes, n_resources);
332
333        // Step 7: compute resource lifetimes
334        let mut lifetimes: Vec<Option<PassRange>> = vec![None; n_resources];
335        for (sorted_idx, pass) in compiled_passes.iter().enumerate() {
336            for v in pass.writes.iter().chain(pass.reads.iter()) {
337                let i = v.resource.index();
338                let merged = match lifetimes[i] {
339                    None => PassRange {
340                        first: sorted_idx,
341                        last: sorted_idx,
342                    },
343                    Some(PassRange { first, .. }) => PassRange {
344                        first,
345                        last: sorted_idx,
346                    },
347                };
348                lifetimes[i] = Some(merged);
349            }
350        }
351
352        let compiled_resources: Vec<CompiledResource> = resources
353            .into_iter()
354            .enumerate()
355            .map(|(i, decl)| {
356                // A resource that's declared but never touched gets a
357                // degenerate `[0, 0]` lifetime; the executor can treat
358                // it as a leak warning later.
359                let lifetime = lifetimes[i].unwrap_or(PassRange { first: 0, last: 0 });
360                CompiledResource {
361                    label: decl.label(),
362                    origin: decl.origin(),
363                    lifetime,
364                    tex_desc: decl.texture_desc(),
365                    buf_desc: decl.buffer_desc(),
366                }
367            })
368            .collect();
369
370        Ok(CompiledGraph {
371            passes: compiled_passes,
372            resources: compiled_resources,
373        })
374    }
375}
376
377// Derive each pass's `barriers_before` from the per-resource state machine
378// (Undefined -> Read -> Write). Emit a `BarrierOp` whenever a resource's
379// effective access (Write if the pass writes it, else Read) differs from its
380// prior state.
381//
382// Effective-access rule: a pass that writes a resource leaves it in `Write`
383// state regardless of whether it also reads it (blend-style read-modify-write
384// is handled by the backend's render-pass setup, not by an intra-pass
385// barrier).
386//
387// Read-stage union: a `* -> Read` barrier carries the stage union of the WHOLE
388// following read-run (every pass that reads this version before the next
389// writer), and a `Read -> Write` barrier carries the prior run's union. This is
390// why the work is done per resource rather than per pass: a write read by a
391// compute consumer and a fragment consumer needs ONE producer barrier that
392// makes the write visible to both stages, so the deriver must see the whole run
393// before emitting that first barrier. A per-consumer read-to-read barrier would
394// not carry the producing write and so would not synchronise the second stage.
395fn derive_barriers(passes: &mut [CompiledPass], n_resources: usize) {
396    // Per-pass effective access to one resource: a write (which wins over any
397    // read the same pass declares) or a read in the pass's shader stage.
398    #[derive(Copy, Clone)]
399    enum Eff {
400        Write,
401        Read(ReadStages),
402    }
403
404    // Build each resource's timeline: its (pass index, effective access) pairs
405    // in pass order. The outer loop is ascending pass index and each pass
406    // contributes at most one entry per resource, so every timeline stays
407    // sorted by pass index without an explicit sort.
408    let mut timeline: Vec<Vec<(usize, Eff)>> = (0..n_resources).map(|_| Vec::new()).collect();
409    for (i, pass) in passes.iter().enumerate() {
410        let stage = ReadStages::for_pass_kind(pass.kind);
411        let mut access: HashMap<ResourceId, Eff> = HashMap::new();
412        for r in &pass.reads {
413            access.entry(r.resource).or_insert(Eff::Read(stage));
414        }
415        for w in &pass.writes {
416            access.insert(w.resource, Eff::Write);
417        }
418        // Push in resource-index order so each pass's `barriers_before` ends up
419        // sorted by resource index (the outer resource loop below is ascending),
420        // matching the deterministic order the executor expects.
421        let mut touched: Vec<(ResourceId, Eff)> = access.into_iter().collect();
422        touched.sort_by_key(|(r, _)| r.0);
423        for (res, eff) in touched {
424            timeline[res.index()].push((i, eff));
425        }
426    }
427
428    // Walk each resource's timeline, tracking its running state and the current
429    // read-run's stage union, emitting a barrier on each state change.
430    for (r_idx, entries) in timeline.iter().enumerate() {
431        let resource = ResourceId(r_idx as u32);
432        let mut state = ResourceState::Undefined;
433        let mut run_stages = ReadStages::empty();
434
435        for (k, &(pass_idx, eff)) in entries.iter().enumerate() {
436            match eff {
437                Eff::Write => {
438                    // Emitted for every write, including a write that follows a
439                    // write. Each pass contributes at most one timeline entry per
440                    // resource, so back-to-back `Write` entries are always
441                    // different passes, and their ordering is not implied by
442                    // anything else: on the read-modify-write chains (hdr_resolve
443                    // through the decoration passes) each pass records into its own
444                    // command buffer, and command buffers in one submission may
445                    // overlap in execution. The resulting `Write -> Write` op is a
446                    // pure execution + memory dependency with no state change,
447                    // which each backend translates by its own rules.
448                    let read_stages = if state == ResourceState::Read {
449                        // A `Read -> Write` carries the prior run's union: the
450                        // write must wait on every reader.
451                        run_stages
452                    } else {
453                        // `Undefined -> Write` and `Write -> Write` have no Read
454                        // side.
455                        ReadStages::empty()
456                    };
457                    passes[pass_idx].barriers_before.push(BarrierOp {
458                        resource,
459                        from: state,
460                        to: ResourceState::Write,
461                        read_stages,
462                    });
463                    state = ResourceState::Write;
464                    run_stages = ReadStages::empty();
465                }
466                Eff::Read(_) => {
467                    if state != ResourceState::Read {
468                        // First read of a new run: union the contiguous run's
469                        // stages so the one producer barrier covers them all.
470                        let mut run = ReadStages::empty();
471                        for &(_, e) in entries[k..].iter() {
472                            match e {
473                                Eff::Read(s) => run = run.union(s),
474                                Eff::Write => break,
475                            }
476                        }
477                        passes[pass_idx].barriers_before.push(BarrierOp {
478                            resource,
479                            from: state,
480                            to: ResourceState::Read,
481                            read_stages: run,
482                        });
483                        state = ResourceState::Read;
484                        run_stages = run;
485                    }
486                    // Continuation read: the run's barrier already carries this
487                    // stage (unioned above), so emit nothing.
488                }
489            }
490        }
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::super::types::{BufferUsage, PixelFormat, TextureDesc, TextureSize, TextureUsage};
497    use super::*;
498    use crate::render_graph::builder::GraphBuilder;
499    use crate::render_graph::passes::PassId;
500    use crate::render_graph::types::{BufferDesc, PassKind};
501
502    fn tex() -> TextureDesc {
503        TextureDesc::texture_2d(
504            TextureSize::Drawable,
505            TextureSize::Drawable,
506            PixelFormat::Rgba16Float,
507            TextureUsage::SHADER_READ | TextureUsage::RENDER_TARGET,
508        )
509    }
510
511    fn buf() -> BufferDesc {
512        BufferDesc {
513            size_bytes: None,
514            usage: BufferUsage::STORAGE,
515        }
516    }
517
518    #[test]
519    fn linear_chain_toposorts_in_declared_order() {
520        // A writes T -> B reads T writes U -> C reads U presents.
521        let mut b = GraphBuilder::new();
522        let t = b.create_texture("t", tex());
523        let u = b.create_texture("u", tex());
524
525        let t1 = b
526            .add_pass(PassId::Shadow, PassKind::Render)
527            .write_texture(t);
528        let u1 = {
529            let mut p = b.add_pass(PassId::Main, PassKind::Render);
530            p.read_texture(t1);
531            p.write_texture(u)
532        };
533        b.add_pass(PassId::Composite, PassKind::Render)
534            .read_texture(u1)
535            .presents();
536
537        let g = b.compile().expect("graph compiles");
538        let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
539        assert_eq!(order, vec![PassId::Shadow, PassId::Main, PassId::Composite]);
540    }
541
542    #[test]
543    fn diamond_toposorts_with_stable_tiebreak() {
544        // A writes T; B reads T writes U; C reads T writes V; D reads U+V presents.
545        // B and C are both ready after A; declared first wins, so the
546        // order is [A, B, C, D].
547        let mut b = GraphBuilder::new();
548        let t = b.create_texture("t", tex());
549        let u = b.create_texture("u", tex());
550        let v = b.create_texture("v", tex());
551
552        let t1 = b
553            .add_pass(PassId::Shadow, PassKind::Render)
554            .write_texture(t);
555        let u1 = {
556            let mut p = b.add_pass(PassId::Main, PassKind::Render);
557            p.read_texture(t1);
558            p.write_texture(u)
559        };
560        let v1 = {
561            let mut p = b.add_pass(PassId::SsaoKernel, PassKind::Render);
562            p.read_texture(t1);
563            p.write_texture(v)
564        };
565        b.add_pass(PassId::Composite, PassKind::Render)
566            .read_texture(u1)
567            .read_texture(v1)
568            .presents();
569
570        let g = b.compile().expect("graph compiles");
571        let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
572        // Shadow must precede both Main and SsaoKernel (they read its
573        // output); Composite must come last (it reads both their
574        // outputs). The stable tie-break picks Main over SsaoKernel
575        // because Main was declared first.
576        assert_eq!(
577            order,
578            vec![
579                PassId::Shadow,
580                PassId::Main,
581                PassId::SsaoKernel,
582                PassId::Composite,
583            ]
584        );
585    }
586
587    #[test]
588    fn read_modify_write_chain_orders_correctly() {
589        // A writes hdr v1; B reads v1 writes v2 (decals); C reads v2 writes v3
590        // (fog); D reads v3 presents. The version-bump-implies-edge rule
591        // forces strict serial order.
592        let mut b = GraphBuilder::new();
593        let hdr = b.create_texture("hdr", tex());
594
595        let v1 = b
596            .add_pass(PassId::Main, PassKind::Render)
597            .write_texture(hdr);
598        let v2 = {
599            let mut p = b.add_pass(PassId::Decals, PassKind::Render);
600            p.read_texture(v1);
601            p.write_texture(v1)
602        };
603        let v3 = {
604            let mut p = b.add_pass(PassId::Fog, PassKind::Render);
605            p.read_texture(v2);
606            p.write_texture(v2)
607        };
608        b.add_pass(PassId::Composite, PassKind::Render)
609            .read_texture(v3)
610            .presents();
611
612        let g = b.compile().expect("graph compiles");
613        let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
614        assert_eq!(
615            order,
616            vec![PassId::Main, PassId::Decals, PassId::Fog, PassId::Composite,]
617        );
618    }
619
620    #[test]
621    fn war_exposes_cross_rmw_cycle() {
622        // Two passes RMW different resources, each reading the other's
623        // pre-write version: Decals reads y_v1 + writes x_v1 → x_v2;
624        // SsaoBlur reads x_v1 + writes y_v1 → y_v2. With WAR enforced,
625        // each write needs the *other* pass to have completed its read
626        // first, giving SsaoBlur → Decals (Decals's write of x_v2
627        // depends on SsaoBlur reading x_v1) AND Decals → SsaoBlur
628        // (SsaoBlur's write of y_v2 depends on Decals reading y_v1).
629        // That's a cycle, and it's a real one: there's no valid serial
630        // order for this pattern.
631        let mut b = GraphBuilder::new();
632        let x = b.create_texture("x", tex());
633        let y = b.create_texture("y", tex());
634
635        let x1 = b.add_pass(PassId::Main, PassKind::Render).write_texture(x);
636        let y1 = b.add_pass(PassId::Fog, PassKind::Render).write_texture(y);
637
638        let _x2 = {
639            let mut p = b.add_pass(PassId::Decals, PassKind::Render);
640            p.read_texture(y1);
641            p.write_texture(x1)
642        };
643        let _y2 = {
644            let mut p = b.add_pass(PassId::SsaoBlur, PassKind::Render);
645            p.read_texture(x1);
646            p.write_texture(y1)
647        };
648        b.add_pass(PassId::Composite, PassKind::Render).presents();
649
650        match b.compile() {
651            Err(GraphError::Cycle) => {}
652            other => panic!("expected Cycle, got {:?}", other),
653        }
654    }
655
656    #[test]
657    fn mutual_write_cycle_errors() {
658        // A writes X v1 reads Y v2; B writes Y v1 reads X v2; C writes X v2;
659        // D writes Y v2. C and D depend on A and B respectively (WAW on the
660        // version bump). A reads Y v2 → depends on D. B reads X v2 → depends
661        // on C. So: A -> C, B -> D, A -> D, B -> C, D -> A, C -> B. Cycle.
662        let mut b = GraphBuilder::new();
663        let x = b.create_texture("x", tex());
664        let y = b.create_texture("y", tex());
665
666        // First writes establish v1 for both.
667        let x1 = b.add_pass(PassId::Main, PassKind::Render).write_texture(x);
668        let y1 = b.add_pass(PassId::Fog, PassKind::Render).write_texture(y);
669
670        // Second writes claim v2 for both, declaring the cross-dependency.
671        // Decals reads y v2 (will exist), writes x v2.
672        // SsaoBlur reads x v2 (will exist), writes y v2.
673        // Each "reads v2" requires the other pass to have written v2 first.
674        // Mutual.
675        {
676            let mut p = b.add_pass(PassId::Decals, PassKind::Render);
677            // Read of v2 of y; producer is SsaoBlur (pass index 3).
678            p.read_texture(super::super::types::TextureHandle {
679                resource: y1.resource,
680                version: 2,
681            });
682            p.write_texture(x1);
683        }
684        {
685            let mut p = b.add_pass(PassId::SsaoBlur, PassKind::Render);
686            p.read_texture(super::super::types::TextureHandle {
687                resource: x1.resource,
688                version: 2,
689            });
690            p.write_texture(y1);
691        }
692        b.add_pass(PassId::Composite, PassKind::Render).presents();
693
694        match b.compile() {
695            Err(GraphError::Cycle) => {}
696            other => panic!("expected Cycle, got {:?}", other),
697        }
698    }
699
700    #[test]
701    fn war_edges_pin_reader_before_writer() {
702        // Main writes hdr_resolve v1. AutoExposure reads v1. Decals
703        // writes v1 → v2. Without WAR edges, toposort could place
704        // Decals before AutoExposure (Decals → Main → AutoExposure has
705        // no edge constraint either way). WAR forces AutoExposure to
706        // precede Decals because Decals's write of v2 depends on all
707        // readers of v1 completing first.
708        //
709        // Declaration order in this test deliberately puts Decals
710        // BEFORE AutoExposure so the toposort can't rely on it. Only
711        // the WAR edge gets us the right order.
712        let mut b = GraphBuilder::new();
713        let hdr = b.create_texture("hdr_resolve", tex());
714
715        let hdr_v1 = b
716            .add_pass(PassId::Main, PassKind::Render)
717            .write_texture(hdr);
718
719        // Decals declared before AutoExposure on purpose; the WAR edge
720        // from AutoExposure to Decals must override declaration order.
721        let _hdr_v2 = b
722            .add_pass(PassId::Decals, PassKind::Render)
723            .write_texture(hdr_v1);
724
725        b.add_pass(PassId::AutoExposure, PassKind::Compute)
726            .read_texture(hdr_v1);
727
728        b.add_pass(PassId::Composite, PassKind::Render).presents();
729
730        let g = b.compile().expect("compiles");
731        let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
732        // Main first, then AutoExposure (WAR-pinned before Decals),
733        // then Decals, then Composite.
734        assert_eq!(
735            order,
736            vec![
737                PassId::Main,
738                PassId::AutoExposure,
739                PassId::Decals,
740                PassId::Composite,
741            ]
742        );
743    }
744
745    #[test]
746    fn imported_v0_read_does_not_error() {
747        // The engine owns imported resources, so reading them at version
748        // 0 (their initial / engine-produced version) is always legal,
749        // even when no graph pass writes them. Mirrors how the Main pass
750        // reads env_irradiance / env_prefilter cubemaps the engine
751        // uploaded at init.
752        let mut b = GraphBuilder::new();
753        let env = b.import_texture("env", tex());
754        let scene = b.create_texture("scene", tex());
755        {
756            let mut p = b.add_pass(PassId::Main, PassKind::Render);
757            p.read_texture(env);
758            p.write_texture(scene);
759        }
760        b.add_pass(PassId::Composite, PassKind::Render)
761            .read_texture(super::super::types::TextureHandle {
762                resource: scene.resource,
763                version: 1,
764            })
765            .presents();
766
767        let g = b.compile().expect("imported v0 read should compile");
768        let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
769        assert_eq!(order, vec![PassId::Main, PassId::Composite]);
770    }
771
772    #[test]
773    fn transient_v0_read_still_errors() {
774        // The imported-v0 escape hatch only covers imported resources.
775        // A transient declared via create_texture and then read without
776        // a write is a real bug: the engine has no implicit producer.
777        let mut b = GraphBuilder::new();
778        let t = b.create_texture("scratch", tex());
779        b.add_pass(PassId::Composite, PassKind::Render)
780            .read_texture(t)
781            .presents();
782        match b.compile() {
783            Err(GraphError::MissingProducer {
784                pass: PassId::Composite,
785                resource_label: "scratch",
786                version: 0,
787            }) => {}
788            other => panic!("expected MissingProducer for transient v0, got {:?}", other),
789        }
790    }
791
792    #[test]
793    fn missing_producer_errors() {
794        // Read of a resource version that was never written.
795        let mut b = GraphBuilder::new();
796        let t = b.create_texture("t", tex());
797
798        // Skip the write; directly synthesise a handle at version 1
799        // (which write_texture would have returned).
800        let phantom = super::super::types::TextureHandle {
801            resource: t.resource,
802            version: 1,
803        };
804        b.add_pass(PassId::Composite, PassKind::Render)
805            .read_texture(phantom)
806            .presents();
807
808        match b.compile() {
809            Err(GraphError::MissingProducer {
810                pass: PassId::Composite,
811                resource_label: "t",
812                version: 1,
813            }) => {}
814            other => panic!("expected MissingProducer, got {:?}", other),
815        }
816    }
817
818    #[test]
819    fn no_presenter_errors() {
820        let mut b = GraphBuilder::new();
821        let t = b.create_texture("t", tex());
822        b.add_pass(PassId::Main, PassKind::Render).write_texture(t);
823        // No presents()!
824        match b.compile() {
825            Err(GraphError::MissingPresenter) => {}
826            other => panic!("expected MissingPresenter, got {:?}", other),
827        }
828    }
829
830    #[test]
831    fn multiple_presenters_errors() {
832        let mut b = GraphBuilder::new();
833        let t = b.create_texture("t", tex());
834        {
835            let mut p = b.add_pass(PassId::Main, PassKind::Render);
836            p.presents();
837            let _ = p.write_texture(t);
838        }
839        b.add_pass(PassId::Composite, PassKind::Render).presents();
840        match b.compile() {
841            Err(GraphError::MultiplePresenters(2)) => {}
842            other => panic!("expected MultiplePresenters(2), got {:?}", other),
843        }
844    }
845
846    #[test]
847    fn barriers_emit_on_state_transitions() {
848        // Main writes T -> Composite reads T. The barrier on Composite
849        // should be Undefined→...→Write (Main's effective state is
850        // Write because it writes) and then Write→Read (Composite reads).
851        let mut b = GraphBuilder::new();
852        let t = b.create_texture("t", tex());
853        let t1 = b.add_pass(PassId::Main, PassKind::Render).write_texture(t);
854        b.add_pass(PassId::Composite, PassKind::Render)
855            .read_texture(t1)
856            .presents();
857
858        let g = b.compile().expect("compiles");
859        // Pass 0 (Main): T transitions Undefined -> Write, one barrier.
860        assert_eq!(g.passes[0].barriers_before.len(), 1);
861        assert_eq!(
862            g.passes[0].barriers_before[0].from,
863            ResourceState::Undefined
864        );
865        assert_eq!(g.passes[0].barriers_before[0].to, ResourceState::Write);
866        // Pass 1 (Composite): T transitions Write -> Read, one barrier.
867        assert_eq!(g.passes[1].barriers_before.len(), 1);
868        assert_eq!(g.passes[1].barriers_before[0].from, ResourceState::Write);
869        assert_eq!(g.passes[1].barriers_before[0].to, ResourceState::Read);
870    }
871
872    #[test]
873    fn pass_barriers_for_filters_by_label() {
874        // Main writes "keep" + "skip"; Composite reads both. The allowlist
875        // ["keep"] must select only "keep"'s barrier on each pass, dropping
876        // "skip"'s entirely.
877        let mut b = GraphBuilder::new();
878        let keep = b.create_texture("keep", tex());
879        let skip = b.create_texture("skip", tex());
880        let (keep1, skip1) = {
881            let mut p = b.add_pass(PassId::Main, PassKind::Render);
882            (p.write_texture(keep), p.write_texture(skip))
883        };
884        b.add_pass(PassId::Composite, PassKind::Render)
885            .read_texture(keep1)
886            .read_texture(skip1)
887            .presents();
888
889        let g = b.compile().expect("compiles");
890        // Main writes both: two barriers total, one kept by the allowlist.
891        let main = &g.passes[0];
892        let kept = g.pass_barriers_for(main, &["keep"]);
893        assert_eq!(kept.len(), 1);
894        assert_eq!(kept[0].0, "keep");
895        assert_eq!(kept[0].1.source_state(), ResourceState::Undefined);
896        assert_eq!(kept[0].1.to_state(), ResourceState::Write);
897        // Composite reads both: "keep" transitions Write -> Read.
898        let composite = &g.passes[1];
899        let kept = g.pass_barriers_for(composite, &["keep"]);
900        assert_eq!(kept.len(), 1);
901        assert_eq!(kept[0].1.source_state(), ResourceState::Write);
902        assert_eq!(kept[0].1.to_state(), ResourceState::Read);
903        // An empty allowlist keeps nothing; an unknown label keeps nothing.
904        assert!(g.pass_barriers_for(main, &[]).is_empty());
905        assert!(g.pass_barriers_for(main, &["nope"]).is_empty());
906    }
907
908    #[test]
909    fn consecutive_reads_coalesce_no_barriers() {
910        // A writes T. B reads T. C reads T. D reads T presents.
911        // Barriers: A=W, B=W->R, C=R->R (none), D=R->R (none).
912        let mut b = GraphBuilder::new();
913        let t = b.create_texture("t", tex());
914        let t1 = b.add_pass(PassId::Main, PassKind::Render).write_texture(t);
915        b.add_pass(PassId::Decals, PassKind::Render)
916            .read_texture(t1);
917        b.add_pass(PassId::Fog, PassKind::Render).read_texture(t1);
918        b.add_pass(PassId::Composite, PassKind::Render)
919            .read_texture(t1)
920            .presents();
921
922        let g = b.compile().expect("compiles");
923        // pass[0] = Main: 1 barrier (Undefined -> Write)
924        assert_eq!(g.passes[0].barriers_before.len(), 1);
925        // pass[1] = Decals: 1 barrier (Write -> Read)
926        assert_eq!(g.passes[1].barriers_before.len(), 1);
927        // pass[2] = Fog: 0 barriers (already Read)
928        assert_eq!(g.passes[2].barriers_before.len(), 0);
929        // pass[3] = Composite: 0 barriers
930        assert_eq!(g.passes[3].barriers_before.len(), 0);
931    }
932
933    #[test]
934    fn lifetime_intervals_span_first_write_to_last_read() {
935        // Main(0) writes T; Decals(1) reads T; Fog(2) ignores T; Composite(3) reads T.
936        // T lifetime: [0, 3].
937        let mut b = GraphBuilder::new();
938        let t = b.create_texture("t", tex());
939        let unrelated = b.create_texture("u", tex());
940
941        let t1 = b.add_pass(PassId::Main, PassKind::Render).write_texture(t);
942        b.add_pass(PassId::Decals, PassKind::Render)
943            .read_texture(t1);
944        b.add_pass(PassId::Fog, PassKind::Render)
945            .write_texture(unrelated);
946        b.add_pass(PassId::Composite, PassKind::Render)
947            .read_texture(t1)
948            .presents();
949
950        let g = b.compile().expect("compiles");
951        let t_idx = t.resource.index();
952        let u_idx = unrelated.resource.index();
953        assert_eq!(g.resources[t_idx].lifetime.first, 0);
954        assert_eq!(g.resources[t_idx].lifetime.last, 3);
955        assert_eq!(g.resources[u_idx].lifetime.first, 2);
956        assert_eq!(g.resources[u_idx].lifetime.last, 2);
957    }
958
959    #[test]
960    fn buffer_dep_edges_work_too() {
961        // GPU-cull-style: cull writes draw_args (buffer), main reads it.
962        let mut b = GraphBuilder::new();
963        let draw_args = b.create_buffer("draw_args", buf());
964        let scene = b.create_texture("scene", tex());
965
966        let args1 = b
967            .add_pass(PassId::Cull, PassKind::Compute)
968            .write_buffer(draw_args);
969        let scene1 = {
970            let mut p = b.add_pass(PassId::Main, PassKind::Render);
971            p.read_buffer(args1);
972            p.write_texture(scene)
973        };
974        b.add_pass(PassId::Composite, PassKind::Render)
975            .read_texture(scene1)
976            .presents();
977
978        let g = b.compile().expect("compiles");
979        let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
980        assert_eq!(order, vec![PassId::Cull, PassId::Main, PassId::Composite]);
981    }
982
983    fn find(g: &CompiledGraph, id: PassId) -> &CompiledPass {
984        g.passes.iter().find(|p| p.id == id).expect("pass present")
985    }
986
987    #[test]
988    fn mixed_stage_read_run_unions_consumer_stages() {
989        // Main writes hdr; AutoExposure (compute) and Composite (render) read
990        // it. The single producer barrier on the first reader must carry BOTH
991        // stages so the write is made visible to the compute and the fragment
992        // consumer; the second reader coalesces (no barrier).
993        let mut b = GraphBuilder::new();
994        let hdr = b.create_texture("hdr", tex());
995        let hdr_v1 = b
996            .add_pass(PassId::Main, PassKind::Render)
997            .write_texture(hdr);
998        b.add_pass(PassId::AutoExposure, PassKind::Compute)
999            .read_texture(hdr_v1);
1000        b.add_pass(PassId::Composite, PassKind::Render)
1001            .read_texture(hdr_v1)
1002            .presents();
1003
1004        let g = b.compile().expect("compiles");
1005        // AutoExposure is the first reader: one Write -> Read barrier carrying
1006        // the whole run's stage union.
1007        let ae = find(&g, PassId::AutoExposure);
1008        assert_eq!(ae.barriers_before.len(), 1);
1009        assert_eq!(ae.barriers_before[0].source_state(), ResourceState::Write);
1010        assert_eq!(ae.barriers_before[0].to_state(), ResourceState::Read);
1011        let rs = ae.barriers_before[0].read_stages();
1012        assert!(rs.contains(ReadStages::COMPUTE));
1013        assert!(rs.contains(ReadStages::FRAGMENT));
1014        // Composite coalesces into the run: no hdr barrier.
1015        assert_eq!(find(&g, PassId::Composite).barriers_before.len(), 0);
1016    }
1017
1018    #[test]
1019    fn fragment_read_carries_only_fragment_stage() {
1020        // A lone render-pass consumer carries FRAGMENT only (the common case;
1021        // the existing migrated resources all look like this).
1022        let mut b = GraphBuilder::new();
1023        let t = b.create_texture("t", tex());
1024        let t1 = b.add_pass(PassId::Main, PassKind::Render).write_texture(t);
1025        b.add_pass(PassId::Composite, PassKind::Render)
1026            .read_texture(t1)
1027            .presents();
1028
1029        let g = b.compile().expect("compiles");
1030        let comp = find(&g, PassId::Composite);
1031        assert_eq!(comp.barriers_before.len(), 1);
1032        let rs = comp.barriers_before[0].read_stages();
1033        assert!(rs.contains(ReadStages::FRAGMENT));
1034        assert!(!rs.contains(ReadStages::COMPUTE));
1035    }
1036
1037    #[test]
1038    fn compute_read_carries_only_compute_stage() {
1039        // Cull (compute) writes draw_args; AutoExposure (compute) reads it.
1040        // The reader's barrier carries COMPUTE only. A render presenter writes
1041        // an unrelated target so the graph is well-formed.
1042        let mut b = GraphBuilder::new();
1043        let args = b.create_buffer("draw_args", buf());
1044        let scene = b.create_texture("scene", tex());
1045        let args1 = b
1046            .add_pass(PassId::Cull, PassKind::Compute)
1047            .write_buffer(args);
1048        b.add_pass(PassId::AutoExposure, PassKind::Compute)
1049            .read_buffer(args1);
1050        {
1051            let mut p = b.add_pass(PassId::Composite, PassKind::Render);
1052            let _ = p.write_texture(scene);
1053            p.presents();
1054        }
1055
1056        let g = b.compile().expect("compiles");
1057        let ae = find(&g, PassId::AutoExposure);
1058        assert_eq!(ae.barriers_before.len(), 1);
1059        let rs = ae.barriers_before[0].read_stages();
1060        assert!(rs.contains(ReadStages::COMPUTE));
1061        assert!(!rs.contains(ReadStages::FRAGMENT));
1062    }
1063
1064    #[test]
1065    fn war_barrier_carries_prior_read_run_stage_union() {
1066        // Main writes hdr v1; AutoExposure (compute) and Fog (render) read v1;
1067        // SsaoBlur writes v1 -> v2 (write only). SsaoBlur's WAR barrier must
1068        // wait on BOTH readers, so it carries the prior run's union.
1069        let mut b = GraphBuilder::new();
1070        let hdr = b.create_texture("hdr", tex());
1071        let v1 = b
1072            .add_pass(PassId::Main, PassKind::Render)
1073            .write_texture(hdr);
1074        b.add_pass(PassId::AutoExposure, PassKind::Compute)
1075            .read_texture(v1);
1076        b.add_pass(PassId::Fog, PassKind::Render).read_texture(v1);
1077        let v2 = b
1078            .add_pass(PassId::SsaoBlur, PassKind::Render)
1079            .write_texture(v1);
1080        b.add_pass(PassId::Composite, PassKind::Render)
1081            .read_texture(v2)
1082            .presents();
1083
1084        let g = b.compile().expect("compiles");
1085        let blur = find(&g, PassId::SsaoBlur);
1086        assert_eq!(blur.barriers_before.len(), 1);
1087        assert_eq!(blur.barriers_before[0].source_state(), ResourceState::Read);
1088        assert_eq!(blur.barriers_before[0].to_state(), ResourceState::Write);
1089        let rs = blur.barriers_before[0].read_stages();
1090        assert!(rs.contains(ReadStages::COMPUTE));
1091        assert!(rs.contains(ReadStages::FRAGMENT));
1092    }
1093
1094    #[test]
1095    fn producer_write_barrier_has_empty_read_stages() {
1096        // A write-only producer transition (Undefined -> Write) has no Read
1097        // side, so its stage mask is empty.
1098        let mut b = GraphBuilder::new();
1099        let t = b.create_texture("t", tex());
1100        let t1 = b.add_pass(PassId::Main, PassKind::Render).write_texture(t);
1101        b.add_pass(PassId::Composite, PassKind::Render)
1102            .read_texture(t1)
1103            .presents();
1104
1105        let g = b.compile().expect("compiles");
1106        let main = find(&g, PassId::Main);
1107        assert_eq!(main.barriers_before.len(), 1);
1108        assert_eq!(main.barriers_before[0].to_state(), ResourceState::Write);
1109        assert!(main.barriers_before[0].read_stages().is_empty());
1110    }
1111}