concinnity-core 0.18.69

Runtime vocabulary for the Concinnity engine: GPU layouts, ECS components, registry, CPU kernels
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
// src/render_graph/transient.rs
//
// The slot list a backend's transient pool is built from, and the check that
// keeps that list sound.
//
// A pool takes one [`TransientSlot`] per aliasing-plan slot and makes one
// allocation for it, sized to its largest member, with every member placed at
// offset 0. Each member arrives as a resolved [`TransientTexture`] -- concrete
// pixel extents and the graph's own format / usage / sample count -- so a
// backend translates one description into its native descriptor rather than
// keeping a per-label table of its own. That is the point: a table restating
// what the graph already declares can disagree with it, and a disagreement
// about a format or an extent is silent.
//
// Pools are built at init / resize; graphs compile per frame. So the plan a
// pool is built from describes ONE graph, and reusing its grouping for the
// frames that follow is safe only while no slot has two members live at once in
// any of them. That is not free: a resource whose lifetime a pass *extends*
// looks more disjoint in a graph missing that pass, and there is no single
// maximal graph to plan against -- `unified_gbuffer_prepass`,
// `rt_reflections_enabled` and `upscale_enabled` substitute passes rather than
// adding them, so no one graph contains every lifetime. Three things cover it,
// and each catches what the others cannot:
//
//   1. The pool plans against its build configuration, and treats every input
//      it cannot rebuild on as live. [`planning_inputs`].
//   2. `slot_conflicts_over_reachable_graphs` in this module's tests sweeps the
//      reachable input space and fails on any slot with two overlapping
//      members.
//   3. Each executor asserts [`slot_conflicts`] per frame under
//      `debug_assertions`, over the graph it is about to run -- which covers
//      the combinations the sweep did not reach.

use super::alias::plan_aliasing_for;
use super::compile::CompiledGraph;
use super::frame::FrameGraphInputs;
use super::types::{ClearValue, PixelFormat, TextureUsage};
use alloc::string::String;
use alloc::string::ToString;
use alloc::vec::Vec;

/// One pooled transient, resolved against a concrete drawable extent. The
/// backend translates this into its native texture descriptor; nothing here is
/// backend-specific.
///
/// `PartialEq` but not `Eq`: the clear value is floats.
#[derive(Clone, Debug, PartialEq)]
pub struct TransientTexture {
    /// The graph label, which is also how a feature reads the texture back out
    /// of the pool and how the barrier registry names it.
    pub label: &'static str,
    /// Width in pixels.
    pub width: u32,
    /// Height in pixels.
    pub height: u32,
    /// 1 for a 2D texture, > 1 for a volume.
    pub depth: u32,
    /// Texel format.
    pub format: PixelFormat,
    /// MSAA sample count; 1 for non-multisample.
    pub sample_count: u32,
    /// Array layers; 1 for plain 2D.
    pub array_layers: u32,
    /// Mip levels in the chain.
    pub mip_levels: u32,
    /// How passes bind the texture.
    pub usage: TextureUsage,
    /// What the writing pass clears this target to. Carried through because
    /// D3D12 bakes it into the resource at creation; see `TextureDesc::clear`.
    pub clear: ClearValue,
}

/// One slot: the members that share a backing allocation, in the order they
/// reuse it (lifetime-start). A single-member slot is a plain pooled target; a
/// multi-member slot is a realised alias, and the order is what each backend's
/// aliasing barriers are wired from.
#[derive(Clone, Debug, PartialEq)]
pub struct TransientSlot {
    /// Textures sharing this slot, with pairwise-disjoint lifetimes.
    pub members: Vec<TransientTexture>,
}

impl TransientSlot {
    /// The member textures' labels, in assignment order.
    pub fn labels(&self) -> Vec<&'static str> {
        self.members.iter().map(|m| m.label).collect()
    }
}

