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