gioser

tawasuyu/llimphi

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

Rust 484Markdown 258TOML 131RON 38118 Definiciones

⎇ main llimphi-3d/examples/rugosidad_demo.rs Rust · 264 líneas · 11.1 KB Historia Quién tocó qué crudo

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//! Certificación headless del **spike de rugosidad sub-voxel** (bump procedural
2//! en el ray-march). Prueba, por STATS numéricas (regla 8), la tesis del diseño:
3//!
4//!   1. **La geometría/silueta NO cambia** — el conjunto de píxeles cubiertos es
5//!      idéntico con rugosidad off y on (la perturbación toca sólo la normal de
6//!      SOMBREADO, no el `t`/posición del hit). → `coverage_mismatch ≈ 0`.
7//!   2. **El sombreado deja de ser plano** — las caras axiales, que off producen
8//!      poquísimos niveles de luminancia (constante + gradiente de AO), on ganan
9//!      una micro-variación continua. → sube la "energía de textura" (|Δlum| entre
10//!      píxeles vecinos) y el nº de luminancias distintas, monótono con la amplitud.
11//!
12//! Corre un barrido de amplitud contra la línea base (amp=0) e imprime la tabla.
13//! Emite además `off.png` / `on.png` por si se quiere MIRAR una comparación (último
14//! recurso, no la evidencia primaria).
15//!
16//! `cargo run -p llimphi-3d --example rugosidad_demo --release -- [dim]`
17
18use std::fs::File;
19use std::io::BufWriter;
20
21use llimphi_3d::glam::Vec3;
22use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer};
23use llimphi_hal::{wgpu, Hal};
24use llimphi_raster::peniko::Color;
25use llimphi_raster::{vello, Renderer};
26
27const W: u32 = 720;
28const H: u32 = 480;
29const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
30/// Color de limpieza del fondo (los misses hacen `discard` → queda este color).
31const BG: [u8; 3] = [18, 22, 32];
32
33fn main() {
34    let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(96);
35    let d = dim as f32;
36
37    let hal = pollster::block_on(Hal::new(None)).expect("hal");
38    let mut renderer = Renderer::new(&hal).expect("renderer");
39
40    // Escena de fábrica: piso plano + esfera + pilares. El piso es la superficie
41    // ideal para exhibir rugosidad (cara superior grande y axial).
42    let grid = VoxelGrid::demo_scene([dim, dim, dim]);
43    let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
44    vr.rugosidad_freq = 1.6;
45
46    let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 24_f32.to_radians(), d * 1.7);
47
48    let amps = [0.0_f32, 0.4, 0.8, 1.2];
49    let mut frames: Vec<(f32, Vec<u8>)> = Vec::new();
50    for &amp in &amps {
51        vr.rugosidad_amp = amp;
52        frames.push((amp, render_pixels(&hal, &mut renderer, &mut vr, &camera)));
53    }
54
55    let base = &frames[0].1;
56    let base_cov = coverage(base);
57    let base_covered: usize = base_cov.iter().filter(|&&c| c).count();
58
59    println!("rugosidad_demo — dim={dim}³, freq={:.2}, {W}x{H}", vr.rugosidad_freq);
60    println!("píxeles cubiertos (silueta base): {base_covered}");
61    println!();
62    println!(
63        "{:>6} │ {:>10} │ {:>9} │ {:>9} │ {:>9} │ {:>8} │ {:>8}",
64        "amp", "cobertura", "cov.mism", "cambió%", "Δlum.med", "Δlum.máx", "textura"
65    );
66    println!("{:─<7}┼{:─<12}┼{:─<11}┼{:─<11}┼{:─<11}┼{:─<10}┼{:─<10}", "", "", "", "", "", "", "");
67
68    // (amp, cov_mismatch_pct, mean_delta_vs_base, texture_energy)
69    let mut stats: Vec<(f32, f32, f32, f32)> = Vec::new();
70    for (amp, px) in &frames {
71        let cov = coverage(px);
72        let covered: usize = cov.iter().filter(|&&c| c).count();
73        // Silueta: nº de píxeles cuya cobertura difiere de la base (debe ser ~0).
74        let cov_mismatch = cov.iter().zip(&base_cov).filter(|(a, b)| a != b).count();
75
76        // Sobre los píxeles cubiertos EN AMBOS: cuántos cambiaron de sombreado y
77        // cuánto (vs la base plana). `Δlum.med` es 0 por construcción en amp=0 →
78        // aísla LIMPIAMENTE el efecto de la rugosidad (a diferencia de `textura`,
79        // que ya arranca alto por el grano de color horneado en el albedo).
80        let mut changed = 0usize;
81        let mut sum_d = 0u64;
82        let mut max_d = 0u32;
83        let mut both = 0usize;
84        for i in 0..(W * H) as usize {
85            if cov[i] && base_cov[i] {
86                both += 1;
87                let d = (lum(px, i) as i32 - lum(base, i) as i32).unsigned_abs();
88                if d > 2 {
89                    changed += 1;
90                }
91                sum_d += d as u64;
92                max_d = max_d.max(d);
93            }
94        }
95        let changed_pct = 100.0 * changed as f32 / both.max(1) as f32;
96        let mean_d = sum_d as f32 / both.max(1) as f32;
97        let cov_mism_pct = 100.0 * cov_mismatch as f32 / base_covered.max(1) as f32;
98        // "Energía de textura": |Δlum| medio entre píxeles vecinos ambos cubiertos.
99        let tex = texture_energy(px, &cov);
100
101        println!(
102            "{amp:>6.2} │ {covered:>10} │ {cov_mismatch:>9} │ {changed_pct:>8.2}% │ {mean_d:>9.3} │ {max_d:>8} │ {tex:>8.3}"
103        );
104        stats.push((*amp, cov_mism_pct, mean_d, tex));
105    }
106
107    // Verificación dura (asserts):
108    //  1. La silueta NO se mueve — la geometría es idéntica off/on (bump toca sólo
109    //     la normal de sombreado). Tolerancia mínima por bordes sin antialias.
110    //  2. El sombreado cambia de verdad en la mayoría de la superficie (Δlum vs base
111    //     >> 0), y ese cambio crece MONÓTONO con la amplitud (es la amplitud quien
112    //     lo maneja, no ruido). `Δlum.med` y `textura` deben ambos subir en cada paso.
113    println!();
114    let (_, mism_pct, mean_d_last, _) = *stats.last().unwrap();
115    println!("silueta: mismatch amp_max vs base = {mism_pct:.3}% de la cobertura");
116    println!(
117        "efecto (Δlum.med vs base): {}",
118        stats.iter().map(|s| format!("{:.2}", s.2)).collect::<Vec<_>>().join(" → ")
119    );
120    println!(
121        "textura (|Δlum| vecinos): {}",
122        stats.iter().map(|s| format!("{:.2}", s.3)).collect::<Vec<_>>().join(" → ")
123    );
124    assert!(mism_pct < 0.5, "la silueta cambió demasiado: {mism_pct:.3}% (la geometría NO debía moverse)");
125    assert!(mean_d_last > 3.0, "la rugosidad casi no cambió el sombreado (Δlum.med={mean_d_last:.3})");
126    for w in stats.windows(2) {
127        assert!(w[1].2 > w[0].2, "Δlum.med no crece con la amplitud: {:.3} → {:.3}", w[0].2, w[1].2);
128        assert!(w[1].3 > w[0].3, "textura no crece con la amplitud: {:.3} → {:.3}", w[0].3, w[1].3);
129    }
130
131    // PNGs comparativos (último recurso, opcional de mirar).
132    let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into());
133    write_png(&frames[0].1, &format!("{dir}/rugosidad_off.png"));
134    write_png(&frames.last().unwrap().1, &format!("{dir}/rugosidad_on.png"));
135    println!();
136    println!("PNG comparativo: {dir}/rugosidad_off.png  vs  {dir}/rugosidad_on.png");
137    println!("✓ certificado: geometría intacta + sombreado ya no plano.");
138}
139
140/// Luminancia Rec.709 (byte) del píxel `i`.
141fn lum(px: &[u8], i: usize) -> u8 {
142    let o = i * 4;
143    (0.2126 * px[o] as f32 + 0.7152 * px[o + 1] as f32 + 0.0722 * px[o + 2] as f32) as u8
144}
145
146/// Máscara de cobertura: píxel != fondo (con tolerancia). El fondo es el color de
147/// limpieza; un voxel golpeado nunca cae exactamente ahí.
148fn coverage(px: &[u8]) -> Vec<bool> {
149    (0..(W * H) as usize)
150        .map(|i| {
151            let o = i * 4;
152            let dr = (px[o] as i32 - BG[0] as i32).abs();
153            let dg = (px[o + 1] as i32 - BG[1] as i32).abs();
154            let db = (px[o + 2] as i32 - BG[2] as i32).abs();
155            dr + dg + db > 12
156        })
157        .collect()
158}
159
160/// Energía de micro-textura: |Δlum| medio entre píxeles horizontalmente vecinos
161/// ambos cubiertos. Mide cuánto varía la superficie (plana ≈ 0).
162fn texture_energy(px: &[u8], cov: &[bool]) -> f32 {
163    let mut sum = 0u64;
164    let mut n = 0u64;
165    for y in 0..H as usize {
166        for x in 0..(W as usize - 1) {
167            let i = y * W as usize + x;
168            if cov[i] && cov[i + 1] {
169                sum += (lum(px, i) as i32 - lum(px, i + 1) as i32).unsigned_abs() as u64;
170                n += 1;
171            }
172        }
173    }
174    sum as f32 / n.max(1) as f32
175}
176
177fn render_pixels(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec<u8> {
178    let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
179        label: Some("inter"),
180        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
181        mip_level_count: 1,
182        sample_count: 1,
183        dimension: wgpu::TextureDimension::D2,
184        format: FMT,
185        usage: wgpu::TextureUsages::STORAGE_BINDING
186            | wgpu::TextureUsages::TEXTURE_BINDING
187            | wgpu::TextureUsages::RENDER_ATTACHMENT
188            | wgpu::TextureUsages::COPY_SRC,
189        view_formats: &[],
190    });
191    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
192
193    let base = vello::Scene::new();
194    renderer
195        .render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255))
196        .expect("render base");
197
198    let mut enc = hal
199        .device
200        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
201    vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera);
202    hal.queue.submit(std::iter::once(enc.finish()));
203    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
204
205    readback(hal, &inter)
206}
207
208fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
209    let unpadded = (W * 4) as usize;
210    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
211    let padded = unpadded.div_ceil(align) * align;
212    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
213        label: Some("readback"),
214        size: (padded * H as usize) as u64,
215        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
216        mapped_at_creation: false,
217    });
218    let mut enc = hal
219        .device
220        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
221    enc.copy_texture_to_buffer(
222        wgpu::TexelCopyTextureInfo {
223            texture: target,
224            mip_level: 0,
225            origin: wgpu::Origin3d::ZERO,
226            aspect: wgpu::TextureAspect::All,
227        },
228        wgpu::TexelCopyBufferInfo {
229            buffer: &buf,
230            layout: wgpu::TexelCopyBufferLayout {
231                offset: 0,
232                bytes_per_row: Some(padded as u32),
233                rows_per_image: Some(H),
234            },
235        },
236        wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
237    );
238    hal.queue.submit(std::iter::once(enc.finish()));
239    let slice = buf.slice(..);
240    let (tx, rx) = std::sync::mpsc::channel();
241    slice.map_async(wgpu::MapMode::Read, move |r| {
242        let _ = tx.send(r);
243    });
244    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
245    rx.recv().unwrap().unwrap();
246    let data = slice.get_mapped_range();
247    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
248    for row in 0..H as usize {
249        let s = row * padded;
250        pixels.extend_from_slice(&data[s..s + unpadded]);
251    }
252    drop(data);
253    buf.unmap();
254    pixels
255}
256
257fn write_png(pixels: &[u8], path: &str) {
258    let file = File::create(path).expect("png");
259    let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
260    enc.set_color(png::ColorType::Rgba);
261    enc.set_depth(png::BitDepth::Eight);
262    let mut w = enc.write_header().unwrap();
263    w.write_image_data(pixels).unwrap();
264}