/// The inputs a pool plans its slots against, given the configuration it was
/// built for. `build` carries the flags the pool is rebuilt on (SSAO and bloom
/// being switched on or off both rebuild it, as does a resize); every gated pass
/// is forced on here, so no lifetime a pass would extend is missing from the
/// graph the grouping is decided on.
///
/// `composite_reads_ao` is ON, and the history is worth keeping. It used to be
/// off, on the argument that it describes a different frame rather than a fuller
/// one (it is reachable only in the occlusion view, which forces bloom off) and
/// that planning against it would refuse the only aliasing the pool had. The
/// second half has expired now that the G-buffer channels are pooled: there is
/// plenty else to alias, and turning it on costs this plan nothing.
///
/// The first half turned out to be a trap. Modelling `ao_output` as short-lived
/// is only safe while nothing else is pooled around the reflection resolve --
/// the moment a one-pass post-stack target joins the pool, the greedy pairs it
/// with `ao_output` and the sweep reports the overlap the occlusion view really
/// has. Measured, not argued: adding such a target made both sweeps fail here.
/// Extending a lifetime is always the safe direction, so it stays on.
///
/// `upscale_enabled` is NOT forced on, and a lifetime read out of this graph can
/// therefore be shorter than the real maximal one. Upscale substitutes for
/// TaaResolve, so forcing it would drop the TAA branch instead; neither branch
/// dominates the other and one graph cannot hold both. The concrete casualty is
/// `gbuffer_depth`, whose only consumer is the upscaler and which looks one-pass
/// here -- see `the_prepass_depth_is_short_lived_only_in_the_planning_graph`.
///
/// Nothing here is load-bearing on its own. What makes the grouping sound is the
/// sweep over the reachable space in this module's tests plus each executor's
/// per-frame assertion; if this graph ever becomes too permissive the sweep is
/// what fails.
pub(crate) fn planning_inputs(build: &FrameGraphInputs) -> FrameGraphInputs {
    FrameGraphInputs {
        // `world_hidden` masks passes off rather than on, so leaving it false
        // keeps the richer graph.
        world_hidden: false,
        composite_reads_ao: true,
        shadow_enabled: true,
        bindless_cull_enabled: true,
        auto_exposure_enabled: true,
        velocity_enabled: true,
        taa_enabled: true,
        ssr_enabled: true,
        particles_enabled: true,
        fog_enabled: true,
        decals_enabled: true,
        ssr_prepass_enabled: true,
        transparent_enabled: true,
        lines_enabled: true,
        raymarch_enabled: true,
        two_pass_occlusion_enabled: true,
        ssgi_enabled: true,
        clustered_lighting_enabled: true,
        hiz_build_enabled: true,
        ..*build
    }
}

// The slots a pool built for `build` should allocate, over the transients
// `poolable` accepts, at `drawable_w` x `drawable_h`. Empty when the pool owns
// nothing. Returns `None` when the planning graph fails to compile, which is a
// caller's cue to fall back to one slot per managed resource rather than
// silently aliasing on a plan that was never made.
pub(crate) fn plan_transient_slots(
    build: &FrameGraphInputs,
    poolable: &dyn Fn(&str) -> bool,
    drawable_w: u32,
    drawable_h: u32,
) -> Option<Vec<TransientSlot>> {
    let graph = super::frame::build_frame_graph(&planning_inputs(build)).ok()?;
    let plan = plan_aliasing_for(&graph, drawable_w, drawable_h, poolable);
    Some(
        plan.slots
            .iter()
            .map(|slot| TransientSlot {
                members: slot
                    .members
                    .iter()
                    .map(|&idx| resolve(&graph, idx, drawable_w, drawable_h))
                    .collect(),
            })
            .collect(),
    )
}

/// The transients a backend pool owns; everything else the graph declares
/// transient stays backend-owned. One set for every backend, because which
/// labels are pooled is policy rather than a per-backend capability: a set that
/// differed per backend would make their footprints incomparable and would leave
/// the soundness sweep below checking a grouping no backend builds.
///
/// `gbuffer_depth` is deliberately absent while its three colour siblings are
/// here. D3D12 creates a shader-readable depth target with a typeless resource
/// format (`R32_TYPELESS`) and views it as `D32_FLOAT` / `R32_FLOAT`, while
/// `PixelFormat::Depth32Float` names one format for all three roles, so the pool
/// would create a resource the feature's SRV cannot view. Pooling it would
/// reclaim nothing anyway: its one-pass planning lifetime is an artifact of a
/// graph that cannot model the upscaler (see
/// `the_prepass_depth_is_short_lived_only_in_the_planning_graph`).
pub fn pooled(label: &str) -> bool {
    matches!(
        label,
        "ao_output"
            | "bloom_top"
            | "gbuffer_normal_depth"
            | "gbuffer_roughness"
            | "gbuffer_velocity"
    )
}

