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-3d/examples/silueta_por_voxel.rs Rust · 289 siq'ikuna · 11.6 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//! Certificación headless de la **SILUETA sub-voxel POR MATERIAL** (cierra el gap
2//! "honesto" que quedaba del canal por voxel: `trace_smooth` trazaba la isosuperficie
3//! GLOBAL, así que el `bisel` por voxel sólo cambiaba la NORMAL de sombreado, no el
4//! CONTORNO — dos materiales no podían tener siluetas distintas en un frame).
5//!
6//! Con `VoxelRenderer::silueta_por_voxel`, el CAMPO mismo mezcla escalón↔trilineal
7//! según el `bisel` del material sólido cercano: un material duro (bisel 0) conserva la
8//! silueta axial/blocky, uno liso (bisel 1) la redondea. En el MISMO frame.
9//!
10//! Escena: DOS domos (paraboloides) lado a lado. El izquierdo se marca DURO (bisel 0
11//! por voxel), el derecho LISO (bisel 1 por voxel). Se rinde dos veces con el MISMO
12//! `bisel` global (1 → `trace_smooth` activo):
13//!   - **OFF** (`silueta_por_voxel=false`): el campo es trilineal para ambos → los dos
14//!     domos salen con silueta redondeada (idénticos en contorno).
15//!   - **ON**: el izquierdo se vuelve blocky (silueta en las caras del voxel), el
16//!     derecho sigue redondeado (su `s=1` cae al fast-path trilineal, byte-idéntico).
17//!
18//! Se certifica por STATS (regla 8): el cambio de COBERTURA (silueta) OFF→ON, por mitad
19//! de pantalla. La mitad IZQUIERDA (dura) mueve su contorno mucho; la DERECHA (lisa) no
20//! cambia (mismo campo trilineal) → la silueta por material es real y AISLADA.
21//!
22//! `cargo run -p llimphi-3d --example silueta_por_voxel --release -- [dim]`
23
24use std::fs::File;
25use std::io::BufWriter;
26
27use llimphi_3d::glam::Vec3;
28use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer};
29use llimphi_hal::{wgpu, Hal};
30use llimphi_raster::peniko::Color;
31use llimphi_raster::{vello, Renderer};
32
33const W: u32 = 720;
34const H: u32 = 480;
35const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
36const BG: [u8; 3] = [18, 22, 32];
37
38fn main() {
39    let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(120);
40
41    let hal = pollster::block_on(Hal::new(None)).expect("hal");
42    let mut renderer = Renderer::new(&hal).expect("renderer");
43
44    // Cámara de frente (yaw 0): X del mundo → X de pantalla (izq/der no se cruzan).
45    let d = dim as f32;
46    let camera = Camera3d::orbit(Vec3::ZERO, 0_f32.to_radians(), 26_f32.to_radians(), d * 1.6);
47
48    // Global fijo: bisel global ON (enciende trace_smooth = la isosuperficie que la
49    // silueta por voxel necesita); sin rugosidad (aislar la geometría, no el sombreado).
50    let setup = |vr: &mut VoxelRenderer| {
51        vr.rugosidad_amp = 0.0;
52        vr.bisel = 1.0;
53    };
54
55    // MISMA escena (izq duro / der liso, por voxel) en ambas pasadas; sólo cambia el flag.
56    let grid = two_domes(dim);
57    let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
58    setup(&mut vr);
59
60    vr.silueta_por_voxel = false;
61    let off = render_pixels(&hal, &mut renderer, &mut vr, &camera);
62    vr.silueta_por_voxel = true;
63    let on = render_pixels(&hal, &mut renderer, &mut vr, &camera);
64
65    let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into());
66    write_png(&off, &format!("{dir}/silueta_off.png"));
67    write_png(&on, &format!("{dir}/silueta_on.png"));
68
69    let cov_off = coverage(&off);
70    let cov_on = coverage(&on);
71
72    // Cobertura por mitad (cuántos píxeles pinta cada domo) y cuánto CAMBIÓ el contorno.
73    let (n_off_l, n_off_r) = count_halves(&cov_off);
74    let (n_on_l, n_on_r) = count_halves(&cov_on);
75    let (chg_l, chg_r) = changed_halves(&cov_off, &cov_on);
76    // Fracción del contorno que se movió, relativa a la cobertura del domo de esa mitad.
77    let frac_l = 100.0 * chg_l as f32 / n_off_l.max(1) as f32;
78    let frac_r = 100.0 * chg_r as f32 / n_off_r.max(1) as f32;
79
80    println!("silueta_por_voxel — dim={dim}³, {W}x{H} (izq DURO / der LISO, un frame)");
81    println!();
82    println!("{:>10} │ {:>12} │ {:>12}", "pasada", "cobertura izq", "cobertura der");
83    println!("{:─<11}┼{:─<14}┼{:─<14}", "", "", "");
84    println!("{:>10} │ {:>12} │ {:>12}", "OFF", n_off_l, n_off_r);
85    println!("{:>10} │ {:>12} │ {:>12}", "ON", n_on_l, n_on_r);
86    println!();
87    println!("píxeles de silueta que CAMBIARON OFF→ON:  izq {chg_l} ({frac_l:.1}%)   der {chg_r} ({frac_r:.1}%)");
88    println!("PNG: {dir}/silueta_off.png  vs  {dir}/silueta_on.png");
89    println!();
90
91    // El bisel sub-voxel mueve la silueta ~½ voxel: la banda que cambia es fina (fila de
92    // contorno), no un bloque. Lo contundente es la ASIMETRÍA y el aislamiento perfecto.
93
94    // 1. La mitad DERECHA (lisa) NO cambia: su `s=1` cae al fast-path trilineal → campo
95    //    byte-idéntico OFF/ON. Prueba que el efecto es por-material, no global.
96    assert!(frac_r < 0.3, "la mitad lisa cambió su silueta ({frac_r:.1}%) — debería ser ~0 (mismo campo trilineal)");
97
98    // 2. La mitad IZQUIERDA (dura) mueve el contorno: la silueta pasó de redondeada
99    //    (isosuperficie) a blocky (caras del voxel).
100    assert!(chg_l > 120, "la mitad dura casi no movió su silueta ({chg_l} px) — el flag no hizo efecto");
101
102    // 3. El efecto está AISLADO por material: la banda dura supera con creces a la lisa.
103    assert!(chg_l > chg_r * 8 + 60, "la silueta dura no se aisló de la lisa (izq {chg_l} px vs der {chg_r} px)");
104
105    println!("✓ certificado: en UN frame, el material duro conserva silueta blocky y el liso");
106    println!("  la redondea. El contorno se movió sólo en la mitad dura ({chg_l} px vs {chg_r} px, aislado).");
107}
108
109/// Dos CAJAS (prismas) lado a lado + piso. La izquierda se marca DURA (bisel 0 por
110/// voxel), la derecha LISA (bisel 1). Las aristas de una caja se biselan a lo largo de
111/// TODA su longitud → el bisel/silueta mueve el contorno en una banda alta (mucho más
112/// que un domo). El piso queda sin autor (look global).
113fn two_domes(dim: u32) -> VoxelGrid {
114    let gx = dim;
115    let gy = (dim as f32 * 0.78) as u32;
116    let gz = dim;
117    let mut g = VoxelGrid::new([gx, gy, gz]);
118    let base = 2u32;
119    let half = gx as f32 * 0.15; // semilado de la caja
120    let box_top = base + (gy as f32 * 0.66) as u32;
121    let cajas = [
122        (gx as f32 * 0.30, gz as f32 * 0.5, true),  // izquierda → dura
123        (gx as f32 * 0.70, gz as f32 * 0.5, false), // derecha → lisa
124    ];
125    for z in 0..gz {
126        for x in 0..gx {
127            let mut dentro = false;
128            let mut es_izq = true;
129            for &(cx, cz, izq) in &cajas {
130                if (x as f32 - cx).abs() <= half && (z as f32 - cz).abs() <= half {
131                    dentro = true;
132                    es_izq = izq;
133                    break;
134                }
135            }
136            if !dentro {
137                continue;
138            }
139            let top = box_top.min(gy - 1);
140            for y in 0..=top {
141                let t = (y as f32 / gy as f32 * 160.0) as u8;
142                g.set(x, y, z, [130 + t / 3, 120 + t / 4, 95]);
143                if es_izq {
144                    g.set_detail(x, y, z, 0.0, 0.0); // DURA: bisel 0 (autorado)
145                } else {
146                    g.set_detail(x, y, z, 0.0, 1.0); // LISA: bisel 1
147                }
148            }
149        }
150    }
151    g.reset_dirty();
152    g
153}
154
155fn coverage(px: &[u8]) -> Vec<bool> {
156    (0..(W * H) as usize)
157        .map(|i| {
158            let o = i * 4;
159            let dr = (px[o] as i32 - BG[0] as i32).abs();
160            let dg = (px[o + 1] as i32 - BG[1] as i32).abs();
161            let db = (px[o + 2] as i32 - BG[2] as i32).abs();
162            dr + dg + db > 12
163        })
164        .collect()
165}
166
167/// Píxeles cubiertos por mitad de pantalla (izq = x < W/2).
168fn count_halves(cov: &[bool]) -> (u64, u64) {
169    let (mut l, mut r) = (0u64, 0u64);
170    for y in 0..H as usize {
171        for x in 0..W as usize {
172            if cov[y * W as usize + x] {
173                if x < W as usize / 2 {
174                    l += 1;
175                } else {
176                    r += 1;
177                }
178            }
179        }
180    }
181    (l, r)
182}
183
184/// Píxeles cuya cobertura CAMBIÓ entre dos pasadas (silueta que se movió), por mitad.
185fn changed_halves(a: &[bool], b: &[bool]) -> (u64, u64) {
186    let (mut l, mut r) = (0u64, 0u64);
187    for y in 0..H as usize {
188        for x in 0..W as usize {
189            let i = y * W as usize + x;
190            if a[i] != b[i] {
191                if x < W as usize / 2 {
192                    l += 1;
193                } else {
194                    r += 1;
195                }
196            }
197        }
198    }
199    (l, r)
200}
201
202fn render_pixels(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec<u8> {
203    let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
204        label: Some("inter"),
205        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
206        mip_level_count: 1,
207        sample_count: 1,
208        dimension: wgpu::TextureDimension::D2,
209        format: FMT,
210        usage: wgpu::TextureUsages::STORAGE_BINDING
211            | wgpu::TextureUsages::TEXTURE_BINDING
212            | wgpu::TextureUsages::RENDER_ATTACHMENT
213            | wgpu::TextureUsages::COPY_SRC,
214        view_formats: &[],
215    });
216    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
217
218    let base = vello::Scene::new();
219    renderer
220        .render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255))
221        .expect("render base");
222
223    let mut enc = hal
224        .device
225        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
226    vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera);
227    hal.queue.submit(std::iter::once(enc.finish()));
228    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
229
230    readback(hal, &inter)
231}
232
233fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
234    let unpadded = (W * 4) as usize;
235    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
236    let padded = unpadded.div_ceil(align) * align;
237    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
238        label: Some("readback"),
239        size: (padded * H as usize) as u64,
240        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
241        mapped_at_creation: false,
242    });
243    let mut enc = hal
244        .device
245        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
246    enc.copy_texture_to_buffer(
247        wgpu::TexelCopyTextureInfo {
248            texture: target,
249            mip_level: 0,
250            origin: wgpu::Origin3d::ZERO,
251            aspect: wgpu::TextureAspect::All,
252        },
253        wgpu::TexelCopyBufferInfo {
254            buffer: &buf,
255            layout: wgpu::TexelCopyBufferLayout {
256                offset: 0,
257                bytes_per_row: Some(padded as u32),
258                rows_per_image: Some(H),
259            },
260        },
261        wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
262    );
263    hal.queue.submit(std::iter::once(enc.finish()));
264    let slice = buf.slice(..);
265    let (tx, rx) = std::sync::mpsc::channel();
266    slice.map_async(wgpu::MapMode::Read, move |r| {
267        let _ = tx.send(r);
268    });
269    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
270    rx.recv().unwrap().unwrap();
271    let data = slice.get_mapped_range();
272    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
273    for row in 0..H as usize {
274        let s = row * padded;
275        pixels.extend_from_slice(&data[s..s + unpadded]);
276    }
277    drop(data);
278    buf.unmap();
279    pixels
280}
281
282fn write_png(pixels: &[u8], path: &str) {
283    let file = File::create(path).expect("png");
284    let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
285    enc.set_color(png::ColorType::Rgba);
286    enc.set_depth(png::BitDepth::Eight);
287    let mut w = enc.write_header().unwrap();
288    w.write_image_data(pixels).unwrap();
289}