Skip to main content

cvkg_render_gpu/
lib.rs

1//! # CVKG Agentic Development Guidelines (v1.2)
2//!
3//! All AI agents contributing to this crate MUST follow ALL seven rules:
4//!
5//! ── Karpathy Guidelines (1–4) ────────────────────────────────────────────
6//! 1. THINK FIRST     — State assumptions. Surface ambiguity. Push back on complexity.
7//! 2. STAY SIMPLE     — Minimum code. No speculative features. No unasked-for abstractions.
8//! 3. BE SURGICAL     — Touch only what's required. Own your orphans. Don't improve neighbors.
9//! 4. VERIFY GOALS    — Turn tasks into checkable criteria. Loop until they pass. Never commit broken.
10//!
11//! ── CVKG Extended Protocols (5–7) ────────────────────────────────────────
12//! 5. TRIPLE-PASS     — Read the target, its surrounding context, and its full call graph
13//!                      at least THREE TIMES before making any edit or revision.
14//! 6. COMMENT ALL     — Every major pub fn, unsafe block, and non-trivial algorithm in
15//!                      every .rs/.ts/.h/.wgsl file MUST have a descriptive doc comment.
16//!                      Comments describe WHY and WHAT CONTRACT, not HOW mechanically.
17//! 7. MONITOR LOOPS   — Check every tool call / command for progress every 30 seconds.
18//!                      After 3 consecutive identical failures, stop, write BLOCKED.md,
19//!                      and move to unblocked work. Never silently accept a broken state.
20//!
21//! Sources:
22//!   Karpathy: https://github.com/multica-ai/andrej-karpathy-skills
23//!   CVKG Extended: Section 2 of the CVKG Design Specification
24
25//! # Surtr Render Pipeline
26//!
27//! The "Fiery Giant" of the CVKG architecture. This is the authoritative GPU renderer
28//! powered by `wgpu`. It manages the heat of the GPU to forge high-fidelity 
29//! "Berserker" aesthetics.
30//!
31//! - **The Flaming Sword**: Command submission and synchronization.
32//! - **Muspelheim Passes**: Multi-pass Gaussian blur and bloom for Bifrost/Gungnir.
33
34use cvkg_core::Rect;
35use std::sync::Arc;
36
37// ShieldWall — re-export AccessKit types so callers can build tree updates
38// without depending on accesskit directly.
39pub use accesskit::{
40    ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler,
41    Node, NodeId, Role, Tree, TreeId, TreeUpdate,
42};
43pub use accesskit_winit::Adapter as ShieldWallAdapter;
44
45
46#[repr(C)]
47#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
48pub struct Vertex {
49    pub position: [f32; 2],
50    pub uv: [f32; 2],
51    pub color: [f32; 4],
52    pub mode: u32,
53}
54
55/// Represents a single batched GPU draw call.
56/// Batches are broken whenever the active texture or primitive mode changes.
57#[derive(Debug, Clone)]
58struct DrawCall {
59    pub texture_name: Option<String>,
60    pub index_start: u32,
61    pub index_count: u32,
62}
63
64impl Vertex {
65    const ATTRIBUTES: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
66        0 => Float32x2,
67        1 => Float32x2,
68        2 => Float32x4,
69        3 => Uint32
70    ];
71
72    fn desc() -> wgpu::VertexBufferLayout<'static> {
73        wgpu::VertexBufferLayout {
74            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
75            step_mode: wgpu::VertexStepMode::Vertex,
76            attributes: &Self::ATTRIBUTES,
77        }
78    }
79}
80
81/// SurtrRenderer implements the high-performance GPU backend.
82pub struct SurtrRenderer {
83    device: Arc<wgpu::Device>,
84    queue: Arc<wgpu::Queue>,
85    surface: wgpu::Surface<'static>,
86    config: wgpu::SurfaceConfiguration,
87    pipeline: wgpu::RenderPipeline,
88    
89    // Muspelheim Pass Resources
90    #[allow(dead_code)]
91    bloom_extract_pipeline: wgpu::RenderPipeline,
92    blur_h_pipeline: wgpu::RenderPipeline,
93    blur_v_pipeline: wgpu::RenderPipeline,
94    composite_pipeline: wgpu::RenderPipeline,
95    blur_texture_a: wgpu::TextureView,
96    blur_texture_b: wgpu::TextureView,
97    blur_bind_group_a: wgpu::BindGroup,
98    blur_bind_group_b: wgpu::BindGroup,
99    
100    // Text Forge
101    #[allow(dead_code)]
102    font_system: cosmic_text::FontSystem,
103    #[allow(dead_code)]
104    swash_cache: cosmic_text::SwashCache,
105
106    // Niflheim Resources
107    dummy_bind_group: wgpu::BindGroup,
108    texture_bind_group_layout: wgpu::BindGroupLayout,
109    textures: std::collections::HashMap<String, wgpu::BindGroup>,
110
111    // The Forge's Anvil (GPU Buffers)
112    vertex_buffer: wgpu::Buffer,
113    index_buffer: wgpu::Buffer,
114    vertices: Vec<Vertex>,
115    indices: Vec<u16>,
116    draw_calls: Vec<DrawCall>,
117    current_texture_name: Option<String>,
118
119    // Opacity stack: each push multiplies into the current effective alpha.
120    opacity_stack: Vec<f32>,
121    // Clip rect stack: stored for future scissor-rect support.
122    clip_stack: Vec<Rect>,
123}
124
125const MAX_VERTICES: usize = 10000;
126const MAX_INDICES: usize = 15000;
127
128impl SurtrRenderer {
129    /// Forge a new SurtrRenderer from a winit window.
130    pub async fn forge(window: Arc<winit::window::Window>) -> Self {
131        let instance = wgpu::Instance::default();
132        let surface = instance.create_surface(window.clone()).unwrap();
133        let adapter = instance.request_adapter(&wgpu::RequestAdapterOptions {
134            power_preference: wgpu::PowerPreference::HighPerformance,
135            compatible_surface: Some(&surface),
136            force_fallback_adapter: false,
137        }).await.expect("Failed to find a suitable GPU for Surtr");
138
139        let (device, queue) = adapter.request_device(
140            &wgpu::DeviceDescriptor {
141                label: Some("Surtr Forge"),
142                required_features: wgpu::Features::empty(),
143                required_limits: wgpu::Limits::default(),
144            },
145            None,
146        ).await.expect("Failed to create Surtr device");
147
148        let device = Arc::new(device);
149        let queue = Arc::new(queue);
150        
151        let size = window.inner_size();
152        let config = surface.get_default_config(&adapter, size.width, size.height).unwrap();
153        surface.configure(&device, &config);
154
155        // Load the Muspelheim Shaders
156        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
157            label: Some("Muspelheim Main Shader"),
158            source: wgpu::ShaderSource::Wgsl(include_str!("shaders.wgsl").into()),
159        });
160
161        // Niflheim Bind Group Layout (for textures/samplers)
162        let texture_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
163            entries: &[
164                wgpu::BindGroupLayoutEntry {
165                    binding: 0,
166                    visibility: wgpu::ShaderStages::FRAGMENT,
167                    ty: wgpu::BindingType::Texture {
168                        multisampled: false,
169                        view_dimension: wgpu::TextureViewDimension::D2,
170                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
171                    },
172                    count: None,
173                },
174                wgpu::BindGroupLayoutEntry {
175                    binding: 1,
176                    visibility: wgpu::ShaderStages::FRAGMENT,
177                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
178                    count: None,
179                },
180            ],
181            label: Some("Niflheim Texture Bind Group Layout"),
182        });
183
184        // Pipeline setup
185        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
186            label: Some("Surtr Pipeline Layout"),
187            bind_group_layouts: &[&texture_bind_group_layout],
188            push_constant_ranges: &[],
189        });
190
191        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
192            label: Some("Surtr Main Pipeline"),
193            layout: Some(&pipeline_layout),
194            vertex: wgpu::VertexState {
195                module: &shader,
196                entry_point: "vs_main",
197                buffers: &[Vertex::desc()],
198            },
199            fragment: Some(wgpu::FragmentState {
200                module: &shader,
201                entry_point: "fs_main",
202                targets: &[Some(wgpu::ColorTargetState {
203                    format: config.format,
204                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
205                    write_mask: wgpu::ColorWrites::ALL,
206                })],
207            }),
208            primitive: wgpu::PrimitiveState::default(),
209            depth_stencil: None,
210            multisample: wgpu::MultisampleState::default(),
211            multiview: None,
212        });
213
214        // Muspelheim Bloom Extract Pipeline
215        let bloom_extract_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
216            label: Some("Muspelheim Bloom Extract"),
217            layout: Some(&pipeline_layout),
218            vertex: wgpu::VertexState {
219                module: &shader,
220                entry_point: "vs_fullscreen",
221                buffers: &[],
222            },
223            fragment: Some(wgpu::FragmentState {
224                module: &shader,
225                entry_point: "fs_bloom_extract",
226                targets: &[Some(wgpu::ColorTargetState {
227                    format: config.format,
228                    blend: None,
229                    write_mask: wgpu::ColorWrites::ALL,
230                })],
231            }),
232            primitive: wgpu::PrimitiveState::default(),
233            depth_stencil: None,
234            multisample: wgpu::MultisampleState::default(),
235            multiview: None,
236        });
237
238        // Muspelheim Blur Pipelines (H and V)
239        let blur_h_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
240            label: Some("Muspelheim Horizontal Blur"),
241            layout: Some(&pipeline_layout),
242            vertex: wgpu::VertexState {
243                module: &shader,
244                entry_point: "vs_fullscreen",
245                buffers: &[],
246            },
247            fragment: Some(wgpu::FragmentState {
248                module: &shader,
249                entry_point: "fs_blur_h",
250                targets: &[Some(wgpu::ColorTargetState {
251                    format: config.format,
252                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
253                    write_mask: wgpu::ColorWrites::ALL,
254                })],
255            }),
256            primitive: wgpu::PrimitiveState::default(),
257            depth_stencil: None,
258            multisample: wgpu::MultisampleState::default(),
259            multiview: None,
260        });
261
262        let blur_v_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
263            label: Some("Muspelheim Vertical Blur"),
264            layout: Some(&pipeline_layout),
265            vertex: wgpu::VertexState {
266                module: &shader,
267                entry_point: "vs_fullscreen",
268                buffers: &[],
269            },
270            fragment: Some(wgpu::FragmentState {
271                module: &shader,
272                entry_point: "fs_blur_v",
273                targets: &[Some(wgpu::ColorTargetState {
274                    format: config.format,
275                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
276                    write_mask: wgpu::ColorWrites::ALL,
277                })],
278            }),
279            primitive: wgpu::PrimitiveState::default(),
280            depth_stencil: None,
281            multisample: wgpu::MultisampleState::default(),
282            multiview: None,
283        });
284
285        // Muspelheim Composite Pipeline (additive blend onto screen)
286        let composite_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
287            label: Some("Muspelheim Composite"),
288            layout: Some(&pipeline_layout),
289            vertex: wgpu::VertexState {
290                module: &shader,
291                entry_point: "vs_fullscreen",
292                buffers: &[],
293            },
294            fragment: Some(wgpu::FragmentState {
295                module: &shader,
296                entry_point: "fs_composite",
297                targets: &[Some(wgpu::ColorTargetState {
298                    format: config.format,
299                    // Additive blend: src + dst — glow lights up the scene
300                    blend: Some(wgpu::BlendState {
301                        color: wgpu::BlendComponent {
302                            src_factor: wgpu::BlendFactor::One,
303                            dst_factor: wgpu::BlendFactor::One,
304                            operation: wgpu::BlendOperation::Add,
305                        },
306                        alpha: wgpu::BlendComponent {
307                            src_factor: wgpu::BlendFactor::One,
308                            dst_factor: wgpu::BlendFactor::One,
309                            operation: wgpu::BlendOperation::Add,
310                        },
311                    }),
312                    write_mask: wgpu::ColorWrites::ALL,
313                })],
314            }),
315            primitive: wgpu::PrimitiveState::default(),
316            depth_stencil: None,
317            multisample: wgpu::MultisampleState::default(),
318            multiview: None,
319        });
320
321        // Muspelheim Intermediate Textures
322        let blur_tex_desc = wgpu::TextureDescriptor {
323            label: Some("Muspelheim Intermediate"),
324            size: wgpu::Extent3d {
325                width: config.width,
326                height: config.height,
327                depth_or_array_layers: 1,
328            },
329            mip_level_count: 1,
330            sample_count: 1,
331            dimension: wgpu::TextureDimension::D2,
332            format: config.format,
333            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
334            view_formats: &[],
335        };
336        let blur_texture_a_obj = device.create_texture(&blur_tex_desc);
337        let blur_texture_b_obj = device.create_texture(&blur_tex_desc);
338        let blur_texture_a = blur_texture_a_obj.create_view(&wgpu::TextureViewDescriptor::default());
339        let blur_texture_b = blur_texture_b_obj.create_view(&wgpu::TextureViewDescriptor::default());
340
341        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
342            address_mode_u: wgpu::AddressMode::ClampToEdge,
343            address_mode_v: wgpu::AddressMode::ClampToEdge,
344            mag_filter: wgpu::FilterMode::Linear,
345            ..Default::default()
346        });
347
348        let blur_bind_group_a = device.create_bind_group(&wgpu::BindGroupDescriptor {
349            layout: &texture_bind_group_layout,
350            entries: &[
351                wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&blur_texture_a) },
352                wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&sampler) },
353            ],
354            label: Some("Blur Bind Group A"),
355        });
356
357        let blur_bind_group_b = device.create_bind_group(&wgpu::BindGroupDescriptor {
358            layout: &texture_bind_group_layout,
359            entries: &[
360                wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&blur_texture_b) },
361                wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&sampler) },
362            ],
363            label: Some("Blur Bind Group B"),
364        });
365
366        // Forge the Niflheim Dummy Texture (1x1 White)
367        let dummy_size = wgpu::Extent3d {
368            width: 1,
369            height: 1,
370            depth_or_array_layers: 1,
371        };
372        let dummy_texture = device.create_texture(&wgpu::TextureDescriptor {
373            label: Some("Niflheim Dummy Texture"),
374            size: dummy_size,
375            mip_level_count: 1,
376            sample_count: 1,
377            dimension: wgpu::TextureDimension::D2,
378            format: wgpu::TextureFormat::Rgba8UnormSrgb,
379            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
380            view_formats: &[],
381        });
382        queue.write_texture(
383            wgpu::ImageCopyTexture {
384                texture: &dummy_texture,
385                mip_level: 0,
386                origin: wgpu::Origin3d::ZERO,
387                aspect: wgpu::TextureAspect::All,
388            },
389            &[255, 255, 255, 255],
390            wgpu::ImageDataLayout {
391                offset: 0,
392                bytes_per_row: Some(4),
393                rows_per_image: Some(1),
394            },
395            dummy_size,
396        );
397
398        let dummy_view = dummy_texture.create_view(&wgpu::TextureViewDescriptor::default());
399        let dummy_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
400            address_mode_u: wgpu::AddressMode::ClampToEdge,
401            address_mode_v: wgpu::AddressMode::ClampToEdge,
402            address_mode_w: wgpu::AddressMode::ClampToEdge,
403            mag_filter: wgpu::FilterMode::Linear,
404            min_filter: wgpu::FilterMode::Nearest,
405            mipmap_filter: wgpu::FilterMode::Nearest,
406            ..Default::default()
407        });
408
409        let dummy_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
410            layout: &texture_bind_group_layout,
411            entries: &[
412                wgpu::BindGroupEntry {
413                    binding: 0,
414                    resource: wgpu::BindingResource::TextureView(&dummy_view),
415                },
416                wgpu::BindGroupEntry {
417                    binding: 1,
418                    resource: wgpu::BindingResource::Sampler(&dummy_sampler),
419                },
420            ],
421            label: Some("Niflheim Dummy Bind Group"),
422        });
423
424        // Forge the Anvil (Buffers)
425        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
426            label: Some("Surtr Vertex Anvil"),
427            size: (MAX_VERTICES * std::mem::size_of::<Vertex>()) as u64,
428            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
429            mapped_at_creation: false,
430        });
431
432        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
433            label: Some("Surtr Index Anvil"),
434            size: (MAX_INDICES * std::mem::size_of::<u16>()) as u64,
435            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
436            mapped_at_creation: false,
437        });
438
439
440        Self {
441            device,
442            queue,
443            surface,
444            config,
445            pipeline,
446            bloom_extract_pipeline,
447            blur_h_pipeline,
448            blur_v_pipeline,
449            composite_pipeline,
450            blur_texture_a,
451            blur_texture_b,
452            blur_bind_group_a,
453            blur_bind_group_b,
454            font_system: cosmic_text::FontSystem::new(),
455            swash_cache: cosmic_text::SwashCache::new(),
456            dummy_bind_group,
457            texture_bind_group_layout,
458            textures: std::collections::HashMap::new(),
459            vertex_buffer,
460            index_buffer,
461            vertices: Vec::with_capacity(MAX_VERTICES),
462            indices: Vec::with_capacity(MAX_INDICES),
463            draw_calls: Vec::new(),
464            current_texture_name: None,
465            opacity_stack: Vec::new(),
466            clip_stack: Vec::new(),
467        }
468    }
469
470    /// begin_frame — Strike the flaming sword to begin a new GPU frame.
471    pub fn begin_frame(&mut self) -> wgpu::CommandEncoder {
472        self.vertices.clear();
473        self.indices.clear();
474        self.draw_calls.clear();
475        self.current_texture_name = None;
476        self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
477            label: Some("Surtr's Flaming Sword"),
478        })
479    }
480
481    /// end_frame — Quench the blade by submitting the full Muspelheim multi-pass effect.
482    pub fn end_frame(&mut self, mut encoder: wgpu::CommandEncoder) {
483        self.queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices));
484        self.queue.write_buffer(&self.index_buffer, 0, bytemuck::cast_slice(&self.indices));
485
486        let frame = self.surface.get_current_texture()
487            .expect("Surtr: failed to acquire surface texture");
488        let screen = frame.texture.create_view(&wgpu::TextureViewDescriptor::default());
489
490        // ── Pass 1: Base scene → screen ─────────────────────────────────────
491        {
492            let mut p = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
493                label: Some("Surtr P1 Base"),
494                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
495                    view: &screen,
496                    resolve_target: None,
497                    ops: wgpu::Operations {
498                        load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), // Ginnungagap
499                        store: wgpu::StoreOp::Store,
500                    },
501                })],
502                depth_stencil_attachment: None,
503                occlusion_query_set: None,
504                timestamp_writes: None,
505            });
506            if !self.draw_calls.is_empty() {
507                p.set_pipeline(&self.pipeline);
508                p.set_vertex_buffer(0, self.vertex_buffer.slice(..));
509                p.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
510                for call in &self.draw_calls {
511                    let bg = if let Some(name) = &call.texture_name {
512                        self.textures.get(name).unwrap_or(&self.dummy_bind_group)
513                    } else {
514                        &self.dummy_bind_group
515                    };
516                    p.set_bind_group(0, bg, &[]);
517                    p.draw_indexed(call.index_start..call.index_start + call.index_count, 0, 0..1);
518                }
519            }
520        }
521
522        // ── Pass 2: Bloom extract  screen → tex_a ────────────────────────────
523        {
524            let mut p = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
525                label: Some("Surtr P2 Bloom Src"),
526                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
527                    view: &self.blur_texture_a,
528                    resolve_target: None,
529                    ops: wgpu::Operations {
530                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
531                        store: wgpu::StoreOp::Store,
532                    },
533                })],
534                depth_stencil_attachment: None,
535                occlusion_query_set: None,
536                timestamp_writes: None,
537            });
538            if !self.draw_calls.is_empty() {
539                p.set_pipeline(&self.pipeline);
540                p.set_vertex_buffer(0, self.vertex_buffer.slice(..));
541                p.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
542                for call in &self.draw_calls {
543                    let bg = if let Some(name) = &call.texture_name {
544                        self.textures.get(name).unwrap_or(&self.dummy_bind_group)
545                    } else {
546                        &self.dummy_bind_group
547                    };
548                    p.set_bind_group(0, bg, &[]);
549                    p.draw_indexed(call.index_start..call.index_start + call.index_count, 0, 0..1);
550                }
551            }
552        }
553
554        // ── Passes 3–6+: Ping-pong Gaussian blur ──────────────────────────
555        let blur_iters: u32 = 6;
556        for i in 0..blur_iters {
557            {
558                let label = format!("Surtr Blur H iter {}", i);
559                let mut p = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
560                    label: Some(&label),
561                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
562                        view: &self.blur_texture_b,
563                        resolve_target: None,
564                        ops: wgpu::Operations {
565                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
566                            store: wgpu::StoreOp::Store,
567                        },
568                    })],
569                    depth_stencil_attachment: None,
570                    occlusion_query_set: None,
571                    timestamp_writes: None,
572                });
573                p.set_pipeline(&self.blur_h_pipeline);
574                p.set_bind_group(0, &self.blur_bind_group_a, &[]);
575                p.draw(0..3, 0..1);
576            }
577            {
578                let label = format!("Surtr Blur V iter {}", i);
579                let mut p = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
580                    label: Some(&label),
581                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
582                        view: &self.blur_texture_a,
583                        resolve_target: None,
584                        ops: wgpu::Operations {
585                            load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
586                            store: wgpu::StoreOp::Store,
587                        },
588                    })],
589                    depth_stencil_attachment: None,
590                    occlusion_query_set: None,
591                    timestamp_writes: None,
592                });
593                p.set_pipeline(&self.blur_v_pipeline);
594                p.set_bind_group(0, &self.blur_bind_group_b, &[]);
595                p.draw(0..3, 0..1);
596            }
597        }
598
599        // ── Pass 7: Additive composite  tex_a → screen ──────────────────────
600        {
601            let mut p = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
602                label: Some("Surtr P7 Composite"),
603                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
604                    view: &screen,
605                    resolve_target: None,
606                    ops: wgpu::Operations {
607                        load: wgpu::LoadOp::Load,
608                        store: wgpu::StoreOp::Store,
609                    },
610                })],
611                depth_stencil_attachment: None,
612                occlusion_query_set: None,
613                timestamp_writes: None,
614            });
615            p.set_pipeline(&self.composite_pipeline);
616            p.set_bind_group(0, &self.blur_bind_group_a, &[]);
617            p.draw(0..3, 0..1);
618        }
619
620        self.queue.submit(Some(encoder.finish()));
621        frame.present();
622    }
623}
624
625impl cvkg_core::Renderer for SurtrRenderer {
626    fn fill_rect(&mut self, rect: Rect, color: [f32; 4]) {
627        self.fill_rect_with_mode(rect, self.apply_opacity(color), 0, None);
628    }
629
630    fn fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: [f32; 4]) {
631        // GPU-side rounding is handled in the fragment shader via mode 2 (future).
632        // For now fall through to a plain quad.
633        self.fill_rect_with_mode(rect, self.apply_opacity(color), 0, None);
634    }
635
636    fn fill_ellipse(&mut self, rect: Rect, color: [f32; 4]) {
637        // Approximated as a quad; a true SDF ellipse shader is a future enhancement.
638        self.fill_rect_with_mode(rect, self.apply_opacity(color), 0, None);
639    }
640
641    fn stroke_rect(&mut self, rect: Rect, color: [f32; 4], stroke_width: f32) {
642        let c = self.apply_opacity(color);
643        let hw = stroke_width;
644        // Top, bottom, left, right edge bars
645        self.fill_rect_with_mode(Rect { x: rect.x, y: rect.y, width: rect.width, height: hw }, c, 1, None);
646        self.fill_rect_with_mode(Rect { x: rect.x, y: rect.y + rect.height - hw, width: rect.width, height: hw }, c, 1, None);
647        self.fill_rect_with_mode(Rect { x: rect.x, y: rect.y, width: hw, height: rect.height }, c, 1, None);
648        self.fill_rect_with_mode(Rect { x: rect.x + rect.width - hw, y: rect.y, width: hw, height: rect.height }, c, 1, None);
649    }
650
651    fn stroke_rounded_rect(&mut self, rect: Rect, _radius: f32, color: [f32; 4], stroke_width: f32) {
652        // Delegate to stroke_rect until shader SDF support is added.
653        self.stroke_rect(rect, color, stroke_width);
654    }
655
656    fn stroke_ellipse(&mut self, rect: Rect, color: [f32; 4], stroke_width: f32) {
657        self.stroke_rect(rect, color, stroke_width);
658    }
659
660    fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, color: [f32; 4], stroke_width: f32) {
661        // Represent as a thin oriented quad.
662        let dx = x2 - x1;
663        let dy = y2 - y1;
664        let len = (dx * dx + dy * dy).sqrt();
665        if len < 0.001 { return; }
666        let c = self.apply_opacity(color);
667        // Fall back to axis-aligned approximation for the vertex buffer.
668        let min_x = x1.min(x2);
669        let min_y = y1.min(y2);
670        let w = (dx.abs()).max(stroke_width);
671        let h = (dy.abs()).max(stroke_width);
672        self.fill_rect_with_mode(Rect { x: min_x, y: min_y, width: w, height: h }, c, 1, None);
673    }
674
675    fn draw_text(&mut self, text: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
676        // Full cosmic-text rasterisation path is a future enhancement.
677        // For now, reserve space as a coloured placeholder so layout is correct.
678        let c = self.apply_opacity(color);
679        self.fill_rect_with_mode(Rect { x, y, width: size * 0.6 * text.len() as f32, height: size }, c, 0, None);
680    }
681
682    fn draw_texture(&mut self, texture_id: u32, rect: Rect) {
683        self.fill_rect_with_mode(rect, [1.0, 1.0, 1.0, 1.0], 2, Some(format!("tex_{}", texture_id)));
684    }
685
686    fn draw_image(&mut self, image_name: &str, rect: Rect) {
687        self.fill_rect_with_mode(rect, [1.0, 1.0, 1.0, 1.0], 2, Some(image_name.to_string()));
688    }
689
690    fn load_image(&mut self, name: &str, data: &[u8]) {
691        let img = image::load_from_memory(data).expect("Failed to load image").to_rgba8();
692        let (width, height) = img.dimensions();
693        let size = wgpu::Extent3d { width, height, depth_or_array_layers: 1 };
694        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
695            label: Some(name),
696            size,
697            mip_level_count: 1,
698            sample_count: 1,
699            dimension: wgpu::TextureDimension::D2,
700            format: wgpu::TextureFormat::Rgba8UnormSrgb,
701            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
702            view_formats: &[],
703        });
704        self.queue.write_texture(
705            wgpu::ImageCopyTexture {
706                texture: &texture,
707                mip_level: 0,
708                origin: wgpu::Origin3d::ZERO,
709                aspect: wgpu::TextureAspect::All,
710            },
711            &img,
712            wgpu::ImageDataLayout {
713                offset: 0,
714                bytes_per_row: Some(4 * width),
715                rows_per_image: Some(height),
716            },
717            size,
718        );
719        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
720        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
721            address_mode_u: wgpu::AddressMode::ClampToEdge,
722            address_mode_v: wgpu::AddressMode::ClampToEdge,
723            mag_filter: wgpu::FilterMode::Linear,
724            ..Default::default()
725        });
726        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
727            layout: &self.texture_bind_group_layout,
728            entries: &[
729                wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
730                wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&sampler) },
731            ],
732            label: Some(name),
733        });
734        self.textures.insert(name.to_string(), bind_group);
735    }
736
737    fn push_clip_rect(&mut self, rect: Rect) {
738        // Stored for future scissor-rect integration into the render pass.
739        self.clip_stack.push(rect);
740    }
741
742    fn pop_clip_rect(&mut self) {
743        self.clip_stack.pop();
744    }
745
746    fn push_opacity(&mut self, opacity: f32) {
747        let current = self.opacity_stack.last().copied().unwrap_or(1.0);
748        self.opacity_stack.push(current * opacity);
749    }
750
751    fn pop_opacity(&mut self) {
752        self.opacity_stack.pop();
753    }
754}
755
756impl cvkg_core::FrameRenderer<wgpu::CommandEncoder> for SurtrRenderer {
757    fn begin_frame(&mut self) -> wgpu::CommandEncoder {
758        self.begin_frame()
759    }
760
761    fn end_frame(&mut self, encoder: wgpu::CommandEncoder) {
762        self.end_frame(encoder)
763    }
764}
765
766impl SurtrRenderer {
767    /// Returns the current effective opacity (product of all stacked values).
768    fn apply_opacity(&self, mut color: [f32; 4]) -> [f32; 4] {
769        if let Some(&alpha) = self.opacity_stack.last() {
770            color[3] *= alpha;
771        }
772        color
773    }
774
775    /// Converts a Rect in logical pixels into NDC and appends a quad.
776    /// `mode` controls the fragment shader path: 0 = solid fill, 1 = glow/bloom, 2 = textured.
777    pub fn fill_rect_with_mode(&mut self, rect: Rect, color: [f32; 4], mode: u32, texture_name: Option<String>) {
778        // Batching: check if we need to start a new DrawCall
779        let needs_new_call = self.draw_calls.is_empty() || self.current_texture_name != texture_name;
780
781        if needs_new_call {
782            self.current_texture_name = texture_name.clone();
783            self.draw_calls.push(DrawCall {
784                texture_name,
785                index_start: self.indices.len() as u32,
786                index_count: 0,
787            });
788        }
789
790        let base_idx = self.vertices.len() as u16;
791
792        // Use actual surface size for NDC conversion so quads are correct at any resolution.
793        let half_w = self.config.width as f32 / 2.0;
794        let half_h = self.config.height as f32 / 2.0;
795
796        let x1 = (rect.x / half_w) - 1.0;
797        let y1 = 1.0 - (rect.y / half_h);
798        let x2 = ((rect.x + rect.width) / half_w) - 1.0;
799        let y2 = 1.0 - ((rect.y + rect.height) / half_h);
800
801        self.vertices.push(Vertex { position: [x1, y1], uv: [0.0, 0.0], color, mode });
802        self.vertices.push(Vertex { position: [x2, y1], uv: [1.0, 0.0], color, mode });
803        self.vertices.push(Vertex { position: [x2, y2], uv: [1.0, 1.0], color, mode });
804        self.vertices.push(Vertex { position: [x1, y2], uv: [0.0, 1.0], color, mode });
805
806        self.indices.extend_from_slice(&[
807            base_idx, base_idx + 1, base_idx + 2,
808            base_idx, base_idx + 2, base_idx + 3,
809        ]);
810
811        // Update the current draw call's count
812        if let Some(call) = self.draw_calls.last_mut() {
813            call.index_count += 6;
814        }
815    }
816}