Skip to main content

cvkg_render_gpu/renderer/
draw.rs

1use super::GpuRenderer;
2use super::context_helpers::create_surface_context;
3use crate::types::{DrawCall, MAX_PARTICLES};
4use crate::vertex::{InstanceData, Vertex};
5use cvkg_core::{Rect, Renderer};
6use std::sync::Arc;
7
8impl GpuRenderer {
9    /// begin_frame_headless -- Strike the flaming sword to begin a new GPU frame for headless rendering.
10    pub fn begin_frame_headless(&mut self) -> wgpu::CommandEncoder {
11        self.current_window = None;
12        self.compositor_index_cursor = self.indices.len() as u32;
13        self.reset_frame_state();
14
15        // Recall staging belt buffers so they can be reused for vertex upload
16        self.staging_belt.recall();
17
18        let ctx = self
19            .headless_context
20            .as_ref()
21            .expect("Headless context not initialized");
22        let time = self.start_time.elapsed().as_secs_f32();
23        let logical_w = ctx.width as f32 / ctx.scale_factor;
24        let logical_h = ctx.height as f32 / ctx.scale_factor;
25        let dt = time - self.current_scene.time;
26        self.current_scene.time = time;
27        self.current_scene.delta_time = dt;
28        self.current_scene.resolution = [logical_w, logical_h];
29        self.current_scene.scale_factor = ctx.scale_factor;
30        self.current_scene.proj =
31            glam::Mat4::orthographic_lh(0.0, logical_w, logical_h, 0.0, -1000.0, 1000.0);
32
33        self.queue.write_buffer(
34            &self.scene_buffer,
35            0,
36            bytemuck::bytes_of(&self.current_scene),
37        );
38
39        self.device
40            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
41                label: Some("Surtr Headless Command Encoder"),
42            })
43    }
44
45    /// Reset per-frame state shared by both `begin_frame` and `begin_frame_headless`.
46    /// Factored out to avoid the copy-paste duplication hazard identified in the audit.
47    fn reset_frame_state(&mut self) {
48        self.vertices.clear();
49        self.indices.clear();
50        self.instance_data.clear();
51        self.draw_calls.clear();
52        self.svg.clear_filter_batches();
53        self.shared_elements.clear();
54        self.current_texture_id = None;
55        self.opacity_stack.clear();
56        self.opacity_stack.push(1.0);
57        self.clip_stack.clear();
58        self.slice_stack.clear();
59        self.transform_stack.clear();
60        self.portal_regions.clear();
61        self.hologram_instances.clear();
62        self.current_z = 0.0;
63        self.vnode_stack.clear();
64        self.event_handlers.clear();
65        // P2-13: Always update the volumetric time uniform, even if the
66        // volumetric pass is skipped by the frame budget system. This prevents
67        // a visible time pop when the pass resumes after being skipped.
68        let current_time = self.current_time();
69        let resolution = [self.current_width() as f32, self.current_height() as f32];
70        let time_uniform: [f32; 4] = [
71            current_time,
72            resolution[0],
73            resolution[1],
74            0.0, // _pad
75        ];
76        self.queue.write_buffer(
77            &self.volumetric_uniform_buffer,
78            0,
79            bytemuck::cast_slice(&time_uniform),
80        );
81        // Clear per-frame state but NOT memo_cache -- use generation counter instead
82        self.frame_generation += 1;
83        // Evict memo cache entries that are too old to prevent unbounded growth.
84        const MAX_MEMO_AGE: u64 = 1000;
85        if self.frame_generation > MAX_MEMO_AGE {
86            let cutoff = self.frame_generation - MAX_MEMO_AGE;
87            self.memo_cache.retain(|_, entry| entry.frame_gen >= cutoff);
88        }
89        self.last_frame_start = std::time::Instant::now();
90        self.telemetry.draw_calls = 0;
91        self.telemetry.vertices = 0;
92    }
93
94    /// begin_frame -- Strike the flaming sword to begin a new GPU frame for a specific window.
95    pub fn begin_frame(&mut self, window_id: winit::window::WindowId) -> wgpu::CommandEncoder {
96        self.begin_frame_internal(window_id, true)
97    }
98
99    /// Begin a frame without resetting per-frame state.
100    /// Used when reusing the previous frame's draw calls (view unchanged).
101    pub fn begin_frame_reuse(
102        &mut self,
103        window_id: winit::window::WindowId,
104    ) -> wgpu::CommandEncoder {
105        self.begin_frame_internal(window_id, false)
106    }
107
108    fn begin_frame_internal(
109        &mut self,
110        window_id: winit::window::WindowId,
111        reset_state: bool,
112    ) -> wgpu::CommandEncoder {
113        // Drain AI material channel
114        if let Some(rx) = &self.ai_material_rx {
115            while let Ok(res) = rx.try_recv() {
116                match res {
117                    Ok(_) => tracing::info!("[Surtr] Received AI generated material"),
118                    Err(e) => tracing::warn!("[Surtr] AI material generation error: {:?}", e),
119                }
120            }
121        }
122
123        // Skuld timestamp query removed — was causing GPU sync stalls (10ms/frame)
124        // and buffer mapping errors. GPU time can be profiled externally if needed.
125
126        self.staging_belt.recall();
127        self.current_window = Some(window_id);
128        if reset_state {
129            self.reset_frame_state();
130        }
131
132        let ctx = self
133            .surfaces
134            .get(&window_id)
135            .expect("Window not registered");
136        let time = self.start_time.elapsed().as_secs_f32();
137        let logical_w = ctx.config.width as f32 / ctx.scale_factor;
138        let logical_h = ctx.config.height as f32 / ctx.scale_factor;
139        let dt = time - self.current_scene.time;
140        self.current_scene.time = time;
141        self.current_scene.delta_time = dt;
142        self.current_scene.resolution = [logical_w, logical_h];
143        self.current_scene.scale_factor = ctx.scale_factor;
144        self.current_scene.proj =
145            glam::Mat4::orthographic_lh(0.0, logical_w, logical_h, 0.0, -1000.0, 1000.0);
146
147        self.queue.write_buffer(
148            &self.scene_buffer,
149            0,
150            bytemuck::bytes_of(&self.current_scene),
151        );
152
153        self.device
154            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
155                label: Some("Surtr Command Encoder"),
156            })
157    }
158
159    /// register_window -- Attaches a new OS window to the shared GPU context.
160    pub fn register_window(&mut self, window: Arc<winit::window::Window>) {
161        let size = window.inner_size();
162        let surface = self
163            .instance
164            .create_surface(window.clone())
165            .expect("Failed to create surface");
166        let caps = surface.get_capabilities(&self.adapter);
167        let format = caps.formats[0];
168
169        // Dynamic present mode selection -- Mailbox not available on all platforms (e.g. Wayland)
170        let present_mode = if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
171            wgpu::PresentMode::Mailbox
172        } else {
173            tracing::warn!("[GPU] Mailbox not supported, falling back to Fifo (V-Sync)");
174            wgpu::PresentMode::Fifo
175        };
176
177        let alpha_mode = if caps
178            .alpha_modes
179            .contains(&wgpu::CompositeAlphaMode::PostMultiplied)
180        {
181            wgpu::CompositeAlphaMode::PostMultiplied
182        } else if caps
183            .alpha_modes
184            .contains(&wgpu::CompositeAlphaMode::PreMultiplied)
185        {
186            wgpu::CompositeAlphaMode::PreMultiplied
187        } else {
188            caps.alpha_modes[0]
189        };
190
191        tracing::info!(
192            "[GPU] Configuring surface: {}x{} | {:?} | {:?}",
193            size.width,
194            size.height,
195            present_mode,
196            alpha_mode
197        );
198
199        let config = wgpu::SurfaceConfiguration {
200            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
201            format,
202            width: size.width,
203            height: size.height,
204            present_mode,
205            alpha_mode,
206            view_formats: vec![],
207            desired_maximum_frame_latency: 1,
208        };
209        surface.configure(&self.device, &config);
210
211        let ctx = create_surface_context(
212            &self.device,
213            surface,
214            config,
215            &self.env_bind_group_layout,
216            &self.texture_bind_group_layout,
217            window.scale_factor() as f32,
218            self.quality_level.msaa_sample_count(),
219            &mut self.registry,
220        );
221
222        self.surfaces.insert(window.id(), ctx);
223    }
224
225    pub(crate) fn shatter_internal(
226        &mut self,
227        rect: Rect,
228        pieces: u32,
229        force: f32,
230        color: [f32; 4],
231        material_id: u32,
232    ) {
233        // High-Fidelity Variable Particle Density
234        let count = (pieces as f32).sqrt().ceil() as u32;
235        let dw = rect.width / count as f32;
236        let dh = rect.height / count as f32;
237
238        let c = self.apply_opacity(color);
239
240        let cx = rect.x + rect.width * 0.5;
241        let cy = rect.y + rect.height * 0.5;
242
243        for y in 0..count {
244            for x in 0..count {
245                let init_x = rect.x + x as f32 * dw;
246                let init_y = rect.y + y as f32 * dh;
247
248                // Center of the shard relative to the card center
249                let dx = (init_x + dw * 0.5) - cx;
250                let dy = (init_y + dh * 0.5) - cy;
251                let dist = (dx * dx + dy * dy).sqrt().max(1.0);
252
253                // Normal direction outwards
254                let nx = dx / dist;
255                let ny = dy / dist;
256
257                // Hash-based pseudo-random variations for dispersion
258                let hash =
259                    ((x as f32 * 12.9898 + y as f32 * 78.233).sin().fract() * 43_758.547).fract();
260                let hash2 =
261                    ((x as f32 * 37.11 + y as f32 * 149.87).sin().fract() * 23_412.19).fract();
262
263                let speed_var = 0.5 + hash * 1.5;
264                let angle = ny.atan2(nx) + (hash2 - 0.5) * 0.6;
265                let disp_x = angle.cos() * force * 50.0 * speed_var;
266                let disp_y = angle.sin() * force * 50.0 * speed_var;
267
268                // Downward gravity-like drift over time/force
269                let gravity = force * force * 20.0;
270
271                // Shrink shard size as it scatters away
272                // Assuming max force in demo is ~6.0
273                let scale_factor = (1.0 - (force / 6.0).min(1.0)).max(0.0);
274                let shard_w = dw * scale_factor;
275                let shard_h = dh * scale_factor;
276
277                let displaced_x = init_x + disp_x + (dw - shard_w) * 0.5;
278                let displaced_y = init_y + disp_y + gravity + (dh - shard_h) * 0.5;
279
280                let shard_rect = Rect {
281                    x: displaced_x,
282                    y: displaced_y,
283                    width: shard_w,
284                    height: shard_h,
285                };
286
287                let uv = Rect {
288                    x: x as f32 / count as f32,
289                    y: y as f32 / count as f32,
290                    width: 1.0 / count as f32,
291                    height: 1.0 / count as f32,
292                };
293
294                self.fill_rect_with_full_params(shard_rect, c, material_id, None, force, uv);
295            }
296        }
297    }
298
299    pub(crate) fn recursive_bolt(
300        &mut self,
301        from: [f32; 2],
302        to: [f32; 2],
303        depth: u32,
304        color: [f32; 4],
305    ) {
306        if depth == 0 {
307            self.draw_lightning_segment(from, to, color);
308            return;
309        }
310
311        let mid_x = (from[0] + to[0]) * 0.5;
312        let mid_y = (from[1] + to[1]) * 0.5;
313
314        let dx = to[0] - from[0];
315        let dy = to[1] - from[1];
316        let len = (dx * dx + dy * dy).sqrt();
317
318        if len < 1e-4 {
319            return;
320        }
321
322        // Perpendicular offset for jaggedness
323        let offset_scale = len * 0.15;
324        let seed = (from[0] * 12.9898 + from[1] * 78.233 + (depth as f32) * 37.11)
325            .sin()
326            .fract();
327        let offset_x = -dy / len * (seed - 0.5) * offset_scale;
328        let offset_y = dx / len * (seed - 0.5) * offset_scale;
329
330        let mid = [mid_x + offset_x, mid_y + offset_y];
331
332        self.recursive_bolt(from, mid, depth - 1, color);
333        self.recursive_bolt(mid, to, depth - 1, color);
334
335        // 20% chance of a secondary branch
336        if depth > 2 && seed > 0.8 {
337            let branch_to = [
338                mid[0] + offset_x * 2.0 + (seed * 100.0).sin() * 50.0,
339                mid[1] + offset_y * 2.0 + (seed * 100.0).cos() * 50.0,
340            ];
341            self.recursive_bolt(mid, branch_to, depth - 2, color);
342        }
343    }
344
345    pub(crate) fn draw_lightning_segment(&mut self, from: [f32; 2], to: [f32; 2], color: [f32; 4]) {
346        let dx = to[0] - from[0];
347        let dy = to[1] - from[1];
348        let len = (dx * dx + dy * dy).sqrt();
349        if len < 0.001 {
350            return;
351        }
352
353        let glow_width = 32.0;
354        let core_width = 4.0;
355        let c = self.apply_opacity(color);
356
357        // 1. Render Volumetric Glow (Cyan)
358        let gnx = -dy / len * glow_width * 0.5;
359        let gny = dx / len * glow_width * 0.5;
360        let gp1 = [from[0] + gnx, from[1] + gny];
361        let gp2 = [to[0] + gnx, to[1] + gny];
362        let gp3 = [to[0] - gnx, to[1] - gny];
363        let gp4 = [from[0] - gnx, from[1] - gny];
364        self.push_oriented_quad(
365            [gp1, gp2, gp3, gp4],
366            c,
367            9,
368            Rect {
369                x: 0.0,
370                y: 0.0,
371                width: 1.0,
372                height: 1.0,
373            },
374        );
375
376        // 2. Render Blinding Core (White)
377        let cnx = -dy / len * core_width * 0.5;
378        let cny = dx / len * core_width * 0.5;
379        let cp1 = [from[0] + cnx, from[1] + cny];
380        let cp2 = [to[0] + cnx, to[1] + cny];
381        let cp3 = [to[0] - cnx, to[1] - cny];
382        let cp4 = [from[0] - cnx, from[1] - cny];
383        self.push_oriented_quad(
384            [cp1, cp2, cp3, cp4],
385            [1.0, 1.0, 1.0, c[3]],
386            0,
387            Rect {
388                x: 0.0,
389                y: 0.0,
390                width: 1.0,
391                height: 1.0,
392            },
393        );
394    }
395
396    pub(crate) fn push_oriented_quad(
397        &mut self,
398        points: [[f32; 2]; 4],
399        color: [f32; 4],
400        material_id: u32,
401        uv_rect: Rect,
402    ) {
403        let scissor = self.clip_stack.last().copied();
404        let texture_id = None; // Oriented quads like lightning don't use textures yet
405
406        let (translation, scale_transform, rotation, _, _) = self.current_transform();
407        let current_instance_data = InstanceData {
408            translation,
409            scale: scale_transform,
410            rotation,
411            blur_radius: 0.0,
412            ior_override: 0.0,
413            glass_intensity: 1.0,
414        };
415
416        // CRITICAL FIX: Only break batch on material/scissor/texture state changes.
417        // Transform (translation/scale/rotation) is per-instance data.
418        let material =
419            Self::resolve_material_with_context(material_id, &self.current_draw_material);
420        let final_material_id = match material {
421            cvkg_core::DrawMaterial::Opaque => material_id,
422            cvkg_core::DrawMaterial::TopUI => crate::renderer::material_id::TOP_UI,
423            cvkg_core::DrawMaterial::Glass { .. } => crate::renderer::material_id::GLASS,
424            cvkg_core::DrawMaterial::Blend { mode } => 7 + mode,
425        };
426
427        let last_call = self.draw_calls.last();
428        let needs_new_call = self.draw_calls.is_empty()
429            || self.current_texture_id != texture_id
430            || last_call.unwrap().scissor_rect != scissor
431            || last_call.unwrap().material != material
432            || {
433                let last_material = last_call.unwrap().material;
434                matches!((material, last_material),
435                    (cvkg_core::DrawMaterial::Glass { blur_radius: a, ior_override: b, glass_intensity: c },
436                     cvkg_core::DrawMaterial::Glass { blur_radius: d, ior_override: e, glass_intensity: f })
437                    if a != d || b != e || c != f)
438            };
439
440        if needs_new_call {
441            self.current_texture_id = texture_id;
442            self.instance_data.push(current_instance_data);
443            self.draw_calls.push(DrawCall {
444                target_id: None,
445                texture_id,
446                scissor_rect: scissor,
447                index_start: self.indices.len() as u32,
448                index_count: 0,
449                instance_count: 1,
450                material,
451                instance_start: (self.instance_data.len() - 1) as u32,
452                draw_order: 0,
453            });
454        } else {
455            // Same batch - add instance data and increment instance count
456            self.instance_data.push(current_instance_data);
457            if let Some(call) = self.draw_calls.last_mut() {
458                call.instance_count += 1;
459            }
460        }
461
462        let uvs = [
463            [uv_rect.x, uv_rect.y],
464            [uv_rect.x + uv_rect.width, uv_rect.y],
465            [uv_rect.x + uv_rect.width, uv_rect.y + uv_rect.height],
466            [uv_rect.x, uv_rect.y + uv_rect.height],
467        ];
468
469        let rect = Rect {
470            x: points[0][0],
471            y: points[0][1],
472            width: 1.0,
473            height: 1.0,
474        };
475
476        for i in 0..4 {
477            let px = points[i][0];
478            let py = points[i][1];
479
480            self.vertices.push(Vertex {
481                position: [px, py, 0.0],
482                normal: [0.0, 0.0, 1.0],
483                uv: uvs[i],
484                color,
485                material_id: final_material_id,
486                radius: 0.0,
487                slice: [0.0, 0.0, 0.0, 1.0],
488                logical: [px - rect.x, py - rect.y],
489                size: [rect.width, rect.height],
490                clip: [-f32::INFINITY, -f32::INFINITY, f32::INFINITY, f32::INFINITY],
491                tex_index: 0,
492            });
493        }
494
495        // Push indices for the quad (two triangles: 0-1-2 and 0-2-3)
496        let base = self.vertices.len() as u32 - 4;
497        self.indices
498            .extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
499
500        if let Some(call) = self.draw_calls.last_mut() {
501            call.index_count += 6;
502        }
503    }
504
505    pub(crate) fn get_texture_id(&mut self, name: &str) -> Option<u32> {
506        self.texture_registry.get(name).copied()
507    }
508
509    /// fill_rect_with_mode -- Specialized rectangle drawing with mode-specific shader logic.
510    pub fn fill_rect_with_mode(
511        &mut self,
512        rect: Rect,
513        color: [f32; 4],
514        material_id: u32,
515        texture_id: Option<u32>,
516    ) {
517        self.fill_rect_with_full_params(
518            rect,
519            color,
520            material_id,
521            texture_id,
522            0.0,
523            Rect {
524                x: 0.0,
525                y: 0.0,
526                width: 1.0,
527                height: 1.0,
528            },
529        );
530    }
531
532    pub(crate) fn fill_rect_with_full_params(
533        &mut self,
534        rect: Rect,
535        color: [f32; 4],
536        material_id: u32,
537        texture_id: Option<u32>,
538        radius: f32,
539        uv_rect: Rect,
540    ) {
541        // If a shadow is active, draw it first, offset by shadow._offset
542        if let Some(shadow) = self.shadow_stack.last().copied()
543            && shadow.color[3] > 0.001
544        {
545            let shadow_rect = Rect {
546                x: rect.x + shadow._offset[0],
547                y: rect.y + shadow._offset[1],
548                width: rect.width,
549                height: rect.height,
550            };
551            Renderer::draw_drop_shadow(
552                self,
553                shadow_rect,
554                radius,
555                shadow.color,
556                shadow.radius,
557                0.0, // Spread
558            );
559        }
560
561        let slice = self
562            .slice_stack
563            .last()
564            .copied()
565            .map(|(a, o)| [a, o, 1.0, 1.0])
566            .unwrap_or([0.0, 0.0, 0.0, 1.0]);
567        self.fill_rect_with_full_params_and_slice(
568            rect,
569            color,
570            material_id,
571            texture_id,
572            radius,
573            uv_rect,
574            slice,
575            [0.0, 0.0],
576        );
577    }
578
579    #[allow(clippy::too_many_arguments)]
580    pub(crate) fn fill_rect_with_full_params_and_slice(
581        &mut self,
582        mut rect: Rect,
583        color: [f32; 4],
584        material_id: u32,
585        texture_id: Option<u32>,
586        radius: f32,
587        uv_rect: Rect,
588        slice: [f32; 4],
589        _glyph_time: [f32; 2],
590    ) {
591        // Pixel-snap rect coordinates to prevent sub-pixel blurring on high-DPI displays.
592        // Only snap for non-glass materials where visual crispness matters.
593        if material_id != crate::renderer::material_id::GLASS {
594            let scale = self.current_scale_factor();
595            let snap = |v: f32| (v * scale).round() / scale;
596            rect.x = snap(rect.x);
597            rect.y = snap(rect.y);
598            rect.width = snap(rect.width);
599            rect.height = snap(rect.height);
600        }
601
602        let scissor = self.clip_stack.last().copied();
603
604        let material =
605            Self::resolve_material_with_context(material_id, &self.current_draw_material);
606        let final_material_id = match material {
607            cvkg_core::DrawMaterial::Opaque => material_id,
608            cvkg_core::DrawMaterial::TopUI => crate::renderer::material_id::TOP_UI,
609            cvkg_core::DrawMaterial::Glass { .. } => crate::renderer::material_id::GLASS,
610            cvkg_core::DrawMaterial::Blend { mode } => 7 + mode,
611        };
612
613        let (translation, scale_transform, rotation, _, _) = self.current_transform();
614        let (blur_radius, ior_override, glass_intensity) = if let cvkg_core::DrawMaterial::Glass {
615            blur_radius,
616            ior_override,
617            glass_intensity,
618        } = material
619        {
620            (blur_radius, ior_override, glass_intensity)
621        } else {
622            (0.0, 0.0, 1.0)
623        };
624
625        let current_instance_data = InstanceData {
626            translation,
627            scale: scale_transform,
628            rotation,
629            blur_radius,
630            ior_override,
631            glass_intensity,
632        };
633
634        // Batching: check if we need to start a new DrawCall
635        // With Texture Array, we no longer need to break batches when the texture changes,
636        // as long as they are all part of the same array bind group (Group 0).
637        // CRITICAL FIX: Only break batch on material/scissor/blur/glass state changes.
638        // Transform (translation/scale/rotation) is per-instance data and should NOT
639        // break the batch - multiple instances with different transforms can share a draw call.
640        let last_call = self.draw_calls.last();
641        let needs_new_call = self.draw_calls.is_empty()
642            || last_call.unwrap().scissor_rect != scissor
643            || last_call.unwrap().material != material
644            || last_call.unwrap().texture_id != self.current_texture_id
645            || {
646                // Check if glass/blur state changed (these require pipeline changes)
647                let last_material = last_call.unwrap().material;
648                matches!((material, last_material),
649                    (cvkg_core::DrawMaterial::Glass { blur_radius: a, ior_override: b, glass_intensity: c },
650                     cvkg_core::DrawMaterial::Glass { blur_radius: d, ior_override: e, glass_intensity: f })
651                    if a != d || b != e || c != f)
652            };
653
654        if needs_new_call {
655            self.current_texture_id = Some(0); // All textures are now in the binding array at Group 0
656            self.instance_data.push(current_instance_data);
657            self.draw_calls.push(DrawCall {
658                target_id: None,
659                texture_id: self.current_texture_id,
660                scissor_rect: scissor,
661                index_start: self.indices.len() as u32,
662                index_count: 0,
663                instance_count: 1,
664                material,
665                instance_start: (self.instance_data.len() - 1) as u32,
666                draw_order: 0,
667            });
668        } else {
669            // Same batch - add instance data and increment instance count
670            self.instance_data.push(current_instance_data);
671            if let Some(call) = self.draw_calls.last_mut() {
672                call.instance_count += 1;
673            }
674        }
675
676        let scale = self.current_scale_factor();
677        let snap = |v: f32| (v * scale).round() / scale;
678
679        let base_idx = self.vertices.len() as u32;
680        let x1 = snap(rect.x);
681        let y1 = snap(rect.y);
682        let x2 = snap(rect.x + rect.width);
683        let y2 = snap(rect.y + rect.height);
684        // Negate z-index: higher z-index should be closer (win under GreaterEqual depth test)
685        let z = -self.current_z;
686        let normal = [0.0, 0.0, 1.0];
687        let clip_rect = self.clip_stack.last().copied().unwrap_or(cvkg_core::Rect {
688            x: -10000.0,
689            y: -10000.0,
690            width: 20000.0,
691            height: 20000.0,
692        });
693        let clip = [clip_rect.x, clip_rect.y, clip_rect.width, clip_rect.height];
694
695        let tex_index = texture_id.unwrap_or(0);
696
697        self.vertices.push(Vertex {
698            position: [x1, y1, z],
699            normal,
700            uv: [uv_rect.x, uv_rect.y],
701            color,
702            material_id: final_material_id,
703            radius,
704            slice,
705            logical: [0.0, 0.0],
706            size: [rect.width, rect.height],
707            clip,
708            tex_index,
709        });
710        self.vertices.push(Vertex {
711            position: [x2, y1, z],
712            normal,
713            uv: [uv_rect.x + uv_rect.width, uv_rect.y],
714            color,
715            material_id: final_material_id,
716            radius,
717            slice,
718            logical: [rect.width, 0.0],
719            size: [rect.width, rect.height],
720            clip,
721            tex_index,
722        });
723        self.vertices.push(Vertex {
724            position: [x2, y2, z],
725            normal,
726            uv: [uv_rect.x + uv_rect.width, uv_rect.y + uv_rect.height],
727            color,
728            material_id: final_material_id,
729            radius,
730            slice,
731            logical: [rect.width, rect.height],
732            size: [rect.width, rect.height],
733            clip,
734            tex_index,
735        });
736        self.vertices.push(Vertex {
737            position: [x1, y2, z],
738            normal,
739            uv: [uv_rect.x, uv_rect.y + uv_rect.height],
740            color,
741            material_id: final_material_id,
742            radius,
743            slice,
744            logical: [0.0, rect.height],
745            size: [rect.width, rect.height],
746            clip,
747            tex_index,
748        });
749
750        self.indices.extend_from_slice(&[
751            base_idx,
752            base_idx + 1,
753            base_idx + 2,
754            base_idx,
755            base_idx + 2,
756            base_idx + 3,
757        ]);
758
759        if let Some(call) = self.draw_calls.last_mut() {
760            call.index_count += 6;
761        }
762    }
763
764    /// Pass 1: Clear scene+depth, draw atmosphere, draw opaque geometry.
765    /// end_frame -- Quench the blade by submitting the full Muspelheim multi-pass effect.
766    ///
767    /// Since the Renderer 3.0 migration, the pass sequence is driven by a Kvasir
768    /// dependency graph rather than hardcoded ordering. The graph is built each
769    /// frame (cheap -- just node/edge allocation), validated (cycle detection,
770    /// input satisfiability), then executed. Conditional passes (glass, bloom,
771    /// accessibility) are automatically eliminated when not needed.
772    pub fn end_frame(&mut self, mut encoder: wgpu::CommandEncoder) {
773        struct ActiveFrameResources {
774            surface_texture: Option<wgpu::SurfaceTexture>,
775            target_view: wgpu::TextureView,
776            scene_texture: wgpu::TextureView,
777            scene_msaa_texture: wgpu::TextureView,
778            depth_texture_view: wgpu::TextureView,
779            blur_env_bind_group_a: wgpu::BindGroup,
780            blur_env_bind_group_b: wgpu::BindGroup,
781            bloom_env_bind_group_a: wgpu::BindGroup,
782            bloom_env_bind_group_b: wgpu::BindGroup,
783        }
784
785        let res = if let Some(window_id) = self.current_window {
786            let Some(ctx) = self.surfaces.get(&window_id) else {
787                tracing::error!("[GPU] Missing surface context for end_frame");
788                return;
789            };
790            let frame = match ctx.surface.get_current_texture() {
791                wgpu::CurrentSurfaceTexture::Success(t) => t,
792                wgpu::CurrentSurfaceTexture::Suboptimal(t) => {
793                    ctx.surface.configure(&self.device, &ctx.config);
794                    t
795                }
796                other => {
797                    tracing::warn!(
798                        "[GPU] Surface texture acquisition failed ({:?}), reconfiguring surface",
799                        other
800                    );
801                    ctx.surface.configure(&self.device, &ctx.config);
802                    // Retry once after reconfiguration; if it fails again, skip the frame.
803                    match ctx.surface.get_current_texture() {
804                        wgpu::CurrentSurfaceTexture::Success(t) => t,
805                        wgpu::CurrentSurfaceTexture::Suboptimal(t) => {
806                            ctx.surface.configure(&self.device, &ctx.config);
807                            t
808                        }
809                        retry_failed => {
810                            tracing::error!(
811                                "[GPU] Surface texture retry also failed ({:?}), skipping frame",
812                                retry_failed
813                            );
814                            self.queue.submit(std::iter::once(encoder.finish()));
815                            return;
816                        }
817                    }
818                }
819            };
820            let view = frame
821                .texture
822                .create_view(&wgpu::TextureViewDescriptor::default());
823
824            ActiveFrameResources {
825                surface_texture: Some(frame),
826                target_view: view,
827                scene_texture: ctx.scene_texture.clone(),
828                scene_msaa_texture: ctx.scene_msaa_texture.clone(),
829                depth_texture_view: ctx.depth_texture_view.clone(),
830                blur_env_bind_group_a: ctx.blur_env_bind_group_a.clone(),
831                blur_env_bind_group_b: ctx.blur_env_bind_group_b.clone(),
832                bloom_env_bind_group_a: ctx.bloom_env_bind_group_a.clone(),
833                bloom_env_bind_group_b: ctx.bloom_env_bind_group_b.clone(),
834            }
835        } else {
836            let Some(ctx) = self.headless_context.as_ref() else {
837                tracing::error!("[GPU] No headless context for end_frame");
838                return;
839            };
840
841            ActiveFrameResources {
842                surface_texture: None,
843                target_view: ctx.output_view.clone(),
844                scene_texture: ctx.scene_texture.clone(),
845                scene_msaa_texture: ctx.scene_msaa_texture.clone(),
846                depth_texture_view: ctx.depth_texture_view.clone(),
847                blur_env_bind_group_a: ctx.blur_env_bind_group_a.clone(),
848                blur_env_bind_group_b: ctx.blur_env_bind_group_b.clone(),
849                bloom_env_bind_group_a: ctx.bloom_env_bind_group_a.clone(),
850                bloom_env_bind_group_b: ctx.bloom_env_bind_group_b.clone(),
851            }
852        };
853
854        // Auto-flush staging belt if render_frame() was not called but geometry was queued.
855        // This ensures apps that forget render_frame() still see their draw calls rendered.
856        if !self.frame_rendered && (!self.vertices.is_empty() || !self.indices.is_empty()) {
857            tracing::debug!(
858                "[GPU] Auto-flushing staging belt in end_frame (render_frame was not called)"
859            );
860            let mut staging_encoder =
861                self.device
862                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
863                        label: Some("Surtr Auto-Flush Staging Encoder"),
864                    });
865            if !self.vertices.is_empty() {
866                let v_bytes = bytemuck::cast_slice(&self.vertices);
867                self.staging_belt
868                    .write_buffer(
869                        &mut staging_encoder,
870                        &self.geometry_buffers.vertex_buffer,
871                        0,
872                        wgpu::BufferSize::new(v_bytes.len() as u64).unwrap(),
873                    )
874                    .copy_from_slice(v_bytes);
875            }
876            if !self.indices.is_empty() {
877                let i_bytes = bytemuck::cast_slice(&self.indices);
878                self.staging_belt
879                    .write_buffer(
880                        &mut staging_encoder,
881                        &self.geometry_buffers.index_buffer,
882                        0,
883                        wgpu::BufferSize::new(i_bytes.len() as u64).unwrap(),
884                    )
885                    .copy_from_slice(i_bytes);
886            }
887            if !self.instance_data.is_empty() {
888                let inst_bytes = bytemuck::cast_slice(&self.instance_data);
889                self.staging_belt
890                    .write_buffer(
891                        &mut staging_encoder,
892                        &self.geometry_buffers.instance_buffer,
893                        0,
894                        wgpu::BufferSize::new(inst_bytes.len() as u64).unwrap(),
895                    )
896                    .copy_from_slice(inst_bytes);
897            }
898            self.staging_belt.finish();
899            self.staging_command_buffers.push(staging_encoder.finish());
900        }
901
902        // ── Build and execute the Kvasir frame graph ─────────────────────────────
903        let has_glass = self
904            .draw_calls
905            .iter()
906            .any(|c| matches!(c.material, cvkg_core::DrawMaterial::Glass { .. }));
907        let has_bloom = self.bloom_enabled;
908        let has_accessibility =
909            self.color_blind_mode != crate::color_blindness::ColorBlindMode::Normal;
910
911        // Build the frame graph using the Kvasir helper for correct pass ordering.
912        // Conditional passes (glass, bloom, accessibility) are included/excluded based on frame state.
913        // This replaces the hardcoded if/else pass dispatch with a data-driven approach:
914        // the graph declares which passes exist and their ordering, and we execute only enabled ones.
915        //
916        // NOTE: Geometry is uploaded by render_frame() via StagingBelt into staging_command_buffers.
917        // Those staging commands must be submitted before the render pass encoders below, which is
918        // guaranteed by inserting the render encoders after the existing staging entries (see submit block).
919
920        let (blur_id, bloom_id) = if let Some(window_id) = self.current_window {
921            let ctx = self.surfaces.get(&window_id).unwrap();
922            (ctx.blur_tex_a, ctx.bloom_tex_a)
923        } else {
924            let ctx = self.headless_context.as_ref().unwrap();
925            (ctx.blur_tex_a, ctx.bloom_tex_a)
926        };
927        self.registry
928            .alias(crate::kvasir::nodes::RES_BLUR_A, blur_id);
929        self.registry
930            .alias(crate::kvasir::nodes::RES_BLOOM_A, bloom_id);
931        self.registry
932            .alias_view(crate::kvasir::nodes::RES_SCENE, res.scene_texture.clone());
933        self.registry.alias_view(
934            crate::kvasir::nodes::RES_SCENE_MSAA,
935            res.scene_msaa_texture.clone(),
936        );
937
938        let scale = self.current_scale_factor();
939        let scale_bits = scale.to_bits();
940        let active_offscreens_count = self.active_offscreens.len();
941        let portal_regions_count = self.portal_regions.len();
942        let width = self.current_width();
943        let height = self.current_height();
944        let has_volumetric = self.volumetric_enabled;
945
946        // Compute content hashes for cache key (must match construction site)
947        let mut offscreen_hash: u64 = 0;
948        for offscreen in &self.active_offscreens {
949            offscreen_hash = offscreen_hash.wrapping_add(
950                offscreen.target_id.wrapping_mul(31)
951                    ^ (offscreen.blend_mode as u64).wrapping_mul(17),
952            );
953        }
954        let mut portal_hash: u64 = 0;
955        for region in &self.portal_regions {
956            portal_hash = portal_hash.wrapping_add(
957                (region.x.to_bits() as u64)
958                    .wrapping_mul(7)
959                    .wrapping_add((region.y.to_bits() as u64).wrapping_mul(13))
960                    .wrapping_add((region.width.to_bits() as u64).wrapping_mul(19))
961                    .wrapping_add((region.height.to_bits() as u64).wrapping_mul(23)),
962            );
963        }
964
965        let use_cache = if let Some(ref cached) = self.cached_graph_plan {
966            cached.matches(
967                has_glass,
968                has_bloom,
969                has_accessibility,
970                has_volumetric,
971                active_offscreens_count,
972                offscreen_hash,
973                portal_regions_count,
974                portal_hash,
975                width,
976                height,
977                scale_bits,
978                self.material_compilation_hash,
979            )
980        } else {
981            false
982        };
983
984        if !use_cache {
985            let render_graph = crate::kvasir::nodes::build_render_graph(
986                &crate::kvasir::nodes::RenderGraphConfig {
987                    has_glass,
988                    has_bloom,
989                    has_accessibility,
990                    has_volumetric,
991                    active_offscreens: &self.active_offscreens,
992                    portal_regions: &self.portal_regions.iter().cloned().collect::<Vec<_>>(),
993                    width,
994                    height,
995                    scale,
996                },
997            );
998            let planner = crate::kvasir::planner::ExecutionPlanner::new(&render_graph);
999            let compiled_plan = match planner.compile() {
1000                Ok(plan) => plan,
1001                Err(e) => {
1002                    tracing::error!(
1003                        "[Kvasir] Render graph compilation failed ({}), skipping render passes",
1004                        e
1005                    );
1006                    // Present the frame with whatever was rendered (stale scene or blank).
1007                    if let Some(surface_texture) = res.surface_texture {
1008                        surface_texture.present();
1009                        tracing::info!("[Surtr] Frame presented (graph compilation fallback)");
1010                    }
1011                    return;
1012                }
1013            };
1014
1015            // Reuse the already-computed hashes (computed above for cache matching)
1016            self.cached_graph_plan = Some(crate::kvasir::graph_cache::CachedGraphPlan {
1017                has_glass,
1018                has_bloom,
1019                has_accessibility,
1020                has_volumetric,
1021                active_offscreens_count,
1022                offscreen_content_hash: offscreen_hash,
1023                portal_regions_count,
1024                portal_content_hash: portal_hash,
1025                width,
1026                height,
1027                scale_bits,
1028                material_compilation_hash: self.material_compilation_hash,
1029                graph: render_graph,
1030                plan: compiled_plan,
1031            });
1032        }
1033
1034        let cached = self.cached_graph_plan.as_ref().unwrap();
1035        let frame_start = self.last_frame_start;
1036        let budget_ms = self.frame_budget.target_ms;
1037        let allow_degradation = self.frame_budget.allow_degradation;
1038
1039        for &node_key in &cached.plan {
1040            // Frame budget enforcement: if we're already over budget and degradation
1041            // is allowed, skip expensive COSMETIC passes (bloom, volumetric).
1042            //
1043            // P0-2 fix: BackdropBlur, BackdropRegion, and Accessibility are FUNCTIONAL
1044            // passes, not cosmetic effects:
1045            //   * BackdropBlur/BackdropRegion implement glassmorphism (frosted glass
1046            //     panels, modals, sidebars). Skipping them makes glass elements
1047            //     render as opaque solid rectangles, breaking the visual contract
1048            //     for any app using glass materials.
1049            //   * Accessibility is required for screen readers and other AT;
1050            //     skipping it makes the UI unusable for visually-impaired users.
1051            // Only BloomExtract/BloomBlur (post-processing glow) and Volumetric
1052            // (raymarched lighting) are true cosmetics and safe to degrade.
1053            if allow_degradation && budget_ms > 0.0 {
1054                let elapsed_ms = frame_start.elapsed().as_secs_f32() * 1000.0;
1055                if elapsed_ms > budget_ms
1056                    && let Some(node) = cached.graph.node(node_key)
1057                {
1058                    match node.pass_id() {
1059                        crate::kvasir::nodes::PassId::BloomExtract
1060                        | crate::kvasir::nodes::PassId::BloomBlur
1061                        | crate::kvasir::nodes::PassId::Volumetric => {
1062                            tracing::trace!(
1063                                "[Kvasir] Skipping {} (over budget: {:.1}ms > {:.1}ms)",
1064                                node.label(),
1065                                elapsed_ms,
1066                                budget_ms
1067                            );
1068                            continue;
1069                        }
1070                        _ => {} // Always run: Glass, BackdropBlur, BackdropRegion,
1071                                // Accessibility, Geometry, UI, Composite, Present, ...
1072                    }
1073                }
1074            }
1075            if let Some(node) = cached.graph.node(node_key) {
1076                tracing::trace!("[Kvasir] Executing node: {}", node.label());
1077                let mut ctx = crate::kvasir::node::ExecutionContext {
1078                    device: &self.device,
1079                    queue: &self.queue,
1080                    encoder: &mut encoder,
1081                    registry: &self.registry,
1082                    renderer: self,
1083                    target_view: &res.target_view,
1084                    depth_view: &res.depth_texture_view,
1085                    blur_env_bind_group_a: &res.blur_env_bind_group_a,
1086                    blur_env_bind_group_b: &res.blur_env_bind_group_b,
1087                    bloom_env_bind_group_a: &res.bloom_env_bind_group_a,
1088                    bloom_env_bind_group_b: &res.bloom_env_bind_group_b,
1089                    scale_factor: scale,
1090                };
1091                node.execute(&mut ctx);
1092            }
1093        }
1094
1095        // ── Particle Compute Pass ──────────────────────────────────────────
1096        // Flush staged particles to GPU, then run compute integration.
1097        // Must run BEFORE the submit so particle positions are up-to-date.
1098        if !self.particles.staging.is_empty() || self.particles.count > 0 {
1099            // 1. Flush staged particles into the ring buffer
1100            if !self.particles.staging.is_empty() {
1101                let write_start = self.particles.write_head as usize;
1102                let write_count = self.particles.staging.len();
1103                let max = MAX_PARTICLES;
1104
1105                // P1-6 fix: cap the write to max particles to prevent
1106                // wrap-around overlap. If write_count > max, only the
1107                // LAST `max` particles are kept (the most recent ones
1108                // are most relevant for particle effects, and the
1109                // earlier ones are dropped). Without this cap, if
1110                // write_count > max - write_start, the second chunk
1111                // would write past offset 0 and overlap the first
1112                // chunk, corrupting the buffer.
1113                let effective_count = write_count.min(max);
1114                let drop_count = write_count - effective_count;
1115
1116                // Write particles in ring-buffer fashion
1117                let first_chunk = (max - write_start).min(effective_count);
1118                let bytes = bytemuck::cast_slice(
1119                    &self.particles.staging[drop_count..drop_count + first_chunk],
1120                );
1121                self.queue.write_buffer(
1122                    &self.particle_buffer,
1123                    (write_start * std::mem::size_of::<crate::types::GpuParticle>()) as u64,
1124                    bytes,
1125                );
1126                if first_chunk < effective_count {
1127                    let remaining = effective_count - first_chunk;
1128                    let bytes2 = bytemuck::cast_slice(
1129                        &self.particles.staging
1130                            [drop_count + first_chunk..drop_count + first_chunk + remaining],
1131                    );
1132                    self.queue.write_buffer(&self.particle_buffer, 0, bytes2);
1133                    self.particles.write_head = remaining as u32;
1134                } else {
1135                    self.particles.write_head = ((write_start + effective_count) % max) as u32;
1136                }
1137                self.particles.count =
1138                    (self.particles.count as usize + effective_count).min(max) as u32;
1139                self.particles.staging.clear();
1140
1141                // Invalidate render bind group so it's recreated with new data
1142                self.particle_render_bind_group = None;
1143            }
1144
1145            // 2. Run compute pass to integrate particle physics
1146            let dt = self.current_scene.delta_time;
1147            let uniforms = crate::types::ParticleUniforms { dt, _pad: [0.0; 7] };
1148            self.queue.write_buffer(
1149                &self.particle_uniform_buffer,
1150                0,
1151                bytemuck::bytes_of(&uniforms),
1152            );
1153
1154            let compute_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1155                label: Some("Particle Compute BG"),
1156                layout: &self.particle_compute_bgl,
1157                entries: &[
1158                    wgpu::BindGroupEntry {
1159                        binding: 0,
1160                        resource: self.particle_buffer.as_entire_binding(),
1161                    },
1162                    wgpu::BindGroupEntry {
1163                        binding: 1,
1164                        resource: self.particle_uniform_buffer.as_entire_binding(),
1165                    },
1166                ],
1167            });
1168
1169            let mut compute_encoder =
1170                self.device
1171                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1172                        label: Some("Particle Compute Encoder"),
1173                    });
1174            {
1175                let mut cpass = compute_encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1176                    label: Some("Particle Integration"),
1177                    ..Default::default()
1178                });
1179                cpass.set_pipeline(&self.particle_compute_pipeline);
1180                cpass.set_bind_group(0, &compute_bind_group, &[]);
1181                let workgroups = self.particles.count.div_ceil(64).max(1);
1182                cpass.dispatch_workgroups(workgroups, 1, 1);
1183            }
1184            self.staging_command_buffers.push(compute_encoder.finish());
1185        }
1186
1187        // 3. Compact dead particles periodically (every 2 seconds)
1188        if self.particles.count > 0 && self.particles.last_compact.elapsed().as_secs_f32() > 2.0 {
1189            self.particles.last_compact = std::time::Instant::now();
1190            // Read back particle data to compact dead particles
1191            let read_size = (self.particles.count as usize
1192                * std::mem::size_of::<crate::types::GpuParticle>())
1193                as u64;
1194            let staging_buf = self.device.create_buffer(&wgpu::BufferDescriptor {
1195                label: Some("Particle Compact Staging"),
1196                size: read_size,
1197                usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1198                mapped_at_creation: false,
1199            });
1200            let mut compact_encoder =
1201                self.device
1202                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1203                        label: Some("Particle Compact Copy"),
1204                    });
1205            compact_encoder.copy_buffer_to_buffer(
1206                &self.particle_buffer,
1207                0,
1208                &staging_buf,
1209                0,
1210                read_size,
1211            );
1212            self.staging_command_buffers.push(compact_encoder.finish());
1213            // Note: full GPU readback is expensive; in production we'd use a
1214            // compute compaction pass. For now, dead particles are simply
1215            // overwritten by new ones in the ring buffer (lifetime <= 0 causes
1216            // the vertex shader to output degenerate points behind the camera).
1217        }
1218
1219        // ── Particle Render Pass ────────────────────────────────────────────
1220        // Render live particles as colored points to the swapchain target,
1221        // composited on top of the scene with additive blending.
1222        if self.particles.count > 0 {
1223            // Lazily (re)create the render bind group when staging changed
1224            if self.particle_render_bind_group.is_none() {
1225                self.particle_render_bind_group =
1226                    Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1227                        label: Some("Particle Render BG"),
1228                        layout: &self.particle_render_bgl,
1229                        entries: &[wgpu::BindGroupEntry {
1230                            binding: 0,
1231                            resource: self.particle_buffer.as_entire_binding(),
1232                        }],
1233                    }));
1234            }
1235            if let Some(bg) = &self.particle_render_bind_group {
1236                let mut render_encoder =
1237                    self.device
1238                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1239                            label: Some("Particle Render Encoder"),
1240                        });
1241                {
1242                    let mut rpass = render_encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1243                        label: Some("Particle Render"),
1244                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1245                            view: &res.target_view,
1246                            resolve_target: None,
1247                            ops: wgpu::Operations {
1248                                load: wgpu::LoadOp::Load,
1249                                store: wgpu::StoreOp::Store,
1250                            },
1251                            depth_slice: None,
1252                        })],
1253                        depth_stencil_attachment: None,
1254                        timestamp_writes: None,
1255                        occlusion_query_set: None,
1256                        multiview_mask: None,
1257                    });
1258                    rpass.set_pipeline(&self.particle_render_pipeline);
1259                    rpass.set_bind_group(0, bg, &[]);
1260                    rpass.draw(0..self.particles.count, 0..1);
1261                }
1262                self.staging_command_buffers.push(render_encoder.finish());
1263            }
1264        }
1265
1266        // ── Submit ─────────────────────────────────────────────────────────────
1267        // staging_command_buffers already contains the geometry upload encoder from
1268        // render_frame() (StagingBelt). The render pass encoders must come AFTER it
1269        // so the GPU sees vertex/index data before the draw calls that reference it.
1270        self.staging_command_buffers.push(encoder.finish());
1271
1272        // Skuld: Resolve timestamps (preserved from original)
1273        if let (Some(q), Some(b), Some(rb)) = (
1274            &self.skuld_queries,
1275            &self.skuld_buffer,
1276            &self.skuld_read_buffer,
1277        ) {
1278            let mut resolve_encoder =
1279                self.device
1280                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1281                        label: Some("Skuld Resolve Encoder"),
1282                    });
1283            resolve_encoder.resolve_query_set(q, 0..2, b, 0);
1284            resolve_encoder.copy_buffer_to_buffer(b, 0, rb, 0, 16);
1285            self.staging_command_buffers.push(resolve_encoder.finish());
1286        }
1287
1288        let cmds = std::mem::take(&mut self.staging_command_buffers);
1289        self.queue.submit(cmds);
1290        self.telemetry.frame_time_ms = self.last_frame_start.elapsed().as_secs_f32() * 1000.0;
1291        self.update_vram_telemetry();
1292
1293        // Evict transient frame resources (portal regions, offscreen effects) back into
1294        // the texture pool instead of leaking GPU memory when panels are closed.
1295        self.registry.evict_frame_resources();
1296
1297        if let Some(f) = res.surface_texture {
1298            f.present();
1299            tracing::info!("[Surtr] Frame presented");
1300        }
1301    }
1302
1303    /// Submit pre-routed draw command buckets from the cvkg-compositor.
1304    ///
1305    /// Accepts `CommandBuckets` produced by `CompositorEngine::flatten_and_route()`
1306    /// and submits draw calls in the correct pass order for the Backdrop Capture
1307    /// Architecture:
1308    /// 1. Scene commands (opaque) → Scene Capture pass
1309    /// 2. Glass commands → Material Composite pass (samples blur pyramid)
1310    /// 3. Overlay commands → Top-Level Foreground pass
1311    pub fn submit_buckets(&mut self, buckets: &cvkg_compositor::CommandBuckets) {
1312        // Scene pass -- opaque draw calls, sorted by (z_index, draw_order)
1313        let mut active_offscreens = Vec::new();
1314        let mut current_target_id = None;
1315
1316        // Collect and sort scene commands by (z_index, draw_order) for correct painter's order.
1317        let mut sorted_scene: Vec<_> = buckets.scene_commands.iter().collect();
1318        sorted_scene.sort_by_key(|cmd| match cmd {
1319            cvkg_compositor::engine::RenderCommand::Draw(routed) => {
1320                (routed.z_index as i64, routed.draw_order as i64)
1321            }
1322            _ => (0, 0),
1323        });
1324
1325        for cmd in sorted_scene {
1326            match cmd {
1327                cvkg_compositor::engine::RenderCommand::Draw(routed) => {
1328                    self.set_material(cvkg_core::DrawMaterial::Opaque);
1329                    self.submit_routed(routed, current_target_id);
1330                }
1331                cvkg_compositor::engine::RenderCommand::PushOffscreen {
1332                    source_layer,
1333                    material,
1334                    bounds,
1335                } => {
1336                    current_target_id = Some(source_layer.0);
1337
1338                    // Pre-allocate the texture
1339                    let width = (bounds.width).max(1.0) as u32;
1340                    let height = (bounds.height).max(1.0) as u32;
1341                    self.registry
1342                        .allocate_offscreen(&self.device, source_layer.0, [width, height]);
1343
1344                    if let cvkg_compositor::Material::ShaderEffect {
1345                        effect_name,
1346                        params_json: _,
1347                        ..
1348                    } = material
1349                    {
1350                        active_offscreens.push(crate::types::OffscreenEffectConfig {
1351                            target_id: source_layer.0,
1352                            effect: effect_name.clone(),
1353                            blend_mode: 0,          // Default blend
1354                            effect_args: [0.0; 16], // Need to parse params_json
1355                        });
1356                    }
1357                }
1358                cvkg_compositor::engine::RenderCommand::PopOffscreen => {
1359                    current_target_id = None;
1360                }
1361            }
1362        }
1363        self.active_offscreens = active_offscreens;
1364
1365        // Glass pass -- glassmorphism draw calls sampling blur pyramid
1366        let mut sorted_glass: Vec<_> = buckets.glass_commands.iter().collect();
1367        sorted_glass.sort_by_key(|cmd| match cmd {
1368            cvkg_compositor::engine::RenderCommand::Draw(routed) => {
1369                (routed.z_index as i64, routed.draw_order as i64)
1370            }
1371            _ => (0, 0),
1372        });
1373        for cmd in sorted_glass {
1374            if let cvkg_compositor::engine::RenderCommand::Draw(routed) = cmd {
1375                self.set_material(Self::convert_compositor_material(&routed.material));
1376                self.submit_routed(routed, None);
1377            }
1378        }
1379
1380        // Overlay pass -- foreground UI (crisp text, icons, edge lighting)
1381        let mut sorted_overlay: Vec<_> = buckets.overlay_commands.iter().collect();
1382        sorted_overlay.sort_by_key(|cmd| match cmd {
1383            cvkg_compositor::engine::RenderCommand::Draw(routed) => {
1384                (routed.z_index as i64, routed.draw_order as i64)
1385            }
1386            _ => (0, 0),
1387        });
1388        for cmd in sorted_overlay {
1389            if let cvkg_compositor::engine::RenderCommand::Draw(routed) = cmd {
1390                self.set_material(cvkg_core::DrawMaterial::TopUI);
1391                self.submit_routed(routed, None);
1392            }
1393        }
1394    }
1395
1396    /// Submit a single routed draw command through the internal pipeline.
1397    pub(crate) fn submit_routed(
1398        &mut self,
1399        routed: &cvkg_compositor::RoutedDrawCommand,
1400        target_id: Option<u64>,
1401    ) {
1402        let cmd = &routed.command;
1403        if cmd.index_count == 0 {
1404            return;
1405        }
1406        let material = Self::convert_compositor_material(&routed.material);
1407        self.draw_calls.push(DrawCall {
1408            texture_id: cmd.texture_id,
1409            scissor_rect: cmd.scissor_rect,
1410            index_start: cmd.index_start,
1411            index_count: cmd.index_count,
1412            instance_count: 1,
1413            material,
1414            target_id,
1415            instance_start: cmd.instance_id,
1416            draw_order: 0,
1417        });
1418    }
1419
1420    /// Returns the current effective opacity (product of all stacked values).
1421    pub(crate) fn apply_opacity(&self, mut color: [f32; 4]) -> [f32; 4] {
1422        if let Some(&alpha) = self.opacity_stack.last() {
1423            color[3] *= alpha;
1424        }
1425        color
1426    }
1427
1428    /// Resolve a material_id to DrawMaterial with default parameters.
1429    /// Used by draw_svg which doesn't have a current_draw_material context.
1430    pub(crate) fn resolve_material(material_id: u32) -> cvkg_core::DrawMaterial {
1431        Self::resolve_material_with_context(material_id, &cvkg_core::DrawMaterial::Opaque)
1432    }
1433
1434    /// Resolve a material_id to DrawMaterial, using current_draw_material as context
1435    /// for glass parameters. Centralizes the material routing logic used by both
1436    /// fill_rect_with_full_params_and_slice and emit_draw_call.
1437    pub(crate) fn resolve_material_with_context(
1438        material_id: u32,
1439        current: &cvkg_core::DrawMaterial,
1440    ) -> cvkg_core::DrawMaterial {
1441        use crate::renderer::material_id::*;
1442
1443        // If current context is TopUI, route all non-glass elements to the overlay pass.
1444        // This ensures dropdowns, popovers, and menus render crisp text/shapes on top of other content.
1445        if matches!(current, cvkg_core::DrawMaterial::TopUI) && material_id != GLASS {
1446            return cvkg_core::DrawMaterial::TopUI;
1447        }
1448
1449        // If current context has an active Blend mode, route standard opaque quads to that Blend mode.
1450        if let cvkg_core::DrawMaterial::Blend { mode } = current
1451            && material_id == 0
1452        {
1453            return cvkg_core::DrawMaterial::Blend { mode: *mode };
1454        }
1455
1456        match material_id {
1457            GLASS => {
1458                if let cvkg_core::DrawMaterial::Glass {
1459                    blur_radius,
1460                    ior_override,
1461                    glass_intensity,
1462                } = current
1463                {
1464                    cvkg_core::DrawMaterial::Glass {
1465                        blur_radius: *blur_radius,
1466                        ior_override: *ior_override,
1467                        glass_intensity: *glass_intensity,
1468                    }
1469                } else {
1470                    cvkg_core::DrawMaterial::Glass {
1471                        blur_radius: 20.0,
1472                        ior_override: 0.0,
1473                        glass_intensity: 1.0,
1474                    }
1475                }
1476            }
1477            TOP_UI => cvkg_core::DrawMaterial::TopUI,
1478            BLEND_START..=BLEND_END => cvkg_core::DrawMaterial::Blend {
1479                mode: (material_id - 7),
1480            },
1481            _ => cvkg_core::DrawMaterial::Opaque,
1482        }
1483    }
1484
1485    /// Convert a compositor Material to a core DrawMaterial.
1486    /// Centralizes the mapping used by submit_buckets and submit_routed.
1487    pub(crate) fn convert_compositor_material(
1488        mat: &cvkg_compositor::Material,
1489    ) -> cvkg_core::DrawMaterial {
1490        match mat {
1491            cvkg_compositor::Material::Glass { blur_radius, .. } => {
1492                cvkg_core::DrawMaterial::Glass {
1493                    blur_radius: *blur_radius,
1494                    ior_override: 0.0,
1495                    glass_intensity: 1.0,
1496                }
1497            }
1498            cvkg_compositor::Material::Overlay => cvkg_core::DrawMaterial::TopUI,
1499            cvkg_compositor::Material::Multiply => cvkg_core::DrawMaterial::Blend { mode: 1 },
1500            cvkg_compositor::Material::Screen => cvkg_core::DrawMaterial::Blend { mode: 2 },
1501            cvkg_compositor::Material::BlendOverlay => cvkg_core::DrawMaterial::Blend { mode: 3 },
1502            cvkg_compositor::Material::Darken => cvkg_core::DrawMaterial::Blend { mode: 4 },
1503            cvkg_compositor::Material::Lighten => cvkg_core::DrawMaterial::Blend { mode: 5 },
1504            cvkg_compositor::Material::ColorDodge => cvkg_core::DrawMaterial::Blend { mode: 6 },
1505            cvkg_compositor::Material::ColorBurn => cvkg_core::DrawMaterial::Blend { mode: 7 },
1506            cvkg_compositor::Material::HardLight => cvkg_core::DrawMaterial::Blend { mode: 8 },
1507            cvkg_compositor::Material::SoftLight => cvkg_core::DrawMaterial::Blend { mode: 9 },
1508            cvkg_compositor::Material::Difference => cvkg_core::DrawMaterial::Blend { mode: 10 },
1509            cvkg_compositor::Material::Exclusion => cvkg_core::DrawMaterial::Blend { mode: 11 },
1510            cvkg_compositor::Material::Hue => cvkg_core::DrawMaterial::Blend { mode: 12 },
1511            cvkg_compositor::Material::Saturation => cvkg_core::DrawMaterial::Blend { mode: 13 },
1512            cvkg_compositor::Material::Color => cvkg_core::DrawMaterial::Blend { mode: 14 },
1513            cvkg_compositor::Material::Luminosity => cvkg_core::DrawMaterial::Blend { mode: 15 },
1514            cvkg_compositor::Material::Opaque => cvkg_core::DrawMaterial::Opaque,
1515            _ => cvkg_core::DrawMaterial::Opaque,
1516        }
1517    }
1518
1519    /// Helper: position vertices from SVG view_box into output rect.
1520    pub(crate) fn position_vertices(
1521        vertices: &mut [Vertex],
1522        view_box: Rect,
1523        rect: Rect,
1524        material_id: u32,
1525        clip: [f32; 4],
1526        snap: impl Fn(f32) -> f32,
1527    ) {
1528        for v in vertices.iter_mut() {
1529            let rel_x = (v.position[0] - view_box.x) / view_box.width;
1530            let rel_y = (v.position[1] - view_box.y) / view_box.height;
1531            v.position[0] = snap(rect.x + rel_x * rect.width);
1532            v.position[1] = snap(rect.y + rel_y * rect.height);
1533            v.position[2] = 0.0; // z will be set by transform stack
1534            v.logical = [v.position[0], v.position[1]];
1535            v.clip = clip;
1536            v.material_id = material_id;
1537        }
1538    }
1539
1540    /// Helper: emit a draw call for a batch of vertices.
1541    pub(crate) fn emit_draw_call(
1542        renderer: &mut GpuRenderer,
1543        material: cvkg_core::DrawMaterial,
1544        texture_id: Option<u32>,
1545        scissor_rect: Rect,
1546        index_count: u32,
1547        base_vertex: u32,
1548    ) {
1549        let draw_order = renderer.current_draw_order;
1550        let (translation, scale_transform, rotation, _, _) = renderer.current_transform();
1551        let current_instance_data = InstanceData {
1552            translation,
1553            scale: scale_transform,
1554            rotation,
1555            blur_radius: 0.0,
1556            ior_override: 0.0,
1557            glass_intensity: 1.0,
1558        };
1559        // CRITICAL FIX: Only break batch on material/scissor/texture state changes.
1560        // Transform (translation/scale/rotation) is per-instance data.
1561        let last_call = renderer.draw_calls.last();
1562        let needs_new_call = renderer.draw_calls.is_empty()
1563            || renderer.current_texture_id != texture_id
1564            || last_call.unwrap().scissor_rect != renderer.clip_stack.last().copied()
1565            || last_call.unwrap().material != material
1566            || {
1567                let last_material = last_call.unwrap().material;
1568                matches!((material, last_material),
1569                    (cvkg_core::DrawMaterial::Glass { blur_radius: a, ior_override: b, glass_intensity: c },
1570                     cvkg_core::DrawMaterial::Glass { blur_radius: d, ior_override: e, glass_intensity: f })
1571                    if a != d || b != e || c != f)
1572            };
1573
1574        if needs_new_call {
1575            renderer.current_texture_id = texture_id;
1576            renderer.instance_data.push(current_instance_data);
1577            renderer.draw_calls.push(DrawCall {
1578                target_id: None,
1579                texture_id,
1580                scissor_rect: renderer.clip_stack.last().copied(),
1581                index_start: (renderer.indices.len() - index_count as usize) as u32,
1582                index_count,
1583                instance_count: 1,
1584                material,
1585                instance_start: (renderer.instance_data.len() - 1) as u32,
1586                draw_order: 0,
1587            });
1588        } else {
1589            // Same batch - add instance data and increment instance count
1590            renderer.instance_data.push(current_instance_data);
1591            if let Some(call) = renderer.draw_calls.last_mut() {
1592                call.instance_count += 1;
1593            }
1594        }
1595    }
1596
1597    /// capture_frame -- Read back the rendered frame as a byte buffer (RGBA8).
1598    pub async fn capture_frame(&self) -> Result<Vec<u8>, String> {
1599        let ctx = self
1600            .headless_context
1601            .as_ref()
1602            .ok_or("Headless context required for capture")?;
1603
1604        let u32_size = std::mem::size_of::<u32>() as u32;
1605        let width = ctx.width;
1606        let height = ctx.height;
1607        let bytes_per_row = width * u32_size;
1608        let padding = (256 - (bytes_per_row % 256)) % 256;
1609        let padded_bytes_per_row = bytes_per_row + padding;
1610
1611        let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
1612            label: Some("Capture Buffer"),
1613            size: (padded_bytes_per_row as u64 * height as u64),
1614            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1615            mapped_at_creation: false,
1616        });
1617
1618        let mut encoder = self
1619            .device
1620            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1621                label: Some("Capture Encoder"),
1622            });
1623
1624        encoder.copy_texture_to_buffer(
1625            wgpu::TexelCopyTextureInfo {
1626                texture: &ctx.output_texture,
1627                mip_level: 0,
1628                origin: wgpu::Origin3d::ZERO,
1629                aspect: wgpu::TextureAspect::All,
1630            },
1631            wgpu::TexelCopyBufferInfo {
1632                buffer: &output_buffer,
1633                layout: wgpu::TexelCopyBufferLayout {
1634                    offset: 0,
1635                    bytes_per_row: Some(padded_bytes_per_row),
1636                    rows_per_image: Some(height),
1637                },
1638            },
1639            wgpu::Extent3d {
1640                width,
1641                height,
1642                depth_or_array_layers: 1,
1643            },
1644        );
1645
1646        self.queue.submit(Some(encoder.finish()));
1647
1648        let buffer_slice = output_buffer.slice(..);
1649        let (sender, receiver) = futures::channel::oneshot::channel();
1650        buffer_slice.map_async(wgpu::MapMode::Read, move |v| {
1651            let _ = sender.send(v);
1652        });
1653
1654        let _ = self.device.poll(wgpu::PollType::Wait {
1655            submission_index: None,
1656            timeout: None,
1657        });
1658
1659        if let Ok(Ok(_)) = receiver.await {
1660            let data = buffer_slice.get_mapped_range();
1661            let mut result = Vec::with_capacity((width * height * 4) as usize);
1662
1663            for y in 0..height {
1664                let start = (y * padded_bytes_per_row) as usize;
1665                let end = start + bytes_per_row as usize;
1666                result.extend_from_slice(&data[start..end]);
1667            }
1668
1669            tracing::trace!(
1670                "[GPU] capture_frame: data len={}, first 4 bytes={:?}",
1671                data.len(),
1672                &data[0..4.min(data.len())]
1673            );
1674
1675            drop(data);
1676            output_buffer.unmap();
1677            Ok(result)
1678        } else {
1679            Err("Failed to capture frame".to_string())
1680        }
1681    }
1682
1683    /// Hash a set of gradient stops for cache lookup.
1684    /// Uses the position and color of each stop to produce a stable hash.
1685    fn hash_gradient_stops(stops: &[[f32; 4]]) -> u64 {
1686        use std::hash::{Hash, Hasher};
1687        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1688        for stop in stops {
1689            for v in stop {
1690                v.to_bits().hash(&mut hasher);
1691            }
1692        }
1693        hasher.finish()
1694    }
1695
1696    /// Upload gradient stops as a 32x1 RGBA8 texture.
1697    /// RGB = stop color (linear-ish sRGB from the component), A = stop position (0-255 mapped to 0-1).
1698    /// The texture is cached by hash; stops are only re-uploaded when the hash changes.
1699    #[allow(clippy::collapsible_if)]
1700    pub(crate) fn upload_gradient_stops(&mut self, stops: &[[f32; 4]]) {
1701        if stops.is_empty() {
1702            return;
1703        }
1704
1705        let hash = Self::hash_gradient_stops(stops);
1706
1707        // Check if the texture is already cached with this hash
1708        if hash == self.gradient_stops_hash {
1709            if let Some((_, _, bg)) = self.gradient_texture_cache.get(&hash) {
1710                self.gradient_bind_group = bg.clone();
1711                return;
1712            }
1713        }
1714
1715        // Check if we have a cached texture for this hash (from a previous frame)
1716        if let Some((_, view, bg)) = self.gradient_texture_cache.get(&hash) {
1717            self.gradient_stop_texture = view.texture().clone();
1718            self.gradient_stop_texture_view = view.clone();
1719            self.gradient_bind_group = bg.clone();
1720            self.gradient_stops_hash = hash;
1721            return;
1722        }
1723
1724        // Upload stops into a 32x1 RGBA8 texture
1725        let max_stops = 32u32;
1726        let num_stops = stops.len().min(max_stops as usize) as u32;
1727
1728        // Build RGBA8 data: pack position into alpha as u8
1729        let mut data = vec![0u8; (max_stops as usize) * 4];
1730        for (i, stop) in stops.iter().enumerate().take(max_stops as usize) {
1731            // Convert linear-ish float color to sRGB u8
1732            let r = (stop[0].clamp(0.0, 1.0) * 255.0).round() as u8;
1733            let g = (stop[1].clamp(0.0, 1.0) * 255.0).round() as u8;
1734            let b = (stop[2].clamp(0.0, 1.0) * 255.0).round() as u8;
1735            let a = (stop[3].clamp(0.0, 1.0) * 255.0).round() as u8;
1736            // Store position in the alpha channel (4th byte)
1737            // The color goes in RGB (bytes 0-2), position in byte 3
1738            #[allow(clippy::identity_op)]
1739            {
1740                data[i * 4 + 0] = r;
1741                data[i * 4 + 1] = g;
1742                data[i * 4 + 2] = b;
1743                data[i * 4 + 3] = a;
1744            }
1745        }
1746
1747        // Create or reuse texture
1748        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1749            label: Some("Gradient Stops Texture"),
1750            size: wgpu::Extent3d {
1751                width: max_stops,
1752                height: 1,
1753                depth_or_array_layers: 1,
1754            },
1755            mip_level_count: 1,
1756            sample_count: 1,
1757            dimension: wgpu::TextureDimension::D2,
1758            format: wgpu::TextureFormat::Rgba8Unorm,
1759            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1760            view_formats: &[],
1761        });
1762
1763        self.queue.write_texture(
1764            wgpu::TexelCopyTextureInfo {
1765                texture: &texture,
1766                mip_level: 0,
1767                origin: wgpu::Origin3d::ZERO,
1768                aspect: wgpu::TextureAspect::All,
1769            },
1770            &data,
1771            wgpu::TexelCopyBufferLayout {
1772                offset: 0,
1773                bytes_per_row: Some(max_stops * 4),
1774                rows_per_image: Some(1),
1775            },
1776            wgpu::Extent3d {
1777                width: max_stops,
1778                height: 1,
1779                depth_or_array_layers: 1,
1780            },
1781        );
1782
1783        let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1784
1785        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1786            layout: &self.gradient_bind_group_layout,
1787            entries: &[
1788                wgpu::BindGroupEntry {
1789                    binding: 0,
1790                    resource: wgpu::BindingResource::TextureView(&texture_view),
1791                },
1792                wgpu::BindGroupEntry {
1793                    binding: 1,
1794                    resource: wgpu::BindingResource::Sampler(&self.dummy_sampler),
1795                },
1796            ],
1797            label: Some("Gradient Bind Group"),
1798        });
1799
1800        // Cache the texture
1801        self.gradient_stops_hash = hash;
1802        self.gradient_stop_texture = texture.clone();
1803        self.gradient_stop_texture_view = texture_view.clone();
1804        self.gradient_bind_group = bind_group.clone();
1805        self.gradient_texture_cache
1806            .insert(hash, (texture, texture_view, bind_group));
1807    }
1808
1809    /// Draw a multi-stop gradient quad using the GPU shader.
1810    /// rect: bounding rectangle in logical pixels
1811    /// stops: array of [R, G, B, A] where A is the position (0.0-1.0)
1812    /// angle: gradient angle in radians (for linear gradients)
1813    /// is_radial: true for radial gradient, false for linear
1814    pub fn draw_gradient_multi(
1815        &mut self,
1816        rect: Rect,
1817        stops: &[[f32; 4]],
1818        angle: f32,
1819        is_radial: bool,
1820    ) {
1821        if stops.is_empty() {
1822            return;
1823        }
1824
1825        // Upload gradient stops (cached by hash)
1826        self.upload_gradient_stops(stops);
1827
1828        let num_stops = stops.len().min(32) as f32;
1829        let material_id = if is_radial { 31u32 } else { 30u32 };
1830
1831        // Use a white base color; the shader reads stops from the texture
1832        let white = [1.0f32, 1.0, 1.0, 1.0];
1833
1834        // slice.x = angle (for linear), slice.y = num_stops
1835        let slice = [angle, num_stops, 0.0, 1.0];
1836
1837        self.fill_rect_with_full_params_and_slice(
1838            rect,
1839            white,
1840            material_id,
1841            None,
1842            0.0,
1843            Rect {
1844                x: 0.0,
1845                y: 0.0,
1846                width: 1.0,
1847                height: 1.0,
1848            },
1849            slice,
1850            [0.0, 0.0],
1851        );
1852    }
1853}