concinnity_core/render/render_graph/frame.rs
1// src/render_graph/frame.rs
2//
3// Per-frame graph builder. On Metal, every pass that ran inline
4// through `draw_frame` now dispatches through a single
5// `build_frame_graph()` → `execute_graph()` pair.
6//
7// The frame builder declares conditional passes based on the
8// `FrameGraphInputs` struct (one bool per gated pass). Read / write
9// declarations on each pass let the compile pass derive:
10//
11// * Execution order (toposort over RAW / WAW / WAR edges, ties broken
12// by declaration order).
13// * Per-pass barriers (`pass.barriers_before` per resource state
14// transition). Metal mostly ignores these (Apple GPUs handle most
15// hazards implicitly); the Vulkan / DirectX executors emit
16// `vkCmdPipelineBarrier` / `D3D12_RESOURCE_BARRIER` from them.
17// * Transient resource lifetimes (`PassRange` per resource), the
18// aliasing input.
19//
20// Resources split into two origins. `import_texture` = engine-owned: the
21// resource outlives the frame (the cross-frame shadow map, the TAA history
22// `scene_color`, the froxel volume, the cross-frame Hi-Z pyramid) and the
23// backend always owns its GPU object.
24//
25// A resource is declared only where a pass actually writes it. Several engine
26// bindings point two names at one texture depending on configuration --
27// `scene_pre_taa` is `hdr_resolve` without a reflection resolve, `scene_color`
28// is the pre-TAA scene without TAA, `hdr_color` is the resolve target without
29// MSAA -- and declaring the second name anyway would give one GPU object two
30// independent barrier timelines. The builder threads the upstream handle
31// through instead, so one texture is always one resource. `create_texture` =
32// transient: single-frame intermediates (hdr intermediates excepted)
33// the aliasing planner ([`super::alias`]) may pack into shared physical memory,
34// since their `[first, last]` lifetimes are disjoint. In practice only
35// `ao_output` and `bloom_top` are independently poolable today; the other
36// `create_texture` intermediates fold into the long-lived gbuffer MRT
37// (`velocity`, `ssr_gbuffer`) or are themselves long-lived (`gbuffer`), so a
38// backend pool leaves them backend-owned. The planner sizes each transient
39// from its desc; the origin marks aliasing candidacy and has no effect on pass
40// order or barriers.
41
42use crate::gfx::render_types::NUM_SHADOW_CASCADES;
43
44use super::{
45 BufferDesc, BufferUsage, CompiledGraph, GraphBuilder, GraphError, PassId, PassKind,
46 PixelFormat, TextureDesc, TextureHandle, TextureSize, TextureUsage, full_mip_levels,
47};
48
49/// Per-frame inputs that gate conditional passes. Built by `draw_frame`
50/// from the live `MtlContext` state and consumed by `build_frame_graph`
51/// so the conditional-inclusion decisions made here match what the
52/// executor will dispatch.
53#[derive(Copy, Clone, Debug, PartialEq, Eq)]
54pub struct FrameGraphInputs {
55 /// `true` when a `ShadowStage` is in the world (i.e. the backend's
56 /// shadow pipeline + cascade uniforms are live). Skips the Shadow
57 /// pass when false rather than relying on the encoder's early
58 /// return, so the compiled graph reflects what actually runs.
59 pub shadow_enabled: bool,
60 /// Per-cascade slice dimensions of the shadow-map array texture.
61 /// Carried so the imported `shadow_map` resource carries its real
62 /// shape for aliasing; ignored by the executor.
63 pub shadow_map_size: u32,
64 /// Pixel dimensions of the HDR off-screen targets the Main pass
65 /// writes (and the post stack consumes). Carried for aliasing;
66 /// ignored by the executor.
67 pub hdr_width: u32,
68 /// HDR target height in pixels.
69 pub hdr_height: u32,
70 /// MSAA sample count of the HDR colour + depth attachments, typically
71 /// 4. The resolve target is single-sample regardless.
72 pub hdr_sample_count: u32,
73 /// `true` when GPU-driven cull is going to run this frame, i.e. the
74 /// bindless static path is configured AND there is geometry to cull
75 /// AND the per-frame `object_buffer` / `draw_args` buffers built. The
76 /// graph adds the Cull compute pass and the Main read-edge from
77 /// `draw_args` only when this is on; otherwise Main draws via the
78 /// legacy per-draw path with no graph dependency on the cull output.
79 pub bindless_cull_enabled: bool,
80 /// `true` when the auto-exposure compute pipelines are built (i.e.
81 /// the world declared `PostProcessConfig.auto_exposure`). The graph
82 /// appends an `AutoExposure` compute pass that reads the Main pass's
83 /// `hdr_resolve_v1` (pre-decoration) and writes the histogram +
84 /// readback buffer. The compile pass's WAR step pins AutoExposure
85 /// before the first hdr_resolve post-Main writer (Decals or Fog or
86 /// ParticlesDraw) so AutoExposure samples the un-decorated scene.
87 pub auto_exposure_enabled: bool,
88 /// `true` when `PostProcessConfig.bloom_intensity > 0.0`. The graph
89 /// adds a `Bloom` pass that thresholds / downsamples / upsamples the
90 /// post-TAA scene into the bloom mip chain; Composite reads the
91 /// bloom output so the toposort orders Bloom before Composite.
92 pub bloom_enabled: bool,
93 /// `true` when TAA is on (the velocity pre-pass only runs as part of
94 /// the TAA stack). The graph adds a `Velocity` render pass that
95 /// writes the per-pixel motion-vector buffer TaaResolve consumes;
96 /// TaaResolve declares the read so Velocity → TaaResolve is explicit.
97 pub velocity_enabled: bool,
98 /// `true` when TAA is on. The graph adds a `TaaResolve` render pass
99 /// that reads the pre-TAA scene (SSR resolve output or hdr_resolve)
100 /// and writes the imported `scene_color` Bloom + Composite consume.
101 pub taa_enabled: bool,
102 /// `true` when SSR is on. The graph adds an `SsrResolve` render pass
103 /// that reads the post-decoration `hdr_resolve` and writes the
104 /// imported `scene_pre_taa` texture, which only exists when this or
105 /// `rt_reflections_enabled` is set. When TAA is also on, TaaResolve
106 /// reads the post-SsrResolve version; with TAA off, Bloom +
107 /// Composite read that version directly.
108 pub ssr_enabled: bool,
109 /// `true` when the particle system is going to run this frame:
110 /// `particle_pipelines` built AND at least one live emitter. The
111 /// graph adds a `ParticlesDraw` render pass that blend-writes
112 /// `hdr_resolve`. The bundled ParticlesSim compute sub-pass runs
113 /// inside the same `encode_particles` call so it keeps its per-pass
114 /// timing slot without needing its own graph node.
115 pub particles_enabled: bool,
116 /// `true` when a `VolumetricFog` is in the world. The graph adds a
117 /// `Fog` render pass between Decals and ParticlesDraw on the
118 /// hdr_resolve RMW chain.
119 pub fog_enabled: bool,
120 /// `true` when at least one `Decal` is in the world AND the decal
121 /// pipeline is built. The graph adds a `Decals` render pass at the
122 /// head of the hdr_resolve post-Main RMW chain.
123 pub decals_enabled: bool,
124 /// `true` when the SSR pre-pass should run; matches
125 /// `self.ssr_settings.is_some()`. The graph adds an `SsrPrepass`
126 /// render pass that writes the imported `ssr_gbuffer` texture;
127 /// SsaoBlur reads it when SSAO is also on (G-buffer sharing).
128 pub ssr_prepass_enabled: bool,
129 /// `true` when SSAO should run; matches
130 /// `self.ssao_settings.is_some()`. The graph adds an `SsaoBlur`
131 /// render pass that dispatches the bundled `encode_ssao` (which
132 /// internally encodes SsaoPrepass + SsaoKernel + SsaoBlur). SsaoBlur
133 /// writes `ao_output`; Main reads it. SsaoPrepass + SsaoKernel
134 /// stay as timing-only PassIds (same pattern as ParticlesSim).
135 pub ssao_enabled: bool,
136 /// `true` when temporal upscaling is on (e.g. MetalFX on Metal). The
137 /// graph adds an `Upscale` pass between the post-SSR scene and the
138 /// Bloom + Composite stack that reads `scene_pre_taa` + `velocity`
139 /// and writes the imported `scene_color` at output resolution. When
140 /// this is on, `TaaResolve` is *not* added: the upscaler does
141 /// temporal accumulation itself, so adding TAA on top would
142 /// double-temporal. `velocity_enabled` should still be on (the
143 /// scaler consumes motion vectors); the engine layer is responsible
144 /// for keeping the two flags in sync.
145 pub upscale_enabled: bool,
146 /// `true` when at least one transparent / translucent draw is in the
147 /// world (water, glass, ...). The graph adds a `Transparent` render
148 /// pass after `SsrResolve` and before `TaaResolve` / `Upscale` that
149 /// reads the latest scene-pre-taa colour + main depth and
150 /// alpha-blends translucent geometry back-to-front into the same
151 /// target. The pass aggregates N draws, each owns its own
152 /// pipeline + descriptor set, the executor receives the sorted list
153 /// at encode time.
154 pub transparent_enabled: bool,
155 /// `true` when a system submitted world-space lines this frame AND the
156 /// backend's line pipeline is live. The graph adds a `Lines` render pass at
157 /// the tail of the hdr_resolve RMW chain: it blend-writes the scene colour
158 /// and samples the resolved scene depth so a line behind geometry is
159 /// occluded by it. A frame with no lines omits the node entirely.
160 pub lines_enabled: bool,
161 /// `true` when at least one visible `SdfVolume` is in the world AND
162 /// the backend's raymarch pipeline is live. The graph adds a
163 /// `Raymarch` render pass between `AutoExposure` and `Decals` on the
164 /// hdr_resolve RMW chain: it reads the head of the chain (so
165 /// AutoExposure samples the pre-raymarch scene) and writes the next
166 /// version that Decals then bumps further. The pass also RMWs the
167 /// main depth attachment so subsequent passes see raymarched
168 /// surfaces' depth, and that read-modify-write is declared, which is
169 /// what makes the post-Raymarch depth version the one every later
170 /// decoration pass samples.
171 pub raymarch_enabled: bool,
172 /// `true` when two-pass Hi-Z occlusion culling is requested
173 /// (`PostProcessConfig.occlusion_two_pass`) AND the bindless GPU-cull
174 /// path is active this frame. Only meaningful alongside
175 /// `bindless_cull_enabled`; the builder ANDs the two so a world that
176 /// asks for two-pass without a bindless shader simply gets the
177 /// single-pass path. When on, the graph inserts `HizBuild` → `Cull2`
178 /// → `Main2` between `Main` and the post-decoration chain: `HizBuild`
179 /// rebuilds the Hi-Z pyramid from phase-1 depth, `Cull2` re-tests the
180 /// objects phase-1 cull marked occluded, and `Main2` redraws the
181 /// disoccluded survivors. `Main2`'s hdr_resolve write becomes the head
182 /// of the post chain so AutoExposure / Decals / Fog / SSR see the
183 /// combined two-pass result.
184 pub two_pass_occlusion_enabled: bool,
185 /// `true` when screen-space global illumination is on
186 /// (`PostProcessConfig.indirect_lighting == "ssgi"`); matches
187 /// `self.ssgi_settings.is_some()`. The graph inserts an `Ssgi` render pass
188 /// on the hdr_resolve RMW chain right after `Raymarch` and before `Decals`:
189 /// it reads the head of the chain (the lit scene, its bounce-radiance
190 /// source) and writes the next version with the gathered indirect term
191 /// additively composited in. SSGI reuses the SSR pre-pass G-buffer for
192 /// normals + depth, so `ssr_prepass_enabled` is forced on whenever this is
193 /// set.
194 pub ssgi_enabled: bool,
195 /// `true` when hardware ray-traced reflections are live (RT requested + GPU
196 /// supports it + the scene acceleration structure built); matches
197 /// `self.rt_accel.is_some()`. The graph adds an `RtReflections` render pass
198 /// in the *same slot* as `SsrResolve` (reads the post-decoration
199 /// `hdr_resolve`, writes `scene_pre_taa`). RT *takes precedence* over SSR: a
200 /// world may enable both, and where this is set the builder inserts
201 /// `RtReflections` and omits `SsrResolve`, so at most one of them is in the
202 /// graph. Like SSGI it reuses the SSR depth + normal + roughness pre-pass,
203 /// so `ssr_prepass_enabled` is forced on whenever this is set.
204 pub rt_reflections_enabled: bool,
205 /// `true` to collapse the SSR / SSAO / velocity geometry pre-passes into a
206 /// single `GBufferPrepass` node that writes view-space normal+depth,
207 /// roughness, and motion in one traversal: every consumer reads that one
208 /// output. When set, the builder emits `GBufferPrepass` (gated on any of
209 /// `ssr_prepass_enabled || ssao_enabled || velocity_enabled`) instead of the
210 /// separate `SsrPrepass` + `Velocity` nodes.
211 pub unified_gbuffer_prepass: bool,
212 /// `true` when an opaque full-screen menu backdrop covers the scene, so
213 /// nothing the world passes produce is visible. The builder masks every
214 /// gated world pass off and collapses the graph to `Main -> Composite`
215 /// (Composite still presents the menu overlay). The backend pairs this with
216 /// an empty visible set so the surviving Main pass is a bare clear; the
217 /// opaque overlay then covers it.
218 pub world_hidden: bool,
219 /// `true` when the scene has local lights to cluster. The graph adds a
220 /// `LightCull` compute pass before Main that bins the lights into per-cluster
221 /// lists Main reads (RAW edge). A backend with no light-cull pipeline keeps
222 /// this false and iterates the local lights directly.
223 pub clustered_lighting_enabled: bool,
224 /// `true` when the composite samples the SSAO output directly (the
225 /// occlusion view mode). Declares a Composite read of `ao_output`, so the
226 /// pool-aliased transient stays live to the end of the frame instead of
227 /// dying after Main. No effect while `ssao_enabled` is false.
228 pub composite_reads_ao: bool,
229 /// Number of spot shadow map slices to render, i.e. how many spot lights cast
230 /// shadows. Zero skips the SpotShadow pass and its imported array entirely.
231 pub shadowed_spot_count: u32,
232 /// Per-slice edge of the spot shadow map array, so the imported resource
233 /// carries its real dimensions.
234 pub spot_shadow_slice_size: u32,
235 /// `true` when the GPU-cull path built a Hi-Z pyramid, so the frame ends by
236 /// reducing its final depth into that pyramid for the next frame's phase-1
237 /// cull. The graph adds a terminal `HizFinal` compute pass reading the last
238 /// depth version and writing the pyramid, plus a `Cull` read of the pyramid
239 /// the previous frame left there, which is what orders this frame's cull
240 /// ahead of the rebuild that overwrites it.
241 pub hiz_build_enabled: bool,
242}
243
244impl FrameGraphInputs {
245 // Every gated pass off, at a representative resolution. A neutral base a
246 // caller can flip individual flags on, e.g. to plan a worst-case graph for
247 // transient-memory allocation (where the allocation must cover every
248 // per-frame graph, not just the current frame's active passes).
249 pub(crate) fn all_off() -> Self {
250 FrameGraphInputs {
251 shadow_enabled: false,
252 shadow_map_size: 2048,
253 hdr_width: 1280,
254 hdr_height: 720,
255 hdr_sample_count: 1,
256 bindless_cull_enabled: false,
257 auto_exposure_enabled: false,
258 bloom_enabled: false,
259 velocity_enabled: false,
260 taa_enabled: false,
261 ssr_enabled: false,
262 particles_enabled: false,
263 fog_enabled: false,
264 decals_enabled: false,
265 ssr_prepass_enabled: false,
266 ssao_enabled: false,
267 upscale_enabled: false,
268 transparent_enabled: false,
269 lines_enabled: false,
270 raymarch_enabled: false,
271 two_pass_occlusion_enabled: false,
272 ssgi_enabled: false,
273 rt_reflections_enabled: false,
274 unified_gbuffer_prepass: false,
275 world_hidden: false,
276 clustered_lighting_enabled: false,
277 composite_reads_ao: false,
278 shadowed_spot_count: 0,
279 spot_shadow_slice_size: 512,
280 hiz_build_enabled: false,
281 }
282 }
283}
284
285// Every gated pass flag, paired with the setter that turns it on. The two
286// exhaustive sweeps ([`super::validate`] and [`super::transient`]) are only
287// as wide as this table, so a new gated pass belongs here as well as in
288// `all_off`.
289#[cfg(test)]
290pub(crate) type FlagSetter = fn(&mut FrameGraphInputs);
291#[cfg(test)]
292pub(crate) const GATED_FLAGS: &[(&str, FlagSetter)] = &[
293 ("shadow", |i| i.shadow_enabled = true),
294 ("bindless_cull", |i| i.bindless_cull_enabled = true),
295 ("auto_exposure", |i| i.auto_exposure_enabled = true),
296 ("bloom", |i| i.bloom_enabled = true),
297 ("velocity", |i| i.velocity_enabled = true),
298 ("taa", |i| i.taa_enabled = true),
299 ("ssr", |i| i.ssr_enabled = true),
300 ("particles", |i| i.particles_enabled = true),
301 ("fog", |i| i.fog_enabled = true),
302 ("decals", |i| i.decals_enabled = true),
303 ("ssr_prepass", |i| i.ssr_prepass_enabled = true),
304 ("ssao", |i| i.ssao_enabled = true),
305 ("upscale", |i| i.upscale_enabled = true),
306 ("transparent", |i| i.transparent_enabled = true),
307 ("lines", |i| i.lines_enabled = true),
308 ("raymarch", |i| i.raymarch_enabled = true),
309 ("two_pass_occlusion", |i| {
310 i.two_pass_occlusion_enabled = true
311 }),
312 ("ssgi", |i| i.ssgi_enabled = true),
313 ("rt_reflections", |i| i.rt_reflections_enabled = true),
314 ("unified_gbuffer", |i| i.unified_gbuffer_prepass = true),
315 ("world_hidden", |i| i.world_hidden = true),
316 ("clustered_lighting", |i| {
317 i.clustered_lighting_enabled = true
318 }),
319 ("composite_reads_ao", |i| i.composite_reads_ao = true),
320 ("shadowed_spots", |i| i.shadowed_spot_count = 2),
321 ("hiz_build", |i| i.hiz_build_enabled = true),
322];
323
324// Build the full per-frame render graph. Conditional passes are
325// included based on the `inputs` flags. The compile pass derives
326// execution order, per-pass barriers, and resource lifetimes via
327// RAW + WAW + WAR edges over the version-chained read / write
328// declarations.
329//
330// Order (with all flags on):
331//
332// ```text
333// Cull → SsrPrepass → SsaoBlur → Shadow → Main → AutoExposure
334// → Raymarch → Velocity → Decals → Fog → ParticlesDraw → SsrResolve
335// → Transparent → TaaResolve → Bloom → HizFinal → Composite
336// ```
337//
338// Main depth has a shorter chain over the same spine: Main writes it,
339// Main2 and Raymarch bump it, and Decals / Fog / Lines / Transparent /
340// HizFinal all sample the last version. HizFinal is the frame's terminal
341// depth consumer, which is what keeps the depth live to the end of the
342// graph rather than only to the last decoration.
343//
344// The hdr_resolve version chain (Main writes v1, AutoExposure reads
345// v1 (WAR-pinned before subsequent writers), Decals → v2, Fog → v3,
346// ParticlesDraw → v4, SsrResolve reads v4) is the spine that
347// orders the bulk of the post stack. scene_pre_taa / scene_color /
348// bloom_top each have their own short version chains that branch off
349// the spine where a pass writes them. Transparent extends whichever
350// chain carries the pre-TAA scene -- scene_pre_taa when a reflection
351// resolve produced it, hdr_resolve itself otherwise -- so TaaResolve /
352// Upscale pick up translucent geometry as part of temporal accumulation.
353//
354// When `two_pass_occlusion_enabled` is on the spine gains a phase-2
355// prefix: `Cull → Main → HizBuild → Cull2 → Main2 → AutoExposure →
356// …`. `Main` writes hdr_resolve v1 / hdr_depth v1; `HizBuild` reads
357// the depth and writes the Hi-Z pyramid; `Cull2` reads the pyramid +
358// the phase-1 status buffer and writes `draw_args2`; `Main2` RMWs
359// hdr_color / hdr_depth / hdr_resolve → v2, and that v2 (not v1)
360// becomes the head AutoExposure reads and the RMW chain extends.
361
362// The four attachments the unified G-buffer pre-pass writes in one draw. They
363// are separate resources rather than one handle because their shapes differ
364// (three colour formats and a depth target) and so do their consumers, so one
365// handle would give each of them the union of four lifetimes.
366#[derive(Copy, Clone)]
367struct GBufferHandles {
368 normal_depth: TextureHandle,
369 roughness: TextureHandle,
370 velocity: TextureHandle,
371 depth: TextureHandle,
372}
373
374/// Compile the frame graph for `inputs`: the pass list above, gated down to the
375/// passes this frame actually runs.
376pub fn build_frame_graph(inputs: &FrameGraphInputs) -> Result<CompiledGraph, GraphError> {
377 // When an opaque menu backdrop hides the scene, every world pass is wasted:
378 // nothing it produces is visible. Force every gated world pass off so the
379 // graph collapses to the minimal `Main -> Composite` (Composite still
380 // presents the overlay). Main survives as a bare clear because the backend
381 // feeds it an empty visible set this frame; the opaque overlay covers it.
382 let masked = if inputs.world_hidden {
383 Some(FrameGraphInputs {
384 shadow_enabled: false,
385 bindless_cull_enabled: false,
386 auto_exposure_enabled: false,
387 bloom_enabled: false,
388 velocity_enabled: false,
389 taa_enabled: false,
390 ssr_enabled: false,
391 particles_enabled: false,
392 fog_enabled: false,
393 decals_enabled: false,
394 ssr_prepass_enabled: false,
395 ssao_enabled: false,
396 upscale_enabled: false,
397 transparent_enabled: false,
398 lines_enabled: false,
399 raymarch_enabled: false,
400 two_pass_occlusion_enabled: false,
401 ssgi_enabled: false,
402 rt_reflections_enabled: false,
403 clustered_lighting_enabled: false,
404 shadowed_spot_count: 0,
405 spot_shadow_slice_size: 512,
406 ..*inputs
407 })
408 } else {
409 None
410 };
411 let inputs = masked.as_ref().unwrap_or(inputs);
412
413 let mut b = GraphBuilder::new();
414
415 // Engine-owned imports the Main pass writes into. hdr_resolve is the scene
416 // spine: also written by Decals / Fog / ParticlesDraw and read by
417 // AutoExposure / SsrResolve, so its version chain is the longest.
418 //
419 // `hdr_color` is the multisample colour attachment, and it exists only when
420 // the world is multisampled. Without MSAA there is no separate resolve step
421 // and the single colour target *is* the spine, which every backend already
422 // reflects (Vulkan leaves `color_images` empty; DirectX and Metal leave
423 // their `resolve` field `None` and bind `color`). Declaring it
424 // unconditionally would put two graph resources on one GPU object, and the
425 // moment either became graph-driven they would transition it twice from
426 // states it was no longer in.
427 let hdr_color = (inputs.hdr_sample_count > 1)
428 .then(|| b.import_texture("hdr_color", hdr_color_desc(inputs)));
429 let hdr_depth = b.import_texture("hdr_depth", hdr_depth_desc(inputs));
430 let hdr_resolve = b.import_texture("hdr_resolve", hdr_resolve_desc(inputs));
431
432 // Two-pass occlusion only applies when the bindless GPU-cull path is
433 // active: Hi-Z occlusion rides that path. ANDing here means a world
434 // that requests two-pass without a bindless shader falls back to the
435 // single-pass path with no orphaned phase-2 nodes.
436 let two_pass = inputs.bindless_cull_enabled && inputs.two_pass_occlusion_enabled;
437
438 // The Hi-Z depth pyramid, imported up front because `Cull` reads the version
439 // the *previous* frame left there before `HizFinal` (and, under two-pass,
440 // the mid-frame `HizBuild`) overwrites it. Never a transient: its contents
441 // cross the frame boundary, so the aliasing planner must not place it.
442 let hiz_pyramid = (inputs.hiz_build_enabled || two_pass)
443 .then(|| b.import_texture("hiz_pyramid", hiz_pyramid_desc(inputs)));
444
445 // Cull (compute) writes the indirect-draw args buffer Main consumes
446 // through executeCommandsInBuffer. Under two-pass occlusion it also
447 // writes a per-object status buffer (drawn / hi-z-candidate / culled)
448 // that `Cull2` reads to decide which phase-1-occluded objects to
449 // re-test against the rebuilt pyramid.
450 let (draw_args_v1, cull_status_v1) = if inputs.bindless_cull_enabled {
451 // Import both buffers up front: a live `PassBuilder` holds `&mut b`,
452 // so the resource declarations have to happen before `add_pass`.
453 let draw_args = b.import_buffer("draw_args", draw_args_desc());
454 let cull_status = if two_pass {
455 Some(b.import_buffer("cull_status", cull_status_desc()))
456 } else {
457 None
458 };
459 let mut cull = b.add_pass(PassId::Cull, PassKind::Compute);
460 // The previous frame's pyramid is this cull's occlusion test. Declaring
461 // the read is what gives the terminal rebuild a WAR edge to wait on.
462 if let Some(h) = hiz_pyramid {
463 cull.read_texture(h);
464 }
465 let da = cull.write_buffer(draw_args);
466 let cs = cull_status.map(|h| cull.write_buffer(h));
467 (Some(da), cs)
468 } else {
469 (None, None)
470 };
471
472 // Unified G-buffer pre-pass: one node writes the view-space normal+depth /
473 // roughness / velocity / depth that SSR, SSAO, SSGI, RT, TAA, and the
474 // upscaler read, replacing the separate SsrPrepass + Velocity nodes. Runs
475 // when any of those consumers is on. Every backend takes this path when its
476 // G-buffer targets are built; the separate nodes below are the fallback for
477 // a build without them.
478 let gbuffer_v1 = if inputs.unified_gbuffer_prepass
479 && (inputs.ssr_prepass_enabled || inputs.ssao_enabled || inputs.velocity_enabled)
480 {
481 let normal_depth =
482 b.create_texture("gbuffer_normal_depth", gbuffer_normal_depth_desc(inputs));
483 let roughness = b.create_texture("gbuffer_roughness", gbuffer_roughness_desc(inputs));
484 let velocity = b.create_texture("gbuffer_velocity", velocity_desc(inputs));
485 let depth = b.create_texture("gbuffer_depth", gbuffer_depth_desc(inputs));
486 let mut gb = b.add_pass(PassId::GBufferPrepass, PassKind::Render);
487 // When the GPU-driven cull path is active the pre-pass reuses the main
488 // pass's per-frame indirect command buffer (camera frustum, same cull
489 // output), so it must run after Cull. Reading the cull-produced draw_args
490 // buffer pins that ordering in the toposort (a no-op when bindless cull is
491 // off, where draw_args_v1 is None). Mirrors the Main pass's edge.
492 if let Some(h) = draw_args_v1 {
493 gb.read_buffer(h);
494 }
495 // One draw writes all four attachments; they are separate resources
496 // because their shapes and their consumers differ.
497 Some(GBufferHandles {
498 normal_depth: gb.write_texture(normal_depth),
499 roughness: gb.write_texture(roughness),
500 velocity: gb.write_texture(velocity),
501 depth: gb.write_texture(depth),
502 })
503 } else {
504 None
505 };
506
507 // SSR pre-pass writes the SSR G-buffer; SSAO reads it when both are on (the
508 // shared-G-buffer fast path). Under the unified path the merged node above
509 // supplies the same normal+depth handle, so this separate node is skipped.
510 let ssr_gbuffer_v1 = if let Some(g) = gbuffer_v1 {
511 Some(g.normal_depth)
512 } else if inputs.ssr_prepass_enabled {
513 let ssr_gbuffer = b.create_texture("ssr_gbuffer", ssr_gbuffer_desc(inputs));
514 Some(
515 b.add_pass(PassId::SsrPrepass, PassKind::Render)
516 .write_texture(ssr_gbuffer),
517 )
518 } else {
519 None
520 };
521
522 // SSAO bundle writes ao_output. PassId::SsaoBlur is the single
523 // graph node for the entire encode_ssao bundle; SsaoPrepass +
524 // SsaoKernel keep their per-pass timing slots via inline
525 // `pass_timing.attach_render` calls inside encode_ssao but they're
526 // not graph nodes (the executor rejects them if mis-added).
527 let ao_output_v1 = if inputs.ssao_enabled {
528 let ao_output = b.create_texture("ao_output", ao_output_desc(inputs));
529 let mut ssao = b.add_pass(PassId::SsaoBlur, PassKind::Render);
530 if let Some(h) = ssr_gbuffer_v1 {
531 ssao.read_texture(h);
532 }
533 Some(ssao.write_texture(ao_output))
534 } else {
535 None
536 };
537
538 // Shadow optionally precedes Main and produces the shadow_map
539 // handle Main samples. When off, Main does not declare a shadow_map
540 // read, mirroring the encoder's `enable_shadows` shader path.
541 let shadow_v1 = if inputs.shadow_enabled {
542 let shadow_map = b.import_texture("shadow_map", shadow_map_desc(inputs.shadow_map_size));
543 Some(
544 b.add_pass(PassId::Shadow, PassKind::Render)
545 .write_texture(shadow_map),
546 )
547 } else {
548 None
549 };
550
551 // Clustered light binning (compute): bins the scene's local lights into
552 // per-cluster index lists. Writes the imported cluster buffer; Main's read
553 // below pins LightCull before Main in the toposort. Backend-owned buffer, so
554 // the import is a dependency-tracking stub.
555 let cluster_lights_v1 = if inputs.clustered_lighting_enabled {
556 let cluster_lights = b.import_buffer("cluster_light_list", cluster_light_list_desc());
557 Some(
558 b.add_pass(PassId::LightCull, PassKind::Compute)
559 .write_buffer(cluster_lights),
560 )
561 } else {
562 None
563 };
564
565 // Spot shadows: one depth-only render per shadowed spot into its slice of
566 // the spot shadow array. Like the cascade pass it precedes Main, which
567 // samples the array; backend-owned, so the import tracks dependencies only.
568 let spot_shadow_v1 = if inputs.shadowed_spot_count > 0 {
569 let spot_map = b.import_texture(
570 "spot_shadow_map",
571 spot_shadow_map_desc(inputs.spot_shadow_slice_size, inputs.shadowed_spot_count),
572 );
573 Some(
574 b.add_pass(PassId::SpotShadow, PassKind::Render)
575 .write_texture(spot_map),
576 )
577 } else {
578 None
579 };
580
581 // Main pass: reads optional shadow_map / spot_shadow_map / draw_args /
582 // ao_output / cluster lights; writes the three HDR targets. Captures hdr_resolve_v1 (head of the
583 // hdr_resolve RMW chain, the version AutoExposure reads when two-pass
584 // is off) and hdr_depth_v1 (the depth HizBuild reduces under two-pass).
585 let (hdr_resolve_v1, hdr_depth_v1) = {
586 let mut main = b.add_pass(PassId::Main, PassKind::Render);
587 if let Some(h) = shadow_v1 {
588 main.read_texture(h);
589 }
590 if let Some(h) = spot_shadow_v1 {
591 main.read_texture(h);
592 }
593 if let Some(h) = draw_args_v1 {
594 main.read_buffer(h);
595 }
596 if let Some(h) = cluster_lights_v1 {
597 main.read_buffer(h);
598 }
599 if let Some(h) = ao_output_v1 {
600 main.read_texture(h);
601 }
602 if let Some(h) = hdr_color {
603 let _ = main.write_texture(h);
604 }
605 let depth_v1 = main.write_texture(hdr_depth);
606 let resolve_v1 = main.write_texture(hdr_resolve);
607 (resolve_v1, depth_v1)
608 };
609
610 // Two-pass occlusion phase 2: rebuild the Hi-Z pyramid from phase-1
611 // depth (HizBuild), re-test the objects phase-1 cull marked occluded
612 // (Cull2), and redraw the disoccluded survivors (Main2). Main2 RMWs
613 // hdr_color / hdr_depth / hdr_resolve, so its hdr_resolve write becomes
614 // the head of the post-decoration chain: AutoExposure and every later
615 // RMW pass see the combined phase-1 + phase-2 scene. Without two-pass
616 // the head stays at Main's hdr_resolve_v1.
617 let mut hiz_cur = hiz_pyramid;
618 let mut depth_cur = hdr_depth_v1;
619 let hdr_resolve_head = if let (true, Some(hiz)) = (two_pass, hiz_pyramid) {
620 // HizBuild (compute): read phase-1 depth, write the Hi-Z pyramid.
621 // The depth RAW edge pins it after Main; the pyramid write is a WAR
622 // against Cull's read of the previous frame's contents.
623 let mut hizb = b.add_pass(PassId::HizBuild, PassKind::Compute);
624 hizb.read_texture(depth_cur);
625 let hiz_v1 = hizb.write_texture(hiz);
626 hiz_cur = Some(hiz_v1);
627
628 // Cull2 (compute): read the rebuilt pyramid + the phase-1 status
629 // buffer, write a second indirect-draw-args buffer Main2 consumes.
630 let draw_args2 = b.import_buffer("draw_args2", draw_args_desc());
631 let mut cull2 = b.add_pass(PassId::Cull2, PassKind::Compute);
632 cull2.read_texture(hiz_v1);
633 if let Some(cs) = cull_status_v1 {
634 cull2.read_buffer(cs);
635 }
636 let draw_args2_v1 = cull2.write_buffer(draw_args2);
637
638 // Main2 (render): read the phase-2 draw args; RMW hdr_color /
639 // hdr_depth / hdr_resolve. The draw_args2 RAW edge pins it after
640 // Cull2; the hdr_depth write (WAR vs HizBuild's read) pins it after
641 // HizBuild; the hdr_color / hdr_resolve WAW edges pin it after Main.
642 let mut main2 = b.add_pass(PassId::Main2, PassKind::Render);
643 main2.read_buffer(draw_args2_v1);
644 depth_cur = main2.write_texture(depth_cur);
645 if let Some(h) = hdr_color {
646 let _ = main2.write_texture(h);
647 }
648 main2.write_texture(hdr_resolve_v1)
649 } else {
650 hdr_resolve_v1
651 };
652
653 // AutoExposure (compute) reads the post-main scene (hdr_resolve_head:
654 // Main2's output under two-pass, Main's otherwise). The compile pass's
655 // WAR step pins it before the first hdr_resolve writer that bumps the
656 // next version (Raymarch / Decals / Fog / ParticlesDraw), so
657 // AutoExposure samples the un-decorated scene even though the GPU
658 // texture object is the same one those passes later blend-write.
659 if inputs.auto_exposure_enabled {
660 b.add_pass(PassId::AutoExposure, PassKind::Compute)
661 .read_texture(hdr_resolve_head);
662 }
663
664 // Velocity (render) writes the per-pixel motion-vector buffer TaaResolve /
665 // Upscale consume. The read edge from those passes pins it ahead of them in
666 // the toposort. Under the unified path the merged G-buffer node already
667 // carries velocity, so TAA / Upscale read that handle and this separate node
668 // is skipped.
669 let velocity_v1 = if let Some(g) = gbuffer_v1 {
670 Some(g.velocity)
671 } else if inputs.velocity_enabled {
672 let velocity = b.create_texture("velocity", velocity_desc(inputs));
673 Some(
674 b.add_pass(PassId::Velocity, PassKind::Render)
675 .write_texture(velocity),
676 )
677 } else {
678 None
679 };
680
681 // hdr_resolve post-Main RMW chain: Raymarch → Decals → Fog →
682 // ParticlesDraw, each blend- or opaque-writing on top of the
683 // previous version. The handle walks forward through `h` so each
684 // write picks up the latest version, giving the compile pass clean
685 // WAW edges to derive the chain order. Raymarch slots first so its
686 // depth+colour write is visible to every later post-decoration
687 // pass; AutoExposure's WAR-read on hdr_resolve_head pins it before
688 // Raymarch (so SDF brightness doesn't skew exposure for the same
689 // frame), matching the doc's chosen one-frame-lag trade-off.
690 //
691 // Main depth rides the same pattern: Raymarch sphere-traces against it and
692 // writes the hit depth back, so it bumps the depth version, and every later
693 // decoration samples that version rather than the one Main left.
694 let mut h = hdr_resolve_head;
695 if inputs.raymarch_enabled {
696 let mut rm = b.add_pass(PassId::Raymarch, PassKind::Render);
697 rm.read_texture(h);
698 h = rm.write_texture(h);
699 depth_cur = rm.write_texture(depth_cur);
700 }
701 // SSGI reads the lit scene (its bounce-radiance source) and RMWs the
702 // gathered + denoised indirect term back in. Slots right after Raymarch so
703 // it can bounce raymarched surfaces too, and before Decals / Fog /
704 // Particles so those decorations layer on top of the indirect light.
705 // AutoExposure's WAR-read on hdr_resolve_head pins it ahead of SSGI, so the
706 // added bounce doesn't skew the same frame's exposure (the same one-frame
707 // trade-off Raymarch documents).
708 if inputs.ssgi_enabled {
709 let mut ssgi = b.add_pass(PassId::Ssgi, PassKind::Render);
710 ssgi.read_texture(h);
711 // The gather is against the pre-pass view normal + linear depth; with
712 // no G-buffer there is nothing to gather against and the encoder skips.
713 if let Some(g) = ssr_gbuffer_v1 {
714 ssgi.read_texture(g);
715 }
716 h = ssgi.write_texture(h);
717 }
718 if inputs.decals_enabled {
719 // Projected decals reconstruct each pixel's world position from the
720 // scene depth, so the pass samples depth while blend-writing colour.
721 let mut decals = b.add_pass(PassId::Decals, PassKind::Render);
722 decals.read_texture(depth_cur);
723 h = decals.write_texture(h);
724 }
725 if inputs.fog_enabled {
726 // FogFroxel (compute) populates the 3D scatter/transmittance
727 // volume the Fog fragment shader samples. The post-write handle
728 // (`froxel_v1`) is what Fog reads: that gives the compile pass
729 // a clean RAW edge so FogFroxel runs before Fog in the toposort.
730 // All three backends implement the froxel path; the Fog render
731 // pass trilinear-samples the volume by (screen_uv, view_z).
732 let froxel_v0 = b.import_texture("fog_froxel_volume", froxel_volume_desc(inputs));
733 let mut froxel = b.add_pass(PassId::FogFroxel, PassKind::Compute);
734 // Each slab does a cascade tap, so the kernel is a second reader of the
735 // shadow map alongside Main. Declaring it puts the compute stage into the
736 // read run's union, which is what makes one transition serve both.
737 if let Some(h) = shadow_v1 {
738 froxel.read_texture(h);
739 }
740 let froxel_v1 = froxel.write_texture(froxel_v0);
741 let mut fog_pass = b.add_pass(PassId::Fog, PassKind::Render);
742 fog_pass.read_texture(froxel_v1);
743 // Scene depth bounds the ray march / froxel lookup per pixel.
744 fog_pass.read_texture(depth_cur);
745 h = fog_pass.write_texture(h);
746 }
747 if inputs.particles_enabled {
748 h = b
749 .add_pass(PassId::ParticlesDraw, PassKind::Render)
750 .write_texture(h);
751 }
752 if inputs.lines_enabled {
753 // Last of the hdr_resolve decorations: line geometry draws over the
754 // lit + decorated scene, and SSR / TAA then treat it like any other
755 // scene content. Samples depth rather than testing against it, so a
756 // line behind geometry fades instead of disappearing.
757 let mut lines = b.add_pass(PassId::Lines, PassKind::Render);
758 lines.read_texture(depth_cur);
759 h = lines.write_texture(h);
760 }
761 let hdr_resolve_cur = h;
762
763 // `scene_pre_taa` is a distinct texture only when a pass writes it:
764 // SsrResolve / RtReflections produce it, and Transparent read-modify-writes
765 // it. With neither resolve the engine binds the pre-TAA scene name straight
766 // to `hdr_resolve`, so declaring it here would put two graph resources on
767 // one GPU object -- each with its own barrier timeline over the same memory.
768 // Threading the upstream handle expresses that binding with one resource.
769 let scene_pre_taa_cur = if inputs.rt_reflections_enabled || inputs.ssr_enabled {
770 let scene_pre_taa = b.import_texture("scene_pre_taa", scene_color_desc(inputs));
771 // SsrResolve and RtReflections occupy the same slot: both read the
772 // post-decoration hdr_resolve and write scene_pre_taa. Hardware RT
773 // *takes precedence* over SSR: a world can enable both (RT on the
774 // backend / GPU that supports it, SSR as the cross-backend fallback),
775 // and where RT is live the builder picks it and omits SsrResolve. Only
776 // one of the two is ever inserted.
777 // Both resolves trace against the pre-pass view normal + linear depth
778 // and pick their blur radius from its roughness. Declaring those reads
779 // is what keeps the G-buffer's modelled lifetime as long as its real
780 // one: roughness has no other consumer, so without this it would look
781 // dead the moment the pre-pass finished.
782 let mut current = if inputs.rt_reflections_enabled {
783 let mut rt = b.add_pass(PassId::RtReflections, PassKind::Render);
784 rt.read_texture(hdr_resolve_cur);
785 if let Some(g) = ssr_gbuffer_v1 {
786 rt.read_texture(g);
787 }
788 if let Some(g) = gbuffer_v1 {
789 rt.read_texture(g.roughness);
790 }
791 rt.write_texture(scene_pre_taa)
792 } else {
793 let mut ssr = b.add_pass(PassId::SsrResolve, PassKind::Render);
794 ssr.read_texture(hdr_resolve_cur);
795 if let Some(g) = ssr_gbuffer_v1 {
796 ssr.read_texture(g);
797 }
798 if let Some(g) = gbuffer_v1 {
799 ssr.read_texture(g.roughness);
800 }
801 ssr.write_texture(scene_pre_taa)
802 };
803 if inputs.transparent_enabled {
804 let mut trans = b.add_pass(PassId::Transparent, PassKind::Render);
805 // Pin Transparent after the whole post-decoration hdr_resolve chain
806 // (Main → Decals → Fog → ParticlesDraw), which the scene_pre_taa
807 // edge below does not imply.
808 trans.read_texture(hdr_resolve_cur);
809 // Depth is read at its latest version, not the imported v0:
810 // reading v0 would be a WAR against Main's write and pin
811 // Transparent *before* Main, closing a cycle.
812 trans.read_texture(depth_cur);
813 // RMW the resolve output. The read declares the sample dependency
814 // (translucents sample the resolved scene for refraction); the
815 // write produces the blended version downstream passes consume.
816 trans.read_texture(current);
817 current = trans.write_texture(current);
818 }
819 current
820 } else if inputs.transparent_enabled {
821 // With no reflection resolve the pre-TAA scene *is* `hdr_resolve` and
822 // glass blends straight into it, so Transparent extends the hdr_resolve
823 // chain by one version instead of branching a second resource onto the
824 // same object.
825 let mut trans = b.add_pass(PassId::Transparent, PassKind::Render);
826 trans.read_texture(depth_cur);
827 trans.read_texture(hdr_resolve_cur);
828 trans.write_texture(hdr_resolve_cur)
829 } else {
830 hdr_resolve_cur
831 };
832
833 // `scene_color` is the engine-owned output the post-TAA composite stack
834 // consumes, and -- with TAA on -- the history slot next frame samples, which
835 // is why it stays imported rather than becoming a transient. Declared only
836 // when TaaResolve or Upscale writes it, for the same one-object-one-resource
837 // reason as above: with neither, the engine binds the name to the latest
838 // pre-TAA scene texture. The two writers are mutually exclusive -- the
839 // upscaler does its own temporal accumulation, so layering TaaResolve on top
840 // would double-temporal the scene.
841 let scene_color_cur = if inputs.upscale_enabled {
842 let scene_color = b.import_texture("scene_color", scene_color_desc(inputs));
843 // Compute, not render: the temporal upscaler is a dispatch, so its reads
844 // want the non-pixel shader-resource state. Declaring it as a render
845 // pass put the fragment stage in the read-stage union and left the
846 // backend flipping the scene and the motion buffer by hand.
847 let mut up = b.add_pass(PassId::Upscale, PassKind::Compute);
848 up.read_texture(scene_pre_taa_cur);
849 // Explicit velocity read so the toposort pins Velocity →
850 // Upscale. The scaler consumes motion vectors directly.
851 if let Some(v) = velocity_v1 {
852 up.read_texture(v);
853 }
854 // The scaler also samples the pre-pass depth (single-sample at render
855 // resolution), which is the only consumer that target has.
856 if let Some(g) = gbuffer_v1 {
857 up.read_texture(g.depth);
858 }
859 up.write_texture(scene_color)
860 } else if inputs.taa_enabled {
861 let scene_color = b.import_texture("scene_color", scene_color_desc(inputs));
862 let mut taa = b.add_pass(PassId::TaaResolve, PassKind::Render);
863 taa.read_texture(scene_pre_taa_cur);
864 // Explicit velocity read so the toposort pins Velocity →
865 // TaaResolve. Without this the order rests on declaration order
866 // alone.
867 if let Some(v) = velocity_v1 {
868 taa.read_texture(v);
869 }
870 taa.write_texture(scene_color)
871 } else {
872 scene_pre_taa_cur
873 };
874
875 let bloom_top_v1 = if inputs.bloom_enabled {
876 let bloom_top = b.create_texture("bloom_top", bloom_top_desc(inputs));
877 Some(
878 b.add_pass(PassId::Bloom, PassKind::Render)
879 .read_texture(scene_color_cur)
880 .write_texture(bloom_top),
881 )
882 } else {
883 None
884 };
885
886 // HizFinal (compute) reduces the frame's final depth into the Hi-Z pyramid
887 // the next frame's phase-1 Cull tests against. Declared last of the
888 // depth consumers so it reads the version every decoration pass has
889 // finished with, which is also what keeps the depth's graph lifetime
890 // honest: without this node the depth would look dead after the last
891 // decoration while a post-graph pass still read it.
892 if let (true, Some(hiz)) = (inputs.hiz_build_enabled, hiz_cur) {
893 let mut hizf = b.add_pass(PassId::HizFinal, PassKind::Compute);
894 hizf.read_texture(depth_cur);
895 let _ = hizf.write_texture(hiz);
896 }
897
898 // Composite (the presenter) reads scene_color + optional bloom_top,
899 // and writes the swapchain via `presents()`. The occlusion view mode adds
900 // an ao_output read so the pooled transient survives to the present.
901 {
902 let mut composite = b.add_pass(PassId::Composite, PassKind::Render);
903 composite.read_texture(scene_color_cur);
904 if let Some(h) = bloom_top_v1 {
905 composite.read_texture(h);
906 }
907 if inputs.composite_reads_ao
908 && let Some(h) = ao_output_v1
909 {
910 composite.read_texture(h);
911 }
912 composite.presents();
913 }
914
915 b.compile()
916}
917
918fn froxel_volume_desc(inputs: &FrameGraphInputs) -> TextureDesc {
919 // The volumetric-fog froxel volume: a 3D texture the fog kernel writes and
920 // the fog pass samples. Its Z extent also rides in `FogFroxelParams.
921 // froxel_dims` so shaders can map indices to volume UVs.
922 let _ = inputs;
923 TextureDesc::volume_3d(
924 TextureSize::Absolute(FOG_FROXEL_X),
925 TextureSize::Absolute(FOG_FROXEL_Y),
926 FOG_FROXEL_Z,
927 PixelFormat::Rgba16Float,
928 TextureUsage::STORAGE.union(TextureUsage::SHADER_READ),
929 )
930}
931
932/// X/Y/Z dimensions of the volumetric-fog froxel volume. Sized to keep the
933/// per-frame compute cost modest (~230 k threads per dispatch) while
934/// preserving enough screen-space detail for shaft-of-light shadowing.
935/// Backends that implement the froxel path read these constants directly;
936/// the values also ride in `FogFroxelParams.froxel_dims` so shaders can map
937/// between absolute indices and normalised volume UVs without recompiling.
938pub const FOG_FROXEL_X: u32 = 80;
939/// Fog froxels down the screen. See [`FOG_FROXEL_X`].
940pub const FOG_FROXEL_Y: u32 = 45;
941/// Fog froxel depth slices. See [`FOG_FROXEL_X`].
942pub const FOG_FROXEL_Z: u32 = 64;
943
944fn shadow_map_desc(size: u32) -> TextureDesc {
945 TextureDesc::texture_2d(
946 TextureSize::Absolute(size.max(1)),
947 TextureSize::Absolute(size.max(1)),
948 PixelFormat::Depth32Float,
949 TextureUsage::DEPTH_STENCIL.union(TextureUsage::SHADER_READ),
950 )
951 .with_array_layers(NUM_SHADOW_CASCADES as u32)
952}
953
954fn spot_shadow_map_desc(slice_size: u32, slices: u32) -> TextureDesc {
955 TextureDesc::texture_2d(
956 TextureSize::Absolute(slice_size.max(1)),
957 TextureSize::Absolute(slice_size.max(1)),
958 PixelFormat::Depth32Float,
959 TextureUsage::DEPTH_STENCIL.union(TextureUsage::SHADER_READ),
960 )
961 .with_array_layers(slices.max(1))
962}
963
964fn hdr_color_desc(inputs: &FrameGraphInputs) -> TextureDesc {
965 render_res_2d(
966 inputs,
967 PixelFormat::Rgba16Float,
968 TextureUsage::RENDER_TARGET,
969 )
970 .with_sample_count(inputs.hdr_sample_count.max(1))
971}
972
973fn hdr_depth_desc(inputs: &FrameGraphInputs) -> TextureDesc {
974 render_res_2d(
975 inputs,
976 PixelFormat::Depth32Float,
977 TextureUsage::DEPTH_STENCIL.union(TextureUsage::SHADER_READ),
978 )
979 .with_sample_count(inputs.hdr_sample_count.max(1))
980}
981
982// A single-sample 2D target at the HDR render resolution, which is what most of
983// the frame's off-screen targets are. Render resolution is not the drawable
984// extent under temporal upscaling, so these are `Absolute` off the inputs
985// rather than `Drawable`.
986fn render_res_2d(
987 inputs: &FrameGraphInputs,
988 format: PixelFormat,
989 usage: TextureUsage,
990) -> TextureDesc {
991 TextureDesc::texture_2d(
992 TextureSize::Absolute(inputs.hdr_width.max(1)),
993 TextureSize::Absolute(inputs.hdr_height.max(1)),
994 format,
995 usage,
996 )
997}
998
999fn draw_args_desc() -> BufferDesc {
1000 BufferDesc {
1001 size_bytes: None,
1002 usage: BufferUsage::STORAGE.union(BufferUsage::INDIRECT),
1003 }
1004}
1005
1006fn cull_status_desc() -> BufferDesc {
1007 // One u32 per draw object: phase-1 cull writes drawn / hi-z-candidate /
1008 // culled, Cull2 reads it. Both phases bind it the same read-write way, so it
1009 // never transitions to a read state and its ordering comes from an execution
1010 // barrier; `UNORDERED` is what says so. The executor owns the allocation
1011 // (sized to the live draw-object count).
1012 BufferDesc {
1013 size_bytes: None,
1014 usage: BufferUsage::STORAGE.union(BufferUsage::UNORDERED),
1015 }
1016}
1017
1018fn cluster_light_list_desc() -> BufferDesc {
1019 // Per-cluster light-index lists LightCull writes and Main reads. An identity
1020 // stub: the backend owns the real (persistent) buffer, so the graph only
1021 // tracks the read/write dependency, not the allocation.
1022 BufferDesc {
1023 size_bytes: None,
1024 usage: BufferUsage::STORAGE,
1025 }
1026}
1027
1028fn hiz_pyramid_desc(inputs: &FrameGraphInputs) -> TextureDesc {
1029 // R32Float depth-mip pyramid rebuilt mid-frame from phase-1 depth, MAX
1030 // reduction. The cull kernel samples the coarse levels, so the chain is
1031 // most of the footprint and the desc carries its real length -- the same
1032 // `floor(log2(max)) + 1` each backend's Hi-Z build derives.
1033 render_res_2d(
1034 inputs,
1035 PixelFormat::R32Float,
1036 TextureUsage::STORAGE.union(TextureUsage::SHADER_READ),
1037 )
1038 .with_mip_levels(full_mip_levels(
1039 inputs.hdr_width.max(1),
1040 inputs.hdr_height.max(1),
1041 ))
1042}
1043
1044// The unified G-buffer pre-pass writes four separate targets in one draw. They
1045// are four graph resources rather than one handle because their shapes differ
1046// (three colour formats and a depth target) and so do their consumers, and a
1047// resource the aliaser may place has to name the memory it actually needs.
1048fn gbuffer_normal_depth_desc(inputs: &FrameGraphInputs) -> TextureDesc {
1049 // RGBA16F view-space normal + linear depth, read by SSR / SSAO / SSGI / RT.
1050 render_res_2d(
1051 inputs,
1052 PixelFormat::Rgba16Float,
1053 TextureUsage::RENDER_TARGET.union(TextureUsage::SHADER_READ),
1054 )
1055}
1056
1057fn gbuffer_roughness_desc(inputs: &FrameGraphInputs) -> TextureDesc {
1058 // R8 perceptual roughness, read by the reflection resolve to pick its
1059 // blur radius. Clears to 1.0 (fully rough), so a pixel the pre-pass never
1060 // rasterises reflects nothing -- the one graph target whose cleared
1061 // background carries meaning, and the reason `TextureDesc` models a clear
1062 // value at all.
1063 render_res_2d(
1064 inputs,
1065 PixelFormat::R8Unorm,
1066 TextureUsage::RENDER_TARGET.union(TextureUsage::SHADER_READ),
1067 )
1068 .with_clear_color([1.0, 0.0, 0.0, 0.0])
1069}
1070
1071fn gbuffer_depth_desc(inputs: &FrameGraphInputs) -> TextureDesc {
1072 // The pre-pass's own depth attachment. Single-sample regardless of the
1073 // main pass's MSAA: the pre-pass rasterises once.
1074 render_res_2d(
1075 inputs,
1076 PixelFormat::Depth32Float,
1077 TextureUsage::DEPTH_STENCIL.union(TextureUsage::SHADER_READ),
1078 )
1079}
1080
1081fn ssr_gbuffer_desc(inputs: &FrameGraphInputs) -> TextureDesc {
1082 // RGBA16F view-space normal + linear depth at HDR dims; shared with
1083 // SSAO when both passes are on.
1084 render_res_2d(
1085 inputs,
1086 PixelFormat::Rgba16Float,
1087 TextureUsage::RENDER_TARGET.union(TextureUsage::SHADER_READ),
1088 )
1089}
1090
1091fn ao_output_desc(inputs: &FrameGraphInputs) -> TextureDesc {
1092 // R8 occlusion at HDR dims; sampled by Main's ambient term.
1093 render_res_2d(
1094 inputs,
1095 PixelFormat::R8Unorm,
1096 TextureUsage::RENDER_TARGET.union(TextureUsage::SHADER_READ),
1097 )
1098}
1099
1100fn velocity_desc(inputs: &FrameGraphInputs) -> TextureDesc {
1101 // RG16F motion-vector buffer at HDR dims, sampled by TaaResolve.
1102 render_res_2d(
1103 inputs,
1104 PixelFormat::Rg16Float,
1105 TextureUsage::RENDER_TARGET.union(TextureUsage::SHADER_READ),
1106 )
1107}
1108
1109fn bloom_top_desc(inputs: &FrameGraphInputs) -> TextureDesc {
1110 // bloom_top is `bloom_targets.mips[0]`, the bloom chain's half-resolution
1111 // top octave; the prefilter pass writes into it and the upsample chain
1112 // accumulates back into it for Composite to sample.
1113 //
1114 // Half the *drawable* extent, not half the render resolution: every backend
1115 // builds its bloom chain from the output extent, so under temporal
1116 // upscaling (where render resolution is smaller) an `hdr_width >> 1` desc
1117 // names a texture no backend creates.
1118 let _ = inputs;
1119 TextureDesc::texture_2d(
1120 TextureSize::DrawableScaled(0.5),
1121 TextureSize::DrawableScaled(0.5),
1122 PixelFormat::Rgba16Float,
1123 TextureUsage::RENDER_TARGET.union(TextureUsage::SHADER_READ),
1124 )
1125}
1126
1127fn hdr_resolve_desc(inputs: &FrameGraphInputs) -> TextureDesc {
1128 render_res_2d(
1129 inputs,
1130 PixelFormat::Rgba16Float,
1131 TextureUsage::RENDER_TARGET.union(TextureUsage::SHADER_READ),
1132 )
1133}
1134
1135fn scene_color_desc(inputs: &FrameGraphInputs) -> TextureDesc {
1136 // The engine-owned scene_color texture the post stack consumes is
1137 // single-sample at HDR dims regardless of whether the per-frame
1138 // resolution lands on taa_targets / ssr_targets.output / hdr_resolve.
1139 render_res_2d(inputs, PixelFormat::Rgba16Float, TextureUsage::SHADER_READ)
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144 use super::*;
1145 use alloc::vec;
1146 use alloc::vec::Vec;
1147
1148 // The production defaults, with MSAA on so the resolve-chain tests have a
1149 // multisampled hdr target to exercise.
1150 fn all_off() -> FrameGraphInputs {
1151 FrameGraphInputs {
1152 hdr_sample_count: 4,
1153 ..FrameGraphInputs::all_off()
1154 }
1155 }
1156
1157 #[test]
1158 fn minimum_graph_is_main_then_composite() {
1159 let g = build_frame_graph(&all_off()).expect("compiles");
1160 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1161 assert_eq!(order, vec![PassId::Main, PassId::Composite]);
1162 assert!(g.passes[1].presents);
1163 }
1164
1165 #[test]
1166 fn world_hidden_collapses_to_minimum_graph() {
1167 // Every heavy world pass requested, but the opaque menu backdrop is up:
1168 // the builder must mask them all off and yield the bare Main -> Composite
1169 // graph, with Composite still the presenter for the overlay.
1170 let mut i = all_off();
1171 i.shadow_enabled = true;
1172 i.bindless_cull_enabled = true;
1173 i.ssr_prepass_enabled = true;
1174 i.ssao_enabled = true;
1175 i.ssgi_enabled = true;
1176 i.rt_reflections_enabled = true;
1177 i.bloom_enabled = true;
1178 i.taa_enabled = true;
1179 i.velocity_enabled = true;
1180 i.two_pass_occlusion_enabled = true;
1181 i.world_hidden = true;
1182 let g = build_frame_graph(&i).expect("compiles");
1183 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1184 assert_eq!(order, vec![PassId::Main, PassId::Composite]);
1185 assert!(g.passes[1].presents);
1186 }
1187
1188 #[test]
1189 fn shadow_orders_before_main() {
1190 let mut i = all_off();
1191 i.shadow_enabled = true;
1192 let g = build_frame_graph(&i).expect("compiles");
1193 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1194 assert_eq!(order, vec![PassId::Shadow, PassId::Main, PassId::Composite]);
1195 }
1196
1197 #[test]
1198 fn cull_orders_before_main_via_draw_args() {
1199 let mut i = all_off();
1200 i.bindless_cull_enabled = true;
1201 let g = build_frame_graph(&i).expect("compiles");
1202 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1203 assert_eq!(order, vec![PassId::Cull, PassId::Main, PassId::Composite]);
1204 assert_eq!(g.passes[0].kind, PassKind::Compute);
1205 }
1206
1207 #[test]
1208 fn two_pass_inserts_phase2_chain_after_main() {
1209 // With bindless cull + two-pass on, the graph gains the phase-2
1210 // prefix Cull → Main → HizBuild → Cull2 → Main2, strictly ordered.
1211 let mut i = all_off();
1212 i.bindless_cull_enabled = true;
1213 i.two_pass_occlusion_enabled = true;
1214 let g = build_frame_graph(&i).expect("compiles");
1215 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1216 assert_eq!(
1217 order,
1218 vec![
1219 PassId::Cull,
1220 PassId::Main,
1221 PassId::HizBuild,
1222 PassId::Cull2,
1223 PassId::Main2,
1224 PassId::Composite,
1225 ]
1226 );
1227 assert_eq!(g.passes[2].kind, PassKind::Compute); // HizBuild
1228 assert_eq!(g.passes[3].kind, PassKind::Compute); // Cull2
1229 assert_eq!(g.passes[4].kind, PassKind::Render); // Main2
1230 }
1231
1232 // Index of `label` in the compiled graph's resource arena.
1233 fn resource_of(g: &CompiledGraph, label: &str) -> usize {
1234 g.resources
1235 .iter()
1236 .position(|r| r.label == label)
1237 .unwrap_or_else(|| panic!("{label} missing from the graph"))
1238 }
1239
1240 #[test]
1241 fn hiz_final_closes_the_frame_over_the_last_depth_version() {
1242 // The terminal pyramid rebuild must read the depth version every
1243 // decoration pass has already written and read, so main depth stays live
1244 // to the end of the graph. Raymarch bumps depth to v2, so HizFinal reads
1245 // v2 and lands after Lines / Transparent, which read the same version.
1246 let mut i = all_off();
1247 i.hiz_build_enabled = true;
1248 i.raymarch_enabled = true;
1249 i.lines_enabled = true;
1250 i.transparent_enabled = true;
1251 let g = build_frame_graph(&i).expect("compiles");
1252 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1253 let pos = |p: PassId| order.iter().position(|x| *x == p).expect("present");
1254 assert!(pos(PassId::Lines) < pos(PassId::HizFinal));
1255 assert!(pos(PassId::Transparent) < pos(PassId::HizFinal));
1256 assert!(pos(PassId::HizFinal) < pos(PassId::Composite));
1257
1258 let depth = resource_of(&g, "hdr_depth");
1259 let hizf = &g.passes[pos(PassId::HizFinal)];
1260 let read = hizf
1261 .reads
1262 .iter()
1263 .find(|r| r.resource_index() == depth)
1264 .expect("HizFinal reads depth");
1265 assert_eq!(read.version(), 2, "Main writes v1, Raymarch bumps to v2");
1266 // And it is the graph's last touch of depth: the resource's lifetime has
1267 // to reach this pass or an aliaser could reuse the memory under it.
1268 assert_eq!(g.resources[depth].lifetime.last, pos(PassId::HizFinal));
1269 }
1270
1271 #[test]
1272 fn cull_reads_the_pyramid_the_terminal_build_overwrites() {
1273 // Phase-1 cull tests against the pyramid the previous frame left. That
1274 // read is what gives the terminal rebuild a write-after-read edge; without
1275 // it the rebuild is unordered against the cull that is still sampling.
1276 let mut i = all_off();
1277 i.hiz_build_enabled = true;
1278 i.bindless_cull_enabled = true;
1279 let g = build_frame_graph(&i).expect("compiles");
1280 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1281 let pos = |p: PassId| order.iter().position(|x| *x == p).expect("present");
1282 let hiz = resource_of(&g, "hiz_pyramid");
1283 assert!(
1284 g.passes[pos(PassId::Cull)]
1285 .reads
1286 .iter()
1287 .any(|r| r.resource_index() == hiz)
1288 );
1289 assert!(pos(PassId::Cull) < pos(PassId::HizFinal));
1290 }
1291
1292 #[test]
1293 fn hiz_final_off_means_no_pyramid_in_the_graph() {
1294 // A world without the GPU-cull path builds no pyramid, so neither the node
1295 // nor the resource may appear (an imported resource with no pass would
1296 // still take a registry entry in every backend).
1297 let g = build_frame_graph(&all_off()).expect("compiles");
1298 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1299 assert!(!order.contains(&PassId::HizFinal));
1300 assert!(!g.resources.iter().any(|r| r.label == "hiz_pyramid"));
1301 }
1302
1303 #[test]
1304 fn a_hidden_world_still_rebuilds_the_pyramid() {
1305 // Masking drops every world pass, but the pyramid feeds the *next* frame's
1306 // cull, so the terminal build survives: dropping it would leave a stale
1307 // pyramid the frame after the menu closes.
1308 let mut i = all_off();
1309 i.hiz_build_enabled = true;
1310 i.bindless_cull_enabled = true;
1311 i.world_hidden = true;
1312 let g = build_frame_graph(&i).expect("compiles");
1313 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1314 assert_eq!(
1315 order,
1316 vec![PassId::Main, PassId::HizFinal, PassId::Composite]
1317 );
1318 }
1319
1320 #[test]
1321 fn depth_readers_all_sample_the_post_raymarch_version() {
1322 // Raymarch writes hit depth back, so every later decoration must sample
1323 // the version it produced. Reading Main's version instead would be a
1324 // write-after-read against Raymarch and pin the readers ahead of it.
1325 let mut i = all_off();
1326 i.raymarch_enabled = true;
1327 i.decals_enabled = true;
1328 i.fog_enabled = true;
1329 i.lines_enabled = true;
1330 i.transparent_enabled = true;
1331 let g = build_frame_graph(&i).expect("compiles");
1332 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1333 let pos = |p: PassId| order.iter().position(|x| *x == p).expect("present");
1334 let depth = resource_of(&g, "hdr_depth");
1335 for pass in [
1336 PassId::Decals,
1337 PassId::Fog,
1338 PassId::Lines,
1339 PassId::Transparent,
1340 ] {
1341 let read = g.passes[pos(pass)]
1342 .reads
1343 .iter()
1344 .find(|r| r.resource_index() == depth)
1345 .unwrap_or_else(|| panic!("{pass:?} reads depth"));
1346 assert_eq!(read.version(), 2, "{pass:?}");
1347 }
1348 assert!(pos(PassId::Raymarch) < pos(PassId::Decals));
1349 }
1350
1351 #[test]
1352 fn the_msaa_colour_attachment_is_declared_only_when_multisampled() {
1353 // Without MSAA there is no resolve step and the single colour target is
1354 // the spine, so declaring `hdr_color` too would put two graph resources
1355 // on one GPU object. Every backend already reflects this: Vulkan leaves
1356 // `color_images` empty, DirectX and Metal leave `resolve` None.
1357 let mut i = all_off();
1358 i.hdr_sample_count = 1;
1359 let g = build_frame_graph(&i).expect("compiles");
1360 assert!(!g.resources.iter().any(|r| r.label == "hdr_color"));
1361 assert!(g.resources.iter().any(|r| r.label == "hdr_resolve"));
1362
1363 i.hdr_sample_count = 4;
1364 let g = build_frame_graph(&i).expect("compiles");
1365 assert!(g.resources.iter().any(|r| r.label == "hdr_color"));
1366 assert!(g.resources.iter().any(|r| r.label == "hdr_resolve"));
1367 }
1368
1369 #[test]
1370 fn dropping_the_msaa_attachment_keeps_the_phase_order() {
1371 // The hdr_color write-after-write is one of three edges pinning Main2
1372 // after Main; the depth and hdr_resolve writes carry the order on their
1373 // own, so a single-sampled two-pass frame still runs in phase order.
1374 let mut i = all_off();
1375 i.hdr_sample_count = 1;
1376 i.bindless_cull_enabled = true;
1377 i.two_pass_occlusion_enabled = true;
1378 let g = build_frame_graph(&i).expect("compiles");
1379 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1380 assert_eq!(
1381 order,
1382 vec![
1383 PassId::Cull,
1384 PassId::Main,
1385 PassId::HizBuild,
1386 PassId::Cull2,
1387 PassId::Main2,
1388 PassId::Composite,
1389 ]
1390 );
1391 }
1392
1393 #[test]
1394 fn two_pass_without_bindless_cull_is_noop() {
1395 // Two-pass rides the bindless GPU-cull path; requesting it without
1396 // a bindless shader must not insert any phase-2 nodes.
1397 let mut i = all_off();
1398 i.two_pass_occlusion_enabled = true; // bindless_cull_enabled left false
1399 let g = build_frame_graph(&i).expect("compiles");
1400 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1401 assert_eq!(order, vec![PassId::Main, PassId::Composite]);
1402 assert!(!order.contains(&PassId::HizBuild));
1403 assert!(!order.contains(&PassId::Cull2));
1404 assert!(!order.contains(&PassId::Main2));
1405 }
1406
1407 #[test]
1408 fn two_pass_shifts_post_chain_head_to_main2() {
1409 // AutoExposure + the RMW chain must read Main2's hdr_resolve (v2),
1410 // not Main's (v1), so the post stack sees the combined two-pass
1411 // scene. Main writes v1, Main2 writes v2, AutoExposure reads v2,
1412 // Decals bumps to v3.
1413 let mut i = all_off();
1414 i.bindless_cull_enabled = true;
1415 i.two_pass_occlusion_enabled = true;
1416 i.auto_exposure_enabled = true;
1417 i.decals_enabled = true;
1418 let g = build_frame_graph(&i).expect("compiles");
1419 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1420 let pos = |p: PassId| order.iter().position(|x| *x == p).expect("present");
1421 assert!(pos(PassId::Main2) < pos(PassId::AutoExposure));
1422 assert!(pos(PassId::AutoExposure) < pos(PassId::Decals));
1423 // Version walk: Main2 RMWs hdr_resolve to v2, Decals to v3.
1424 let main2 = &g.passes[pos(PassId::Main2)];
1425 // hdr_resolve is the last write Main2 declares (depth, color, resolve).
1426 assert_eq!(main2.writes.last().unwrap().version(), 2);
1427 let decals = &g.passes[pos(PassId::Decals)];
1428 assert_eq!(decals.writes[0].version(), 3);
1429 }
1430
1431 #[test]
1432 fn ssao_orders_before_main_via_ao_output() {
1433 let mut i = all_off();
1434 i.ssao_enabled = true;
1435 let g = build_frame_graph(&i).expect("compiles");
1436 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1437 assert_eq!(
1438 order,
1439 vec![PassId::SsaoBlur, PassId::Main, PassId::Composite]
1440 );
1441 }
1442
1443 #[test]
1444 fn ao_output_barriers_are_graph_driven() {
1445 // The DirectX + Vulkan executors emit `ao_output`'s transitions from
1446 // these barriers (resolving them to RENDER_TARGET / COLOR_ATTACHMENT on
1447 // SsaoBlur and back to the sampled state on Main). Pin the exact pair
1448 // so the executor's stripped inline / render-pass-baked transitions
1449 // stay matched to what the graph derives.
1450 use super::super::ResourceState;
1451 let mut i = all_off();
1452 i.ssao_enabled = true;
1453 let g = build_frame_graph(&i).expect("compiles");
1454 let pass = |id: PassId| g.passes.iter().find(|p| p.id == id).expect("present");
1455
1456 let ssao = g.pass_barriers_for(pass(PassId::SsaoBlur), &["ao_output"]);
1457 assert_eq!(ssao.len(), 1, "SsaoBlur has exactly one ao_output barrier");
1458 assert_eq!(ssao[0].1.source_state(), ResourceState::Undefined);
1459 assert_eq!(ssao[0].1.to_state(), ResourceState::Write);
1460
1461 let main = g.pass_barriers_for(pass(PassId::Main), &["ao_output"]);
1462 assert_eq!(main.len(), 1, "Main has exactly one ao_output barrier");
1463 assert_eq!(main[0].1.source_state(), ResourceState::Write);
1464 assert_eq!(main[0].1.to_state(), ResourceState::Read);
1465 }
1466
1467 #[test]
1468 fn shadow_map_barriers_are_graph_driven() {
1469 // The executors emit `shadow_map`'s transitions from these barriers. The
1470 // graph derives the producer (Undefined -> Write) + the Main consumer
1471 // (Write -> Read); each backend resolves the producer against the
1472 // resource's resting state. DirectX rests it sampled, so the producer is
1473 // the real PIXEL_SHADER_RESOURCE -> DEPTH_WRITE cross-frame reset (folded
1474 // off the old inline restore); Main's consumer replaces the encoder's
1475 // stripped sampled transition. With SSAO also on, Main carries both
1476 // shadow_map and ao_output barriers, exercising multi-resource emission
1477 // in one pass.
1478 use super::super::ResourceState;
1479 let mut i = all_off();
1480 i.shadow_enabled = true;
1481 i.ssao_enabled = true;
1482 let g = build_frame_graph(&i).expect("compiles");
1483 let pass = |id: PassId| g.passes.iter().find(|p| p.id == id).expect("present");
1484
1485 let shadow = g.pass_barriers_for(pass(PassId::Shadow), &["shadow_map"]);
1486 assert_eq!(shadow.len(), 1, "Shadow has exactly one shadow_map barrier");
1487 assert_eq!(shadow[0].1.source_state(), ResourceState::Undefined);
1488 assert_eq!(shadow[0].1.to_state(), ResourceState::Write);
1489
1490 let main = g.pass_barriers_for(pass(PassId::Main), &["shadow_map"]);
1491 assert_eq!(main.len(), 1, "Main has exactly one shadow_map barrier");
1492 assert_eq!(main[0].1.source_state(), ResourceState::Write);
1493 assert_eq!(main[0].1.to_state(), ResourceState::Read);
1494
1495 // Main carries both migrated resources' barriers in one pass.
1496 let both = g.pass_barriers_for(pass(PassId::Main), &["shadow_map", "ao_output"]);
1497 assert_eq!(
1498 both.len(),
1499 2,
1500 "Main carries shadow_map + ao_output barriers"
1501 );
1502 }
1503
1504 #[test]
1505 fn fog_froxel_volume_barriers_are_graph_driven() {
1506 // The executors emit `fog_froxel_volume`'s transitions from these
1507 // barriers. FogFroxel's producer (Undefined -> Write) is the compute
1508 // write, a real sampled -> storage open on both backends now: DirectX
1509 // resolves it to PIXEL_SHADER_RESOURCE -> UNORDERED_ACCESS, Vulkan to
1510 // SHADER_READ_ONLY -> GENERAL (both rest the volume sampled, with no
1511 // inline reset). Fog's consumer (Write -> Read) is the storage-write ->
1512 // sampled close the fragment reads through.
1513 use super::super::ResourceState;
1514 let mut i = all_off();
1515 i.fog_enabled = true;
1516 let g = build_frame_graph(&i).expect("compiles");
1517 let pass = |id: PassId| g.passes.iter().find(|p| p.id == id).expect("present");
1518
1519 let froxel = g.pass_barriers_for(pass(PassId::FogFroxel), &["fog_froxel_volume"]);
1520 assert_eq!(
1521 froxel.len(),
1522 1,
1523 "FogFroxel has exactly one fog_froxel_volume barrier"
1524 );
1525 assert_eq!(froxel[0].1.source_state(), ResourceState::Undefined);
1526 assert_eq!(froxel[0].1.to_state(), ResourceState::Write);
1527
1528 let fog = g.pass_barriers_for(pass(PassId::Fog), &["fog_froxel_volume"]);
1529 assert_eq!(
1530 fog.len(),
1531 1,
1532 "Fog has exactly one fog_froxel_volume barrier"
1533 );
1534 assert_eq!(fog[0].1.source_state(), ResourceState::Write);
1535 assert_eq!(fog[0].1.to_state(), ResourceState::Read);
1536 }
1537
1538 #[test]
1539 fn ssr_prepass_and_ssao_share_gbuffer_pinning_order() {
1540 let mut i = all_off();
1541 i.ssr_prepass_enabled = true;
1542 i.ssao_enabled = true;
1543 let g = build_frame_graph(&i).expect("compiles");
1544 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1545 assert_eq!(
1546 order,
1547 vec![
1548 PassId::SsrPrepass,
1549 PassId::SsaoBlur,
1550 PassId::Main,
1551 PassId::Composite,
1552 ]
1553 );
1554 }
1555
1556 #[test]
1557 fn unified_gbuffer_prepass_replaces_ssr_and_velocity() {
1558 // With the unified flag on, one GBufferPrepass node stands in for the
1559 // separate SsrPrepass + Velocity nodes; SSAO reads its output and TAA
1560 // reads its motion. Neither old node appears.
1561 let mut i = all_off();
1562 i.unified_gbuffer_prepass = true;
1563 i.ssr_prepass_enabled = true;
1564 i.ssao_enabled = true;
1565 i.velocity_enabled = true;
1566 i.taa_enabled = true;
1567 let g = build_frame_graph(&i).expect("compiles");
1568 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1569 assert!(order.contains(&PassId::GBufferPrepass));
1570 assert!(!order.contains(&PassId::SsrPrepass));
1571 assert!(!order.contains(&PassId::Velocity));
1572 let gb = order
1573 .iter()
1574 .position(|p| *p == PassId::GBufferPrepass)
1575 .unwrap();
1576 let ssao = order.iter().position(|p| *p == PassId::SsaoBlur).unwrap();
1577 let main = order.iter().position(|p| *p == PassId::Main).unwrap();
1578 let taa = order.iter().position(|p| *p == PassId::TaaResolve).unwrap();
1579 assert!(
1580 gb < ssao && ssao < main,
1581 "GBufferPrepass before SsaoBlur+Main"
1582 );
1583 assert!(gb < taa, "GBufferPrepass before TaaResolve");
1584 }
1585
1586 #[test]
1587 fn unified_gbuffer_prepass_runs_for_ssao_only() {
1588 // SSAO alone (no SSR / velocity) still triggers the merged node.
1589 let mut i = all_off();
1590 i.unified_gbuffer_prepass = true;
1591 i.ssao_enabled = true;
1592 let g = build_frame_graph(&i).expect("compiles");
1593 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1594 assert_eq!(
1595 order,
1596 vec![
1597 PassId::GBufferPrepass,
1598 PassId::SsaoBlur,
1599 PassId::Main,
1600 PassId::Composite,
1601 ]
1602 );
1603 }
1604
1605 #[test]
1606 fn unified_gbuffer_prepass_runs_for_velocity_only() {
1607 // Velocity alone (TAA, no SSR/SSAO) still triggers the merged node, and
1608 // the standalone Velocity node is not emitted.
1609 let mut i = all_off();
1610 i.unified_gbuffer_prepass = true;
1611 i.velocity_enabled = true;
1612 i.taa_enabled = true;
1613 let g = build_frame_graph(&i).expect("compiles");
1614 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1615 assert!(order.contains(&PassId::GBufferPrepass));
1616 assert!(!order.contains(&PassId::Velocity));
1617 }
1618
1619 #[test]
1620 fn gbuffer_prepass_orders_after_cull_via_draw_args() {
1621 // The GPU-driven G-buffer pre-pass reuses the main pass's per-frame
1622 // indirect command buffer, so it must run after Cull. With bindless cull
1623 // on and a G-buffer consumer active, the draw_args read edge pins
1624 // Cull -> GBufferPrepass (-> Main).
1625 let mut i = all_off();
1626 i.bindless_cull_enabled = true;
1627 i.unified_gbuffer_prepass = true;
1628 i.ssao_enabled = true;
1629 let g = build_frame_graph(&i).expect("compiles");
1630 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1631 let cull = order.iter().position(|p| *p == PassId::Cull).unwrap();
1632 let gb = order
1633 .iter()
1634 .position(|p| *p == PassId::GBufferPrepass)
1635 .unwrap();
1636 let main = order.iter().position(|p| *p == PassId::Main).unwrap();
1637 assert!(cull < gb, "Cull before GBufferPrepass");
1638 assert!(gb < main, "GBufferPrepass before Main");
1639 }
1640
1641 #[test]
1642 fn unified_gbuffer_prepass_omitted_when_no_consumers() {
1643 // The flag on but no consumer active: no pre-pass node at all.
1644 let mut i = all_off();
1645 i.unified_gbuffer_prepass = true;
1646 let g = build_frame_graph(&i).expect("compiles");
1647 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1648 assert_eq!(order, vec![PassId::Main, PassId::Composite]);
1649 }
1650
1651 #[test]
1652 fn auto_exposure_war_pinned_before_first_hdr_writer() {
1653 // AutoExposure reads hdr_resolve_v1. Decals writes v2 (when
1654 // enabled). The WAR edge from AutoExposure to Decals pins
1655 // AutoExposure before Decals; without it, the toposort could
1656 // place them in either order.
1657 let mut i = all_off();
1658 i.auto_exposure_enabled = true;
1659 i.decals_enabled = true;
1660 let g = build_frame_graph(&i).expect("compiles");
1661 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1662 assert_eq!(
1663 order,
1664 vec![
1665 PassId::Main,
1666 PassId::AutoExposure,
1667 PassId::Decals,
1668 PassId::Composite,
1669 ]
1670 );
1671 }
1672
1673 #[test]
1674 fn full_hdr_chain_orders_decals_fog_particles_then_ssr() {
1675 let mut i = all_off();
1676 i.decals_enabled = true;
1677 i.fog_enabled = true;
1678 i.particles_enabled = true;
1679 i.ssr_enabled = true;
1680 let g = build_frame_graph(&i).expect("compiles");
1681 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1682 assert_eq!(
1683 order,
1684 vec![
1685 PassId::Main,
1686 PassId::Decals,
1687 PassId::FogFroxel,
1688 PassId::Fog,
1689 PassId::ParticlesDraw,
1690 PassId::SsrResolve,
1691 PassId::Composite,
1692 ]
1693 );
1694 // Version chain on hdr_resolve walks 1 → 2 → 3 → 4 with
1695 // SsrResolve reading v4. FogFroxel slots between Decals and Fog
1696 // (writing the froxel volume to v1) but doesn't touch hdr_resolve,
1697 // so the version walk skips it.
1698 let decals = &g.passes[1];
1699 assert_eq!(decals.writes[0].version(), 2);
1700 let fog = &g.passes[3];
1701 assert_eq!(fog.writes[0].version(), 3);
1702 let particles = &g.passes[4];
1703 assert_eq!(particles.writes[0].version(), 4);
1704 let ssr = &g.passes[5];
1705 assert_eq!(ssr.reads[0].version(), 4);
1706 }
1707
1708 #[test]
1709 fn upscale_replaces_taa_and_pins_after_velocity() {
1710 // Upscale takes TaaResolve's slot when temporal upscaling is on.
1711 // TaaResolve must not appear in the compiled graph (the scaler
1712 // does temporal accumulation itself), and Velocity must precede
1713 // Upscale via the explicit motion-vector read.
1714 let mut i = all_off();
1715 i.velocity_enabled = true;
1716 i.upscale_enabled = true;
1717 let g = build_frame_graph(&i).expect("compiles");
1718 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1719 assert!(order.contains(&PassId::Upscale));
1720 assert!(!order.contains(&PassId::TaaResolve));
1721 assert!(
1722 order.iter().position(|p| *p == PassId::Velocity).unwrap()
1723 < order.iter().position(|p| *p == PassId::Upscale).unwrap()
1724 );
1725 }
1726
1727 #[test]
1728 fn upscale_takes_precedence_when_both_taa_and_upscale_requested() {
1729 // If both flags somehow arrive set (the engine layer should
1730 // forbid this, but the graph is the safety net), Upscale wins
1731 // and TaaResolve is omitted.
1732 let mut i = all_off();
1733 i.velocity_enabled = true;
1734 i.taa_enabled = true;
1735 i.upscale_enabled = true;
1736 let g = build_frame_graph(&i).expect("compiles");
1737 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1738 assert!(order.contains(&PassId::Upscale));
1739 assert!(!order.contains(&PassId::TaaResolve));
1740 }
1741
1742 #[test]
1743 fn velocity_taa_pinned_via_explicit_read() {
1744 // TaaResolve reads the velocity buffer explicitly so the
1745 // toposort orders Velocity before TaaResolve via RAW (not
1746 // declaration order).
1747 let mut i = all_off();
1748 i.velocity_enabled = true;
1749 i.taa_enabled = true;
1750 let g = build_frame_graph(&i).expect("compiles");
1751 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1752 // Main runs first, then Velocity + TaaResolve in compile-pass
1753 // order. TaaResolve reads scene_color v0 (imported v0 rule) +
1754 // velocity v1.
1755 assert!(
1756 order.iter().position(|p| *p == PassId::Velocity).unwrap()
1757 < order.iter().position(|p| *p == PassId::TaaResolve).unwrap()
1758 );
1759 }
1760
1761 #[test]
1762 fn transparent_pinned_between_ssr_resolve_and_taa() {
1763 // Transparent extends the scene_pre_taa chain by one version
1764 // after SsrResolve, so the toposort orders SsrResolve →
1765 // Transparent → TaaResolve via RAW + WAW edges on the same
1766 // texture.
1767 let mut i = all_off();
1768 i.ssr_enabled = true;
1769 i.taa_enabled = true;
1770 i.velocity_enabled = true;
1771 i.transparent_enabled = true;
1772 let g = build_frame_graph(&i).expect("compiles");
1773 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1774 let pos = |p: PassId| order.iter().position(|x| *x == p).expect("present");
1775 assert!(pos(PassId::SsrResolve) < pos(PassId::Transparent));
1776 assert!(pos(PassId::Transparent) < pos(PassId::TaaResolve));
1777 }
1778
1779 #[test]
1780 fn transparent_works_without_ssr() {
1781 // Without a reflection resolve there is no scene_pre_taa texture, so
1782 // Transparent RMWs hdr_resolve directly and TaaResolve reads that.
1783 let mut i = all_off();
1784 i.taa_enabled = true;
1785 i.velocity_enabled = true;
1786 i.transparent_enabled = true;
1787 let g = build_frame_graph(&i).expect("compiles");
1788 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1789 assert!(order.contains(&PassId::Transparent));
1790 assert!(!order.contains(&PassId::SsrResolve));
1791 let pos = |p: PassId| order.iter().position(|x| *x == p).expect("present");
1792 assert!(pos(PassId::Main) < pos(PassId::Transparent));
1793 assert!(pos(PassId::Transparent) < pos(PassId::TaaResolve));
1794 }
1795
1796 // A resource is declared only where a pass writes it. The engine points
1797 // several names at one texture depending on configuration, and declaring the
1798 // second name anyway would give one GPU object two barrier timelines, each
1799 // transitioning it from a state the other just left. These pin the three
1800 // configurations where that could recur.
1801
1802 #[test]
1803 fn no_reflection_resolve_means_no_scene_pre_taa_resource() {
1804 // With neither SSR nor RT the engine binds the pre-TAA scene name to
1805 // hdr_resolve itself, so a scene_pre_taa resource would be a second
1806 // handle on that object.
1807 let mut i = all_off();
1808 i.taa_enabled = true;
1809 i.velocity_enabled = true;
1810 i.transparent_enabled = true;
1811 let g = build_frame_graph(&i).expect("compiles");
1812 assert!(
1813 !g.resources.iter().any(|r| r.label == "scene_pre_taa"),
1814 "scene_pre_taa is hdr_resolve here; it must not be declared twice"
1815 );
1816 // And it reappears the moment a pass genuinely writes it.
1817 i.ssr_enabled = true;
1818 let g = build_frame_graph(&i).expect("compiles");
1819 assert!(g.resources.iter().any(|r| r.label == "scene_pre_taa"));
1820 }
1821
1822 #[test]
1823 fn no_temporal_pass_means_no_scene_color_resource() {
1824 // Neither TaaResolve nor Upscale runs, so scene_color is bound to the
1825 // latest pre-TAA scene texture and Composite reads that version.
1826 let mut i = all_off();
1827 i.ssr_enabled = true;
1828 i.bloom_enabled = true;
1829 let g = build_frame_graph(&i).expect("compiles");
1830 assert!(
1831 !g.resources.iter().any(|r| r.label == "scene_color"),
1832 "scene_color is the pre-TAA scene here; it must not be declared twice"
1833 );
1834 // Bloom and Composite consume the SsrResolve output instead.
1835 let pre_taa = resource_of(&g, "scene_pre_taa");
1836 let bloom = g
1837 .passes
1838 .iter()
1839 .find(|p| p.id == PassId::Bloom)
1840 .expect("bloom present");
1841 assert!(
1842 bloom.reads.iter().any(|r| r.resource_index() == pre_taa),
1843 "Bloom reads the resolve output directly"
1844 );
1845 i.taa_enabled = true;
1846 i.velocity_enabled = true;
1847 let g = build_frame_graph(&i).expect("compiles");
1848 assert!(g.resources.iter().any(|r| r.label == "scene_color"));
1849 }
1850
1851 #[test]
1852 fn glass_without_a_reflection_resolve_extends_the_hdr_chain() {
1853 // Transparent blends into whichever texture carries the pre-TAA scene.
1854 // With no resolve that is hdr_resolve, so its write must bump the
1855 // hdr_resolve version rather than branch a second resource -- otherwise
1856 // the glass blend and the decoration chain order independently over one
1857 // object.
1858 let mut i = all_off();
1859 i.transparent_enabled = true;
1860 i.decals_enabled = true;
1861 let g = build_frame_graph(&i).expect("compiles");
1862 let hdr = resource_of(&g, "hdr_resolve");
1863 let trans = g
1864 .passes
1865 .iter()
1866 .find(|p| p.id == PassId::Transparent)
1867 .expect("transparent present");
1868 let write = trans
1869 .writes
1870 .iter()
1871 .find(|w| w.resource_index() == hdr)
1872 .expect("Transparent writes hdr_resolve");
1873 // It RMWs: the version it produces is one past the decoration chain's.
1874 let read = trans
1875 .reads
1876 .iter()
1877 .find(|r| r.resource_index() == hdr)
1878 .expect("Transparent reads hdr_resolve");
1879 assert_eq!(
1880 write.version(),
1881 read.version() + 1,
1882 "the glass blend extends the chain it read"
1883 );
1884 // Composite sees the blended version, not the pre-glass one.
1885 let composite = g
1886 .passes
1887 .iter()
1888 .find(|p| p.id == PassId::Composite)
1889 .expect("composite present");
1890 assert!(
1891 composite
1892 .reads
1893 .iter()
1894 .any(|r| r.resource_index() == hdr && r.version() == write.version()),
1895 "Composite reads the post-glass version"
1896 );
1897 }
1898
1899 #[test]
1900 fn transparent_off_means_no_slot() {
1901 // The pass is omitted when nothing in the world is transparent:
1902 // no orphan slot, no executor stub triggered.
1903 let mut i = all_off();
1904 i.ssr_enabled = true;
1905 i.taa_enabled = true;
1906 i.velocity_enabled = true;
1907 // transparent_enabled left at false.
1908 let g = build_frame_graph(&i).expect("compiles");
1909 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1910 assert!(!order.contains(&PassId::Transparent));
1911 }
1912
1913 #[test]
1914 fn lines_close_the_hdr_decoration_chain() {
1915 // The node RMWs hdr_resolve last, so the lines draw over the lit +
1916 // decorated scene and SSR / TAA then consume them like any other
1917 // scene content.
1918 let mut i = all_off();
1919 i.decals_enabled = true;
1920 i.particles_enabled = true;
1921 i.ssr_enabled = true;
1922 i.lines_enabled = true;
1923 let g = build_frame_graph(&i).expect("compiles");
1924 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1925 let pos = |p: PassId| order.iter().position(|x| *x == p).expect("present");
1926 assert!(pos(PassId::Decals) < pos(PassId::Lines));
1927 assert!(pos(PassId::ParticlesDraw) < pos(PassId::Lines));
1928 assert!(pos(PassId::Lines) < pos(PassId::SsrResolve));
1929 }
1930
1931 #[test]
1932 fn lines_off_means_no_slot() {
1933 // A frame that published no lines omits the pass entirely.
1934 let g = build_frame_graph(&all_off()).expect("compiles");
1935 assert!(!g.passes.iter().any(|p| p.id == PassId::Lines));
1936 }
1937
1938 #[test]
1939 fn a_hidden_world_drops_the_lines() {
1940 // Behind an opaque menu backdrop nothing of the world is visible, so
1941 // the masked graph drops the lines with every other world pass.
1942 let mut i = all_off();
1943 i.lines_enabled = true;
1944 i.world_hidden = true;
1945 let g = build_frame_graph(&i).expect("compiles");
1946 assert!(!g.passes.iter().any(|p| p.id == PassId::Lines));
1947 }
1948
1949 #[test]
1950 fn ssgi_off_means_no_slot() {
1951 // IBL-only indirect lighting: the pass is omitted entirely.
1952 let g = build_frame_graph(&all_off()).expect("compiles");
1953 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1954 assert!(!order.contains(&PassId::Ssgi));
1955 }
1956
1957 #[test]
1958 fn rt_reflections_off_means_no_slot() {
1959 // No ray tracing requested: the pass is omitted entirely.
1960 let g = build_frame_graph(&all_off()).expect("compiles");
1961 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1962 assert!(!order.contains(&PassId::RtReflections));
1963 }
1964
1965 #[test]
1966 fn rt_reflections_occupy_the_ssr_resolve_slot() {
1967 // RtReflections reads the post-decoration hdr_resolve and writes
1968 // scene_pre_taa, exactly where SsrResolve would, so it orders after
1969 // ParticlesDraw and before TaaResolve.
1970 let mut i = all_off();
1971 i.rt_reflections_enabled = true;
1972 i.particles_enabled = true;
1973 i.taa_enabled = true;
1974 i.velocity_enabled = true;
1975 let g = build_frame_graph(&i).expect("compiles");
1976 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1977 let pos = |p: PassId| order.iter().position(|x| *x == p).expect("present");
1978 assert!(order.contains(&PassId::RtReflections));
1979 assert!(pos(PassId::ParticlesDraw) < pos(PassId::RtReflections));
1980 assert!(pos(PassId::RtReflections) < pos(PassId::TaaResolve));
1981 }
1982
1983 #[test]
1984 fn rt_reflections_take_precedence_over_ssr_resolve() {
1985 // RT alone inserts RtReflections, not SsrResolve.
1986 let mut i = all_off();
1987 i.rt_reflections_enabled = true;
1988 let g = build_frame_graph(&i).expect("compiles");
1989 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
1990 assert!(order.contains(&PassId::RtReflections));
1991 assert!(!order.contains(&PassId::SsrResolve));
1992
1993 // With both flags set (RT available + SSR fallback authored), hardware
1994 // RT wins and SsrResolve is omitted; never two in the same slot.
1995 let mut both = all_off();
1996 both.ssr_enabled = true;
1997 both.rt_reflections_enabled = true;
1998 let g = build_frame_graph(&both).expect("compiles");
1999 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
2000 assert!(order.contains(&PassId::RtReflections));
2001 assert!(!order.contains(&PassId::SsrResolve));
2002 }
2003
2004 #[test]
2005 fn ssgi_pinned_between_auto_exposure_and_decals() {
2006 // AutoExposure reads hdr_resolve_v1 (WAR); SSGI RMWs to v2; Decals
2007 // RMWs to v3. The toposort orders the three through the version chain.
2008 let mut i = all_off();
2009 i.auto_exposure_enabled = true;
2010 i.ssgi_enabled = true;
2011 i.decals_enabled = true;
2012 let g = build_frame_graph(&i).expect("compiles");
2013 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
2014 assert_eq!(
2015 order,
2016 vec![
2017 PassId::Main,
2018 PassId::AutoExposure,
2019 PassId::Ssgi,
2020 PassId::Decals,
2021 PassId::Composite,
2022 ]
2023 );
2024 // Version chain on hdr_resolve walks 1 → 2 → 3.
2025 let ssgi = &g.passes[2];
2026 assert_eq!(ssgi.writes[0].version(), 2);
2027 let decals = &g.passes[3];
2028 assert_eq!(decals.writes[0].version(), 3);
2029 }
2030
2031 #[test]
2032 fn ssgi_after_raymarch_on_the_chain() {
2033 // With both on, SSGI reads the post-raymarch scene: Raymarch v1→v2,
2034 // SSGI v2→v3, SsrResolve reads v3.
2035 let mut i = all_off();
2036 i.raymarch_enabled = true;
2037 i.ssgi_enabled = true;
2038 i.ssr_enabled = true;
2039 let g = build_frame_graph(&i).expect("compiles");
2040 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
2041 let pos = |p: PassId| order.iter().position(|x| *x == p).expect("present");
2042 assert!(pos(PassId::Raymarch) < pos(PassId::Ssgi));
2043 assert!(pos(PassId::Ssgi) < pos(PassId::SsrResolve));
2044 let ssgi = &g.passes[pos(PassId::Ssgi)];
2045 assert_eq!(ssgi.writes[0].version(), 3);
2046 }
2047
2048 #[test]
2049 fn raymarch_off_means_no_slot() {
2050 // No `SdfVolume` in the world: pass is omitted, no executor stub
2051 // ever fires.
2052 let g = build_frame_graph(&all_off()).expect("compiles");
2053 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
2054 assert!(!order.contains(&PassId::Raymarch));
2055 }
2056
2057 #[test]
2058 fn raymarch_pinned_between_auto_exposure_and_decals() {
2059 // AutoExposure reads hdr_resolve_v1 (WAR); Raymarch RMWs to v2;
2060 // Decals RMWs to v3. The toposort orders the three through the
2061 // version chain without needing declaration-order tie-breaks.
2062 let mut i = all_off();
2063 i.auto_exposure_enabled = true;
2064 i.raymarch_enabled = true;
2065 i.decals_enabled = true;
2066 let g = build_frame_graph(&i).expect("compiles");
2067 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
2068 assert_eq!(
2069 order,
2070 vec![
2071 PassId::Main,
2072 PassId::AutoExposure,
2073 PassId::Raymarch,
2074 PassId::Decals,
2075 PassId::Composite,
2076 ]
2077 );
2078 // Version chain on hdr_resolve walks 1 → 2 → 3.
2079 let raymarch = &g.passes[2];
2080 assert_eq!(raymarch.writes[0].version(), 2);
2081 let decals = &g.passes[3];
2082 assert_eq!(decals.writes[0].version(), 3);
2083 }
2084
2085 #[test]
2086 fn raymarch_works_without_auto_exposure_or_decals() {
2087 // Standalone Raymarch RMWs hdr_resolve_v1 → v2; SsrResolve reads
2088 // v2 instead of v1. Nothing else in the post chain.
2089 let mut i = all_off();
2090 i.raymarch_enabled = true;
2091 i.ssr_enabled = true;
2092 let g = build_frame_graph(&i).expect("compiles");
2093 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
2094 assert_eq!(
2095 order,
2096 vec![
2097 PassId::Main,
2098 PassId::Raymarch,
2099 PassId::SsrResolve,
2100 PassId::Composite,
2101 ]
2102 );
2103 let raymarch = &g.passes[1];
2104 assert_eq!(raymarch.writes[0].version(), 2);
2105 let ssr = &g.passes[2];
2106 assert_eq!(ssr.reads[0].version(), 2);
2107 }
2108
2109 #[test]
2110 fn full_graph_orders_all_passes_correctly() {
2111 // Everything on: every pass shows up in the expected order.
2112 // This is the showcase configuration.
2113 let mut i = all_off();
2114 i.shadow_enabled = true;
2115 i.bindless_cull_enabled = true;
2116 i.auto_exposure_enabled = true;
2117 i.bloom_enabled = true;
2118 i.velocity_enabled = true;
2119 i.taa_enabled = true;
2120 i.ssr_enabled = true;
2121 i.particles_enabled = true;
2122 i.fog_enabled = true;
2123 i.decals_enabled = true;
2124 i.ssr_prepass_enabled = true;
2125 i.ssao_enabled = true;
2126 i.transparent_enabled = true;
2127 i.raymarch_enabled = true;
2128
2129 let g = build_frame_graph(&i).expect("compiles");
2130 let order: Vec<PassId> = g.passes.iter().map(|p| p.id).collect();
2131
2132 // Spot-check relative ordering rather than the exact list: with
2133 // many independent passes the toposort has flexibility on
2134 // tie-breaks.
2135 fn idx(order: &[PassId], p: PassId) -> usize {
2136 order.iter().position(|x| *x == p).expect("pass present")
2137 }
2138 // Cull / SsrPrepass / SsaoBlur / Shadow / SSAO all precede Main.
2139 assert!(idx(&order, PassId::Cull) < idx(&order, PassId::Main));
2140 assert!(idx(&order, PassId::SsrPrepass) < idx(&order, PassId::Main));
2141 assert!(idx(&order, PassId::SsaoBlur) < idx(&order, PassId::Main));
2142 assert!(idx(&order, PassId::Shadow) < idx(&order, PassId::Main));
2143 // SsrPrepass precedes SsaoBlur (G-buffer share).
2144 assert!(idx(&order, PassId::SsrPrepass) < idx(&order, PassId::SsaoBlur));
2145 // AutoExposure post-Main, pre-Raymarch (WAR-pinned on hdr_resolve_v1).
2146 assert!(idx(&order, PassId::Main) < idx(&order, PassId::AutoExposure));
2147 assert!(idx(&order, PassId::AutoExposure) < idx(&order, PassId::Raymarch));
2148 // Raymarch leads the hdr_resolve RMW chain so Decals / Fog /
2149 // ParticlesDraw blend on top of the raymarched colour.
2150 assert!(idx(&order, PassId::Raymarch) < idx(&order, PassId::Decals));
2151 // hdr_resolve chain.
2152 assert!(idx(&order, PassId::Decals) < idx(&order, PassId::Fog));
2153 assert!(idx(&order, PassId::Fog) < idx(&order, PassId::ParticlesDraw));
2154 assert!(idx(&order, PassId::ParticlesDraw) < idx(&order, PassId::SsrResolve));
2155 // FogFroxel populates the volume Fog samples, so it must precede Fog.
2156 assert!(idx(&order, PassId::FogFroxel) < idx(&order, PassId::Fog));
2157 // Velocity precedes TaaResolve.
2158 assert!(idx(&order, PassId::Velocity) < idx(&order, PassId::TaaResolve));
2159 // Post-TAA chain. Transparent slots between SsrResolve and TaaResolve.
2160 assert!(idx(&order, PassId::SsrResolve) < idx(&order, PassId::Transparent));
2161 assert!(idx(&order, PassId::Transparent) < idx(&order, PassId::TaaResolve));
2162 assert!(idx(&order, PassId::TaaResolve) < idx(&order, PassId::Bloom));
2163 assert!(idx(&order, PassId::Bloom) < idx(&order, PassId::Composite));
2164 // Composite is the presenter and runs last.
2165 assert_eq!(order.last(), Some(&PassId::Composite));
2166 assert!(g.passes.last().unwrap().presents);
2167 }
2168}