tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ main llimphi-voxel/examples/cara_viva_demo.rs Rust · 270 lines · 11.2 KB History Who touched what raw

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

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}