Skip to main content

khora_infra/graphics/wgpu/
system.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! The concrete, WGPU-based implementation of the `RenderSystem` trait.
16
17use crate::telemetry::gpu_monitor::GpuMonitor;
18
19use super::backend::WgpuBackendSelector;
20use super::context::WgpuGraphicsContext;
21use super::device::WgpuDevice;
22use super::profiler::WgpuTimestampProfiler;
23use super::resilience::{
24    classify_acquire, surface_is_renderable, AcquireAction, SurfaceAcquireStatus,
25};
26use khora_core::math::LinearRgba;
27use khora_core::platform::window::{KhoraWindow, KhoraWindowHandle};
28use khora_core::renderer::api::command::{
29    BindGroupId, BindGroupLayoutId, LoadOp, Operations, RenderPassColorAttachment,
30    RenderPassDescriptor, StoreOp,
31};
32use khora_core::renderer::api::core::{
33    BackendSelectionConfig, GraphicsAdapterInfo, RenderSettings, RenderStats,
34};
35use khora_core::renderer::api::resource::{
36    BufferId, ImageAspect, TextureDescriptor, TextureDimension, TextureId, TextureUsage,
37    TextureViewDescriptor, TextureViewId, ViewInfo,
38};
39use khora_core::renderer::api::scene::RenderObject;
40use khora_core::renderer::api::util::ShaderStageFlags;
41use khora_core::renderer::api::util::{IndexFormat, SampleCount, TextureFormat};
42use khora_core::renderer::traits::{
43    FrameTargets, GpuProfiler, GraphicsBackendSelector, RenderSystem,
44};
45use khora_core::renderer::{GraphicsDevice, RenderError};
46use khora_core::telemetry::ResourceMonitor;
47use khora_core::Stopwatch;
48use std::fmt;
49use std::sync::{Arc, Mutex};
50use std::time::Instant;
51use winit::dpi::PhysicalSize;
52
53/// The concrete, WGPU-based implementation of the [`RenderSystem`] trait.
54///
55/// This struct encapsulates all the state necessary to drive rendering with WGPU,
56/// including the graphics context, the logical device, GPU profiler, and complex
57/// state for handling window resizing gracefully.
58///
59/// It acts as the primary rendering backend for the engine when the WGPU feature is enabled.
60pub struct WgpuRenderSystem {
61    graphics_context_shared: Option<Arc<Mutex<WgpuGraphicsContext>>>,
62    wgpu_device: Option<Arc<WgpuDevice>>,
63    gpu_monitor: Option<Arc<GpuMonitor>>,
64    current_width: u32,
65    current_height: u32,
66    frame_count: u64,
67    last_frame_stats: RenderStats,
68    gpu_profiler: Option<Box<dyn GpuProfiler>>,
69    current_frame_view_id: Option<TextureViewId>,
70
71    // --- Camera Uniform Resources ---
72    camera_uniform_buffer: Option<BufferId>,
73    camera_bind_group: Option<BindGroupId>,
74    camera_bind_group_layout: Option<BindGroupLayoutId>,
75
76    // --- Depth Buffer Resources ---
77    depth_texture: Option<TextureId>,
78    depth_texture_view: Option<TextureViewId>,
79
80    // --- Frame lifecycle ---
81    /// Surface texture acquired by `begin_frame()`, consumed by `end_frame()`.
82    active_frame_texture: Option<wgpu::SurfaceTexture>,
83
84    // --- Resize Heuristics State ---
85    last_resize_event: Option<Instant>,
86    pending_resize: bool,
87    last_surface_config: Option<Instant>,
88    pending_resize_frames: u32,
89    last_pending_size: Option<(u32, u32)>,
90    stable_size_frame_count: u32,
91
92    // --- Offscreen Viewport ---
93    viewport_texture: Option<wgpu::Texture>,
94    viewport_view: Option<wgpu::TextureView>,
95    viewport_depth_texture: Option<wgpu::Texture>,
96    viewport_depth_view: Option<wgpu::TextureView>,
97    viewport_width: u32,
98    viewport_height: u32,
99    /// Registered abstract view IDs for the viewport (returned by `begin_frame` when
100    /// `render_to_viewport == true`).
101    viewport_color_view_id: Option<khora_core::renderer::api::resource::TextureViewId>,
102    viewport_depth_view_id: Option<khora_core::renderer::api::resource::TextureViewId>,
103    /// When true, `begin_frame` returns viewport targets instead of the swapchain
104    /// and the engine skips its own present (caller manages the viewport).
105    render_to_viewport: bool,
106    // Grid + gizmo rendering moved to the engine-side `GridLane` /
107    // `GizmoLane` (under `OverlayAgent`) — the backend owns no
108    // render-strategy pipelines.
109}
110
111impl fmt::Debug for WgpuRenderSystem {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        f.debug_struct("WgpuRenderSystem")
114            .field("graphics_context_shared", &self.graphics_context_shared)
115            .field("wgpu_device", &self.wgpu_device)
116            .field("gpu_monitor", &self.gpu_monitor)
117            .field("current_width", &self.current_width)
118            .field("current_height", &self.current_height)
119            .field("frame_count", &self.frame_count)
120            .field("last_frame_stats", &self.last_frame_stats)
121            .field(
122                "gpu_profiler",
123                &self.gpu_profiler.as_ref().map(|_| "GpuProfiler(...)"),
124            )
125            .field("current_frame_view_id", &self.current_frame_view_id)
126            .field(
127                "camera_uniform_buffer",
128                &self.camera_uniform_buffer.as_ref().map(|_| "Buffer(...)"),
129            )
130            .field(
131                "camera_bind_group",
132                &self.camera_bind_group.as_ref().map(|_| "BindGroup(...)"),
133            )
134            .field(
135                "camera_bind_group_layout",
136                &self
137                    .camera_bind_group_layout
138                    .as_ref()
139                    .map(|_| "BindGroupLayout(...)"),
140            )
141            .finish()
142    }
143}
144
145impl Default for WgpuRenderSystem {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151impl WgpuRenderSystem {
152    /// Creates a new, uninitialized `WgpuRenderSystem`.
153    ///
154    /// The system is not usable until [`RenderSystem::init`] is called.
155    pub fn new() -> Self {
156        log::info!("WgpuRenderSystem created (uninitialized).");
157        Self {
158            graphics_context_shared: None,
159            wgpu_device: None,
160            gpu_monitor: None,
161            current_width: 0,
162            current_height: 0,
163            frame_count: 0,
164            last_frame_stats: RenderStats::default(),
165            gpu_profiler: None,
166            current_frame_view_id: None,
167            camera_uniform_buffer: None,
168            camera_bind_group: None,
169            camera_bind_group_layout: None,
170            depth_texture: None,
171            depth_texture_view: None,
172            active_frame_texture: None,
173            last_resize_event: None,
174            pending_resize: false,
175            last_surface_config: None,
176            pending_resize_frames: 0,
177            last_pending_size: None,
178            stable_size_frame_count: 0,
179            viewport_texture: None,
180            viewport_view: None,
181            viewport_depth_texture: None,
182            viewport_depth_view: None,
183            viewport_width: 0,
184            viewport_height: 0,
185            viewport_color_view_id: None,
186            viewport_depth_view_id: None,
187            render_to_viewport: false,
188        }
189    }
190
191    async fn initialize(
192        &mut self,
193        window_handle: KhoraWindowHandle,
194        window_size: PhysicalSize<u32>,
195    ) -> Result<Vec<Arc<dyn ResourceMonitor>>, RenderError> {
196        if self.graphics_context_shared.is_some() {
197            return Err(RenderError::InitializationFailed(
198                "WgpuRenderSystem is already initialized.".to_string(),
199            ));
200        }
201        log::info!("WgpuRenderSystem: Initializing...");
202
203        // wgpu 29 requires the display handle to be registered with the Instance up
204        // front — surfaces created later are validated against it. Hand the window's
205        // Arc directly; it implements `HasDisplayHandle + Debug + Send + Sync + 'static`,
206        // which satisfies `wgpu::WgpuHasDisplayHandle`.
207        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
208            Box::new(window_handle.clone()),
209        ));
210        let backend_selector = WgpuBackendSelector::new(instance.clone());
211        let selection_config = BackendSelectionConfig::default();
212
213        let selection_result = backend_selector
214            .select_backend(&selection_config)
215            .await
216            .map_err(|e| RenderError::InitializationFailed(e.to_string()))?;
217        let adapter = selection_result.adapter;
218
219        let context = WgpuGraphicsContext::new(&instance, window_handle, adapter, window_size)
220            .await
221            .map_err(|e| RenderError::InitializationFailed(e.to_string()))?;
222
223        self.current_width = context.get_size().0;
224        self.current_height = context.get_size().1;
225        let context_arc = Arc::new(Mutex::new(context));
226        self.graphics_context_shared = Some(context_arc.clone());
227
228        log::info!(
229            "WgpuRenderSystem: GraphicsContext created with size: {}x{}",
230            self.current_width,
231            self.current_height
232        );
233
234        let graphics_device = WgpuDevice::new(context_arc.clone());
235        let device_arc = Arc::new(graphics_device);
236        self.wgpu_device = Some(device_arc.clone());
237
238        if let Ok(gc_guard) = context_arc.lock() {
239            if WgpuTimestampProfiler::feature_available(gc_guard.active_device_features) {
240                if let Some(mut profiler) = WgpuTimestampProfiler::new(&gc_guard.device) {
241                    let period = gc_guard.queue.get_timestamp_period();
242                    profiler.set_timestamp_period(period);
243                    self.gpu_profiler = Some(Box::new(profiler));
244                }
245            } else {
246                log::info!("GPU timestamp feature not available; instrumentation disabled.");
247            }
248        }
249
250        let mut created_monitors: Vec<Arc<dyn ResourceMonitor>> = Vec::new();
251        let gpu_monitor = Arc::new(GpuMonitor::new("WGPU".to_string()));
252        created_monitors.push(gpu_monitor.clone());
253        self.gpu_monitor = Some(gpu_monitor);
254
255        let vram_monitor = device_arc as Arc<dyn ResourceMonitor>;
256        created_monitors.push(vram_monitor);
257
258        // Initialize camera uniform resources
259        self.initialize_camera_uniforms()?;
260
261        // Initialize depth texture for depth buffering
262        self.create_depth_texture()?;
263
264        Ok(created_monitors)
265    }
266
267    /// Initializes the camera uniform buffer and bind group.
268    ///
269    /// This creates:
270    /// - A uniform buffer to hold camera data (view-projection matrix and camera position)
271    /// - A bind group layout describing the shader resource binding
272    /// - A bind group that binds the buffer to group 0, binding 0
273    fn initialize_camera_uniforms(&mut self) -> Result<(), RenderError> {
274        use khora_core::renderer::api::command::{
275            BindGroupDescriptor, BindGroupEntry, BindGroupLayoutDescriptor, BindGroupLayoutEntry,
276            BindingResource, BindingType, BufferBinding, BufferBindingType,
277        };
278        use khora_core::renderer::api::resource::{
279            BufferDescriptor, BufferUsage, CameraUniformData,
280        };
281
282        let device = self.wgpu_device.as_ref().ok_or_else(|| {
283            RenderError::InitializationFailed("WGPU device not initialized".to_string())
284        })?;
285
286        let buffer_size = std::mem::size_of::<CameraUniformData>() as u64;
287
288        // Create the uniform buffer using the abstract API
289        let buffer_descriptor = BufferDescriptor {
290            label: Some(std::borrow::Cow::Borrowed("Camera Uniform Buffer")),
291            size: buffer_size,
292            usage: BufferUsage::UNIFORM | BufferUsage::COPY_DST,
293            mapped_at_creation: false,
294        };
295
296        let uniform_buffer = device.create_buffer(&buffer_descriptor).map_err(|e| {
297            RenderError::InitializationFailed(format!(
298                "Failed to create camera uniform buffer: {:?}",
299                e
300            ))
301        })?;
302
303        // Create the bind group layout using the abstract API
304        let layout_entry = BindGroupLayoutEntry {
305            binding: 0,
306            visibility: ShaderStageFlags::VERTEX | ShaderStageFlags::FRAGMENT,
307            ty: BindingType::Buffer {
308                ty: BufferBindingType::Uniform,
309                has_dynamic_offset: false,
310                min_binding_size: None,
311            },
312        };
313
314        let layout_descriptor = BindGroupLayoutDescriptor {
315            label: Some("Camera Bind Group Layout"),
316            entries: &[layout_entry],
317        };
318
319        let bind_group_layout = device
320            .create_bind_group_layout(&layout_descriptor)
321            .map_err(|e| {
322                RenderError::InitializationFailed(format!(
323                    "Failed to create camera bind group layout: {:?}",
324                    e
325                ))
326            })?;
327
328        // Create the bind group using the abstract API
329        let bind_group_entry = BindGroupEntry {
330            binding: 0,
331            resource: BindingResource::Buffer(BufferBinding {
332                buffer: uniform_buffer,
333                offset: 0,
334                size: None,
335            }),
336            _phantom: std::marker::PhantomData,
337        };
338
339        let bind_group_descriptor = BindGroupDescriptor {
340            label: Some("Camera Bind Group"),
341            layout: bind_group_layout,
342            entries: &[bind_group_entry],
343        };
344
345        let bind_group = device
346            .create_bind_group(&bind_group_descriptor)
347            .map_err(|e| {
348                RenderError::InitializationFailed(format!(
349                    "Failed to create camera bind group: {:?}",
350                    e
351                ))
352            })?;
353
354        self.camera_uniform_buffer = Some(uniform_buffer);
355        self.camera_bind_group_layout = Some(bind_group_layout);
356        self.camera_bind_group = Some(bind_group);
357
358        log::info!("Camera uniform resources initialized with abstract API");
359
360        Ok(())
361    }
362
363    /// Updates the camera uniform buffer with the current ViewInfo data.
364    ///
365    /// This method is called every frame to upload the latest camera matrices
366    /// to the GPU uniform buffer.
367    fn update_camera_uniforms(&mut self, view_info: &ViewInfo) {
368        use khora_core::renderer::api::resource::CameraUniformData;
369
370        let uniform_data = CameraUniformData::from_view_info(view_info);
371
372        if let (Some(device), Some(buffer_id)) = (&self.wgpu_device, &self.camera_uniform_buffer) {
373            // Write the uniform data to the buffer using the abstract API
374            if let Err(e) =
375                device.write_buffer(*buffer_id, 0, bytemuck::cast_slice(&[uniform_data]))
376            {
377                log::warn!("Failed to write camera uniform data: {:?}", e);
378            }
379        }
380    }
381
382    /// Creates or recreates the depth texture for depth buffering.
383    ///
384    /// This method should be called during initialization and whenever the window is resized.
385    /// It destroys any existing depth texture resources before creating new ones.
386    fn create_depth_texture(&mut self) -> Result<(), RenderError> {
387        use khora_core::math::Extent3D;
388        use std::borrow::Cow;
389
390        let device = self.wgpu_device.as_ref().ok_or_else(|| {
391            RenderError::InitializationFailed("WGPU device not initialized".to_string())
392        })?;
393
394        // Skip if dimensions are zero
395        if self.current_width == 0 || self.current_height == 0 {
396            return Ok(());
397        }
398
399        // Destroy old depth texture resources if they exist
400        if let Some(old_view) = self.depth_texture_view.take() {
401            let _ = device.destroy_texture_view(old_view);
402        }
403        if let Some(old_tex) = self.depth_texture.take() {
404            let _ = device.destroy_texture(old_tex);
405        }
406
407        // Create new depth texture
408        let texture_desc = TextureDescriptor {
409            label: Some(Cow::Borrowed("Depth Texture")),
410            size: Extent3D {
411                width: self.current_width,
412                height: self.current_height,
413                depth_or_array_layers: 1,
414            },
415            mip_level_count: 1,
416            sample_count: SampleCount::X1,
417            dimension: TextureDimension::D2,
418            format: TextureFormat::Depth32Float,
419            usage: TextureUsage::RENDER_ATTACHMENT,
420            view_formats: Cow::Borrowed(&[]),
421        };
422
423        let texture_id = device.create_texture(&texture_desc).map_err(|e| {
424            RenderError::InitializationFailed(format!("Failed to create depth texture: {:?}", e))
425        })?;
426
427        // Create depth texture view
428        let view_desc = TextureViewDescriptor {
429            label: Some(Cow::Borrowed("Depth Texture View")),
430            format: Some(TextureFormat::Depth32Float),
431            dimension: None,
432            aspect: ImageAspect::DepthOnly,
433            base_mip_level: 0,
434            mip_level_count: None,
435            base_array_layer: 0,
436            array_layer_count: None,
437        };
438
439        let view_id = device
440            .create_texture_view(texture_id, &view_desc)
441            .map_err(|e| {
442                RenderError::InitializationFailed(format!(
443                    "Failed to create depth texture view: {:?}",
444                    e
445                ))
446            })?;
447
448        self.depth_texture = Some(texture_id);
449        self.depth_texture_view = Some(view_id);
450
451        log::info!(
452            "Depth texture created: {}x{} (Depth32Float)",
453            self.current_width,
454            self.current_height
455        );
456
457        Ok(())
458    }
459
460    /// Creates an offscreen render target for the editor viewport and
461    /// registers it as an egui texture.
462    ///
463    /// Returns the `egui::TextureId` that can be displayed via
464    /// [`UiBuilder::viewport_image`].
465    pub fn create_viewport_target(
466        &mut self,
467        width: u32,
468        height: u32,
469        overlay: &mut crate::ui::egui::overlay::EguiOverlay,
470    ) -> Result<egui::TextureId, RenderError> {
471        let gc = self
472            .graphics_context_shared
473            .as_ref()
474            .ok_or(RenderError::NotInitialized)?
475            .lock()
476            .map_err(|_| RenderError::Internal("Context lock poisoned".into()))?;
477
478        // Use RGBA8 so the viewport texture is always bindable in shaders.
479        let format = wgpu::TextureFormat::Rgba8UnormSrgb;
480
481        // --- Color texture ---
482        let color_tex = gc.device.create_texture(&wgpu::TextureDescriptor {
483            label: Some("viewport_color"),
484            size: wgpu::Extent3d {
485                width,
486                height,
487                depth_or_array_layers: 1,
488            },
489            mip_level_count: 1,
490            sample_count: 1,
491            dimension: wgpu::TextureDimension::D2,
492            format,
493            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
494            view_formats: &[],
495        });
496        let color_view = color_tex.create_view(&wgpu::TextureViewDescriptor::default());
497
498        // --- Depth texture ---
499        let depth_tex = gc.device.create_texture(&wgpu::TextureDescriptor {
500            label: Some("viewport_depth"),
501            size: wgpu::Extent3d {
502                width,
503                height,
504                depth_or_array_layers: 1,
505            },
506            mip_level_count: 1,
507            sample_count: 1,
508            dimension: wgpu::TextureDimension::D2,
509            format: wgpu::TextureFormat::Depth32Float,
510            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
511            view_formats: &[],
512        });
513        let depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default());
514
515        // Register with the egui overlay renderer.
516        let egui_id = overlay.register_viewport_texture(&gc.device, &color_view);
517
518        let device = self
519            .wgpu_device
520            .clone()
521            .ok_or(RenderError::NotInitialized)?;
522        let viewport_color_vid = device
523            .register_texture_view(&color_tex, Some("viewport_color_view"))
524            .map_err(|e| RenderError::Internal(format!("register viewport color view: {e}")))?;
525        let viewport_depth_vid = device
526            .register_texture_view(&depth_tex, Some("viewport_depth_view"))
527            .map_err(|e| RenderError::Internal(format!("register viewport depth view: {e}")))?;
528
529        self.viewport_texture = Some(color_tex);
530        self.viewport_view = Some(color_view);
531        self.viewport_depth_texture = Some(depth_tex);
532        self.viewport_depth_view = Some(depth_view);
533        self.viewport_width = width;
534        self.viewport_height = height;
535        self.viewport_color_view_id = Some(viewport_color_vid);
536        self.viewport_depth_view_id = Some(viewport_depth_vid);
537
538        log::info!("Viewport target created: {width}x{height} ({format:?})");
539
540        Ok(egui_id)
541    }
542
543    /// Renders a clear pass to the offscreen viewport target.
544    ///
545    /// Call this once per frame (after `begin_frame()`, before
546    /// `render_overlay()`). The cleared image will be visible in the
547    /// egui viewport panel.
548    pub fn render_viewport_clear(&mut self, clear_color: LinearRgba) -> Result<(), RenderError> {
549        let color_view = self
550            .viewport_view
551            .as_ref()
552            .ok_or(RenderError::NotInitialized)?;
553        let depth_view = self
554            .viewport_depth_view
555            .as_ref()
556            .ok_or(RenderError::NotInitialized)?;
557
558        let gc = self
559            .graphics_context_shared
560            .as_ref()
561            .ok_or(RenderError::NotInitialized)?
562            .lock()
563            .map_err(|_| RenderError::Internal("Context lock poisoned".into()))?;
564
565        let mut encoder = gc
566            .device
567            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
568                label: Some("viewport_clear_encoder"),
569            });
570
571        {
572            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
573                label: Some("viewport_clear_pass"),
574                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
575                    view: color_view,
576                    resolve_target: None,
577                    ops: wgpu::Operations {
578                        load: wgpu::LoadOp::Clear(wgpu::Color {
579                            r: clear_color.r as f64,
580                            g: clear_color.g as f64,
581                            b: clear_color.b as f64,
582                            a: clear_color.a as f64,
583                        }),
584                        store: wgpu::StoreOp::Store,
585                    },
586                    depth_slice: None,
587                })],
588                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
589                    view: depth_view,
590                    depth_ops: Some(wgpu::Operations {
591                        load: wgpu::LoadOp::Clear(1.0),
592                        store: wgpu::StoreOp::Store,
593                    }),
594                    stencil_ops: None,
595                }),
596                timestamp_writes: None,
597                occlusion_query_set: None,
598                multiview_mask: None,
599            });
600            // Pass drops here — the clear is all we need for now.
601        }
602
603        gc.queue.submit(std::iter::once(encoder.finish()));
604        Ok(())
605    }
606
607    /// Returns the current viewport dimensions `(width, height)` in pixels.
608    pub fn viewport_size(&self) -> (u32, u32) {
609        (self.viewport_width, self.viewport_height)
610    }
611
612    /// Creates an [`EguiOverlay`] backed by the current wgpu graphics context.
613    ///
614    /// Must be called after [`RenderSystem::init`].
615    pub fn create_editor_overlay(
616        &self,
617        event_loop: &winit::event_loop::ActiveEventLoop,
618        shader_source: &str,
619    ) -> Result<crate::ui::egui::overlay::EguiOverlay, RenderError> {
620        let gc = self
621            .graphics_context_shared
622            .as_ref()
623            .ok_or(RenderError::NotInitialized)?
624            .lock()
625            .map_err(|_| RenderError::Internal("Context lock poisoned".into()))?;
626
627        Ok(crate::ui::egui::overlay::EguiOverlay::new(
628            event_loop,
629            gc.surface_config.format,
630            &gc.device,
631            shader_source,
632        ))
633    }
634
635    /// Creates an [`EguiOverlay`] **and** an [`EguiEditorShell`] that share
636    /// the same `egui::Context`, plus an offscreen viewport target.
637    ///
638    /// The overlay handles input / rendering while the shell manages the
639    /// dock layout, menu bar, toolbar, and panel dispatch. The viewport
640    /// target is an offscreen texture used to display the 3D scene inside
641    /// an egui panel.
642    pub fn create_editor_overlay_and_shell(
643        &mut self,
644        event_loop: &winit::event_loop::ActiveEventLoop,
645        shader_source: &str,
646        theme: khora_core::ui::UiTheme,
647        viewport_handle: khora_core::ui::editor::viewport_texture::ViewportTextureHandle,
648    ) -> Result<
649        (
650            crate::ui::egui::overlay::EguiOverlay,
651            crate::ui::egui::shell::EguiEditorShell,
652        ),
653        RenderError,
654    > {
655        let mut overlay = self.create_editor_overlay(event_loop, shader_source)?;
656
657        // Create an offscreen viewport target (initial 800×600).
658        let egui_id = self.create_viewport_target(800, 600, &mut overlay)?;
659
660        // Grid + gizmo rendering are owned by the engine-side `GridLane`
661        // / `GizmoLane` (under `OverlayAgent`) — no render-strategy
662        // pipeline is created on the backend.
663
664        let mut shell = crate::ui::egui::shell::EguiEditorShell::new(overlay.context(), theme);
665        shell.register_viewport_texture(viewport_handle, egui_id);
666
667        Ok((overlay, shell))
668    }
669
670    /// Checks the device-health flags raised by the wgpu error callbacks.
671    ///
672    /// Returns a fatal, non-panicking [`RenderError`] when the device has been
673    /// lost or has run out of memory so the host can tear down cleanly. The
674    /// frame loop calls this before any GPU submission; once a fatal condition
675    /// is observed the system stops acquiring/submitting work.
676    fn check_device_health(&self) -> Result<(), RenderError> {
677        let Some(device) = self.wgpu_device.as_ref() else {
678            return Ok(());
679        };
680        if device.is_device_out_of_memory() {
681            return Err(RenderError::DeviceOutOfMemory(
682                "device reported out-of-memory via wgpu error callback".to_string(),
683            ));
684        }
685        if device.is_device_lost() {
686            return Err(RenderError::DeviceLost);
687        }
688        Ok(())
689    }
690
691    /// Acquires the swapchain texture with full resilience.
692    ///
693    /// Covers every [`wgpu::CurrentSurfaceTexture`] outcome via the pure
694    /// [`classify_acquire`] policy:
695    /// - `Success`/`Suboptimal` → return the texture.
696    /// - `Lost`/`Outdated` with a valid size → reconfigure and retry in-frame.
697    /// - `Lost`/`Outdated` at zero size, `Timeout`, `Occluded` → skip the frame
698    ///   (returns `Ok(None)`), no error spam.
699    /// - `Validation`/unknown → non-fatal [`RenderError::SurfaceAcquisitionFailed`].
700    ///
701    /// `Ok(None)` means "skip this frame, retry next frame"; the caller must
702    /// not treat it as an error.
703    fn acquire_surface_texture(
704        &mut self,
705        gc: &Arc<Mutex<WgpuGraphicsContext>>,
706    ) -> Result<Option<wgpu::SurfaceTexture>, RenderError> {
707        // A zero-size (minimized) window has no renderable surface. Skip the
708        // frame silently rather than churning reconfigure/acquire every tick.
709        if !surface_is_renderable(self.current_width, self.current_height) {
710            log::debug!(
711                "WgpuRenderSystem: surface not renderable ({}x{}); skipping frame.",
712                self.current_width,
713                self.current_height
714            );
715            return Ok(None);
716        }
717
718        // Bounded retry: at most one in-frame reconfigure for a lost/outdated
719        // surface, then a single re-acquire. Avoids any unbounded spin.
720        let max_attempts = 2;
721        for attempt in 0..max_attempts {
722            let mut gc_guard = gc
723                .lock()
724                .map_err(|_| RenderError::Internal("graphics context lock poisoned".into()))?;
725
726            let (status, texture) = match gc_guard.get_current_texture() {
727                wgpu::CurrentSurfaceTexture::Success(t)
728                | wgpu::CurrentSurfaceTexture::Suboptimal(t) => {
729                    (SurfaceAcquireStatus::Usable, Some(t))
730                }
731                wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => {
732                    (SurfaceAcquireStatus::LostOrOutdated, None)
733                }
734                wgpu::CurrentSurfaceTexture::Timeout => (SurfaceAcquireStatus::Timeout, None),
735                wgpu::CurrentSurfaceTexture::Occluded => (SurfaceAcquireStatus::Occluded, None),
736                wgpu::CurrentSurfaceTexture::Validation => (SurfaceAcquireStatus::Validation, None),
737                // Forward-compat: should wgpu add a swapchain-status variant in
738                // a future release, classify it as a non-fatal unknown hiccup
739                // rather than failing to compile or panicking. Unreachable today
740                // because the enum is currently exhaustive.
741                #[allow(unreachable_patterns)]
742                _ => (SurfaceAcquireStatus::Unknown, None),
743            };
744
745            let has_valid_size = surface_is_renderable(self.current_width, self.current_height);
746            match classify_acquire(status, has_valid_size) {
747                AcquireAction::Proceed => {
748                    // `texture` is `Some` exactly for the `Usable` status.
749                    return Ok(texture);
750                }
751                AcquireAction::ReconfigureAndRetry => {
752                    log::warn!(
753                        "WgpuRenderSystem: surface lost/outdated; reconfiguring to {}x{} (attempt {}).",
754                        self.current_width,
755                        self.current_height,
756                        attempt + 1
757                    );
758                    gc_guard.resize(self.current_width, self.current_height);
759                    drop(gc_guard);
760                    self.last_surface_config = Some(Instant::now());
761                    self.pending_resize = false;
762                    // Loop to re-acquire on the next iteration.
763                    continue;
764                }
765                AcquireAction::SkipFrame => {
766                    log::debug!(
767                        "WgpuRenderSystem: acquire status {:?} — skipping frame.",
768                        status
769                    );
770                    return Ok(None);
771                }
772                AcquireAction::NonFatalError => {
773                    log::error!(
774                        "WgpuRenderSystem: non-fatal surface acquire failure ({:?}).",
775                        status
776                    );
777                    return Err(RenderError::SurfaceAcquisitionFailed(format!("{status:?}")));
778                }
779            }
780        }
781
782        // Exhausted the in-frame reconfigure budget without a usable texture.
783        // Skip this frame; the next frame retries from a freshly configured
784        // surface rather than erroring out.
785        log::warn!(
786            "WgpuRenderSystem: surface still unavailable after {} acquire attempts; skipping frame.",
787            max_attempts
788        );
789        Ok(None)
790    }
791}
792
793impl RenderSystem for WgpuRenderSystem {
794    fn init(
795        &mut self,
796        window: &dyn KhoraWindow,
797    ) -> Result<Vec<Arc<dyn ResourceMonitor>>, RenderError> {
798        let (width, height) = window.inner_size();
799        let window_size = PhysicalSize::new(width, height);
800        let window_handle_arc = window.clone_handle_arc();
801        pollster::block_on(self.initialize(window_handle_arc, window_size))
802    }
803
804    fn resize(&mut self, new_width: u32, new_height: u32) {
805        if new_width > 0 && new_height > 0 {
806            log::debug!(
807                "WgpuRenderSystem: resize_surface called with W:{new_width}, H:{new_height}"
808            );
809            self.current_width = new_width;
810            self.current_height = new_height;
811            let now = Instant::now();
812            if let Some((lw, lh)) = self.last_pending_size {
813                if lw == new_width && lh == new_height {
814                    self.stable_size_frame_count = self.stable_size_frame_count.saturating_add(1);
815                } else {
816                    self.stable_size_frame_count = 0;
817                }
818            }
819            self.last_pending_size = Some((new_width, new_height));
820
821            let immediate_threshold_ms: u128 = 80;
822            let can_immediate = self
823                .last_surface_config
824                .map(|t| t.elapsed().as_millis() >= immediate_threshold_ms)
825                .unwrap_or(true);
826            let early_stable = self.stable_size_frame_count >= 2
827                && self
828                    .last_surface_config
829                    .map(|t| t.elapsed().as_millis() >= 20)
830                    .unwrap_or(true);
831            if can_immediate || early_stable {
832                let mut did_resize = false;
833                if let Some(gc_arc_mutex) = &self.graphics_context_shared {
834                    if let Ok(mut gc_guard) = gc_arc_mutex.lock() {
835                        gc_guard.resize(self.current_width, self.current_height);
836                        self.last_surface_config = Some(now);
837                        self.pending_resize = false;
838                        self.pending_resize_frames = 0;
839                        did_resize = true;
840                    }
841                }
842                if did_resize {
843                    // Recreate depth texture to match new size (after lock is released)
844                    if let Err(e) = self.create_depth_texture() {
845                        log::warn!("Failed to recreate depth texture during resize: {:?}", e);
846                    }
847                    log::info!(
848                        "WGPUGraphicsContext: Immediate/Early surface configuration to {}x{}",
849                        self.current_width,
850                        self.current_height
851                    );
852                    return;
853                }
854            }
855            self.last_resize_event = Some(now);
856            self.pending_resize = true;
857            self.pending_resize_frames = 0;
858        } else {
859            log::warn!(
860                "WgpuRenderSystem::resize_surface called with zero size ({new_width}, {new_height}). Ignoring."
861            );
862        }
863    }
864
865    fn prepare_frame(&mut self, view_info: &ViewInfo) {
866        if self.graphics_context_shared.is_none() {
867            return;
868        }
869        let stopwatch = Stopwatch::new();
870
871        // Update camera uniform buffer with the current ViewInfo
872        self.update_camera_uniforms(view_info);
873
874        self.last_frame_stats.cpu_preparation_time_ms = stopwatch.elapsed_ms().unwrap_or(0) as f32;
875    }
876
877    fn render(
878        &mut self,
879        renderables: &[RenderObject],
880        _view_info: &ViewInfo,
881        settings: &RenderSettings,
882    ) -> Result<RenderStats, RenderError> {
883        let full_frame_timer = Stopwatch::new();
884
885        let device = self
886            .wgpu_device
887            .clone()
888            .ok_or(RenderError::NotInitialized)?;
889
890        // Bail out early (non-panicking) on a lost / out-of-memory device.
891        self.check_device_health()?;
892
893        // Poll the device to process any pending GPU-to-CPU callbacks, such as
894        // those from the profiler's `map_async` calls. This is crucial.
895        device.poll_device_non_blocking();
896
897        let gc = self
898            .graphics_context_shared
899            .clone()
900            .ok_or(RenderError::NotInitialized)?;
901
902        if let Some(p) = self.gpu_profiler.as_mut() {
903            p.try_read_previous_frame();
904        }
905
906        // --- Handle Pending Resizes ---
907        let mut resized_this_frame = false;
908        if self.pending_resize {
909            self.pending_resize_frames = self.pending_resize_frames.saturating_add(1);
910            if let Some(t) = self.last_resize_event {
911                let quiet_elapsed = t.elapsed().as_millis();
912                let debounce_quiet_ms = settings.resize_debounce_ms as u128;
913                let max_pending_frames = settings.resize_max_pending_frames;
914                let early_stable = self.stable_size_frame_count >= 3;
915
916                if quiet_elapsed >= debounce_quiet_ms
917                    || self.pending_resize_frames >= max_pending_frames
918                    || early_stable
919                {
920                    if let Ok(mut gc_guard) = gc.lock() {
921                        gc_guard.resize(self.current_width, self.current_height);
922                        self.pending_resize = false;
923                        self.last_surface_config = Some(Instant::now());
924                        self.stable_size_frame_count = 0;
925                        resized_this_frame = true;
926                        log::info!(
927                            "Deferred surface configuration to {}x{}",
928                            self.current_width,
929                            self.current_height
930                        );
931                    }
932                }
933            }
934            if self.pending_resize && !resized_this_frame {
935                return Ok(self.last_frame_stats.clone());
936            }
937        }
938
939        // Recreate depth texture if we just resized
940        if resized_this_frame {
941            if let Err(e) = self.create_depth_texture() {
942                log::warn!(
943                    "Failed to recreate depth texture during deferred resize: {:?}",
944                    e
945                );
946            }
947        }
948
949        // --- 1. Acquire Frame from Swap Chain (resilient) ---
950        device.wait_for_last_submission();
951        let output_surface_texture = match self.acquire_surface_texture(&gc)? {
952            Some(texture) => texture,
953            None => {
954                // Transient skip — return last frame's stats unchanged so the
955                // caller treats this as a no-op frame, not a hard failure.
956                return Ok(self.last_frame_stats.clone());
957            }
958        };
959
960        let command_recording_timer = Stopwatch::new();
961
962        // --- 2. Create a managed, abstract view for the swap chain texture ---
963        if let Some(old_id) = self.current_frame_view_id.take() {
964            device.destroy_texture_view(old_id)?;
965        }
966        let target_view_id = device.register_texture_view(
967            &output_surface_texture.texture,
968            Some("Primary Swap Chain View"),
969        )?;
970        self.current_frame_view_id = Some(target_view_id);
971
972        // --- 3. Create an abstract Command Encoder ---
973        let mut command_encoder = device.create_command_encoder(Some("Khora Main Command Encoder"));
974
975        // --- 4. Profiler Pass A (records start timestamps) ---
976        if settings.enable_gpu_timestamps {
977            if let Some(profiler) = self.gpu_profiler.as_ref() {
978                let _pass_a = command_encoder.begin_profiler_compute_pass(
979                    Some("Timestamp Pass A"),
980                    profiler.as_ref(),
981                    0,
982                );
983            }
984        }
985
986        // --- 5. Main Render Pass (drawing all objects) ---
987        {
988            let gc_guard = gc
989                .lock()
990                .map_err(|_| RenderError::Internal("graphics context lock poisoned".into()))?;
991            let wgpu_color = gc_guard.get_clear_color();
992            let clear_color = LinearRgba::new(
993                wgpu_color.r as f32,
994                wgpu_color.g as f32,
995                wgpu_color.b as f32,
996                wgpu_color.a as f32,
997            );
998
999            let color_attachment = RenderPassColorAttachment {
1000                view: &target_view_id,
1001                resolve_target: None,
1002                ops: Operations {
1003                    load: LoadOp::Clear(clear_color),
1004                    store: StoreOp::Store,
1005                },
1006                base_array_layer: 0,
1007                base_mip_level: 0,
1008            };
1009
1010            // Create depth/stencil attachment if depth texture is available
1011            use khora_core::renderer::api::command::RenderPassDepthStencilAttachment;
1012            let depth_attachment = self.depth_texture_view.as_ref().map(|depth_view| {
1013                RenderPassDepthStencilAttachment {
1014                    view: depth_view,
1015                    depth_ops: Some(Operations {
1016                        load: LoadOp::Clear(1.0), // Clear to far plane (1.0)
1017                        store: StoreOp::Store,
1018                    }),
1019                    stencil_ops: None, // No stencil operations
1020                    base_array_layer: 0,
1021                }
1022            });
1023
1024            let pass_descriptor = RenderPassDescriptor {
1025                label: Some("Khora Main Abstract Render Pass"),
1026                color_attachments: &[color_attachment],
1027                depth_stencil_attachment: depth_attachment,
1028            };
1029
1030            let mut render_pass = command_encoder.begin_render_pass(&pass_descriptor);
1031
1032            // Apply the same bind group and pipeline to all chunks to limit state changes
1033            if let Some(camera_bind_group) = &self.camera_bind_group {
1034                render_pass.set_bind_group(0, camera_bind_group, &[]);
1035            }
1036            let (draw_calls, triangles) = renderables.iter().fold((0, 0), |(dc, tris), obj| {
1037                render_pass.set_pipeline(&obj.pipeline);
1038                render_pass.set_vertex_buffer(0, &obj.vertex_buffer, 0);
1039                render_pass.set_index_buffer(&obj.index_buffer, 0, IndexFormat::Uint16);
1040                render_pass.draw_indexed(0..obj.index_count, 0, 0..1);
1041                (dc + 1, tris + obj.index_count / 3)
1042            });
1043            self.last_frame_stats.draw_calls = draw_calls;
1044            self.last_frame_stats.triangles_rendered = triangles;
1045        }
1046
1047        // --- 6. Profiler Pass B and Timestamp Resolution ---
1048        if settings.enable_gpu_timestamps {
1049            if let Some(profiler) = self.gpu_profiler.as_ref() {
1050                // This scope ensures the compute pass ends, releasing its mutable borrow on the encoder,
1051                // before we try to mutably borrow the encoder again for resolve/copy.
1052                {
1053                    let _pass_b = command_encoder.begin_profiler_compute_pass(
1054                        Some("Timestamp Pass B"),
1055                        profiler.as_ref(),
1056                        1,
1057                    );
1058                }
1059                profiler.resolve_and_copy(command_encoder.as_mut());
1060                profiler.copy_to_staging(command_encoder.as_mut(), self.frame_count);
1061            }
1062        }
1063
1064        // --- 7. Finalize and Submit Commands ---
1065        let submission_timer = Stopwatch::new();
1066        let submission_ms = match command_encoder.finish() {
1067            Some(command_buffer) => {
1068                device.submit_command_buffer(command_buffer);
1069                submission_timer.elapsed_ms().unwrap_or(0)
1070            }
1071            None => {
1072                log::error!(
1073                    "WgpuRenderSystem: command_encoder.finish() returned None — skipping submit"
1074                );
1075                0
1076            }
1077        };
1078
1079        if settings.enable_gpu_timestamps {
1080            if let Some(p) = self.gpu_profiler.as_mut() {
1081                p.schedule_map_after_submit(self.frame_count);
1082            }
1083        }
1084
1085        // --- 8. Present the final image to the screen ---
1086        output_surface_texture.present();
1087
1088        // --- 9. Update final frame statistics ---
1089        self.frame_count += 1;
1090        if let Some(p) = self.gpu_profiler.as_ref() {
1091            self.last_frame_stats.gpu_main_pass_time_ms = p.last_main_pass_ms();
1092            self.last_frame_stats.gpu_frame_total_time_ms = p.last_frame_total_ms();
1093        }
1094        let full_frame_ms = full_frame_timer.elapsed_ms().unwrap_or(0);
1095        self.last_frame_stats.frame_number = self.frame_count;
1096        self.last_frame_stats.cpu_preparation_time_ms =
1097            (full_frame_ms - command_recording_timer.elapsed_ms().unwrap_or(0)) as f32;
1098        self.last_frame_stats.cpu_render_submission_time_ms = submission_ms as f32;
1099
1100        if let Some(monitor) = &self.gpu_monitor {
1101            monitor.update_from_frame_stats(&self.last_frame_stats);
1102        }
1103
1104        Ok(self.last_frame_stats.clone())
1105    }
1106
1107    fn begin_frame(&mut self) -> Result<FrameTargets, RenderError> {
1108        let device = self
1109            .wgpu_device
1110            .clone()
1111            .ok_or(RenderError::NotInitialized)?;
1112
1113        // Bail out early (non-panicking) if the device was reported lost or
1114        // out-of-memory: there is no point acquiring or submitting any work.
1115        self.check_device_health()?;
1116
1117        // Process any pending GPU-to-CPU callbacks (profiler map_async, etc.).
1118        device.poll_device_non_blocking();
1119        // Block until the previous submission is consumed so the acquire
1120        // semaphore is guaranteed to be unsignaled.
1121        device.wait_for_last_submission();
1122
1123        let gc = self
1124            .graphics_context_shared
1125            .clone()
1126            .ok_or(RenderError::NotInitialized)?;
1127
1128        if let Some(p) = self.gpu_profiler.as_mut() {
1129            p.try_read_previous_frame();
1130        }
1131
1132        // --- Handle Pending Resizes ---
1133        let mut resized_this_frame = false;
1134        if self.pending_resize {
1135            self.pending_resize_frames = self.pending_resize_frames.saturating_add(1);
1136            if let Some(t) = self.last_resize_event {
1137                let quiet_elapsed = t.elapsed().as_millis();
1138                let debounce_quiet_ms = 120u128;
1139                let max_pending_frames = 10u32;
1140                let early_stable = self.stable_size_frame_count >= 3;
1141
1142                if quiet_elapsed >= debounce_quiet_ms
1143                    || self.pending_resize_frames >= max_pending_frames
1144                    || early_stable
1145                {
1146                    if let Ok(mut gc_guard) = gc.lock() {
1147                        gc_guard.resize(self.current_width, self.current_height);
1148                        self.pending_resize = false;
1149                        self.last_surface_config = Some(Instant::now());
1150                        self.stable_size_frame_count = 0;
1151                        resized_this_frame = true;
1152                    }
1153                }
1154            }
1155        }
1156
1157        if resized_this_frame {
1158            if let Err(e) = self.create_depth_texture() {
1159                log::warn!("Failed to recreate depth texture: {:?}", e);
1160            }
1161        }
1162
1163        // --- Acquire swapchain texture (resilient: see acquire_surface_texture) ---
1164        let output_surface_texture = match self.acquire_surface_texture(&gc)? {
1165            Some(texture) => texture,
1166            None => {
1167                // Transient skip (minimized / timeout / occluded / reconfigure
1168                // in flight). Not an error — report a non-fatal acquisition
1169                // failure so the engine skips this frame and retries next one.
1170                return Err(RenderError::SurfaceAcquisitionFailed(
1171                    "frame skipped (surface not ready)".to_string(),
1172                ));
1173            }
1174        };
1175
1176        // --- Create texture view for the frame ---
1177        if let Some(old_id) = self.current_frame_view_id.take() {
1178            device.destroy_texture_view(old_id)?;
1179        }
1180        let target_view_id = device.register_texture_view(
1181            &output_surface_texture.texture,
1182            Some("Primary Swap Chain View"),
1183        )?;
1184        self.current_frame_view_id = Some(target_view_id);
1185
1186        self.active_frame_texture = Some(output_surface_texture);
1187
1188        // Compute targets for the engine: choose between swapchain and viewport.
1189        let (color, depth) = if self.render_to_viewport {
1190            let c = self
1191                .viewport_color_view_id
1192                .ok_or_else(|| RenderError::Internal("viewport color view not created".into()))?;
1193            (c, self.viewport_depth_view_id)
1194        } else {
1195            (target_view_id, self.depth_texture_view)
1196        };
1197
1198        Ok(FrameTargets { color, depth })
1199    }
1200
1201    fn end_frame(&mut self) -> Result<RenderStats, RenderError> {
1202        // If the device went down between acquire and present, surface a fatal
1203        // error instead of presenting a texture from a dead device.
1204        self.check_device_health()?;
1205
1206        // `SurfaceTexture::present()` is infallible in this wgpu version: if a
1207        // present fails internally it is reported through the device error
1208        // callback (handled by `check_device_health`), and an un-presented
1209        // texture is discarded on drop rather than panicking.
1210        if let Some(texture) = self.active_frame_texture.take() {
1211            texture.present();
1212        }
1213
1214        self.frame_count += 1;
1215        self.last_frame_stats.frame_number = self.frame_count;
1216
1217        if let Some(monitor) = &self.gpu_monitor {
1218            monitor.update_from_frame_stats(&self.last_frame_stats);
1219        }
1220
1221        Ok(self.last_frame_stats.clone())
1222    }
1223
1224    fn render_overlay(
1225        &mut self,
1226        overlay: &mut dyn khora_core::ui::EditorOverlay,
1227        screen: khora_core::ui::OverlayScreenDescriptor,
1228    ) -> Result<(), RenderError> {
1229        let gc_arc = self
1230            .graphics_context_shared
1231            .as_ref()
1232            .ok_or(RenderError::NotInitialized)?
1233            .clone();
1234
1235        // Create encoder and target view while holding the lock, then release.
1236        let (encoder, target_view) = {
1237            let gc = gc_arc
1238                .lock()
1239                .map_err(|_| RenderError::Internal("Context lock poisoned".into()))?;
1240
1241            let surface_tex = self
1242                .active_frame_texture
1243                .as_ref()
1244                .ok_or(RenderError::NotInitialized)?;
1245
1246            let target_view = surface_tex
1247                .texture
1248                .create_view(&wgpu::TextureViewDescriptor::default());
1249
1250            let encoder = gc
1251                .device
1252                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1253                    label: Some("egui_overlay_encoder"),
1254                });
1255
1256            (encoder, target_view)
1257        }; // gc lock released — overlay will re-acquire it
1258
1259        let mut render_state = crate::ui::egui::overlay::EguiFrameRenderState {
1260            graphics_context: gc_arc.clone(),
1261            encoder: Some(encoder),
1262            target_view,
1263            width_px: screen.width_px,
1264            height_px: screen.height_px,
1265        };
1266
1267        overlay
1268            .end_frame_and_render(&mut render_state as &mut dyn std::any::Any)
1269            .map_err(|e| RenderError::RenderingFailed(e.to_string()))?;
1270
1271        // Submit the encoder
1272        let encoder = render_state.encoder.take().ok_or_else(|| {
1273            RenderError::RenderingFailed("Encoder consumed during overlay render".into())
1274        })?;
1275
1276        let gc = gc_arc
1277            .lock()
1278            .map_err(|_| RenderError::Internal("Context lock poisoned".into()))?;
1279        gc.queue.submit(std::iter::once(encoder.finish()));
1280
1281        Ok(())
1282    }
1283
1284    fn get_last_frame_stats(&self) -> &RenderStats {
1285        &self.last_frame_stats
1286    }
1287
1288    fn supports_feature(&self, feature_name: &str) -> bool {
1289        self.wgpu_device
1290            .as_ref()
1291            .is_some_and(|d| d.supports_feature(feature_name))
1292    }
1293
1294    fn shutdown(&mut self) {
1295        log::info!("WgpuRenderSystem shutting down...");
1296        if let Some(mut profiler) = self.gpu_profiler.take() {
1297            if let Some(device) = self.wgpu_device.as_ref() {
1298                if let Some(wgpu_profiler) = profiler
1299                    .as_any_mut()
1300                    .downcast_mut::<WgpuTimestampProfiler>()
1301                {
1302                    wgpu_profiler.shutdown(device);
1303                }
1304            }
1305        }
1306        if let Some(old_id) = self.current_frame_view_id.take() {
1307            if let Some(device) = self.wgpu_device.as_ref() {
1308                let _ = device.destroy_texture_view(old_id);
1309            }
1310        }
1311        self.wgpu_device = None;
1312        self.graphics_context_shared = None;
1313        self.gpu_monitor = None;
1314    }
1315
1316    fn as_any(&self) -> &dyn std::any::Any {
1317        self
1318    }
1319
1320    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1321        self
1322    }
1323
1324    fn render_to_viewport(&self) -> bool {
1325        self.render_to_viewport
1326    }
1327
1328    fn set_render_to_viewport(&mut self, enabled: bool) {
1329        self.render_to_viewport = enabled;
1330    }
1331
1332    fn get_adapter_info(&self) -> Option<GraphicsAdapterInfo> {
1333        self.wgpu_device.as_ref().map(|d| d.get_adapter_info())
1334    }
1335
1336    fn graphics_device(&self) -> Arc<dyn GraphicsDevice> {
1337        // Invariant: `wgpu_device` is populated during render-system
1338        // initialization (adapter + device creation) and is only cleared on
1339        // shutdown. The bootstrap path calls `graphics_device()` exactly once,
1340        // right after a successful init and long before shutdown — so the
1341        // device is always present here. The trait returns a bare
1342        // `Arc<dyn GraphicsDevice>` (no fallible variant), and there is no
1343        // sound placeholder device to substitute, so a `None` at this point is
1344        // an init-ordering bug rather than a recoverable runtime condition.
1345        self.wgpu_device.clone().expect(
1346            "WgpuRenderSystem::graphics_device called before initialization or after shutdown",
1347        )
1348    }
1349}
1350
1351unsafe impl Send for WgpuRenderSystem {}
1352unsafe impl Sync for WgpuRenderSystem {}