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