Skip to main content

concinnity_core/render/render_graph/
transient.rs

1// src/render_graph/transient.rs
2//
3// The slot list a backend's transient pool is built from, and the check that
4// keeps that list sound.
5//
6// A pool takes one [`TransientSlot`] per aliasing-plan slot and makes one
7// allocation for it, sized to its largest member, with every member placed at
8// offset 0. Each member arrives as a resolved [`TransientTexture`] -- concrete
9// pixel extents and the graph's own format / usage / sample count -- so a
10// backend translates one description into its native descriptor rather than
11// keeping a per-label table of its own. That is the point: a table restating
12// what the graph already declares can disagree with it, and a disagreement
13// about a format or an extent is silent.
14//
15// Pools are built at init / resize; graphs compile per frame. So the plan a
16// pool is built from describes ONE graph, and reusing its grouping for the
17// frames that follow is safe only while no slot has two members live at once in
18// any of them. That is not free: a resource whose lifetime a pass *extends*
19// looks more disjoint in a graph missing that pass, and there is no single
20// maximal graph to plan against -- `unified_gbuffer_prepass`,
21// `rt_reflections_enabled` and `upscale_enabled` substitute passes rather than
22// adding them, so no one graph contains every lifetime. Three things cover it,
23// and each catches what the others cannot:
24//
25//   1. The pool plans against its build configuration, and treats every input
26//      it cannot rebuild on as live. [`planning_inputs`].
27//   2. `slot_conflicts_over_reachable_graphs` in this module's tests sweeps the
28//      reachable input space and fails on any slot with two overlapping
29//      members.
30//   3. Each executor asserts [`slot_conflicts`] per frame under
31//      `debug_assertions`, over the graph it is about to run -- which covers
32//      the combinations the sweep did not reach.
33
34use super::alias::plan_aliasing_for;
35use super::compile::CompiledGraph;
36use super::frame::FrameGraphInputs;
37use super::types::{ClearValue, PixelFormat, TextureUsage};
38use alloc::string::String;
39use alloc::string::ToString;
40use alloc::vec::Vec;
41
42/// One pooled transient, resolved against a concrete drawable extent. The
43/// backend translates this into its native texture descriptor; nothing here is
44/// backend-specific.
45///
46/// `PartialEq` but not `Eq`: the clear value is floats.
47#[derive(Clone, Debug, PartialEq)]
48pub struct TransientTexture {
49    /// The graph label, which is also how a feature reads the texture back out
50    /// of the pool and how the barrier registry names it.
51    pub label: &'static str,
52    /// Width in pixels.
53    pub width: u32,
54    /// Height in pixels.
55    pub height: u32,
56    /// 1 for a 2D texture, > 1 for a volume.
57    pub depth: u32,
58    /// Texel format.
59    pub format: PixelFormat,
60    /// MSAA sample count; 1 for non-multisample.
61    pub sample_count: u32,
62    /// Array layers; 1 for plain 2D.
63    pub array_layers: u32,
64    /// Mip levels in the chain.
65    pub mip_levels: u32,
66    /// How passes bind the texture.
67    pub usage: TextureUsage,
68    /// What the writing pass clears this target to. Carried through because
69    /// D3D12 bakes it into the resource at creation; see `TextureDesc::clear`.
70    pub clear: ClearValue,
71}
72
73/// One slot: the members that share a backing allocation, in the order they
74/// reuse it (lifetime-start). A single-member slot is a plain pooled target; a
75/// multi-member slot is a realised alias, and the order is what each backend's
76/// aliasing barriers are wired from.
77#[derive(Clone, Debug, PartialEq)]
78pub struct TransientSlot {
79    /// Textures sharing this slot, with pairwise-disjoint lifetimes.
80    pub members: Vec<TransientTexture>,
81}
82
83impl TransientSlot {
84    /// The member textures' labels, in assignment order.
85    pub fn labels(&self) -> Vec<&'static str> {
86        self.members.iter().map(|m| m.label).collect()
87    }
88}
89
90/// The inputs a pool plans its slots against, given the configuration it was
91/// built for. `build` carries the flags the pool is rebuilt on (SSAO and bloom
92/// being switched on or off both rebuild it, as does a resize); every gated pass
93/// is forced on here, so no lifetime a pass would extend is missing from the
94/// graph the grouping is decided on.
95///
96/// `composite_reads_ao` is ON, and the history is worth keeping. It used to be
97/// off, on the argument that it describes a different frame rather than a fuller
98/// one (it is reachable only in the occlusion view, which forces bloom off) and
99/// that planning against it would refuse the only aliasing the pool had. The
100/// second half has expired now that the G-buffer channels are pooled: there is
101/// plenty else to alias, and turning it on costs this plan nothing.
102///
103/// The first half turned out to be a trap. Modelling `ao_output` as short-lived
104/// is only safe while nothing else is pooled around the reflection resolve --
105/// the moment a one-pass post-stack target joins the pool, the greedy pairs it
106/// with `ao_output` and the sweep reports the overlap the occlusion view really
107/// has. Measured, not argued: adding such a target made both sweeps fail here.
108/// Extending a lifetime is always the safe direction, so it stays on.
109///
110/// `upscale_enabled` is NOT forced on, and a lifetime read out of this graph can
111/// therefore be shorter than the real maximal one. Upscale substitutes for
112/// TaaResolve, so forcing it would drop the TAA branch instead; neither branch
113/// dominates the other and one graph cannot hold both. The concrete casualty is
114/// `gbuffer_depth`, whose only consumer is the upscaler and which looks one-pass
115/// here -- see `the_prepass_depth_is_short_lived_only_in_the_planning_graph`.
116///
117/// Nothing here is load-bearing on its own. What makes the grouping sound is the
118/// sweep over the reachable space in this module's tests plus each executor's
119/// per-frame assertion; if this graph ever becomes too permissive the sweep is
120/// what fails.
121pub(crate) fn planning_inputs(build: &FrameGraphInputs) -> FrameGraphInputs {
122    FrameGraphInputs {
123        // `world_hidden` masks passes off rather than on, so leaving it false
124        // keeps the richer graph.
125        world_hidden: false,
126        composite_reads_ao: true,
127        shadow_enabled: true,
128        bindless_cull_enabled: true,
129        auto_exposure_enabled: true,
130        velocity_enabled: true,
131        taa_enabled: true,
132        ssr_enabled: true,
133        particles_enabled: true,
134        fog_enabled: true,
135        decals_enabled: true,
136        ssr_prepass_enabled: true,
137        transparent_enabled: true,
138        lines_enabled: true,
139        raymarch_enabled: true,
140        two_pass_occlusion_enabled: true,
141        ssgi_enabled: true,
142        clustered_lighting_enabled: true,
143        hiz_build_enabled: true,
144        ..*build
145    }
146}
147
148// The slots a pool built for `build` should allocate, over the transients
149// `poolable` accepts, at `drawable_w` x `drawable_h`. Empty when the pool owns
150// nothing. Returns `None` when the planning graph fails to compile, which is a
151// caller's cue to fall back to one slot per managed resource rather than
152// silently aliasing on a plan that was never made.
153pub(crate) fn plan_transient_slots(
154    build: &FrameGraphInputs,
155    poolable: &dyn Fn(&str) -> bool,
156    drawable_w: u32,
157    drawable_h: u32,
158) -> Option<Vec<TransientSlot>> {
159    let graph = super::frame::build_frame_graph(&planning_inputs(build)).ok()?;
160    let plan = plan_aliasing_for(&graph, drawable_w, drawable_h, poolable);
161    Some(
162        plan.slots
163            .iter()
164            .map(|slot| TransientSlot {
165                members: slot
166                    .members
167                    .iter()
168                    .map(|&idx| resolve(&graph, idx, drawable_w, drawable_h))
169                    .collect(),
170            })
171            .collect(),
172    )
173}
174
175/// The transients a backend pool owns; everything else the graph declares
176/// transient stays backend-owned. One set for every backend, because which
177/// labels are pooled is policy rather than a per-backend capability: a set that
178/// differed per backend would make their footprints incomparable and would leave
179/// the soundness sweep below checking a grouping no backend builds.
180///
181/// `gbuffer_depth` is deliberately absent while its three colour siblings are
182/// here. D3D12 creates a shader-readable depth target with a typeless resource
183/// format (`R32_TYPELESS`) and views it as `D32_FLOAT` / `R32_FLOAT`, while
184/// `PixelFormat::Depth32Float` names one format for all three roles, so the pool
185/// would create a resource the feature's SRV cannot view. Pooling it would
186/// reclaim nothing anyway: its one-pass planning lifetime is an artifact of a
187/// graph that cannot model the upscaler (see
188/// `the_prepass_depth_is_short_lived_only_in_the_planning_graph`).
189pub fn pooled(label: &str) -> bool {
190    matches!(
191        label,
192        "ao_output"
193            | "bloom_top"
194            | "gbuffer_normal_depth"
195            | "gbuffer_roughness"
196            | "gbuffer_velocity"
197    )
198}
199
200/// The feature gates a backend's transient pool is built for, i.e. the ones it
201/// is rebuilt on. Everything else `planning_inputs` forces live.
202#[derive(Copy, Clone, Debug, PartialEq, Eq)]
203pub struct PoolGates {
204    /// SSAO is built, so `ao_output` exists.
205    pub ssao: bool,
206    /// The bloom chain's top octave is managed. Metal and DirectX pass `true`
207    /// unconditionally: they toggle bloom per frame off the post-process
208    /// intensity while the composite binds mip 0 either way, so a pool built at
209    /// init / resize cannot gate on it. Vulkan rebuilds on the flag and passes
210    /// the real value.
211    pub bloom: bool,
212    /// The unified G-buffer pre-pass is built, so its colour channels exist.
213    pub gbuffer: bool,
214}
215
216/// The alias-slot list a pool built for `gates` should allocate, taken straight
217/// from the graph: the grouping and each member's extent, format and usage come
218/// from one planning graph, so init and resize cannot drift apart and neither
219/// can the graph and the resource it describes.
220///
221/// `render_extent` sizes the render-resolution transients and `output_extent` is
222/// the drawable the half-resolution ones scale off; under temporal upscaling
223/// they differ, which is why both are passed rather than derived.
224///
225/// A planning graph that does not compile is a hard error rather than an empty
226/// pool: every consumer reads its target back out by label, so silently pooling
227/// nothing would fail later and further from the cause.
228pub fn plan_pool_slots(
229    gates: PoolGates,
230    render_extent: (u32, u32),
231    output_extent: (u32, u32),
232) -> Result<Vec<TransientSlot>, String> {
233    let mut build = FrameGraphInputs::all_off();
234    build.hdr_width = render_extent.0;
235    build.hdr_height = render_extent.1;
236    build.ssao_enabled = gates.ssao;
237    build.bloom_enabled = gates.bloom;
238    // The unified pre-pass SUBSTITUTES for the separate SsrPrepass / Velocity
239    // nodes rather than adding to them, so `planning_inputs` cannot force it on
240    // the way it does the purely additive passes: it has to follow the build.
241    // `velocity_enabled` is what makes the node appear once the flag is set,
242    // and this gate must match the one that builds the pre-pass itself, or a
243    // consumer reads a label the pool never created.
244    build.unified_gbuffer_prepass = gates.gbuffer;
245    build.velocity_enabled = gates.gbuffer;
246    plan_transient_slots(&build, &pooled, output_extent.0, output_extent.1)
247        .ok_or_else(|| "transient pool: the planning frame graph failed to compile".to_string())
248}
249
250// One graph resource as the pool must create it.
251fn resolve(
252    graph: &CompiledGraph,
253    idx: usize,
254    drawable_w: u32,
255    drawable_h: u32,
256) -> TransientTexture {
257    let res = &graph.resources[idx];
258    let desc = res
259        .tex_desc
260        .expect("the planner only places resources carrying a texture desc");
261    let (width, height, depth) = desc.extent(drawable_w, drawable_h);
262    TransientTexture {
263        label: res.label,
264        width,
265        height,
266        depth,
267        format: desc.format,
268        sample_count: desc.sample_count.max(1),
269        array_layers: desc.array_layers.max(1),
270        mip_levels: desc.mip_levels.max(1),
271        usage: desc.usage,
272        clear: desc.clear,
273    }
274}
275
276// Two members of one slot whose lifetimes overlap in a graph, i.e. two
277// resources that would be live at once on the same bytes.
278#[derive(Copy, Clone, Debug, Eq, PartialEq)]
279pub(crate) struct SlotConflict {
280    pub slot: usize,
281    pub a: &'static str,
282    pub b: &'static str,
283}
284
285impl core::fmt::Display for SlotConflict {
286    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
287        write!(
288            f,
289            "slot {}: {} and {} are both live",
290            self.slot, self.a, self.b
291        )
292    }
293}
294
295// Every pair of slot members whose `[first, last]` lifetimes overlap in
296// `graph`. Empty when the grouping is sound for this graph, which is the
297// invariant a pool's aliasing rests on: members of a slot share bytes, so two
298// live at once means one is reading memory the other overwrote.
299//
300// Labels absent from `graph` are skipped -- a pool holds a resource for as long
301// as its build configuration says so, and a frame that omits the pass writing
302// it simply does not use it.
303pub(crate) fn slot_conflicts(
304    graph: &CompiledGraph,
305    slots: &[Vec<&'static str>],
306) -> Vec<SlotConflict> {
307    let lifetime = |label: &str| {
308        graph
309            .resources
310            .iter()
311            .find(|r| r.label == label)
312            .map(|r| (r.lifetime.first, r.lifetime.last))
313    };
314    let mut conflicts = Vec::new();
315    for (slot, members) in slots.iter().enumerate() {
316        for (i, &a) in members.iter().enumerate() {
317            let Some((a_first, a_last)) = lifetime(a) else {
318                continue;
319            };
320            for &b in &members[i + 1..] {
321                let Some((b_first, b_last)) = lifetime(b) else {
322                    continue;
323                };
324                if a_first <= b_last && b_first <= a_last {
325                    conflicts.push(SlotConflict { slot, a, b });
326                }
327            }
328        }
329    }
330    conflicts
331}
332
333/// Panic if any alias slot has two members live at once in `graph`. Members of a
334/// slot share bytes, so two live at once means one reads memory the other
335/// overwrote, and unlike a barrier gap there is no validation layer behind it on
336/// any backend. Every executor calls this per frame under `debug_assertions`,
337/// over the graph it is about to run; `backend` names the caller in the message.
338///
339/// This is the layer the sweep in this module's tests cannot be: the pool is
340/// planned once per build configuration while graphs compile per frame, and
341/// passes that *substitute* for one another mean there is no single maximal
342/// graph to plan against. The sweep covers the input space it models; this
343/// covers the graph actually in hand.
344pub fn assert_slot_aliasing_sound(
345    graph: &CompiledGraph,
346    slot_labels: &[Vec<&'static str>],
347    backend: &str,
348) {
349    let conflicts = slot_conflicts(graph, slot_labels);
350    assert!(
351        conflicts.is_empty(),
352        "transient pool ({backend}): alias slot members are simultaneously live: {}",
353        conflicts
354            .iter()
355            .map(|c| c.to_string())
356            .collect::<Vec<_>>()
357            .join(", ")
358    );
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::gfx::view_modes::{ShowFlags, ViewMode};
365    use crate::render::render_graph::frame::build_frame_graph;
366    use alloc::format;
367    use alloc::vec;
368
369    // The flags a build configuration carries, i.e. the ones a pool is rebuilt
370    // on. Everything else `planning_inputs` forces live.
371    //
372    // The G-buffer gate is one of them because `unified_gbuffer_prepass`
373    // *substitutes* for the separate SsrPrepass / Velocity nodes rather than
374    // adding to them, so `planning_inputs` cannot force it on the way it forces
375    // the purely additive passes.
376    fn build_inputs(ssao: bool, bloom: bool) -> FrameGraphInputs {
377        build_inputs_with(ssao, bloom, true)
378    }
379
380    fn build_inputs_with(ssao: bool, bloom: bool, gbuffer: bool) -> FrameGraphInputs {
381        let mut i = FrameGraphInputs::all_off();
382        i.ssao_enabled = ssao;
383        i.bloom_enabled = bloom;
384        i.unified_gbuffer_prepass = gbuffer;
385        i.velocity_enabled = gbuffer;
386        i.hdr_width = 1920;
387        i.hdr_height = 1080;
388        i
389    }
390
391    #[test]
392    fn slots_carry_the_graphs_own_shape() {
393        // The point of planning off the graph: the extent, format and usage a
394        // pool creates come from the graph's desc, so there is no second
395        // description to disagree with it.
396        let slots = plan_transient_slots(&build_inputs(true, true), &pooled, 1920, 1080)
397            .expect("planning graph compiles");
398        let member = |label: &str| {
399            slots
400                .iter()
401                .flat_map(|s| &s.members)
402                .find(|m| m.label == label)
403                .unwrap_or_else(|| panic!("{label} pooled"))
404                .clone()
405        };
406
407        // `ao_output` is render-resolution R8.
408        let ao = member("ao_output");
409        assert_eq!((ao.width, ao.height, ao.depth), (1920, 1080, 1));
410        assert_eq!(ao.format, PixelFormat::R8Unorm);
411        assert_eq!((ao.sample_count, ao.array_layers, ao.mip_levels), (1, 1, 1));
412        assert!(ao.usage.contains(TextureUsage::RENDER_TARGET));
413        assert!(ao.usage.contains(TextureUsage::SHADER_READ));
414
415        // `bloom_top` is half the *drawable* extent, which is what every
416        // backend builds its bloom chain from -- not half the render
417        // resolution, which differs from it under temporal upscaling.
418        let bloom = member("bloom_top");
419        assert_eq!((bloom.width, bloom.height), (960, 540));
420        assert_eq!(bloom.format, PixelFormat::Rgba16Float);
421    }
422
423    #[test]
424    fn bloom_top_follows_the_drawable_not_the_render_resolution() {
425        // An upscaled configuration: render resolution well under the
426        // drawable. `ao_output` follows the render resolution and `bloom_top`
427        // the drawable, and the two must not track each other.
428        let mut build = build_inputs(true, true);
429        build.hdr_width = 1280;
430        build.hdr_height = 720;
431        let slots =
432            plan_transient_slots(&build, &pooled, 2560, 1440).expect("planning graph compiles");
433        let member = |label: &str| {
434            slots
435                .iter()
436                .flat_map(|s| &s.members)
437                .find(|m| m.label == label)
438                .unwrap_or_else(|| panic!("{label} pooled"))
439                .clone()
440        };
441        assert_eq!(
442            (member("ao_output").width, member("ao_output").height),
443            (1280, 720)
444        );
445        assert_eq!(
446            (member("bloom_top").width, member("bloom_top").height),
447            (1280, 720),
448            "half of 2560x1440, which happens to equal the render resolution here"
449        );
450    }
451
452    #[test]
453    fn an_unpooled_label_gets_no_slot() {
454        // A pool owns what its build configuration says it owns; the rest of
455        // the graph's transients stay backend-owned and unplanned.
456        let slots = plan_transient_slots(&build_inputs(true, true), &|_| false, 1920, 1080)
457            .expect("planning graph compiles");
458        assert!(slots.is_empty());
459
460        let only_ao =
461            plan_transient_slots(&build_inputs(true, true), &|l| l == "ao_output", 1920, 1080)
462                .expect("planning graph compiles");
463        assert_eq!(only_ao.len(), 1);
464        assert_eq!(only_ao[0].labels(), vec!["ao_output"]);
465    }
466
467    #[test]
468    fn ssao_off_leaves_only_the_bloom_target() {
469        // No SSAO and no G-buffer pre-pass: `bloom_top` is the only pooled
470        // resource the graph declares.
471        let slots =
472            plan_transient_slots(&build_inputs_with(false, true, false), &pooled, 1920, 1080)
473                .expect("planning graph compiles");
474        let labels: Vec<&str> = slots.iter().flat_map(|s| s.labels()).collect();
475        assert_eq!(labels, vec!["bloom_top"]);
476    }
477
478    #[test]
479    fn nothing_pooled_when_neither_feature_is_built() {
480        let slots =
481            plan_transient_slots(&build_inputs_with(false, false, false), &pooled, 1920, 1080)
482                .expect("planning graph compiles");
483        assert!(slots.is_empty());
484    }
485
486    #[test]
487    fn the_gbuffer_gate_places_its_colour_targets() {
488        // The gate is a build flag rather than something `planning_inputs`
489        // forces, so a pool built without it must place none of them -- which is
490        // what makes it safe for a backend to pool them only when the pre-pass
491        // exists.
492        let off = plan_transient_slots(&build_inputs_with(true, true, false), &pooled, 1920, 1080)
493            .expect("planning graph compiles");
494        let off_labels: Vec<&str> = off.iter().flat_map(|s| s.labels()).collect();
495        assert!(
496            !off_labels.contains(&"gbuffer_normal_depth"),
497            "{off_labels:?}"
498        );
499
500        let on = plan_transient_slots(&build_inputs(true, true), &pooled, 1920, 1080)
501            .expect("planning graph compiles");
502        let on_labels: Vec<&str> = on.iter().flat_map(|s| s.labels()).collect();
503        for want in [
504            "gbuffer_normal_depth",
505            "gbuffer_roughness",
506            "gbuffer_velocity",
507        ] {
508            assert!(on_labels.contains(&want), "{want}: {on_labels:?}");
509        }
510    }
511
512    #[test]
513    fn slot_conflicts_reports_an_overlapping_pair() {
514        // Negative control for the predicate the executors assert. `ao_output`
515        // and `bloom_top` overlap in the occlusion view (which extends
516        // `ao_output` to the composite) with bloom also on, so grouping them
517        // must report. Without this the sweep below could pass on a predicate
518        // that never reports anything.
519        let mut i = FrameGraphInputs::all_off();
520        i.ssao_enabled = true;
521        i.bloom_enabled = true;
522        i.composite_reads_ao = true;
523        let graph = build_frame_graph(&i).expect("compiles");
524
525        let grouped = vec![vec!["ao_output", "bloom_top"]];
526        let conflicts = slot_conflicts(&graph, &grouped);
527        assert_eq!(conflicts.len(), 1, "{conflicts:?}");
528        assert_eq!(conflicts[0].slot, 0);
529
530        // One per slot: split them and the same graph is sound.
531        let split = vec![vec!["ao_output"], vec!["bloom_top"]];
532        assert_eq!(slot_conflicts(&graph, &split), vec![]);
533    }
534
535    // A conflict prints the slot and both members, which is what the executors'
536    // per-frame assertion reports when it fires.
537    #[test]
538    fn a_conflict_names_the_slot_and_both_members() {
539        let conflict = SlotConflict {
540            slot: 2,
541            a: "ao_output",
542            b: "bloom_top",
543        };
544        assert_eq!(
545            conflict.to_string(),
546            "slot 2: ao_output and bloom_top are both live"
547        );
548    }
549
550    // The executors' per-frame gate: silent on a sound grouping, and a panic
551    // naming the backend and the overlap on an unsound one.
552    #[test]
553    fn the_aliasing_assertion_passes_a_sound_grouping() {
554        let graph = build_frame_graph(&FrameGraphInputs::all_off()).expect("compiles");
555        assert_slot_aliasing_sound(&graph, &[vec!["ao_output"], vec!["bloom_top"]], "test");
556    }
557
558    #[test]
559    #[should_panic(expected = "alias slot members are simultaneously live")]
560    fn the_aliasing_assertion_fires_on_an_overlapping_pair() {
561        let mut i = FrameGraphInputs::all_off();
562        i.ssao_enabled = true;
563        i.bloom_enabled = true;
564        i.composite_reads_ao = true;
565        let graph = build_frame_graph(&i).expect("compiles");
566        assert_slot_aliasing_sound(&graph, &[vec!["ao_output", "bloom_top"]], "test");
567    }
568
569    #[test]
570    fn a_label_absent_from_the_graph_is_not_a_conflict() {
571        // A pool holds `ao_output` for as long as SSAO is built; a frame whose
572        // graph omits the SSAO pass simply does not use it, which is not a
573        // reason to alarm.
574        let graph = build_frame_graph(&FrameGraphInputs::all_off()).expect("compiles");
575        let grouped = vec![vec!["ao_output", "bloom_top"]];
576        assert_eq!(slot_conflicts(&graph, &grouped), vec![]);
577    }
578
579    #[test]
580    fn planning_inputs_forces_the_gated_passes_on() {
581        // A pass the planning graph omits is a lifetime it under-reports, so
582        // every gated pass is on regardless of the build configuration.
583        let planned = planning_inputs(&build_inputs(false, false));
584        assert!(planned.ssgi_enabled);
585        assert!(planned.transparent_enabled);
586        assert!(planned.raymarch_enabled);
587        assert!(!planned.world_hidden, "masking off passes is not the risk");
588        // On, and it has to be: the occlusion view extends `ao_output` to the
589        // Composite, past the reflection resolve. A plan made without it pairs
590        // `ao_output` with `ssr_reflection`, which the sweep rejects.
591        assert!(planned.composite_reads_ao);
592        // The build flags pass through, because the pool *is* rebuilt on them.
593        assert!(!planned.ssao_enabled);
594        assert!(!planned.bloom_enabled);
595        assert!(planning_inputs(&build_inputs(true, true)).ssao_enabled);
596    }
597
598    #[test]
599    fn the_pool_actually_aliases_something() {
600        // Anti-vacuity guard for the two sweeps below. Single-member slots are
601        // trivially conflict-free, so a plan that stopped aliasing would leave
602        // the sweeps passing while checking nothing.
603        //
604        // What aliases is worth reading: `bloom_top` (Bloom -> Composite, late)
605        // pairs with whichever early resource is largest. The three G-buffer
606        // colour targets do NOT alias each other -- every one is written by the
607        // pre-pass and read by a late consumer, so their lifetimes span most of
608        // the frame. That is why pooling this group reclaims far less than the
609        // HDR / post groups will.
610        let slots = plan_transient_slots(&build_inputs(true, true), &pooled, 1920, 1080)
611            .expect("planning graph compiles");
612        let shared: Vec<Vec<&'static str>> = slots
613            .iter()
614            .map(|s| s.labels())
615            .filter(|l| l.len() > 1)
616            .collect();
617        assert!(
618            !shared.is_empty(),
619            "no slot aliases anything, so the sweeps check nothing: {:?}",
620            labels_of(&slots)
621        );
622        assert!(
623            shared.iter().any(|l| l.contains(&"bloom_top")),
624            "bloom_top is the late resource that makes an alias possible: {:?}",
625            labels_of(&slots)
626        );
627    }
628
629    fn labels_of(slots: &[TransientSlot]) -> Vec<Vec<&'static str>> {
630        slots.iter().map(|s| s.labels()).collect()
631    }
632
633    // Bytes a slot list costs (each slot sized to its largest member) against
634    // what the same members would cost unaliased. This is the measurement that
635    // decides whether a group migration is worth its wiring, and it runs
636    // headlessly -- a group's footprint is NOT its saving, because members with
637    // overlapping lifetimes each need their own slot.
638    // The members already carry resolved pixel extents, so no drawable is
639    // needed here.
640    fn slot_bytes(slots: &[TransientSlot]) -> (u64, u64) {
641        let member_bytes = |m: &TransientTexture| -> u64 {
642            let texels = (m.width as u64) * (m.height as u64) * (m.depth.max(1) as u64);
643            texels
644                * m.format.bytes_per_texel() as u64
645                * m.sample_count.max(1) as u64
646                * m.array_layers.max(1) as u64
647        };
648        let mut aliased = 0;
649        let mut unaliased = 0;
650        for slot in slots {
651            let mut largest = 0;
652            for m in &slot.members {
653                let b = member_bytes(m);
654                unaliased += b;
655                largest = largest.max(b);
656            }
657            aliased += largest;
658        }
659        (aliased, unaliased)
660    }
661
662    #[test]
663    fn the_pooled_set_reclaims_what_the_plan_says() {
664        // A regression guard on the *saving*, which is the point of the pool and
665        // is otherwise invisible until someone measures a running frame: every
666        // other test here would still pass if the plan quietly stopped aliasing.
667        //
668        // The number is small on purpose, and knowing why is what keeps the next
669        // group migration honest. At 1920x1080 the plan is
670        //   [gbuffer_normal_depth + bloom_top] [gbuffer_roughness]
671        //   [gbuffer_velocity] [ao_output]
672        // i.e. only `bloom_top` aliases at all. Every other pooled member is
673        // live across most of the frame -- the G-buffer channels from the
674        // pre-pass to their last consumer, `ao_output` from the SSAO node to
675        // Main (to the Composite in the occlusion view) -- so they overlap each
676        // other and each needs its own slot. Aliasing pays for *short* lifetimes,
677        // and this renderer has few.
678        let slots = plan_transient_slots(&build_inputs(true, true), &pooled, 1920, 1080)
679            .expect("planning graph compiles");
680        let (aliased, unaliased) = slot_bytes(&slots);
681        let saved = unaliased - aliased;
682        assert!(
683            saved >= 3 * 1024 * 1024,
684            "aliasing reclaims {} MiB (aliased {} MiB of {} MiB) from {:?}",
685            saved / (1024 * 1024),
686            aliased / (1024 * 1024),
687            unaliased / (1024 * 1024),
688            labels_of(&slots)
689        );
690    }
691
692    #[test]
693    fn the_prepass_depth_is_short_lived_only_in_the_planning_graph() {
694        // `gbuffer_depth` looks like the best aliasing candidate in the whole
695        // graph: the planning graph gives it a ONE-PASS lifetime, and reading
696        // that at face value says it could share memory with `hdr_depth` and
697        // reclaim ~8 MiB at 1080p. It cannot. Its only consumer is the temporal
698        // upscaler, and `planning_inputs` cannot force `upscale_enabled` on
699        // because Upscale *substitutes* for TaaResolve rather than adding to it
700        // -- the same mutually-exclusive shape as `unified_gbuffer_prepass`. So
701        // the planning graph models the TAA branch, in which nothing reads the
702        // pre-pass depth at all.
703        //
704        // In the upscaling branch it is live from the pre-pass to Upscale, which
705        // is past the point `hdr_depth` starts, so the two overlap and each
706        // needs its own slot. Pool either of them on that reading and the sweeps
707        // fail -- which is how this was caught.
708        let build = build_inputs(true, true);
709        let planned = build_frame_graph(&planning_inputs(&build)).expect("compiles");
710        let life = |g: &CompiledGraph, label: &str| {
711            let r = g
712                .resources
713                .iter()
714                .find(|r| r.label == label)
715                .unwrap_or_else(|| panic!("{label} declared"));
716            (r.lifetime.first, r.lifetime.last)
717        };
718        let (first, last) = life(&planned, "gbuffer_depth");
719        assert_eq!(
720            first, last,
721            "the planning graph gives the pre-pass depth a one-pass lifetime"
722        );
723
724        let mut upscaling = build;
725        upscaling.upscale_enabled = true;
726        let real = build_frame_graph(&planning_inputs(&upscaling)).expect("compiles");
727        let (up_first, up_last) = life(&real, "gbuffer_depth");
728        assert!(
729            up_last > up_first,
730            "the upscaler reads the pre-pass depth, so its real lifetime spans passes"
731        );
732        let (hdr_first, hdr_last) = life(&real, "hdr_depth");
733        assert!(
734            up_first <= hdr_last && hdr_first <= up_last,
735            "the two depth targets overlap once the upscale branch is modelled: \
736             gbuffer_depth [{up_first},{up_last}] vs hdr_depth [{hdr_first},{hdr_last}]"
737        );
738    }
739
740    // Every gated flag, mirroring `validate::tests::FLAGS`: the sweep is only
741    // as wide as this table, so extend it when a gated pass is added.
742    use super::super::frame::GATED_FLAGS as FLAGS;
743
744    // Assert the slots a pool built for `build` would allocate are conflict-free
745    // in the graph `inputs` compiles to.
746    fn assert_sound(build: &FrameGraphInputs, inputs: &FrameGraphInputs, what: &str) {
747        let slots =
748            plan_transient_slots(build, &pooled, 1920, 1080).expect("the planning graph compiles");
749        let grouped: Vec<Vec<&'static str>> = slots.iter().map(|s| s.labels()).collect();
750        let graph = build_frame_graph(inputs)
751            .unwrap_or_else(|e| panic!("graph failed to compile for {what}: {e}"));
752        let conflicts = slot_conflicts(&graph, &grouped);
753        assert!(
754            conflicts.is_empty(),
755            "aliasing conflict for {what}: {}",
756            conflicts
757                .iter()
758                .map(|c| c.to_string())
759                .collect::<Vec<_>>()
760                .join(", ")
761        );
762    }
763
764    #[test]
765    fn slot_conflicts_over_reachable_graphs() {
766        // The check the pool's whole aliasing rests on: for every build
767        // configuration and every graph a session can reach from it, no slot
768        // has two live members. `apply_view` is applied because the reachable
769        // space is the *masked* one -- the occlusion view extends `ao_output`
770        // to the composite but forces bloom off, so the pair that would
771        // conflict is not actually reachable, and a sweep over raw flag
772        // combinations would report a hazard no session can hit.
773        let builds = [
774            build_inputs(false, false),
775            build_inputs(true, false),
776            build_inputs(false, true),
777            build_inputs(true, true),
778        ];
779        for build in &builds {
780            for (i, (a_name, set_a)) in FLAGS.iter().enumerate() {
781                for (b_name, set_b) in FLAGS.iter().skip(i) {
782                    let mut inputs = FrameGraphInputs::all_off();
783                    set_a(&mut inputs);
784                    set_b(&mut inputs);
785                    let what = format!("{a_name} + {b_name}");
786                    assert_sound(build, &inputs, &what);
787                    for mode in ViewMode::ALL {
788                        for show in [ShowFlags::all(), ShowFlags(0)] {
789                            let masked =
790                                crate::render::render_graph::apply_view(&inputs, mode, show);
791                            assert_sound(
792                                build,
793                                &masked,
794                                &format!("{what} under {mode:?} / {show:?}"),
795                            );
796                        }
797                    }
798                }
799            }
800        }
801    }
802
803    #[test]
804    fn slot_conflicts_over_the_fully_loaded_graph_in_every_view() {
805        // The wide end: every pass on at once, swept across every view mode and
806        // every show-flag subset, which is where a mask that turns one pass off
807        // while leaving a lifetime-extending one on would show up.
808        let mut loaded = FrameGraphInputs::all_off();
809        for (name, set) in FLAGS {
810            if *name != "world_hidden" {
811                set(&mut loaded);
812            }
813        }
814        let build = build_inputs(true, true);
815        for mode in ViewMode::ALL {
816            for bits in 0..(1u32 << ShowFlags::LABELED.len()) {
817                let show = ShowFlags(bits);
818                let masked = crate::render::render_graph::apply_view(&loaded, mode, show);
819                assert_sound(
820                    &build,
821                    &masked,
822                    &format!("loaded under {mode:?} / {bits:b}"),
823                );
824            }
825        }
826    }
827}