Skip to main content

llimphi_raster/
gpu.rs

1//! Backend GPU directo (Fases 2 + 3 del SDD §"GPU directo wgpu").
2//!
3//! Cuatro pipelines `wgpu` cacheadas en [`GpuPipelines`] (lines / tris /
4//! rects / discs) + un acumulador [`GpuBatch`] que las apps usan por frame para
5//! emitir centenares de miles a millones de primitivos en una draw call
6//! por tipo, sin pasar por vello.
7//!
8//! Diseño minimal Fase 2/3:
9//!
10//! - Vertex format triángulos: `[x: f32, y: f32, rgba: u32]` (12 B/vert).
11//! - Instance format líneas: `[x0, y0, x1, y1, rgba]` (20 B/seg).
12//! - Instance format rects:  `[x, y, w, h, rgba]` (20 B/rect).
13//! - Instance format discos: `[cx, cy, r, stroke, rgba]` (20 B/disco).
14//! - Sin texturas. Rects/líneas/tris obtienen AA de **bordes** vía MSAA 4×
15//!   (ver más abajo); los discos SÍ traen AA por SDF en el fragment
16//!   (smoothstep sobre `fwidth`), que MSAA respeta. Así rects/tris/líneas
17//!   instanciados salen con bordes suaves sin que el caller toque nada.
18//! - Blending alfa habilitado: el alpha del color es respetado.
19//! - El viewport `(width, height)` se pasa al flush y va en un uniform —
20//!   los shaders convierten pixel → NDC ahí.
21//!
22//! Cache de pipelines: una sola instancia de `GpuPipelines` por
23//! `(device, color_format)`. Construirla compila los 3 pipelines en
24//! caliente (~ms en hardware moderno). Los callers la mantienen viva
25//! entre frames (en su Model o vía `OnceLock`).
26//!
27//! Grow strategy: `flush` crea un buffer por tipo no vacío en el
28//! mismo frame. Sin reuso entre frames — Fase 4 (`GpuSceneCanvas`)
29//! introducirá el `GpuBuffers` persistente que dobla capacidad si
30//! aparece la necesidad.
31//!
32//! ## MSAA 4× (antialiasing de bordes)
33//!
34//! El pase no dibuja directo sobre el `view` que recibe `flush`. En su
35//! lugar rasteriza todos los primitivos a una textura **multisample 4×**
36//! (cleared a transparente), la *resuelve* a una textura single-sample
37//! scratch y **compone con alpha** ese resultado sobre el `view`. Así:
38//!
39//! - Los bordes de rects/tris/líneas quedan suaves (4 muestras/pixel),
40//!   no escalonados.
41//! - El contenido previo del `view` (lo que vello pintó) se preserva,
42//!   porque el composite es alpha-over con `LoadOp::Load` — exactamente
43//!   la semántica que tenía el viejo render pass directo con `LoadOp::Load`.
44//! - `LoadOp::Clear(c)` se respeta: el `view` se limpia a `c` antes del
45//!   composite (equivalente a la pasada directa anterior).
46//!
47//! Backward-compat: la firma pública de `flush` / `GpuPipelines::new` no
48//! cambia. Las texturas MSAA + scratch se crean por-flush dimensionadas
49//! al `viewport` (mismo patrón que los buffers por-frame), así el resize
50//! "sale gratis" — cada frame usa el tamaño que se le pasa, sin estado
51//! persistente que recrear. El pipeline de composite se compila una vez
52//! y se cachea en `GpuPipelines` (es `Sync`, vive en `OnceLock`).
53
54use llimphi_hal::wgpu;
55use vello::peniko::Color;
56
57/// Número de muestras del MSAA del pase GPU. 4× es el punto dulce
58/// universal (soportado por todo hardware moderno, coste moderado).
59const MSAA_SAMPLES: u32 = 4;
60
61/// Pipelines cacheadas. Crear uno por proceso (o por surface format).
62///
63/// Para uso típico via [`GpuBatch`] los campos no se tocan directo. La
64/// API pública existe para callers avanzados que quieran montar su propio
65/// buffer persistente (datos que no cambian por frame: starfield Gaia,
66/// particles iniciales, viewport estático) y emitir draw calls
67/// manualmente reusando estas pipelines.
68///
69/// Layouts:
70/// - Vertex buffer triángulos: `[x: f32, y: f32, rgba: u32]` (12 B/vert).
71/// - Instance buffer rects:    `[x, y, w, h, rgba]`           (20 B/inst).
72/// - Instance buffer líneas:   `[x0, y0, x1, y1, rgba]`       (20 B/inst).
73/// - Bind group 0 binding 0: uniform `{viewport: vec2<f32>, line_width: f32, _pad: f32}` (16 B).
74pub struct GpuPipelines {
75    pub lines: wgpu::RenderPipeline,
76    pub tris: wgpu::RenderPipeline,
77    pub rects: wgpu::RenderPipeline,
78    /// Discos/anillos rellenos con AA por SDF en el fragment. Instance
79    /// format: `[cx, cy, r, stroke, rgba]` (20 B/disco). `stroke <= 0`
80    /// → disco lleno; `stroke > 0` → anillo de ese grosor (px). Ver
81    /// [`GpuBatch::add_disc`] / [`GpuBatch::add_ring`].
82    pub discs: wgpu::RenderPipeline,
83    pub bind_layout: wgpu::BindGroupLayout,
84    /// Pipeline de pantalla completa que compone (alpha-over) la textura
85    /// scratch resuelta del MSAA sobre el `view` del `flush`. Single-sample.
86    /// El formato del target es el `color_format` con el que se construyó.
87    composite: wgpu::RenderPipeline,
88    composite_bgl: wgpu::BindGroupLayout,
89    composite_sampler: wgpu::Sampler,
90    /// Formato de color del target — necesario para crear las texturas
91    /// MSAA/scratch del `flush` con el mismo formato que el `view`.
92    color_format: wgpu::TextureFormat,
93}
94
95impl GpuPipelines {
96    /// Compila los 3 pipelines apuntando al `color_format` del target
97    /// que recibirán en `flush` (el de la intermediate de `WinitSurface`,
98    /// normalmente `Rgba8Unorm`).
99    pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
100        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
101            label: Some("llimphi-raster-gpu-shader"),
102            source: wgpu::ShaderSource::Wgsl(WGSL.into()),
103        });
104
105        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
106            label: Some("llimphi-raster-gpu-bgl"),
107            entries: &[wgpu::BindGroupLayoutEntry {
108                binding: 0,
109                visibility: wgpu::ShaderStages::VERTEX,
110                ty: wgpu::BindingType::Buffer {
111                    ty: wgpu::BufferBindingType::Uniform,
112                    has_dynamic_offset: false,
113                    min_binding_size: None,
114                },
115                count: None,
116            }],
117        });
118
119        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
120            label: Some("llimphi-raster-gpu-pl"),
121            bind_group_layouts: &[&bind_layout],
122            push_constant_ranges: &[],
123        });
124
125        let color_targets = [Some(wgpu::ColorTargetState {
126            format: color_format,
127            blend: Some(wgpu::BlendState::ALPHA_BLENDING),
128            write_mask: wgpu::ColorWrites::ALL,
129        })];
130
131        // Triángulos (vertex buffer plano, color per-vertex).
132        let tris = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
133            label: Some("llimphi-raster-gpu-tris"),
134            layout: Some(&pipeline_layout),
135            vertex: wgpu::VertexState {
136                module: &shader,
137                entry_point: Some("vs_tris"),
138                compilation_options: Default::default(),
139                buffers: &[wgpu::VertexBufferLayout {
140                    array_stride: 12,
141                    step_mode: wgpu::VertexStepMode::Vertex,
142                    attributes: &[
143                        wgpu::VertexAttribute {
144                            format: wgpu::VertexFormat::Float32x2,
145                            offset: 0,
146                            shader_location: 0,
147                        },
148                        wgpu::VertexAttribute {
149                            format: wgpu::VertexFormat::Uint32,
150                            offset: 8,
151                            shader_location: 1,
152                        },
153                    ],
154                }],
155            },
156            primitive: tri_primitive(),
157            depth_stencil: None,
158            multisample: wgpu::MultisampleState {
159                count: MSAA_SAMPLES,
160                ..Default::default()
161            },
162            fragment: Some(wgpu::FragmentState {
163                module: &shader,
164                entry_point: Some("fs"),
165                compilation_options: Default::default(),
166                targets: &color_targets,
167            }),
168            multiview: None,
169            cache: None,
170        });
171
172        // Rects (instanced quad).
173        let rects = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
174            label: Some("llimphi-raster-gpu-rects"),
175            layout: Some(&pipeline_layout),
176            vertex: wgpu::VertexState {
177                module: &shader,
178                entry_point: Some("vs_rects"),
179                compilation_options: Default::default(),
180                buffers: &[wgpu::VertexBufferLayout {
181                    array_stride: 20,
182                    step_mode: wgpu::VertexStepMode::Instance,
183                    attributes: &[
184                        wgpu::VertexAttribute {
185                            format: wgpu::VertexFormat::Float32x2,
186                            offset: 0,
187                            shader_location: 0,
188                        },
189                        wgpu::VertexAttribute {
190                            format: wgpu::VertexFormat::Float32x2,
191                            offset: 8,
192                            shader_location: 1,
193                        },
194                        wgpu::VertexAttribute {
195                            format: wgpu::VertexFormat::Uint32,
196                            offset: 16,
197                            shader_location: 2,
198                        },
199                    ],
200                }],
201            },
202            primitive: tri_primitive(),
203            depth_stencil: None,
204            multisample: wgpu::MultisampleState {
205                count: MSAA_SAMPLES,
206                ..Default::default()
207            },
208            fragment: Some(wgpu::FragmentState {
209                module: &shader,
210                entry_point: Some("fs"),
211                compilation_options: Default::default(),
212                targets: &color_targets,
213            }),
214            multiview: None,
215            cache: None,
216        });
217
218        // Líneas con grosor: cada segmento es una instancia de 20 B; el
219        // VS expande a un quad de 6 vértices perpendicular al segmento
220        // usando un grosor uniforme en píxeles (vienen del uniform).
221        let lines = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
222            label: Some("llimphi-raster-gpu-lines"),
223            layout: Some(&pipeline_layout),
224            vertex: wgpu::VertexState {
225                module: &shader,
226                entry_point: Some("vs_lines"),
227                compilation_options: Default::default(),
228                buffers: &[wgpu::VertexBufferLayout {
229                    array_stride: 20,
230                    step_mode: wgpu::VertexStepMode::Instance,
231                    attributes: &[
232                        wgpu::VertexAttribute {
233                            format: wgpu::VertexFormat::Float32x4,
234                            offset: 0,
235                            shader_location: 0,
236                        },
237                        wgpu::VertexAttribute {
238                            format: wgpu::VertexFormat::Uint32,
239                            offset: 16,
240                            shader_location: 1,
241                        },
242                    ],
243                }],
244            },
245            primitive: tri_primitive(),
246            depth_stencil: None,
247            multisample: wgpu::MultisampleState {
248                count: MSAA_SAMPLES,
249                ..Default::default()
250            },
251            fragment: Some(wgpu::FragmentState {
252                module: &shader,
253                entry_point: Some("fs"),
254                compilation_options: Default::default(),
255                targets: &color_targets,
256            }),
257            multiview: None,
258            cache: None,
259        });
260
261        // Discos/anillos (instanced quad + SDF AA en el fragment). Cada
262        // disco es una instancia de 24 B: `[cx, cy, r, stroke, rgba]`. El
263        // VS expande un quad que cubre el disco (con 1 px de margen para
264        // que el smoothstep del borde no se recorte) y pasa al FS la
265        // posición local en px; el FS calcula la distancia al centro y
266        // hace smoothstep sobre ~1 px (`fwidth`) → borde antialiased.
267        let discs = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
268            label: Some("llimphi-raster-gpu-discs"),
269            layout: Some(&pipeline_layout),
270            vertex: wgpu::VertexState {
271                module: &shader,
272                entry_point: Some("vs_discs"),
273                compilation_options: Default::default(),
274                buffers: &[wgpu::VertexBufferLayout {
275                    array_stride: 20,
276                    step_mode: wgpu::VertexStepMode::Instance,
277                    attributes: &[
278                        // cx, cy
279                        wgpu::VertexAttribute {
280                            format: wgpu::VertexFormat::Float32x2,
281                            offset: 0,
282                            shader_location: 0,
283                        },
284                        // r, stroke
285                        wgpu::VertexAttribute {
286                            format: wgpu::VertexFormat::Float32x2,
287                            offset: 8,
288                            shader_location: 1,
289                        },
290                        // rgba
291                        wgpu::VertexAttribute {
292                            format: wgpu::VertexFormat::Uint32,
293                            offset: 16,
294                            shader_location: 2,
295                        },
296                    ],
297                }],
298            },
299            primitive: tri_primitive(),
300            depth_stencil: None,
301            multisample: wgpu::MultisampleState {
302                count: MSAA_SAMPLES,
303                ..Default::default()
304            },
305            fragment: Some(wgpu::FragmentState {
306                module: &shader,
307                entry_point: Some("fs_disc"),
308                compilation_options: Default::default(),
309                targets: &color_targets,
310            }),
311            multiview: None,
312            cache: None,
313        });
314
315        // Pipeline de composite (alpha-over) de la scratch resuelta del
316        // MSAA sobre el `view`. Single-sample (count = 1), pase fullscreen
317        // de un triángulo. Asume alpha **premultiplicado** — el MSAA + el
318        // blending de los primitivos producen color premultiplicado, así
319        // que el over correcto es `src.rgb*1 + dst.rgb*(1-src.a)`.
320        let composite_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
321            label: Some("llimphi-raster-gpu-composite-bgl"),
322            entries: &[
323                wgpu::BindGroupLayoutEntry {
324                    binding: 0,
325                    visibility: wgpu::ShaderStages::FRAGMENT,
326                    ty: wgpu::BindingType::Texture {
327                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
328                        view_dimension: wgpu::TextureViewDimension::D2,
329                        multisampled: false,
330                    },
331                    count: None,
332                },
333                wgpu::BindGroupLayoutEntry {
334                    binding: 1,
335                    visibility: wgpu::ShaderStages::FRAGMENT,
336                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
337                    count: None,
338                },
339            ],
340        });
341        let composite_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
342            label: Some("llimphi-raster-gpu-composite-pl"),
343            bind_group_layouts: &[&composite_bgl],
344            push_constant_ranges: &[],
345        });
346        let composite = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
347            label: Some("llimphi-raster-gpu-composite"),
348            layout: Some(&composite_pl),
349            vertex: wgpu::VertexState {
350                module: &shader,
351                entry_point: Some("vs_composite"),
352                compilation_options: Default::default(),
353                buffers: &[],
354            },
355            primitive: wgpu::PrimitiveState::default(),
356            depth_stencil: None,
357            multisample: wgpu::MultisampleState::default(),
358            fragment: Some(wgpu::FragmentState {
359                module: &shader,
360                entry_point: Some("fs_composite"),
361                compilation_options: Default::default(),
362                targets: &[Some(wgpu::ColorTargetState {
363                    format: color_format,
364                    blend: Some(wgpu::BlendState {
365                        color: wgpu::BlendComponent {
366                            src_factor: wgpu::BlendFactor::One,
367                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
368                            operation: wgpu::BlendOperation::Add,
369                        },
370                        alpha: wgpu::BlendComponent {
371                            src_factor: wgpu::BlendFactor::One,
372                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
373                            operation: wgpu::BlendOperation::Add,
374                        },
375                    }),
376                    write_mask: wgpu::ColorWrites::ALL,
377                })],
378            }),
379            multiview: None,
380            cache: None,
381        });
382        let composite_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
383            label: Some("llimphi-raster-gpu-composite-sampler"),
384            ..Default::default()
385        });
386
387        Self {
388            lines,
389            tris,
390            rects,
391            discs,
392            bind_layout,
393            composite,
394            composite_bgl,
395            composite_sampler,
396            color_format,
397        }
398    }
399}
400
401fn tri_primitive() -> wgpu::PrimitiveState {
402    wgpu::PrimitiveState {
403        topology: wgpu::PrimitiveTopology::TriangleList,
404        strip_index_format: None,
405        front_face: wgpu::FrontFace::Ccw,
406        cull_mode: None,
407        unclipped_depth: false,
408        polygon_mode: wgpu::PolygonMode::Fill,
409        conservative: false,
410    }
411}
412
413/// Acumulador de primitivas por frame. Construir → `add_*` → `flush`.
414pub struct GpuBatch<'a> {
415    pipelines: &'a GpuPipelines,
416    line_verts: Vec<u8>,
417    tri_verts: Vec<u8>,
418    rect_insts: Vec<u8>,
419    disc_insts: Vec<u8>,
420    line_width: f32,
421    line_count: u32,
422    tri_vert_count: u32,
423    rect_count: u32,
424    disc_count: u32,
425}
426
427impl<'a> GpuBatch<'a> {
428    pub fn new(pipelines: &'a GpuPipelines) -> Self {
429        Self {
430            pipelines,
431            line_verts: Vec::new(),
432            tri_verts: Vec::new(),
433            rect_insts: Vec::new(),
434            disc_insts: Vec::new(),
435            line_width: 1.0,
436            line_count: 0,
437            tri_vert_count: 0,
438            rect_count: 0,
439            disc_count: 0,
440        }
441    }
442
443    /// Grosor de las próximas líneas (en pixels del frame, sin AA).
444    /// Se aplica a todas las líneas del batch — el lado bueno de una
445    /// sola draw call es que sólo hay un grosor "vivo" por flush.
446    pub fn line_width(&mut self, w: f32) {
447        self.line_width = w;
448    }
449
450    /// Añade un segmento de línea como instancia.
451    pub fn add_line(&mut self, p0: (f32, f32), p1: (f32, f32), color: Color) {
452        let rgba = pack_rgba(color);
453        self.line_verts.extend_from_slice(&p0.0.to_ne_bytes());
454        self.line_verts.extend_from_slice(&p0.1.to_ne_bytes());
455        self.line_verts.extend_from_slice(&p1.0.to_ne_bytes());
456        self.line_verts.extend_from_slice(&p1.1.to_ne_bytes());
457        self.line_verts.extend_from_slice(&rgba.to_ne_bytes());
458        self.line_count += 1;
459    }
460
461    /// Añade una polilínea como secuencia de segmentos individuales
462    /// (line-list). Para N puntos emite N-1 instancias.
463    pub fn add_polyline(&mut self, points: &[(f32, f32)], color: Color) {
464        if points.len() < 2 {
465            return;
466        }
467        for w in points.windows(2) {
468            self.add_line(w[0], w[1], color);
469        }
470    }
471
472    /// Añade un triángulo con color por vértice.
473    pub fn add_tri(
474        &mut self,
475        a: (f32, f32),
476        b: (f32, f32),
477        c: (f32, f32),
478        ca: Color,
479        cb: Color,
480        cc: Color,
481    ) {
482        self.push_tri_vert(a, ca);
483        self.push_tri_vert(b, cb);
484        self.push_tri_vert(c, cc);
485    }
486
487    fn push_tri_vert(&mut self, p: (f32, f32), color: Color) {
488        let rgba = pack_rgba(color);
489        self.tri_verts.extend_from_slice(&p.0.to_ne_bytes());
490        self.tri_verts.extend_from_slice(&p.1.to_ne_bytes());
491        self.tri_verts.extend_from_slice(&rgba.to_ne_bytes());
492        self.tri_vert_count += 1;
493    }
494
495    /// Añade un triangle list crudo `[(x, y); 3*N]` con un mismo color
496    /// uniforme por vértice. Útil para teselaciones precomputadas
497    /// (contornos, polígonos rellenos).
498    pub fn add_tri_list(&mut self, verts: &[(f32, f32)], color: Color) {
499        for &p in verts {
500            self.push_tri_vert(p, color);
501        }
502    }
503
504    /// Añade un rectángulo lleno como instancia (sin radio — para
505    /// rounded rects sigue por vello).
506    pub fn add_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Color) {
507        let rgba = pack_rgba(color);
508        self.rect_insts.extend_from_slice(&x.to_ne_bytes());
509        self.rect_insts.extend_from_slice(&y.to_ne_bytes());
510        self.rect_insts.extend_from_slice(&w.to_ne_bytes());
511        self.rect_insts.extend_from_slice(&h.to_ne_bytes());
512        self.rect_insts.extend_from_slice(&rgba.to_ne_bytes());
513        self.rect_count += 1;
514    }
515
516    /// Añade un disco (círculo relleno) con AA por shader como instancia.
517    /// `(cx, cy)` es el centro y `r` el radio, ambos en pixels del frame.
518    /// El borde queda antialiased vía un SDF + `smoothstep` de ~1 px en
519    /// el fragment — no escalonado, sin MSAA. El alpha del color se
520    /// respeta (blending alfa activo).
521    pub fn add_disc(&mut self, cx: f32, cy: f32, r: f32, color: Color) {
522        self.push_disc(cx, cy, r, 0.0, color);
523    }
524
525    /// Añade un anillo (círculo hueco / stroke circular) con AA por
526    /// shader. `r` es el radio exterior; `stroke` el grosor del trazo en
527    /// px (el agujero interior tiene radio `r - stroke`). `stroke <= 0`
528    /// degenera en un disco lleno. Ambos bordes (externo e interno)
529    /// quedan antialiased.
530    pub fn add_ring(&mut self, cx: f32, cy: f32, r: f32, stroke: f32, color: Color) {
531        self.push_disc(cx, cy, r, stroke.max(0.0), color);
532    }
533
534    fn push_disc(&mut self, cx: f32, cy: f32, r: f32, stroke: f32, color: Color) {
535        let rgba = pack_rgba(color);
536        self.disc_insts.extend_from_slice(&cx.to_ne_bytes());
537        self.disc_insts.extend_from_slice(&cy.to_ne_bytes());
538        self.disc_insts.extend_from_slice(&r.to_ne_bytes());
539        self.disc_insts.extend_from_slice(&stroke.to_ne_bytes());
540        self.disc_insts.extend_from_slice(&rgba.to_ne_bytes());
541        self.disc_count += 1;
542    }
543
544    /// Cuenta total de primitivas pendientes (útil para benches).
545    pub fn primitive_count(&self) -> u32 {
546        self.line_count + self.rect_count + self.disc_count + self.tri_vert_count / 3
547    }
548
549    /// Despacha las primitivas acumuladas como 1 draw call por tipo
550    /// no vacío contra `view`. `viewport` es el tamaño en pixels del
551    /// target (lo usa el VS para mapear pixel → NDC).
552    ///
553    /// `load_op` decide si la pasada conserva el contenido previo
554    /// (`Load`, lo normal cuando vello ya pintó algo) o limpia
555    /// (`Clear(color)`). Apps que llamen a `GpuBatch` desde
556    /// `gpu_paint_with` quieren `Load`.
557    pub fn flush(
558        self,
559        device: &wgpu::Device,
560        queue: &wgpu::Queue,
561        encoder: &mut wgpu::CommandEncoder,
562        view: &wgpu::TextureView,
563        viewport: (f32, f32),
564        load_op: wgpu::LoadOp<wgpu::Color>,
565    ) {
566        let total =
567            self.line_count + self.tri_vert_count + self.rect_count + self.disc_count;
568        if total == 0 {
569            return;
570        }
571
572        // Uniforms: [viewport.w, viewport.h, line_width, _pad].
573        let u_data = [viewport.0, viewport.1, self.line_width, 0.0];
574        let mut u_bytes = Vec::with_capacity(16);
575        for v in u_data {
576            u_bytes.extend_from_slice(&v.to_ne_bytes());
577        }
578        let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
579            label: Some("llimphi-raster-gpu-u"),
580            size: 16,
581            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
582            mapped_at_creation: false,
583        });
584        queue.write_buffer(&uniforms, 0, &u_bytes);
585
586        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
587            label: Some("llimphi-raster-gpu-bg"),
588            layout: &self.pipelines.bind_layout,
589            entries: &[wgpu::BindGroupEntry {
590                binding: 0,
591                resource: uniforms.as_entire_binding(),
592            }],
593        });
594
595        // Buffers por tipo (sólo si hay datos).
596        let lines_buf = (!self.line_verts.is_empty()).then(|| {
597            let b = device.create_buffer(&wgpu::BufferDescriptor {
598                label: Some("llimphi-raster-gpu-lines-buf"),
599                size: self.line_verts.len() as u64,
600                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
601                mapped_at_creation: false,
602            });
603            queue.write_buffer(&b, 0, &self.line_verts);
604            b
605        });
606        let tris_buf = (!self.tri_verts.is_empty()).then(|| {
607            let b = device.create_buffer(&wgpu::BufferDescriptor {
608                label: Some("llimphi-raster-gpu-tris-buf"),
609                size: self.tri_verts.len() as u64,
610                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
611                mapped_at_creation: false,
612            });
613            queue.write_buffer(&b, 0, &self.tri_verts);
614            b
615        });
616        let rects_buf = (!self.rect_insts.is_empty()).then(|| {
617            let b = device.create_buffer(&wgpu::BufferDescriptor {
618                label: Some("llimphi-raster-gpu-rects-buf"),
619                size: self.rect_insts.len() as u64,
620                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
621                mapped_at_creation: false,
622            });
623            queue.write_buffer(&b, 0, &self.rect_insts);
624            b
625        });
626        let discs_buf = (!self.disc_insts.is_empty()).then(|| {
627            let b = device.create_buffer(&wgpu::BufferDescriptor {
628                label: Some("llimphi-raster-gpu-discs-buf"),
629                size: self.disc_insts.len() as u64,
630                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
631                mapped_at_creation: false,
632            });
633            queue.write_buffer(&b, 0, &self.disc_insts);
634            b
635        });
636
637        // ── MSAA 4× ──────────────────────────────────────────────────
638        // Texturas por-flush dimensionadas al viewport (mismo patrón que
639        // los buffers de arriba; el resize "sale gratis"). `tex_w/h` se
640        // clampean a ≥1 para evitar Extent3d de 0 (un viewport degenerado
641        // no debería llegar acá, pero defensivo).
642        let tex_w = (viewport.0.round() as u32).max(1);
643        let tex_h = (viewport.1.round() as u32).max(1);
644        let extent = wgpu::Extent3d {
645            width: tex_w,
646            height: tex_h,
647            depth_or_array_layers: 1,
648        };
649        let fmt = self.pipelines.color_format;
650        // Color attachment multisample: lo rasterizan los 4 pipelines.
651        let msaa_tex = device.create_texture(&wgpu::TextureDescriptor {
652            label: Some("llimphi-raster-gpu-msaa"),
653            size: extent,
654            mip_level_count: 1,
655            sample_count: MSAA_SAMPLES,
656            dimension: wgpu::TextureDimension::D2,
657            format: fmt,
658            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
659            view_formats: &[],
660        });
661        let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default());
662        // Scratch single-sample: recibe el resolve del MSAA y luego se
663        // samplea en el composite sobre el `view`.
664        let resolve_tex = device.create_texture(&wgpu::TextureDescriptor {
665            label: Some("llimphi-raster-gpu-resolve"),
666            size: extent,
667            mip_level_count: 1,
668            sample_count: 1,
669            dimension: wgpu::TextureDimension::D2,
670            format: fmt,
671            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
672                | wgpu::TextureUsages::TEXTURE_BINDING,
673            view_formats: &[],
674        });
675        let resolve_view =
676            resolve_tex.create_view(&wgpu::TextureViewDescriptor::default());
677
678        // Pase de primitivos: MSAA cleared a TRANSPARENT, resuelto al
679        // scratch single-sample. El scratch queda con alpha
680        // **premultiplicado** (el blending alfa de los pipelines sobre
681        // fondo transparente produce `rgb = color*alpha`, `a = alpha`).
682        {
683            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
684                label: Some("llimphi-raster-gpu-pass"),
685                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
686                    view: &msaa_view,
687                    resolve_target: Some(&resolve_view),
688                    depth_slice: None,
689                    ops: wgpu::Operations {
690                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
691                        store: wgpu::StoreOp::Store,
692                    },
693                })],
694                depth_stencil_attachment: None,
695                timestamp_writes: None,
696                occlusion_query_set: None,
697            });
698            pass.set_bind_group(0, &bind_group, &[]);
699
700            // Orden de draws: rects (fondo) → discos → tris → lines (encima).
701            // Match de la convención usual "fill abajo, stroke arriba".
702            if let Some(buf) = rects_buf.as_ref() {
703                pass.set_pipeline(&self.pipelines.rects);
704                pass.set_vertex_buffer(0, buf.slice(..));
705                pass.draw(0..6, 0..self.rect_count);
706            }
707            if let Some(buf) = discs_buf.as_ref() {
708                pass.set_pipeline(&self.pipelines.discs);
709                pass.set_vertex_buffer(0, buf.slice(..));
710                pass.draw(0..6, 0..self.disc_count);
711            }
712            if let Some(buf) = tris_buf.as_ref() {
713                pass.set_pipeline(&self.pipelines.tris);
714                pass.set_vertex_buffer(0, buf.slice(..));
715                pass.draw(0..self.tri_vert_count, 0..1);
716            }
717            if let Some(buf) = lines_buf.as_ref() {
718                pass.set_pipeline(&self.pipelines.lines);
719                pass.set_vertex_buffer(0, buf.slice(..));
720                pass.draw(0..6, 0..self.line_count);
721            }
722        }
723
724        // Composite del scratch resuelto sobre el `view`. Respeta el
725        // `load_op` recibido:
726        //  - `Load`  → alpha-over: preserva lo que ya está en `view`
727        //              (vello), exactamente como el viejo pase directo.
728        //  - `Clear` → limpia `view` al color pedido y luego compone
729        //              el scratch encima (mismo resultado que limpiar y
730        //              dibujar directo).
731        let composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
732            label: Some("llimphi-raster-gpu-composite-bg"),
733            layout: &self.pipelines.composite_bgl,
734            entries: &[
735                wgpu::BindGroupEntry {
736                    binding: 0,
737                    resource: wgpu::BindingResource::TextureView(&resolve_view),
738                },
739                wgpu::BindGroupEntry {
740                    binding: 1,
741                    resource: wgpu::BindingResource::Sampler(
742                        &self.pipelines.composite_sampler,
743                    ),
744                },
745            ],
746        });
747        let mut cpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
748            label: Some("llimphi-raster-gpu-composite-pass"),
749            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
750                view,
751                resolve_target: None,
752                depth_slice: None,
753                ops: wgpu::Operations {
754                    // El blend del pipeline ya hace el alpha-over; con
755                    // Load conserva el fondo, con Clear lo borra primero.
756                    load: load_op,
757                    store: wgpu::StoreOp::Store,
758                },
759            })],
760            depth_stencil_attachment: None,
761            timestamp_writes: None,
762            occlusion_query_set: None,
763        });
764        cpass.set_pipeline(&self.pipelines.composite);
765        cpass.set_bind_group(0, &composite_bg, &[]);
766        cpass.draw(0..3, 0..1);
767    }
768}
769
770/// Empaqueta un `peniko::Color` a u32 little-endian RGBA8.
771/// El shader lo lee como `inst.rgba` y separa bytes — debe coincidir
772/// con la convención del WGSL (`r = rgba & 0xFF`, etc.).
773fn pack_rgba(c: Color) -> u32 {
774    let [r, g, b, a] = c.to_rgba8().to_u8_array();
775    (r as u32) | ((g as u32) << 8) | ((b as u32) << 16) | ((a as u32) << 24)
776}
777
778const WGSL: &str = r#"
779struct Uniforms {
780    viewport:   vec2<f32>,
781    line_width: f32,
782    _pad:       f32,
783};
784
785@group(0) @binding(0) var<uniform> u: Uniforms;
786
787struct V2F {
788    @builtin(position) pos: vec4<f32>,
789    @location(0) color: vec4<f32>,
790};
791
792fn unpack_rgba(c: u32) -> vec4<f32> {
793    let r = f32( c        & 0xFFu) / 255.0;
794    let g = f32((c >>  8u) & 0xFFu) / 255.0;
795    let b = f32((c >> 16u) & 0xFFu) / 255.0;
796    let a = f32((c >> 24u) & 0xFFu) / 255.0;
797    return vec4<f32>(r, g, b, a);
798}
799
800fn px_to_ndc(p: vec2<f32>) -> vec2<f32> {
801    return vec2<f32>(p.x / u.viewport.x * 2.0 - 1.0, 1.0 - p.y / u.viewport.y * 2.0);
802}
803
804// -------- triángulos: 1 vértice = (xy, rgba) --------
805
806@vertex
807fn vs_tris(@location(0) xy: vec2<f32>, @location(1) rgba: u32) -> V2F {
808    var out: V2F;
809    out.pos = vec4<f32>(px_to_ndc(xy), 0.0, 1.0);
810    out.color = unpack_rgba(rgba);
811    return out;
812}
813
814// -------- rects: 1 instancia = (xy, wh, rgba), 6 vértices/quad --------
815
816@vertex
817fn vs_rects(
818    @builtin(vertex_index) vid: u32,
819    @location(0) inst_xy: vec2<f32>,
820    @location(1) inst_wh: vec2<f32>,
821    @location(2) inst_rgba: u32,
822) -> V2F {
823    var corners = array<vec2<f32>, 6>(
824        vec2<f32>(0.0, 0.0),
825        vec2<f32>(1.0, 0.0),
826        vec2<f32>(1.0, 1.0),
827        vec2<f32>(0.0, 0.0),
828        vec2<f32>(1.0, 1.0),
829        vec2<f32>(0.0, 1.0),
830    );
831    let local = corners[vid];
832    let px = inst_xy + local * inst_wh;
833    var out: V2F;
834    out.pos = vec4<f32>(px_to_ndc(px), 0.0, 1.0);
835    out.color = unpack_rgba(inst_rgba);
836    return out;
837}
838
839// -------- líneas: 1 instancia = (p0xy, p1xy, rgba), expandida a quad ----
840
841@vertex
842fn vs_lines(
843    @builtin(vertex_index) vid: u32,
844    @location(0) seg: vec4<f32>,
845    @location(1) rgba: u32,
846) -> V2F {
847    // Quad perpendicular al segmento, grosor uniforme `u.line_width` px.
848    // vid 0..5 mapea a los 6 vértices del quad (2 tris).
849    let p0 = seg.xy;
850    let p1 = seg.zw;
851    let dir = normalize(p1 - p0);
852    let n = vec2<f32>(-dir.y, dir.x);
853    let half_w = u.line_width * 0.5;
854    let offsets = array<vec2<f32>, 6>(
855        vec2<f32>(0.0, -half_w),  // p0 -n
856        vec2<f32>(0.0,  half_w),  // p0 +n
857        vec2<f32>(1.0,  half_w),  // p1 +n
858        vec2<f32>(0.0, -half_w),  // p0 -n
859        vec2<f32>(1.0,  half_w),  // p1 +n
860        vec2<f32>(1.0, -half_w),  // p1 -n
861    );
862    let o = offsets[vid];
863    let along = mix(p0, p1, o.x);
864    let across = n * o.y;
865    let px = along + across;
866    var out: V2F;
867    out.pos = vec4<f32>(px_to_ndc(px), 0.0, 1.0);
868    out.color = unpack_rgba(rgba);
869    return out;
870}
871
872// -------- discos/anillos: 1 instancia = (cxcy, r/stroke, rgba) --------
873//
874// Quad que cubre el disco con 1.5 px de margen (para que el smoothstep
875// del borde no se recorte). El VS pasa al FS la posición local en px
876// relativa al centro; el FS evalúa el SDF del círculo y hace smoothstep
877// sobre `fwidth` → borde antialiased. `stroke > 0` recorta un anillo.
878
879struct DiscV2F {
880    @builtin(position) pos: vec4<f32>,
881    @location(0) color: vec4<f32>,
882    @location(1) local: vec2<f32>,  // px relativos al centro
883    @location(2) params: vec2<f32>, // r, stroke (px)
884};
885
886@vertex
887fn vs_discs(
888    @builtin(vertex_index) vid: u32,
889    @location(0) inst_c: vec2<f32>,
890    @location(1) inst_rs: vec2<f32>,
891    @location(2) inst_rgba: u32,
892) -> DiscV2F {
893    var corners = array<vec2<f32>, 6>(
894        vec2<f32>(-1.0, -1.0),
895        vec2<f32>( 1.0, -1.0),
896        vec2<f32>( 1.0,  1.0),
897        vec2<f32>(-1.0, -1.0),
898        vec2<f32>( 1.0,  1.0),
899        vec2<f32>(-1.0,  1.0),
900    );
901    let r = inst_rs.x;
902    let margin = r + 1.5;          // 1.5 px de aire para el AA del borde
903    let local = corners[vid] * margin;
904    let px = inst_c + local;
905    var out: DiscV2F;
906    out.pos = vec4<f32>(px_to_ndc(px), 0.0, 1.0);
907    out.color = unpack_rgba(inst_rgba);
908    out.local = local;
909    out.params = inst_rs;
910    return out;
911}
912
913@fragment
914fn fs_disc(in: DiscV2F) -> @location(0) vec4<f32> {
915    let r = in.params.x;
916    let stroke = in.params.y;
917    let dist = length(in.local);     // distancia al centro en px
918    // Ancho del filtro AA en px (≈ 1 px en pantalla).
919    let aa = fwidth(dist);
920    // Borde exterior: cobertura 1 dentro de r, 0 fuera de r+aa.
921    var cov = 1.0 - smoothstep(r - aa, r + aa, dist);
922    // Anillo: si hay stroke, recortamos el agujero interior con AA.
923    if (stroke > 0.0) {
924        let inner = max(r - stroke, 0.0);
925        cov = cov * smoothstep(inner - aa, inner + aa, dist);
926    }
927    if (cov <= 0.0) {
928        discard;
929    }
930    return vec4<f32>(in.color.rgb, in.color.a * cov);
931}
932
933@fragment
934fn fs(in: V2F) -> @location(0) vec4<f32> {
935    return in.color;
936}
937
938// -------- composite: blit fullscreen de la scratch resuelta del MSAA ----
939//
940// Triángulo de pantalla completa (3 vértices, sin vertex buffer). Samplea
941// la scratch (alpha **premultiplicado**) y la emite tal cual; el alpha-over
942// real lo hace el BlendState del pipeline (`One, OneMinusSrcAlpha`).
943
944@group(0) @binding(0) var src_tex: texture_2d<f32>;
945@group(0) @binding(1) var src_samp: sampler;
946
947struct CompV2F {
948    @builtin(position) pos: vec4<f32>,
949    @location(0) uv: vec2<f32>,
950};
951
952@vertex
953fn vs_composite(@builtin(vertex_index) vid: u32) -> CompV2F {
954    // Triángulo gigante que cubre el viewport (técnica estándar).
955    var uvs = array<vec2<f32>, 3>(
956        vec2<f32>(0.0, 0.0),
957        vec2<f32>(2.0, 0.0),
958        vec2<f32>(0.0, 2.0),
959    );
960    let uv = uvs[vid];
961    var out: CompV2F;
962    out.pos = vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
963    // El framebuffer tiene Y hacia abajo; la textura, hacia arriba en UV.
964    out.uv = vec2<f32>(uv.x, 1.0 - uv.y);
965    return out;
966}
967
968@fragment
969fn fs_composite(in: CompV2F) -> @location(0) vec4<f32> {
970    return textureSample(src_tex, src_samp, in.uv);
971}
972"#;