tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
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}