gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main llimphi-raster/tests/gpu_hdr_precision.rs Rust · 170 siq'ikuna · 6.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

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}