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