Skip to main content

concinnity_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::render_graph::frame::build_frame_graph;
365    use alloc::format;
366    use alloc::vec;
367    use concinnity_core::gfx::view_modes::{ShowFlags, ViewMode};
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    #[test]
536    fn a_label_absent_from_the_graph_is_not_a_conflict() {
537        // A pool holds `ao_output` for as long as SSAO is built; a frame whose
538        // graph omits the SSAO pass simply does not use it, which is not a
539        // reason to alarm.
540        let graph = build_frame_graph(&FrameGraphInputs::all_off()).expect("compiles");
541        let grouped = vec![vec!["ao_output", "bloom_top"]];
542        assert_eq!(slot_conflicts(&graph, &grouped), vec![]);
543    }
544
545    #[test]
546    fn planning_inputs_forces_the_gated_passes_on() {
547        // A pass the planning graph omits is a lifetime it under-reports, so
548        // every gated pass is on regardless of the build configuration.
549        let planned = planning_inputs(&build_inputs(false, false));
550        assert!(planned.ssgi_enabled);
551        assert!(planned.transparent_enabled);
552        assert!(planned.raymarch_enabled);
553        assert!(!planned.world_hidden, "masking off passes is not the risk");
554        // On, and it has to be: the occlusion view extends `ao_output` to the
555        // Composite, past the reflection resolve. A plan made without it pairs
556        // `ao_output` with `ssr_reflection`, which the sweep rejects.
557        assert!(planned.composite_reads_ao);
558        // The build flags pass through, because the pool *is* rebuilt on them.
559        assert!(!planned.ssao_enabled);
560        assert!(!planned.bloom_enabled);
561        assert!(planning_inputs(&build_inputs(true, true)).ssao_enabled);
562    }
563
564    #[test]
565    fn the_pool_actually_aliases_something() {
566        // Anti-vacuity guard for the two sweeps below. Single-member slots are
567        // trivially conflict-free, so a plan that stopped aliasing would leave
568        // the sweeps passing while checking nothing.
569        //
570        // What aliases is worth reading: `bloom_top` (Bloom -> Composite, late)
571        // pairs with whichever early resource is largest. The three G-buffer
572        // colour targets do NOT alias each other -- every one is written by the
573        // pre-pass and read by a late consumer, so their lifetimes span most of
574        // the frame. That is why pooling this group reclaims far less than the
575        // HDR / post groups will.
576        let slots = plan_transient_slots(&build_inputs(true, true), &pooled, 1920, 1080)
577            .expect("planning graph compiles");
578        let shared: Vec<Vec<&'static str>> = slots
579            .iter()
580            .map(|s| s.labels())
581            .filter(|l| l.len() > 1)
582            .collect();
583        assert!(
584            !shared.is_empty(),
585            "no slot aliases anything, so the sweeps check nothing: {:?}",
586            labels_of(&slots)
587        );
588        assert!(
589            shared.iter().any(|l| l.contains(&"bloom_top")),
590            "bloom_top is the late resource that makes an alias possible: {:?}",
591            labels_of(&slots)
592        );
593    }
594
595    fn labels_of(slots: &[TransientSlot]) -> Vec<Vec<&'static str>> {
596        slots.iter().map(|s| s.labels()).collect()
597    }
598
599    // Bytes a slot list costs (each slot sized to its largest member) against
600    // what the same members would cost unaliased. This is the measurement that
601    // decides whether a group migration is worth its wiring, and it runs
602    // headlessly -- a group's footprint is NOT its saving, because members with
603    // overlapping lifetimes each need their own slot.
604    // The members already carry resolved pixel extents, so no drawable is
605    // needed here.
606    fn slot_bytes(slots: &[TransientSlot]) -> (u64, u64) {
607        let member_bytes = |m: &TransientTexture| -> u64 {
608            let texels = (m.width as u64) * (m.height as u64) * (m.depth.max(1) as u64);
609            texels
610                * m.format.bytes_per_texel() as u64
611                * m.sample_count.max(1) as u64
612                * m.array_layers.max(1) as u64
613        };
614        let mut aliased = 0;
615        let mut unaliased = 0;
616        for slot in slots {
617            let mut largest = 0;
618            for m in &slot.members {
619                let b = member_bytes(m);
620                unaliased += b;
621                largest = largest.max(b);
622            }
623            aliased += largest;
624        }
625        (aliased, unaliased)
626    }
627
628    #[test]
629    fn the_pooled_set_reclaims_what_the_plan_says() {
630        // A regression guard on the *saving*, which is the point of the pool and
631        // is otherwise invisible until someone measures a running frame: every
632        // other test here would still pass if the plan quietly stopped aliasing.
633        //
634        // The number is small on purpose, and knowing why is what keeps the next
635        // group migration honest. At 1920x1080 the plan is
636        //   [gbuffer_normal_depth + bloom_top] [gbuffer_roughness]
637        //   [gbuffer_velocity] [ao_output]
638        // i.e. only `bloom_top` aliases at all. Every other pooled member is
639        // live across most of the frame -- the G-buffer channels from the
640        // pre-pass to their last consumer, `ao_output` from the SSAO node to
641        // Main (to the Composite in the occlusion view) -- so they overlap each
642        // other and each needs its own slot. Aliasing pays for *short* lifetimes,
643        // and this renderer has few.
644        let slots = plan_transient_slots(&build_inputs(true, true), &pooled, 1920, 1080)
645            .expect("planning graph compiles");
646        let (aliased, unaliased) = slot_bytes(&slots);
647        let saved = unaliased - aliased;
648        assert!(
649            saved >= 3 * 1024 * 1024,
650            "aliasing reclaims {} MiB (aliased {} MiB of {} MiB) from {:?}",
651            saved / (1024 * 1024),
652            aliased / (1024 * 1024),
653            unaliased / (1024 * 1024),
654            labels_of(&slots)
655        );
656    }
657
658    #[test]
659    fn the_prepass_depth_is_short_lived_only_in_the_planning_graph() {
660        // `gbuffer_depth` looks like the best aliasing candidate in the whole
661        // graph: the planning graph gives it a ONE-PASS lifetime, and reading
662        // that at face value says it could share memory with `hdr_depth` and
663        // reclaim ~8 MiB at 1080p. It cannot. Its only consumer is the temporal
664        // upscaler, and `planning_inputs` cannot force `upscale_enabled` on
665        // because Upscale *substitutes* for TaaResolve rather than adding to it
666        // -- the same mutually-exclusive shape as `unified_gbuffer_prepass`. So
667        // the planning graph models the TAA branch, in which nothing reads the
668        // pre-pass depth at all.
669        //
670        // In the upscaling branch it is live from the pre-pass to Upscale, which
671        // is past the point `hdr_depth` starts, so the two overlap and each
672        // needs its own slot. Pool either of them on that reading and the sweeps
673        // fail -- which is how this was caught.
674        let build = build_inputs(true, true);
675        let planned = build_frame_graph(&planning_inputs(&build)).expect("compiles");
676        let life = |g: &CompiledGraph, label: &str| {
677            let r = g
678                .resources
679                .iter()
680                .find(|r| r.label == label)
681                .unwrap_or_else(|| panic!("{label} declared"));
682            (r.lifetime.first, r.lifetime.last)
683        };
684        let (first, last) = life(&planned, "gbuffer_depth");
685        assert_eq!(
686            first, last,
687            "the planning graph gives the pre-pass depth a one-pass lifetime"
688        );
689
690        let mut upscaling = build;
691        upscaling.upscale_enabled = true;
692        let real = build_frame_graph(&planning_inputs(&upscaling)).expect("compiles");
693        let (up_first, up_last) = life(&real, "gbuffer_depth");
694        assert!(
695            up_last > up_first,
696            "the upscaler reads the pre-pass depth, so its real lifetime spans passes"
697        );
698        let (hdr_first, hdr_last) = life(&real, "hdr_depth");
699        assert!(
700            up_first <= hdr_last && hdr_first <= up_last,
701            "the two depth targets overlap once the upscale branch is modelled: \
702             gbuffer_depth [{up_first},{up_last}] vs hdr_depth [{hdr_first},{hdr_last}]"
703        );
704    }
705
706    // Every gated flag, mirroring `validate::tests::FLAGS`: the sweep is only
707    // as wide as this table, so extend it when a gated pass is added.
708    use super::super::frame::GATED_FLAGS as FLAGS;
709
710    // Assert the slots a pool built for `build` would allocate are conflict-free
711    // in the graph `inputs` compiles to.
712    fn assert_sound(build: &FrameGraphInputs, inputs: &FrameGraphInputs, what: &str) {
713        let slots =
714            plan_transient_slots(build, &pooled, 1920, 1080).expect("the planning graph compiles");
715        let grouped: Vec<Vec<&'static str>> = slots.iter().map(|s| s.labels()).collect();
716        let graph = build_frame_graph(inputs)
717            .unwrap_or_else(|e| panic!("graph failed to compile for {what}: {e}"));
718        let conflicts = slot_conflicts(&graph, &grouped);
719        assert!(
720            conflicts.is_empty(),
721            "aliasing conflict for {what}: {}",
722            conflicts
723                .iter()
724                .map(|c| c.to_string())
725                .collect::<Vec<_>>()
726                .join(", ")
727        );
728    }
729
730    #[test]
731    fn slot_conflicts_over_reachable_graphs() {
732        // The check the pool's whole aliasing rests on: for every build
733        // configuration and every graph a session can reach from it, no slot
734        // has two live members. `apply_view` is applied because the reachable
735        // space is the *masked* one -- the occlusion view extends `ao_output`
736        // to the composite but forces bloom off, so the pair that would
737        // conflict is not actually reachable, and a sweep over raw flag
738        // combinations would report a hazard no session can hit.
739        let builds = [
740            build_inputs(false, false),
741            build_inputs(true, false),
742            build_inputs(false, true),
743            build_inputs(true, true),
744        ];
745        for build in &builds {
746            for (i, (a_name, set_a)) in FLAGS.iter().enumerate() {
747                for (b_name, set_b) in FLAGS.iter().skip(i) {
748                    let mut inputs = FrameGraphInputs::all_off();
749                    set_a(&mut inputs);
750                    set_b(&mut inputs);
751                    let what = format!("{a_name} + {b_name}");
752                    assert_sound(build, &inputs, &what);
753                    for mode in ViewMode::ALL {
754                        for show in [ShowFlags::all(), ShowFlags(0)] {
755                            let masked = crate::render_graph::apply_view(&inputs, mode, show);
756                            assert_sound(
757                                build,
758                                &masked,
759                                &format!("{what} under {mode:?} / {show:?}"),
760                            );
761                        }
762                    }
763                }
764            }
765        }
766    }
767
768    #[test]
769    fn slot_conflicts_over_the_fully_loaded_graph_in_every_view() {
770        // The wide end: every pass on at once, swept across every view mode and
771        // every show-flag subset, which is where a mask that turns one pass off
772        // while leaving a lifetime-extending one on would show up.
773        let mut loaded = FrameGraphInputs::all_off();
774        for (name, set) in FLAGS {
775            if *name != "world_hidden" {
776                set(&mut loaded);
777            }
778        }
779        let build = build_inputs(true, true);
780        for mode in ViewMode::ALL {
781            for bits in 0..(1u32 << ShowFlags::LABELED.len()) {
782                let show = ShowFlags(bits);
783                let masked = crate::render_graph::apply_view(&loaded, mode, show);
784                assert_sound(
785                    &build,
786                    &masked,
787                    &format!("loaded under {mode:?} / {bits:b}"),
788                );
789            }
790        }
791    }
792}