//! Correctitud del `scissor` del GPU-directo (habilitador de damage / //! partial-present): un `flush` con `scissor(x,y,w,h)` + `LoadOp::Load` sólo //! pinta dentro del rect y **preserva** el resto del `view`. Es la propiedad que //! el runtime necesita para re-pintar únicamente la región sucia (un cursor, un //! spinner) sin tocar la pantalla entera. Certificado por texto (§8), llvmpipe. use llimphi_hal::{wgpu, Hal}; use llimphi_raster::gpu::{GpuBatch, GpuPipelines}; use llimphi_raster::peniko::Color; const N: u32 = 16; const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; fn read_all(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("scissor-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 } fn px(buf: &[u8], x: u32, y: u32) -> [u8; 4] { let o = ((y * N + x) * 4) as usize; [buf[o], buf[o + 1], buf[o + 2], buf[o + 3]] } /// Pinta una escena de AA fino y devuelve el buffer entero. /// /// Con `recorte`, el `flush` rasteriza a una textura **del tamaño del rect** y /// el vertex shader corre las coordenadas; sin él, a una de pantalla completa. /// Son dos caminos distintos que tienen que dar el mismo píxel. fn escena_con_bordes(hal: &Hal, pipes: &GpuPipelines, recorte: Option<(u32, u32, u32, u32)>) -> Vec { let target = hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("bordes-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: &[], }); let view = target.create_view(&wgpu::TextureViewDescriptor::default()); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); let mut b = GpuBatch::new(pipes); // Coordenadas fraccionarias a propósito: el AA del MSAA y el SDF del disco // dependen de la posición subpíxel. Medio píxel de desfase en el // corrimiento del shader cambiaría estos bordes y no el interior. b.add_disc(7.3, 8.6, 4.4, Color::from_rgba8(255, 220, 40, 255)); b.add_line((3.5, 3.5), (12.25, 13.75), Color::from_rgba8(40, 255, 220, 255)); b.add_rect(5.2, 5.9, 5.3, 4.1, Color::from_rgba8(220, 40, 255, 160)); if let Some((x, y, w, h)) = recorte { b.scissor(x, y, w, h); } b.flush( &hal.device, &hal.queue, &mut enc, &view, (N as f32, N as f32), wgpu::LoadOp::Clear(wgpu::Color::BLACK), ); hal.queue.submit(std::iter::once(enc.finish())); let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); read_all(hal, &target) } #[test] fn el_recorte_cae_en_el_mismo_pixel_exacto_que_la_pasada_completa() { // Desde 2026-08-05 el `flush` con scissor rasteriza a una textura del // tamaño del rect y **corre las coordenadas** en el vertex shader (antes // rasterizaba a pantalla completa y recortaba con `set_scissor_rect`). Es // el cambio que hace que el clear y el resolve del MSAA sean del rect y no // de la pantalla — pero mueve el origen, y un desfase de medio píxel ahí // saldría sólo en los bordes antialiaseados, no en el relleno. let hal = pollster::block_on(Hal::new_headless(true)).expect("hal"); let pipes = GpuPipelines::new(&hal.device, FMT); let hal = pollster::block_on(Hal::new_headless(true)).expect("hal"); let pipes = GpuPipelines::new(&hal.device, FMT); let completa = escena_con_bordes(&hal, &pipes, None); // Orígenes **impares** primero: son los que rompían. El AA del disco sale // de `fwidth`, cuyas derivadas se calculan por bloques de 2×2 fragmentos // alineados a la grilla del target; correr el origen un número impar de // píxeles reagrupaba esos bloques y la orla del disco salía hasta 11/255 // distinta. Por eso el `flush` baja el origen a par. Las líneas y los rects // pasaban igual — su AA es del MSAA, que no mira vecinos. for (rx, ry, rw, rh) in [(3, 3, 10, 11), (5, 7, 9, 9), (2, 4, 12, 10), (0, 0, 16, 16)] { let recortada = escena_con_bordes(&hal, &pipes, Some((rx, ry, rw, rh))); let (mut distintos, mut con_color, mut peor) = (0, 0, 0i32); for y in ry..ry + rh { for x in rx..rx + rw { let (a, b) = (px(&completa, x, y), px(&recortada, x, y)); if a != b { distintos += 1; peor = peor.max( (0..4).map(|i| (a[i] as i32 - b[i] as i32).abs()).max().unwrap(), ); } if a[0] > 8 || a[1] > 8 || a[2] > 8 { con_color += 1; } } } // Guarda del arnés: si el rect cayera sobre un vacío, comparar nada con // nada pasaría siempre. assert!(con_color > 30, "el rect ({rx},{ry},{rw},{rh}) casi no tiene dibujo ({con_color} px)"); assert_eq!( distintos, 0, "rect ({rx},{ry},{rw},{rh}): {distintos} px distintos del completo (peor canal Δ{peor})" ); } } #[test] fn scissor_pinta_solo_la_region_sucia_y_preserva_el_resto() { let hal = pollster::block_on(Hal::new_headless(true)).expect("hal"); let pipelines = GpuPipelines::new(&hal.device, FMT); let target = hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("scissor-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: &[], }); let view = target.create_view(&wgpu::TextureViewDescriptor::default()); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); // 1) Pintar TODO de azul (estado "previo" del view). let mut base = GpuBatch::new(&pipelines); base.add_rect(0.0, 0.0, N as f32, N as f32, Color::from_rgba8(0, 0, 255, 255)); base.flush( &hal.device, &hal.queue, &mut enc, &view, (N as f32, N as f32), wgpu::LoadOp::Clear(wgpu::Color::BLACK), ); // 2) Un rect rojo full-viewport PERO con scissor a [4,4]-[12,12] y Load: // debe pintar rojo sólo ahí y dejar el azul intacto afuera. let mut dirty = GpuBatch::new(&pipelines); dirty.add_rect(0.0, 0.0, N as f32, N as f32, Color::from_rgba8(255, 0, 0, 255)); dirty.scissor(4, 4, 8, 8); dirty.flush( &hal.device, &hal.queue, &mut enc, &view, (N as f32, N as f32), wgpu::LoadOp::Load, ); hal.queue.submit(std::iter::once(enc.finish())); let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); let buf = read_all(&hal, &target); // Centro (8,8): dentro del scissor → rojo. let c = px(&buf, 8, 8); assert!(c[0] > 200 && c[2] < 60, "centro debería ser rojo, fue {c:?}"); // Esquinas fuera del scissor → azul preservado. for (x, y) in [(1, 1), (14, 1), (1, 14), (14, 14)] { let p = px(&buf, x, y); assert!( p[2] > 200 && p[0] < 60, "({x},{y}) fuera del scissor debería seguir azul, fue {p:?}" ); } // Justo fuera del borde del scissor (3,3) azul; justo dentro (4,4) rojo. let out = px(&buf, 3, 3); assert!(out[2] > 200 && out[0] < 60, "(3,3) borde-fuera debería ser azul, fue {out:?}"); let inn = px(&buf, 5, 5); assert!(inn[0] > 200 && inn[2] < 60, "(5,5) borde-dentro debería ser rojo, fue {inn:?}"); }