tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ main llimphi-3d/examples/detalle_por_voxel.rs Rust · 347 lines · 14.5 KB History Who touched what raw

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 **detalle sub-voxel POR VOXEL** (cierra la limitación
2//! "HONESTA" de las capas 1/2/2b: rugosidad y bisel eran un look GLOBAL del ray-march
3//! → dos materiales en la misma escena no podían lucir distinto a la vez).
4//!
5//! Ahora cada voxel puede llevar su propio `(rugosidad, bisel)` (`VoxelGrid::set_detail`
6//! → atlas `dpool` paralelo al pool de color). El shader lo usa: rugosidad por voxel en
7//! el bump de sombreado, bisel por voxel en la mezcla facetado↔suave de la normal. Un
8//! voxel SIN autor (`[0,0]`, el caso común) cae al look global → retrocompat.
9//!
10//! Escena: DOS domos idénticos lado a lado en el MISMO frame. Se rinde dos veces:
11//!   - **GLOBAL** (sin detalle por voxel; global rug=0, bisel=1): los dos domos son
12//!     indistinguibles — el escalar global no puede diferenciarlos.
13//!   - **POR VOXEL** (mismos globals): el domo izquierdo se marca `rug` alto + `bisel`
14//!     0 (RUGOSO + FACETADO); el derecho `rug` 0 + `bisel` 1 (LISO). En un solo frame
15//!     el izquierdo queda áspero y con normales axiales, el derecho suave.
16//!
17//! Se certifica por STATS (regla 8), por mitad de pantalla (un domo por mitad):
18//!   1. **GLOBAL** → las dos mitades casi no difieren (Δno-axial y Δtextura chicos).
19//!   2. **POR VOXEL** → la mitad derecha es MUCHO más no-axial (lisa) que la izquierda
20//!      (facetada), y la izquierda MUCHO más áspera (textura) que la derecha.
21//!   3. La divergencia POR VOXEL ≫ la GLOBAL (lo que el escalar global no lograba).
22//!
23//! `cargo run -p llimphi-3d --example detalle_por_voxel --release -- [dim]`
24
25use std::fs::File;
26use std::io::BufWriter;
27
28use llimphi_3d::glam::Vec3;
29use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer};
30use llimphi_hal::{wgpu, Hal};
31use llimphi_raster::peniko::Color;
32use llimphi_raster::{vello, Renderer};
33
34const W: u32 = 720;
35const H: u32 = 480;
36const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
37const BG: [u8; 3] = [18, 22, 32];
38
39fn main() {
40    let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(120);
41
42    let hal = pollster::block_on(Hal::new(None)).expect("hal");
43    let mut renderer = Renderer::new(&hal).expect("renderer");
44
45    // Cámara de frente (yaw 0) → el eje X del mundo cae en el eje X de la pantalla:
46    // el domo izquierdo queda a la izquierda, el derecho a la derecha.
47    let d = dim as f32;
48    let camera = Camera3d::orbit(Vec3::ZERO, 0_f32.to_radians(), 26_f32.to_radians(), d * 1.55);
49
50    // Global fijo para AMBAS pasadas: sin rugosidad global, bisel global ON (deja que
51    // el bisel POR VOXEL module la normal; con global rug=0 los domos "globales" son
52    // lisos e iguales).
53    let setup = |vr: &mut VoxelRenderer| {
54        vr.rugosidad_amp = 0.0;
55        vr.rugosidad_freq = 1.7;
56        vr.bisel = 1.0;
57    };
58
59    // --- Pasada GLOBAL: sin detalle por voxel ---
60    let grid_global = two_domes(dim, false);
61    let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid_global);
62    setup(&mut vr);
63    vr.debug_normals = true;
64    let g_norm = render_pixels(&hal, &mut renderer, &mut vr, &camera);
65    vr.debug_normals = false;
66    let g_shade = render_pixels(&hal, &mut renderer, &mut vr, &camera);
67
68    // --- Pasada POR VOXEL: detalle autorado por domo ---
69    let grid_pv = two_domes(dim, true);
70    let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid_pv);
71    setup(&mut vr);
72    vr.debug_normals = true;
73    let p_norm = render_pixels(&hal, &mut renderer, &mut vr, &camera);
74    vr.debug_normals = false;
75    let p_shade = render_pixels(&hal, &mut renderer, &mut vr, &camera);
76
77    let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into());
78    write_png(&g_shade, &format!("{dir}/detalle_global.png"));
79    write_png(&p_shade, &format!("{dir}/detalle_por_voxel.png"));
80
81    // Métricas por mitad (izq = domo rugoso/facetado, der = domo liso).
82    let g = HalfStats::measure(&g_norm, &g_shade);
83    let p = HalfStats::measure(&p_norm, &p_shade);
84
85    println!("detalle_por_voxel — dim={dim}³, {W}x{H} (dos domos, un frame)");
86    println!();
87    println!("{:>10} │ {:>12} │ {:>12} │ {:>10} │ {:>10}", "pasada", "no-axial izq", "no-axial der", "text.izq", "text.der");
88    println!("{:─<11}┼{:─<14}┼{:─<14}┼{:─<12}┼{:─<12}", "", "", "", "", "");
89    println!("{:>10} │ {:>11.1}% │ {:>11.1}% │ {:>10.2} │ {:>10.2}", "GLOBAL", g.na_l, g.na_r, g.tex_l, g.tex_r);
90    println!("{:>10} │ {:>11.1}% │ {:>11.1}% │ {:>10.2} │ {:>10.2}", "POR VOXEL", p.na_l, p.na_r, p.tex_l, p.tex_r);
91    println!();
92
93    // Divergencias entre mitades (lo que el look global NO puede generar).
94    let g_na_div = (g.na_l - g.na_r).abs();
95    let p_na_div = (p.na_r - p.na_l).abs();
96    let g_tex_div = (g.tex_l - g.tex_r).abs();
97    let p_tex_div = (p.tex_l - p.tex_r).abs();
98    println!("divergencia no-axial entre mitades:  GLOBAL {g_na_div:.1}%  →  POR VOXEL {p_na_div:.1}%");
99    println!("divergencia textura entre mitades:    GLOBAL {g_tex_div:.2}  →  POR VOXEL {p_tex_div:.2}");
100    println!("PNG: {dir}/detalle_global.png  vs  {dir}/detalle_por_voxel.png");
101
102    // 1. GLOBAL: las dos mitades son casi iguales (el escalar no diferencia materiales).
103    assert!(g_na_div < 12.0, "GLOBAL: las mitades ya diferían en no-axial ({g_na_div:.1}%) — deberían ser casi iguales");
104    assert!(g_tex_div < 3.0, "GLOBAL: las mitades ya diferían en textura ({g_tex_div:.2}) — deberían ser casi iguales");
105
106    // 2. POR VOXEL: der (liso) mucho más no-axial que izq (facetado); izq mucho más
107    //    áspera (textura) que der.
108    assert!(p.na_r > p.na_l + 20.0, "POR VOXEL: el domo derecho (bisel=1) no quedó más liso que el izquierdo (facetado): {:.1}% vs {:.1}%", p.na_r, p.na_l);
109    assert!(p.tex_l > p.tex_r + 4.0, "POR VOXEL: el domo izquierdo (rug alto) no quedó más áspero que el derecho (liso): {:.2} vs {:.2}", p.tex_l, p.tex_r);
110
111    // 3. La divergencia POR VOXEL supera claramente a la GLOBAL en ambos ejes.
112    assert!(p_na_div > g_na_div + 15.0, "la divergencia de bisel por voxel no supera a la global ({p_na_div:.1}% vs {g_na_div:.1}%)");
113    assert!(p_tex_div > g_tex_div + 4.0, "la divergencia de rugosidad por voxel no supera a la global ({p_tex_div:.2} vs {g_tex_div:.2})");
114
115    println!();
116    println!("✓ certificado: dos materiales en UN frame con look sub-voxel distinto (izq rugoso+facetado, der liso).");
117    println!("  El look global no puede: sus dos mitades quedaron casi iguales.");
118}
119
120/// Dos domos (paraboloides) lado a lado + piso. Con `paint`, el domo izquierdo se marca
121/// RUGOSO + FACETADO (rug alto, bisel 0) y el derecho LISO (rug 0, bisel 1) por voxel.
122fn two_domes(dim: u32, paint: bool) -> VoxelGrid {
123    let gx = dim;
124    let gy = (dim as f32 * 0.78) as u32;
125    let gz = dim;
126    let mut g = VoxelGrid::new([gx, gy, gz]);
127    let base = 2u32;
128    let peak = gy as f32 * 0.72;
129    let radius = gx as f32 * 0.20;
130    // Centros de los dos domos (en X): izquierdo ~0.28, derecho ~0.72.
131    let domos = [
132        (gx as f32 * 0.28, gz as f32 * 0.5, true),  // izquierdo → rugoso/facetado
133        (gx as f32 * 0.72, gz as f32 * 0.5, false), // derecho → liso
134    ];
135    for z in 0..gz {
136        for x in 0..gx {
137            // Cada columna toma el domo más cercano (los domos no se solapan).
138            let mut best_h = 0f32;
139            let mut es_izq = true;
140            for &(cx, cz, izq) in &domos {
141                let dx = x as f32 - cx;
142                let dz = z as f32 - cz;
143                let r = (dx * dx + dz * dz).sqrt() / radius;
144                let hill = (peak * (1.0 - r * r)).max(0.0);
145                if hill > best_h {
146                    best_h = hill;
147                    es_izq = izq;
148                }
149            }
150            let top = (base + best_h as u32).min(gy - 1);
151            for y in 0..=top {
152                let t = (y as f32 / gy as f32 * 160.0) as u8;
153                g.set(x, y, z, [130 + t / 3, 120 + t / 4, 95]);
154                // Detalle por voxel sólo en el volumen del domo (no el piso llano).
155                if paint && best_h > 0.5 {
156                    if es_izq {
157                        g.set_detail(x, y, z, 1.4, 0.0); // rugoso + facetado
158                    } else {
159                        g.set_detail(x, y, z, 0.0, 1.0); // liso
160                    }
161                }
162            }
163        }
164    }
165    g.reset_dirty();
166    g
167}
168
169/// Estadísticas por mitad de pantalla: `_l` = mitad izquierda (x < W/2), `_r` = derecha.
170struct HalfStats {
171    na_l: f32,
172    na_r: f32,
173    tex_l: f32,
174    tex_r: f32,
175}
176
177impl HalfStats {
178    fn measure(norm: &[u8], shade: &[u8]) -> Self {
179        let cov = coverage(shade);
180        let (na_l, na_r) = non_axial_halves(norm, &cov);
181        let (tex_l, tex_r) = texture_halves(shade, &cov);
182        Self { na_l, na_r, tex_l, tex_r }
183    }
184}
185
186fn lum(px: &[u8], i: usize) -> f32 {
187    let o = i * 4;
188    0.2126 * px[o] as f32 + 0.7152 * px[o + 1] as f32 + 0.0722 * px[o + 2] as f32
189}
190
191fn coverage(px: &[u8]) -> Vec<bool> {
192    (0..(W * H) as usize)
193        .map(|i| {
194            let o = i * 4;
195            let dr = (px[o] as i32 - BG[0] as i32).abs();
196            let dg = (px[o + 1] as i32 - BG[1] as i32).abs();
197            let db = (px[o + 2] as i32 - BG[2] as i32).abs();
198            dr + dg + db > 12
199        })
200        .collect()
201}
202
203/// % de píxeles cubiertos NO axiales (normal diagnóstica con mayor componente < 0.94),
204/// separado por mitad izquierda/derecha de pantalla.
205fn non_axial_halves(px: &[u8], cov: &[bool]) -> (f32, f32) {
206    let (mut cl, mut nl, mut cr, mut nr) = (0u64, 0u64, 0u64, 0u64);
207    for y in 0..H as usize {
208        for x in 0..W as usize {
209            let i = y * W as usize + x;
210            if !cov[i] {
211                continue;
212            }
213            let o = i * 4;
214            let nx = px[o] as f32 / 255.0 * 2.0 - 1.0;
215            let ny = px[o + 1] as f32 / 255.0 * 2.0 - 1.0;
216            let nz = px[o + 2] as f32 / 255.0 * 2.0 - 1.0;
217            let m = nx.abs().max(ny.abs()).max(nz.abs());
218            let axial = m >= 0.94;
219            if x < W as usize / 2 {
220                cl += 1;
221                if !axial {
222                    nl += 1;
223                }
224            } else {
225                cr += 1;
226                if !axial {
227                    nr += 1;
228                }
229            }
230        }
231    }
232    (100.0 * nl as f32 / cl.max(1) as f32, 100.0 * nr as f32 / cr.max(1) as f32)
233}
234
235/// "Textura" = gradiente local medio de luminancia (|Δ derecha| + |Δ abajo|) sobre los
236/// píxeles cubiertos, por mitad. La rugosidad mete ruido de normal → ruido de sombreado
237/// → gradiente local alto; la superficie lisa lo tiene bajo.
238fn texture_halves(px: &[u8], cov: &[bool]) -> (f32, f32) {
239    let (mut sl, mut cl, mut sr, mut cr) = (0f64, 0u64, 0f64, 0u64);
240    for y in 0..H as usize - 1 {
241        for x in 0..W as usize - 1 {
242            let i = y * W as usize + x;
243            if !cov[i] || !cov[i + 1] || !cov[i + W as usize] {
244                continue;
245            }
246            let l = lum(px, i);
247            let grad = (l - lum(px, i + 1)).abs() + (l - lum(px, i + W as usize)).abs();
248            if x < W as usize / 2 {
249                sl += grad as f64;
250                cl += 1;
251            } else {
252                sr += grad as f64;
253                cr += 1;
254            }
255        }
256    }
257    ((sl / cl.max(1) as f64) as f32, (sr / cr.max(1) as f64) as f32)
258}
259
260fn render_pixels(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec<u8> {
261    let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
262        label: Some("inter"),
263        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
264        mip_level_count: 1,
265        sample_count: 1,
266        dimension: wgpu::TextureDimension::D2,
267        format: FMT,
268        usage: wgpu::TextureUsages::STORAGE_BINDING
269            | wgpu::TextureUsages::TEXTURE_BINDING
270            | wgpu::TextureUsages::RENDER_ATTACHMENT
271            | wgpu::TextureUsages::COPY_SRC,
272        view_formats: &[],
273    });
274    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
275
276    let base = vello::Scene::new();
277    renderer
278        .render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255))
279        .expect("render base");
280
281    let mut enc = hal
282        .device
283        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
284    vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera);
285    hal.queue.submit(std::iter::once(enc.finish()));
286    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
287
288    readback(hal, &inter)
289}
290
291fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
292    let unpadded = (W * 4) as usize;
293    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
294    let padded = unpadded.div_ceil(align) * align;
295    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
296        label: Some("readback"),
297        size: (padded * H as usize) as u64,
298        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
299        mapped_at_creation: false,
300    });
301    let mut enc = hal
302        .device
303        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
304    enc.copy_texture_to_buffer(
305        wgpu::TexelCopyTextureInfo {
306            texture: target,
307            mip_level: 0,
308            origin: wgpu::Origin3d::ZERO,
309            aspect: wgpu::TextureAspect::All,
310        },
311        wgpu::TexelCopyBufferInfo {
312            buffer: &buf,
313            layout: wgpu::TexelCopyBufferLayout {
314                offset: 0,
315                bytes_per_row: Some(padded as u32),
316                rows_per_image: Some(H),
317            },
318        },
319        wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
320    );
321    hal.queue.submit(std::iter::once(enc.finish()));
322    let slice = buf.slice(..);
323    let (tx, rx) = std::sync::mpsc::channel();
324    slice.map_async(wgpu::MapMode::Read, move |r| {
325        let _ = tx.send(r);
326    });
327    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
328    rx.recv().unwrap().unwrap();
329    let data = slice.get_mapped_range();
330    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
331    for row in 0..H as usize {
332        let s = row * padded;
333        pixels.extend_from_slice(&data[s..s + unpadded]);
334    }
335    drop(data);
336    buf.unmap();
337    pixels
338}
339
340fn write_png(pixels: &[u8], path: &str) {
341    let file = File::create(path).expect("png");
342    let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
343    enc.set_color(png::ColorType::Rgba);
344    enc.set_depth(png::BitDepth::Eight);
345    let mut w = enc.write_header().unwrap();
346    w.write_image_data(pixels).unwrap();
347}