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