//! **¿El texto por GPU-directo sale más barato que por vello?** //! //! El atlas de glifos (`llimphi-glifos` + `GpuBatch::add_glyph`, 2026-08-05) //! está certificado como **correcto**: `llimphi-test/tests/texto_gpu_directo.rs` //! pone los dos caminos sobre el mismo `parley::Layout` y mide `Δ64:0`. Lo que //! no estaba medido es si **vale la pena** — y sin ese número el atlas sería //! justo la enfermedad que la Iniciativa #4 del `PLAN-MOTOR-PROFUNDO` viene a //! curar: algo construido que nadie usa porque nadie sabe si conviene. //! //! El argumento a favor era «80 contornos rellenos contra 80 instancias de //! 48 B». Eso es un argumento, no una medición. //! //! ## Qué se mide y qué NO //! //! El **shaping es el mismo para los dos** —los dos consumen un `parley::Layout` //! ya resuelto— así que queda fuera del bucle medido a propósito: meterlo sería //! sumarle a los dos el mismo número y achatar el factor. //! //! De cada camino se miden dos cosas por separado, y la distinción importa: //! //! - **preparar** — construir la escena de vello (un `draw_glyphs` por run) o //! armar los quads (una búsqueda en el atlas por glifo). Es CPU pura, así que //! este número **vale en cualquier máquina**, incluso en llvmpipe. //! - **total** — preparar + rasterizar + esperar a la GPU. En una máquina sin //! GPU real esto mide una CPU rasterizando, y **no decide nada** sobre el //! comportamiento en hardware. Se imprime igual, marcado. //! //! El atlas se llena **antes** del bucle medido y la textura se sube una vez: //! lo que se mide es el régimen estable, que es donde vive una app. El primer //! frame de un atlas frío paga la rasterización de cada glifo y no es //! representativo de nada. use std::time::Instant; use llimphi_glifos::{quads_de_layout, AtlasGlifos, QuadGlifo}; use llimphi_hal::{wgpu, Hal}; use llimphi_raster::gpu_atlas::AtlasTextura; use llimphi_raster::peniko::{color::palette, Color}; use llimphi_raster::{vello, GpuBatch, GpuPipelines}; /// La frase de prueba. 56 caracteres con acentos y dígitos: un repertorio /// realista, no veinte aes que compartirían la misma entrada del atlas y /// harían ver el caché mucho mejor de lo que es. const FRASE: &str = "El zorro marrón salta sobre el perro perezoso 0123456789"; const TAM: f32 = 11.0; const ALTO_LINEA: f32 = 12.0; /// Cuántos renglones entran en una columna sin salirse del target. const POR_COLUMNA: u32 = 84; const ANCHO_COLUMNA: f32 = 330.0; /// Cuántos renglones por corrida. El tope son 3 columnas × 84 = 252, o sea /// ~14.000 glifos: más que eso empezaría a pintar fuera del target, y un glifo /// que el rasterizador descarta por estar afuera haría mentir al número. const RENGLONES: &[u32] = &[10, 50, 150, 252]; /// Muestras del bench de texto, más que las del resto del binario a propósito /// (ver el comentario en `main`). Un cuadro de texto cuesta pocos ms, así que /// el ruido de la máquina se le nota mucho más que a un cuadro de 1 M de rects. pub const WARMUP: usize = 15; pub const MEDIDOS: usize = 60; pub struct FilaTexto { pub renglones: u32, pub glifos: u32, pub vello_prep_ms: f64, pub vello_total_ms: f64, pub atlas_prep_ms: f64, pub atlas_total_ms: f64, } impl FilaTexto { pub fn factor_prep(&self) -> f64 { self.vello_prep_ms / self.atlas_prep_ms.max(1e-9) } pub fn factor_total(&self) -> f64 { self.vello_total_ms / self.atlas_total_ms.max(1e-9) } } fn origen(i: u32) -> (f32, f32) { let col = i / POR_COLUMNA; let fila = i % POR_COLUMNA; (8.0 + col as f32 * ANCHO_COLUMNA, 12.0 + fila as f32 * ALTO_LINEA) } /// Mediana de una muestra. Copiada de `main` a propósito: no vale la pena /// exportarla, y una función de tres líneas duplicada se lee mejor que un /// `pub(crate)` que obliga a saltar de archivo. fn mediana(v: &mut [f64]) -> f64 { v.sort_by(|a, b| a.partial_cmp(b).unwrap()); let n = v.len(); if n == 0 { 0.0 } else if n % 2 == 1 { v[n / 2] } else { (v[n / 2 - 1] + v[n / 2]) / 2.0 } } pub fn correr( hal: &Hal, renderer: &mut vello::Renderer, pipelines: &GpuPipelines, view: &wgpu::TextureView, w: u32, h: u32, warmup: usize, medidos: usize, ) -> Vec { // El layout, UNA vez. Los dos caminos lo comparten: el shaping no está en // discusión y meterlo adentro del bucle le sumaría lo mismo a cada uno. let mut ts = llimphi_text::Typesetter::new(); let color = Color::from_rgba8(235, 235, 245, 255); let bloque = llimphi_text::TextBlock::simple(FRASE, TAM, color, (0.0, 0.0)); let layout = llimphi_text::layout_block(&mut ts, &bloque); let mut filas = Vec::new(); for &n in RENGLONES { // ── Atlas caliente ANTES de medir ────────────────────────────── // Un atlas frío paga la rasterización de cada glifo en el primer // frame. Eso es real y hay que decirlo, pero no es lo que hace una // app cuadro a cuadro, que es lo que este bench compara. let mut atlas = AtlasGlifos::new(1024); let mut quads: Vec = Vec::new(); for i in 0..n { quads_de_layout(&mut atlas, &layout, origen(i), &mut quads); } let glifos = quads.len() as u32; let mut tex = AtlasTextura::new(&hal.device, pipelines, atlas.lado()); if let Some(s) = atlas.tomar_sucio() { tex.subir(&hal.queue, atlas.pixeles(), (s.x, s.y, s.ancho, s.alto)); } // ── Camino vello ─────────────────────────────────────────────── let mut scene = vello::Scene::new(); let (mut prep_v, mut total_v) = (Vec::new(), Vec::new()); for frame in 0..(warmup + medidos) { let t0 = Instant::now(); scene.reset(); for i in 0..n { let (x, y) = origen(i); llimphi_text::draw_layout(&mut scene, &layout, color, (x as f64, y as f64)); } let t_prep = t0.elapsed().as_secs_f64() * 1000.0; renderer .render_to_texture( &hal.device, &hal.queue, &scene, view, &vello::RenderParams { base_color: palette::css::BLACK, width: w, height: h, antialiasing_method: vello::AaConfig::Area, }, ) .expect("vello render"); hal.device .poll(wgpu::PollType::wait_indefinitely()) .expect("poll"); let t_total = t0.elapsed().as_secs_f64() * 1000.0; if frame >= warmup { prep_v.push(t_prep); total_v.push(t_total); } } // ── Camino atlas ─────────────────────────────────────────────── let (mut prep_a, mut total_a) = (Vec::new(), Vec::new()); let mut buf: Vec = Vec::new(); for frame in 0..(warmup + medidos) { let t0 = Instant::now(); let mut batch = GpuBatch::new(pipelines); batch.atlas(&tex); // Se rearman los quads cada cuadro, igual que vello rearma su // escena: es la comparación honesta. Guardarlos entre frames sería // una optimización que el otro camino no tiene. buf.clear(); for i in 0..n { quads_de_layout(&mut atlas, &layout, origen(i), &mut buf); } for q in &buf { batch.add_glyph(q.x, q.y, q.w, q.h, (q.u0, q.v0, q.u1, q.v1), color); } let t_prep = t0.elapsed().as_secs_f64() * 1000.0; let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("bench-texto-enc"), }); batch.flush( &hal.device, &hal.queue, &mut enc, view, (w as f32, h as f32), wgpu::LoadOp::Clear(wgpu::Color::BLACK), ); hal.queue.submit(std::iter::once(enc.finish())); hal.device .poll(wgpu::PollType::wait_indefinitely()) .expect("poll"); let t_total = t0.elapsed().as_secs_f64() * 1000.0; if frame >= warmup { prep_a.push(t_prep); total_a.push(t_total); } } // Contraprueba de que el atlas no se llenó: si hubiera descartado // glifos, estaría midiendo un dibujo incompleto contra uno entero. assert_eq!( atlas.fallos, 0, "el atlas descartó glifos: la comparación no es entre dos dibujos iguales" ); filas.push(FilaTexto { renglones: n, glifos, vello_prep_ms: mediana(&mut prep_v), vello_total_ms: mediana(&mut total_v), atlas_prep_ms: mediana(&mut prep_a), atlas_total_ms: mediana(&mut total_a), }); } filas } pub fn imprimir(filas: &[FilaTexto], gpu_real: bool) { println!("## Texto: vello contra el atlas de glifos"); println!(); println!( "Frase de {} caracteres a {TAM} px, repetida N veces. El **shaping está \ fuera del bucle**: los dos caminos consumen el mismo `parley::Layout`, \ así que meterlo sumaría lo mismo a cada uno. El atlas se llena antes de \ medir — se compara el régimen estable, no el primer cuadro.", FRASE.chars().count() ); println!(); println!("| renglones | glifos | vello prep ms | atlas prep ms | prep × | vello total ms | atlas total ms | total × |"); println!("|---:|---:|---:|---:|---:|---:|---:|---:|"); for f in filas { println!( "| {} | {} | {:.3} | {:.3} | {:.2}× | {:.2} | {:.2} | {:.2}× |", f.renglones, f.glifos, f.vello_prep_ms, f.atlas_prep_ms, f.factor_prep(), f.vello_total_ms, f.atlas_total_ms, f.factor_total() ); } println!(); println!("**Cómo leer esto.** La columna `prep` es CPU pura —armar la escena \ contra armar los quads— y vale en cualquier máquina."); if gpu_real { println!(); println!("La columna `total` incluye el rasterizado y la espera a la GPU, \ y en esta corrida hay GPU real: el número decide."); } else { println!(); println!("La columna `total` incluye el rasterizado, pero **esta corrida \ no tiene GPU real** (adapter de software): ahí se está midiendo \ una CPU rasterizando, no una GPU. Ese número NO decide nada \ sobre hardware — hay que volver a correr esto en metal."); } }