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::format;
39use alloc::string::String;
40use alloc::string::ToString;
41use alloc::vec;
42use alloc::vec::Vec;
43
44/// One pooled transient, resolved against a concrete drawable extent. The
45/// backend translates this into its native texture descriptor; nothing here is
46/// backend-specific.
47///
48/// `PartialEq` but not `Eq`: the clear value is floats.
49#[derive(Clone, Debug, PartialEq)]
50pub struct TransientTexture {
51    /// The graph label, which is also how a feature reads the texture back out
52    /// of the pool and how the barrier registry names it.
53    pub label: &'static str,
54    /// Width in pixels.
55    pub width: u32,
56    /// Height in pixels.
57    pub height: u32,
58    /// 1 for a 2D texture, > 1 for a volume.
59    pub depth: u32,
60    /// Texel format.
61    pub format: PixelFormat,
62    /// MSAA sample count; 1 for non-multisample.
63    pub sample_count: u32,
64    /// Array layers; 1 for plain 2D.
65    pub array_layers: u32,
66    /// Mip levels in the chain.
67    pub mip_levels: u32,
68    /// How passes bind the texture.
69    pub usage: TextureUsage,
70    /// What the writing pass clears this target to. Carried through because
71    /// D3D12 bakes it into the resource at creation; see `TextureDesc::clear`.
72    pub clear: ClearValue,
73}
74
75/// One slot: the members that share a backing allocation, in the order they
76/// reuse it (lifetime-start). A single-member slot is a plain pooled target; a
77/// multi-member slot is a realised alias, and the order is what each backend's
78/// aliasing barriers are wired from.
79#[derive(Clone, Debug, PartialEq)]
80pub struct TransientSlot {
81    /// Textures sharing this slot, with pairwise-disjoint lifetimes.
82    pub members: Vec<TransientTexture>,
83}
84
85impl TransientSlot {
86    /// The member textures' labels, in assignment order.
87    pub fn labels(&self) -> Vec<&'static str> {
88        self.members.iter().map(|m| m.label).collect()
89    }
90}
91
92/// The inputs a pool plans its slots against, given the configuration it was
93/// built for. `build` carries the flags the pool is rebuilt on (SSAO and bloom
94/// being switched on or off both rebuild it, as does a resize); every gated pass
95/// is forced on here, so no lifetime a pass would extend is missing from the
96/// graph the grouping is decided on.
97///
98/// `composite_reads_ao` is ON, and the history is worth keeping. It used to be
99/// off, on the argument that it describes a different frame rather than a fuller
100/// one (it is reachable only in the occlusion view, which forces bloom off) and
101/// that planning against it would refuse the only aliasing the pool had. The
102/// second half has expired now that the G-buffer channels are pooled: there is
103/// plenty else to alias, and turning it on costs this plan nothing.
104///
105/// The first half turned out to be a trap. Modelling `ao_output` as short-lived
106/// is only safe while nothing else is pooled around the reflection resolve --
107/// the moment a one-pass post-stack target joins the pool, the greedy pairs it
108/// with `ao_output` and the sweep reports the overlap the occlusion view really
109/// has. Measured, not argued: adding such a target made both sweeps fail here.
110/// Extending a lifetime is always the safe direction, so it stays on.
111///
112/// `upscale_enabled` is NOT forced on, and a lifetime read out of this graph can
113/// therefore be shorter than the real maximal one. Upscale substitutes for
114/// TaaResolve, so forcing it would drop the TAA branch instead; neither branch
115/// dominates the other and one graph cannot hold both. The concrete casualty is
116/// `gbuffer_depth`, whose only consumer is the upscaler and which looks one-pass
117/// here -- see `the_prepass_depth_is_short_lived_only_in_the_planning_graph`.
118///
119/// Nothing here is load-bearing on its own. What makes the grouping sound is the
120/// sweep over the reachable space in this module's tests plus each executor's
121/// per-frame assertion; if this graph ever becomes too permissive the sweep is
122/// what fails.
123pub(crate) fn planning_inputs(build: &FrameGraphInputs) -> FrameGraphInputs {
124    FrameGraphInputs {
125        // `world_hidden` masks passes off rather than on, so leaving it false
126        // keeps the richer graph.
127        world_hidden: false,
128        composite_reads_ao: true,
129        shadow_enabled: true,
130        bindless_cull_enabled: true,
131        auto_exposure_enabled: true,
132        velocity_enabled: true,
133        taa_enabled: true,
134        ssr_enabled: true,
135        particles_enabled: true,
136        fog_enabled: true,
137        decals_enabled: true,
138        ssr_prepass_enabled: true,
139        transparent_enabled: true,
140        lines_enabled: true,
141        raymarch_enabled: true,
142        two_pass_occlusion_enabled: true,
143        ssgi_enabled: true,
144        clustered_lighting_enabled: true,
145        hiz_build_enabled: true,
146        ..*build
147    }
148}
149
150// The slots a pool built for `build` should allocate, over the transients
151// `poolable` accepts, at `drawable_w` x `drawable_h`. Empty when the pool owns
152// nothing. Returns `None` when the planning graph fails to compile, which is a
153// caller's cue to fall back to one slot per managed resource rather than
154// silently aliasing on a plan that was never made.
155pub(crate) fn plan_transient_slots(
156    build: &FrameGraphInputs,
157    poolable: &dyn Fn(&str) -> bool,
158    drawable_w: u32,
159    drawable_h: u32,
160) -> Option<Vec<TransientSlot>> {
161    let graph = super::frame::build_frame_graph(&planning_inputs(build)).ok()?;
162    let plan = plan_aliasing_for(&graph, drawable_w, drawable_h, poolable);
163    Some(
164        plan.slots
165            .iter()
166            .map(|slot| TransientSlot {
167                members: slot
168                    .members
169                    .iter()
170                    .map(|&idx| resolve(&graph, idx, drawable_w, drawable_h))
171                    .collect(),
172            })
173            .collect(),
174    )
175}
176
177/// The transients a backend pool owns; everything else the graph declares
178/// transient stays backend-owned. One set for every backend, because which
179/// labels are pooled is policy rather than a per-backend capability: a set that
180/// differed per backend would make their footprints incomparable and would leave
181/// the soundness sweep below checking a grouping no backend builds.
182///
183/// `gbuffer_depth` is deliberately absent while its three colour siblings are
184/// here. D3D12 creates a shader-readable depth target with a typeless resource
185/// format (`R32_TYPELESS`) and views it as `D32_FLOAT` / `R32_FLOAT`, while
186/// `PixelFormat::Depth32Float` names one format for all three roles, so the pool
187/// would create a resource the feature's SRV cannot view. Pooling it would
188/// reclaim nothing anyway: its one-pass planning lifetime is an artifact of a
189/// graph that cannot model the upscaler (see
190/// `the_prepass_depth_is_short_lived_only_in_the_planning_graph`).
191pub fn pooled(label: &str) -> bool {
192    matches!(
193        label,
194        "ao_output"
195            | "bloom_top"
196            | "gbuffer_normal_depth"
197            | "gbuffer_roughness"
198            | "gbuffer_velocity"
199    )
200}
201
202/// The feature gates a backend's transient pool is built for, i.e. the ones it
203/// is rebuilt on. Everything else `planning_inputs` forces live.
204#[derive(Copy, Clone, Debug, PartialEq, Eq)]
205pub struct PoolGates {
206    /// SSAO is built, so `ao_output` exists.
207    pub ssao: bool,
208    /// The bloom chain's top octave is managed. Metal and DirectX pass `true`
209    /// unconditionally: they toggle bloom per frame off the post-process
210    /// intensity while the composite binds mip 0 either way, so a pool built at
211    /// init / resize cannot gate on it. Vulkan rebuilds on the flag and passes
212    /// the real value.
213    pub bloom: bool,
214    /// The unified G-buffer pre-pass is built, so its colour channels exist.
215    pub gbuffer: bool,
216}
217
218/// The alias-slot list a pool built for `gates` should allocate, taken straight
219/// from the graph: the grouping and each member's extent, format and usage come
220/// from one planning graph, so init and resize cannot drift apart and neither
221/// can the graph and the resource it describes.
222///
223/// `render_extent` sizes the render-resolution transients and `output_extent` is
224/// the drawable the half-resolution ones scale off; under temporal upscaling
225/// they differ, which is why both are passed rather than derived.
226///
227/// A planning graph that does not compile is a hard error rather than an empty
228/// pool: every consumer reads its target back out by label, so silently pooling
229/// nothing would fail later and further from the cause.
230pub fn plan_pool_slots(
231    gates: PoolGates,
232    render_extent: (u32, u32),
233    output_extent: (u32, u32),
234) -> Result<Vec<TransientSlot>, String> {
235    let mut build = FrameGraphInputs::all_off();
236    build.hdr_width = render_extent.0;
237    build.hdr_height = render_extent.1;
238    build.ssao_enabled = gates.ssao;
239    build.bloom_enabled = gates.bloom;
240    // The unified pre-pass SUBSTITUTES for the separate SsrPrepass / Velocity
241    // nodes rather than adding to them, so `planning_inputs` cannot force it on
242    // the way it does the purely additive passes: it has to follow the build.
243    // `velocity_enabled` is what makes the node appear once the flag is set,
244    // and this gate must match the one that builds the pre-pass itself, or a
245    // consumer reads a label the pool never created.
246    build.unified_gbuffer_prepass = gates.gbuffer;
247    build.velocity_enabled = gates.gbuffer;
248    plan_transient_slots(&build, &pooled, output_extent.0, output_extent.1)
249        .ok_or_else(|| "transient pool: the planning frame graph failed to compile".to_string())
250}
251
252// One graph resource as the pool must create it.
253fn resolve(
254    graph: &CompiledGraph,
255    idx: usize,
256    drawable_w: u32,
257    drawable_h: u32,
258) -> TransientTexture {
259    let res = &graph.resources[idx];
260    let desc = res
261        .tex_desc
262        .expect("the planner only places resources carrying a texture desc");
263    let (width, height, depth) = desc.extent(drawable_w, drawable_h);
264    TransientTexture {
265        label: res.label,
266        width,
267        height,
268        depth,
269        format: desc.format,
270        sample_count: desc.sample_count.max(1),
271        array_layers: desc.array_layers.max(1),
272        mip_levels: desc.mip_levels.max(1),
273        usage: desc.usage,
274        clear: desc.clear,
275    }
276}
277
278// Two members of one slot whose lifetimes overlap in a graph, i.e. two
279// resources that would be live at once on the same bytes.
280#[derive(Copy, Clone, Debug, Eq, PartialEq)]
281pub(crate) struct SlotConflict {
282    pub slot: usize,
283    pub a: &'static str,
284    pub b: &'static str,
285}
286
287impl core::fmt::Display for SlotConflict {
288    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
289        write!(
290            f,
291            "slot {}: {} and {} are both live",
292            self.slot, self.a, self.b
293        )
294    }
295}
296
297// Every pair of slot members whose `[first, last]` lifetimes overlap in
298// `graph`. Empty when the grouping is sound for this graph, which is the
299// invariant a pool's aliasing rests on: members of a slot share bytes, so two
300// live at once means one is reading memory the other overwrote.
301//
302// Labels absent from `graph` are skipped -- a pool holds a resource for as long
303// as its build configuration says so, and a frame that omits the pass writing
304// it simply does not use it.
305pub(crate) fn slot_conflicts(
306    graph: &CompiledGraph,
307    slots: &[Vec<&'static str>],
308) -> Vec<SlotConflict> {
309    let lifetime = |label: &str| {
310        graph
311            .resources
312            .iter()
313            .find(|r| r.label == label)
314            .map(|r| (r.lifetime.first, r.lifetime.last))
315    };
316    let mut conflicts = Vec::new();
317    for (slot, members) in slots.iter().enumerate() {
318        for (i, &a) in members.iter().enumerate() {
319            let Some((a_first, a_last)) = lifetime(a) else {
320                continue;
321            };
322            for &b in &members[i + 1..] {
323                let Some((b_first, b_last)) = lifetime(b) else {
324                    continue;
325                };
326                if a_first <= b_last && b_first <= a_last {
327                    conflicts.push(SlotConflict { slot, a, b });
328                }
329            }
330        }
331    }
332    conflicts
333}
334
335/// Panic if any alias slot has two members live at once in `graph`. Members of a
336/// slot share bytes, so two live at once means one reads memory the other
337/// overwrote, and unlike a barrier gap there is no validation layer behind it on
338/// any backend. Every executor calls this per frame under `debug_assertions`,
339/// over the graph it is about to run; `backend` names the caller in the message.
340///
341/// This is the layer the sweep in this module's tests cannot be: the pool is
342/// planned once per build configuration while graphs compile per frame, and
343/// passes that *substitute* for one another mean there is no single maximal
344/// graph to plan against. The sweep covers the input space it models; this
345/// covers the graph actually in hand.
346pub fn assert_slot_aliasing_sound(
347    graph: &CompiledGraph,
348    slot_labels: &[Vec<&'static str>],
349    backend: &str,
350) {
351    let conflicts = slot_conflicts(graph, slot_labels);
352    assert!(
353        conflicts.is_empty(),
354        "transient pool ({backend}): alias slot members are simultaneously live: {}",
355        conflicts
356            .iter()
357            .map(|c| c.to_string())
358            .collect::<Vec<_>>()
359            .join(", ")
360    );
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::render_graph::frame::build_frame_graph;
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    type FlagSetter = fn(&mut FrameGraphInputs);
709    const FLAGS: &[(&str, FlagSetter)] = &[
710        ("shadow", |i| i.shadow_enabled = true),
711        ("bindless_cull", |i| i.bindless_cull_enabled = true),
712        ("auto_exposure", |i| i.auto_exposure_enabled = true),
713        ("bloom", |i| i.bloom_enabled = true),
714        ("velocity", |i| i.velocity_enabled = true),
715        ("taa", |i| i.taa_enabled = true),
716        ("ssr", |i| i.ssr_enabled = true),
717        ("particles", |i| i.particles_enabled = true),
718        ("fog", |i| i.fog_enabled = true),
719        ("decals", |i| i.decals_enabled = true),
720        ("ssr_prepass", |i| i.ssr_prepass_enabled = true),
721        ("ssao", |i| i.ssao_enabled = true),
722        ("upscale", |i| i.upscale_enabled = true),
723        ("transparent", |i| i.transparent_enabled = true),
724        ("lines", |i| i.lines_enabled = true),
725        ("raymarch", |i| i.raymarch_enabled = true),
726        ("two_pass_occlusion", |i| {
727            i.two_pass_occlusion_enabled = true
728        }),
729        ("ssgi", |i| i.ssgi_enabled = true),
730        ("rt_reflections", |i| i.rt_reflections_enabled = true),
731        ("unified_gbuffer", |i| i.unified_gbuffer_prepass = true),
732        ("world_hidden", |i| i.world_hidden = true),
733        ("clustered_lighting", |i| {
734            i.clustered_lighting_enabled = true
735        }),
736        ("composite_reads_ao", |i| i.composite_reads_ao = true),
737        ("shadowed_spots", |i| i.shadowed_spot_count = 2),
738        ("hiz_build", |i| i.hiz_build_enabled = true),
739    ];
740
741    // Assert the slots a pool built for `build` would allocate are conflict-free
742    // in the graph `inputs` compiles to.
743    fn assert_sound(build: &FrameGraphInputs, inputs: &FrameGraphInputs, what: &str) {
744        let slots =
745            plan_transient_slots(build, &pooled, 1920, 1080).expect("the planning graph compiles");
746        let grouped: Vec<Vec<&'static str>> = slots.iter().map(|s| s.labels()).collect();
747        let graph = build_frame_graph(inputs)
748            .unwrap_or_else(|e| panic!("graph failed to compile for {what}: {e}"));
749        let conflicts = slot_conflicts(&graph, &grouped);
750        assert!(
751            conflicts.is_empty(),
752            "aliasing conflict for {what}: {}",
753            conflicts
754                .iter()
755                .map(|c| c.to_string())
756                .collect::<Vec<_>>()
757                .join(", ")
758        );
759    }
760
761    #[test]
762    fn slot_conflicts_over_reachable_graphs() {
763        // The check the pool's whole aliasing rests on: for every build
764        // configuration and every graph a session can reach from it, no slot
765        // has two live members. `apply_view` is applied because the reachable
766        // space is the *masked* one -- the occlusion view extends `ao_output`
767        // to the composite but forces bloom off, so the pair that would
768        // conflict is not actually reachable, and a sweep over raw flag
769        // combinations would report a hazard no session can hit.
770        let builds = [
771            build_inputs(false, false),
772            build_inputs(true, false),
773            build_inputs(false, true),
774            build_inputs(true, true),
775        ];
776        for build in &builds {
777            for (i, (a_name, set_a)) in FLAGS.iter().enumerate() {
778                for (b_name, set_b) in FLAGS.iter().skip(i) {
779                    let mut inputs = FrameGraphInputs::all_off();
780                    set_a(&mut inputs);
781                    set_b(&mut inputs);
782                    let what = format!("{a_name} + {b_name}");
783                    assert_sound(build, &inputs, &what);
784                    for mode in ViewMode::ALL {
785                        for show in [ShowFlags::all(), ShowFlags(0)] {
786                            let masked = crate::render_graph::apply_view(&inputs, mode, show);
787                            assert_sound(
788                                build,
789                                &masked,
790                                &format!("{what} under {mode:?} / {show:?}"),
791                            );
792                        }
793                    }
794                }
795            }
796        }
797    }
798
799    #[test]
800    fn slot_conflicts_over_the_fully_loaded_graph_in_every_view() {
801        // The wide end: every pass on at once, swept across every view mode and
802        // every show-flag subset, which is where a mask that turns one pass off
803        // while leaving a lifetime-extending one on would show up.
804        let mut loaded = FrameGraphInputs::all_off();
805        for (name, set) in FLAGS {
806            if *name != "world_hidden" {
807                set(&mut loaded);
808            }
809        }
810        let build = build_inputs(true, true);
811        for mode in ViewMode::ALL {
812            for bits in 0..(1u32 << ShowFlags::LABELED.len()) {
813                let show = ShowFlags(bits);
814                let masked = crate::render_graph::apply_view(&loaded, mode, show);
815                assert_sound(
816                    &build,
817                    &masked,
818                    &format!("loaded under {mode:?} / {bits:b}"),
819                );
820            }
821        }
822    }
823}