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}