concinnity_render/render_graph/passes.rs
1// src/render_graph/passes.rs
2//
3// Stable identity for every render-graph pass. Used by:
4//
5// - The graph itself, as the dispatch key the executor matches on.
6// - The per-pass GPU timer (the Metal backend's `pass_timing`), which keys
7// its sample-buffer slots off the same integer. Adding a new pass = a
8// new variant here + a new entry in [`PASS_NAMES`] + a bumped
9// `PASS_COUNT`; nothing else needs to change for timing to flow. The
10// `every_pass_id_round_trips_to_its_name` test forces all three edits
11// at compile time (a missed registration would otherwise report zero
12// GPU time for the pass), and `pass_timing::slot_pair` debug_asserts
13// the index at runtime.
14//
15// Variants are intentionally `#[repr(u32)]` so a `PassId` round-trips
16// through `as usize` into the [`PASS_NAMES`] / counter-sample-buffer
17// slot index. Adding a variant in the middle of the list will renumber
18// later variants and silently shift every timing slot; append-only.
19
20/// Stable display name for each pass. Index = `PassId as usize`. Used by
21/// the WS `profile.passes` reply and the per-pass timing readback.
22pub const PASS_NAMES: [&str; PASS_COUNT] = [
23 "cull",
24 "shadow",
25 "ssr_prepass",
26 "ssao_prepass",
27 "ssao_kernel",
28 "ssao_blur",
29 "main",
30 "auto_exposure",
31 "decals",
32 "fog",
33 "particles_sim",
34 "particles_draw",
35 "ssr_resolve",
36 "velocity",
37 "taa_resolve",
38 "bloom",
39 "composite",
40 "fog_froxel",
41 "upscale",
42 "transparent",
43 "raymarch",
44 "hiz_build",
45 "cull2",
46 "main2",
47 "ssgi",
48 "rt_reflections",
49 "gbuffer_prepass",
50 "reflection_composite",
51 "light_cull",
52 "spot_shadow",
53 "lines",
54 "hiz_final",
55];
56
57/// Number of distinct passes the engine times. Sized to match
58/// [`PASS_NAMES`]; the per-pass timing array in
59/// [`crate::profile::RenderStats`] is sized to at least this many
60/// slots.
61pub const PASS_COUNT: usize = 32;
62
63/// One per-pass identity. Cast to `usize` to index [`PASS_NAMES`] or any
64/// `[T; PASS_COUNT]` companion array.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66#[repr(u32)]
67pub enum PassId {
68 /// GPU visibility cull; produces the indirect draw arguments.
69 Cull = 0,
70 /// Directional shadow cascades and spot shadow slices.
71 Shadow = 1,
72 /// Depth / normal prepass feeding screen-space reflections.
73 SsrPrepass = 2,
74 /// Depth / normal prepass feeding ambient occlusion.
75 SsaoPrepass = 3,
76 /// The ambient-occlusion gather.
77 SsaoKernel = 4,
78 /// Bilateral blur over the occlusion buffer.
79 SsaoBlur = 5,
80 /// The lit forward pass.
81 Main = 6,
82 /// Luminance reduction driving auto-exposure.
83 AutoExposure = 7,
84 /// Projected decals.
85 Decals = 8,
86 /// Volumetric fog composite.
87 Fog = 9,
88 /// Particle simulation compute.
89 ParticlesSim = 10,
90 /// Particle draw.
91 ParticlesDraw = 11,
92 /// Reflection trace and composite.
93 SsrResolve = 12,
94 /// Screen-space velocity, for TAA and motion blur.
95 Velocity = 13,
96 /// Temporal anti-aliasing resolve.
97 TaaResolve = 14,
98 /// Bloom down/upsample chain.
99 Bloom = 15,
100 /// Tonemap, grade, and present.
101 Composite = 16,
102 /// Volumetric-fog froxel-volume compute pass. Populates a 3D
103 /// `(scattered, transmittance)` texture once per frame, sampled by the
104 /// fullscreen `Fog` render pass instead of an inline ray-march. Metal
105 /// only; DX/Vulkan keep the ray-march and never insert this pass.
106 FogFroxel = 17,
107 /// Temporal upscaling pass. When the world's `PostProcessConfig`
108 /// enables `temporal_upscaling`, the renderer draws the 3D scene at a
109 /// fraction of drawable size and inserts this pass between the post-SSR
110 /// scene and the Bloom + Composite stack. The backend runs its
111 /// platform-native temporal upscaler (MetalFX on macOS; FSR / DLSS /
112 /// XeSS slots on the Windows backends are placeholders today) to
113 /// reconstruct a drawable-resolution image. Replaces `TaaResolve`:
114 /// the upscaler does temporal accumulation itself, so adding both
115 /// would double-temporal the scene.
116 Upscale = 18,
117 /// Transparent / translucent geometry pass. Runs after `SsrResolve`
118 /// (so water + glass see opaque reflections) and before
119 /// `TaaResolve` / `Upscale` (so translucents pick up temporal
120 /// accumulation). Reads the latest scene-pre-taa colour + main
121 /// depth as sampled textures; writes scene-pre-taa blended
122 /// (SRC_ALPHA / ONE_MINUS_SRC_ALPHA). Each transparent draw owns
123 /// its own pipeline + descriptor set; the pass aggregates them as
124 /// a back-to-front sorted list at encode time. Gated on
125 /// `FrameGraphInputs::transparent_enabled`; when no consumer is
126 /// in the world, the slot is omitted entirely.
127 Transparent = 19,
128 /// Raymarched SDF volume pass. Rasterises the back faces of each
129 /// `SdfVolume`'s world-space bounding box and runs the user-authored
130 /// fragment shader, which sphere-traces a signed distance field
131 /// inside the box. Hit fragments write opaque colour into
132 /// `hdr_resolve` (RMW between `AutoExposure` and `Decals`) and
133 /// update the main depth attachment so the raymarched surface
134 /// composites with rasterised geometry naturally: decals, fog,
135 /// SSR-resolve, and TAA all consume the post-Raymarch depth and
136 /// colour. Gated on `FrameGraphInputs::raymarch_enabled`; when no
137 /// `SdfVolume` is in the world the slot is omitted entirely.
138 Raymarch = 20,
139 /// Mid-frame Hi-Z (depth-mip pyramid) rebuild for two-pass occlusion
140 /// culling. Inserted only when `FrameGraphInputs::two_pass_occlusion_enabled`
141 /// is on: after `Main` (phase 1) has written this frame's depth, this
142 /// compute pass reduces it into the Hi-Z pyramid so `Cull2` can re-test
143 /// the objects phase 1 occluded against up-to-date depth. Distinct from
144 /// the end-of-frame Hi-Z build (which feeds the *next* frame's phase-1
145 /// cull and stays an inline action, not a graph node). Every backend
146 /// implements the node; whether it appears is `two_pass_occlusion_enabled`,
147 /// which each seeds from its own two-pass state.
148 HizBuild = 21,
149 /// Phase-2 GPU cull for two-pass occlusion. Re-tests the objects `Cull`
150 /// (phase 1) marked Hi-Z-occluded against the freshly rebuilt pyramid
151 /// (`HizBuild`) and encodes a draw for any that turn out visible into a
152 /// second indirect command buffer `Main2` consumes. Reads the per-object
153 /// status buffer phase-1 cull wrote + the `draw_args2` buffer it writes.
154 /// Gated on `FrameGraphInputs::two_pass_occlusion_enabled`.
155 Cull2 = 22,
156 /// Phase-2 main pass for two-pass occlusion. Loads (does not clear) the
157 /// HDR colour + depth `Main` wrote and re-runs only the bindless-static
158 /// indirect draw through `Cull2`'s command buffer, depth-compositing the
159 /// disoccluded geometry with phase 1. Instanced + skinned geometry is not
160 /// Hi-Z-culled, so it is fully drawn in phase 1 and not repeated here.
161 /// Becomes the new head of the hdr_resolve post-decoration chain (so
162 /// AutoExposure / Decals / Fog / SSR see the combined result). Gated on
163 /// `FrameGraphInputs::two_pass_occlusion_enabled`.
164 Main2 = 23,
165 /// Screen-space global illumination. A refinement of SSR: it reuses the
166 /// SSR depth + normal pre-pass G-buffer and screen-space ray-march, but
167 /// integrates bounced radiance over a cosine-weighted hemisphere instead
168 /// of along one reflection vector. Sits on the hdr_resolve RMW chain (after
169 /// `Raymarch`, before `Decals`): it reads the lit scene as the bounce
170 /// radiance source and additively composites the gathered + denoised
171 /// indirect term back into it, so the near-field colour bleed layers on top
172 /// of the IBL ambient. Gated on `FrameGraphInputs::ssgi_enabled`; when
173 /// `indirect_lighting` is IBL-only the slot is omitted entirely.
174 Ssgi = 24,
175 /// Hardware ray-traced reflections. Occupies the same scene-pre-taa slot as
176 /// `SsrResolve` (reads the post-decoration `hdr_resolve`, writes
177 /// `scene_pre_taa`) and takes precedence over it: when this pass is live the
178 /// builder inserts it and omits `SsrResolve` (a world may author both; RT
179 /// runs where available, SSR is the fallback). It still relies on the SSR
180 /// depth + normal + roughness
181 /// pre-pass (so `SsrPrepass` is forced on), but instead of a screen-space
182 /// march it traces a world-space reflection ray against an acceleration
183 /// structure built over the static scene geometry, so off-screen reflected
184 /// geometry appears. Gated on `FrameGraphInputs::rt_reflections_enabled`,
185 /// and so only on GPUs that report ray-tracing support.
186 RtReflections = 25,
187 /// Unified geometry G-buffer pre-pass. One jittered traversal of the visible
188 /// set writes view-space normal + linear depth, perceptual roughness, and
189 /// screen-space motion into a single MRT (plus a sampleable depth), replacing
190 /// the separate `SsrPrepass` + `Velocity` (and the SSAO-owned prepass): every
191 /// consumer (SSR, SSAO, SSGI, RT, TAA, upscaler) reads this one output. Gated
192 /// on `FrameGraphInputs::unified_gbuffer_prepass`.
193 GBufferPrepass = 26,
194 /// Roughness-aware reflection composite. Not a standalone graph node: it is
195 /// encoded inline at the tail of the `SsrResolve` / `RtReflections` pass
196 /// (both write a reflection target, then blur it by roughness and composite
197 /// it over the scene). It carries its own timing slot so its cost is visible
198 /// separately from the trace/march that precedes it. Inline on every
199 /// backend, so no backend's graph executor ever dispatches this id: each
200 /// treats it as a programming error the way it treats the bundled SSAO
201 /// sub-passes.
202 ReflectionComposite = 27,
203 /// Clustered light-binning compute pass. Once per frame, before Main: bins the
204 /// scene's local lights (the GpuLight buffer) into a per-cluster index list
205 /// over a screen-tiled, exponential-depth froxel grid, which the forward pass
206 /// reads to shade each fragment from only its cluster's lights instead of
207 /// iterating every light. Runs when the world has local lights. Writes a
208 /// storage buffer Main reads (RAW edge).
209 LightCull = 28,
210 /// Depth-only render of each shadowed spot light's cone into one slice of the
211 /// spot shadow map array. Local lights are static, so the projections are
212 /// built once; only the depth contents refresh, one slice per frame under
213 /// `ShadowUpdate::Hybrid`. Runs when the world has a shadow-casting spot.
214 SpotShadow = 29,
215 /// World-space line geometry (trajectories, tethers, path previews, the
216 /// editor's origin axes). Blend-writes the resolved scene colour after the
217 /// world decorations, sampling the resolved scene depth so a line behind
218 /// geometry is occluded by it. Gated on `FrameGraphInputs::lines_enabled`:
219 /// a frame that submits no lines omits the node entirely, so a frame that
220 /// draws none never pays for it.
221 Lines = 30,
222 /// Terminal Hi-Z (depth-mip pyramid) build. Reduces the frame's final main
223 /// depth into the pyramid the *next* frame's phase-1 `Cull` tests against,
224 /// so it is declared last and reads the depth every decoration pass has
225 /// finished with. Distinct from `HizBuild`, which rebuilds the same pyramid
226 /// mid-frame from phase-1 depth for `Cull2`; when two-pass occlusion is on
227 /// both run and this one supersedes it for the next frame. Present whenever
228 /// the GPU-cull path built a pyramid (`FrameGraphInputs::hiz_build_enabled`).
229 HizFinal = 31,
230}
231
232impl PassId {
233 /// Stable display name, looked up in [`PASS_NAMES`]. `'static` since
234 /// the table is `const`.
235 pub fn name(self) -> &'static str {
236 PASS_NAMES[self as usize]
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 // Every `PassId` variant, in declaration (index) order. This is a sized
245 // `[PassId; PASS_COUNT]` array, so adding a variant forces three edits to
246 // keep this compiling: bump `PASS_COUNT`, add the `PASS_NAMES` entry, and
247 // list the variant here. Combined with `expected_name`'s wildcard-free
248 // match below, a new graph pass cannot ship without a timing name (which
249 // would otherwise read as zero GPU time).
250 const ALL: [PassId; PASS_COUNT] = [
251 PassId::Cull,
252 PassId::Shadow,
253 PassId::SsrPrepass,
254 PassId::SsaoPrepass,
255 PassId::SsaoKernel,
256 PassId::SsaoBlur,
257 PassId::Main,
258 PassId::AutoExposure,
259 PassId::Decals,
260 PassId::Fog,
261 PassId::ParticlesSim,
262 PassId::ParticlesDraw,
263 PassId::SsrResolve,
264 PassId::Velocity,
265 PassId::TaaResolve,
266 PassId::Bloom,
267 PassId::Composite,
268 PassId::FogFroxel,
269 PassId::Upscale,
270 PassId::Transparent,
271 PassId::Raymarch,
272 PassId::HizBuild,
273 PassId::Cull2,
274 PassId::Main2,
275 PassId::Ssgi,
276 PassId::RtReflections,
277 PassId::GBufferPrepass,
278 PassId::ReflectionComposite,
279 PassId::LightCull,
280 PassId::SpotShadow,
281 PassId::Lines,
282 PassId::HizFinal,
283 ];
284
285 // Expected timing name per variant. The match has no wildcard arm, so
286 // adding a `PassId` variant fails to compile here until it is named. This
287 // is the forcing function for the timing-name registration gotcha.
288 fn expected_name(pass: PassId) -> &'static str {
289 match pass {
290 PassId::Cull => "cull",
291 PassId::Shadow => "shadow",
292 PassId::SsrPrepass => "ssr_prepass",
293 PassId::SsaoPrepass => "ssao_prepass",
294 PassId::SsaoKernel => "ssao_kernel",
295 PassId::SsaoBlur => "ssao_blur",
296 PassId::Main => "main",
297 PassId::AutoExposure => "auto_exposure",
298 PassId::Decals => "decals",
299 PassId::Fog => "fog",
300 PassId::ParticlesSim => "particles_sim",
301 PassId::ParticlesDraw => "particles_draw",
302 PassId::SsrResolve => "ssr_resolve",
303 PassId::Velocity => "velocity",
304 PassId::TaaResolve => "taa_resolve",
305 PassId::Bloom => "bloom",
306 PassId::Composite => "composite",
307 PassId::FogFroxel => "fog_froxel",
308 PassId::Upscale => "upscale",
309 PassId::Transparent => "transparent",
310 PassId::Raymarch => "raymarch",
311 PassId::HizBuild => "hiz_build",
312 PassId::Cull2 => "cull2",
313 PassId::Main2 => "main2",
314 PassId::Ssgi => "ssgi",
315 PassId::RtReflections => "rt_reflections",
316 PassId::GBufferPrepass => "gbuffer_prepass",
317 PassId::ReflectionComposite => "reflection_composite",
318 PassId::LightCull => "light_cull",
319 PassId::SpotShadow => "spot_shadow",
320 PassId::Lines => "lines",
321 PassId::HizFinal => "hiz_final",
322 }
323 }
324
325 #[test]
326 fn pass_names_match_pass_count() {
327 assert_eq!(PASS_NAMES.len(), PASS_COUNT);
328 }
329
330 #[test]
331 fn every_pass_id_round_trips_to_its_name() {
332 // Each variant's integer value must equal its position in both `ALL`
333 // and `PASS_NAMES` so the per-pass timing arrays stay aligned, and
334 // every pass must carry a non-empty, expected name.
335 for (i, &pass) in ALL.iter().enumerate() {
336 assert_eq!(pass as usize, i, "{pass:?} index out of order");
337 assert_eq!(pass.name(), PASS_NAMES[i], "{pass:?} name table mismatch");
338 assert_eq!(pass.name(), expected_name(pass), "{pass:?} name drifted");
339 assert!(!pass.name().is_empty(), "{pass:?} has an empty name");
340 }
341 // The last listed variant pins the high end of the index range.
342 assert_eq!(ALL[PASS_COUNT - 1] as usize, PASS_COUNT - 1);
343 }
344}