tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1//! Certificación visual de la **cara viva** del ser SDF ([`Gesto`]): párpados que
2//! bajan, pupilas que apuntan, cabeza que gira, boca que se abre — todo como decals
3//! recalculados por cuadro, sin tocar geometría ni assets.
4//!
5//! La última fila es el CUERPO: **aplastar y estirar** ([`Pose::squash`]), el
6//! principio de animación que hace que un muñeco que rebota parezca que pesa.
7//!
8//! Rinde UNA plancha de contacto (12 viñetas en un solo PNG: dos filas de cara en
9//! primer plano, una de cuerpo entero) e imprime, por viñeta, cuántos píxeles cambió
10//! respecto de la neutra: la evidencia numérica de que cada perilla mueve algo.
11//!
12//! `cargo run -p llimphi-voxel --example cara_viva_demo --release`
13
14use std::fs::File;
15use std::io::BufWriter;
16
17use llimphi_3d::glam::Vec3;
18use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer};
19use llimphi_hal::{wgpu, Hal};
20use llimphi_raster::peniko::Color;
21use llimphi_raster::{vello, Renderer};
22use llimphi_voxel::{altura_ojos, ser_sdf_con, squash_del_rebote, Clip, Colores, EstiloSer, Gesto, Pose};
23
24/// Viñeta (4 columnas × 3 filas → plancha de 1280×1080).
25const TW: u32 = 320;
26const TH: u32 = 360;
27const COLS: u32 = 4;
28const ROWS: u32 = 3;
29const W: u32 = TW * COLS;
30const H: u32 = TH * ROWS;
31const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
32const BG: [u8; 3] = [22, 26, 36];
33
34const DIM: u32 = 112;
35const FLOOR_H: u32 = 2;
36/// Alto del ser en voxels (misma referencia que el resto de los demos).
37const ALTO: f32 = 72.0;
38
39fn main() {
40 let hal = pollster::block_on(Hal::new(None)).expect("hal");
41 let mut renderer = Renderer::new(&hal).expect("renderer");
42 let dir = std::env::var("CLAUDE_JOB_DIR").map(|d| format!("{d}/tmp")).unwrap_or("/tmp".into());
43
44 let col = Colores::nuevos([0.90, 0.72, 0.58], [0.22, 0.60, 0.72], [0.20, 0.24, 0.36]);
45 let estilo = EstiloSer::amable();
46
47 // El ser vive parado en el centro de la grilla; el look-at se resuelve en ESAS
48 // coordenadas (voxel `[0, dim]`), como en el studio.
49 let pos = Vec3::new(DIM as f32 * 0.5, FLOOR_H as f32, DIM as f32 * 0.5);
50 let ojos_y = altura_ojos(&estilo, ALTO);
51 // Ojo de la cámara en coords de grilla (la cámara orbita en centrado; +half).
52 let half = DIM as f32 * 0.5;
53
54 // El ojo de la cámara en coords de GRILLA (la cámara orbita en centrado): es el
55 // objetivo del caso "mirando a cámara", que es el uso real de la feature.
56 let ojo_camara = camara().eye + Vec3::splat(half);
57
58 let quieto = Pose::default();
59 let casos: Vec<(&str, Gesto, Pose, bool)> = vec![
60 ("neutro", Gesto::neutro(), quieto, false),
61 ("medio parpadeo", Gesto::neutro().con_parpado(0.55), quieto, false),
62 ("ojos cerrados", Gesto::neutro().con_parpado(0.0), quieto, false),
63 (
64 "mirando a cámara",
65 Gesto::neutro().mirar(ojo_camara, pos, 0.0, ojos_y),
66 quieto,
67 false,
68 ),
69 (
70 "mirando arriba",
71 Gesto::neutro().mirar(pos + Vec3::new(0.0, ojos_y + 60.0, 30.0), pos, 0.0, ojos_y),
72 quieto,
73 false,
74 ),
75 ("boca abierta", Gesto::neutro().con_boca(1.0, 1.1), quieto, false),
76 ("mueca", Gesto::neutro().con_boca(0.25, -1.0), quieto, false),
77 // El caso "automático": la cara que sale sola de la fase del clip.
78 ("vivo (festejo)", Gesto::vivo(2.6, Clip::Cheer), quieto, false),
79 // --- Fila 3: el CUERPO deformado (cámara entera, no busto) ---
80 ("cuerpo neutro", Gesto::neutro(), quieto, true),
81 (
82 "aplastado",
83 Gesto::neutro(),
84 Pose { squash: 0.25, ..Pose::default() },
85 true,
86 ),
87 (
88 "estirado",
89 Gesto::neutro(),
90 Pose { squash: -0.25, ..Pose::default() },
91 true,
92 ),
93 (
94 "festejo (rebote)",
95 Gesto::vivo(2.6, Clip::Cheer),
96 Pose { squash: squash_del_rebote(2.6, 0.10), ..Clip::Cheer.pose(2.6) },
97 true,
98 ),
99 ];
100
101 // Piso damero (el mundo con el que compone y sobre el que sombrea).
102 let mut grid = VoxelGrid::new([DIM, DIM, DIM]);
103 for x in 0..DIM {
104 for z in 0..DIM {
105 for y in 0..FLOOR_H {
106 let c = if (x / 8 + z / 8) % 2 == 0 { [148, 142, 132] } else { [118, 114, 108] };
107 grid.set(x, y, z, c);
108 }
109 }
110 }
111
112 println!("cara_viva_demo — {} viñetas, primer plano a la cara", casos.len());
113 let mut plancha = vec![0u8; (W * H * 4) as usize];
114 // Una base por encuadre: la cara neutra para las dos primeras filas, el cuerpo
115 // neutro para la tercera (comparar entre encuadres distintos no diría nada).
116 let mut base_cara: Option<Vec<u8>> = None;
117 let mut base_cuerpo: Option<Vec<u8>> = None;
118 for (i, (nombre, gesto, pose_i, cuerpo)) in casos.iter().enumerate() {
119 let ser = ser_sdf_con(&estilo, pose_i, ALTO, col, gesto).posado([pos.x, pos.y, pos.z], 0.0);
120 let px = render_cara(&hal, &mut renderer, &grid, ser, *cuerpo);
121 let base = if *cuerpo { &mut base_cuerpo } else { &mut base_cara };
122 let (dif, es_base) = match base {
123 None => {
124 *base = Some(px.clone());
125 (0, true)
126 }
127 Some(b) => (diff(b, &px), false),
128 };
129 println!(
130 " {nombre:18} párpado={:.2} boca={:.2} squash={:+.3} ojos=[{:+.2},{:+.2}] cabeza=[{:+.2},{:+.2}] → Δ{dif} px vs neutro",
131 gesto.parpado, gesto.boca, pose_i.squash, gesto.ojos[0], gesto.ojos[1], gesto.cabeza[0], gesto.cabeza[1]
132 );
133 // Cada encuadre tiene su propia neutra; el resto tiene que moverse contra ella.
134 if !es_base {
135 assert!(dif > 60, "{nombre}: no cambió nada visible ({dif} px)");
136 }
137 pegar(&mut plancha, &px, i as u32 % COLS, i as u32 / COLS);
138 }
139 let out = format!("{dir}/cara_viva.png");
140 write_png(&plancha, &out);
141 println!("✓ plancha de contacto → {out}");
142}
143
144/// Copia una viñeta `TW×TH` a su celda de la plancha.
145fn pegar(plancha: &mut [u8], tile: &[u8], cx: u32, cy: u32) {
146 for y in 0..TH as usize {
147 let src = y * TW as usize * 4;
148 let dst = ((cy as usize * TH as usize + y) * W as usize + cx as usize * TW as usize) * 4;
149 plancha[dst..dst + TW as usize * 4].copy_from_slice(&tile[src..src + TW as usize * 4]);
150 }
151}
152
153/// Píxeles que difieren de forma perceptible entre dos viñetas.
154fn diff(a: &[u8], b: &[u8]) -> usize {
155 (0..(TW * TH) as usize)
156 .filter(|&i| {
157 let o = i * 4;
158 (a[o] as i32 - b[o] as i32).abs()
159 + (a[o + 1] as i32 - b[o + 1] as i32).abs()
160 + (a[o + 2] as i32 - b[o + 2] as i32).abs()
161 > 24
162 })
163 .count()
164}
165
166/// Renderiza UNA viñeta: primer plano 3/4 a la cara del ser sobre el piso voxel.
167fn render_cara(
168 hal: &Hal,
169 renderer: &mut Renderer,
170 grid: &VoxelGrid,
171 ser: llimphi_3d::SdfSer,
172 cuerpo_entero: bool,
173) -> Vec<u8> {
174 let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, grid);
175 vr.sun_dir = normalize([0.4, 0.6, 0.7]);
176 vr.sers.push(ser);
177
178 let camera = if cuerpo_entero { camara_cuerpo() } else { camara() };
179
180 let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
181 label: Some("inter"),
182 size: wgpu::Extent3d { width: TW, height: TH, depth_or_array_layers: 1 },
183 mip_level_count: 1,
184 sample_count: 1,
185 dimension: wgpu::TextureDimension::D2,
186 format: FMT,
187 usage: wgpu::TextureUsages::STORAGE_BINDING
188 | wgpu::TextureUsages::TEXTURE_BINDING
189 | wgpu::TextureUsages::RENDER_ATTACHMENT
190 | wgpu::TextureUsages::COPY_SRC,
191 view_formats: &[],
192 });
193 let view = inter.create_view(&wgpu::TextureViewDescriptor::default());
194 let base = vello::Scene::new();
195 renderer
196 .render_to_view(hal, &base, &view, TW, TH, Color::from_rgba8(BG[0], BG[1], BG[2], 255))
197 .expect("base");
198 let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
199 vr.render(&hal.device, &hal.queue, &mut enc, &view, (TW, TH), &camera);
200 hal.queue.submit(std::iter::once(enc.finish()));
201 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
202 readback(hal, &inter)
203}
204
205/// La cámara de TODAS las viñetas (una sola definición: el look-at de los casos
206/// "mirando a cámara" tiene que apuntar al MISMO ojo con el que se rinde).
207/// Coords CENTRADAS — el shader hace `ro = cam_eye + dim/2`. Plano medio corto
208/// (busto): a distancia de retrato la perspectiva no deforma la cara.
209fn camara() -> Camera3d {
210 let ojos = altura_ojos(&EstiloSer::amable(), ALTO);
211 let look = Vec3::new(0.0, FLOOR_H as f32 + ojos - DIM as f32 * 0.5, 0.0);
212 Camera3d::orbit(look, 24_f32.to_radians(), 4_f32.to_radians(), ALTO * 1.15)
213}
214
215/// Plano entero: el cuerpo completo con aire arriba y abajo, para que aplastar y
216/// estirar se midan contra el encuadre (si la cámara siguiera a la cabeza, la
217/// deformación se vería como un zoom).
218fn camara_cuerpo() -> Camera3d {
219 let look = Vec3::new(0.0, FLOOR_H as f32 + ALTO * 0.5 - DIM as f32 * 0.5, 0.0);
220 Camera3d::orbit(look, 24_f32.to_radians(), 8_f32.to_radians(), ALTO * 2.3)
221}
222
223fn normalize(v: [f32; 3]) -> [f32; 3] {
224 let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6);
225 [v[0] / l, v[1] / l, v[2] / l]
226}
227
228fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
229 let unpadded = (TW * 4) as usize;
230 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
231 let padded = unpadded.div_ceil(align) * align;
232 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
233 label: Some("readback"),
234 size: (padded * TH as usize) as u64,
235 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
236 mapped_at_creation: false,
237 });
238 let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
239 enc.copy_texture_to_buffer(
240 wgpu::TexelCopyTextureInfo { texture: target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
241 wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded as u32), rows_per_image: Some(TH) } },
242 wgpu::Extent3d { width: TW, height: TH, depth_or_array_layers: 1 },
243 );
244 hal.queue.submit(std::iter::once(enc.finish()));
245 let slice = buf.slice(..);
246 let (tx, rx) = std::sync::mpsc::channel();
247 slice.map_async(wgpu::MapMode::Read, move |r| {
248 let _ = tx.send(r);
249 });
250 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
251 rx.recv().unwrap().unwrap();
252 let data = slice.get_mapped_range();
253 let mut pixels = Vec::with_capacity((TW * TH * 4) as usize);
254 for row in 0..TH as usize {
255 let s = row * padded;
256 pixels.extend_from_slice(&data[s..s + unpadded]);
257 }
258 drop(data);
259 buf.unmap();
260 pixels
261}
262
263fn write_png(pixels: &[u8], path: &str) {
264 let file = File::create(path).expect("png");
265 let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
266 enc.set_color(png::ColorType::Rgba);
267 enc.set_depth(png::BitDepth::Eight);
268 let mut w = enc.write_header().unwrap();
269 w.write_image_data(pixels).unwrap();
270}