Skip to main content

myth_render/graph/
composer.rs

1//! Frame Composer
2//!
3//! `FrameComposer` orchestrates the entire rendering pipeline for a single
4//! frame using the Declarative Render Graph (RDG). All GPU work — compute
5//! pre-processing, shadow mapping, scene rendering, post-processing, and
6//! custom user hooks — flows through a single unified RDG.
7//!
8//! # Resource Ownership (Explicit Wiring)
9//!
10//! The Composer registers only the **external** `Surface_Out` resource and
11//! the routing-level `LDR_Intermediate`.  All scene-level transient resources
12//! (`Scene_Color_HDR`, `Scene_Depth`, MSAA intermediates, specular MRT, etc.)
13//! are created by their **producer passes** inside `add_to_graph()`.  Each
14//! pass returns typed output structs (`PrepassOutputs`, `OpaqueOutputs`, …)
15//! carrying `TextureNodeId` values that the Composer threads to downstream
16//! consumers — no blackboard lookups remain for mutable resources.
17//!
18//! # Rendering Architecture
19//!
20//! ```text
21//! ┌────────────────────────────────────────────────────────────────┐
22//! │                    Unified RDG Pipeline                        │
23//! │                                                                │
24//! │  HighFidelity:                                                 │
25//! │  BRDF LUT → IBL → Shadow → Prepass → SSAO → Opaque →         │
26//! │  SSSS → Skybox → TransmissionCopy → Transparent →             │
27//! │  [Bloom_System: Extract → DS_1..N → US_N..0 → Composite] →   │
28//! │  ToneMap → FXAA → [User Hooks] → Surface                     │
29//! │                                                                │
30//! │  BasicForward:                                                 │
31//! │  BRDF LUT → IBL → Shadow → Skybox(prepare) →                  │
32//! │  SimpleForward → [User Hooks] → Surface                       │
33//! └────────────────────────────────────────────────────────────────┘
34//! ```
35//!
36//! # Example
37//!
38//! ```ignore
39//! renderer.begin_frame(scene, &camera, assets, time)?
40//!     .add_custom_pass(HookStage::AfterPostProcess, |rdg, bb| {
41//!         let new_surface = rdg.add_pass("UI_Pass", |builder| {
42//!             let out = builder.mutate_texture(bb.surface_out, "Surface_With_UI");
43//!             (ui_node, out)
44//!         });
45//!         GraphBlackboard { surface_out: new_surface, ..bb }
46//!     })
47//!     .render();
48//! ```
49
50use crate::core::binding::GlobalBindGroupCache;
51use crate::core::gpu::Tracked;
52use crate::core::{ResourceManager, WgpuContext};
53use crate::graph::ExtractedScene;
54use crate::graph::RenderState;
55use crate::graph::core::GraphStorage;
56use crate::graph::core::graph::FrameConfig;
57use crate::graph::core::{
58    ExecuteContext, FrameArena, GraphBlackboard, HookStage, PrepareContext, RenderGraph,
59    TextureDesc, TransientPool, ViewResolver,
60};
61use crate::graph::frame::{PreparedSkyboxDraw, RenderLists};
62use crate::graph::passes::utils::add_msaa_resolve_pass;
63use crate::graph::passes::{
64    BloomFeature, BrdfLutFeature, CasFeature, FxaaFeature, IblComputeFeature, MsaaSyncFeature,
65    OpaqueFeature, PrepassFeature, ShadowFeature, SimpleForwardFeature, SkyboxFeature, SsaoFeature,
66    SsssFeature, TaaFeature, ToneMappingFeature, TransmissionCopyFeature, TransparentFeature,
67};
68use crate::pipeline::PipelineCache;
69use crate::pipeline::ShaderManager;
70use crate::renderer::FrameTime;
71use myth_assets::AssetServer;
72use myth_scene::Scene;
73use myth_scene::camera::RenderCamera;
74
75pub struct ComposerContext<'a> {
76    pub wgpu_ctx: &'a mut WgpuContext,
77    pub resource_manager: &'a mut ResourceManager,
78    pub pipeline_cache: &'a mut PipelineCache,
79    pub shader_manager: &'a mut ShaderManager,
80
81    pub extracted_scene: &'a ExtractedScene,
82    pub render_state: &'a RenderState,
83
84    pub global_bind_group_cache: &'a mut GlobalBindGroupCache,
85
86    /// Render lists (populated by `SceneCullPass`)
87    pub render_lists: &'a mut RenderLists,
88
89    // External scene data
90    pub scene: &'a mut Scene,
91    pub camera: &'a RenderCamera,
92    pub assets: &'a AssetServer,
93    pub frame_time: FrameTime,
94
95    pub graph_storage: &'a mut GraphStorage,
96    pub transient_pool: &'a mut TransientPool,
97    // pub sampler_registry: &'a mut SamplerRegistry,
98    pub frame_arena: &'a FrameArena,
99
100    // ─── RDG Features ────────────────────────────────────────────────────
101    // Post-processing
102    pub fxaa_pass: &'a mut FxaaFeature,
103    pub taa_pass: &'a mut TaaFeature,
104    pub cas_pass: &'a mut CasFeature,
105    pub tone_map_pass: &'a mut ToneMappingFeature,
106    pub bloom_pass: &'a mut BloomFeature,
107    pub ssao_pass: &'a mut SsaoFeature,
108    // Scene rendering
109    pub prepass: &'a mut PrepassFeature,
110    pub opaque_pass: &'a mut OpaqueFeature,
111    pub skybox_pass: &'a mut SkyboxFeature,
112    pub transparent_pass: &'a mut TransparentFeature,
113    pub transmission_copy_pass: &'a mut TransmissionCopyFeature,
114    pub simple_forward_pass: &'a mut SimpleForwardFeature,
115    pub ssss_pass: &'a mut SsssFeature,
116    pub msaa_sync_pass: &'a mut MsaaSyncFeature,
117
118    // Shadow + Compute
119    pub shadow_pass: &'a mut ShadowFeature,
120    pub brdf_pass: &'a mut BrdfLutFeature,
121    pub ibl_pass: &'a mut IblComputeFeature,
122
123    // Debug view (compile-time gated)
124    #[cfg(feature = "debug_view")]
125    pub debug_view_pass: &'a mut crate::graph::passes::DebugViewFeature,
126}
127
128pub struct GraphBuilderContext<'a, 'g> {
129    pub graph: &'g mut RenderGraph<'a>,
130    pub pipeline_cache: &'a PipelineCache,
131    pub frame_config: &'g FrameConfig,
132}
133
134impl GraphBuilderContext<'_, '_> {
135    #[cfg(feature = "rdg_inspector")]
136    pub fn with_group<F, R>(&mut self, group_name: &'static str, f: F) -> R
137    where
138        F: FnOnce(&mut Self) -> R,
139    {
140        self.graph.push_group(group_name);
141        let result = f(self);
142        self.graph.pop_group();
143        result
144    }
145
146    /// Zero-cost fallback when the inspector is disabled.
147    #[cfg(not(feature = "rdg_inspector"))]
148    #[inline]
149    pub fn with_group<F, R>(&mut self, _group_name: &'static str, f: F) -> R
150    where
151        F: FnOnce(&mut Self) -> R,
152    {
153        f(self)
154    }
155}
156
157/// Frame Composer
158///
159/// Holds all context references needed to render a single frame and provides
160/// a fluent API for injecting custom RDG passes via hooks.
161///
162pub struct FrameComposer<'a> {
163    ctx: ComposerContext<'a>,
164    frame_config: FrameConfig,
165    #[allow(clippy::type_complexity)]
166    hooks: smallvec::SmallVec<
167        [(
168            HookStage,
169            Option<Box<dyn FnOnce(&mut RenderGraph<'a>, GraphBlackboard) -> GraphBlackboard + 'a>>,
170        ); 4],
171    >,
172}
173
174impl<'a> FrameComposer<'a> {
175    /// Creates a new frame composer.
176    pub(crate) fn new(ctx: ComposerContext<'a>, size: (u32, u32)) -> Self {
177        let frame_config = FrameConfig {
178            width: size.0,
179            height: size.1,
180            depth_format: ctx.wgpu_ctx.depth_format,
181            msaa_samples: ctx.wgpu_ctx.msaa_samples,
182            surface_format: ctx.wgpu_ctx.surface_view_format,
183            hdr_format: crate::HDR_TEXTURE_FORMAT,
184        };
185
186        Self {
187            ctx,
188            frame_config,
189            hooks: smallvec::SmallVec::new(),
190        }
191    }
192
193    /// Returns a reference to the wgpu device.
194    #[inline]
195    #[must_use]
196    pub fn device(&self) -> &wgpu::Device {
197        &self.ctx.wgpu_ctx.device
198    }
199
200    /// Returns a reference to the resource manager.
201    ///
202    /// Useful for user-land passes that need to resolve engine resources
203    /// (e.g. texture handles) before the RDG prepare phase.
204    #[inline]
205    #[must_use]
206    pub fn resource_manager(&self) -> &ResourceManager {
207        self.ctx.resource_manager
208    }
209
210    /// Registers a custom pass hook that will be invoked during graph building.
211    ///
212    /// The closure receives a mutable reference to the [`RenderGraph`] and
213    /// the [`GraphBlackboard`] containing the frame's well-known resource slots.
214    /// It must return an (optionally updated) [`GraphBlackboard`] — the Rust
215    /// type system enforces that every hook path returns a valid blackboard.
216    ///
217    /// Hooks registered with [`HookStage::AfterPostProcess`] run after all
218    /// built-in post-processing (Bloom, ToneMap, FXAA) and are typically used
219    /// for UI rendering.
220    ///
221    /// # Example
222    ///
223    /// ```ignore
224    /// composer
225    ///     .add_custom_pass(HookStage::AfterPostProcess, |rdg, bb| {
226    ///         let new_surface = rdg.add_pass("MyPass", |builder| {
227    ///             let out = builder.mutate_texture(bb.surface_out, "Surface_MyPass");
228    ///             (MyPassNode { target: out }, out)
229    ///         });
230    ///         GraphBlackboard { surface_out: new_surface, ..bb }
231    ///     })
232    ///     .render();
233    /// ```
234    #[inline]
235    #[must_use]
236    pub fn add_custom_pass<F>(mut self, stage: HookStage, hook: F) -> Self
237    where
238        F: FnOnce(&mut RenderGraph<'a>, GraphBlackboard) -> GraphBlackboard + 'a,
239    {
240        self.hooks.push((stage, Some(Box::new(hook))));
241        self
242    }
243
244    /// Executes the full rendering pipeline and presents to the screen.
245    ///
246    /// # Architecture
247    ///
248    /// 1. **Acquire Surface** — get the swap-chain back buffer
249    /// 2. **Build RDG** — register resources, wire passes (compute, shadow,
250    ///    scene, post-processing), invoke user hooks
251    /// 3. **Compile & Execute** — topological sort, allocate transients,
252    ///    prepare, execute, submit
253    /// 4. **Present** — present swap-chain
254    ///
255    /// Consumes `self`; the composer cannot be reused after render.
256    pub fn render(mut self) {
257        // ━━━ 1. Acquire Surface ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
258        let output = match self.ctx.wgpu_ctx.surface.get_current_texture() {
259            // todo: handle `Outdated` by reconfiguring the surface and retrying acquisition?
260            wgpu::CurrentSurfaceTexture::Success(frame) => frame,
261            wgpu::CurrentSurfaceTexture::Suboptimal(frame) => {
262                self.ctx
263                    .wgpu_ctx
264                    .surface
265                    .configure(&self.ctx.wgpu_ctx.device, &self.ctx.wgpu_ctx.config);
266                frame
267            }
268            _ => {
269                log::error!("Failed to acquire swap-chain surface");
270                return;
271            }
272        };
273
274        let view_format = self.ctx.wgpu_ctx.surface_view_format;
275        let surface_view = output.texture.create_view(&wgpu::TextureViewDescriptor {
276            format: Some(view_format),
277            ..Default::default()
278        });
279        let width = output.texture.width();
280        let height = output.texture.height();
281
282        // ━━━ 2. Build Unified RDG ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
283
284        let mut graph = RenderGraph::new(self.ctx.graph_storage, self.ctx.frame_arena);
285
286        let surface_desc = TextureDesc::new_2d(
287            width,
288            height,
289            view_format,
290            wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
291        );
292
293        let surface_view_tracked = Tracked::with_id(surface_view, 0);
294
295        let surface_out =
296            graph.import_external_resource("Surface_View", surface_desc, &surface_view_tracked);
297
298        let mut graph_ctx = GraphBuilderContext {
299            graph: &mut graph,
300            pipeline_cache: self.ctx.pipeline_cache,
301            frame_config: &self.frame_config,
302        };
303
304        // ── 2a. Register Resources ──────────────────────────────────────
305        // Only the swapchain surface is truly external.
306        // Scene colour and depth are transient — owned and aliased by the RDG.
307
308        let is_high_fidelity = self.ctx.wgpu_ctx.render_path.supports_post_processing();
309        let msaa_samples = self.ctx.wgpu_ctx.msaa_samples;
310        let is_msaa = msaa_samples > 1;
311
312        // ── 2b. Scene Configuration ────────────────────────────────────
313
314        let ssao_enabled = self.ctx.scene.ssao.enabled && is_high_fidelity;
315        let needs_feature_id = is_high_fidelity
316            && (self.ctx.scene.screen_space.enable_sss || self.ctx.scene.screen_space.enable_ssr);
317        let needs_normal = ssao_enabled || needs_feature_id;
318        let needs_skybox = self.ctx.scene.background.needs_skybox_pass();
319        let ssss_enabled = self.ctx.scene.screen_space.enable_sss;
320        let has_transmission = self.ctx.render_lists.use_transmission;
321        let bloom_enabled = self.ctx.scene.bloom.enabled && is_high_fidelity;
322        // let fxaa_enabled = self.ctx.wgpu_ctx.fxaa_enabled && is_high_fidelity;
323        // let taa_enabled = self.ctx.wgpu_ctx.taa_enabled && is_high_fidelity;
324
325        // ── 2c. Wire Compute + Shadow Passes ───────────────────────────
326        graph_ctx.with_group("Compute", |c| {
327            if self.ctx.resource_manager.needs_brdf_compute {
328                self.ctx.brdf_pass.add_to_graph(c);
329            }
330
331            if let Some(source) = self.ctx.resource_manager.pending_ibl_source.take() {
332                self.ctx.ibl_pass.add_to_graph(c, source);
333            }
334        });
335
336        let shadow_tex = if self.ctx.extracted_scene.has_shadow_casters() {
337            graph_ctx.with_group("Shadow", |c| self.ctx.shadow_pass.add_to_graph(c))
338        } else {
339            None
340        };
341
342        // ── 2d. Wire Scene Rendering Passes (explicit data-flow) ──────
343        //
344        // Each pass's `add_to_graph` creates its own transient resources
345        // internally and returns typed output structs.  The Composer
346        // threads `TextureNodeId` values from producer to consumer —
347        // no blackboard lookups remain for mutable resources.
348
349        // Track scene_color / scene_depth for the GraphBlackboard (hooks).
350        let mut bb_scene_color = None;
351        let mut bb_scene_depth = None;
352
353        // Debug view: capture intermediate texture IDs for safe resolution.
354        #[cfg(feature = "debug_view")]
355        let mut dbg_normals: Option<crate::graph::core::TextureNodeId> = None;
356        #[cfg(feature = "debug_view")]
357        let mut dbg_velocity: Option<crate::graph::core::TextureNodeId> = None;
358        #[cfg(feature = "debug_view")]
359        let mut dbg_ssao: Option<crate::graph::core::TextureNodeId> = None;
360        #[cfg(feature = "debug_view")]
361        let mut dbg_bloom_color: Option<crate::graph::core::TextureNodeId> = None;
362
363        let mut current_surface = surface_out;
364
365        if is_high_fidelity {
366            // ────────────────────────────────────────────────────────────
367            // HighFidelity pipeline: separate passes, explicit wiring.
368            // ────────────────────────────────────────────────────────────
369
370            // ── Scene Rendering Group ──────────────────────────────────
371
372            let taa_enabled = self.ctx.camera.aa_mode.is_taa();
373
374            let cas_enabled = if let Some(s) = self.ctx.camera.aa_mode.taa_settings() {
375                s.sharpen_intensity > 0.0
376            } else {
377                false
378            };
379
380            let fxaa_enabled = self.ctx.camera.aa_mode.is_fxaa();
381
382            let (mut active_color, mut scene_depth) = graph_ctx.with_group("Scene", |c| {
383                // 1. Prepass
384                let prepass_out =
385                    self.ctx
386                        .prepass
387                        .add_to_graph(c, needs_normal, needs_feature_id, taa_enabled);
388
389                let scene_depth = prepass_out.scene_depth;
390
391                // 2. SSAO
392                let ssao_output = if ssao_enabled {
393                    Some(
394                        self.ctx.ssao_pass.add_to_graph(
395                            c,
396                            scene_depth,
397                            prepass_out
398                                .scene_normals
399                                .expect("SSAO requires scene normals from Prepass"),
400                        ),
401                    )
402                } else {
403                    None
404                };
405
406                // 3. Opaque
407                let opaque_out = self.ctx.opaque_pass.add_to_graph(
408                    c,
409                    scene_depth,
410                    self.ctx.extracted_scene.background.clear_color(),
411                    ssss_enabled,
412                    ssao_output,
413                    shadow_tex,
414                );
415
416                let mut active_color = opaque_out.active_color;
417
418                // 4. SSSS
419                if ssss_enabled {
420                    if is_msaa {
421                        let hdr_desc = TextureDesc::new_2d(
422                            c.frame_config.width,
423                            c.frame_config.height,
424                            crate::HDR_TEXTURE_FORMAT,
425                            wgpu::TextureUsages::RENDER_ATTACHMENT
426                                | wgpu::TextureUsages::TEXTURE_BINDING
427                                | wgpu::TextureUsages::COPY_SRC,
428                        );
429                        // If MSAA is enabled, resolve the opaque output to an intermediate non-MSAA texture for SSSS input.
430                        active_color = add_msaa_resolve_pass(c, active_color, hdr_desc);
431                    }
432
433                    active_color = self.ctx.ssss_pass.add_to_graph(
434                        c,
435                        active_color,
436                        prepass_out.scene_depth,
437                        prepass_out.scene_normals.unwrap(),
438                        prepass_out.feature_id.unwrap(),
439                        opaque_out.specular_mrt.unwrap(),
440                    );
441
442                    if is_msaa {
443                        // If MSAA is enabled, synchronize the SSSS output back to an MSAA texture for downstream passes (Skybox, Transparent) that expect MSAA input.
444                        // This avoids redundant MSAA resolve + re-multisample operations.
445                        active_color = self.ctx.msaa_sync_pass.add_to_graph(c, active_color);
446                    }
447                }
448
449                // 5. Skybox
450                if needs_skybox {
451                    active_color =
452                        self.ctx
453                            .skybox_pass
454                            .add_to_graph(c, active_color, opaque_out.active_depth);
455                }
456
457                // ── 6. TAA Resolve ────────────────────────────────────────────
458                // Resolve temporal anti-aliasing before bloom/tone-mapping.
459                // The resolved colour replaces post_transparent_color for
460                // downstream post-processing.
461                if taa_enabled && let Some(velocity) = prepass_out.velocity_buffer {
462                    c.with_group("TAA_System", |c| {
463                        active_color =
464                            self.ctx
465                                .taa_pass
466                                .add_to_graph(c, active_color, velocity, scene_depth);
467
468                        // ── 6b. CAS (Contrast Adaptive Sharpening) ────────────
469                        // Recover fine detail lost to temporal filtering.
470                        if cas_enabled {
471                            active_color = self.ctx.cas_pass.add_to_graph(c, active_color);
472                        }
473                    });
474                }
475
476                // 7. Transmission Copy
477                let transmission_tex = if has_transmission {
478                    Some(
479                        self.ctx
480                            .transmission_copy_pass
481                            .add_to_graph(c, active_color),
482                    )
483                } else {
484                    None
485                };
486
487                // 8. Transparent
488                let active_color = self.ctx.transparent_pass.add_to_graph(
489                    c,
490                    active_color,
491                    opaque_out.active_depth,
492                    transmission_tex,
493                    ssao_output,
494                    shadow_tex,
495                );
496
497                // Capture intermediate IDs for debug view resolution.
498                #[cfg(feature = "debug_view")]
499                {
500                    dbg_normals = prepass_out.scene_normals;
501                    dbg_velocity = prepass_out.velocity_buffer;
502                    dbg_ssao = ssao_output;
503                }
504
505                (active_color, scene_depth)
506            });
507
508            // ── Before-Post-Process Hooks ──────────────────────────────
509            {
510                let mut blackboard = GraphBlackboard {
511                    scene_color: Some(active_color),
512                    scene_depth: Some(scene_depth),
513                    surface_out,
514                };
515                for (stage, hook_opt) in &mut self.hooks {
516                    if *stage == HookStage::BeforePostProcess
517                        && let Some(hook) = hook_opt.take()
518                    {
519                        blackboard = hook(graph_ctx.graph, blackboard);
520                    }
521                }
522
523                active_color = blackboard.scene_color.unwrap_or(active_color);
524                scene_depth = blackboard.scene_depth.unwrap_or(scene_depth);
525            }
526
527            // ── Post-Processing Group ──────────────────────────────────
528            current_surface = graph_ctx.with_group("PostProcess", |ctx| {
529                // Bloom (internally flattened into Bloom_System subgroup)
530                if bloom_enabled {
531                    active_color = self.ctx.bloom_pass.add_to_graph(
532                        ctx,
533                        active_color,
534                        self.ctx.scene.bloom.karis_average,
535                        self.ctx.scene.bloom.max_mip_levels(),
536                    );
537                }
538
539                #[cfg(feature = "debug_view")]
540                {
541                    dbg_bloom_color = if bloom_enabled {
542                        Some(active_color)
543                    } else {
544                        None
545                    };
546                }
547
548                // ToneMapping: HDR → LDR
549                let mut surface = if fxaa_enabled {
550                    // Route through an intermediate LDR texture for FXAA input
551                    let ldr = ctx
552                        .graph
553                        .register_resource("LDR_Intermediate", surface_desc, false);
554                    self.ctx.tone_map_pass.add_to_graph(ctx, active_color, ldr)
555                } else {
556                    self.ctx
557                        .tone_map_pass
558                        .add_to_graph(ctx, active_color, current_surface)
559                };
560
561                // FXAA: anti-alias the LDR result onto the surface
562                if fxaa_enabled {
563                    let ldr_intermediate = surface;
564                    surface =
565                        self.ctx
566                            .fxaa_pass
567                            .add_to_graph(ctx, ldr_intermediate, current_surface);
568                }
569
570                bb_scene_color = Some(active_color);
571                bb_scene_depth = Some(scene_depth);
572
573                surface
574            });
575
576            // ── Debug View Override ────────────────────────────────────
577            // Resolve the semantic DebugViewTarget to a concrete
578            // TextureNodeId, then blit it onto the surface.  Targets
579            // whose producer was disabled (e.g. SSAO off) safely
580            // resolve to None — no pass is injected.
581            #[cfg(feature = "debug_view")]
582            {
583                use crate::graph::render_state::DebugViewTarget;
584                let target = self.ctx.render_state.debug_view_target;
585                let source: Option<crate::graph::core::TextureNodeId> = match target {
586                    DebugViewTarget::None => None,
587                    // SceneDepth is Depth32Float — incompatible with float
588                    // texture sampling.  A dedicated depth-copy-to-color
589                    // pass will be added in a future iteration.
590                    DebugViewTarget::SceneDepth => None,
591                    DebugViewTarget::SceneNormal => dbg_normals,
592                    DebugViewTarget::Velocity => dbg_velocity,
593                    DebugViewTarget::SsaoRaw => dbg_ssao,
594                    DebugViewTarget::BloomMip0 => dbg_bloom_color,
595                };
596
597                if let Some(src) = source {
598                    current_surface =
599                        self.ctx
600                            .debug_view_pass
601                            .add_to_graph(&mut graph_ctx, src, current_surface);
602                }
603            }
604        } else {
605            // BasicForward pipeline: single-pass LDR rendering.
606
607            let prepared_skybox = if needs_skybox {
608                let skybox_pipeline = self.ctx.skybox_pass.current_pipeline;
609                let skybox_bind_group = &self.ctx.skybox_pass.current_bind_group;
610
611                if let (Some(pipeline_id), Some(bg)) = (skybox_pipeline, skybox_bind_group) {
612                    Some(PreparedSkyboxDraw {
613                        pipeline: self.ctx.pipeline_cache.get_render_pipeline(pipeline_id),
614                        bind_group: bg,
615                    })
616                } else {
617                    None
618                }
619            } else {
620                None
621            };
622
623            graph_ctx.with_group("BasicForward", |c| {
624                self.ctx.simple_forward_pass.add_to_graph(
625                    c,
626                    surface_out,
627                    self.ctx.extracted_scene.background.clear_color(),
628                    prepared_skybox,
629                    shadow_tex,
630                );
631            });
632        }
633
634        // ── After-Post-Process Hooks (UI, debug overlays) ──────────────
635        {
636            let mut blackboard = GraphBlackboard {
637                scene_color: bb_scene_color,
638                scene_depth: bb_scene_depth,
639                surface_out: current_surface,
640            };
641            for (stage, hook_opt) in &mut self.hooks {
642                if *stage == HookStage::AfterPostProcess
643                    && let Some(hook) = hook_opt.take()
644                {
645                    blackboard = hook(&mut graph, blackboard);
646                }
647            }
648        }
649
650        // ━━━ 3. Compile & Execute RDG ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
651
652        graph.compile(self.ctx.transient_pool, &self.ctx.wgpu_ctx.device);
653
654        // ─── 3a. RDG Prepare: transient-only BindGroup assembly ────────
655        //
656        // Only the swapchain surface is truly external — all other textures
657        // (scene_color, scene_depth, etc.) are RDG transient resources.
658
659        let mut prepare_ctx = PrepareContext {
660            views: ViewResolver {
661                resources: &graph.storage.resources,
662                pool: self.ctx.transient_pool,
663            },
664            device: &self.ctx.wgpu_ctx.device,
665            queue: &self.ctx.wgpu_ctx.queue,
666            sampler_registry: &self.ctx.resource_manager.sampler_registry,
667            global_bind_group_cache: self.ctx.global_bind_group_cache,
668            system_textures: &self.ctx.resource_manager.system_textures,
669        };
670
671        for &pass_idx in &graph.storage.execution_queue {
672            let pass = graph.storage.passes[pass_idx].get_pass_mut();
673            pass.prepare(&mut prepare_ctx);
674        }
675
676        // ─── 3c. Bake render commands ──────────────────────────────────
677        //
678        // Resolve every asset handle (geometry, material, pipeline) to its
679        // physical wgpu reference.  After this point the execute phase is
680        // "blind" — it processes only pre-resolved GPU state.
681        let prepass_config = if is_high_fidelity {
682            Some(crate::graph::bake::PrepassBakeConfig {
683                local_cache: self.ctx.prepass.local_cache(),
684                needs_normal: self.ctx.prepass.needs_normal(),
685                needs_feature_id: self.ctx.prepass.needs_feature_id(),
686                needs_velocity: self.ctx.prepass.needs_velocity(),
687            })
688        } else {
689            None
690        };
691
692        let baked_lists = crate::graph::bake::bake_render_lists(
693            self.ctx.render_lists,
694            self.ctx.resource_manager,
695            self.ctx.pipeline_cache,
696            &prepass_config,
697        );
698
699        // ─── 3d. Execute ───────────────────────────────────────────────
700
701        let mut execute_ctx = ExecuteContext {
702            resources: &graph.storage.resources,
703            pool: self.ctx.transient_pool,
704            device: &self.ctx.wgpu_ctx.device,
705            queue: &self.ctx.wgpu_ctx.queue,
706            pipeline_cache: self.ctx.pipeline_cache,
707            global_bind_group_cache: self.ctx.global_bind_group_cache,
708            mipmap_generator: &self.ctx.resource_manager.mipmap_generator,
709            baked_lists: &baked_lists,
710            wgpu_ctx: &*self.ctx.wgpu_ctx,
711            current_timeline_index: 0,
712        };
713
714        let mut encoder =
715            self.ctx
716                .wgpu_ctx
717                .device
718                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
719                    label: Some("Unified Encoder"),
720                });
721
722        for (timeline_index, &pass_idx) in graph.storage.execution_queue.iter().enumerate() {
723            execute_ctx.current_timeline_index = timeline_index;
724            #[cfg(debug_assertions)]
725            encoder.push_debug_group(graph.storage.passes[pass_idx].name);
726            graph.storage.passes[pass_idx]
727                .get_pass_mut()
728                .execute(&execute_ctx, &mut encoder);
729            #[cfg(debug_assertions)]
730            encoder.pop_debug_group();
731        }
732
733        // ━━━ 4. Submit & Present ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
734
735        self.ctx.wgpu_ctx.queue.submit(Some(encoder.finish()));
736        output.present();
737    }
738}