/// The feature gates a backend's transient pool is built for, i.e. the ones it
/// is rebuilt on. Everything else `planning_inputs` forces live.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct PoolGates {
    /// SSAO is built, so `ao_output` exists.
    pub ssao: bool,
    /// The bloom chain's top octave is managed. Metal and DirectX pass `true`
    /// unconditionally: they toggle bloom per frame off the post-process
    /// intensity while the composite binds mip 0 either way, so a pool built at
    /// init / resize cannot gate on it. Vulkan rebuilds on the flag and passes
    /// the real value.
    pub bloom: bool,
    /// The unified G-buffer pre-pass is built, so its colour channels exist.
    pub gbuffer: bool,
}

/// The alias-slot list a pool built for `gates` should allocate, taken straight
/// from the graph: the grouping and each member's extent, format and usage come
/// from one planning graph, so init and resize cannot drift apart and neither
/// can the graph and the resource it describes.
///
/// `render_extent` sizes the render-resolution transients and `output_extent` is
/// the drawable the half-resolution ones scale off; under temporal upscaling
/// they differ, which is why both are passed rather than derived.
///
/// A planning graph that does not compile is a hard error rather than an empty
/// pool: every consumer reads its target back out by label, so silently pooling
/// nothing would fail later and further from the cause.
pub fn plan_pool_slots(
    gates: PoolGates,
    render_extent: (u32, u32),
    output_extent: (u32, u32),
) -> Result<Vec<TransientSlot>, String> {
    let mut build = FrameGraphInputs::all_off();
    build.hdr_width = render_extent.0;
    build.hdr_height = render_extent.1;
    build.ssao_enabled = gates.ssao;
    build.bloom_enabled = gates.bloom;
    // The unified pre-pass SUBSTITUTES for the separate SsrPrepass / Velocity
    // nodes rather than adding to them, so `planning_inputs` cannot force it on
    // the way it does the purely additive passes: it has to follow the build.
    // `velocity_enabled` is what makes the node appear once the flag is set,
    // and this gate must match the one that builds the pre-pass itself, or a
    // consumer reads a label the pool never created.
    build.unified_gbuffer_prepass = gates.gbuffer;
    build.velocity_enabled = gates.gbuffer;
    plan_transient_slots(&build, &pooled, output_extent.0, output_extent.1)
        .ok_or_else(|| "transient pool: the planning frame graph failed to compile".to_string())
}

// One graph resource as the pool must create it.
fn resolve(
    graph: &CompiledGraph,
    idx: usize,
    drawable_w: u32,
    drawable_h: u32,
) -> TransientTexture {
    let res = &graph.resources[idx];
    let desc = res
        .tex_desc
        .expect("the planner only places resources carrying a texture desc");
    let (width, height, depth) = desc.extent(drawable_w, drawable_h);
    TransientTexture {
        label: res.label,
        width,
        height,
        depth,
        format: desc.format,
        sample_count: desc.sample_count.max(1),
        array_layers: desc.array_layers.max(1),
        mip_levels: desc.mip_levels.max(1),
        usage: desc.usage,
        clear: desc.clear,
    }
}

// Two members of one slot whose lifetimes overlap in a graph, i.e. two
// resources that would be live at once on the same bytes.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub(crate) struct SlotConflict {
    pub slot: usize,
    pub a: &'static str,
    pub b: &'static str,
}

impl core::fmt::Display for SlotConflict {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        write!(
            f,
            "slot {}: {} and {} are both live",
            self.slot, self.a, self.b
        )
    }
}

