tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 614Markdown 274TOML 149RON 310606 Definitions

⎇ main llimphi-raster/src/gpu.rs Rust · 1560 lines · 64.1 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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, y: f32, color: 4×f32]` (24 B/vert).
11//! - Instance format líneas: `[x0, y0, x1, y1, color: 4×f32]` (32 B/seg).
12//! - Instance format rects:  `[x, y, w, h, color: 4×f32]` (32 B/rect).
13//! - Instance format discos: `[cx, cy, r, stroke, color: 4×f32]` (32 B/disco).
14//! - El color va como **4×f32** (no u32 empaquetado): preserva precisión HDR
15//!   (>1.0) y sub-8-bit cuando el target es `Rgba16Float`/`Rgb10a2`; en target
16//!   8-bit el resultado visible es idéntico al pack anterior.
17//! - Sin texturas. Rects/líneas/tris obtienen AA de **bordes** vía MSAA 4×
18//!   (ver más abajo); los discos SÍ traen AA por SDF en el fragment
19//!   (smoothstep sobre `fwidth`), que MSAA respeta. Así rects/tris/líneas
20//!   instanciados salen con bordes suaves sin que el caller toque nada.
21//! - Blending alfa habilitado: el alpha del color es respetado.
22//! - El viewport `(width, height)` se pasa al flush y va en un uniform —
23//!   los shaders convierten pixel → NDC ahí.
24//!
25//! Cache de pipelines: una sola instancia de `GpuPipelines` por
26//! `(device, color_format)`. Construirla compila los 3 pipelines en
27//! caliente (~ms en hardware moderno). Los callers la mantienen viva
28//! entre frames (en su Model o vía `OnceLock`).
29//!
30//! Grow strategy: `flush` crea un buffer por tipo no vacío en el
31//! mismo frame. Sin reuso entre frames — Fase 4 (`GpuSceneCanvas`)
32//! introducirá el `GpuBuffers` persistente que dobla capacidad si
33//! aparece la necesidad.
34//!
35//! Techo de buffer: `max_buffer_size` es un límite duro del dispositivo
36//! (256 MB en una Iris Xe = ~8,4 M de primitivas a 32 B). Pasado eso, wgpu
37//! **rechaza** la reserva; no es "va más lento", es que no anda. `flush`
38//! parte cada tipo en lotes consecutivos ([`units_per_chunk`]) y dibuja uno
39//! por lote conservando el orden, así que el alpha-over sale idéntico. En el
40//! caso normal —todo entra— es un lote y las mismas cuatro draw calls.
41//!
42//! ## MSAA 4× (antialiasing de bordes)
43//!
44//! El pase no dibuja directo sobre el `view` que recibe `flush`. En su
45//! lugar rasteriza todos los primitivos a una textura **multisample 4×**
46//! (cleared a transparente), la *resuelve* a una textura single-sample
47//! scratch y **compone con alpha** ese resultado sobre el `view`. Así:
48//!
49//! - Los bordes de rects/tris/líneas quedan suaves (4 muestras/pixel),
50//!   no escalonados.
51//! - El contenido previo del `view` (lo que vello pintó) se preserva,
52//!   porque el composite es alpha-over con `LoadOp::Load` — exactamente
53//!   la semántica que tenía el viejo render pass directo con `LoadOp::Load`.
54//! - `LoadOp::Clear(c)` se respeta: el `view` se limpia a `c` antes del
55//!   composite (equivalente a la pasada directa anterior).
56//!
57//! Backward-compat: la firma pública de `flush` / `GpuPipelines::new` no
58//! cambia. Las texturas MSAA + scratch se crean por-flush dimensionadas
59//! **al rect sucio** ([`GpuBatch::scissor`]) o, si no hay, al `viewport`
60//! entero; así el resize "sale gratis" — cada frame usa el tamaño que se le
61//! pasa, sin estado persistente que recrear. Medido: crear el par de texturas
62//! a 1920×1080 son 6,8 µs, así que cachearlas no compraría nada. El pipeline
63//! de composite se compila una vez y se cachea en `GpuPipelines` (es `Sync`,
64//! vive en `OnceLock`).
65//!
66//! Que las intermedias sigan al rect y no al viewport es lo que hace que
67//! `scissor` valga: el `LoadOp::Clear` del attachment y el resolve del MSAA
68//! son operaciones de **toda** la superficie —el scissor es estado de
69//! rasterización, no toca los load/store ops—, así que con texturas de
70//! pantalla completa pintar un cursor de 32 px seguía costando limpiar y
71//! resolver 1920×1080 en 4 muestras. Medido antes y después, mismo escenario:
72//! el ahorro pasó de **14,8 % a 97,9 %**.
73
74use llimphi_hal::wgpu;
75use vello::peniko::Color;
76
77/// Número de muestras del MSAA del pase GPU. 4× es el punto dulce
78/// universal (soportado por todo hardware moderno, coste moderado).
79const MSAA_SAMPLES: u32 = 4;
80
81/// Bytes por instancia de rect: `x, y, w, h` (4 × f32) + color (4 × f32).
82///
83/// Es **público y una sola constante** porque quien arme un buffer de
84/// instancias a mano —el bench standalone, un caller que quiera reusar el
85/// buffer entre frames— tiene que empaquetar exactamente esto, y adivinarlo
86/// sale caro y en silencio: cuando el color del vértice se ensanchó de un `u32`
87/// empaquetado a `Float32x4` (2026-07-13) el stride pasó de 20 a 32 B, el bench
88/// siguió escribiendo 20, y wgpu rechazó todos sus draws con
89/// «Instance N extends beyond limit N×0,625». El bench no dibujó nada durante
90/// semanas y reportó 23.000 fps por ello.
91pub const RECT_INSTANCE_STRIDE: u32 = 32;
92
93/// Bytes por instancia de línea: `x0, y0, x1, y1` + color (4 × f32) + grosor.
94///
95/// El grosor viaja **por instancia** desde 2026-08-05. Antes era un uniforme del
96/// lote entero, así que una grilla fina y una curva gruesa no podían convivir en
97/// un `flush`: había que cortar el lote, y cada `flush` arma su propia textura
98/// MSAA 4×, resuelve y compone. O sea que «una draw call para todo» dejaba de
99/// serlo apenas el dibujo tenía dos grosores — que es cualquier gráfico.
100pub const LINE_INSTANCE_STRIDE: u32 = 36;
101
102/// Bytes por instancia de disco/anillo: `cx, cy, r, stroke` + color (4 × f32).
103pub const DISC_INSTANCE_STRIDE: u32 = 32;
104
105/// Bytes por **vértice** de triángulo: `x, y` + color (4 × f32). Ojo que acá la
106/// unidad es el vértice, no la primitiva: el triangle-list va sin instanciar y
107/// se dibuja de a 3.
108pub const TRI_VERTEX_STRIDE: u32 = 24;
109
110/// Bytes por instancia de quad texturado: `x, y, w, h` + `u0, v0, u1, v1` +
111/// color (4 × f32).
112///
113/// Es el formato del texto en el camino directo: un glifo es un quad con la UV
114/// de su mapa de cobertura dentro del atlas. 48 B por letra, y un renglón de 80
115/// caracteres entra en una draw call — contra 80 contornos rellenos por vello.
116pub const QUAD_INSTANCE_STRIDE: u32 = 48;
117
118/// Cuántas unidades entran en un buffer de `max_bytes`, redondeando a un borde
119/// válido.
120///
121/// «Unidad» es la instancia en lines/rects/discs y el **vértice** en el
122/// triangle-list; por eso `multiplo`: cortar el buffer de triángulos en un
123/// múltiplo de 3 no es cosmético, un corte a mitad de triángulo dibujaría
124/// basura con los dos vértices que le quedan del lote siguiente.
125///
126/// Nunca devuelve 0 —eso sería un bucle infinito de lotes vacíos—: si ni una
127/// unidad entra en el límite, devuelve `multiplo` y deja que wgpu se queje con
128/// su propio mensaje, que es más informativo que un cuelgue nuestro.
129pub fn units_per_chunk(stride: u32, max_bytes: u64, multiplo: u32) -> u32 {
130    let multiplo = multiplo.max(1);
131    let cabe = (max_bytes / stride.max(1) as u64).min(u32::MAX as u64) as u32;
132    (cabe / multiplo * multiplo).max(multiplo)
133}
134
135/// Pipelines cacheadas. Crear uno por proceso (o por surface format).
136///
137/// Para uso típico via [`GpuBatch`] los campos no se tocan directo. La
138/// API pública existe para callers avanzados que quieran montar su propio
139/// buffer persistente (datos que no cambian por frame: starfield Gaia,
140/// particles iniciales, viewport estático) y emitir draw calls
141/// manualmente reusando estas pipelines.
142///
143/// Layouts (color = 4×f32, HDR-capable):
144/// - Vertex buffer triángulos: `[x, y, color:4×f32]`          (24 B/vert).
145/// - Instance buffer rects:    `[x, y, w, h, color:4×f32]`    (32 B/inst).
146/// - Instance buffer líneas:   `[x0, y0, x1, y1, color:4×f32, w]` (36 B/inst).
147/// - Bind group 0 binding 0: uniform `{dst_size: vec2<f32>, origin: vec2<f32>,
148///   _pad: vec4<f32>}` (32 B). `dst_size` es la textura intermedia y
149///   `origin` su esquina en coordenadas de viewport: los callers siguen
150///   hablando en píxeles de pantalla y el corrimiento lo hace el shader.
151pub struct GpuPipelines {
152    pub lines: wgpu::RenderPipeline,
153    pub tris: wgpu::RenderPipeline,
154    pub rects: wgpu::RenderPipeline,
155    /// Discos/anillos rellenos con AA por SDF en el fragment. Instance
156    /// format: `[cx, cy, r, stroke, rgba]` (20 B/disco). `stroke <= 0`
157    /// → disco lleno; `stroke > 0` → anillo de ese grosor (px). Ver
158    /// [`GpuBatch::add_disc`] / [`GpuBatch::add_ring`].
159    pub discs: wgpu::RenderPipeline,
160    /// Quads texturados contra un atlas de cobertura de un canal — el camino
161    /// del **texto** en el path directo. Instance format:
162    /// `[x, y, w, h, u0, v0, u1, v1, rgba]` (48 B/quad). Ver
163    /// [`GpuBatch::add_glyph`] y [`crate::gpu_atlas::AtlasTextura`].
164    pub quads: wgpu::RenderPipeline,
165    pub bind_layout: wgpu::BindGroupLayout,
166    /// Layout del grupo 1 del pipeline de quads: la textura del atlas + su
167    /// sampler. Sólo lo usa `quads`; los otros cuatro pipelines siguen con un
168    /// solo grupo, así que no pagan nada por esto.
169    atlas_bgl: wgpu::BindGroupLayout,
170    atlas_sampler: wgpu::Sampler,
171    /// Pipeline de pantalla completa que compone (alpha-over) la textura
172    /// scratch resuelta del MSAA sobre el `view` del `flush`. Single-sample.
173    /// El formato del target es el `color_format` con el que se construyó.
174    composite: wgpu::RenderPipeline,
175    composite_bgl: wgpu::BindGroupLayout,
176    composite_sampler: wgpu::Sampler,
177    /// Formato de color del target — necesario para crear las texturas
178    /// MSAA/scratch del `flush` con el mismo formato que el `view`.
179    color_format: wgpu::TextureFormat,
180}
181
182impl GpuPipelines {
183    /// Compila los 3 pipelines apuntando al `color_format` del target
184    /// que recibirán en `flush` (el de la intermediate de `WinitSurface`,
185    /// normalmente `Rgba8Unorm`).
186    pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
187        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
188            label: Some("llimphi-raster-gpu-shader"),
189            source: wgpu::ShaderSource::Wgsl(WGSL.into()),
190        });
191
192        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
193            label: Some("llimphi-raster-gpu-bgl"),
194            entries: &[wgpu::BindGroupLayoutEntry {
195                binding: 0,
196                visibility: wgpu::ShaderStages::VERTEX,
197                ty: wgpu::BindingType::Buffer {
198                    ty: wgpu::BufferBindingType::Uniform,
199                    has_dynamic_offset: false,
200                    min_binding_size: None,
201                },
202                count: None,
203            }],
204        });
205
206        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
207            label: Some("llimphi-raster-gpu-pl"),
208            bind_group_layouts: &[Some(&bind_layout)],
209            immediate_size: 0,
210        });
211
212        let color_targets = [Some(wgpu::ColorTargetState {
213            format: color_format,
214            blend: Some(wgpu::BlendState::ALPHA_BLENDING),
215            write_mask: wgpu::ColorWrites::ALL,
216        })];
217
218        // Triángulos (vertex buffer plano, color per-vertex).
219        let tris = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
220            label: Some("llimphi-raster-gpu-tris"),
221            layout: Some(&pipeline_layout),
222            vertex: wgpu::VertexState {
223                module: &shader,
224                entry_point: Some("vs_tris"),
225                compilation_options: Default::default(),
226                buffers: &[wgpu::VertexBufferLayout {
227                    array_stride: 24,
228                    step_mode: wgpu::VertexStepMode::Vertex,
229                    attributes: &[
230                        wgpu::VertexAttribute {
231                            format: wgpu::VertexFormat::Float32x2,
232                            offset: 0,
233                            shader_location: 0,
234                        },
235                        wgpu::VertexAttribute {
236                            format: wgpu::VertexFormat::Float32x4,
237                            offset: 8,
238                            shader_location: 1,
239                        },
240                    ],
241                }],
242            },
243            primitive: tri_primitive(),
244            depth_stencil: None,
245            multisample: wgpu::MultisampleState {
246                count: MSAA_SAMPLES,
247                ..Default::default()
248            },
249            fragment: Some(wgpu::FragmentState {
250                module: &shader,
251                entry_point: Some("fs"),
252                compilation_options: Default::default(),
253                targets: &color_targets,
254            }),
255            multiview_mask: None,
256            cache: None,
257        });
258
259        // Rects (instanced quad).
260        let rects = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
261            label: Some("llimphi-raster-gpu-rects"),
262            layout: Some(&pipeline_layout),
263            vertex: wgpu::VertexState {
264                module: &shader,
265                entry_point: Some("vs_rects"),
266                compilation_options: Default::default(),
267                buffers: &[wgpu::VertexBufferLayout {
268                    array_stride: RECT_INSTANCE_STRIDE as u64,
269                    step_mode: wgpu::VertexStepMode::Instance,
270                    attributes: &[
271                        wgpu::VertexAttribute {
272                            format: wgpu::VertexFormat::Float32x2,
273                            offset: 0,
274                            shader_location: 0,
275                        },
276                        wgpu::VertexAttribute {
277                            format: wgpu::VertexFormat::Float32x2,
278                            offset: 8,
279                            shader_location: 1,
280                        },
281                        wgpu::VertexAttribute {
282                            format: wgpu::VertexFormat::Float32x4,
283                            offset: 16,
284                            shader_location: 2,
285                        },
286                    ],
287                }],
288            },
289            primitive: tri_primitive(),
290            depth_stencil: None,
291            multisample: wgpu::MultisampleState {
292                count: MSAA_SAMPLES,
293                ..Default::default()
294            },
295            fragment: Some(wgpu::FragmentState {
296                module: &shader,
297                entry_point: Some("fs"),
298                compilation_options: Default::default(),
299                targets: &color_targets,
300            }),
301            multiview_mask: None,
302            cache: None,
303        });
304
305        // Líneas con grosor: cada segmento es una instancia de 20 B; el
306        // VS expande a un quad de 6 vértices perpendicular al segmento
307        // usando un grosor uniforme en píxeles (vienen del uniform).
308        let lines = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
309            label: Some("llimphi-raster-gpu-lines"),
310            layout: Some(&pipeline_layout),
311            vertex: wgpu::VertexState {
312                module: &shader,
313                entry_point: Some("vs_lines"),
314                compilation_options: Default::default(),
315                buffers: &[wgpu::VertexBufferLayout {
316                    // Del `const`, no un 32 a mano: el stride escrito dos veces
317                    // fue lo que dejó al bench midiendo draws rechazados.
318                    array_stride: LINE_INSTANCE_STRIDE as u64,
319                    step_mode: wgpu::VertexStepMode::Instance,
320                    attributes: &[
321                        wgpu::VertexAttribute {
322                            format: wgpu::VertexFormat::Float32x4,
323                            offset: 0,
324                            shader_location: 0,
325                        },
326                        wgpu::VertexAttribute {
327                            format: wgpu::VertexFormat::Float32x4,
328                            offset: 16,
329                            shader_location: 1,
330                        },
331                        // Grosor de ESTA línea, en px.
332                        wgpu::VertexAttribute {
333                            format: wgpu::VertexFormat::Float32,
334                            offset: 32,
335                            shader_location: 2,
336                        },
337                    ],
338                }],
339            },
340            primitive: tri_primitive(),
341            depth_stencil: None,
342            multisample: wgpu::MultisampleState {
343                count: MSAA_SAMPLES,
344                ..Default::default()
345            },
346            fragment: Some(wgpu::FragmentState {
347                module: &shader,
348                entry_point: Some("fs"),
349                compilation_options: Default::default(),
350                targets: &color_targets,
351            }),
352            multiview_mask: None,
353            cache: None,
354        });
355
356        // Discos/anillos (instanced quad + SDF AA en el fragment). Cada
357        // disco es una instancia de 24 B: `[cx, cy, r, stroke, rgba]`. El
358        // VS expande un quad que cubre el disco (con 1 px de margen para
359        // que el smoothstep del borde no se recorte) y pasa al FS la
360        // posición local en px; el FS calcula la distancia al centro y
361        // hace smoothstep sobre ~1 px (`fwidth`) → borde antialiased.
362        let discs = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
363            label: Some("llimphi-raster-gpu-discs"),
364            layout: Some(&pipeline_layout),
365            vertex: wgpu::VertexState {
366                module: &shader,
367                entry_point: Some("vs_discs"),
368                compilation_options: Default::default(),
369                buffers: &[wgpu::VertexBufferLayout {
370                    array_stride: 32,
371                    step_mode: wgpu::VertexStepMode::Instance,
372                    attributes: &[
373                        // cx, cy
374                        wgpu::VertexAttribute {
375                            format: wgpu::VertexFormat::Float32x2,
376                            offset: 0,
377                            shader_location: 0,
378                        },
379                        // r, stroke
380                        wgpu::VertexAttribute {
381                            format: wgpu::VertexFormat::Float32x2,
382                            offset: 8,
383                            shader_location: 1,
384                        },
385                        // rgba (4×f32, HDR-capable)
386                        wgpu::VertexAttribute {
387                            format: wgpu::VertexFormat::Float32x4,
388                            offset: 16,
389                            shader_location: 2,
390                        },
391                    ],
392                }],
393            },
394            primitive: tri_primitive(),
395            depth_stencil: None,
396            multisample: wgpu::MultisampleState {
397                count: MSAA_SAMPLES,
398                ..Default::default()
399            },
400            fragment: Some(wgpu::FragmentState {
401                module: &shader,
402                entry_point: Some("fs_disc"),
403                compilation_options: Default::default(),
404                targets: &color_targets,
405            }),
406            multiview_mask: None,
407            cache: None,
408        });
409
410        // Quads texturados (el texto). Grupo 0: el uniform de siempre.
411        // Grupo 1: la textura del atlas de cobertura + su sampler.
412        //
413        // El sampler es **Nearest** y no es una economía: los quads de glifo se
414        // colocan en coordenadas enteras y el desplazamiento subpíxel viaja
415        // horneado en el mapa (`llimphi-glifos` rasteriza una variante por
416        // fracción), así que el mapeo textura→pantalla es exactamente 1:1.
417        // Filtrar ahí no agregaría suavidad: sólo emborronaría un mapa que ya
418        // trae su propio antialiasing.
419        let atlas_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
420            label: Some("llimphi-raster-gpu-atlas-bgl"),
421            entries: &[
422                wgpu::BindGroupLayoutEntry {
423                    binding: 0,
424                    visibility: wgpu::ShaderStages::FRAGMENT,
425                    ty: wgpu::BindingType::Texture {
426                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
427                        view_dimension: wgpu::TextureViewDimension::D2,
428                        multisampled: false,
429                    },
430                    count: None,
431                },
432                wgpu::BindGroupLayoutEntry {
433                    binding: 1,
434                    visibility: wgpu::ShaderStages::FRAGMENT,
435                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
436                    count: None,
437                },
438            ],
439        });
440        let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
441            label: Some("llimphi-raster-gpu-atlas-sampler"),
442            address_mode_u: wgpu::AddressMode::ClampToEdge,
443            address_mode_v: wgpu::AddressMode::ClampToEdge,
444            mag_filter: wgpu::FilterMode::Nearest,
445            min_filter: wgpu::FilterMode::Nearest,
446            ..Default::default()
447        });
448        let quads_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
449            label: Some("llimphi-raster-gpu-quads-pl"),
450            bind_group_layouts: &[Some(&bind_layout), Some(&atlas_bgl)],
451            immediate_size: 0,
452        });
453        let quads = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
454            label: Some("llimphi-raster-gpu-quads"),
455            layout: Some(&quads_pl),
456            vertex: wgpu::VertexState {
457                module: &shader,
458                entry_point: Some("vs_quads"),
459                compilation_options: Default::default(),
460                buffers: &[wgpu::VertexBufferLayout {
461                    array_stride: QUAD_INSTANCE_STRIDE as u64,
462                    step_mode: wgpu::VertexStepMode::Instance,
463                    attributes: &[
464                        // x, y, w, h
465                        wgpu::VertexAttribute {
466                            format: wgpu::VertexFormat::Float32x4,
467                            offset: 0,
468                            shader_location: 0,
469                        },
470                        // u0, v0, u1, v1
471                        wgpu::VertexAttribute {
472                            format: wgpu::VertexFormat::Float32x4,
473                            offset: 16,
474                            shader_location: 1,
475                        },
476                        // rgba
477                        wgpu::VertexAttribute {
478                            format: wgpu::VertexFormat::Float32x4,
479                            offset: 32,
480                            shader_location: 2,
481                        },
482                    ],
483                }],
484            },
485            primitive: tri_primitive(),
486            depth_stencil: None,
487            multisample: wgpu::MultisampleState {
488                count: MSAA_SAMPLES,
489                ..Default::default()
490            },
491            fragment: Some(wgpu::FragmentState {
492                module: &shader,
493                entry_point: Some("fs_quad"),
494                compilation_options: Default::default(),
495                targets: &color_targets,
496            }),
497            multiview_mask: None,
498            cache: None,
499        });
500
501        // Pipeline de composite (alpha-over) de la scratch resuelta del
502        // MSAA sobre el `view`. Single-sample (count = 1), pase fullscreen
503        // de un triángulo. Asume alpha **premultiplicado** — el MSAA + el
504        // blending de los primitivos producen color premultiplicado, así
505        // que el over correcto es `src.rgb*1 + dst.rgb*(1-src.a)`.
506        let composite_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
507            label: Some("llimphi-raster-gpu-composite-bgl"),
508            entries: &[
509                wgpu::BindGroupLayoutEntry {
510                    binding: 0,
511                    visibility: wgpu::ShaderStages::FRAGMENT,
512                    ty: wgpu::BindingType::Texture {
513                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
514                        view_dimension: wgpu::TextureViewDimension::D2,
515                        multisampled: false,
516                    },
517                    count: None,
518                },
519                wgpu::BindGroupLayoutEntry {
520                    binding: 1,
521                    visibility: wgpu::ShaderStages::FRAGMENT,
522                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
523                    count: None,
524                },
525                // Dónde va el blit dentro del `view`. Sin scissor es el
526                // viewport entero; con scissor, el rect sucio.
527                wgpu::BindGroupLayoutEntry {
528                    binding: 2,
529                    visibility: wgpu::ShaderStages::VERTEX,
530                    ty: wgpu::BindingType::Buffer {
531                        ty: wgpu::BufferBindingType::Uniform,
532                        has_dynamic_offset: false,
533                        min_binding_size: None,
534                    },
535                    count: None,
536                },
537            ],
538        });
539        let composite_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
540            label: Some("llimphi-raster-gpu-composite-pl"),
541            bind_group_layouts: &[Some(&composite_bgl)],
542            immediate_size: 0,
543        });
544        let composite = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
545            label: Some("llimphi-raster-gpu-composite"),
546            layout: Some(&composite_pl),
547            vertex: wgpu::VertexState {
548                module: &shader,
549                entry_point: Some("vs_composite"),
550                compilation_options: Default::default(),
551                buffers: &[],
552            },
553            primitive: wgpu::PrimitiveState::default(),
554            depth_stencil: None,
555            multisample: wgpu::MultisampleState::default(),
556            fragment: Some(wgpu::FragmentState {
557                module: &shader,
558                entry_point: Some("fs_composite"),
559                compilation_options: Default::default(),
560                targets: &[Some(wgpu::ColorTargetState {
561                    format: color_format,
562                    blend: Some(wgpu::BlendState {
563                        color: wgpu::BlendComponent {
564                            src_factor: wgpu::BlendFactor::One,
565                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
566                            operation: wgpu::BlendOperation::Add,
567                        },
568                        alpha: wgpu::BlendComponent {
569                            src_factor: wgpu::BlendFactor::One,
570                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
571                            operation: wgpu::BlendOperation::Add,
572                        },
573                    }),
574                    write_mask: wgpu::ColorWrites::ALL,
575                })],
576            }),
577            multiview_mask: None,
578            cache: None,
579        });
580        let composite_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
581            label: Some("llimphi-raster-gpu-composite-sampler"),
582            ..Default::default()
583        });
584
585        Self {
586            lines,
587            tris,
588            rects,
589            discs,
590            quads,
591            bind_layout,
592            atlas_bgl,
593            atlas_sampler,
594            composite,
595            composite_bgl,
596            composite_sampler,
597            color_format,
598        }
599    }
600
601    /// Layout del grupo 1 del pipeline de quads. Lo necesita
602    /// [`crate::gpu_atlas::AtlasTextura`] para armar su bind group.
603    pub fn atlas_layout(&self) -> &wgpu::BindGroupLayout {
604        &self.atlas_bgl
605    }
606
607    /// El sampler del atlas (Nearest + ClampToEdge). Uno solo para todos los
608    /// atlas: no tiene estado por textura.
609    pub fn atlas_sampler(&self) -> &wgpu::Sampler {
610        &self.atlas_sampler
611    }
612}
613
614fn tri_primitive() -> wgpu::PrimitiveState {
615    wgpu::PrimitiveState {
616        topology: wgpu::PrimitiveTopology::TriangleList,
617        strip_index_format: None,
618        front_face: wgpu::FrontFace::Ccw,
619        cull_mode: None,
620        unclipped_depth: false,
621        polygon_mode: wgpu::PolygonMode::Fill,
622        conservative: false,
623    }
624}
625
626/// Acumulador de primitivas por frame. Construir → `add_*` → `flush`.
627pub struct GpuBatch<'a> {
628    pipelines: &'a GpuPipelines,
629    line_verts: Vec<u8>,
630    tri_verts: Vec<u8>,
631    rect_insts: Vec<u8>,
632    disc_insts: Vec<u8>,
633    quad_insts: Vec<u8>,
634    line_width: f32,
635    line_count: u32,
636    tri_vert_count: u32,
637    rect_count: u32,
638    disc_count: u32,
639    quad_count: u32,
640    /// El atlas contra el que se muestrean los quads. Sin él los quads
641    /// acumulados **no se dibujan**: no hay de dónde sacar la cobertura.
642    atlas: Option<&'a crate::gpu_atlas::AtlasTextura>,
643    /// Rect de recorte (x, y, w, h) en px del target. `None` = pinta todo el
644    /// viewport (comportamiento histórico). Cuando está seteado, ambas pasadas
645    /// (primitivos y composite) se restringen a ese rect → sólo se tocan los
646    /// píxeles sucios: es el habilitador de **damage / partial-present**.
647    scissor: Option<(u32, u32, u32, u32)>,
648    /// Techo por buffer en bytes. `None` = el de la GPU
649    /// (`device.limits().max_buffer_size`). Ver [`GpuBatch::max_buffer_bytes`].
650    max_buffer_bytes: Option<u64>,
651}
652
653impl<'a> GpuBatch<'a> {
654    pub fn new(pipelines: &'a GpuPipelines) -> Self {
655        Self {
656            pipelines,
657            line_verts: Vec::new(),
658            tri_verts: Vec::new(),
659            rect_insts: Vec::new(),
660            disc_insts: Vec::new(),
661            quad_insts: Vec::new(),
662            line_width: 1.0,
663            line_count: 0,
664            tri_vert_count: 0,
665            rect_count: 0,
666            disc_count: 0,
667            quad_count: 0,
668            atlas: None,
669            scissor: None,
670            max_buffer_bytes: None,
671        }
672    }
673
674    /// Restringe el `flush` a un rect (x, y, w, h) en px del target: sólo se
675    /// rasterizan y componen los píxeles dentro del rect, el resto del `view`
676    /// queda intacto. Es el primitivo de **damage / partial-present**: cuando
677    /// sólo cambió una región chica (un cursor, un spinner, un nodo animado), el
678    /// caller setea el rect sucio y evita re-pintar la pantalla entera. El rect
679    /// se clampea al viewport en `flush` (un rect fuera de rango no revienta).
680    /// Sin llamar a esto, `flush` pinta todo el viewport (histórico).
681    ///
682    /// Las coordenadas son siempre **de viewport**: el rect se pide donde se ve,
683    /// no relativo a nada. `flush` baja el origen a par —por las derivadas del
684    /// AA, ver el comentario ahí— así que el área efectivamente tocada puede
685    /// tener una fila y una columna de más; con `LoadOp::Load` eso se repinta
686    /// con lo mismo que ya había.
687    pub fn scissor(&mut self, x: u32, y: u32, w: u32, h: u32) {
688        self.scissor = Some((x, y, w, h));
689    }
690
691    /// Fuerza el tamaño máximo de cada buffer de este `flush`, en bytes.
692    ///
693    /// Por defecto se toma de `device.limits().max_buffer_size` y no hay que
694    /// tocarlo. Existe por dos razones: **testear el troceado sin reservar
695    /// cientos de MB** (bajarlo a un puñado de bytes obliga a partir una escena
696    /// mínima), y dejarle al caller la puerta de bajar el techo si su plataforma
697    /// se porta mal con reservas grandes.
698    pub fn max_buffer_bytes(&mut self, bytes: u64) {
699        self.max_buffer_bytes = Some(bytes.max(1));
700    }
701
702    /// Grosor de las líneas que se agreguen **a partir de acá** (px del frame,
703    /// sin AA). Es una pluma: se cambia las veces que haga falta y cada línea se
704    /// queda con el que estaba puesto cuando se la agregó.
705    ///
706    /// **Cambió en 2026-08-05.** Antes era un uniforme del lote, así que valía
707    /// para todas las líneas del `flush` sin importar el orden de las llamadas —
708    /// y dibujar dos grosores obligaba a partir en dos `flush`, cada uno con su
709    /// textura MSAA 4×, su resolve y su composite. Una grilla con una curva
710    /// encima es el caso más común de un gráfico, y era justo el que no entraba
711    /// en «una draw call para todo».
712    pub fn line_width(&mut self, w: f32) {
713        self.line_width = w;
714    }
715
716    /// Añade un segmento de línea como instancia.
717    pub fn add_line(&mut self, p0: (f32, f32), p1: (f32, f32), color: Color) {
718        self.line_verts.extend_from_slice(&p0.0.to_ne_bytes());
719        self.line_verts.extend_from_slice(&p0.1.to_ne_bytes());
720        self.line_verts.extend_from_slice(&p1.0.to_ne_bytes());
721        self.line_verts.extend_from_slice(&p1.1.to_ne_bytes());
722        push_color(&mut self.line_verts, color);
723        self.line_verts
724            .extend_from_slice(&self.line_width.to_ne_bytes());
725        self.line_count += 1;
726    }
727
728    /// Añade una polilínea como secuencia de segmentos individuales
729    /// (line-list). Para N puntos emite N-1 instancias.
730    pub fn add_polyline(&mut self, points: &[(f32, f32)], color: Color) {
731        if points.len() < 2 {
732            return;
733        }
734        for w in points.windows(2) {
735            self.add_line(w[0], w[1], color);
736        }
737    }
738
739    /// Añade un triángulo con color por vértice.
740    pub fn add_tri(
741        &mut self,
742        a: (f32, f32),
743        b: (f32, f32),
744        c: (f32, f32),
745        ca: Color,
746        cb: Color,
747        cc: Color,
748    ) {
749        self.push_tri_vert(a, ca);
750        self.push_tri_vert(b, cb);
751        self.push_tri_vert(c, cc);
752    }
753
754    fn push_tri_vert(&mut self, p: (f32, f32), color: Color) {
755        self.tri_verts.extend_from_slice(&p.0.to_ne_bytes());
756        self.tri_verts.extend_from_slice(&p.1.to_ne_bytes());
757        push_color(&mut self.tri_verts, color);
758        self.tri_vert_count += 1;
759    }
760
761    /// Añade un triangle list crudo `[(x, y); 3*N]` con un mismo color
762    /// uniforme por vértice. Útil para teselaciones precomputadas
763    /// (contornos, polígonos rellenos).
764    pub fn add_tri_list(&mut self, verts: &[(f32, f32)], color: Color) {
765        for &p in verts {
766            self.push_tri_vert(p, color);
767        }
768    }
769
770    /// Añade un rectángulo lleno como instancia (sin radio — para
771    /// rounded rects sigue por vello).
772    pub fn add_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Color) {
773        self.rect_insts.extend_from_slice(&x.to_ne_bytes());
774        self.rect_insts.extend_from_slice(&y.to_ne_bytes());
775        self.rect_insts.extend_from_slice(&w.to_ne_bytes());
776        self.rect_insts.extend_from_slice(&h.to_ne_bytes());
777        push_color(&mut self.rect_insts, color);
778        self.rect_count += 1;
779    }
780
781    /// Añade un disco (círculo relleno) con AA por shader como instancia.
782    /// `(cx, cy)` es el centro y `r` el radio, ambos en pixels del frame.
783    /// El borde queda antialiased vía un SDF + `smoothstep` de ~1 px en
784    /// el fragment — no escalonado, sin MSAA. El alpha del color se
785    /// respeta (blending alfa activo).
786    pub fn add_disc(&mut self, cx: f32, cy: f32, r: f32, color: Color) {
787        self.push_disc(cx, cy, r, 0.0, color);
788    }
789
790    /// Añade un anillo (círculo hueco / stroke circular) con AA por
791    /// shader. `r` es el radio exterior; `stroke` el grosor del trazo en
792    /// px (el agujero interior tiene radio `r - stroke`). `stroke <= 0`
793    /// degenera en un disco lleno. Ambos bordes (externo e interno)
794    /// quedan antialiased.
795    pub fn add_ring(&mut self, cx: f32, cy: f32, r: f32, stroke: f32, color: Color) {
796        self.push_disc(cx, cy, r, stroke.max(0.0), color);
797    }
798
799    fn push_disc(&mut self, cx: f32, cy: f32, r: f32, stroke: f32, color: Color) {
800        self.disc_insts.extend_from_slice(&cx.to_ne_bytes());
801        self.disc_insts.extend_from_slice(&cy.to_ne_bytes());
802        self.disc_insts.extend_from_slice(&r.to_ne_bytes());
803        self.disc_insts.extend_from_slice(&stroke.to_ne_bytes());
804        push_color(&mut self.disc_insts, color);
805        self.disc_count += 1;
806    }
807
808    /// Fija el atlas de cobertura contra el que se muestrean los quads.
809    ///
810    /// Sin esto, los quads acumulados con [`GpuBatch::add_glyph`] **no se
811    /// dibujan** — silenciosamente, y a propósito: la alternativa sería un
812    /// panic dentro de `flush`, o sea reventar el frame de una app porque
813    /// alguien pidió texto antes de tener el atlas listo. Que no aparezca el
814    /// texto es un síntoma leíble; que se caiga la ventana, no.
815    pub fn atlas(&mut self, atlas: &'a crate::gpu_atlas::AtlasTextura) {
816        self.atlas = Some(atlas);
817    }
818
819    /// Un quad texturado: `(x, y, w, h)` en px de pantalla, `uv` como
820    /// `(u0, v0, u1, v1)` normalizado en el atlas, y el color con el que se
821    /// tiñe la cobertura.
822    ///
823    /// El atlas guarda **cobertura**, no color: el quad sale del color pedido
824    /// con su alfa multiplicado por lo que dice el atlas. Por eso el mismo
825    /// glifo cacheado sirve para pintar en cualquier color sin re-rasterizar.
826    pub fn add_glyph(
827        &mut self,
828        x: f32,
829        y: f32,
830        w: f32,
831        h: f32,
832        uv: (f32, f32, f32, f32),
833        color: Color,
834    ) {
835        for v in [x, y, w, h, uv.0, uv.1, uv.2, uv.3] {
836            self.quad_insts.extend_from_slice(&v.to_ne_bytes());
837        }
838        push_color(&mut self.quad_insts, color);
839        self.quad_count += 1;
840    }
841
842    /// Cuenta total de primitivas pendientes (útil para benches).
843    pub fn primitive_count(&self) -> u32 {
844        self.line_count
845            + self.rect_count
846            + self.disc_count
847            + self.quad_count
848            + self.tri_vert_count / 3
849    }
850
851    /// Despacha las primitivas acumuladas como 1 draw call por tipo
852    /// no vacío contra `view`. `viewport` es el tamaño en pixels del
853    /// target (lo usa el VS para mapear pixel → NDC).
854    ///
855    /// `load_op` decide si la pasada conserva el contenido previo
856    /// (`Load`, lo normal cuando vello ya pintó algo) o limpia
857    /// (`Clear(color)`). Apps que llamen a `GpuBatch` desde
858    /// `gpu_paint_with` quieren `Load`.
859    pub fn flush(
860        self,
861        device: &wgpu::Device,
862        queue: &wgpu::Queue,
863        encoder: &mut wgpu::CommandEncoder,
864        view: &wgpu::TextureView,
865        viewport: (f32, f32),
866        load_op: wgpu::LoadOp<wgpu::Color>,
867    ) {
868        // Los quads sólo cuentan si hay atlas: sin él no se pueden dibujar, y
869        // sumarlos acá haría que un batch de puro texto sin atlas armara las
870        // texturas MSAA y las dos pasadas para no pintar nada.
871        let quads_vivos = if self.atlas.is_some() { self.quad_count } else { 0 };
872        let total = self.line_count
873            + self.tri_vert_count
874            + self.rect_count
875            + self.disc_count
876            + quads_vivos;
877        if total == 0 {
878            return;
879        }
880
881        // Rect de damage clampeado al viewport, ANTES que nada: de él salen el
882        // tamaño de las texturas intermedias y el corrimiento que va al shader.
883        // Si se pidió scissor pero clampea a área 0 (región sucia fuera de
884        // rango), no hay nada que pintar → salir.
885        let vp_w = (viewport.0.round() as u32).max(1);
886        let vp_h = (viewport.1.round() as u32).max(1);
887        let rect: (u32, u32, u32, u32) = match self.scissor {
888            None => (0, 0, vp_w, vp_h),
889            Some((x, y, w, h)) => {
890                let x = x.min(vp_w);
891                let y = y.min(vp_h);
892                let w = w.min(vp_w.saturating_sub(x));
893                let h = h.min(vp_h.saturating_sub(y));
894                if w == 0 || h == 0 {
895                    return;
896                }
897                // **El origen se baja a par.** El AA del disco sale de
898                // `fwidth`, y las derivadas se calculan por bloques de 2×2
899                // fragmentos alineados a la grilla del *target*. Correr el
900                // origen un número impar de píxeles reagrupa esos bloques: el
901                // píxel se aparea con otro vecino, la diferencia finita cambia y
902                // el borde antialiaseado sale distinto que en la pasada
903                // completa. Medido: hasta 11/255 de diferencia en la orla del
904                // disco — invisible en un frame suelto, una costura al mover el
905                // rect sucio de cuadro en cuadro. Con el origen par la paridad
906                // se conserva y el reparto en bloques es idéntico.
907                //
908                // Cuesta a lo sumo una fila y una columna de más, que con
909                // `LoadOp::Load` se repintan con lo mismo que ya tenían.
910                let (x0, y0) = (x & !1, y & !1);
911                let w = (w + (x - x0)).min(vp_w - x0);
912                let h = (h + (y - y0)).min(vp_h - y0);
913                (x0, y0, w, h)
914            }
915        };
916
917        // Uniforms: [target.w, target.h, origin.x, origin.y, 0,0,0,0]. El
918        // grosor de línea ya no vive acá: viaja por instancia.
919        // `target` es la textura intermedia (= el rect sucio) y `origin` su
920        // esquina, así que el caller sigue hablando en coordenadas de viewport
921        // y el corrimiento lo hace el vertex shader.
922        let u_data = [
923            rect.2 as f32,
924            rect.3 as f32,
925            rect.0 as f32,
926            rect.1 as f32,
927            0.0,
928            0.0,
929            0.0,
930            0.0,
931        ];
932        let mut u_bytes = Vec::with_capacity(32);
933        for v in u_data {
934            u_bytes.extend_from_slice(&v.to_ne_bytes());
935        }
936        let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
937            label: Some("llimphi-raster-gpu-u"),
938            size: 32,
939            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
940            mapped_at_creation: false,
941        });
942        queue.write_buffer(&uniforms, 0, &u_bytes);
943
944        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
945            label: Some("llimphi-raster-gpu-bg"),
946            layout: &self.pipelines.bind_layout,
947            entries: &[wgpu::BindGroupEntry {
948                binding: 0,
949                resource: uniforms.as_entire_binding(),
950            }],
951        });
952
953        // Buffers por tipo (sólo si hay datos), **troceados** al techo de la
954        // GPU. Una sola draw call para todo es el punto del camino directo,
955        // pero `max_buffer_size` es un límite duro (256 MB en esta Iris Xe): a
956        // 32 B por rect, 8,4 M de primitivas y wgpu rechaza la reserva. Antes
957        // eso no era «más lento», era **no anda**; ahora la escena se parte en
958        // lotes consecutivos y se dibuja igual. El orden entre lotes se
959        // conserva, así que el alpha-over sale idéntico al de un buffer único.
960        let techo = self
961            .max_buffer_bytes
962            .unwrap_or_else(|| device.limits().max_buffer_size);
963        let lines_bufs = chunked_buffers(
964            device,
965            queue,
966            "llimphi-raster-gpu-lines-buf",
967            &self.line_verts,
968            LINE_INSTANCE_STRIDE,
969            1,
970            techo,
971        );
972        let tris_bufs = chunked_buffers(
973            device,
974            queue,
975            "llimphi-raster-gpu-tris-buf",
976            &self.tri_verts,
977            TRI_VERTEX_STRIDE,
978            3,
979            techo,
980        );
981        let rects_bufs = chunked_buffers(
982            device,
983            queue,
984            "llimphi-raster-gpu-rects-buf",
985            &self.rect_insts,
986            RECT_INSTANCE_STRIDE,
987            1,
988            techo,
989        );
990        let discs_bufs = chunked_buffers(
991            device,
992            queue,
993            "llimphi-raster-gpu-discs-buf",
994            &self.disc_insts,
995            DISC_INSTANCE_STRIDE,
996            1,
997            techo,
998        );
999        let quads_bufs = if self.atlas.is_some() {
1000            chunked_buffers(
1001                device,
1002                queue,
1003                "llimphi-raster-gpu-quads-buf",
1004                &self.quad_insts,
1005                QUAD_INSTANCE_STRIDE,
1006                1,
1007                techo,
1008            )
1009        } else {
1010            Vec::new()
1011        };
1012
1013        // ── MSAA 4× ──────────────────────────────────────────────────
1014        // Texturas por-flush dimensionadas **al rect sucio**, no al viewport.
1015        //
1016        // Antes iban al viewport y el recorte se hacía con `set_scissor_rect`.
1017        // Eso recortaba lo único que el scissor puede recortar —el sombreado de
1018        // los fragmentos—, pero el `LoadOp::Clear` del attachment y el resolve
1019        // del MSAA son operaciones de **toda** la superficie por definición (el
1020        // scissor es estado de rasterización, no toca los load/store ops). O sea
1021        // que pintar un cursor de 32 px seguía costando limpiar y resolver
1022        // 1920×1080 en 4 muestras. Medido acá antes de cambiarlo: pintar el
1023        // 0,05 % de la pantalla ahorraba **14,8 %**, no 99 %.
1024        //
1025        // Con la textura del tamaño del rect, el clear y el resolve pasan a ser
1026        // del rect. El `set_scissor_rect` se conserva igual como cinturón: si
1027        // alguna vez el quad del composite no casara exacto, no puede sangrar.
1028        let extent = wgpu::Extent3d {
1029            width: rect.2,
1030            height: rect.3,
1031            depth_or_array_layers: 1,
1032        };
1033        let scissor_px = self.scissor.map(|_| rect);
1034        let fmt = self.pipelines.color_format;
1035        // Color attachment multisample: lo rasterizan los 4 pipelines.
1036        let msaa_tex = device.create_texture(&wgpu::TextureDescriptor {
1037            label: Some("llimphi-raster-gpu-msaa"),
1038            size: extent,
1039            mip_level_count: 1,
1040            sample_count: MSAA_SAMPLES,
1041            dimension: wgpu::TextureDimension::D2,
1042            format: fmt,
1043            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1044            view_formats: &[],
1045        });
1046        let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default());
1047        // Scratch single-sample: recibe el resolve del MSAA y luego se
1048        // samplea en el composite sobre el `view`.
1049        let resolve_tex = device.create_texture(&wgpu::TextureDescriptor {
1050            label: Some("llimphi-raster-gpu-resolve"),
1051            size: extent,
1052            mip_level_count: 1,
1053            sample_count: 1,
1054            dimension: wgpu::TextureDimension::D2,
1055            format: fmt,
1056            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
1057                | wgpu::TextureUsages::TEXTURE_BINDING,
1058            view_formats: &[],
1059        });
1060        let resolve_view =
1061            resolve_tex.create_view(&wgpu::TextureViewDescriptor::default());
1062
1063        // Pase de primitivos: MSAA cleared a TRANSPARENT, resuelto al
1064        // scratch single-sample. El scratch queda con alpha
1065        // **premultiplicado** (el blending alfa de los pipelines sobre
1066        // fondo transparente produce `rgb = color*alpha`, `a = alpha`).
1067        {
1068            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1069                label: Some("llimphi-raster-gpu-pass"),
1070                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1071                    view: &msaa_view,
1072                    resolve_target: Some(&resolve_view),
1073                    depth_slice: None,
1074                    ops: wgpu::Operations {
1075                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
1076                        store: wgpu::StoreOp::Store,
1077                    },
1078                })],
1079                depth_stencil_attachment: None,
1080                timestamp_writes: None,
1081                occlusion_query_set: None,
1082                multiview_mask: None,
1083            });
1084            pass.set_bind_group(0, &bind_group, &[]);
1085            // Sin `set_scissor_rect` acá: el attachment **es** el rect sucio, y
1086            // el vertex shader ya corrió las coordenadas por `origin`. Pedir el
1087            // scissor en coordenadas de viewport sobre una textura del tamaño
1088            // del rect sería, además de redundante, fuera de rango.
1089
1090            // Orden de draws: rects (fondo) → discos → tris → lines (encima).
1091            // Match de la convención usual "fill abajo, stroke arriba".
1092            // Dentro de cada tipo, los lotes van en orden: el caso normal es
1093            // uno solo, y ahí esto son las mismas cuatro draw calls de antes.
1094            if !rects_bufs.is_empty() {
1095                pass.set_pipeline(&self.pipelines.rects);
1096                for (buf, n) in &rects_bufs {
1097                    pass.set_vertex_buffer(0, buf.slice(..));
1098                    pass.draw(0..6, 0..*n);
1099                }
1100            }
1101            if !discs_bufs.is_empty() {
1102                pass.set_pipeline(&self.pipelines.discs);
1103                for (buf, n) in &discs_bufs {
1104                    pass.set_vertex_buffer(0, buf.slice(..));
1105                    pass.draw(0..6, 0..*n);
1106                }
1107            }
1108            if !tris_bufs.is_empty() {
1109                pass.set_pipeline(&self.pipelines.tris);
1110                for (buf, n) in &tris_bufs {
1111                    pass.set_vertex_buffer(0, buf.slice(..));
1112                    // El triangle-list no instancia: `n` acá son vértices.
1113                    pass.draw(0..*n, 0..1);
1114                }
1115            }
1116            if !lines_bufs.is_empty() {
1117                pass.set_pipeline(&self.pipelines.lines);
1118                for (buf, n) in &lines_bufs {
1119                    pass.set_vertex_buffer(0, buf.slice(..));
1120                    pass.draw(0..6, 0..*n);
1121                }
1122            }
1123            // El texto va **último**: sobre el relleno y sobre el trazo, que es
1124            // el orden en que se lee una etiqueta encima de un gráfico.
1125            if let (Some(atlas), false) = (self.atlas, quads_bufs.is_empty()) {
1126                pass.set_pipeline(&self.pipelines.quads);
1127                pass.set_bind_group(1, &atlas.bind_group, &[]);
1128                for (buf, n) in &quads_bufs {
1129                    pass.set_vertex_buffer(0, buf.slice(..));
1130                    pass.draw(0..6, 0..*n);
1131                }
1132            }
1133        }
1134
1135        // Composite del scratch resuelto sobre el `view`. Respeta el
1136        // `load_op` recibido:
1137        //  - `Load`  → alpha-over: preserva lo que ya está en `view`
1138        //              (vello), exactamente como el viejo pase directo.
1139        //  - `Clear` → limpia `view` al color pedido y luego compone
1140        //              el scratch encima (mismo resultado que limpiar y
1141        //              dibujar directo).
1142        // Dónde va el blit: el rect destino en px del `view`, más el tamaño del
1143        // `view` para pasar a NDC. Sin scissor el rect es el viewport entero y
1144        // el quad cubre la pantalla, igual que el triángulo de antes.
1145        let cu_data = [
1146            rect.0 as f32,
1147            rect.1 as f32,
1148            rect.2 as f32,
1149            rect.3 as f32,
1150            vp_w as f32,
1151            vp_h as f32,
1152            0.0,
1153            0.0,
1154        ];
1155        let mut cu_bytes = Vec::with_capacity(32);
1156        for v in cu_data {
1157            cu_bytes.extend_from_slice(&v.to_ne_bytes());
1158        }
1159        let composite_u = device.create_buffer(&wgpu::BufferDescriptor {
1160            label: Some("llimphi-raster-gpu-composite-u"),
1161            size: 32,
1162            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1163            mapped_at_creation: false,
1164        });
1165        queue.write_buffer(&composite_u, 0, &cu_bytes);
1166
1167        let composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1168            label: Some("llimphi-raster-gpu-composite-bg"),
1169            layout: &self.pipelines.composite_bgl,
1170            entries: &[
1171                wgpu::BindGroupEntry {
1172                    binding: 0,
1173                    resource: wgpu::BindingResource::TextureView(&resolve_view),
1174                },
1175                wgpu::BindGroupEntry {
1176                    binding: 1,
1177                    resource: wgpu::BindingResource::Sampler(
1178                        &self.pipelines.composite_sampler,
1179                    ),
1180                },
1181                wgpu::BindGroupEntry {
1182                    binding: 2,
1183                    resource: composite_u.as_entire_binding(),
1184                },
1185            ],
1186        });
1187        let mut cpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1188            label: Some("llimphi-raster-gpu-composite-pass"),
1189            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1190                view,
1191                resolve_target: None,
1192                depth_slice: None,
1193                ops: wgpu::Operations {
1194                    // El blend del pipeline ya hace el alpha-over; con
1195                    // Load conserva el fondo, con Clear lo borra primero.
1196                    load: load_op,
1197                    store: wgpu::StoreOp::Store,
1198                },
1199            })],
1200            depth_stencil_attachment: None,
1201            timestamp_writes: None,
1202            occlusion_query_set: None,
1203            multiview_mask: None,
1204        });
1205        cpass.set_pipeline(&self.pipelines.composite);
1206        cpass.set_bind_group(0, &composite_bg, &[]);
1207        if let Some((x, y, w, h)) = scissor_px {
1208            cpass.set_scissor_rect(x, y, w, h);
1209        }
1210        cpass.draw(0..6, 0..1);
1211    }
1212}
1213
1214/// Empuja un `peniko::Color` como 4×`f32` (RGBA lineal-del-color, 16 B) al
1215/// buffer de vértices/instancias. Se lee `Color::components` **directo** — a
1216/// diferencia de `to_rgba8()`, NO cuantiza a 8-bit ni clampea a [0,1], así que
1217/// un color HDR (componente >1.0) o un paso fino entre dos niveles 8-bit
1218/// sobrevive hasta el target. Cuando el target es 8-bit (`Rgba8Unorm`, el caso
1219/// normal) el resultado visible es idéntico al del pack u32 anterior; la
1220/// precisión extra sólo aparece con un target `Rgba16Float`/`Rgb10a2`. El WGSL
1221/// lo recibe como `vec4<f32>` directo (sin `unpack_rgba`).
1222/// Sube `data` a uno o más buffers de vértices, ninguno mayor que `max_bytes`.
1223///
1224/// Devuelve `(buffer, unidades)` por lote, en orden. Con datos vacíos devuelve
1225/// vacío; en el caso normal —todo entra— devuelve **un** solo lote, así que el
1226/// camino de siempre no paga nada por esto.
1227#[allow(clippy::too_many_arguments)]
1228fn chunked_buffers(
1229    device: &wgpu::Device,
1230    queue: &wgpu::Queue,
1231    label: &str,
1232    data: &[u8],
1233    stride: u32,
1234    multiplo: u32,
1235    max_bytes: u64,
1236) -> Vec<(wgpu::Buffer, u32)> {
1237    if data.is_empty() {
1238        return Vec::new();
1239    }
1240    let por_lote = units_per_chunk(stride, max_bytes, multiplo) as usize;
1241    let bytes_por_lote = por_lote * stride as usize;
1242    let mut out = Vec::with_capacity(data.len().div_ceil(bytes_por_lote));
1243    for trozo in data.chunks(bytes_por_lote) {
1244        let b = device.create_buffer(&wgpu::BufferDescriptor {
1245            label: Some(label),
1246            size: trozo.len() as u64,
1247            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1248            mapped_at_creation: false,
1249        });
1250        queue.write_buffer(&b, 0, trozo);
1251        out.push((b, (trozo.len() / stride as usize) as u32));
1252    }
1253    out
1254}
1255
1256fn push_color(buf: &mut Vec<u8>, c: Color) {
1257    for comp in c.components {
1258        buf.extend_from_slice(&comp.to_ne_bytes());
1259    }
1260}
1261
1262const WGSL: &str = r#"
1263struct Uniforms {
1264    // Tamaño **de la textura destino** en px. Sin scissor es el viewport; con
1265    // scissor es el rect sucio, porque ahí se rasteriza a una textura de ese
1266    // tamaño y no a una de pantalla completa.
1267    dst_size:   vec2<f32>,
1268    // Esquina del rect sucio en coordenadas del viewport. (0,0) sin scissor.
1269    // Los callers siguen dando coordenadas de viewport; el corrimiento lo hace
1270    // el shader, así que nadie aguas arriba se entera del recorte.
1271    origin:     vec2<f32>,
1272    // Relleno hasta 32 B. Acá vivía `line_width`, que ahora viaja por
1273    // instancia: un lote con dos grosores era dos lotes.
1274    _pad0:      vec4<f32>,
1275};
1276
1277@group(0) @binding(0) var<uniform> u: Uniforms;
1278
1279struct V2F {
1280    @builtin(position) pos: vec4<f32>,
1281    @location(0) color: vec4<f32>,
1282};
1283
1284fn px_to_ndc(p: vec2<f32>) -> vec2<f32> {
1285    let q = p - u.origin;
1286    return vec2<f32>(q.x / u.dst_size.x * 2.0 - 1.0, 1.0 - q.y / u.dst_size.y * 2.0);
1287}
1288
1289// -------- triángulos: 1 vértice = (xy, color) --------
1290
1291@vertex
1292fn vs_tris(@location(0) xy: vec2<f32>, @location(1) color: vec4<f32>) -> V2F {
1293    var out: V2F;
1294    out.pos = vec4<f32>(px_to_ndc(xy), 0.0, 1.0);
1295    out.color = color;
1296    return out;
1297}
1298
1299// -------- rects: 1 instancia = (xy, wh, color), 6 vértices/quad --------
1300
1301@vertex
1302fn vs_rects(
1303    @builtin(vertex_index) vid: u32,
1304    @location(0) inst_xy: vec2<f32>,
1305    @location(1) inst_wh: vec2<f32>,
1306    @location(2) color: vec4<f32>,
1307) -> V2F {
1308    var corners = array<vec2<f32>, 6>(
1309        vec2<f32>(0.0, 0.0),
1310        vec2<f32>(1.0, 0.0),
1311        vec2<f32>(1.0, 1.0),
1312        vec2<f32>(0.0, 0.0),
1313        vec2<f32>(1.0, 1.0),
1314        vec2<f32>(0.0, 1.0),
1315    );
1316    let local = corners[vid];
1317    let px = inst_xy + local * inst_wh;
1318    var out: V2F;
1319    out.pos = vec4<f32>(px_to_ndc(px), 0.0, 1.0);
1320    out.color = color;
1321    return out;
1322}
1323
1324// -------- líneas: 1 instancia = (p0xy, p1xy, color), expandida a quad ----
1325
1326@vertex
1327fn vs_lines(
1328    @builtin(vertex_index) vid: u32,
1329    @location(0) seg: vec4<f32>,
1330    @location(1) color: vec4<f32>,
1331    @location(2) width: f32,
1332) -> V2F {
1333    // Quad perpendicular al segmento, con el grosor de ESTA instancia.
1334    // vid 0..5 mapea a los 6 vértices del quad (2 tris).
1335    let p0 = seg.xy;
1336    let p1 = seg.zw;
1337    let dir = normalize(p1 - p0);
1338    let n = vec2<f32>(-dir.y, dir.x);
1339    let half_w = max(width, 0.0) * 0.5;
1340    let offsets = array<vec2<f32>, 6>(
1341        vec2<f32>(0.0, -half_w),  // p0 -n
1342        vec2<f32>(0.0,  half_w),  // p0 +n
1343        vec2<f32>(1.0,  half_w),  // p1 +n
1344        vec2<f32>(0.0, -half_w),  // p0 -n
1345        vec2<f32>(1.0,  half_w),  // p1 +n
1346        vec2<f32>(1.0, -half_w),  // p1 -n
1347    );
1348    let o = offsets[vid];
1349    let along = mix(p0, p1, o.x);
1350    let across = n * o.y;
1351    let px = along + across;
1352    var out: V2F;
1353    out.pos = vec4<f32>(px_to_ndc(px), 0.0, 1.0);
1354    out.color = color;
1355    return out;
1356}
1357
1358// -------- discos/anillos: 1 instancia = (cxcy, r/stroke, rgba) --------
1359//
1360// Quad que cubre el disco con 1.5 px de margen (para que el smoothstep
1361// del borde no se recorte). El VS pasa al FS la posición local en px
1362// relativa al centro; el FS evalúa el SDF del círculo y hace smoothstep
1363// sobre `fwidth` → borde antialiased. `stroke > 0` recorta un anillo.
1364
1365struct DiscV2F {
1366    @builtin(position) pos: vec4<f32>,
1367    @location(0) color: vec4<f32>,
1368    @location(1) local: vec2<f32>,  // px relativos al centro
1369    @location(2) params: vec2<f32>, // r, stroke (px)
1370};
1371
1372@vertex
1373fn vs_discs(
1374    @builtin(vertex_index) vid: u32,
1375    @location(0) inst_c: vec2<f32>,
1376    @location(1) inst_rs: vec2<f32>,
1377    @location(2) color: vec4<f32>,
1378) -> DiscV2F {
1379    var corners = array<vec2<f32>, 6>(
1380        vec2<f32>(-1.0, -1.0),
1381        vec2<f32>( 1.0, -1.0),
1382        vec2<f32>( 1.0,  1.0),
1383        vec2<f32>(-1.0, -1.0),
1384        vec2<f32>( 1.0,  1.0),
1385        vec2<f32>(-1.0,  1.0),
1386    );
1387    let r = inst_rs.x;
1388    let margin = r + 1.5;          // 1.5 px de aire para el AA del borde
1389    let local = corners[vid] * margin;
1390    let px = inst_c + local;
1391    var out: DiscV2F;
1392    out.pos = vec4<f32>(px_to_ndc(px), 0.0, 1.0);
1393    out.color = color;
1394    out.local = local;
1395    out.params = inst_rs;
1396    return out;
1397}
1398
1399@fragment
1400fn fs_disc(in: DiscV2F) -> @location(0) vec4<f32> {
1401    let r = in.params.x;
1402    let stroke = in.params.y;
1403    let dist = length(in.local);     // distancia al centro en px
1404    // Ancho del filtro AA en px (≈ 1 px en pantalla).
1405    let aa = fwidth(dist);
1406    // Borde exterior: cobertura 1 dentro de r, 0 fuera de r+aa.
1407    var cov = 1.0 - smoothstep(r - aa, r + aa, dist);
1408    // Anillo: si hay stroke, recortamos el agujero interior con AA.
1409    if (stroke > 0.0) {
1410        let inner = max(r - stroke, 0.0);
1411        cov = cov * smoothstep(inner - aa, inner + aa, dist);
1412    }
1413    if (cov <= 0.0) {
1414        discard;
1415    }
1416    return vec4<f32>(in.color.rgb, in.color.a * cov);
1417}
1418
1419@fragment
1420fn fs(in: V2F) -> @location(0) vec4<f32> {
1421    return in.color;
1422}
1423
1424// -------- quads texturados: 1 instancia = (xywh, uv, rgba) --------
1425//
1426// El camino del TEXTO. El atlas guarda **cobertura** (un canal), no color: el
1427// fragmento sale del color de la instancia con su alfa multiplicado por lo que
1428// diga el atlas. Por eso el mismo glifo cacheado se pinta de cualquier color
1429// sin volver a rasterizarlo.
1430
1431struct QuadV2F {
1432    @builtin(position) pos: vec4<f32>,
1433    @location(0) color: vec4<f32>,
1434    @location(1) uv: vec2<f32>,
1435};
1436
1437@group(1) @binding(0) var atlas_tex: texture_2d<f32>;
1438@group(1) @binding(1) var atlas_samp: sampler;
1439
1440@vertex
1441fn vs_quads(
1442    @builtin(vertex_index) vid: u32,
1443    @location(0) xywh: vec4<f32>,
1444    @location(1) uv: vec4<f32>,
1445    @location(2) color: vec4<f32>,
1446) -> QuadV2F {
1447    var corners = array<vec2<f32>, 6>(
1448        vec2<f32>(0.0, 0.0),
1449        vec2<f32>(1.0, 0.0),
1450        vec2<f32>(1.0, 1.0),
1451        vec2<f32>(0.0, 0.0),
1452        vec2<f32>(1.0, 1.0),
1453        vec2<f32>(0.0, 1.0),
1454    );
1455    let c = corners[vid];
1456    let px = xywh.xy + c * xywh.zw;
1457    var out: QuadV2F;
1458    out.pos = vec4<f32>(px_to_ndc(px), 0.0, 1.0);
1459    out.color = color;
1460    out.uv = mix(uv.xy, uv.zw, c);
1461    return out;
1462}
1463
1464@fragment
1465fn fs_quad(in: QuadV2F) -> @location(0) vec4<f32> {
1466    let cov = textureSample(atlas_tex, atlas_samp, in.uv).r;
1467    if (cov <= 0.0) {
1468        discard;
1469    }
1470    return vec4<f32>(in.color.rgb, in.color.a * cov);
1471}
1472
1473// -------- composite: blit de la scratch resuelta del MSAA al rect destino ---
1474//
1475// Quad de 6 vértices (sin vertex buffer) colocado exactamente sobre el rect
1476// destino dentro del `view`. Sin scissor el rect es el viewport entero, así
1477// que esto equivale al viejo triángulo de pantalla completa. Samplea la
1478// scratch (alpha **premultiplicado**) y la emite tal cual; el alpha-over real
1479// lo hace el BlendState del pipeline (`One, OneMinusSrcAlpha`).
1480
1481@group(0) @binding(0) var src_tex: texture_2d<f32>;
1482@group(0) @binding(1) var src_samp: sampler;
1483
1484struct CompUniforms {
1485    rect: vec4<f32>,   // x, y, w, h  — dónde va, en px del `view`
1486    vp:   vec4<f32>,   // vw, vh, _, _
1487};
1488@group(0) @binding(2) var<uniform> cu: CompUniforms;
1489
1490struct CompV2F {
1491    @builtin(position) pos: vec4<f32>,
1492    @location(0) uv: vec2<f32>,
1493};
1494
1495@vertex
1496fn vs_composite(@builtin(vertex_index) vid: u32) -> CompV2F {
1497    var corners = array<vec2<f32>, 6>(
1498        vec2<f32>(0.0, 0.0),
1499        vec2<f32>(1.0, 0.0),
1500        vec2<f32>(1.0, 1.0),
1501        vec2<f32>(0.0, 0.0),
1502        vec2<f32>(1.0, 1.0),
1503        vec2<f32>(0.0, 1.0),
1504    );
1505    let c = corners[vid];
1506    let px = cu.rect.xy + c * cu.rect.zw;
1507    var out: CompV2F;
1508    out.pos = vec4<f32>(
1509        px.x / cu.vp.x * 2.0 - 1.0,
1510        1.0 - px.y / cu.vp.y * 2.0,
1511        0.0,
1512        1.0,
1513    );
1514    // La scratch cubre justo el rect y su fila 0 es el borde de arriba, igual
1515    // que el `view`: la uv es el corner tal cual, sin voltear.
1516    out.uv = c;
1517    return out;
1518}
1519
1520@fragment
1521fn fs_composite(in: CompV2F) -> @location(0) vec4<f32> {
1522    return textureSample(src_tex, src_samp, in.uv);
1523}
1524"#;
1525
1526#[cfg(test)]
1527mod tests {
1528    use super::*;
1529
1530    #[test]
1531    fn el_caso_normal_entra_en_un_solo_lote() {
1532        // 256 MB / 32 B = 8.388.608 instancias: el techo real de esta GPU, y el
1533        // número que la tabla del PLAN-MOTOR-PROFUNDO daba por «no entra».
1534        let techo = 256 * 1024 * 1024;
1535        assert_eq!(units_per_chunk(RECT_INSTANCE_STRIDE, techo, 1), 8_388_608);
1536        // Y 10 M —el caso que el bench no podía correr— son dos lotes, no un
1537        // fallo de validación.
1538        assert_eq!(10_000_000_u32.div_ceil(8_388_608), 2);
1539    }
1540
1541    #[test]
1542    fn los_triangulos_cortan_en_multiplo_de_tres() {
1543        // Un lote que termina a mitad de triángulo dibujaría basura con los dos
1544        // vértices que le quedan del siguiente.
1545        for techo in [24 * 10, 24 * 11, 24 * 12] {
1546            assert_eq!(units_per_chunk(TRI_VERTEX_STRIDE, techo, 3) % 3, 0);
1547        }
1548        assert_eq!(units_per_chunk(TRI_VERTEX_STRIDE, 24 * 11, 3), 9);
1549    }
1550
1551    #[test]
1552    fn un_techo_absurdo_no_devuelve_cero() {
1553        // Cero unidades por lote sería un bucle infinito de lotes vacíos. Se
1554        // devuelve el mínimo válido y que wgpu se queje con su propio mensaje,
1555        // que dice más que un cuelgue nuestro.
1556        assert_eq!(units_per_chunk(RECT_INSTANCE_STRIDE, 0, 1), 1);
1557        assert_eq!(units_per_chunk(RECT_INSTANCE_STRIDE, 31, 1), 1);
1558        assert_eq!(units_per_chunk(TRI_VERTEX_STRIDE, 0, 3), 3);
1559    }
1560}