//! Varios grosores de línea en **un solo** `flush` del GPU-directo. //! //! Hasta 2026-08-05 el grosor era un uniforme del lote entero. Eso convertía //! «una draw call para todo» en una promesa que se rompía con el dibujo más //! común que hay: una grilla fina con una curva gruesa encima. Había que cortar //! el lote, y **cada `flush` arma su propia textura MSAA 4×, la resuelve y la //! compone** — o sea que el segundo grosor no costaba una draw call más, costaba //! el ciclo entero otra vez. //! //! El oráculo no es una imagen bendecida: es **la forma vieja**. Un lote con dos //! grosores tiene que dar exactamente los mismos píxeles que dos lotes de un //! grosor cada uno, que es lo que un caller estaba obligado a escribir antes. //! //! Certificado por texto (§8), llvmpipe. use llimphi_hal::{wgpu, Hal}; use llimphi_raster::gpu::{GpuBatch, GpuPipelines}; use llimphi_raster::peniko::Color; const N: u32 = 64; const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; const FINA: f32 = 1.0; const GRUESA: f32 = 9.0; const X_FINA: f32 = 16.0; const X_GRUESA: f32 = 44.0; fn hal() -> Hal { pollster::block_on(Hal::new_headless(true)).expect("hal headless") } fn textura(hal: &Hal) -> wgpu::Texture { hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("grosores-target"), size: wgpu::Extent3d { width: N, height: N, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: FMT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }) } fn leer(hal: &Hal, target: &wgpu::Texture) -> Vec { let unpadded = N as usize * 4; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; let padded = unpadded.div_ceil(align) * align; let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { label: Some("grosores-readback"), size: (padded * N as usize) as u64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); enc.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded as u32), rows_per_image: Some(N), }, }, wgpu::Extent3d { width: N, height: N, depth_or_array_layers: 1 }, ); hal.queue.submit(std::iter::once(enc.finish())); let slice = buf.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); rx.recv().unwrap().unwrap(); let data = slice.get_mapped_range(); let mut out = Vec::with_capacity((N * N * 4) as usize); for row in 0..N as usize { let s = row * padded; out.extend_from_slice(&data[s..s + unpadded]); } drop(data); buf.unmap(); out } /// Dos verticales que **no se tocan**: así el alpha-over de la segunda pasada /// del camino viejo cae sobre fondo intacto y la comparación mide el grosor, no /// el orden de composición. fn fina(b: &mut GpuBatch<'_>) { b.line_width(FINA); b.add_line((X_FINA, 4.0), (X_FINA, 60.0), Color::from_rgba8(255, 80, 80, 255)); } fn gruesa(b: &mut GpuBatch<'_>) { b.line_width(GRUESA); b.add_line((X_GRUESA, 4.0), (X_GRUESA, 60.0), Color::from_rgba8(80, 200, 255, 255)); } /// Píxeles no-negros de la fila `y`, en el rango de columnas `[x0, x1)`. fn ancho_en_fila(px: &[u8], y: u32, x0: u32, x1: u32) -> u32 { (x0..x1) .filter(|x| { let o = ((y * N + x) * 4) as usize; px[o] > 8 || px[o + 1] > 8 || px[o + 2] > 8 }) .count() as u32 } #[test] fn un_lote_con_dos_grosores_da_lo_mismo_que_dos_lotes_de_uno() { let hal = hal(); let pipes = GpuPipelines::new(&hal.device, FMT); // Camino nuevo: un solo flush, la pluma cambia entre línea y línea. let t_uno = textura(&hal); let v_uno = t_uno.create_view(&wgpu::TextureViewDescriptor::default()); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); let mut b = GpuBatch::new(&pipes); fina(&mut b); gruesa(&mut b); b.flush( &hal.device, &hal.queue, &mut enc, &v_uno, (N as f32, N as f32), wgpu::LoadOp::Clear(wgpu::Color::BLACK), ); hal.queue.submit(std::iter::once(enc.finish())); let uno = leer(&hal, &t_uno); // Camino viejo: dos flush, uno por grosor. Es literalmente lo que un caller // estaba obligado a escribir, y es el oráculo de este test. let t_dos = textura(&hal); let v_dos = t_dos.create_view(&wgpu::TextureViewDescriptor::default()); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); let mut b1 = GpuBatch::new(&pipes); fina(&mut b1); b1.flush( &hal.device, &hal.queue, &mut enc, &v_dos, (N as f32, N as f32), wgpu::LoadOp::Clear(wgpu::Color::BLACK), ); let mut b2 = GpuBatch::new(&pipes); gruesa(&mut b2); b2.flush( &hal.device, &hal.queue, &mut enc, &v_dos, (N as f32, N as f32), wgpu::LoadOp::Load, ); hal.queue.submit(std::iter::once(enc.finish())); let dos = leer(&hal, &t_dos); let distintos = uno .chunks_exact(4) .zip(dos.chunks_exact(4)) .filter(|(a, b)| a != b) .count(); assert_eq!(distintos, 0, "el lote único no da los mismos píxeles que los dos lotes"); // Guarda del arnés: si las dos líneas salieran del mismo grosor, la igualdad // de arriba se cumpliría igual y no probaría nada. let a_fina = ancho_en_fila(&uno, 32, 0, N / 2); let a_gruesa = ancho_en_fila(&uno, 32, N / 2, N); assert!( (1..=3).contains(&a_fina), "la línea de {FINA} px salió de {a_fina} px de ancho" ); assert!( (8..=11).contains(&a_gruesa), "la línea de {GRUESA} px salió de {a_gruesa} px de ancho" ); assert!( a_gruesa >= a_fina * 4, "los dos grosores salieron parecidos ({a_fina} vs {a_gruesa}): \ el grosor por instancia no está llegando" ); } #[test] fn la_pluma_vale_desde_que_se_la_cambia_y_no_hacia_atras() { // El cambio de semántica que trae el grosor por instancia, dicho por un // test: antes `line_width` era un uniforme y valía para TODO el lote sin // importar cuándo se llamara. Ahora es una pluma, y cada línea se queda con // el grosor que había puesto al agregarla. Si esto se rompiera, un caller // que alterna grosores dibujaría todo del último. let hal = hal(); let pipes = GpuPipelines::new(&hal.device, FMT); let t = textura(&hal); let v = t.create_view(&wgpu::TextureViewDescriptor::default()); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); let mut b = GpuBatch::new(&pipes); // Fina primero, gruesa después, y **después** se vuelve a poner fina sin // agregar nada: si el grosor fuera del lote, la gruesa saldría fina. fina(&mut b); gruesa(&mut b); b.line_width(FINA); b.flush( &hal.device, &hal.queue, &mut enc, &v, (N as f32, N as f32), wgpu::LoadOp::Clear(wgpu::Color::BLACK), ); hal.queue.submit(std::iter::once(enc.finish())); let px = leer(&hal, &t); let a_gruesa = ancho_en_fila(&px, 32, N / 2, N); assert!( (8..=11).contains(&a_gruesa), "un line_width posterior le cambió el grosor a una línea ya agregada: \ salió de {a_gruesa} px" ); }