//! Certificación headless del **detalle sub-voxel POR VOXEL** (cierra la limitación //! "HONESTA" de las capas 1/2/2b: rugosidad y bisel eran un look GLOBAL del ray-march //! → dos materiales en la misma escena no podían lucir distinto a la vez). //! //! Ahora cada voxel puede llevar su propio `(rugosidad, bisel)` (`VoxelGrid::set_detail` //! → atlas `dpool` paralelo al pool de color). El shader lo usa: rugosidad por voxel en //! el bump de sombreado, bisel por voxel en la mezcla facetado↔suave de la normal. Un //! voxel SIN autor (`[0,0]`, el caso común) cae al look global → retrocompat. //! //! Escena: DOS domos idénticos lado a lado en el MISMO frame. Se rinde dos veces: //! - **GLOBAL** (sin detalle por voxel; global rug=0, bisel=1): los dos domos son //! indistinguibles — el escalar global no puede diferenciarlos. //! - **POR VOXEL** (mismos globals): el domo izquierdo se marca `rug` alto + `bisel` //! 0 (RUGOSO + FACETADO); el derecho `rug` 0 + `bisel` 1 (LISO). En un solo frame //! el izquierdo queda áspero y con normales axiales, el derecho suave. //! //! Se certifica por STATS (regla 8), por mitad de pantalla (un domo por mitad): //! 1. **GLOBAL** → las dos mitades casi no difieren (Δno-axial y Δtextura chicos). //! 2. **POR VOXEL** → la mitad derecha es MUCHO más no-axial (lisa) que la izquierda //! (facetada), y la izquierda MUCHO más áspera (textura) que la derecha. //! 3. La divergencia POR VOXEL ≫ la GLOBAL (lo que el escalar global no lograba). //! //! `cargo run -p llimphi-3d --example detalle_por_voxel --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; 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(120); let hal = pollster::block_on(Hal::new(None)).expect("hal"); let mut renderer = Renderer::new(&hal).expect("renderer"); // Cámara de frente (yaw 0) → el eje X del mundo cae en el eje X de la pantalla: // el domo izquierdo queda a la izquierda, el derecho a la derecha. let d = dim as f32; let camera = Camera3d::orbit(Vec3::ZERO, 0_f32.to_radians(), 26_f32.to_radians(), d * 1.55); // Global fijo para AMBAS pasadas: sin rugosidad global, bisel global ON (deja que // el bisel POR VOXEL module la normal; con global rug=0 los domos "globales" son // lisos e iguales). let setup = |vr: &mut VoxelRenderer| { vr.rugosidad_amp = 0.0; vr.rugosidad_freq = 1.7; vr.bisel = 1.0; }; // --- Pasada GLOBAL: sin detalle por voxel --- let grid_global = two_domes(dim, false); let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid_global); setup(&mut vr); vr.debug_normals = true; let g_norm = render_pixels(&hal, &mut renderer, &mut vr, &camera); vr.debug_normals = false; let g_shade = render_pixels(&hal, &mut renderer, &mut vr, &camera); // --- Pasada POR VOXEL: detalle autorado por domo --- let grid_pv = two_domes(dim, true); let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid_pv); setup(&mut vr); vr.debug_normals = true; let p_norm = render_pixels(&hal, &mut renderer, &mut vr, &camera); vr.debug_normals = false; let p_shade = render_pixels(&hal, &mut renderer, &mut vr, &camera); let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into()); write_png(&g_shade, &format!("{dir}/detalle_global.png")); write_png(&p_shade, &format!("{dir}/detalle_por_voxel.png")); // Métricas por mitad (izq = domo rugoso/facetado, der = domo liso). let g = HalfStats::measure(&g_norm, &g_shade); let p = HalfStats::measure(&p_norm, &p_shade); println!("detalle_por_voxel — dim={dim}³, {W}x{H} (dos domos, un frame)"); println!(); println!("{:>10} │ {:>12} │ {:>12} │ {:>10} │ {:>10}", "pasada", "no-axial izq", "no-axial der", "text.izq", "text.der"); println!("{:─<11}┼{:─<14}┼{:─<14}┼{:─<12}┼{:─<12}", "", "", "", "", ""); println!("{:>10} │ {:>11.1}% │ {:>11.1}% │ {:>10.2} │ {:>10.2}", "GLOBAL", g.na_l, g.na_r, g.tex_l, g.tex_r); println!("{:>10} │ {:>11.1}% │ {:>11.1}% │ {:>10.2} │ {:>10.2}", "POR VOXEL", p.na_l, p.na_r, p.tex_l, p.tex_r); println!(); // Divergencias entre mitades (lo que el look global NO puede generar). let g_na_div = (g.na_l - g.na_r).abs(); let p_na_div = (p.na_r - p.na_l).abs(); let g_tex_div = (g.tex_l - g.tex_r).abs(); let p_tex_div = (p.tex_l - p.tex_r).abs(); println!("divergencia no-axial entre mitades: GLOBAL {g_na_div:.1}% → POR VOXEL {p_na_div:.1}%"); println!("divergencia textura entre mitades: GLOBAL {g_tex_div:.2} → POR VOXEL {p_tex_div:.2}"); println!("PNG: {dir}/detalle_global.png vs {dir}/detalle_por_voxel.png"); // 1. GLOBAL: las dos mitades son casi iguales (el escalar no diferencia materiales). assert!(g_na_div < 12.0, "GLOBAL: las mitades ya diferían en no-axial ({g_na_div:.1}%) — deberían ser casi iguales"); assert!(g_tex_div < 3.0, "GLOBAL: las mitades ya diferían en textura ({g_tex_div:.2}) — deberían ser casi iguales"); // 2. POR VOXEL: der (liso) mucho más no-axial que izq (facetado); izq mucho más // áspera (textura) que der. 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); 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); // 3. La divergencia POR VOXEL supera claramente a la GLOBAL en ambos ejes. 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}%)"); 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})"); println!(); println!("✓ certificado: dos materiales en UN frame con look sub-voxel distinto (izq rugoso+facetado, der liso)."); println!(" El look global no puede: sus dos mitades quedaron casi iguales."); } /// Dos domos (paraboloides) lado a lado + piso. Con `paint`, el domo izquierdo se marca /// RUGOSO + FACETADO (rug alto, bisel 0) y el derecho LISO (rug 0, bisel 1) por voxel. fn two_domes(dim: u32, paint: bool) -> VoxelGrid { let gx = dim; let gy = (dim as f32 * 0.78) as u32; let gz = dim; let mut g = VoxelGrid::new([gx, gy, gz]); let base = 2u32; let peak = gy as f32 * 0.72; let radius = gx as f32 * 0.20; // Centros de los dos domos (en X): izquierdo ~0.28, derecho ~0.72. let domos = [ (gx as f32 * 0.28, gz as f32 * 0.5, true), // izquierdo → rugoso/facetado (gx as f32 * 0.72, gz as f32 * 0.5, false), // derecho → liso ]; for z in 0..gz { for x in 0..gx { // Cada columna toma el domo más cercano (los domos no se solapan). let mut best_h = 0f32; let mut es_izq = true; for &(cx, cz, izq) in &domos { let dx = x as f32 - cx; let dz = z as f32 - cz; let r = (dx * dx + dz * dz).sqrt() / radius; let hill = (peak * (1.0 - r * r)).max(0.0); if hill > best_h { best_h = hill; es_izq = izq; } } let top = (base + best_h as u32).min(gy - 1); for y in 0..=top { let t = (y as f32 / gy as f32 * 160.0) as u8; g.set(x, y, z, [130 + t / 3, 120 + t / 4, 95]); // Detalle por voxel sólo en el volumen del domo (no el piso llano). if paint && best_h > 0.5 { if es_izq { g.set_detail(x, y, z, 1.4, 0.0); // rugoso + facetado } else { g.set_detail(x, y, z, 0.0, 1.0); // liso } } } } } g.reset_dirty(); g } /// Estadísticas por mitad de pantalla: `_l` = mitad izquierda (x < W/2), `_r` = derecha. struct HalfStats { na_l: f32, na_r: f32, tex_l: f32, tex_r: f32, } impl HalfStats { fn measure(norm: &[u8], shade: &[u8]) -> Self { let cov = coverage(shade); let (na_l, na_r) = non_axial_halves(norm, &cov); let (tex_l, tex_r) = texture_halves(shade, &cov); Self { na_l, na_r, tex_l, tex_r } } } fn lum(px: &[u8], i: usize) -> f32 { let o = i * 4; 0.2126 * px[o] as f32 + 0.7152 * px[o + 1] as f32 + 0.0722 * px[o + 2] as f32 } 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() } /// % de píxeles cubiertos NO axiales (normal diagnóstica con mayor componente < 0.94), /// separado por mitad izquierda/derecha de pantalla. fn non_axial_halves(px: &[u8], cov: &[bool]) -> (f32, f32) { let (mut cl, mut nl, mut cr, mut nr) = (0u64, 0u64, 0u64, 0u64); for y in 0..H as usize { for x in 0..W as usize { let i = y * W as usize + x; if !cov[i] { continue; } let o = i * 4; let nx = px[o] as f32 / 255.0 * 2.0 - 1.0; let ny = px[o + 1] as f32 / 255.0 * 2.0 - 1.0; let nz = px[o + 2] as f32 / 255.0 * 2.0 - 1.0; let m = nx.abs().max(ny.abs()).max(nz.abs()); let axial = m >= 0.94; if x < W as usize / 2 { cl += 1; if !axial { nl += 1; } } else { cr += 1; if !axial { nr += 1; } } } } (100.0 * nl as f32 / cl.max(1) as f32, 100.0 * nr as f32 / cr.max(1) as f32) } /// "Textura" = gradiente local medio de luminancia (|Δ derecha| + |Δ abajo|) sobre los /// píxeles cubiertos, por mitad. La rugosidad mete ruido de normal → ruido de sombreado /// → gradiente local alto; la superficie lisa lo tiene bajo. fn texture_halves(px: &[u8], cov: &[bool]) -> (f32, f32) { let (mut sl, mut cl, mut sr, mut cr) = (0f64, 0u64, 0f64, 0u64); for y in 0..H as usize - 1 { for x in 0..W as usize - 1 { let i = y * W as usize + x; if !cov[i] || !cov[i + 1] || !cov[i + W as usize] { continue; } let l = lum(px, i); let grad = (l - lum(px, i + 1)).abs() + (l - lum(px, i + W as usize)).abs(); if x < W as usize / 2 { sl += grad as f64; cl += 1; } else { sr += grad as f64; cr += 1; } } } ((sl / cl.max(1) as f64) as f32, (sr / cr.max(1) as f64) 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(); }