//! Certificación visual de la **cara viva** del ser SDF ([`Gesto`]): párpados que //! bajan, pupilas que apuntan, cabeza que gira, boca que se abre — todo como decals //! recalculados por cuadro, sin tocar geometría ni assets. //! //! La última fila es el CUERPO: **aplastar y estirar** ([`Pose::squash`]), el //! principio de animación que hace que un muñeco que rebota parezca que pesa. //! //! Rinde UNA plancha de contacto (12 viñetas en un solo PNG: dos filas de cara en //! primer plano, una de cuerpo entero) e imprime, por viñeta, cuántos píxeles cambió //! respecto de la neutra: la evidencia numérica de que cada perilla mueve algo. //! //! `cargo run -p llimphi-voxel --example cara_viva_demo --release` 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}; use llimphi_voxel::{altura_ojos, ser_sdf_con, squash_del_rebote, Clip, Colores, EstiloSer, Gesto, Pose}; /// Viñeta (4 columnas × 3 filas → plancha de 1280×1080). const TW: u32 = 320; const TH: u32 = 360; const COLS: u32 = 4; const ROWS: u32 = 3; const W: u32 = TW * COLS; const H: u32 = TH * ROWS; const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; const BG: [u8; 3] = [22, 26, 36]; const DIM: u32 = 112; const FLOOR_H: u32 = 2; /// Alto del ser en voxels (misma referencia que el resto de los demos). const ALTO: f32 = 72.0; fn main() { let hal = pollster::block_on(Hal::new(None)).expect("hal"); let mut renderer = Renderer::new(&hal).expect("renderer"); let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into()); let col = Colores::nuevos([0.90, 0.72, 0.58], [0.22, 0.60, 0.72], [0.20, 0.24, 0.36]); let estilo = EstiloSer::amable(); // El ser vive parado en el centro de la grilla; el look-at se resuelve en ESAS // coordenadas (voxel `[0, dim]`), como en el studio. let pos = Vec3::new(DIM as f32 * 0.5, FLOOR_H as f32, DIM as f32 * 0.5); let ojos_y = altura_ojos(&estilo, ALTO); // Ojo de la cámara en coords de grilla (la cámara orbita en centrado; +half). let half = DIM as f32 * 0.5; // El ojo de la cámara en coords de GRILLA (la cámara orbita en centrado): es el // objetivo del caso "mirando a cámara", que es el uso real de la feature. let ojo_camara = camara().eye + Vec3::splat(half); let quieto = Pose::default(); let casos: Vec<(&str, Gesto, Pose, bool)> = vec![ ("neutro", Gesto::neutro(), quieto, false), ("medio parpadeo", Gesto::neutro().con_parpado(0.55), quieto, false), ("ojos cerrados", Gesto::neutro().con_parpado(0.0), quieto, false), ( "mirando a cámara", Gesto::neutro().mirar(ojo_camara, pos, 0.0, ojos_y), quieto, false, ), ( "mirando arriba", Gesto::neutro().mirar(pos + Vec3::new(0.0, ojos_y + 60.0, 30.0), pos, 0.0, ojos_y), quieto, false, ), ("boca abierta", Gesto::neutro().con_boca(1.0, 1.1), quieto, false), ("mueca", Gesto::neutro().con_boca(0.25, -1.0), quieto, false), // El caso "automático": la cara que sale sola de la fase del clip. ("vivo (festejo)", Gesto::vivo(2.6, Clip::Cheer), quieto, false), // --- Fila 3: el CUERPO deformado (cámara entera, no busto) --- ("cuerpo neutro", Gesto::neutro(), quieto, true), ( "aplastado", Gesto::neutro(), Pose { squash: 0.25, ..Pose::default() }, true, ), ( "estirado", Gesto::neutro(), Pose { squash: -0.25, ..Pose::default() }, true, ), ( "festejo (rebote)", Gesto::vivo(2.6, Clip::Cheer), Pose { squash: squash_del_rebote(2.6, 0.10), ..Clip::Cheer.pose(2.6) }, true, ), ]; // Piso damero (el mundo con el que compone y sobre el que sombrea). let mut grid = VoxelGrid::new([DIM, DIM, DIM]); for x in 0..DIM { for z in 0..DIM { for y in 0..FLOOR_H { let c = if (x / 8 + z / 8) % 2 == 0 { [148, 142, 132] } else { [118, 114, 108] }; grid.set(x, y, z, c); } } } println!("cara_viva_demo — {} viñetas, primer plano a la cara", casos.len()); let mut plancha = vec![0u8; (W * H * 4) as usize]; // Una base por encuadre: la cara neutra para las dos primeras filas, el cuerpo // neutro para la tercera (comparar entre encuadres distintos no diría nada). let mut base_cara: Option> = None; let mut base_cuerpo: Option> = None; for (i, (nombre, gesto, pose_i, cuerpo)) in casos.iter().enumerate() { let ser = ser_sdf_con(&estilo, pose_i, ALTO, col, gesto).posado([pos.x, pos.y, pos.z], 0.0); let px = render_cara(&hal, &mut renderer, &grid, ser, *cuerpo); let base = if *cuerpo { &mut base_cuerpo } else { &mut base_cara }; let (dif, es_base) = match base { None => { *base = Some(px.clone()); (0, true) } Some(b) => (diff(b, &px), false), }; println!( " {nombre:18} párpado={:.2} boca={:.2} squash={:+.3} ojos=[{:+.2},{:+.2}] cabeza=[{:+.2},{:+.2}] → Δ{dif} px vs neutro", gesto.parpado, gesto.boca, pose_i.squash, gesto.ojos[0], gesto.ojos[1], gesto.cabeza[0], gesto.cabeza[1] ); // Cada encuadre tiene su propia neutra; el resto tiene que moverse contra ella. if !es_base { assert!(dif > 60, "{nombre}: no cambió nada visible ({dif} px)"); } pegar(&mut plancha, &px, i as u32 % COLS, i as u32 / COLS); } let out = format!("{dir}/cara_viva.png"); write_png(&plancha, &out); println!("✓ plancha de contacto → {out}"); } /// Copia una viñeta `TW×TH` a su celda de la plancha. fn pegar(plancha: &mut [u8], tile: &[u8], cx: u32, cy: u32) { for y in 0..TH as usize { let src = y * TW as usize * 4; let dst = ((cy as usize * TH as usize + y) * W as usize + cx as usize * TW as usize) * 4; plancha[dst..dst + TW as usize * 4].copy_from_slice(&tile[src..src + TW as usize * 4]); } } /// Píxeles que difieren de forma perceptible entre dos viñetas. fn diff(a: &[u8], b: &[u8]) -> usize { (0..(TW * TH) as usize) .filter(|&i| { let o = i * 4; (a[o] as i32 - b[o] as i32).abs() + (a[o + 1] as i32 - b[o + 1] as i32).abs() + (a[o + 2] as i32 - b[o + 2] as i32).abs() > 24 }) .count() } /// Renderiza UNA viñeta: primer plano 3/4 a la cara del ser sobre el piso voxel. fn render_cara( hal: &Hal, renderer: &mut Renderer, grid: &VoxelGrid, ser: llimphi_3d::SdfSer, cuerpo_entero: bool, ) -> Vec { let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, grid); vr.sun_dir = normalize([0.4, 0.6, 0.7]); vr.sers.push(ser); let camera = if cuerpo_entero { camara_cuerpo() } else { camara() }; let inter = hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("inter"), size: wgpu::Extent3d { width: TW, height: TH, 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 view = inter.create_view(&wgpu::TextureViewDescriptor::default()); let base = vello::Scene::new(); renderer .render_to_view(hal, &base, &view, TW, TH, Color::from_rgba8(BG[0], BG[1], BG[2], 255)) .expect("base"); let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); vr.render(&hal.device, &hal.queue, &mut enc, &view, (TW, TH), &camera); hal.queue.submit(std::iter::once(enc.finish())); let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); readback(hal, &inter) } /// La cámara de TODAS las viñetas (una sola definición: el look-at de los casos /// "mirando a cámara" tiene que apuntar al MISMO ojo con el que se rinde). /// Coords CENTRADAS — el shader hace `ro = cam_eye + dim/2`. Plano medio corto /// (busto): a distancia de retrato la perspectiva no deforma la cara. fn camara() -> Camera3d { let ojos = altura_ojos(&EstiloSer::amable(), ALTO); let look = Vec3::new(0.0, FLOOR_H as f32 + ojos - DIM as f32 * 0.5, 0.0); Camera3d::orbit(look, 24_f32.to_radians(), 4_f32.to_radians(), ALTO * 1.15) } /// Plano entero: el cuerpo completo con aire arriba y abajo, para que aplastar y /// estirar se midan contra el encuadre (si la cámara siguiera a la cabeza, la /// deformación se vería como un zoom). fn camara_cuerpo() -> Camera3d { let look = Vec3::new(0.0, FLOOR_H as f32 + ALTO * 0.5 - DIM as f32 * 0.5, 0.0); Camera3d::orbit(look, 24_f32.to_radians(), 8_f32.to_radians(), ALTO * 2.3) } fn normalize(v: [f32; 3]) -> [f32; 3] { let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6); [v[0] / l, v[1] / l, v[2] / l] } fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec { let unpadded = (TW * 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 * TH 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(TH) } }, wgpu::Extent3d { width: TW, height: TH, 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((TW * TH * 4) as usize); for row in 0..TH 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(); }