tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
1//! Prueba de que el backend GPU-directo carga **precisión de color que vello no
2//! puede** (vello 0.7 exige target `Rgba8Unorm`; sólo emite 8-bit). Con el color
3//! del vértice ensanchado a 4×f32 y un target `Rgba16Float`, el path directo
4//! preserva:
5//!
6//! 1. valores HDR (componente >1.0) que 8-bit **clampea** a 1.0, y
7//! 2. pasos de color **más finos que un nivel 8-bit** que 8-bit **cuantiza** al
8//! mismo byte.
9//!
10//! Es la certificación headless (§8, por texto: `assert` numérico sobre el
11//! readback) del deliverable "GPU-directo desbloquea HDR" de PLAN-MOTOR-PROFUNDO.
12//! Corre en llvmpipe. El look HDR en pantalla sigue siendo [METAL].
13
14use llimphi_hal::{wgpu, Hal};
15use llimphi_raster::gpu::{GpuBatch, GpuPipelines};
16use llimphi_raster::peniko::Color;
17
18const N: u32 = 8; // target chico; el rect cubre todo
19
20/// Decodifica un half-float (f16, IEEE 754 binary16) a f32.
21fn f16_to_f32(h: u16) -> f32 {
22 let sign = if (h >> 15) & 1 == 1 { -1.0 } else { 1.0 };
23 let exp = (h >> 10) & 0x1f;
24 let frac = h & 0x3ff;
25 if exp == 0 {
26 sign * (frac as f32) * 2f32.powi(-24) // subnormal
27 } else if exp == 0x1f {
28 if frac == 0 { sign * f32::INFINITY } else { f32::NAN }
29 } else {
30 sign * (1.0 + frac as f32 / 1024.0) * 2f32.powi(exp as i32 - 15)
31 }
32}
33
34/// Rasteriza un rect que cubre todo el target de formato `fmt` con `color` y
35/// devuelve los bytes crudos del pixel central (fila con padding quitado).
36fn solid_center_bytes(
37 hal: &Hal,
38 fmt: wgpu::TextureFormat,
39 bytes_per_pixel: usize,
40 color: Color,
41) -> Vec<u8> {
42 let pipelines = GpuPipelines::new(&hal.device, fmt);
43 let target = hal.device.create_texture(&wgpu::TextureDescriptor {
44 label: Some("hdr-target"),
45 size: wgpu::Extent3d { width: N, height: N, depth_or_array_layers: 1 },
46 mip_level_count: 1,
47 sample_count: 1,
48 dimension: wgpu::TextureDimension::D2,
49 format: fmt,
50 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
51 view_formats: &[],
52 });
53 let view = target.create_view(&wgpu::TextureViewDescriptor::default());
54
55 let mut batch = GpuBatch::new(&pipelines);
56 batch.add_rect(0.0, 0.0, N as f32, N as f32, color);
57 let mut enc = hal
58 .device
59 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
60 batch.flush(
61 &hal.device,
62 &hal.queue,
63 &mut enc,
64 &view,
65 (N as f32, N as f32),
66 // Clear a negro OPACO: en el pixel cubierto el composite (premult
67 // over) deja el color del rect intacto; en los no cubiertos, negro.
68 wgpu::LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }),
69 );
70 hal.queue.submit(std::iter::once(enc.finish()));
71 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
72
73 // Readback con padding de fila.
74 let unpadded = N as usize * bytes_per_pixel;
75 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
76 let padded = unpadded.div_ceil(align) * align;
77 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
78 label: Some("hdr-readback"),
79 size: (padded * N as usize) as u64,
80 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
81 mapped_at_creation: false,
82 });
83 let mut enc = hal
84 .device
85 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
86 enc.copy_texture_to_buffer(
87 wgpu::TexelCopyTextureInfo {
88 texture: &target,
89 mip_level: 0,
90 origin: wgpu::Origin3d::ZERO,
91 aspect: wgpu::TextureAspect::All,
92 },
93 wgpu::TexelCopyBufferInfo {
94 buffer: &buf,
95 layout: wgpu::TexelCopyBufferLayout {
96 offset: 0,
97 bytes_per_row: Some(padded as u32),
98 rows_per_image: Some(N),
99 },
100 },
101 wgpu::Extent3d { width: N, height: N, depth_or_array_layers: 1 },
102 );
103 hal.queue.submit(std::iter::once(enc.finish()));
104 let slice = buf.slice(..);
105 let (tx, rx) = std::sync::mpsc::channel();
106 slice.map_async(wgpu::MapMode::Read, move |r| {
107 let _ = tx.send(r);
108 });
109 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
110 rx.recv().unwrap().unwrap();
111 let data = slice.get_mapped_range();
112 // Pixel central (4,4).
113 let row = 4usize;
114 let col = 4usize;
115 let start = row * padded + col * bytes_per_pixel;
116 let px = data[start..start + bytes_per_pixel].to_vec();
117 drop(data);
118 buf.unmap();
119 px
120}
121
122/// Lee el canal R de un pixel `Rgba16Float` (2 B/canal).
123fn r_channel_f16(px: &[u8]) -> f32 {
124 f16_to_f32(u16::from_le_bytes([px[0], px[1]]))
125}
126
127#[test]
128fn valor_hdr_mayor_que_uno_sobrevive_en_float_target() {
129 let hal = pollster::block_on(Hal::new_headless(true)).expect("hal");
130 // R = 4.0 (HDR). En Rgba8Unorm esto clampea a 1.0; en float debe sobrevivir.
131 let color = Color::new([4.0, 0.0, 0.0, 1.0]);
132 let px = solid_center_bytes(&hal, wgpu::TextureFormat::Rgba16Float, 8, color);
133 let r = r_channel_f16(&px);
134 assert!(
135 r > 3.9,
136 "el valor HDR 4.0 no sobrevivió al target float: R leído = {r} \
137 (si fuera 8-bit estaría clampeado a 1.0)"
138 );
139}
140
141#[test]
142fn paso_sub_8bit_se_pierde_en_8bit_pero_sobrevive_en_float() {
143 let hal = pollster::block_on(Hal::new_headless(true)).expect("hal");
144 // Dos valores dentro del MISMO nivel de 8-bit: base 100 (lejos de cualquier
145 // borde .5 de redondeo) + 0.4/255. Ambos redondean a 100 en 8-bit, pero
146 // distan ~0.0016 en lineal → f16 (paso ~0.0005 cerca de 0.4) los distingue.
147 let v1 = 100.0 / 255.0;
148 let v2 = v1 + 0.4 / 255.0;
149 let c1 = Color::new([v1, 0.0, 0.0, 1.0]);
150 let c2 = Color::new([v2, 0.0, 0.0, 1.0]);
151
152 // En 8-bit: ambos cuantizan al MISMO byte (indistinguibles).
153 let r1_8 = solid_center_bytes(&hal, wgpu::TextureFormat::Rgba8Unorm, 4, c1)[0];
154 let r2_8 = solid_center_bytes(&hal, wgpu::TextureFormat::Rgba8Unorm, 4, c2)[0];
155 assert_eq!(
156 r1_8, r2_8,
157 "premisa: en 8-bit los dos valores deben cuantizar al mismo byte \
158 (fueron {r1_8} y {r2_8})"
159 );
160
161 // En float: el path directo los preserva distintos.
162 let r1_f = r_channel_f16(&solid_center_bytes(&hal, wgpu::TextureFormat::Rgba16Float, 8, c1));
163 let r2_f = r_channel_f16(&solid_center_bytes(&hal, wgpu::TextureFormat::Rgba16Float, 8, c2));
164 assert!(
165 (r1_f - r2_f).abs() > 0.0007,
166 "el target float debería distinguir el paso sub-8-bit: R1={r1_f} R2={r2_f} \
167 (Δ={:.5}); en 8-bit Δ fue exactamente 0",
168 (r1_f - r2_f).abs()
169 );
170}