tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}