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//! Varios grosores de línea en **un solo** `flush` del GPU-directo.
2//!
3//! Hasta 2026-08-05 el grosor era un uniforme del lote entero. Eso convertía
4//! «una draw call para todo» en una promesa que se rompía con el dibujo más
5//! común que hay: una grilla fina con una curva gruesa encima. Había que cortar
6//! el lote, y **cada `flush` arma su propia textura MSAA 4×, la resuelve y la
7//! compone** — o sea que el segundo grosor no costaba una draw call más, costaba
8//! el ciclo entero otra vez.
9//!
10//! El oráculo no es una imagen bendecida: es **la forma vieja**. Un lote con dos
11//! grosores tiene que dar exactamente los mismos píxeles que dos lotes de un
12//! grosor cada uno, que es lo que un caller estaba obligado a escribir antes.
13//!
14//! Certificado por texto (§8), llvmpipe.
15
16use llimphi_hal::{wgpu, Hal};
17use llimphi_raster::gpu::{GpuBatch, GpuPipelines};
18use llimphi_raster::peniko::Color;
19
20const N: u32 = 64;
21const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
22
23const FINA: f32 = 1.0;
24const GRUESA: f32 = 9.0;
25const X_FINA: f32 = 16.0;
26const X_GRUESA: f32 = 44.0;
27
28fn hal() -> Hal {
29 pollster::block_on(Hal::new_headless(true)).expect("hal headless")
30}
31
32fn textura(hal: &Hal) -> wgpu::Texture {
33 hal.device.create_texture(&wgpu::TextureDescriptor {
34 label: Some("grosores-target"),
35 size: wgpu::Extent3d { width: N, height: N, depth_or_array_layers: 1 },
36 mip_level_count: 1,
37 sample_count: 1,
38 dimension: wgpu::TextureDimension::D2,
39 format: FMT,
40 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
41 view_formats: &[],
42 })
43}
44
45fn leer(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
46 let unpadded = N as usize * 4;
47 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
48 let padded = unpadded.div_ceil(align) * align;
49 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
50 label: Some("grosores-readback"),
51 size: (padded * N as usize) as u64,
52 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
53 mapped_at_creation: false,
54 });
55 let mut enc = hal
56 .device
57 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
58 enc.copy_texture_to_buffer(
59 wgpu::TexelCopyTextureInfo {
60 texture: target,
61 mip_level: 0,
62 origin: wgpu::Origin3d::ZERO,
63 aspect: wgpu::TextureAspect::All,
64 },
65 wgpu::TexelCopyBufferInfo {
66 buffer: &buf,
67 layout: wgpu::TexelCopyBufferLayout {
68 offset: 0,
69 bytes_per_row: Some(padded as u32),
70 rows_per_image: Some(N),
71 },
72 },
73 wgpu::Extent3d { width: N, height: N, depth_or_array_layers: 1 },
74 );
75 hal.queue.submit(std::iter::once(enc.finish()));
76 let slice = buf.slice(..);
77 let (tx, rx) = std::sync::mpsc::channel();
78 slice.map_async(wgpu::MapMode::Read, move |r| {
79 let _ = tx.send(r);
80 });
81 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
82 rx.recv().unwrap().unwrap();
83 let data = slice.get_mapped_range();
84 let mut out = Vec::with_capacity((N * N * 4) as usize);
85 for row in 0..N as usize {
86 let s = row * padded;
87 out.extend_from_slice(&data[s..s + unpadded]);
88 }
89 drop(data);
90 buf.unmap();
91 out
92}
93
94/// Dos verticales que **no se tocan**: así el alpha-over de la segunda pasada
95/// del camino viejo cae sobre fondo intacto y la comparación mide el grosor, no
96/// el orden de composición.
97fn fina(b: &mut GpuBatch<'_>) {
98 b.line_width(FINA);
99 b.add_line((X_FINA, 4.0), (X_FINA, 60.0), Color::from_rgba8(255, 80, 80, 255));
100}
101
102fn gruesa(b: &mut GpuBatch<'_>) {
103 b.line_width(GRUESA);
104 b.add_line((X_GRUESA, 4.0), (X_GRUESA, 60.0), Color::from_rgba8(80, 200, 255, 255));
105}
106
107/// Píxeles no-negros de la fila `y`, en el rango de columnas `[x0, x1)`.
108fn ancho_en_fila(px: &[u8], y: u32, x0: u32, x1: u32) -> u32 {
109 (x0..x1)
110 .filter(|x| {
111 let o = ((y * N + x) * 4) as usize;
112 px[o] > 8 || px[o + 1] > 8 || px[o + 2] > 8
113 })
114 .count() as u32
115}
116
117#[test]
118fn un_lote_con_dos_grosores_da_lo_mismo_que_dos_lotes_de_uno() {
119 let hal = hal();
120 let pipes = GpuPipelines::new(&hal.device, FMT);
121
122 // Camino nuevo: un solo flush, la pluma cambia entre línea y línea.
123 let t_uno = textura(&hal);
124 let v_uno = t_uno.create_view(&wgpu::TextureViewDescriptor::default());
125 let mut enc = hal
126 .device
127 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
128 let mut b = GpuBatch::new(&pipes);
129 fina(&mut b);
130 gruesa(&mut b);
131 b.flush(
132 &hal.device,
133 &hal.queue,
134 &mut enc,
135 &v_uno,
136 (N as f32, N as f32),
137 wgpu::LoadOp::Clear(wgpu::Color::BLACK),
138 );
139 hal.queue.submit(std::iter::once(enc.finish()));
140 let uno = leer(&hal, &t_uno);
141
142 // Camino viejo: dos flush, uno por grosor. Es literalmente lo que un caller
143 // estaba obligado a escribir, y es el oráculo de este test.
144 let t_dos = textura(&hal);
145 let v_dos = t_dos.create_view(&wgpu::TextureViewDescriptor::default());
146 let mut enc = hal
147 .device
148 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
149 let mut b1 = GpuBatch::new(&pipes);
150 fina(&mut b1);
151 b1.flush(
152 &hal.device,
153 &hal.queue,
154 &mut enc,
155 &v_dos,
156 (N as f32, N as f32),
157 wgpu::LoadOp::Clear(wgpu::Color::BLACK),
158 );
159 let mut b2 = GpuBatch::new(&pipes);
160 gruesa(&mut b2);
161 b2.flush(
162 &hal.device,
163 &hal.queue,
164 &mut enc,
165 &v_dos,
166 (N as f32, N as f32),
167 wgpu::LoadOp::Load,
168 );
169 hal.queue.submit(std::iter::once(enc.finish()));
170 let dos = leer(&hal, &t_dos);
171
172 let distintos = uno
173 .chunks_exact(4)
174 .zip(dos.chunks_exact(4))
175 .filter(|(a, b)| a != b)
176 .count();
177 assert_eq!(distintos, 0, "el lote único no da los mismos píxeles que los dos lotes");
178
179 // Guarda del arnés: si las dos líneas salieran del mismo grosor, la igualdad
180 // de arriba se cumpliría igual y no probaría nada.
181 let a_fina = ancho_en_fila(&uno, 32, 0, N / 2);
182 let a_gruesa = ancho_en_fila(&uno, 32, N / 2, N);
183 assert!(
184 (1..=3).contains(&a_fina),
185 "la línea de {FINA} px salió de {a_fina} px de ancho"
186 );
187 assert!(
188 (8..=11).contains(&a_gruesa),
189 "la línea de {GRUESA} px salió de {a_gruesa} px de ancho"
190 );
191 assert!(
192 a_gruesa >= a_fina * 4,
193 "los dos grosores salieron parecidos ({a_fina} vs {a_gruesa}): \
194 el grosor por instancia no está llegando"
195 );
196}
197
198#[test]
199fn la_pluma_vale_desde_que_se_la_cambia_y_no_hacia_atras() {
200 // El cambio de semántica que trae el grosor por instancia, dicho por un
201 // test: antes `line_width` era un uniforme y valía para TODO el lote sin
202 // importar cuándo se llamara. Ahora es una pluma, y cada línea se queda con
203 // el grosor que había puesto al agregarla. Si esto se rompiera, un caller
204 // que alterna grosores dibujaría todo del último.
205 let hal = hal();
206 let pipes = GpuPipelines::new(&hal.device, FMT);
207 let t = textura(&hal);
208 let v = t.create_view(&wgpu::TextureViewDescriptor::default());
209 let mut enc = hal
210 .device
211 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
212
213 let mut b = GpuBatch::new(&pipes);
214 // Fina primero, gruesa después, y **después** se vuelve a poner fina sin
215 // agregar nada: si el grosor fuera del lote, la gruesa saldría fina.
216 fina(&mut b);
217 gruesa(&mut b);
218 b.line_width(FINA);
219 b.flush(
220 &hal.device,
221 &hal.queue,
222 &mut enc,
223 &v,
224 (N as f32, N as f32),
225 wgpu::LoadOp::Clear(wgpu::Color::BLACK),
226 );
227 hal.queue.submit(std::iter::once(enc.finish()));
228 let px = leer(&hal, &t);
229
230 let a_gruesa = ancho_en_fila(&px, 32, N / 2, N);
231 assert!(
232 (8..=11).contains(&a_gruesa),
233 "un line_width posterior le cambió el grosor a una línea ya agregada: \
234 salió de {a_gruesa} px"
235 );
236}