Skip to main content

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}