// Every pair of slot members whose `[first, last]` lifetimes overlap in
// `graph`. Empty when the grouping is sound for this graph, which is the
// invariant a pool's aliasing rests on: members of a slot share bytes, so two
// live at once means one is reading memory the other overwrote.
//
// Labels absent from `graph` are skipped -- a pool holds a resource for as long
// as its build configuration says so, and a frame that omits the pass writing
// it simply does not use it.
pub(crate) fn slot_conflicts(
    graph: &CompiledGraph,
    slots: &[Vec<&'static str>],
) -> Vec<SlotConflict> {
    let lifetime = |label: &str| {
        graph
            .resources
            .iter()
            .find(|r| r.label == label)
            .map(|r| (r.lifetime.first, r.lifetime.last))
    };
    let mut conflicts = Vec::new();
    for (slot, members) in slots.iter().enumerate() {
        for (i, &a) in members.iter().enumerate() {
            let Some((a_first, a_last)) = lifetime(a) else {
                continue;
            };
            for &b in &members[i + 1..] {
                let Some((b_first, b_last)) = lifetime(b) else {
                    continue;
                };
                if a_first <= b_last && b_first <= a_last {
                    conflicts.push(SlotConflict { slot, a, b });
                }
            }
        }
    }
    conflicts
}

/// Panic if any alias slot has two members live at once in `graph`. Members of a
/// slot share bytes, so two live at once means one reads memory the other
/// overwrote, and unlike a barrier gap there is no validation layer behind it on
/// any backend. Every executor calls this per frame under `debug_assertions`,
/// over the graph it is about to run; `backend` names the caller in the message.
///
/// This is the layer the sweep in this module's tests cannot be: the pool is
/// planned once per build configuration while graphs compile per frame, and
/// passes that *substitute* for one another mean there is no single maximal
/// graph to plan against. The sweep covers the input space it models; this
/// covers the graph actually in hand.
pub fn assert_slot_aliasing_sound(
    graph: &CompiledGraph,
    slot_labels: &[Vec<&'static str>],
    backend: &str,
) {
    let conflicts = slot_conflicts(graph, slot_labels);
    assert!(
        conflicts.is_empty(),
        "transient pool ({backend}): alias slot members are simultaneously live: {}",
        conflicts
            .iter()
            .map(|c| c.to_string())
            .collect::<Vec<_>>()
            .join(", ")
    );
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::gfx::view_modes::{ShowFlags, ViewMode};
    use crate::render::render_graph::frame::build_frame_graph;
    use alloc::format;
    use alloc::vec;

    // The flags a build configuration carries, i.e. the ones a pool is rebuilt
    // on. Everything else `planning_inputs` forces live.
    //
    // The G-buffer gate is one of them because `unified_gbuffer_prepass`
    // *substitutes* for the separate SsrPrepass / Velocity nodes rather than
    // adding to them, so `planning_inputs` cannot force it on the way it forces
    // the purely additive passes.
    fn build_inputs(ssao: bool, bloom: bool) -> FrameGraphInputs {
        build_inputs_with(ssao, bloom, true)
    }

    fn build_inputs_with(ssao: bool, bloom: bool, gbuffer: bool) -> FrameGraphInputs {
        let mut i = FrameGraphInputs::all_off();
        i.ssao_enabled = ssao;
        i.bloom_enabled = bloom;
        i.unified_gbuffer_prepass = gbuffer;
        i.velocity_enabled = gbuffer;
        i.hdr_width = 1920;
        i.hdr_height = 1080;
        i
    }

    #[test]
    fn slots_carry_the_graphs_own_shape() {
        // The point of planning off the graph: the extent, format and usage a
        // pool creates come from the graph's desc, so there is no second
        // description to disagree with it.
        let slots = plan_transient_slots(&build_inputs(true, true), &pooled, 1920, 1080)
            .expect("planning graph compiles");
        let member = |label: &str| {
            slots
                .iter()
                .flat_map(|s| &s.members)
                .find(|m| m.label == label)
                .unwrap_or_else(|| panic!("{label} pooled"))
                .clone()
        };

        // `ao_output` is render-resolution R8.
        let ao = member("ao_output");
        assert_eq!((ao.width, ao.height, ao.depth), (1920, 1080, 1));
        assert_eq!(ao.format, PixelFormat::R8Unorm);
        assert_eq!((ao.sample_count, ao.array_layers, ao.mip_levels), (1, 1, 1));
        assert!(ao.usage.contains(TextureUsage::RENDER_TARGET));
        assert!(ao.usage.contains(TextureUsage::SHADER_READ));

        // `bloom_top` is half the *drawable* extent, which is what every
        // backend builds its bloom chain from -- not half the render
        // resolution, which differs from it under temporal upscaling.
        let bloom = member("bloom_top");
        assert_eq!((bloom.width, bloom.height), (960, 540));
        assert_eq!(bloom.format, PixelFormat::Rgba16Float);
    }

    #[test]
    fn bloom_top_follows_the_drawable_not_the_render_resolution() {
        // An upscaled configuration: render resolution well under the
        // drawable. `ao_output` follows the render resolution and `bloom_top`
        // the drawable, and the two must not track each other.
        let mut build = build_inputs(true, true);
        build.hdr_width = 1280;
        build.hdr_height = 720;
        let slots =
            plan_transient_slots(&build, &pooled, 2560, 1440).expect("planning graph compiles");
        let member = |label: &str| {
            slots
                .iter()
                .flat_map(|s| &s.members)
                .find(|m| m.label == label)
                .unwrap_or_else(|| panic!("{label} pooled"))
                .clone()
        };
        assert_eq!(
            (member("ao_output").width, member("ao_output").height),
            (1280, 720)
        );
        assert_eq!(
            (member("bloom_top").width, member("bloom_top").height),
            (1280, 720),
            "half of 2560x1440, which happens to equal the render resolution here"
        );
    }

    #[test]
    fn an_unpooled_label_gets_no_slot() {
        // A pool owns what its build configuration says it owns; the rest of
        // the graph's transients stay backend-owned and unplanned.
        let slots = plan_transient_slots(&build_inputs(true, true), &|_| false, 1920, 1080)
            .expect("planning graph compiles");
        assert!(slots.is_empty());

        let only_ao =
            plan_transient_slots(&build_inputs(true, true), &|l| l == "ao_output", 1920, 1080)
                .expect("planning graph compiles");
        assert_eq!(only_ao.len(), 1);
        assert_eq!(only_ao[0].labels(), vec!["ao_output"]);
    }

    #[test]
    fn ssao_off_leaves_only_the_bloom_target() {
        // No SSAO and no G-buffer pre-pass: `bloom_top` is the only pooled
        // resource the graph declares.
        let slots =
            plan_transient_slots(&build_inputs_with(false, true, false), &pooled, 1920, 1080)
                .expect("planning graph compiles");
        let labels: Vec<&str> = slots.iter().flat_map(|s| s.labels()).collect();
        assert_eq!(labels, vec!["bloom_top"]);
    }

    #[test]
    fn nothing_pooled_when_neither_feature_is_built() {
        let slots =
            plan_transient_slots(&build_inputs_with(false, false, false), &pooled, 1920, 1080)
                .expect("planning graph compiles");
        assert!(slots.is_empty());
    }

    #[test]
    fn the_gbuffer_gate_places_its_colour_targets() {
        // The gate is a build flag rather than something `planning_inputs`
        // forces, so a pool built without it must place none of them -- which is
        // what makes it safe for a backend to pool them only when the pre-pass
        // exists.
        let off = plan_transient_slots(&build_inputs_with(true, true, false), &pooled, 1920, 1080)
            .expect("planning graph compiles");
        let off_labels: Vec<&str> = off.iter().flat_map(|s| s.labels()).collect();
        assert!(
            !off_labels.contains(&"gbuffer_normal_depth"),
            "{off_labels:?}"
        );

        let on = plan_transient_slots(&build_inputs(true, true), &pooled, 1920, 1080)
            .expect("planning graph compiles");
        let on_labels: Vec<&str> = on.iter().flat_map(|s| s.labels()).collect();
        for want in [
            "gbuffer_normal_depth",
            "gbuffer_roughness",
            "gbuffer_velocity",
        ] {
            assert!(on_labels.contains(&want), "{want}: {on_labels:?}");
        }
    }

    #[test]
    fn slot_conflicts_reports_an_overlapping_pair() {
        // Negative control for the predicate the executors assert. `ao_output`
        // and `bloom_top` overlap in the occlusion view (which extends
        // `ao_output` to the composite) with bloom also on, so grouping them
        // must report. Without this the sweep below could pass on a predicate
        // that never reports anything.
        let mut i = FrameGraphInputs::all_off();
        i.ssao_enabled = true;
        i.bloom_enabled = true;
        i.composite_reads_ao = true;
        let graph = build_frame_graph(&i).expect("compiles");

        let grouped = vec![vec!["ao_output", "bloom_top"]];
        let conflicts = slot_conflicts(&graph, &grouped);
        assert_eq!(conflicts.len(), 1, "{conflicts:?}");
        assert_eq!(conflicts[0].slot, 0);

        // One per slot: split them and the same graph is sound.
        let split = vec![vec!["ao_output"], vec!["bloom_top"]];
        assert_eq!(slot_conflicts(&graph, &split), vec![]);
    }

    #[test]
    fn a_label_absent_from_the_graph_is_not_a_conflict() {
        // A pool holds `ao_output` for as long as SSAO is built; a frame whose
        // graph omits the SSAO pass simply does not use it, which is not a
        // reason to alarm.
        let graph = build_frame_graph(&FrameGraphInputs::all_off()).expect("compiles");
        let grouped = vec![vec!["ao_output", "bloom_top"]];
        assert_eq!(slot_conflicts(&graph, &grouped), vec![]);
    }

    #[test]
    fn planning_inputs_forces_the_gated_passes_on() {
        // A pass the planning graph omits is a lifetime it under-reports, so
        // every gated pass is on regardless of the build configuration.
        let planned = planning_inputs(&build_inputs(false, false));
        assert!(planned.ssgi_enabled);
        assert!(planned.transparent_enabled);
        assert!(planned.raymarch_enabled);
        assert!(!planned.world_hidden, "masking off passes is not the risk");
        // On, and it has to be: the occlusion view extends `ao_output` to the
        // Composite, past the reflection resolve. A plan made without it pairs
        // `ao_output` with `ssr_reflection`, which the sweep rejects.
        assert!(planned.composite_reads_ao);
        // The build flags pass through, because the pool *is* rebuilt on them.
        assert!(!planned.ssao_enabled);
        assert!(!planned.bloom_enabled);
        assert!(planning_inputs(&build_inputs(true, true)).ssao_enabled);
    }

    #[test]
    fn the_pool_actually_aliases_something() {
        // Anti-vacuity guard for the two sweeps below. Single-member slots are
        // trivially conflict-free, so a plan that stopped aliasing would leave
        // the sweeps passing while checking nothing.
        //
        // What aliases is worth reading: `bloom_top` (Bloom -> Composite, late)
        // pairs with whichever early resource is largest. The three G-buffer
        // colour targets do NOT alias each other -- every one is written by the
        // pre-pass and read by a late consumer, so their lifetimes span most of
        // the frame. That is why pooling this group reclaims far less than the
        // HDR / post groups will.
        let slots = plan_transient_slots(&build_inputs(true, true), &pooled, 1920, 1080)
            .expect("planning graph compiles");
        let shared: Vec<Vec<&'static str>> = slots
            .iter()
            .map(|s| s.labels())
            .filter(|l| l.len() > 1)
            .collect();
        assert!(
            !shared.is_empty(),
            "no slot aliases anything, so the sweeps check nothing: {:?}",
            labels_of(&slots)
        );
        assert!(
            shared.iter().any(|l| l.contains(&"bloom_top")),
            "bloom_top is the late resource that makes an alias possible: {:?}",
            labels_of(&slots)
        );
    }

    fn labels_of(slots: &[TransientSlot]) -> Vec<Vec<&'static str>> {
        slots.iter().map(|s| s.labels()).collect()
    }

    // Bytes a slot list costs (each slot sized to its largest member) against
    // what the same members would cost unaliased. This is the measurement that
    // decides whether a group migration is worth its wiring, and it runs
    // headlessly -- a group's footprint is NOT its saving, because members with
    // overlapping lifetimes each need their own slot.
    // The members already carry resolved pixel extents, so no drawable is
    // needed here.
    fn slot_bytes(slots: &[TransientSlot]) -> (u64, u64) {
        let member_bytes = |m: &TransientTexture| -> u64 {
            let texels = (m.width as u64) * (m.height as u64) * (m.depth.max(1) as u64);
            texels
                * m.format.bytes_per_texel() as u64
                * m.sample_count.max(1) as u64
                * m.array_layers.max(1) as u64
        };
        let mut aliased = 0;
        let mut unaliased = 0;
        for slot in slots {
            let mut largest = 0;
            for m in &slot.members {
                let b = member_bytes(m);
                unaliased += b;
                largest = largest.max(b);
            }
            aliased += largest;
        }
        (aliased, unaliased)
    }

    #[test]
    fn the_pooled_set_reclaims_what_the_plan_says() {
        // A regression guard on the *saving*, which is the point of the pool and
        // is otherwise invisible until someone measures a running frame: every
        // other test here would still pass if the plan quietly stopped aliasing.
        //
        // The number is small on purpose, and knowing why is what keeps the next
        // group migration honest. At 1920x1080 the plan is
        //   [gbuffer_normal_depth + bloom_top] [gbuffer_roughness]
        //   [gbuffer_velocity] [ao_output]
        // i.e. only `bloom_top` aliases at all. Every other pooled member is
        // live across most of the frame -- the G-buffer channels from the
        // pre-pass to their last consumer, `ao_output` from the SSAO node to
        // Main (to the Composite in the occlusion view) -- so they overlap each
        // other and each needs its own slot. Aliasing pays for *short* lifetimes,
        // and this renderer has few.
        let slots = plan_transient_slots(&build_inputs(true, true), &pooled, 1920, 1080)
            .expect("planning graph compiles");
        let (aliased, unaliased) = slot_bytes(&slots);
        let saved = unaliased - aliased;
        assert!(
            saved >= 3 * 1024 * 1024,
            "aliasing reclaims {} MiB (aliased {} MiB of {} MiB) from {:?}",
            saved / (1024 * 1024),
            aliased / (1024 * 1024),
            unaliased / (1024 * 1024),
            labels_of(&slots)
        );
    }

    #[test]
    fn the_prepass_depth_is_short_lived_only_in_the_planning_graph() {
        // `gbuffer_depth` looks like the best aliasing candidate in the whole
        // graph: the planning graph gives it a ONE-PASS lifetime, and reading
        // that at face value says it could share memory with `hdr_depth` and
        // reclaim ~8 MiB at 1080p. It cannot. Its only consumer is the temporal
        // upscaler, and `planning_inputs` cannot force `upscale_enabled` on
        // because Upscale *substitutes* for TaaResolve rather than adding to it
        // -- the same mutually-exclusive shape as `unified_gbuffer_prepass`. So
        // the planning graph models the TAA branch, in which nothing reads the
        // pre-pass depth at all.
        //
        // In the upscaling branch it is live from the pre-pass to Upscale, which
        // is past the point `hdr_depth` starts, so the two overlap and each
        // needs its own slot. Pool either of them on that reading and the sweeps
        // fail -- which is how this was caught.
        let build = build_inputs(true, true);
        let planned = build_frame_graph(&planning_inputs(&build)).expect("compiles");
        let life = |g: &CompiledGraph, label: &str| {
            let r = g
                .resources
                .iter()
                .find(|r| r.label == label)
                .unwrap_or_else(|| panic!("{label} declared"));
            (r.lifetime.first, r.lifetime.last)
        };
        let (first, last) = life(&planned, "gbuffer_depth");
        assert_eq!(
            first, last,
            "the planning graph gives the pre-pass depth a one-pass lifetime"
        );

        let mut upscaling = build;
        upscaling.upscale_enabled = true;
        let real = build_frame_graph(&planning_inputs(&upscaling)).expect("compiles");
        let (up_first, up_last) = life(&real, "gbuffer_depth");
        assert!(
            up_last > up_first,
            "the upscaler reads the pre-pass depth, so its real lifetime spans passes"
        );
        let (hdr_first, hdr_last) = life(&real, "hdr_depth");
        assert!(
            up_first <= hdr_last && hdr_first <= up_last,
            "the two depth targets overlap once the upscale branch is modelled: \
             gbuffer_depth [{up_first},{up_last}] vs hdr_depth [{hdr_first},{hdr_last}]"
        );
    }

    // Every gated flag, mirroring `validate::tests::FLAGS`: the sweep is only
    // as wide as this table, so extend it when a gated pass is added.
    use super::super::frame::GATED_FLAGS as FLAGS;

    // Assert the slots a pool built for `build` would allocate are conflict-free
    // in the graph `inputs` compiles to.
    fn assert_sound(build: &FrameGraphInputs, inputs: &FrameGraphInputs, what: &str) {
        let slots =
            plan_transient_slots(build, &pooled, 1920, 1080).expect("the planning graph compiles");
        let grouped: Vec<Vec<&'static str>> = slots.iter().map(|s| s.labels()).collect();
        let graph = build_frame_graph(inputs)
            .unwrap_or_else(|e| panic!("graph failed to compile for {what}: {e}"));
        let conflicts = slot_conflicts(&graph, &grouped);
        assert!(
            conflicts.is_empty(),
            "aliasing conflict for {what}: {}",
            conflicts
                .iter()
                .map(|c| c.to_string())
                .collect::<Vec<_>>()
                .join(", ")
        );
    }

    #[test]
    fn slot_conflicts_over_reachable_graphs() {
        // The check the pool's whole aliasing rests on: for every build
        // configuration and every graph a session can reach from it, no slot
        // has two live members. `apply_view` is applied because the reachable
        // space is the *masked* one -- the occlusion view extends `ao_output`
        // to the composite but forces bloom off, so the pair that would
        // conflict is not actually reachable, and a sweep over raw flag
        // combinations would report a hazard no session can hit.
        let builds = [
            build_inputs(false, false),
            build_inputs(true, false),
            build_inputs(false, true),
            build_inputs(true, true),
        ];
        for build in &builds {
            for (i, (a_name, set_a)) in FLAGS.iter().enumerate() {
                for (b_name, set_b) in FLAGS.iter().skip(i) {
                    let mut inputs = FrameGraphInputs::all_off();
                    set_a(&mut inputs);
                    set_b(&mut inputs);
                    let what = format!("{a_name} + {b_name}");
                    assert_sound(build, &inputs, &what);
                    for mode in ViewMode::ALL {
                        for show in [ShowFlags::all(), ShowFlags(0)] {
                            let masked =
                                crate::render::render_graph::apply_view(&inputs, mode, show);
                            assert_sound(
                                build,
                                &masked,
                                &format!("{what} under {mode:?} / {show:?}"),
                            );
                        }
                    }
                }
            }
        }
    }

    #[test]
    fn slot_conflicts_over_the_fully_loaded_graph_in_every_view() {
        // The wide end: every pass on at once, swept across every view mode and
        // every show-flag subset, which is where a mask that turns one pass off
        // while leaving a lifetime-extending one on would show up.
        let mut loaded = FrameGraphInputs::all_off();
        for (name, set) in FLAGS {
            if *name != "world_hidden" {
                set(&mut loaded);
            }
        }
        let build = build_inputs(true, true);
        for mode in ViewMode::ALL {
            for bits in 0..(1u32 << ShowFlags::LABELED.len()) {
                let show = ShowFlags(bits);
                let masked = crate::render::render_graph::apply_view(&loaded, mode, show);
                assert_sound(
                    &build,
                    &masked,
                    &format!("loaded under {mode:?} / {bits:b}"),
                );
            }
        }
    }
}