Skip to main content

myth_render/
renderer.rs

1//! Rendering System
2//!
3//! The main [`Renderer`] struct orchestrating GPU rendering operations.
4
5use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
6
7use crate::core::binding::GlobalBindGroupCache;
8use crate::graph::composer::ComposerContext;
9use crate::graph::core::allocator::TransientPool;
10use crate::graph::core::arena::FrameArena;
11use crate::graph::core::graph::GraphStorage;
12use crate::graph::frame::RenderLists;
13#[cfg(feature = "debug_view")]
14use crate::graph::passes::DebugViewFeature;
15use crate::graph::passes::{
16    BloomFeature, BrdfLutFeature, CasFeature, FxaaFeature, IblComputeFeature, MsaaSyncFeature,
17    OpaqueFeature, PrepassFeature, ShadowFeature, SimpleForwardFeature, SkyboxFeature, SsaoFeature,
18    SsssFeature, TaaFeature, ToneMappingFeature, TransmissionCopyFeature, TransparentFeature,
19};
20use myth_assets::AssetServer;
21use myth_core::Result;
22use myth_scene::Scene;
23use myth_scene::camera::RenderCamera;
24
25use crate::core::{ResourceManager, WgpuContext};
26use crate::graph::{FrameComposer, RenderFrame};
27use crate::pipeline::PipelineCache;
28use crate::pipeline::ShaderManager;
29use crate::settings::{RenderPath, RendererInitConfig, RendererSettings};
30
31/// The main renderer responsible for GPU rendering operations.
32///
33/// The renderer manages the complete rendering pipeline including:
34/// - GPU context (device, queue, surface)
35/// - Resource management (buffers, textures, bind groups)
36/// - Pipeline caching (shader compilation, PSO creation)
37/// - Frame rendering (scene extraction, command submission)
38///
39/// # Lifecycle
40///
41/// 1. Create with [`Renderer::new`] (no GPU resources allocated)
42/// 2. Initialize GPU with [`Renderer::init`]
43/// 3. Render frames with [`Renderer::begin_frame`]
44/// 4. Clean up with [`Renderer::maybe_prune`]
45pub struct Renderer {
46    size: (u32, u32),
47    init_config: RendererInitConfig,
48    settings: RendererSettings,
49    context: Option<RendererState>,
50}
51
52/// Internal renderer state
53struct RendererState {
54    wgpu_ctx: WgpuContext,
55    resource_manager: ResourceManager,
56    pipeline_cache: PipelineCache,
57    shader_manager: ShaderManager,
58
59    render_frame: RenderFrame,
60    /// Render lists (separated from `render_frame` to avoid borrow conflicts)
61    render_lists: RenderLists,
62    // /// Frame blackboard (cross-pass transient data communication, cleared each frame)
63    // blackboard: FrameBlackboard,
64    global_bind_group_cache: GlobalBindGroupCache,
65
66    // ===== RDG (Declarative Render Graph) =====
67    pub(crate) graph_storage: GraphStorage,
68    // pub(crate) sampler_registry: SamplerRegistry,
69    pub(crate) transient_pool: TransientPool,
70    pub(crate) frame_arena: FrameArena,
71
72    // Post-processing passes
73    pub(crate) fxaa_pass: FxaaFeature,
74    pub(crate) taa_pass: TaaFeature,
75    pub(crate) cas_pass: CasFeature,
76    pub(crate) tone_map_pass: ToneMappingFeature,
77    pub(crate) bloom_pass: BloomFeature,
78    pub(crate) ssao_pass: SsaoFeature,
79
80    // Scene rendering passes
81    pub(crate) prepass: PrepassFeature,
82    pub(crate) opaque_pass: OpaqueFeature,
83    pub(crate) skybox_pass: SkyboxFeature,
84    pub(crate) transparent_pass: TransparentFeature,
85    pub(crate) transmission_copy_pass: TransmissionCopyFeature,
86    pub(crate) simple_forward_pass: SimpleForwardFeature,
87    pub(crate) ssss_pass: SsssFeature,
88    pub(crate) msaa_sync_pass: MsaaSyncFeature,
89
90    // Shadow + Compute passes (migrated from old system)
91    pub(crate) shadow_pass: ShadowFeature,
92    pub(crate) brdf_pass: BrdfLutFeature,
93    pub(crate) ibl_pass: IblComputeFeature,
94
95    // Debug view (compile-time gated)
96    #[cfg(feature = "debug_view")]
97    pub(crate) debug_view_pass: DebugViewFeature,
98
99    /// Cached staging buffer for synchronous `readback_pixels()`.
100    /// Re-used across calls when the required size has not changed.
101    cached_readback_buffer: Option<wgpu::Buffer>,
102    /// Size (in bytes) of the cached readback buffer.
103    cached_readback_buffer_size: u64,
104}
105
106#[derive(Debug, Clone, Copy, Default)]
107pub struct FrameTime {
108    pub time: f32,
109    pub delta_time: f32,
110    pub frame_count: u64,
111}
112
113impl Renderer {
114    /// Phase 1: Create configuration (no GPU resources yet).
115    ///
116    /// This only stores the render settings. GPU resources are
117    /// allocated when [`init`](Self::init) is called.
118    ///
119    /// # Arguments
120    ///
121    /// * `init_config` - Static GPU/device configuration (consumed at init time)
122    /// * `settings` - Runtime rendering settings (can be changed later via [`update_settings`](Self::update_settings))
123    #[must_use]
124    pub fn new(init_config: RendererInitConfig, settings: RendererSettings) -> Self {
125        Self {
126            init_config,
127            settings,
128            context: None,
129            size: (0, 0),
130        }
131    }
132
133    /// Returns the current surface size in pixels as `(width, height)`.
134    #[inline]
135    #[must_use]
136    pub fn size(&self) -> (u32, u32) {
137        self.size
138    }
139
140    /// Phase 2: Initialize GPU context with window handle.
141    ///
142    /// This method:
143    /// 1. Creates the wgpu instance and adapter
144    /// 2. Requests a device with required features/limits
145    /// 3. Configures the surface for presentation
146    /// 4. Initializes resource manager and pipeline cache
147    pub async fn init<W>(&mut self, window: W, width: u32, height: u32) -> Result<()>
148    where
149        W: HasWindowHandle + HasDisplayHandle + Send + Sync + 'static,
150    {
151        if self.context.is_some() {
152            return Ok(());
153        }
154
155        self.size = (width, height);
156
157        let wgpu_ctx =
158            WgpuContext::new(window, &self.init_config, &self.settings, width, height).await?;
159
160        self.assemble_state(wgpu_ctx);
161        log::info!("Renderer initialized (windowed)");
162        Ok(())
163    }
164
165    /// Phase 2 (headless): Initialize GPU context without a window.
166    ///
167    /// Creates an offscreen render target of the specified dimensions. No
168    /// window surface is created, making this suitable for server-side
169    /// rendering, automated testing, and GPU readback workflows.
170    ///
171    /// # Arguments
172    ///
173    /// * `width` — Render target width in pixels.
174    /// * `height` — Render target height in pixels.
175    /// * `format` — Desired pixel format. Pass `None` for the default
176    ///   `Rgba8Unorm` (sRGB). Use `Some(Rgba16Float)` for HDR readback.
177    pub async fn init_headless(
178        &mut self,
179        width: u32,
180        height: u32,
181        format: Option<myth_resources::PixelFormat>,
182    ) -> Result<()> {
183        if self.context.is_some() {
184            return Ok(());
185        }
186
187        self.size = (width, height);
188
189        let wgpu_format = format.map(|f| f.to_wgpu(myth_resources::ColorSpace::Srgb));
190
191        let wgpu_ctx = WgpuContext::new_headless(
192            &self.init_config,
193            &self.settings,
194            width,
195            height,
196            wgpu_format,
197        )
198        .await?;
199
200        self.assemble_state(wgpu_ctx);
201        log::info!("Renderer initialized (headless {width}×{height})");
202        Ok(())
203    }
204
205    /// Assembles the internal renderer state from a fully initialised GPU context.
206    fn assemble_state(&mut self, wgpu_ctx: WgpuContext) {
207        let resource_manager = ResourceManager::new(
208            wgpu_ctx.device.clone(),
209            wgpu_ctx.queue.clone(),
210            self.settings.anisotropy_clamp,
211        );
212
213        let render_frame = RenderFrame::new();
214        let global_bind_group_cache = GlobalBindGroupCache::new();
215
216        let shadow_pass = ShadowFeature::new(&wgpu_ctx.device);
217        let brdf_pass = BrdfLutFeature::new(&wgpu_ctx.device);
218        let ibl_pass = IblComputeFeature::new(&wgpu_ctx.device);
219
220        self.context = Some(RendererState {
221            wgpu_ctx,
222            resource_manager,
223            pipeline_cache: PipelineCache::new(),
224            shader_manager: ShaderManager::new(),
225
226            render_frame,
227            render_lists: RenderLists::new(),
228            global_bind_group_cache,
229
230            graph_storage: GraphStorage::new(),
231            transient_pool: TransientPool::new(),
232            frame_arena: FrameArena::new(),
233            fxaa_pass: FxaaFeature::new(),
234            taa_pass: TaaFeature::new(),
235            cas_pass: CasFeature::new(),
236            tone_map_pass: ToneMappingFeature::new(),
237            bloom_pass: BloomFeature::new(),
238            ssao_pass: SsaoFeature::new(),
239
240            prepass: PrepassFeature::new(),
241            opaque_pass: OpaqueFeature::new(),
242            skybox_pass: SkyboxFeature::new(),
243            transparent_pass: TransparentFeature::new(),
244            transmission_copy_pass: TransmissionCopyFeature::new(),
245            simple_forward_pass: SimpleForwardFeature::new(),
246            ssss_pass: SsssFeature::new(),
247            msaa_sync_pass: MsaaSyncFeature::new(),
248
249            shadow_pass,
250            brdf_pass,
251            ibl_pass,
252
253            #[cfg(feature = "debug_view")]
254            debug_view_pass: DebugViewFeature::new(),
255
256            cached_readback_buffer: None,
257            cached_readback_buffer_size: 0,
258        });
259    }
260
261    pub fn resize(&mut self, width: u32, height: u32) {
262        self.size = (width, height);
263        if let Some(state) = &mut self.context {
264            state.wgpu_ctx.resize(width, height);
265            // Invalidate all cached bind groups — texture views are now stale.
266            state.global_bind_group_cache.clear();
267        }
268    }
269
270    /// Begins building a new frame for rendering.
271    ///
272    /// Returns a [`FrameComposer`] that provides a chainable API for
273    /// configuring the render pipeline via custom pass hooks.
274    ///
275    /// # Usage
276    ///
277    /// ```rust,ignore
278    /// // Method 1: Use default built-in passes
279    /// if let Some(composer) = renderer.begin_frame(scene, camera, assets, time) {
280    ///     composer.render();
281    /// }
282    ///
283    /// // Method 2: With custom hooks (e.g., UI overlay)
284    /// if let Some(composer) = renderer.begin_frame(scene, camera, assets, time) {
285    ///     composer
286    ///         .add_custom_pass(HookStage::AfterPostProcess, |graph, bb| {
287    ///             ui_pass.target_tex = bb.surface_out;
288    ///             graph.add_pass(&mut ui_pass);
289    ///         })
290    ///         .render();
291    /// }
292    /// ```
293    ///
294    /// # Returns
295    ///
296    /// Returns `Some(FrameComposer)` if frame preparation succeeds,
297    /// or `None` if rendering should be skipped (e.g., window size is 0).
298    pub fn begin_frame<'a>(
299        &'a mut self,
300        scene: &'a mut Scene,
301        camera: &'a RenderCamera,
302        assets: &'a AssetServer,
303        frame_time: FrameTime,
304    ) -> Option<FrameComposer<'a>> {
305        if self.size.0 == 0 || self.size.1 == 0 {
306            return None;
307        }
308
309        let state = self.context.as_mut()?;
310
311        // ── Frame Arena Lifecycle ───────────────────────────────────────
312        // Reset the arena in O(1) — all previous PassNodes are trivially
313        // forgotten (no Drop needed).
314        state.frame_arena.reset();
315
316        // Advance the bind-group cache's frame counter for TTL tracking.
317        state.global_bind_group_cache.begin_frame();
318
319        // ── Phase 1: Extract scene, build shadow views, prepare global ──
320        let surface_size = state.wgpu_ctx.size();
321        state.render_frame.extract_and_prepare(
322            &mut state.resource_manager,
323            scene,
324            camera,
325            assets,
326            frame_time,
327            &mut state.render_lists,
328            surface_size,
329        );
330
331        let requested_msaa = camera.aa_mode.msaa_sample_count();
332        if state.wgpu_ctx.msaa_samples != requested_msaa {
333            state.wgpu_ctx.msaa_samples = requested_msaa;
334            state.wgpu_ctx.pipeline_settings_version += 1;
335        }
336
337        // ── Phase 2: Cull + sort + command generation ───────────────────
338        crate::graph::culling::cull_and_sort(
339            &state.render_frame.extracted_scene,
340            &state.render_frame.render_state,
341            &state.wgpu_ctx,
342            &mut state.resource_manager,
343            &mut state.pipeline_cache,
344            &mut state.shader_manager,
345            &mut state.render_lists,
346            camera,
347            assets,
348        );
349
350        // ── Phase 2.5: Feature extract & prepare ────────────────────────
351        //
352        // Resolve persistent GPU resources (pipelines, layouts, bind groups)
353        // BEFORE the render graph is built. This ensures all Features are
354        // fully prepared when their ephemeral PassNodes are created.
355        {
356            use crate::HDR_TEXTURE_FORMAT;
357            use crate::graph::core::context::ExtractContext;
358
359            let view_format = state.wgpu_ctx.surface_view_format;
360            let is_hf = state.wgpu_ctx.render_path.supports_post_processing();
361            let scene_id_val = scene.id();
362            let render_state_id = state.render_frame.render_state.id;
363            let global_state_key = (render_state_id, scene_id_val);
364
365            let ssao_enabled = scene.ssao.enabled && is_hf;
366            let needs_feature_id =
367                is_hf && (scene.screen_space.enable_sss || scene.screen_space.enable_ssr);
368
369            // Sync camera debug settings → RenderState before borrowing it.
370            #[cfg(feature = "debug_view")]
371            {
372                let dv = camera.debug_view;
373                state.render_frame.render_state.debug_view_mode = dv.mode;
374                state.render_frame.render_state.debug_view_scale = dv.custom_scale;
375            }
376
377            #[cfg(feature = "debug_view")]
378            let (dbg_needs_normal, dbg_needs_velocity) = {
379                use crate::graph::render_state::DebugViewTarget;
380                let target =
381                    DebugViewTarget::from_mode(state.render_frame.render_state.debug_view_mode);
382                (
383                    target == DebugViewTarget::SceneNormal,
384                    target == DebugViewTarget::Velocity,
385                )
386            };
387
388            #[cfg(not(feature = "debug_view"))]
389            let (dbg_needs_normal, dbg_needs_velocity) = (false, false);
390
391            let needs_normal = ssao_enabled || needs_feature_id || dbg_needs_normal;
392            let needs_velocity = camera.aa_mode.is_taa() || dbg_needs_velocity;
393
394            // let needs_normal = ssao_enabled || needs_feature_id;
395            let needs_skybox = scene.background.needs_skybox_pass();
396            let bloom_enabled = scene.bloom.enabled && is_hf;
397
398            let mut extract_ctx = ExtractContext {
399                device: &state.wgpu_ctx.device,
400                queue: &state.wgpu_ctx.queue,
401                pipeline_cache: &mut state.pipeline_cache,
402                shader_manager: &mut state.shader_manager,
403                global_bind_group_cache: &mut state.global_bind_group_cache,
404                resource_manager: &mut state.resource_manager,
405                wgpu_ctx: &state.wgpu_ctx,
406                render_lists: &mut state.render_lists,
407                extracted_scene: &state.render_frame.extracted_scene,
408                render_state: &state.render_frame.render_state,
409                render_camera: camera,
410                assets,
411            };
412
413            // Always: compute + shadow
414            state.brdf_pass.extract_and_prepare(&mut extract_ctx);
415            state.ibl_pass.extract_and_prepare(&mut extract_ctx);
416            state.shadow_pass.extract_and_prepare(&mut extract_ctx);
417
418            // Skybox (both pipelines)
419            if needs_skybox {
420                let color_format = if is_hf {
421                    HDR_TEXTURE_FORMAT
422                } else {
423                    view_format
424                };
425                state.skybox_pass.extract_and_prepare(
426                    &mut extract_ctx,
427                    &scene.background.mode,
428                    &scene.background.uniforms,
429                    global_state_key,
430                    color_format,
431                );
432            }
433
434            if is_hf {
435                if let Some(taa_settins) = camera.aa_mode.taa_settings() {
436                    state.taa_pass.extract_and_prepare(
437                        &mut extract_ctx,
438                        taa_settins.feedback_weight,
439                        self.size,
440                        HDR_TEXTURE_FORMAT,
441                    );
442
443                    if taa_settins.sharpen_intensity > 0.0 {
444                        state.cas_pass.extract_and_prepare(
445                            &mut extract_ctx,
446                            taa_settins.sharpen_intensity,
447                            HDR_TEXTURE_FORMAT,
448                        );
449                    }
450                }
451
452                if let Some(fxaa_settings) = camera.aa_mode.fxaa_settings() {
453                    state.fxaa_pass.target_quality = fxaa_settings.quality();
454                    state
455                        .fxaa_pass
456                        .extract_and_prepare(&mut extract_ctx, view_format);
457                }
458
459                state.prepass.extract_and_prepare(
460                    &mut extract_ctx,
461                    needs_normal,
462                    needs_feature_id,
463                    needs_velocity,
464                );
465
466                if ssao_enabled {
467                    state
468                        .ssao_pass
469                        .extract_and_prepare(&mut extract_ctx, &scene.ssao.uniforms);
470                }
471
472                state.ssss_pass.extract_and_prepare(&mut extract_ctx);
473
474                // MSAA Sync — needed when SSSS modifies the resolved HDR
475                // buffer and subsequent passes re-enter the MSAA context.
476                let msaa = state.wgpu_ctx.msaa_samples;
477                let needs_specular = scene.screen_space.enable_sss;
478                if msaa > 1 && needs_specular {
479                    state
480                        .msaa_sync_pass
481                        .extract_and_prepare(&mut extract_ctx, msaa);
482                }
483
484                if bloom_enabled {
485                    state.bloom_pass.extract_and_prepare(
486                        &mut extract_ctx,
487                        &scene.bloom.upsample_uniforms,
488                        &scene.bloom.composite_uniforms,
489                    );
490                }
491
492                state.tone_map_pass.extract_and_prepare(
493                    &mut extract_ctx,
494                    scene.tone_mapping.mode,
495                    view_format,
496                    global_state_key,
497                    &scene.tone_mapping.uniforms,
498                    scene.tone_mapping.lut_texture,
499                );
500
501                // Debug View — prepare pipeline & uniforms when active
502                #[cfg(feature = "debug_view")]
503                {
504                    use crate::graph::passes::debug_view::DebugViewUniforms;
505                    use crate::graph::render_state::DebugViewTarget;
506
507                    let dv = camera.debug_view;
508                    let target = DebugViewTarget::from_mode(dv.mode);
509                    if target != DebugViewTarget::None {
510                        let params = DebugViewUniforms {
511                            view_mode: target.view_mode(),
512                            custom_scale: dv.custom_scale,
513                            z_near: camera.near,
514                            z_far: if camera.far.is_infinite() {
515                                10000.0
516                            } else {
517                                camera.far
518                            },
519                        };
520                        let is_depth = target == DebugViewTarget::SceneDepth;
521                        state.debug_view_pass.extract_and_prepare(
522                            &mut extract_ctx,
523                            view_format,
524                            params,
525                            is_depth,
526                        );
527                    }
528                }
529            }
530        }
531
532        // ── Phase 3: Build ComposerContext ──────────────────────────────
533        let ctx = ComposerContext {
534            wgpu_ctx: &mut state.wgpu_ctx,
535            resource_manager: &mut state.resource_manager,
536            pipeline_cache: &mut state.pipeline_cache,
537            shader_manager: &mut state.shader_manager,
538
539            extracted_scene: &state.render_frame.extracted_scene,
540            render_state: &state.render_frame.render_state,
541
542            global_bind_group_cache: &mut state.global_bind_group_cache,
543
544            render_lists: &mut state.render_lists,
545
546            // blackboard: &mut state.blackboard,
547            scene,
548            camera,
549            assets,
550            frame_time,
551
552            graph_storage: &mut state.graph_storage,
553            transient_pool: &mut state.transient_pool,
554            // sampler_registry: &mut state.sampler_registry,
555            frame_arena: &state.frame_arena,
556            fxaa_pass: &mut state.fxaa_pass,
557            taa_pass: &mut state.taa_pass,
558            cas_pass: &mut state.cas_pass,
559            tone_map_pass: &mut state.tone_map_pass,
560            bloom_pass: &mut state.bloom_pass,
561            ssao_pass: &mut state.ssao_pass,
562
563            prepass: &mut state.prepass,
564            opaque_pass: &mut state.opaque_pass,
565            skybox_pass: &mut state.skybox_pass,
566            transparent_pass: &mut state.transparent_pass,
567            transmission_copy_pass: &mut state.transmission_copy_pass,
568            simple_forward_pass: &mut state.simple_forward_pass,
569            ssss_pass: &mut state.ssss_pass,
570            msaa_sync_pass: &mut state.msaa_sync_pass,
571
572            shadow_pass: &mut state.shadow_pass,
573            brdf_pass: &mut state.brdf_pass,
574            ibl_pass: &mut state.ibl_pass,
575
576            #[cfg(feature = "debug_view")]
577            debug_view_pass: &mut state.debug_view_pass,
578        };
579
580        // Return FrameComposer, defer Surface acquisition to render() call
581        Some(FrameComposer::new(ctx, self.size))
582    }
583
584    /// Performs periodic resource cleanup.
585    ///
586    /// Should be called after each frame to release unused GPU resources.
587    /// Uses internal heuristics to avoid expensive cleanup every frame.
588    pub fn maybe_prune(&mut self) {
589        if let Some(state) = &mut self.context {
590            state.render_frame.maybe_prune(&mut state.resource_manager);
591            // Evict stale bind groups that haven't been touched recently.
592            state.global_bind_group_cache.garbage_collect();
593        }
594    }
595
596    // === Runtime Settings API ===
597
598    /// Returns the current [`RenderPath`].
599    #[inline]
600    pub fn render_path(&self) -> &RenderPath {
601        &self.settings.path
602    }
603
604    /// Returns a reference to the current runtime renderer settings.
605    #[inline]
606    pub fn settings(&self) -> &RendererSettings {
607        &self.settings
608    }
609
610    /// Returns a reference to the init-time configuration.
611    #[inline]
612    pub fn init_config(&self) -> &RendererInitConfig {
613        &self.init_config
614    }
615
616    /// Applies new runtime settings, performing an internal diff to update
617    /// only the parts that actually changed.
618    ///
619    /// This is the **single entry point** for all runtime configuration
620    /// changes. Callers (UI panels, scripting layers, etc.) should maintain
621    /// their own [`RendererSettings`] instance, mutate it, and pass it here.
622    pub fn update_settings(&mut self, new_settings: RendererSettings) {
623        if self.settings == new_settings {
624            return;
625        }
626
627        let old = std::mem::replace(&mut self.settings, new_settings);
628
629        if let Some(state) = &mut self.context {
630            // VSync
631            if old.vsync != self.settings.vsync {
632                state.wgpu_ctx.set_vsync(self.settings.vsync);
633            }
634
635            // Render path
636            if old.path != self.settings.path {
637                state.wgpu_ctx.render_path = self.settings.path;
638                state.wgpu_ctx.pipeline_settings_version += 1;
639                log::info!("RenderPath changed to {:?}", self.settings.path);
640            }
641
642            // Anisotropy
643            if old.anisotropy_clamp != self.settings.anisotropy_clamp {
644                state
645                    .resource_manager
646                    .sampler_registry
647                    .set_global_anisotropy(self.settings.anisotropy_clamp);
648                log::info!(
649                    "Anisotropy clamp changed to {}",
650                    self.settings.anisotropy_clamp
651                );
652            }
653        }
654    }
655
656    /// Switches the active render path at runtime.
657    ///
658    /// Convenience wrapper around [`update_settings`](Self::update_settings)
659    /// for changing only the render path.
660    pub fn set_render_path(&mut self, path: RenderPath) {
661        if self.settings.path != path {
662            let mut new = self.settings.clone();
663            new.path = path;
664            self.update_settings(new);
665        }
666    }
667
668    /// Sets the active debug view mode.
669    ///
670    /// When set to anything other than `None`, the FrameComposer will
671    /// either replace the post-process output with a fullscreen
672    /// visualisation of the selected intermediate texture (post-process
673    /// modes), or inject shader defines to short-circuit PBR lighting
674    /// and output raw material attributes (material-override modes).
675    #[cfg(feature = "debug_view")]
676    pub fn set_debug_view_mode(&mut self, mode: myth_scene::camera::DebugViewMode) {
677        if let Some(state) = &mut self.context {
678            state.render_frame.render_state.debug_view_mode = mode;
679        }
680    }
681
682    /// Returns the current debug view mode.
683    #[cfg(feature = "debug_view")]
684    pub fn debug_view_mode(&self) -> myth_scene::camera::DebugViewMode {
685        self.context
686            .as_ref()
687            .map(|s| s.render_frame.render_state.debug_view_mode)
688            .unwrap_or_default()
689    }
690
691    // === Public Methods: For External Plugins (e.g., UI Pass) ===
692
693    /// Returns a reference to the wgpu Device.
694    ///
695    /// Useful for external plugins to initialize GPU resources.
696    pub fn device(&self) -> Option<&wgpu::Device> {
697        self.context.as_ref().map(|s| &s.wgpu_ctx.device)
698    }
699
700    /// Returns a reference to the wgpu Queue.
701    ///
702    /// Useful for external plugins to submit commands.
703    pub fn queue(&self) -> Option<&wgpu::Queue> {
704        self.context.as_ref().map(|s| &s.wgpu_ctx.queue)
705    }
706
707    /// Returns the surface/render-target texture format.
708    ///
709    /// In windowed mode this is the swap-chain format; in headless mode it
710    /// is the offscreen texture format. Returns `None` before initialisation.
711    pub fn surface_format(&self) -> Option<wgpu::TextureFormat> {
712        self.context
713            .as_ref()
714            .map(|s| s.wgpu_ctx.surface_view_format)
715    }
716
717    /// Returns a reference to the `WgpuContext`.
718    ///
719    /// For external plugins that need access to low-level GPU resources.
720    /// Only available after renderer initialization.
721    pub fn wgpu_ctx(&self) -> Option<&WgpuContext> {
722        self.context.as_ref().map(|s| &s.wgpu_ctx)
723    }
724
725    pub fn dump_graph_mermaid(&self) -> Option<String> {
726        self.context
727            .as_ref()
728            .map(|s| s.graph_storage.dump_mermaid())
729    }
730
731    // === Custom Shader Registration API ===
732
733    /// Registers a custom WGSL shader template with the given name.
734    ///
735    /// The source string is pre-processed by the minijinja template engine at
736    /// compile time, so `{$ include "chunks/camera_uniforms.wgsl" $}` and
737    /// similar directives are fully supported.
738    ///
739    /// # Usage
740    ///
741    /// ```rust,ignore
742    /// renderer.register_shader_template(
743    ///     "custom_unlit",
744    ///     include_str!("shaders/custom_unlit.wgsl"),
745    /// );
746    /// ```
747    ///
748    /// After registration, any material declared with
749    /// `#[myth_material(shader = "custom_unlit")]` will use this template.
750    ///
751    /// # Panics
752    ///
753    /// Panics if the renderer has not been initialized via [`init`](Self::init).
754    pub fn register_shader_template(&mut self, name: &str, source: &str) {
755        let state = self
756            .context
757            .as_mut()
758            .expect("Renderer must be initialized before registering shader templates");
759        state.shader_manager.register_template(name, source);
760    }
761
762    /// Returns `true` if the renderer is in headless (offscreen) mode.
763    #[inline]
764    #[must_use]
765    pub fn is_headless(&self) -> bool {
766        self.context
767            .as_ref()
768            .is_some_and(|s| s.wgpu_ctx.is_headless())
769    }
770
771    /// Reads back the current headless render target as raw pixel data.
772    ///
773    /// The returned `Vec<u8>` contains tightly-packed pixel data whose per-pixel
774    /// byte count matches the headless texture format (e.g. 4 bytes for RGBA8,
775    /// 8 bytes for RGBA16Float). Row ordering is top-to-bottom.
776    ///
777    /// A staging buffer is cached internally and re-used across calls as long
778    /// as the required size has not changed, eliminating per-frame allocation.
779    ///
780    /// This method submits a GPU copy command and blocks the calling thread
781    /// until the transfer completes.
782    ///
783    /// # Errors
784    ///
785    /// Returns an error if:
786    /// - The renderer has not been initialised
787    /// - No headless render target exists (windowed mode)
788    /// - The GPU buffer mapping fails
789    pub fn readback_pixels(&mut self) -> Result<Vec<u8>> {
790        let state = self
791            .context
792            .as_mut()
793            .ok_or(myth_core::RenderError::NotInitialized)?;
794
795        let texture = state
796            .wgpu_ctx
797            .headless_texture
798            .as_ref()
799            .ok_or(myth_core::RenderError::NoHeadlessTarget)?;
800
801        let width = state.wgpu_ctx.target_width;
802        let height = state.wgpu_ctx.target_height;
803        let format = state.wgpu_ctx.surface_view_format;
804
805        let bytes_per_pixel = format.block_copy_size(None).ok_or_else(|| {
806            myth_core::RenderError::ReadbackFailed(format!(
807                "unsupported readback format: {format:?}"
808            ))
809        })?;
810
811        let unpadded_bytes_per_row = width * bytes_per_pixel;
812        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
813        let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
814        let buffer_size = u64::from(padded_bytes_per_row) * u64::from(height);
815
816        // Re-use the cached buffer when the required capacity matches.
817        if state.cached_readback_buffer_size != buffer_size {
818            state.cached_readback_buffer = Some(state.wgpu_ctx.device.create_buffer(
819                &wgpu::BufferDescriptor {
820                    label: Some("Readback Buffer"),
821                    size: buffer_size,
822                    usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
823                    mapped_at_creation: false,
824                },
825            ));
826            state.cached_readback_buffer_size = buffer_size;
827        }
828
829        let readback_buffer = state.cached_readback_buffer.as_ref().unwrap();
830
831        let mut encoder =
832            state
833                .wgpu_ctx
834                .device
835                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
836                    label: Some("Readback Encoder"),
837                });
838
839        encoder.copy_texture_to_buffer(
840            wgpu::TexelCopyTextureInfo {
841                texture,
842                mip_level: 0,
843                origin: wgpu::Origin3d::ZERO,
844                aspect: wgpu::TextureAspect::All,
845            },
846            wgpu::TexelCopyBufferInfo {
847                buffer: readback_buffer,
848                layout: wgpu::TexelCopyBufferLayout {
849                    offset: 0,
850                    bytes_per_row: Some(padded_bytes_per_row),
851                    rows_per_image: Some(height),
852                },
853            },
854            wgpu::Extent3d {
855                width,
856                height,
857                depth_or_array_layers: 1,
858            },
859        );
860
861        state
862            .wgpu_ctx
863            .queue
864            .submit(std::iter::once(encoder.finish()));
865
866        // Block until the GPU finishes the copy and the buffer is mappable.
867        let buffer_slice = readback_buffer.slice(..);
868
869        let (tx, rx) = std::sync::mpsc::sync_channel(1);
870        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
871            tx.send(result).ok();
872        });
873        state
874            .wgpu_ctx
875            .device
876            .poll(wgpu::PollType::wait_indefinitely())
877            .map_err(|e| myth_core::RenderError::ReadbackFailed(e.to_string()))?;
878
879        rx.recv()
880            .map_err(|e| myth_core::RenderError::ReadbackFailed(e.to_string()))?
881            .map_err(|e| myth_core::RenderError::ReadbackFailed(e.to_string()))?;
882
883        // Strip per-row padding and produce a tightly-packed pixel buffer.
884        let mapped = buffer_slice.get_mapped_range();
885        let mut pixels = Vec::with_capacity((width * height * bytes_per_pixel) as usize);
886        for row in 0..height {
887            let start = (row * padded_bytes_per_row) as usize;
888            let end = start + unpadded_bytes_per_row as usize;
889            pixels.extend_from_slice(&mapped[start..end]);
890        }
891        drop(mapped);
892        readback_buffer.unmap();
893
894        Ok(pixels)
895    }
896
897    /// Creates a [`ReadbackStream`] backed by the headless render target.
898    ///
899    /// The stream pre-allocates `buffer_count` staging buffers that rotate in
900    /// a ring, enabling fully non-blocking GPU→CPU readback suitable for
901    /// video recording and AI training-data pipelines.
902    ///
903    /// # Errors
904    ///
905    /// Returns an error if the renderer is not initialised or not in headless
906    /// mode, or if the texture format does not support readback.
907    pub fn create_readback_stream(
908        &self,
909        buffer_count: usize,
910        max_stash_size: usize,
911    ) -> Result<crate::core::ReadbackStream> {
912        let state = self
913            .context
914            .as_ref()
915            .ok_or(myth_core::RenderError::NotInitialized)?;
916
917        if state.wgpu_ctx.headless_texture.is_none() {
918            return Err(myth_core::RenderError::NoHeadlessTarget.into());
919        }
920
921        let width = state.wgpu_ctx.target_width;
922        let height = state.wgpu_ctx.target_height;
923        let format = state.wgpu_ctx.surface_view_format;
924
925        let stream = crate::core::ReadbackStream::new(
926            &state.wgpu_ctx.device,
927            width,
928            height,
929            format,
930            buffer_count,
931            max_stash_size,
932        )?;
933
934        Ok(stream)
935    }
936
937    /// Drives pending GPU callbacks without blocking.
938    ///
939    /// Call this once per frame in a readback-stream loop so that `map_async`
940    /// callbacks fire and frames become available via
941    /// [`ReadbackStream::try_recv`].
942    pub fn poll_device(&self) {
943        if let Some(state) = &self.context {
944            let _ = state.wgpu_ctx.device.poll(wgpu::PollType::Poll);
945        }
946    }
947
948    /// Returns a reference to the headless render target texture, if present.
949    #[must_use]
950    pub fn headless_texture(&self) -> Option<&wgpu::Texture> {
951        self.context
952            .as_ref()
953            .and_then(|s| s.wgpu_ctx.headless_texture.as_ref())
954    }
955}