//! Certificación headless del **spike de rugosidad sub-voxel** (bump procedural //! en el ray-march). Prueba, por STATS numéricas (regla 8), la tesis del diseño: //! //! 1. **La geometría/silueta NO cambia** — el conjunto de píxeles cubiertos es //! idéntico con rugosidad off y on (la perturbación toca sólo la normal de //! SOMBREADO, no el `t`/posición del hit). → `coverage_mismatch ≈ 0`. //! 2. **El sombreado deja de ser plano** — las caras axiales, que off producen //! poquísimos niveles de luminancia (constante + gradiente de AO), on ganan //! una micro-variación continua. → sube la "energía de textura" (|Δlum| entre //! píxeles vecinos) y el nº de luminancias distintas, monótono con la amplitud. //! //! Corre un barrido de amplitud contra la línea base (amp=0) e imprime la tabla. //! Emite además `off.png` / `on.png` por si se quiere MIRAR una comparación (último //! recurso, no la evidencia primaria). //! //! `cargo run -p llimphi-3d --example rugosidad_demo --release -- [dim]` use std::fs::File; use std::io::BufWriter; use llimphi_3d::glam::Vec3; use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer}; use llimphi_hal::{wgpu, Hal}; use llimphi_raster::peniko::Color; use llimphi_raster::{vello, Renderer}; const W: u32 = 720; const H: u32 = 480; const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; /// Color de limpieza del fondo (los misses hacen `discard` → queda este color). const BG: [u8; 3] = [18, 22, 32]; fn main() { let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(96); let d = dim as f32; let hal = pollster::block_on(Hal::new(None)).expect("hal"); let mut renderer = Renderer::new(&hal).expect("renderer"); // Escena de fábrica: piso plano + esfera + pilares. El piso es la superficie // ideal para exhibir rugosidad (cara superior grande y axial). let grid = VoxelGrid::demo_scene([dim, dim, dim]); let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid); vr.rugosidad_freq = 1.6; let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 24_f32.to_radians(), d * 1.7); let amps = [0.0_f32, 0.4, 0.8, 1.2]; let mut frames: Vec<(f32, Vec)> = Vec::new(); for & in &s { vr.rugosidad_amp = amp; frames.push((amp, render_pixels(&hal, &mut renderer, &mut vr, &camera))); } let base = &frames[0].1; let base_cov = coverage(base); let base_covered: usize = base_cov.iter().filter(|&&c| c).count(); println!("rugosidad_demo — dim={dim}³, freq={:.2}, {W}x{H}", vr.rugosidad_freq); println!("píxeles cubiertos (silueta base): {base_covered}"); println!(); println!( "{:>6} │ {:>10} │ {:>9} │ {:>9} │ {:>9} │ {:>8} │ {:>8}", "amp", "cobertura", "cov.mism", "cambió%", "Δlum.med", "Δlum.máx", "textura" ); println!("{:─<7}┼{:─<12}┼{:─<11}┼{:─<11}┼{:─<11}┼{:─<10}┼{:─<10}", "", "", "", "", "", "", ""); // (amp, cov_mismatch_pct, mean_delta_vs_base, texture_energy) let mut stats: Vec<(f32, f32, f32, f32)> = Vec::new(); for (amp, px) in &frames { let cov = coverage(px); let covered: usize = cov.iter().filter(|&&c| c).count(); // Silueta: nº de píxeles cuya cobertura difiere de la base (debe ser ~0). let cov_mismatch = cov.iter().zip(&base_cov).filter(|(a, b)| a != b).count(); // Sobre los píxeles cubiertos EN AMBOS: cuántos cambiaron de sombreado y // cuánto (vs la base plana). `Δlum.med` es 0 por construcción en amp=0 → // aísla LIMPIAMENTE el efecto de la rugosidad (a diferencia de `textura`, // que ya arranca alto por el grano de color horneado en el albedo). let mut changed = 0usize; let mut sum_d = 0u64; let mut max_d = 0u32; let mut both = 0usize; for i in 0..(W * H) as usize { if cov[i] && base_cov[i] { both += 1; let d = (lum(px, i) as i32 - lum(base, i) as i32).unsigned_abs(); if d > 2 { changed += 1; } sum_d += d as u64; max_d = max_d.max(d); } } let changed_pct = 100.0 * changed as f32 / both.max(1) as f32; let mean_d = sum_d as f32 / both.max(1) as f32; let cov_mism_pct = 100.0 * cov_mismatch as f32 / base_covered.max(1) as f32; // "Energía de textura": |Δlum| medio entre píxeles vecinos ambos cubiertos. let tex = texture_energy(px, &cov); println!( "{amp:>6.2} │ {covered:>10} │ {cov_mismatch:>9} │ {changed_pct:>8.2}% │ {mean_d:>9.3} │ {max_d:>8} │ {tex:>8.3}" ); stats.push((*amp, cov_mism_pct, mean_d, tex)); } // Verificación dura (asserts): // 1. La silueta NO se mueve — la geometría es idéntica off/on (bump toca sólo // la normal de sombreado). Tolerancia mínima por bordes sin antialias. // 2. El sombreado cambia de verdad en la mayoría de la superficie (Δlum vs base // >> 0), y ese cambio crece MONÓTONO con la amplitud (es la amplitud quien // lo maneja, no ruido). `Δlum.med` y `textura` deben ambos subir en cada paso. println!(); let (_, mism_pct, mean_d_last, _) = *stats.last().unwrap(); println!("silueta: mismatch amp_max vs base = {mism_pct:.3}% de la cobertura"); println!( "efecto (Δlum.med vs base): {}", stats.iter().map(|s| format!("{:.2}", s.2)).collect::>().join(" → ") ); println!( "textura (|Δlum| vecinos): {}", stats.iter().map(|s| format!("{:.2}", s.3)).collect::>().join(" → ") ); assert!(mism_pct < 0.5, "la silueta cambió demasiado: {mism_pct:.3}% (la geometría NO debía moverse)"); assert!(mean_d_last > 3.0, "la rugosidad casi no cambió el sombreado (Δlum.med={mean_d_last:.3})"); for w in stats.windows(2) { assert!(w[1].2 > w[0].2, "Δlum.med no crece con la amplitud: {:.3} → {:.3}", w[0].2, w[1].2); assert!(w[1].3 > w[0].3, "textura no crece con la amplitud: {:.3} → {:.3}", w[0].3, w[1].3); } // PNGs comparativos (último recurso, opcional de mirar). let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into()); write_png(&frames[0].1, &format!("{dir}/rugosidad_off.png")); write_png(&frames.last().unwrap().1, &format!("{dir}/rugosidad_on.png")); println!(); println!("PNG comparativo: {dir}/rugosidad_off.png vs {dir}/rugosidad_on.png"); println!("✓ certificado: geometría intacta + sombreado ya no plano."); } /// Luminancia Rec.709 (byte) del píxel `i`. fn lum(px: &[u8], i: usize) -> u8 { let o = i * 4; (0.2126 * px[o] as f32 + 0.7152 * px[o + 1] as f32 + 0.0722 * px[o + 2] as f32) as u8 } /// Máscara de cobertura: píxel != fondo (con tolerancia). El fondo es el color de /// limpieza; un voxel golpeado nunca cae exactamente ahí. fn coverage(px: &[u8]) -> Vec { (0..(W * H) as usize) .map(|i| { let o = i * 4; let dr = (px[o] as i32 - BG[0] as i32).abs(); let dg = (px[o + 1] as i32 - BG[1] as i32).abs(); let db = (px[o + 2] as i32 - BG[2] as i32).abs(); dr + dg + db > 12 }) .collect() } /// Energía de micro-textura: |Δlum| medio entre píxeles horizontalmente vecinos /// ambos cubiertos. Mide cuánto varía la superficie (plana ≈ 0). fn texture_energy(px: &[u8], cov: &[bool]) -> f32 { let mut sum = 0u64; let mut n = 0u64; for y in 0..H as usize { for x in 0..(W as usize - 1) { let i = y * W as usize + x; if cov[i] && cov[i + 1] { sum += (lum(px, i) as i32 - lum(px, i + 1) as i32).unsigned_abs() as u64; n += 1; } } } sum as f32 / n.max(1) as f32 } fn render_pixels(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec { let inter = hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("inter"), size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: FMT, usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default()); let base = vello::Scene::new(); renderer .render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(BG[0], BG[1], BG[2], 255)) .expect("render base"); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") }); vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera); hal.queue.submit(std::iter::once(enc.finish())); let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); readback(hal, &inter) } fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec { let unpadded = (W * 4) as usize; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; let padded = unpadded.div_ceil(align) * align; let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { label: Some("readback"), size: (padded * H as usize) as u64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); enc.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded as u32), rows_per_image: Some(H), }, }, wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, ); hal.queue.submit(std::iter::once(enc.finish())); let slice = buf.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); rx.recv().unwrap().unwrap(); let data = slice.get_mapped_range(); let mut pixels = Vec::with_capacity((W * H * 4) as usize); for row in 0..H as usize { let s = row * padded; pixels.extend_from_slice(&data[s..s + unpadded]); } drop(data); buf.unmap(); pixels } fn write_png(pixels: &[u8], path: &str) { let file = File::create(path).expect("png"); let mut enc = png::Encoder::new(BufWriter::new(file), W, H); enc.set_color(png::ColorType::Rgba); enc.set_depth(png::BitDepth::Eight); let mut w = enc.write_header().unwrap(); w.write_image_data(pixels).unwrap(); }