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//! Correctitud del `scissor` del GPU-directo (habilitador de damage /
2//! partial-present): un `flush` con `scissor(x,y,w,h)` + `LoadOp::Load` sólo
3//! pinta dentro del rect y **preserva** el resto del `view`. Es la propiedad que
4//! el runtime necesita para re-pintar únicamente la región sucia (un cursor, un
5//! spinner) sin tocar la pantalla entera. Certificado por texto (§8), llvmpipe.
6
7use llimphi_hal::{wgpu, Hal};
8use llimphi_raster::gpu::{GpuBatch, GpuPipelines};
9use llimphi_raster::peniko::Color;
10
11const N: u32 = 16;
12const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
13
14fn read_all(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
15 let unpadded = N as usize * 4;
16 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
17 let padded = unpadded.div_ceil(align) * align;
18 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
19 label: Some("scissor-readback"),
20 size: (padded * N as usize) as u64,
21 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
22 mapped_at_creation: false,
23 });
24 let mut enc = hal
25 .device
26 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
27 enc.copy_texture_to_buffer(
28 wgpu::TexelCopyTextureInfo {
29 texture: target,
30 mip_level: 0,
31 origin: wgpu::Origin3d::ZERO,
32 aspect: wgpu::TextureAspect::All,
33 },
34 wgpu::TexelCopyBufferInfo {
35 buffer: &buf,
36 layout: wgpu::TexelCopyBufferLayout {
37 offset: 0,
38 bytes_per_row: Some(padded as u32),
39 rows_per_image: Some(N),
40 },
41 },
42 wgpu::Extent3d { width: N, height: N, depth_or_array_layers: 1 },
43 );
44 hal.queue.submit(std::iter::once(enc.finish()));
45 let slice = buf.slice(..);
46 let (tx, rx) = std::sync::mpsc::channel();
47 slice.map_async(wgpu::MapMode::Read, move |r| {
48 let _ = tx.send(r);
49 });
50 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
51 rx.recv().unwrap().unwrap();
52 let data = slice.get_mapped_range();
53 let mut out = Vec::with_capacity((N * N * 4) as usize);
54 for row in 0..N as usize {
55 let s = row * padded;
56 out.extend_from_slice(&data[s..s + unpadded]);
57 }
58 drop(data);
59 buf.unmap();
60 out
61}
62
63fn px(buf: &[u8], x: u32, y: u32) -> [u8; 4] {
64 let o = ((y * N + x) * 4) as usize;
65 [buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]
66}
67
68/// Pinta una escena de AA fino y devuelve el buffer entero.
69///
70/// Con `recorte`, el `flush` rasteriza a una textura **del tamaño del rect** y
71/// el vertex shader corre las coordenadas; sin él, a una de pantalla completa.
72/// Son dos caminos distintos que tienen que dar el mismo píxel.
73fn escena_con_bordes(hal: &Hal, pipes: &GpuPipelines, recorte: Option<(u32, u32, u32, u32)>) -> Vec<u8> {
74 let target = hal.device.create_texture(&wgpu::TextureDescriptor {
75 label: Some("bordes-target"),
76 size: wgpu::Extent3d { width: N, height: N, depth_or_array_layers: 1 },
77 mip_level_count: 1,
78 sample_count: 1,
79 dimension: wgpu::TextureDimension::D2,
80 format: FMT,
81 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
82 view_formats: &[],
83 });
84 let view = target.create_view(&wgpu::TextureViewDescriptor::default());
85 let mut enc = hal
86 .device
87 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
88 let mut b = GpuBatch::new(pipes);
89 // Coordenadas fraccionarias a propósito: el AA del MSAA y el SDF del disco
90 // dependen de la posición subpíxel. Medio píxel de desfase en el
91 // corrimiento del shader cambiaría estos bordes y no el interior.
92 b.add_disc(7.3, 8.6, 4.4, Color::from_rgba8(255, 220, 40, 255));
93 b.add_line((3.5, 3.5), (12.25, 13.75), Color::from_rgba8(40, 255, 220, 255));
94 b.add_rect(5.2, 5.9, 5.3, 4.1, Color::from_rgba8(220, 40, 255, 160));
95 if let Some((x, y, w, h)) = recorte {
96 b.scissor(x, y, w, h);
97 }
98 b.flush(
99 &hal.device,
100 &hal.queue,
101 &mut enc,
102 &view,
103 (N as f32, N as f32),
104 wgpu::LoadOp::Clear(wgpu::Color::BLACK),
105 );
106 hal.queue.submit(std::iter::once(enc.finish()));
107 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
108 read_all(hal, &target)
109}
110
111#[test]
112fn el_recorte_cae_en_el_mismo_pixel_exacto_que_la_pasada_completa() {
113 // Desde 2026-08-05 el `flush` con scissor rasteriza a una textura del
114 // tamaño del rect y **corre las coordenadas** en el vertex shader (antes
115 // rasterizaba a pantalla completa y recortaba con `set_scissor_rect`). Es
116 // el cambio que hace que el clear y el resolve del MSAA sean del rect y no
117 // de la pantalla — pero mueve el origen, y un desfase de medio píxel ahí
118 // saldría sólo en los bordes antialiaseados, no en el relleno.
119 let hal = pollster::block_on(Hal::new_headless(true)).expect("hal");
120 let pipes = GpuPipelines::new(&hal.device, FMT);
121
122 let hal = pollster::block_on(Hal::new_headless(true)).expect("hal");
123 let pipes = GpuPipelines::new(&hal.device, FMT);
124 let completa = escena_con_bordes(&hal, &pipes, None);
125
126 // Orígenes **impares** primero: son los que rompían. El AA del disco sale
127 // de `fwidth`, cuyas derivadas se calculan por bloques de 2×2 fragmentos
128 // alineados a la grilla del target; correr el origen un número impar de
129 // píxeles reagrupaba esos bloques y la orla del disco salía hasta 11/255
130 // distinta. Por eso el `flush` baja el origen a par. Las líneas y los rects
131 // pasaban igual — su AA es del MSAA, que no mira vecinos.
132 for (rx, ry, rw, rh) in [(3, 3, 10, 11), (5, 7, 9, 9), (2, 4, 12, 10), (0, 0, 16, 16)] {
133 let recortada = escena_con_bordes(&hal, &pipes, Some((rx, ry, rw, rh)));
134 let (mut distintos, mut con_color, mut peor) = (0, 0, 0i32);
135 for y in ry..ry + rh {
136 for x in rx..rx + rw {
137 let (a, b) = (px(&completa, x, y), px(&recortada, x, y));
138 if a != b {
139 distintos += 1;
140 peor = peor.max(
141 (0..4).map(|i| (a[i] as i32 - b[i] as i32).abs()).max().unwrap(),
142 );
143 }
144 if a[0] > 8 || a[1] > 8 || a[2] > 8 {
145 con_color += 1;
146 }
147 }
148 }
149 // Guarda del arnés: si el rect cayera sobre un vacío, comparar nada con
150 // nada pasaría siempre.
151 assert!(con_color > 30, "el rect ({rx},{ry},{rw},{rh}) casi no tiene dibujo ({con_color} px)");
152 assert_eq!(
153 distintos, 0,
154 "rect ({rx},{ry},{rw},{rh}): {distintos} px distintos del completo (peor canal Δ{peor})"
155 );
156 }
157}
158
159#[test]
160fn scissor_pinta_solo_la_region_sucia_y_preserva_el_resto() {
161 let hal = pollster::block_on(Hal::new_headless(true)).expect("hal");
162 let pipelines = GpuPipelines::new(&hal.device, FMT);
163 let target = hal.device.create_texture(&wgpu::TextureDescriptor {
164 label: Some("scissor-target"),
165 size: wgpu::Extent3d { width: N, height: N, depth_or_array_layers: 1 },
166 mip_level_count: 1,
167 sample_count: 1,
168 dimension: wgpu::TextureDimension::D2,
169 format: FMT,
170 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
171 view_formats: &[],
172 });
173 let view = target.create_view(&wgpu::TextureViewDescriptor::default());
174
175 let mut enc = hal
176 .device
177 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
178
179 // 1) Pintar TODO de azul (estado "previo" del view).
180 let mut base = GpuBatch::new(&pipelines);
181 base.add_rect(0.0, 0.0, N as f32, N as f32, Color::from_rgba8(0, 0, 255, 255));
182 base.flush(
183 &hal.device,
184 &hal.queue,
185 &mut enc,
186 &view,
187 (N as f32, N as f32),
188 wgpu::LoadOp::Clear(wgpu::Color::BLACK),
189 );
190
191 // 2) Un rect rojo full-viewport PERO con scissor a [4,4]-[12,12] y Load:
192 // debe pintar rojo sólo ahí y dejar el azul intacto afuera.
193 let mut dirty = GpuBatch::new(&pipelines);
194 dirty.add_rect(0.0, 0.0, N as f32, N as f32, Color::from_rgba8(255, 0, 0, 255));
195 dirty.scissor(4, 4, 8, 8);
196 dirty.flush(
197 &hal.device,
198 &hal.queue,
199 &mut enc,
200 &view,
201 (N as f32, N as f32),
202 wgpu::LoadOp::Load,
203 );
204 hal.queue.submit(std::iter::once(enc.finish()));
205 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
206
207 let buf = read_all(&hal, &target);
208
209 // Centro (8,8): dentro del scissor → rojo.
210 let c = px(&buf, 8, 8);
211 assert!(c[0] > 200 && c[2] < 60, "centro debería ser rojo, fue {c:?}");
212 // Esquinas fuera del scissor → azul preservado.
213 for (x, y) in [(1, 1), (14, 1), (1, 14), (14, 14)] {
214 let p = px(&buf, x, y);
215 assert!(
216 p[2] > 200 && p[0] < 60,
217 "({x},{y}) fuera del scissor debería seguir azul, fue {p:?}"
218 );
219 }
220 // Justo fuera del borde del scissor (3,3) azul; justo dentro (4,4) rojo.
221 let out = px(&buf, 3, 3);
222 assert!(out[2] > 200 && out[0] < 60, "(3,3) borde-fuera debería ser azul, fue {out:?}");
223 let inn = px(&buf, 5, 5);
224 assert!(inn[0] > 200 && inn[2] < 60, "(5,5) borde-dentro debería ser rojo, fue {inn:?}");
225}