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