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}