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//! **Gesto** — la cara viva del [`ser SDF`](crate::ser_sdf): el estado facial de UN
2//! cuadro (párpados, boca, pupilas, giro de cabeza).
3//!
4//! ## Por qué es dato por cuadro y no un asset
5//!
6//! En el ser SDF la cara son **decals** (primitivas que re-pintan el color de la
7//! superficie sin aportar geometría). Cambiar una expresión no es cambiar una malla ni
8//! un atlas de texturas: es mover, agrandar o agregar un puñado de decals antes de
9//! empacar el uniform. Por eso `Gesto` es un struct chico, `Copy`, puro y determinista
10//! — se calcula por cuadro y se tira.
11//!
12//! ## Las tres capas de la mirada
13//!
14//! 1. **Cabeza** (`cabeza`): giro yaw/pitch de los rasgos, sobre el cabeceo del clip.
15//! 2. **Pupilas** (`ojos`): desvío dentro del domo del ojo — la parte que "sigue" al
16//! objetivo cuando la cabeza ya llegó a su tope creíble.
17//! 3. **Párpados** (`parpado`): tapan el ojo; el parpadeo es lo que separa a un muñeco
18//! de algo que respira.
19//!
20//! [`Gesto::vivo`] da las tres gratis a partir de la fase del clip (parpadeo
21//! determinista + boca que reacciona al gesto + micro-sacadas de los ojos), y
22//! [`Gesto::mirar`] resuelve el look-at contra un punto de mundo — el mismo IK de
23//! cabeza que ya tenía el [`Actor`](crate::Actor) de cajas, ahora del lado SDF.
24
25use llimphi_3d::glam::Vec3;
26use serde::{Deserialize, Serialize};
27
28use crate::actor::Clip;
29
30/// Estado facial de un cuadro. `Default` = [`neutro`](Gesto::neutro): ojos abiertos,
31/// boca cerrada sonriendo, cabeza alineada al cuerpo (el ser tal cual se calibró).
32#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
33pub struct Gesto {
34 /// Apertura de párpados: `1` = abierto, `0` = cerrado.
35 pub parpado: f32,
36 /// Curvatura de la boca: `1` = sonrisa plena, `0` = línea recta, negativo = mueca.
37 pub sonrisa: f32,
38 /// Apertura de la boca `0..1` (hablar, festejar, sorpresa).
39 pub boca: f32,
40 /// Desvío de las pupilas dentro del ojo, `[x, y]` en `-1..1` (x+ = a su izquierda
41 /// en pantalla, y+ = arriba). Es lo que "sigue" al objetivo cuando la cabeza topa.
42 pub ojos: [f32; 2],
43 /// Giro extra de la cabeza `[yaw, pitch]` en radianes, SOBRE el `head_pitch` del
44 /// clip. Positivo en pitch = mirar abajo (misma convención que la pose).
45 pub cabeza: [f32; 2],
46}
47
48impl Default for Gesto {
49 fn default() -> Self {
50 Gesto::neutro()
51 }
52}
53
54/// Tope del giro de cabeza (rad): ±57° de yaw, ±23° de pitch. No es sólo anatomía —
55/// es el límite de la trampa: en el ser SDF el CRÁNEO no rota (su elipsoide tiene los
56/// semiejes alineados a ejes), rotan los rasgos sobre él. Mientras el giro es moderado
57/// eso se lee como una cabeza que gira; pasado ese punto los ojos se van trepando a la
58/// coronilla de un huevo quieto. Verificado a ojo (plancha `cara_viva_demo`): a 40° de
59/// cabeceo la cara se desarma. Lo que sobra lo absorben las pupilas, que es además
60/// como funciona una mirada de verdad.
61const YAW_MAX: f32 = 1.0;
62const PITCH_MAX: f32 = 0.40;
63
64impl Gesto {
65 /// Cara neutra: ojos abiertos, sonrisa plena, boca cerrada, cabeza alineada.
66 pub const fn neutro() -> Self {
67 Self {
68 parpado: 1.0,
69 sonrisa: 1.0,
70 boca: 0.0,
71 ojos: [0.0, 0.0],
72 cabeza: [0.0, 0.0],
73 }
74 }
75
76 /// **La cara viva sin que nadie la anime**: parpadeo determinista + boca que
77 /// reacciona al clip + micro-sacadas de la mirada, todo función de la `fase` del
78 /// clip (la misma que ya acumula el reproductor). Puro: la misma fase da la misma
79 /// cara, así que preview y export coinciden cuadro a cuadro.
80 pub fn vivo(fase: f32, clip: Clip) -> Self {
81 let mut g = Gesto::neutro();
82 g.parpado = parpadeo(fase);
83 // La boca reacciona al gesto: el festejo grita, el saludo habla, el resto calla.
84 let (boca, sonrisa) = match clip {
85 Clip::Cheer => (0.45 + 0.25 * (fase * 3.1).sin().abs(), 1.25),
86 Clip::Wave => (0.22 + 0.10 * (fase * 2.4).sin().abs(), 1.1),
87 Clip::Point => (0.14, 0.9),
88 Clip::Run => (0.10, 0.85),
89 Clip::Idle | Clip::Walk => (0.0, 1.0),
90 };
91 g.boca = boca;
92 g.sonrisa = sonrisa;
93 // Micro-sacadas: los ojos saltan a un punto nuevo cada ~5 unidades de fase y
94 // se quedan ahí (así se mueve un ojo de verdad: sacada + fijación, nunca deriva).
95 let k = (fase * 0.21).floor();
96 let h = hash01(k);
97 let h2 = hash01(k + 37.0);
98 g.ojos = [(h * 2.0 - 1.0) * 0.30, (h2 * 2.0 - 1.0) * 0.16];
99 g
100 }
101
102 /// **Look-at**: orienta cabeza y pupilas hacia `objetivo` (coords de mundo) para un
103 /// ser posado en `pos` con rumbo `yaw`, con los ojos a `altura_ojos` sobre sus pies
104 /// (lo da [`altura_ojos`](crate::ser_sdf::altura_ojos)).
105 ///
106 /// La cabeza gira hasta su tope creíble; el resto del ángulo lo absorben las
107 /// pupilas — por eso un ser al que le pasás la cámara *te mira* aunque esté de
108 /// perfil. Encadenable sobre [`vivo`](Self::vivo).
109 pub fn mirar(mut self, objetivo: Vec3, pos: Vec3, yaw: f32, altura_ojos: f32) -> Self {
110 let d = objetivo - (pos + Vec3::new(0.0, altura_ojos, 0.0));
111 if d.length_squared() < 1e-9 {
112 return self;
113 }
114 // A espacio LOCAL del ser (deshacer su yaw) — la misma inversa que hace el
115 // shader en `ser_local`.
116 let (sy, cy) = yaw.sin_cos();
117 let l = Vec3::new(cy * d.x - sy * d.z, d.y, sy * d.x + cy * d.z).normalize();
118 let yaw_q = l.x.atan2(l.z);
119 let pitch_q = (-l.y).asin();
120 let yaw_c = yaw_q.clamp(-YAW_MAX, YAW_MAX);
121 let pitch_c = pitch_q.clamp(-PITCH_MAX, PITCH_MAX);
122 self.cabeza = [yaw_c, pitch_c];
123 // Lo que la cabeza no alcanzó, más un adelanto de los ojos (el ojo llega
124 // ANTES que la cabeza: es lo que hace que la mirada se sienta intencional).
125 let sobra_x = (yaw_q - yaw_c) * 1.6 + yaw_c * 0.22;
126 let sobra_y = -((pitch_q - pitch_c) * 1.6 + pitch_c * 0.22);
127 self.ojos = [
128 (self.ojos[0] * 0.2 + sobra_x).clamp(-1.0, 1.0),
129 (self.ojos[1] * 0.2 + sobra_y).clamp(-1.0, 1.0),
130 ];
131 self
132 }
133
134 /// Fuerza los párpados (`1` abierto, `0` cerrado) — para dormir, guiñar o dirigir
135 /// un parpadeo a mano en vez del automático.
136 pub fn con_parpado(mut self, v: f32) -> Self {
137 self.parpado = v.clamp(0.0, 1.0);
138 self
139 }
140
141 /// Fuerza la boca: `apertura` `0..1` y `sonrisa` (`1` feliz, `0` recta, `-1` mueca).
142 pub fn con_boca(mut self, apertura: f32, sonrisa: f32) -> Self {
143 self.boca = apertura.clamp(0.0, 1.0);
144 self.sonrisa = sonrisa.clamp(-1.5, 1.5);
145 self
146 }
147}
148
149/// Apertura de párpados `0..1` por la fase: cerrado en una ventana corta (~6% del
150/// ciclo lento), triángulo de cierre/apertura. Mismo reloj que el
151/// [`Actor`](crate::Actor) de cajas → los dos caminos parpadean juntos.
152fn parpadeo(fase: f32) -> f32 {
153 let ph = fase * 0.33;
154 let f = ph - ph.floor();
155 if f > 0.94 {
156 let w = (f - 0.94) / 0.06;
157 (1.0 - (1.0 - (2.0 * w - 1.0).abs())).clamp(0.0, 1.0)
158 } else {
159 1.0
160 }
161}
162
163/// Hash determinista `→ [0,1)` (el clásico de shader): sacadas reproducibles sin `rand`.
164fn hash01(x: f32) -> f32 {
165 let v = (x * 12.9898).sin() * 43758.5453;
166 v - v.floor()
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn el_neutro_no_gesticula() {
175 let g = Gesto::default();
176 assert_eq!(g.parpado, 1.0);
177 assert_eq!(g.boca, 0.0);
178 assert_eq!(g.cabeza, [0.0, 0.0]);
179 }
180
181 #[test]
182 fn parpadea_en_algun_momento_del_ciclo() {
183 // Barrer una vuelta larga: tiene que haber al menos un cuadro casi cerrado
184 // y la mayoría abiertos (no es un ojo que tiembla).
185 let mut cerrados = 0;
186 let mut abiertos = 0;
187 for i in 0..600 {
188 let p = Gesto::vivo(i as f32 * 0.05, Clip::Idle).parpado;
189 if p < 0.15 {
190 cerrados += 1;
191 }
192 if p > 0.99 {
193 abiertos += 1;
194 }
195 }
196 assert!(cerrados > 0, "nunca parpadea");
197 assert!(abiertos > 500, "parpadea de más: {abiertos} abiertos de 600");
198 }
199
200 #[test]
201 fn el_festejo_abre_la_boca_y_el_quieto_no() {
202 assert!(Gesto::vivo(1.0, Clip::Cheer).boca > 0.4);
203 assert_eq!(Gesto::vivo(1.0, Clip::Idle).boca, 0.0);
204 }
205
206 #[test]
207 fn mirar_a_un_lado_gira_la_cabeza_hacia_ese_lado() {
208 let pos = Vec3::new(0.0, 0.0, 0.0);
209 // Ser mirando +Z; objetivo a su +X → yaw positivo (convención rot_y del kit).
210 let g = Gesto::neutro().mirar(Vec3::new(10.0, 1.6, 0.0), pos, 0.0, 1.6);
211 assert!(g.cabeza[0] > 0.5, "gira a +X: {:?}", g.cabeza);
212 let izq = Gesto::neutro().mirar(Vec3::new(-10.0, 1.6, 0.0), pos, 0.0, 1.6);
213 assert!(izq.cabeza[0] < -0.5, "gira a -X: {:?}", izq.cabeza);
214 }
215
216 #[test]
217 fn mirar_arriba_levanta_la_cara_y_mirar_atras_lo_absorben_las_pupilas() {
218 let pos = Vec3::ZERO;
219 let arriba = Gesto::neutro().mirar(Vec3::new(0.0, 20.0, 1.0), pos, 0.0, 1.6);
220 assert!(arriba.cabeza[1] < -0.3, "pitch negativo = mirar arriba: {:?}", arriba.cabeza);
221 // Objetivo DETRÁS: la cabeza topa en el límite y las pupilas se van al borde.
222 let atras = Gesto::neutro().mirar(Vec3::new(0.0, 1.6, -10.0), pos, 0.0, 1.6);
223 assert!((atras.cabeza[0].abs() - YAW_MAX).abs() < 1e-3, "cabeza al tope");
224 assert!(atras.ojos[0].abs() > 0.6, "las pupilas absorben el resto: {:?}", atras.ojos);
225 }
226
227 #[test]
228 fn el_yaw_del_ser_se_deshace_al_mirar() {
229 // Ser girado 90° (mirando +X) y objetivo justo enfrente suyo → cabeza recta.
230 let g = Gesto::neutro().mirar(
231 Vec3::new(10.0, 1.6, 0.0),
232 Vec3::ZERO,
233 std::f32::consts::FRAC_PI_2,
234 1.6,
235 );
236 assert!(g.cabeza[0].abs() < 1e-3, "objetivo al frente = sin giro: {:?}", g.cabeza);
237 }
238}