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