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