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//! **Atlas de cobertura de glifos** — lo que le faltaba al camino GPU-directo
2//! para poder pintar texto.
3//!
4//! `llimphi_raster::GpuBatch` sabe emitir cientos de miles de rects, discos,
5//! líneas y triángulos en una draw call por tipo. Lo que no sabe es qué forma
6//! tiene una «a»: el batcher instanciado habla de quads, no de contornos. Por
7//! eso el path directo estaba fichado como «sin texto» en
8//! `PLAN-MOTOR-PROFUNDO.md` §4a — y era el último hueco real de esa iniciativa.
9//!
10//! La solución es la de siempre en un rasterizador de texto por GPU: **cada
11//! glifo se rasteriza una sola vez** a un mapa de cobertura de 8 bits, se
12//! empaqueta en una textura grande, y de ahí en más pintar texto es pintar
13//! quads con una UV. Un renglón de 80 caracteres pasa de 80 contornos rellenos
14//! a 80 instancias de 48 B.
15//!
16//! ## Por qué un crate aparte y no un módulo de `llimphi-text`
17//!
18//! Porque agrega `swash` con sus features `scale` + `render` (y detrás, `yazi`
19//! y `zeno`). parley ya trae swash —lo usa para shapear— pero con
20//! `default-features = false`, así que el **rasterizador** no está enlazado
21//! hasta que alguien lo pida. Quien no pinte texto por GPU-directo no tiene por
22//! qué cargarlo. Y `llimphi-text` está en ~1.900 LOC, en el borde del umbral de
23//! split de la Regla 1 del repo.
24//!
25//! ## Lo que este crate NO hace, dicho de frente
26//!
27//! - **No shapea.** Recibe un `parley::Layout` ya resuelto. Quién lo arma
28//! —`llimphi_text::Typesetter`— sigue siendo el mismo de siempre, así que el
29//! texto por GPU-directo y el texto por vello parten del **mismo** layout: si
30//! difieren, difieren en el rasterizado y en nada más.
31//! - **No pinta emoji ni glifos de color.** El atlas es de un canal
32//! (`Alpha`/R8): una carita de color no entra ahí. Sólo se piden contornos
33//! (`Source::Outline`). Un glifo de color pediría un atlas RGBA y un pipeline
34//! propio, y meterlos «por las dudas» sería construir sin caller.
35//! - **No hintea.** Es deliberado y no una simplificación: vello rasteriza el
36//! contorno crudo, y si acá se hinteara, el mismo texto saldría con métricas
37//! distintas por cada camino. La comparación entre los dos —que es cómo se
38//! certifica esto— dejaría de significar algo.
39//! - **No crece ni desaloja.** El atlas es de tamaño fijo; lleno, devuelve
40//! `None` y cuenta el fallo. Una política de desalojo sin una app que la
41//! presione se diseña a ciegas; lo honesto es que el número esté a la vista
42//! (`AtlasGlifos::fallos`) y que se decida cuando alguien lo choque.
43
44use std::collections::HashMap;
45use std::hash::{BuildHasherDefault, Hasher};
46
47pub use swash;
48
49/// Hasher barato para las claves de glifo, en vez del SipHash de `std`.
50///
51/// **No es una micro-optimización a ciegas: sale de una medición, y la
52/// medición dice que ayuda pero no alcanza.** El bench (`llimphi-gpu-bench`,
53/// sección «Texto») separa *preparar* —armar la escena de vello contra armar
54/// los quads— del total, y en esa mitad el atlas sale **más lento** que vello
55/// aunque gane 3-5× en el total. Con ~12.000 glifos por cuadro se hacen 12.000
56/// búsquedas, y ahí el hash deja de ser gratis.
57///
58/// A/B en la misma máquina (Iris Xe, 12.096 glifos, 60 muestras, 3 corridas):
59///
60/// | hasher | preparar |
61/// |---|---:|
62/// | SipHash (el de `std`) | ~3,4 ms |
63/// | éste | ~2,5 ms |
64/// | *vello, para comparar* | *~1,9 ms* |
65///
66/// O sea **−27 %**, pero sin dar vuelta el signo: preparar sigue costando más
67/// que por vello, que lo único que hace por glifo es apilar id y posición.
68/// Queda anotado como lo que es: una mejora medida, no la solución.
69///
70/// Es el algoritmo de rustc (FxHash): multiplicar, rotar, xor. No sirve para
71/// entradas hostiles —no hay resistencia a colisiones a propósito— y acá eso da
72/// igual: las claves las genera el shaper, no la red.
73#[derive(Default)]
74pub struct HasherGlifo(u64);
75
76impl Hasher for HasherGlifo {
77 fn finish(&self) -> u64 {
78 self.0
79 }
80 fn write(&mut self, bytes: &[u8]) {
81 for b in bytes {
82 self.agregar(*b as u64);
83 }
84 }
85 fn write_u8(&mut self, v: u8) {
86 self.agregar(v as u64);
87 }
88 fn write_u16(&mut self, v: u16) {
89 self.agregar(v as u64);
90 }
91 fn write_u32(&mut self, v: u32) {
92 self.agregar(v as u64);
93 }
94 fn write_i32(&mut self, v: i32) {
95 self.agregar(v as u32 as u64);
96 }
97 fn write_u64(&mut self, v: u64) {
98 self.agregar(v);
99 }
100 fn write_usize(&mut self, v: usize) {
101 self.agregar(v as u64);
102 }
103}
104
105impl HasherGlifo {
106 #[inline]
107 fn agregar(&mut self, v: u64) {
108 const SEMILLA: u64 = 0x51_7c_c1_b7_27_22_0a_95;
109 self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(SEMILLA);
110 }
111}
112
113type MapaGlifos = HashMap<ClaveGlifo, Option<Colocacion>, BuildHasherDefault<HasherGlifo>>;
114
115/// Cuántas posiciones subpíxel se distinguen por eje. El glifo se rasteriza una
116/// vez por cada fracción, así que la misma «a» a 14 px puede ocupar hasta
117/// `SUBPIXELES²` entradas del atlas.
118///
119/// Cuatro por eje —y no una— porque el texto no cae en la grilla: parley da
120/// posiciones fraccionarias, y redondearlas corre cada glifo hasta medio píxel.
121/// En un renglón eso se ve como espaciado irregular. En los dos ejes y no sólo
122/// en X porque la línea base también es fraccionaria, y ahí el error no se
123/// reparte: **corre el renglón entero**.
124///
125/// En la práctica el costo es mucho menor que el peor caso: un texto normal
126/// tiene una sola fracción de línea base por renglón.
127pub const SUBPIXELES: u32 = 4;
128
129/// Píxeles de aire entre glifos vecinos dentro del atlas. Sin esto, el filtrado
130/// del sampler puede traer un pedazo del glifo de al lado.
131const AIRE: u32 = 1;
132
133/// Lo que identifica a un glifo ya rasterizado. Dos pedidos con la misma clave
134/// dan el mismo mapa de cobertura, y por eso se puede cachear.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
136pub struct ClaveGlifo {
137 /// Identidad del archivo de fuente. Es el `id` del `Blob` que trae parley: un
138 /// contador global asignado al construirlo, O(1) y estable entre clones —
139 /// el mismo truco que usa el caché de boundaries del compositor. Dos blobs
140 /// con bytes idénticos reciben ids distintos, así que a lo sumo se
141 /// rasteriza de más; nunca se devuelve el glifo equivocado.
142 pub fuente: u64,
143 /// Índice dentro de una colección (`.ttc`). 0 para un archivo suelto.
144 pub indice: u32,
145 /// Id del glifo dentro de la fuente (ya shapeado, no un carácter).
146 pub glifo: u16,
147 /// Tamaño en px × 64, redondeado. Cuantizar evita que un zoom continuo
148 /// llene el atlas con una entrada por frame.
149 pub tam_64: u32,
150 /// Fracción subpíxel: `sx + sy * SUBPIXELES`, con cada una en `0..SUBPIXELES`.
151 pub subpx: u8,
152 /// Negrita sintética (la fuente no tiene corte bold y el shaper lo pidió).
153 pub engrosado: bool,
154 /// Itálica sintética, en centésimas de grado de inclinación.
155 pub inclinacion_c: i32,
156}
157
158impl ClaveGlifo {
159 /// El caso simple: un glifo suelto de una fuente suelta, en posición
160 /// entera y sin síntesis. Es lo que pide quien **no** viene de un shaper —
161 /// una grilla de terminal, que mapea `char` → glifo por charmap y coloca
162 /// todo en celdas enteras.
163 pub fn de_glifo(glifo: u16, tam_px: f32) -> Self {
164 Self {
165 fuente: 0,
166 indice: 0,
167 glifo,
168 tam_64: (tam_px * 64.0).round().max(1.0) as u32,
169 subpx: 0,
170 engrosado: false,
171 inclinacion_c: 0,
172 }
173 }
174}
175
176/// Un glifo rasterizado **suelto**: la cobertura y dónde colocarla, sin atlas
177/// de por medio.
178///
179/// Existe para quien empaqueta a su manera. `llimphi_widget_terminal::GlyphAtlas`
180/// es el caso: una grilla de celdas iguales es la estructura correcta para un
181/// terminal —y parte de por qué es rápido— pero el **rasterizador** no tiene por
182/// qué ser otro. Antes lo era (`fontdue`), y llimphi cargaba dos.
183#[derive(Debug, Clone, PartialEq)]
184pub struct MapaGlifo {
185 pub ancho: u32,
186 pub alto: u32,
187 /// Igual que en [`Colocacion`]: `izq` hacia la derecha, `arriba` hacia
188 /// **arriba** (convención OpenType).
189 pub izq: i32,
190 pub arriba: i32,
191 /// `ancho × alto` bytes, fila por fila.
192 pub cobertura: Vec<u8>,
193}
194
195/// El rasterizador de glifos, sin el atlas.
196///
197/// Guarda el `ScaleContext` de swash, que cachea el trabajo de abrir la fuente
198/// entre pedidos — por eso conviene tener uno vivo y no crearlo por glifo.
199#[derive(Default)]
200pub struct Rasterizador {
201 ctx: swash::scale::ScaleContext,
202}
203
204impl Rasterizador {
205 pub fn new() -> Self {
206 Self::default()
207 }
208
209 /// Rasteriza un glifo. `None` si la fuente no parsea o si el glifo no tiene
210 /// mapa (un espacio).
211 ///
212 /// `clave.fuente` **no se mira acá**: los bytes llegan por `datos`. Ese
213 /// campo identifica la fuente para cachear, no para rasterizar.
214 pub fn glifo(&mut self, datos: &[u8], clave: ClaveGlifo) -> Option<MapaGlifo> {
215 let fuente = swash::FontRef::from_index(datos, clave.indice as usize)?;
216 let tam = clave.tam_64 as f32 / 64.0;
217 let sx = (clave.subpx as u32 % SUBPIXELES) as f32 / SUBPIXELES as f32;
218 let sy = (clave.subpx as u32 / SUBPIXELES) as f32 / SUBPIXELES as f32;
219
220 let mut escalador = self
221 .ctx
222 .builder(fuente)
223 .size(tam)
224 // Sin hinting: ver el encabezado del crate.
225 .hint(false)
226 .build();
227
228 let mut render = swash::scale::Render::new(&[swash::scale::Source::Outline]);
229 render.format(swash::zeno::Format::Alpha);
230 // El desplazamiento subpíxel se aplica ANTES de rasterizar: así el
231 // antialiasing corresponde a la posición real del glifo y no a una
232 // versión corrida después.
233 render.offset(swash::zeno::Vector::new(sx, sy));
234 if clave.engrosado {
235 render.embolden(tam * 0.02);
236 }
237 if clave.inclinacion_c != 0 {
238 let grados = clave.inclinacion_c as f32 / 100.0;
239 render.transform(Some(swash::zeno::Transform::skew(
240 swash::zeno::Angle::from_degrees(-grados),
241 swash::zeno::Angle::from_degrees(0.0),
242 )));
243 }
244 let img = render.render(&mut escalador, clave.glifo)?;
245 let (w, h) = (img.placement.width, img.placement.height);
246 if w == 0 || h == 0 {
247 return None;
248 }
249 Some(MapaGlifo {
250 ancho: w,
251 alto: h,
252 izq: img.placement.left,
253 arriba: img.placement.top,
254 cobertura: img.data,
255 })
256 }
257}
258
259/// Métricas de fuente que **no** necesitan shaping, en píxeles ya escalados.
260#[derive(Debug, Clone, Copy, PartialEq)]
261pub struct MetricasFuente {
262 pub ascenso: f32,
263 pub descenso: f32,
264 pub interlinea: f32,
265}
266
267impl MetricasFuente {
268 /// Alto de renglón: la suma de las tres. Es la misma definición que usa
269 /// `fontdue` para su `new_line_size`, y da el **mismo número hasta la
270 /// tercera decimal** en Liberation Mono a 11/12/13/14/16/18 px — verificado
271 /// antes de migrar el rasterizador del terminal, porque un píxel de más en
272 /// la celda cambiaría la grilla y eso se ve.
273 pub fn alto_linea(&self) -> f32 {
274 self.ascenso + self.descenso + self.interlinea
275 }
276}
277
278/// Métricas de la fuente a un tamaño dado.
279pub fn metricas(datos: &[u8], indice: u32, tam_px: f32) -> Option<MetricasFuente> {
280 let f = swash::FontRef::from_index(datos, indice as usize)?;
281 let m = f.metrics(&[]).scale(tam_px);
282 Some(MetricasFuente {
283 ascenso: m.ascent,
284 descenso: m.descent,
285 interlinea: m.leading,
286 })
287}
288
289/// Id del glifo de un carácter según el `cmap` de la fuente. **0 = notdef**
290/// (la fuente no lo tiene), igual que en el formato.
291///
292/// Esto NO es shaping: no resuelve ligaduras, ni marcas combinantes, ni
293/// escrituras complejas. Sirve para quien ya decidió que un carácter es un
294/// glifo — una grilla de terminal— y sería incorrecto para texto general.
295pub fn id_de_caracter(datos: &[u8], indice: u32, ch: char) -> Option<u16> {
296 let f = swash::FontRef::from_index(datos, indice as usize)?;
297 Some(f.charmap().map(ch))
298}
299
300/// Avance horizontal de un glifo, en píxeles.
301pub fn avance(datos: &[u8], indice: u32, tam_px: f32, glifo: u16) -> Option<f32> {
302 let f = swash::FontRef::from_index(datos, indice as usize)?;
303 Some(f.glyph_metrics(&[]).scale(tam_px).advance_width(glifo))
304}
305
306/// Dónde quedó un glifo dentro del atlas y cómo colocarlo en pantalla.
307#[derive(Debug, Clone, Copy, PartialEq)]
308pub struct Colocacion {
309 /// Rect en el atlas, en coordenadas de textura normalizadas `0..1`.
310 pub u0: f32,
311 pub v0: f32,
312 pub u1: f32,
313 pub v1: f32,
314 /// Desplazamiento del mapa respecto del origen del glifo, en px. `izq` es
315 /// hacia la derecha; `arriba` es hacia **arriba** (convención OpenType, eje
316 /// Y para arriba), así que en pantalla el borde superior del mapa va a
317 /// `y_base - arriba`.
318 pub izq: i32,
319 pub arriba: i32,
320 /// Tamaño del mapa en px.
321 pub ancho: u32,
322 pub alto: u32,
323}
324
325/// Un quad listo para el batcher: dónde va en pantalla y de dónde sale su
326/// cobertura. El color no viaja acá porque es del run entero, no del glifo.
327#[derive(Debug, Clone, Copy, PartialEq)]
328pub struct QuadGlifo {
329 pub x: f32,
330 pub y: f32,
331 pub w: f32,
332 pub h: f32,
333 pub u0: f32,
334 pub v0: f32,
335 pub u1: f32,
336 pub v1: f32,
337}
338
339/// Región rectangular del atlas que cambió desde la última subida a GPU.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub struct Sucio {
342 pub x: u32,
343 pub y: u32,
344 pub ancho: u32,
345 pub alto: u32,
346}
347
348/// El atlas: una textura cuadrada de un canal, empaquetada por **estantes**.
349///
350/// Estantes y no un empaquetador óptimo a propósito. Los glifos de un texto
351/// tienen alturas parecidas, así que una fila por rango de altura desperdicia
352/// poco, y el algoritmo cabe en veinte líneas que se pueden leer. Un packer con
353/// árbol se justifica cuando se empaquetan sprites de tamaños arbitrarios.
354pub struct AtlasGlifos {
355 lado: u32,
356 pixeles: Vec<u8>,
357 mapa: MapaGlifos,
358 /// Cursor del estante actual.
359 x: u32,
360 y: u32,
361 alto_estante: u32,
362 sucio: Option<Sucio>,
363 /// Sube cada vez que se escribe en el atlas. Quien tiene una copia en GPU
364 /// compara este número para saber si hay que volver a subir.
365 version: u64,
366 /// Glifos que no entraron porque el atlas está lleno. Que sea un contador
367 /// público y no un panic es lo que permite que una app siga andando (sin
368 /// ese texto) en vez de morirse.
369 pub fallos: u64,
370 /// Cuántas veces un pedido se resolvió con lo que ya estaba.
371 pub aciertos: u64,
372 /// Cuántas veces hubo que rasterizar.
373 pub rasterizados: u64,
374 /// El rasterizador, compartido con quien empaqueta a su manera.
375 rasterizador: Rasterizador,
376}
377
378impl AtlasGlifos {
379 /// Atlas cuadrado de `lado × lado` px, un byte por px. 1024 son 1 MiB y
380 /// alcanzan de sobra para el juego de caracteres de una UI en dos o tres
381 /// tamaños.
382 pub fn new(lado: u32) -> Self {
383 let lado = lado.max(16);
384 Self {
385 lado,
386 pixeles: vec![0u8; (lado * lado) as usize],
387 mapa: MapaGlifos::default(),
388 x: 0,
389 y: 0,
390 alto_estante: 0,
391 sucio: None,
392 version: 0,
393 fallos: 0,
394 aciertos: 0,
395 rasterizados: 0,
396 rasterizador: Rasterizador::new(),
397 }
398 }
399
400 pub fn lado(&self) -> u32 {
401 self.lado
402 }
403
404 /// Los bytes del atlas, en orden de filas, un byte de cobertura por píxel.
405 pub fn pixeles(&self) -> &[u8] {
406 &self.pixeles
407 }
408
409 /// Versión del contenido. Cambia sólo cuando se escribió algo nuevo.
410 pub fn version(&self) -> u64 {
411 self.version
412 }
413
414 /// Cuántos glifos distintos hay guardados (incluye los que no entraron).
415 pub fn entradas(&self) -> usize {
416 self.mapa.len()
417 }
418
419 /// La región que cambió desde la última llamada, y la limpia. Quien sube el
420 /// atlas a una textura puede subir sólo eso en vez del millón de bytes.
421 pub fn tomar_sucio(&mut self) -> Option<Sucio> {
422 self.sucio.take()
423 }
424
425 /// Vacía el atlas. Es la única salida cuando `fallos` empieza a subir: se
426 /// tira todo y se vuelve a llenar con lo que el frame siguiente pida. Es
427 /// brusco —un frame paga la re-rasterización entera— pero es honesto y no
428 /// finge una política de desalojo que nadie midió.
429 pub fn limpiar(&mut self) {
430 self.pixeles.fill(0);
431 self.mapa.clear();
432 self.x = 0;
433 self.y = 0;
434 self.alto_estante = 0;
435 self.version += 1;
436 self.sucio = Some(Sucio { x: 0, y: 0, ancho: self.lado, alto: self.lado });
437 }
438
439 /// Busca un glifo, rasterizándolo si es la primera vez.
440 ///
441 /// `datos`/`indice` son el archivo de fuente; `clave.fuente` es su
442 /// identidad. Devuelve `None` si el glifo está vacío (un espacio no tiene
443 /// mapa) o si el atlas está lleno — el caller no distingue, y no necesita:
444 /// en los dos casos no hay nada que dibujar. Para saber si fue por lleno,
445 /// mirar `fallos`.
446 pub fn glifo(&mut self, datos: &[u8], clave: ClaveGlifo) -> Option<Colocacion> {
447 if let Some(v) = self.mapa.get(&clave) {
448 self.aciertos += 1;
449 return *v;
450 }
451 self.rasterizados += 1;
452 let col = self.rasterizar(datos, clave);
453 self.mapa.insert(clave, col);
454 col
455 }
456
457 /// Rasteriza y **empaqueta**. El rasterizado en sí es de
458 /// [`Rasterizador`] — acá sólo se decide dónde va.
459 fn rasterizar(&mut self, datos: &[u8], clave: ClaveGlifo) -> Option<Colocacion> {
460 let m = self.rasterizador.glifo(datos, clave)?;
461 let (w, h) = (m.ancho, m.alto);
462 let (px, py) = self.reservar(w, h)?;
463 for fila in 0..h {
464 let orig = (fila * w) as usize;
465 let dest = ((py + fila) * self.lado + px) as usize;
466 self.pixeles[dest..dest + w as usize]
467 .copy_from_slice(&m.cobertura[orig..orig + w as usize]);
468 }
469 self.ensuciar(px, py, w, h);
470 self.version += 1;
471
472 let l = self.lado as f32;
473 Some(Colocacion {
474 u0: px as f32 / l,
475 v0: py as f32 / l,
476 u1: (px + w) as f32 / l,
477 v1: (py + h) as f32 / l,
478 izq: m.izq,
479 arriba: m.arriba,
480 ancho: w,
481 alto: h,
482 })
483 }
484
485 /// Reserva `w × h` px por estantes. `None` = no entra.
486 fn reservar(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
487 if w > self.lado || h > self.lado {
488 self.fallos += 1;
489 return None;
490 }
491 if self.x + w > self.lado {
492 // Estante siguiente.
493 self.y += self.alto_estante + AIRE;
494 self.x = 0;
495 self.alto_estante = 0;
496 }
497 if self.y + h > self.lado {
498 self.fallos += 1;
499 return None;
500 }
501 let pos = (self.x, self.y);
502 self.x += w + AIRE;
503 self.alto_estante = self.alto_estante.max(h);
504 Some(pos)
505 }
506
507 fn ensuciar(&mut self, x: u32, y: u32, w: u32, h: u32) {
508 self.sucio = Some(match self.sucio {
509 None => Sucio { x, y, ancho: w, alto: h },
510 Some(s) => {
511 let x0 = s.x.min(x);
512 let y0 = s.y.min(y);
513 let x1 = (s.x + s.ancho).max(x + w);
514 let y1 = (s.y + s.alto).max(y + h);
515 Sucio { x: x0, y: y0, ancho: x1 - x0, alto: y1 - y0 }
516 }
517 });
518 }
519}
520
521/// Cuantiza una coordenada a la grilla subpíxel: devuelve la parte entera y el
522/// índice de fracción.
523fn cuantizar(v: f32) -> (f32, u32) {
524 let ent = v.floor();
525 let frac = v - ent;
526 let i = (frac * SUBPIXELES as f32).round() as u32;
527 // La fracción puede redondear al escalón siguiente (0,95 → 4/4): eso es el
528 // píxel de al lado con fracción 0, no un índice fuera de rango.
529 if i >= SUBPIXELES {
530 (ent + 1.0, 0)
531 } else {
532 (ent, i)
533 }
534}
535
536/// Recorre un layout ya shapeado y emite un quad por glifo visible.
537///
538/// `origen` es la esquina superior-izquierda del bloque en píxeles de pantalla,
539/// el mismo argumento que `llimphi_text::draw_layout`. Devuelve cuántos quads
540/// se agregaron; los glifos sin mapa (espacios) y los que no entraron en el
541/// atlas simplemente no aparecen.
542///
543/// Es genérico en el brush del layout porque acá el color no se mira: quien
544/// llama sabe de qué color pinta el run. Un layout multicolor
545/// (`Typesetter::layout_runs`) se recorre igual, run por run, con
546/// [`quads_de_run`].
547pub fn quads_de_layout<B: parley::Brush>(
548 atlas: &mut AtlasGlifos,
549 layout: &parley::Layout<B>,
550 origen: (f32, f32),
551 salida: &mut Vec<QuadGlifo>,
552) -> usize {
553 let antes = salida.len();
554 for linea in layout.lines() {
555 for item in linea.items() {
556 if let parley::PositionedLayoutItem::GlyphRun(run) = item {
557 quads_de_run(atlas, &run, origen, salida);
558 }
559 }
560 }
561 salida.len() - antes
562}
563
564/// Un solo run. Se expone aparte para que un layout multicolor pueda mirar el
565/// brush de cada run y emitir sus quads con el color que corresponde.
566pub fn quads_de_run<B: parley::Brush>(
567 atlas: &mut AtlasGlifos,
568 run: &parley::GlyphRun<'_, B>,
569 origen: (f32, f32),
570 salida: &mut Vec<QuadGlifo>,
571) {
572 let interno = run.run();
573 let fuente = interno.font();
574 let datos: &[u8] = fuente.data.as_ref();
575 let id = fuente.data.id();
576 let indice = fuente.index;
577 let tam_64 = (interno.font_size() * 64.0).round().max(1.0) as u32;
578 let sintesis = interno.synthesis();
579 let engrosado = sintesis.embolden();
580 let inclinacion_c = sintesis
581 .skew()
582 .map(|g| (g * 100.0).round() as i32)
583 .unwrap_or(0);
584
585 for g in run.positioned_glyphs() {
586 let (gx, sx) = cuantizar(origen.0 + g.x);
587 let (gy, sy) = cuantizar(origen.1 + g.y);
588 let clave = ClaveGlifo {
589 fuente: id,
590 indice,
591 // parley da el id en `u32`; swash lo pide en `u16`. No se pierde
592 // nada: `numGlyphs` es un `uint16` en el formato, así que una
593 // fuente no puede tener más de 65.536 glifos.
594 glifo: g.id as u16,
595 tam_64,
596 subpx: (sx + sy * SUBPIXELES) as u8,
597 engrosado,
598 inclinacion_c,
599 };
600 let Some(c) = atlas.glifo(datos, clave) else {
601 continue;
602 };
603 salida.push(QuadGlifo {
604 x: gx + c.izq as f32,
605 // `arriba` va hacia arriba (OpenType); en pantalla Y crece hacia
606 // abajo, así que el borde superior del mapa cae por encima de la
607 // línea base.
608 y: gy - c.arriba as f32,
609 w: c.ancho as f32,
610 h: c.alto as f32,
611 u0: c.u0,
612 v0: c.v0,
613 u1: c.u1,
614 v1: c.v1,
615 });
616 }
617}
618
619#[cfg(test)]
620mod pruebas {
621 use super::*;
622
623 #[test]
624 fn la_cuantizacion_reparte_las_fracciones_y_no_se_sale() {
625 // El caso normal: cuatro escalones por píxel.
626 assert_eq!(cuantizar(10.0), (10.0, 0));
627 assert_eq!(cuantizar(10.25), (10.0, 1));
628 assert_eq!(cuantizar(10.5), (10.0, 2));
629 assert_eq!(cuantizar(10.75), (10.0, 3));
630 // Y el borde que rompería un índice sin guarda: 0,95 redondea al
631 // escalón 4, que no existe — es el píxel siguiente con fracción 0.
632 assert_eq!(cuantizar(10.95), (11.0, 0));
633 assert_eq!(cuantizar(10.9), (11.0, 0));
634 // Negativos: `floor` los manda para abajo, la fracción sigue en 0..1.
635 assert_eq!(cuantizar(-0.25), (-1.0, 3));
636 }
637
638 #[test]
639 fn el_hasher_barato_reparte_y_no_colapsa() {
640 // Un hasher degenerado —uno que devolviera una constante, o que ignorara
641 // algún campo— seguiría siendo **correcto**: la tabla compara las claves
642 // igual y nunca devuelve el glifo equivocado. Lo que haría es convertir
643 // el mapa en una lista, y entonces el cambio que se hizo para acelerar
644 // el `preparar` lo volvería más lento **en silencio**. Ningún test de
645 // píxeles agarraría eso.
646 use std::hash::Hash;
647 fn h(c: &ClaveGlifo) -> u64 {
648 let mut x = HasherGlifo::default();
649 c.hash(&mut x);
650 x.finish()
651 }
652 let base = ClaveGlifo {
653 fuente: 7,
654 indice: 0,
655 glifo: 42,
656 tam_64: 11 * 64,
657 subpx: 0,
658 engrosado: false,
659 inclinacion_c: 0,
660 };
661 // Cada campo, por separado, tiene que mover el hash: si uno no lo mueve,
662 // ese campo no está entrando en la clave efectiva.
663 let variantes = [
664 ClaveGlifo { fuente: 8, ..base },
665 ClaveGlifo { indice: 1, ..base },
666 ClaveGlifo { glifo: 43, ..base },
667 ClaveGlifo { tam_64: 12 * 64, ..base },
668 ClaveGlifo { subpx: 1, ..base },
669 ClaveGlifo { engrosado: true, ..base },
670 ClaveGlifo { inclinacion_c: 1400, ..base },
671 ];
672 for (i, v) in variantes.iter().enumerate() {
673 assert_ne!(h(&base), h(v), "el campo {i} no mueve el hash");
674 }
675
676 // Y el caso realista: el juego de claves de un texto —un puñado de
677 // glifos por las 16 fracciones subpíxel— tiene que repartirse. Se exige
678 // que no haya colisiones sobre 800 claves; con un hash decente la
679 // probabilidad de una colisión de 64 bits ahí es ~1 en 10^14.
680 let mut vistos = std::collections::HashSet::new();
681 for glifo in 0..50u16 {
682 for subpx in 0..16u8 {
683 vistos.insert(h(&ClaveGlifo { glifo, subpx, ..base }));
684 }
685 }
686 assert_eq!(vistos.len(), 800, "el hasher colapsó claves distintas");
687 }
688
689 #[test]
690 fn el_estante_avanza_y_el_atlas_lleno_no_revienta() {
691 let mut a = AtlasGlifos::new(16);
692 // Dos de 6×4 entran en el primer estante (6+1+6 = 13 ≤ 16).
693 assert_eq!(a.reservar(6, 4), Some((0, 0)));
694 assert_eq!(a.reservar(6, 4), Some((7, 0)));
695 // El tercero no: pasa al estante siguiente, que arranca bajo el más
696 // alto del anterior más el aire.
697 assert_eq!(a.reservar(6, 4), Some((0, 5)));
698 // Y algo más alto que el atlas entero es un fallo contado, no un panic.
699 assert_eq!(a.reservar(6, 99), None);
700 assert_eq!(a.fallos, 1);
701 }
702
703 #[test]
704 fn lo_sucio_es_la_union_de_lo_escrito_y_se_vacia_al_tomarlo() {
705 let mut a = AtlasGlifos::new(64);
706 assert_eq!(a.tomar_sucio(), None);
707 a.ensuciar(2, 3, 4, 5);
708 a.ensuciar(10, 1, 2, 2);
709 // Union: x 2..12, y 1..8.
710 assert_eq!(a.tomar_sucio(), Some(Sucio { x: 2, y: 1, ancho: 10, alto: 7 }));
711 // Tomarlo lo limpia: quien ya subió esa región no la vuelve a subir.
712 assert_eq!(a.tomar_sucio(), None);
713 }
714
715 #[test]
716 fn limpiar_ensucia_todo_y_sube_la_version() {
717 let mut a = AtlasGlifos::new(32);
718 a.ensuciar(0, 0, 1, 1);
719 let v = a.version();
720 a.tomar_sucio();
721 a.limpiar();
722 assert!(a.version() > v);
723 // Todo el atlas quedó sucio: quien tenga una copia en GPU tiene que
724 // volver a subirla entera, porque los UV viejos ya no valen.
725 assert_eq!(
726 a.tomar_sucio(),
727 Some(Sucio { x: 0, y: 0, ancho: 32, alto: 32 })
728 );
729 assert_eq!(a.entradas(), 0);
730 }
731}