//! **Ser SDF** — el mismo diseño de personaje de [`humano`](crate::humano) //! (Kindchenschema, canon de cabezas, shape language) emitido como **ensamble de //! primitivas analíticas** ([`SdfSer`]) en vez de voxelizado: el motor lo traza por //! *sphere tracing* con unión suave (`llimphi_3d::ser`) → silueta matemáticamente //! lisa, normales exactas, sin anillos ni bultos de reconstrucción. //! //! ## Relación con `humanoide_voxel` //! //! MISMAS proporciones y perillas ([`EstiloSer`]), MISMA [`Pose`] (el catálogo de //! [`Clip`](crate::Clip) anima a los dos): este es el camino **hero/cerca** (la //! personalidad importa) y el voxel queda como LOD lejano o para cuando el ser deba //! ser materia editable del mundo. //! //! ## Cara = decals 2D sobre la cabeza 3D //! //! Ojos (esclera + pupila + chispa), sonrisa y **rubor** van como primitivas //! `Decal*`: re-pintan el color de la superficie sin geometría — el truco de //! *Animal Crossing*/*Wind Waker*. Expresión/parpadeo = regenerar los decals //! (son datos por frame, no assets). //! //! El ser se construye en espacio LOCAL (pies en `y≈0`, centrado en X/Z, mirando //! `+Z`); se posa en el mundo con [`SdfSer::posado`] (coords de voxel + yaw). use llimphi_3d::glam::Vec3; use llimphi_3d::{SdfKind, SdfPrim, SdfSer}; use serde::{Deserialize, Serialize}; use crate::actor::Pose; use crate::gesto::Gesto; use crate::humano::{rot_x, rot_y, rot_z, Colores, EstiloSer}; #[inline] fn v3(v: Vec3) -> [f32; 3] { [v.x, v.y, v.z] } /// Alto (desde los pies) al que quedan los OJOS de un ser de `altura` con `estilo` — /// el punto desde el que se mide un look-at. Calco de las proporciones de /// [`ser_sdf`] (centro de cabeza + altura del ojo en la cara). pub fn altura_ojos(estilo: &EstiloSer, altura: f32) -> f32 { let h = altura.max(0.5); let cabezas = estilo.cabezas.clamp(1.6, 8.0); let head_r = h / (2.0 * cabezas); let head_ry = head_r * (1.0 + 0.2 * (estilo.craneo - 1.0)).clamp(0.94, 1.06); h - head_ry * 1.02 + estilo.ojo_altura * head_r } /// **Construye el ser SDF** de `altura` voxels de alto (referencia), con el `estilo` /// dado, en la `pose` dada, coloreado por `col`, con la cara NEUTRA. Pies en `y≈0`, /// centrado en X/Z, mirando a `+Z`. Puro y determinista; el render lo hace /// `VoxelRenderer.sers`. Para que la cara viva (parpadeo, boca, mirada) usá /// [`ser_sdf_con`] con un [`Gesto`]. pub fn ser_sdf(estilo: &EstiloSer, pose: &Pose, altura: f32, col: Colores) -> SdfSer { ser_sdf_con(estilo, pose, altura, col, &Gesto::neutro()) } /// **Nivel de detalle de la cara.** El cuerpo se traza igual en los tres (es la /// silueta, y es analítica: no hay nada que simplificar); lo que se cae con la /// distancia son los rasgos, que a pocos píxeles de cabeza no se distinguen pero /// siguen costando primitivas del uniform — y las primitivas son el recurso escaso /// (240 para todo el reparto). Ver [`reparto_sdf`](crate::reparto_sdf). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DetalleSer { /// Primer plano: cara completa (domos, pupilas, chispas, párpados, rubor, boca). /// ~22 primitivas (30 parpadeando). Hero, /// Plano medio: sin chispa de brillo, sin rubor y con la boca en 2 tramos. /// ~16 primitivas. Medio, /// Fondo: sin cara — sólo el cuerpo y dos puntos oscuros por ojos. 12 primitivas. Lejos, } impl DetalleSer { /// Detalle que le toca a un ser de `altura` visto desde `dist`. Los cortes están /// en **tamaño angular**, no en distancia absoluta: lo que importa es cuántos /// píxeles mide la cabeza, y eso es la razón `dist/altura`. pub fn por_distancia(dist: f32, altura: f32) -> Self { let r = dist / altura.max(1e-3); if r < 6.0 { DetalleSer::Hero } else if r < 16.0 { DetalleSer::Medio } else { DetalleSer::Lejos } } /// Cuántas primitivas cuesta un ser a este detalle (cota superior, con párpados /// y boca abierta) — lo usa el reparto para no pasarse del presupuesto. pub fn costo(self) -> usize { match self { DetalleSer::Hero => 31, DetalleSer::Medio => 23, DetalleSer::Lejos => 12, } } } /// Igual que [`ser_sdf`] pero con la cara del cuadro: párpados, boca, pupilas y giro /// de cabeza salen del [`Gesto`] (dato por cuadro, no asset — la cara son decals). pub fn ser_sdf_con( estilo: &EstiloSer, pose: &Pose, altura: f32, col: Colores, gesto: &Gesto, ) -> SdfSer { ser_sdf_lod(estilo, pose, altura, col, gesto, DetalleSer::Hero) } /// Igual que [`ser_sdf_con`] pero eligiendo el [`DetalleSer`] de la cara. pub fn ser_sdf_lod( estilo: &EstiloSer, pose: &Pose, altura: f32, col: Colores, gesto: &Gesto, detalle: DetalleSer, ) -> SdfSer { ser_sdf_atuendo(estilo, pose, altura, col, gesto, detalle, &Atuendo::default()) } /// **Dónde está la mano derecha** del ser en esta pose, en su espacio LOCAL (pies en /// el origen, mirando `+Z`, sin posar): el centro de la esfera de piel que /// [`ser_sdf_atuendo`] dibuja al final del brazo `arm_r`. Es lo que necesita quien /// le pone algo en la mano —la estrella de la princesa— para que lo lleve de /// verdad cuando saluda o señala, y no flotando al costado. /// /// Repite la cuenta del brazo de [`ser_sdf_atuendo`] en vez de compartirla: la de /// allá vive entre treinta proporciones que sólo usa el cuerpo. Las ata el test /// `la_mano_derecha_es_la_esfera_que_se_dibuja`, en cada clip y en cada edad. pub fn mano_derecha(estilo: &EstiloSer, pose: &Pose, altura: f32) -> Vec3 { let h = altura.max(0.5); let cabezas = estilo.cabezas.clamp(1.6, 8.0); let head_r = h / (2.0 * cabezas); let head_ry = head_r * (1.0 + 0.2 * (estilo.craneo - 1.0)).clamp(0.94, 1.06); let head_cy = h - head_ry * 1.02; let neck = head_r * (0.14 + 0.13 * (cabezas - 2.6).clamp(0.0, 2.2)); let shoulder_y = (head_cy - head_ry - neck).max(head_r); let leg_frac = (0.28 + 0.055 * (cabezas - 2.0)).clamp(0.28, 0.52); let hip_y = shoulder_y * leg_frac; let torso_rx = h * (0.13 + 0.05 * estilo.redondez); let limb_r = h * estilo.extremidad; let ang = estilo.angulosidad.clamp(0.0, 1.0); let chest_r = torso_rx * (0.62 + 0.30 * ang); let belly_r = torso_rx * (0.72 + 0.28 * estilo.panza.clamp(0.8, 1.4)) * (1.0 - 0.22 * ang); let shoulder_x = chest_r + limb_r * (0.45 + 0.55 * ang); let arm_len = (shoulder_y - hip_y) * 1.02; let bob = pose.bob * h * 0.5; let rel = Vec3::new(shoulder_x, shoulder_y - hip_y, limb_r * 0.3); let r = rot_x(rel, pose.lean); let shoulder = Vec3::new(r.x, hip_y + r.y + bob, r.z); let base_out = 0.16 + ((belly_r / chest_r - 1.0) * 0.30).clamp(0.0, 0.24); let mut dir = rot_x(Vec3::new(0.0, -arm_len, 0.0), pose.arm_r); dir = rot_z(dir, pose.arm_r_out + base_out); dir = rot_x(dir, pose.lean); let mano = shoulder + dir; // El aplastamiento del rebote, que allá se aplica al final a todo el ensamble. if pose.squash.abs() > 1e-4 { let s = pose.squash.clamp(-0.45, 0.45); let (ky, kx) = (1.0 - s, 1.0 + 0.55 * s); return Vec3::new(mano.x * kx, mano.y * ky, mano.z * kx); } mano } /// Igual que [`ser_sdf_lod`] pero **vestido**: peinado, barba, sombrero, anteojos y /// falda ([`Atuendo`]). /// /// Nació el 2026-09-06 para la multitud: con un solo casquete de pelo, una vereda /// con veinte personas eran veinte veces la misma persona con la remera de otro /// color. El [`Atuendo::default()`] reproduce **exactamente** el ser de antes (mismo /// casquete, misma lista de primitivas) — lo fija el test `el_atuendo_por_defecto_no_cambia_nada`. pub fn ser_sdf_atuendo( estilo: &EstiloSer, pose: &Pose, altura: f32, col: Colores, gesto: &Gesto, detalle: DetalleSer, atuendo: &Atuendo, ) -> SdfSer { // El SDF es analítico → funciona a CUALQUIER escala (un actor de escena mide // ~1.8; el demo usa 72). Sólo se guarda de degenerados. let h = altura.max(0.5); let cabezas = estilo.cabezas.clamp(1.6, 8.0); // --- Proporciones: calco de `humanoide_voxel` (canon de cabezas + baby-schema) --- let head_r = h / (2.0 * cabezas); let head_ry = head_r * (1.0 + 0.2 * (estilo.craneo - 1.0)).clamp(0.94, 1.06); let head_cy = h - head_ry * 1.02; // El cuello CRECE con el canon: pegado en el chibi (0.14), visible en el adulto // (~0.40) — sin esto la cabeza de 3.6+ cabezas quedaba hundida hasta el mentón // en el torso ("muñeco dentro de una bola"). let neck = head_r * (0.14 + 0.13 * (cabezas - 2.6).clamp(0.0, 2.2)); let shoulder_y = (head_cy - head_ry - neck).max(head_r); let leg_frac = (0.28 + 0.055 * (cabezas - 2.0)).clamp(0.28, 0.52); let hip_y = shoulder_y * leg_frac; let torso_rx = h * (0.13 + 0.05 * estilo.redondez); let limb_r = h * estilo.extremidad; let ang = estilo.angulosidad.clamp(0.0, 1.0); // Cuerpo PERA (cono redondeado vertical): pecho angosto arriba, panza ancha // abajo — la silueta clásica de personaje tierno. El elipsoide único de antes // era casi una esfera que se tragaba hombros y mentón. La angulosidad invierte // la pera (pecho ancho, cadera angosta = atlético). let chest_r = torso_rx * (0.62 + 0.30 * ang); let belly_r = torso_rx * (0.72 + 0.28 * estilo.panza.clamp(0.8, 1.4)) * (1.0 - 0.22 * ang); let chest_y = shoulder_y - chest_r * 0.55; let belly_y = hip_y + belly_r * 0.35; let shoulder_x = chest_r + limb_r * (0.45 + 0.55 * ang); let hip_x = (belly_r * 0.52).min(torso_rx * (0.55 - 0.18 * ang) + limb_r * 0.3); let leg_len = hip_y; let arm_len = (shoulder_y - hip_y) * 1.02; let head_rx = head_r * estilo.mejilla; // Cara más PLANA que ancha (0.88, no esférica): en una cabeza-esfera los rasgos // quedan a ±30° del frente y el ojo lejano se pierde en el escorzo del 3/4 — // los cartoon aplanan la cara para que ambos ojos sobrevivan la vista 3/4. let head_rz = head_rx * 0.88; // Transform del cuerpo superior: rebote `bob` + inclinación `lean` sobre la cadera. let bob = pose.bob * h * 0.5; let upper = |local_y: f32, off: Vec3| -> Vec3 { let rel = Vec3::new(off.x, local_y - hip_y, off.z); let r = rot_x(rel, pose.lean); Vec3::new(r.x, hip_y + r.y + bob, r.z) }; let mut prims: Vec = Vec::with_capacity(24); let geo = |kind: SdfKind, a: Vec3, ra: f32, b: Vec3, rb: f32, color: [f32; 3], blend: f32| SdfPrim { kind, a: v3(a), ra, b: v3(b), rb, color, blend, }; // --- Piernas: conos redondeados (cadera gorda → tobillo), rematan a `limb_r` del // piso para que la tapa esférica APOYE en y=0 (no se entierre). El **apoyo** de // la pose (IK de pies) acorta la pierna del pie que pisa más alto — la cadera se // queda donde está y el pie sube, como en una ladera. for (sx, swing, pie) in [(-1.0f32, pose.leg_l, pose.pie_l), (1.0, pose.leg_r, pose.pie_r)] { let hip = Vec3::new(sx * hip_x, hip_y, 0.0); let dir = rot_x(Vec3::new(0.0, -leg_len, 0.0), swing); let largo = ((leg_len - limb_r) * (1.0 - pie.clamp(0.0, 0.85))).max(0.1); let foot = hip + dir * (largo / leg_len.max(1e-3)); prims.push(geo( SdfKind::Capsula, hip, limb_r * 1.15, foot, limb_r, [col.pants[0], col.pants[1], col.pants[2]], limb_r * 0.5, )); } // --- Torso pera: cápsula vertical pecho(chico)→panza(grande); funde las piernas. prims.push(geo( SdfKind::Capsula, upper(chest_y, Vec3::ZERO), chest_r, upper(belly_y, Vec3::ZERO), belly_r, col.shirt, limb_r * 0.9, )); // --- Brazos: conos redondeados colgando con splay base (no pegados al torso). // El splay crece con la panza: en el cuerpo pera ancho los brazos a plomo quedan // sepultados detrás de la barriga — se abren y adelantan para seguir visibles. let base_out = 0.16 + ((belly_r / chest_r - 1.0) * 0.30).clamp(0.0, 0.24); for (sx, swing, out) in [ (-1.0f32, pose.arm_l, pose.arm_l_out), (1.0, pose.arm_r, pose.arm_r_out), ] { let shoulder = upper(shoulder_y, Vec3::new(sx * shoulder_x, 0.0, limb_r * 0.3)); // La cápsula ARRANCA DENTRO del torso (no en el pivote del hombro): con el // brazo en alto el hombro queda fuera del elipsoide y el smin no puentea → // brazo flotante. Enterrar era veneno en el campo voxel (anillos); en el SDF // analítico es gratis. El pivote de rotación sigue siendo el hombro. let root = upper(chest_y + chest_r * 0.35, Vec3::new(sx * chest_r * 0.68, 0.0, 0.0)); let mut dir = rot_x(Vec3::new(0.0, -arm_len, 0.0), swing); dir = rot_z(dir, sx * (out + base_out)); dir = rot_x(dir, pose.lean); let hand = shoulder + dir; // Blend ANGOSTO (0.4, no 0.7): el ancho tendía una membrana smin entre el // brazo levantado y la mejilla (veta gris en la cara). El anclaje al torso ya // lo garantiza la raíz enterrada, no el blend. prims.push(geo( SdfKind::Capsula, root, limb_r * 0.98, hand, limb_r * 0.80, col.shirt, limb_r * 0.4, )); // Mano: esferita de piel, junta suave con la manga. prims.push(geo( SdfKind::Esfera, hand, limb_r * 0.98, hand, 0.0, col.skin, limb_r * 0.45, )); } // --- Cuello: cápsula corta de piel entre hombros y cabeza — sin ella el smin // no alcanza a cerrar y se ve el fondo por dos huecos a los lados. let head_c = upper(head_cy, Vec3::ZERO); prims.push(geo( SdfKind::Capsula, upper(shoulder_y - neck, Vec3::ZERO), head_r * 0.42, upper(head_cy - head_ry * 0.4, Vec3::ZERO), head_r * 0.40, col.skin, head_r * 0.30, )); // --- Cabeza: elipsoide (la mejilla ya ensancha rx); junta smin con el cuello. prims.push(geo( SdfKind::Elipsoide, head_c, 0.0, Vec3::new(head_rx, head_ry, head_rz), 0.0, col.skin, head_r * 0.35, )); // --- Cara: DECALS sobre la superficie de la cabeza (siguen head_pitch + gesto) --- // Punto sobre el elipsoide de la cabeza para un offset (x,y) local: z sale de la // ecuación del elipsoide (+ un pelito hacia afuera para que el decal "moje" la piel). // `face_pt` lleva un punto LOCAL de la cabeza al mundo (aplica cabeceo del clip + // el giro del gesto); `on_face` lo posa sobre la superficie del elipsoide (+push). // // El CRÁNEO (elipsoide) no rota: sus semiejes están alineados a ejes y el shader // sólo des-rota el yaw del ser entero. Rotan los RASGOS (pelo, ojos, boca) — que // es lo que se ve girar. La cabeza es casi de revolución (rz = 0.88·rx), así que // la silueta no delata la trampa. let head_yaw = gesto.cabeza[0]; let head_pitch = pose.head_pitch + gesto.cabeza[1]; // El giro se hace en **espacio de esfera**: la cabeza es un elipsoide, o sea una // esfera escalada; dividir por los semiejes, rotar, y volver a multiplicar mapea // superficie→superficie EXACTAMENTE. Rotar crudo (que es lo que hacía el cabeceo // del clip, chico) hunde los rasgos dentro del cráneo en cuanto el giro es grande: // el frente está a `rz` del centro y la coronilla a `ry > rz`, así que un ojo // subido 40° se mete un 12 % adentro y se lo traga la cabeza. let hr3 = Vec3::new(head_rx.max(1e-4), head_ry.max(1e-4), head_rz.max(1e-4)); let gira = head_pitch.abs() + head_yaw.abs() > 1e-5; let face_pt = |l: Vec3| -> Vec3 { if !gira { return head_c + l; } head_c + rot_y(rot_x(l / hr3, head_pitch), head_yaw) * hr3 }; // --- Pelo: casquete elipsoidal corrido hacia atrás/arriba (frente despejada), // junta casi dura para que lea como cáscara, no como bulto fundido. NO gira con // el gesto: el pelo es del CRÁNEO, y el cráneo acá está quieto. Haciéndolo girar // (probado), al levantar la cara el casquete se iba para atrás y el ser quedaba // pelado. Lo que se desliza sobre la cabeza son los RASGOS, no el peinado. peinado( atuendo, head_c, Vec3::new(head_rx, head_ry, head_rz), head_r, shoulder_y, col.hair, detalle, &mut prims, ); let surf_z = |lx: f32, ly: f32| -> f32 { let nx = (lx / head_rx).clamp(-0.95, 0.95); let ny = (ly / head_ry).clamp(-0.95, 0.95); head_rz * (1.0 - nx * nx - ny * ny).max(0.04).sqrt() }; let on_face = |lx: f32, ly: f32, push: f32| -> Vec3 { face_pt(Vec3::new(lx, ly, surf_z(lx, ly) + push)) }; let decal_e = |c: Vec3, r: f32, color: [f32; 3]| SdfPrim { kind: SdfKind::DecalEsfera, a: v3(c), ra: r, b: v3(c), rb: 0.0, color, blend: 0.1, }; // Radio de ojo CAPADO para el domo geométrico: el 0.32 del baby-schema literal // hace colgar los domos fuera de la cara (globos googly). let er = (head_r * estilo.ojo_tam).min(head_r * 0.27); let ex = head_rx * 0.46; let eoff = estilo.ojo_altura * head_r; // Mirada: desvío de la pupila DENTRO del domo. El domo es una esfera, así que la // pupila viaja por su superficie (dirección normalizada × radio) — no se despega // ni se aplasta al llegar al borde, como pasaría con un offset plano. let gx = gesto.ojos[0].clamp(-1.0, 1.0); let gy = gesto.ojos[1].clamp(-1.0, 1.0); let parp = gesto.parpado.clamp(0.0, 1.0); // Párpado = decal de PIEL que baja sobre el domo. Cerrar un ojo no es achicar // geometría: es taparlo — así el ojo cerrado conserva el bulto del globo bajo el // párpado (que es lo que se ve en una cara real) en vez de hundirse. // El color es la piel EXACTA: el decal no puede recortarse al domo, así que su // borde muerde la mejilla; con un tono apenas distinto eso se veía como un DISCO // pintado en el cachete. Idéntico = invisible; el ojo cerrado lo dibuja la pestaña. let lid = col.skin; let lash = [col.hair[0] * 0.75, col.hair[1] * 0.72, col.hair[2] * 0.72]; let lejos = detalle == DetalleSer::Lejos; for s in [-1.0f32, 1.0] { // Esclera = GEOMETRÍA: domo blanco apenas saliente (vinyl-toy, como eligió la // versión voxel) — un decal plano se pierde en el escorzo del 3/4; el domo no. // De lejos el domo se va: sobresale 0.2 del radio de cabeza (sub-píxel a esa // distancia) y a cambio dos puntos oscuros leen como ojos mejor que dos // pelotas blancas. let dome_l = Vec3::new(s * ex, eoff, surf_z(s * ex, eoff) - er * 0.25); if !lejos { prims.push(geo( SdfKind::Esfera, face_pt(dome_l), er, Vec3::ZERO, 0.0, [0.94, 0.945, 0.955], er * 0.25, )); } // Pupila/chispa ANCLADAS AL DOMO (no a la mejilla: un decal anclado a la // superficie de la cara también pinta la piel bajo el domo — chorreaba un cono // oscuro). Convergente hacia adentro (mirada amable, no googly). // PEGADAS al frente del domo (0.99·er sobre la dirección): a 0.80er el borde AA // del decal quedaba a exactamente un radio de la mejilla y la pintaba gris. let dir = Vec3::new(-s * 0.20 + gx * 0.55, gy * 0.55, 0.95).normalize(); let pup = dome_l + dir * (er * 0.99); prims.push(decal_e(face_pt(pup), er * 0.48, [0.16, 0.13, 0.18])); // Chispa: el punto de luz del ojo. Es lo primero que se cae al alejarse — // mide 0.2 de un ojo que ya es chico, así que de plano medio para atrás no // aporta un píxel y cuesta una primitiva por ojo. if detalle == DetalleSer::Hero { prims.push(decal_e( face_pt(pup + Vec3::new(-s * er * 0.12, er * 0.26, er * 0.10)), er * 0.20, [0.98, 0.985, 0.99], )); } // Párpado: esfera de piel que arranca ARRIBA del domo (invisible con el ojo // abierto) y baja hasta taparlo. Va DESPUÉS de la pupila en la lista → la // pinta encima (el shader aplica los decals en orden). if parp < 0.995 && !lejos { // Radio JUSTO para tapar el domo (que sobresale 0.75·er de la mejilla) sin // invadir media cara: 1.22·er alcanza, 1.42 pintaba hasta la frente. // Adelantado (z = 0.55·er) para que el borde del párpado cruce el FRENTE // del ojo —donde está la pupila— y no sólo su casquete superior. let lid_r = er * 1.22; // Recorrido calibrado sobre el BORDE del párpado en el plano frontal // (`lid_y − 1.13·er`): a párpado=1 el borde queda arriba del domo (ojo // entero libre), a 0.5 lo parte al medio, a 0 pasa por debajo (tapado). let lid_y = er * 2.15 * parp; prims.push(decal_e( face_pt(dome_l + Vec3::new(0.0, lid_y, er * 0.55)), lid_r, lid, )); // Pestaña: LA línea del ojo cerrado (arco de 3 tramos combado hacia abajo, // sobre el frente del domo). Aparece en el último tramo del cierre, cuando // el párpado ya tapó la pupila — antes no hay línea que dibujar. if parp < 0.5 { let w = 1.0 - parp * 2.0; let hw = er * 0.82 * w; // A MEDIA ALTURA del ojo, no en el borde real del párpado: cuando el // ojo está del todo tapado ese borde cae al ras del pómulo y la línea // se confundía con la sombra del cachete. La convención de dibujo pone // la línea donde estaba la pupila. let ly = -er * 0.12; let arco: Vec = (0..4) .map(|i| { let t = -1.0 + i as f32 * (2.0 / 3.0); // Arco ∩ (puntas apenas más bajas que el centro) = ojo contento, // no dormido; y el frente del domo se aleja en las puntas, así // que la línea sigue su curvatura para no despegarse. let z = er * (0.90 - 0.22 * t * t); dome_l + Vec3::new(t * hw, ly - er * 0.10 * t * t, z) }) .collect(); for seg in arco.windows(2) { prims.push(SdfPrim { kind: SdfKind::DecalCapsula, a: v3(face_pt(seg[0])), ra: er * 0.17, b: v3(face_pt(seg[1])), rb: er * 0.17, color: lash, blend: 0.1, }); } } } } // Rubor: dos parches cálidos bajo/afuera de los ojos (amabilidad barata). // Suavizado 2026-08-09: a distancia de retrato (la que usa el editor de caras) el // parche fuerte y ancho leía como un lunar rojo más grande que el ojo. Un rubor es // una zona cálida, no una mancha: menos saturación y menos radio. let blush = [ (col.skin[0] * 1.02 + 0.04).min(1.0), col.skin[1] * 0.80, col.skin[2] * 0.82, ]; // Acotado a la cara: con ojos bajos (bebé) `eoff − er·1.5` se caía del elipsoide // y el decal terminaba pintando los HOMBROS (pinta cualquier superficie cercana). let blush_y = (eoff - er * 1.5).max(-head_ry * 0.58); if detalle == DetalleSer::Hero { for s in [-1.0f32, 1.0] { prims.push(decal_e(on_face(s * head_rx * 0.68, blush_y, 0.0), er * 0.56, blush)); } } // Sonrisa: arco de decals capsulares (extremos hacia ARRIBA = feliz), en la piel // frontal-baja. Polilínea de 4 segmentos sobre la parábola de `humano::sonrisa`. let mouth = [ (col.skin[0] * 0.72 + 0.086).min(1.0), col.skin[1] * 0.55, col.skin[2] * 0.55, ]; // DEBAJO del borde inferior de los domos (con ojos grandes y bajos —bebé— una // fracción fija de head_r quedaba sepultada detrás de ellos), acotada al mentón. // Arco corto y chato: puntas largas/altas se meten DEBAJO de los domos de los // ojos y asoman como mancha en la mejilla. let boca_y = (eoff - er - head_r * 0.14).max(-head_ry * 0.62); let half_w = head_rx * 0.30; // La sonrisa del gesto CURVA el arco: positivo = puntas arriba (feliz), 0 = línea // recta, negativo = mueca. La geometría del arco no cambia, sólo su parábola. let curve = head_r * 0.12 * gesto.sonrisa.clamp(-1.5, 1.5); // La boca se traza con menos tramos al alejarse (4 → 2) y desaparece de fondo: // una línea de 0.05 radios de cabeza no sobrevive a un puñado de píxeles. let tramos = match detalle { DetalleSer::Hero => 4, DetalleSer::Medio => 2, DetalleSer::Lejos => 0, }; let pts: Vec = (0..=tramos) .map(|i| { let t = -1.0 + i as f32 * (2.0 / tramos.max(1) as f32); on_face(t * half_w, boca_y + curve * t * t, er * 0.1) }) .collect(); if tramos > 0 { for w in pts.windows(2) { prims.push(SdfPrim { kind: SdfKind::DecalCapsula, a: v3(w[0]), ra: head_r * 0.055, b: v3(w[1]), rb: head_r * 0.055, color: mouth, blend: 0.1, }); } } // Boca ABIERTA: hueco oscuro (interior, más oscuro que la línea de labios) pintado // ENCIMA del arco → el arco queda de comisuras y el hueco de apertura. Baja un // poco al abrirse, como una mandíbula. let abre = gesto.boca.clamp(0.0, 1.0); if abre > 0.03 && !lejos { let r = head_r * (0.05 + 0.13 * abre); let hueco = [mouth[0] * 0.45, mouth[1] * 0.40, mouth[2] * 0.42]; prims.push(decal_e( on_face(0.0, boca_y - r * 0.45, er * 0.1), r, hueco, )); } // --- Lo que se pone encima: falda, barba, anteojos, sombrero --------------- vestir( atuendo, head_c, Vec3::new(head_rx, head_ry, head_rz), head_r, shoulder_y, hip_y, torso_rx, eoff, er, ex, &col, detalle, &mut prims, ); // --- APLASTAR Y ESTIRAR --------------------------------------------------- // Se aplica al final, como escala no uniforme del ensamble YA armado alrededor // de los pies (`y = 0`), en vez de propagar el factor por las veinte fórmulas de // proporciones: el layout calibrado no se toca, y el elipsoide (que lleva sus // semiejes en `b`) se deforma EXACTO. La cara viaja pegada porque los decals son // puntos del mismo espacio. // // La única aproximación son los radios de esfera/cápsula, que son isótropos: se // les da la media de los tres ejes. A las amplitudes reales del rebote (4-10 %) // eso no se ve; el que manda la lectura es el cuerpo, que sí se deforma bien. if pose.squash.abs() > 1e-4 { let s = pose.squash.clamp(-0.45, 0.45); let ky = 1.0 - s; let kx = 1.0 + 0.55 * s; // ancha al aplastar, afina al estirar (volumen ~) let km = (kx * 2.0 + ky) / 3.0; for p in &mut prims { p.a = [p.a[0] * kx, p.a[1] * ky, p.a[2] * kx]; p.ra *= km; p.rb *= km; p.blend *= km; // `b` es el segundo punto de la cápsula O los SEMIEJES del elipsoide: en // los dos casos la escala por eje es la correcta (y en el elipsoide, exacta). p.b = [p.b[0] * kx, p.b[1] * ky, p.b[2] * kx]; } } SdfSer { prims, pos: [0.0; 3], yaw: 0.0 } } #[cfg(test)] mod tests { use super::*; use crate::actor::Age; use crate::Gesto; use crate::Clip; fn col() -> Colores { Colores::nuevos([0.85, 0.68, 0.54], [0.2, 0.6, 0.55], [0.18, 0.22, 0.34]) } fn geometria(s: &SdfSer) -> usize { s.prims .iter() .filter(|p| !matches!(p.kind, SdfKind::DecalEsfera | SdfKind::DecalCapsula)) .count() } fn decals(s: &SdfSer) -> usize { s.prims.len() - geometria(s) } #[test] fn ensamble_con_cuerpo_y_cara() { let s = ser_sdf(&EstiloSer::default(), &Pose::default(), 46.0, col()); // 2 piernas + torso + 2 brazos + 2 manos + cuello + cabeza + pelo + 2 escleras // = 12 de geometría. assert_eq!(geometria(&s), 12); // 2 pupilas + 2 chispas + 2 rubor + 4 sonrisa = 10 decals. assert_eq!(decals(&s), 10); } /// El atuendo por defecto tiene que dejar al ser EXACTAMENTE como estaba: el /// casquete de pelo de siempre y ni una primitiva de más. Lo de arriba /// (`ensamble_con_cuerpo_y_cara`, 12 geométricas + 10 decals) es el número /// histórico; acá se fija que la puerta nueva no lo movió. #[test] fn el_atuendo_por_defecto_no_cambia_nada() { let a = ser_sdf(&EstiloSer::amable(), &Pose::default(), 40.0, col()); let b = ser_sdf_atuendo( &EstiloSer::amable(), &Pose::default(), 40.0, col(), &Gesto::neutro(), DetalleSer::Hero, &Atuendo::default(), ); assert_eq!(a.prims.len(), b.prims.len()); for (x, y) in a.prims.iter().zip(b.prims.iter()) { assert_eq!(x.kind, y.kind); assert_eq!(x.a, y.a); assert_eq!(x.b, y.b); assert_eq!(x.ra, y.ra); assert_eq!(x.color, y.color); } } /// Cada pieza del atuendo agrega geometría, y el calvo saca el casquete. Es lo /// que hace que una multitud no sea la misma persona repetida. #[test] fn cada_pieza_del_atuendo_cambia_la_silueta() { let arma = |a: Atuendo| { ser_sdf_atuendo( &EstiloSer::agil(), &Pose::default(), 40.0, col(), &Gesto::neutro(), DetalleSer::Hero, &a, ) }; let base = geometria(&arma(Atuendo::default())); assert_eq!( geometria(&arma(Atuendo::con(Peinado::Calvo))), base - 1, "el calvo pierde el casquete" ); assert!(geometria(&arma(Atuendo::con(Peinado::DosColas))) > base); assert!(geometria(&arma(Atuendo::con(Peinado::Larga))) > base); assert!( geometria(&arma(Atuendo { barba: 0.9, ..Atuendo::default() })) > base, "la barba es geometría, no una mancha" ); assert!(geometria(&arma(Atuendo { falda: 0.8, ..Atuendo::default() })) > base); assert!(geometria(&arma(Atuendo { sombrero: Sombrero::Ala, ..Atuendo::default() })) > base); assert!(geometria(&arma(Atuendo { sombrero: Sombrero::Corona, ..Atuendo::default() })) > base); assert!(geometria(&arma(Atuendo { manto: 0.8, ..Atuendo::default() })) > base); // Los anteojos son DECALS (un aro de geometría a escala de escena // desaparecería bajo el blend), así que no tocan la silueta. let con_anteojos = arma(Atuendo { anteojos: true, ..Atuendo::default() }); assert_eq!(geometria(&con_anteojos), base); assert!(decals(&con_anteojos) > decals(&arma(Atuendo::default()))); } /// Los diez peinados dan diez siluetas de cabeza distintas — si dos coinciden /// prim a prim, uno de los dos no está haciendo nada. #[test] fn los_peinados_no_se_repiten() { let peinados = [ Peinado::Casquete, Peinado::Corto, Peinado::Melena, Peinado::Larga, Peinado::Rodete, Peinado::Coleta, Peinado::DosColas, Peinado::Afro, Peinado::Cresta, Peinado::Calvo, ]; let mut firmas = std::collections::HashSet::new(); for p in peinados { let s = ser_sdf_atuendo( &EstiloSer::amable(), &Pose::default(), 40.0, col(), &Gesto::neutro(), DetalleSer::Hero, &Atuendo::con(p), ); // Firma: las primitivas de geometría por encima de los hombros. let firma: Vec = s .prims .iter() .filter(|x| x.kind != SdfKind::DecalEsfera && x.kind != SdfKind::DecalCapsula) .map(|x| format!("{:?}{:?}{:.3}", x.a, x.b, x.ra)) .collect(); assert!(firmas.insert(firma), "{p:?} dibuja lo mismo que otro peinado"); } } #[test] fn bound_cubre_del_piso_a_la_cabeza() { let s = ser_sdf(&EstiloSer::default(), &Pose::default(), 46.0, col()); let (c, r) = s.bound(); assert!(c[1] - r <= 1.0, "llega al piso"); assert!(c[1] + r >= 45.0, "cubre la cabeza"); } #[test] fn el_apoyo_sube_el_pie_de_ese_lado() { // Las dos primeras primitivas son las piernas (izquierda, derecha); su punto // `b` es el pie. Un apoyo del lado derecho lo levanta y deja al izquierdo donde // estaba: es el mismo IK de pies que planta al muñeco de cajas en la ladera. let llano = ser_sdf(&EstiloSer::default(), &Pose::default(), 46.0, col()); let ladera = ser_sdf( &EstiloSer::default(), &Pose { pie_r: 0.35, ..Pose::default() }, 46.0, col(), ); let pie_izq = |s: &SdfSer| s.prims[0].b[1]; let pie_der = |s: &SdfSer| s.prims[1].b[1]; assert!( pie_der(&ladera) > pie_der(&llano) + 1.0, "el pie derecho sube: {} vs {}", pie_der(&ladera), pie_der(&llano) ); assert!( (pie_izq(&ladera) - pie_izq(&llano)).abs() < 1e-4, "el izquierdo no se mueve" ); } #[test] fn la_mano_derecha_es_la_esfera_que_se_dibuja() { for age in [Age::Baby, Age::Child, Age::Adult, Age::Elder] { let estilo = EstiloSer::por_edad(age); for clip in [Clip::Idle, Clip::Walk, Clip::Run, Clip::Wave, Clip::Point, Clip::Cheer] { for fase in [0.0, 0.7, 1.9] { let pose = clip.pose(fase); let m = mano_derecha(&estilo, &pose, 1.7); let ser = ser_sdf(&estilo, &pose, 1.7, col()); let hay = ser.prims.iter().any(|p| { p.kind == SdfKind::Esfera && p.color == col().skin && Vec3::from_array(p.a).distance(m) < 1e-4 }); assert!(hay, "{age:?}/{clip:?}/{fase}: ninguna mano dibujada en {m:?}"); } } } } #[test] fn saludar_levanta_una_mano() { let idle = ser_sdf(&EstiloSer::default(), &Pose::default(), 46.0, col()); let wave = ser_sdf(&EstiloSer::default(), &Clip::Wave.pose(1.2), 46.0, col()); // Las manos son las esferas de PIEL (las escleras de los ojos son blancas). let mano_alta = |s: &SdfSer| { s.prims .iter() .filter(|p| p.kind == SdfKind::Esfera && p.color == col().skin) .map(|p| p.a[1]) .fold(f32::MIN, f32::max) }; assert!( mano_alta(&wave) > mano_alta(&idle) + 5.0, "la mano sube al saludar: {} vs {}", mano_alta(&wave), mano_alta(&idle) ); } #[test] fn el_bebe_tiene_cabeza_proporcionalmente_mayor() { let head_frac = |age: Age| { let skin = col().skin; let s = ser_sdf(&EstiloSer::por_edad(age), &Pose::default(), 46.0, col()); // La cabeza es el ÚNICO elipsoide de piel (torso = remera, pelo = hair). s.prims .iter() .filter(|p| p.kind == SdfKind::Elipsoide && p.color == skin) .map(|p| p.b[0]) .fold(f32::MIN, f32::max) / 46.0 }; assert!(head_frac(Age::Baby) > head_frac(Age::Adult) + 0.05); } #[test] fn el_parpado_baja_sobre_el_ojo_y_lo_tapa() { let e = EstiloSer::default(); let abierto = ser_sdf_con(&e, &Pose::default(), 46.0, col(), &Gesto::neutro()); let cerrado = ser_sdf_con( &e, &Pose::default(), 46.0, col(), &Gesto::neutro().con_parpado(0.0), ); // Ojo abierto: ni párpado ni pestaña. Cerrado: 2 párpados + 2 arcos de // pestaña (3 tramos cada uno). assert_eq!(decals(&cerrado), decals(&abierto) + 8); // La pupila (decal oscuro) sigue en la lista pero el párpado va DESPUÉS — // el shader pinta en orden, así que la tapa. let pupila = cerrado .prims .iter() .position(|p| p.kind == SdfKind::DecalEsfera && p.color[0] < 0.3) .expect("hay pupila"); let parpado = cerrado .prims .iter() .position(|p| p.kind == SdfKind::DecalEsfera && p.color[0] > 0.7 && p.ra > 0.0) .map(|i| { // el primero grande de piel tras la pupila cerrado.prims[i..] .iter() .position(|p| p.kind == SdfKind::DecalEsfera && p.ra > cerrado.prims[pupila].ra) .map(|k| i + k) .unwrap_or(i) }) .expect("hay párpado"); assert!(parpado > pupila, "el párpado se pinta después de la pupila"); } #[test] fn media_pestanada_no_dibuja_la_linea() { let e = EstiloSer::default(); let medio = ser_sdf_con( &e, &Pose::default(), 46.0, col(), &Gesto::neutro().con_parpado(0.7), ); // Párpado sí (2), pestaña no (sólo bajo 0.5). let base = decals(&ser_sdf(&e, &Pose::default(), 46.0, col())); assert_eq!(decals(&medio), base + 2); } #[test] fn la_mirada_corre_las_pupilas_dentro_del_ojo() { let e = EstiloSer::default(); let pupilas = |g: &Gesto| -> Vec<[f32; 3]> { ser_sdf_con(&e, &Pose::default(), 46.0, col(), g) .prims .iter() .filter(|p| p.kind == SdfKind::DecalEsfera && p.color[0] < 0.3) .map(|p| p.a) .collect() }; let centro = pupilas(&Gesto::neutro()); let mut lado = Gesto::neutro(); lado.ojos = [1.0, 0.0]; let corrida = pupilas(&lado); assert_eq!(centro.len(), 2); for (a, b) in centro.iter().zip(corrida.iter()) { assert!(b[0] > a[0] + 0.05, "la pupila se fue a +X: {a:?} → {b:?}"); } } #[test] fn el_giro_de_cabeza_lleva_la_cara_pero_no_el_craneo() { let e = EstiloSer::default(); let recto = ser_sdf(&e, &Pose::default(), 46.0, col()); let mut g = Gesto::neutro(); g.cabeza = [0.9, 0.0]; let girado = ser_sdf_con(&e, &Pose::default(), 46.0, col(), &g); let craneo = |s: &SdfSer| { s.prims .iter() .find(|p| p.kind == SdfKind::Elipsoide && p.color == col().skin) .unwrap() .a }; assert_eq!(craneo(&recto), craneo(&girado), "el cráneo no se mueve"); // Los domos de los ojos (esferas blancas) SÍ giran: con yaw>0 se corren a +Z/+X. let domos = |s: &SdfSer| -> Vec<[f32; 3]> { s.prims .iter() .filter(|p| p.kind == SdfKind::Esfera && p.color[0] > 0.9 && p.color[2] > 0.9) .map(|p| p.a) .collect() }; let (a, b) = (domos(&recto), domos(&girado)); assert_eq!(a.len(), 2); assert!( a.iter().zip(b.iter()).any(|(u, v)| (u[2] - v[2]).abs() > 0.1), "los ojos giraron con la cabeza" ); } #[test] fn la_boca_abierta_agrega_el_hueco_y_la_mueca_invierte_el_arco() { let e = EstiloSer::default(); let cerrada = ser_sdf(&e, &Pose::default(), 46.0, col()); let abierta = ser_sdf_con( &e, &Pose::default(), 46.0, col(), &Gesto::neutro().con_boca(1.0, 1.0), ); assert_eq!(decals(&abierta), decals(&cerrada) + 1); // Comisuras: en la sonrisa suben respecto del centro; en la mueca bajan. let comisura = |g: &Gesto| -> f32 { let s = ser_sdf_con(&e, &Pose::default(), 46.0, col(), g); let arco: Vec<&SdfPrim> = s .prims .iter() .filter(|p| p.kind == SdfKind::DecalCapsula) .collect(); arco[0].a[1] - arco[1].b[1] // punta izquierda menos el centro del arco }; let feliz = comisura(&Gesto::neutro()); let triste = comisura(&Gesto::neutro().con_boca(0.0, -1.0)); assert!(feliz > 0.0, "sonrisa: puntas arriba ({feliz})"); assert!(triste < 0.0, "mueca: puntas abajo ({triste})"); } #[test] fn altura_ojos_cae_en_la_cabeza() { let e = EstiloSer::default(); let y = altura_ojos(&e, 46.0); let s = ser_sdf(&e, &Pose::default(), 46.0, col()); // Debe coincidir con la altura real de los domos de los ojos del ensamble. let domo_y = s .prims .iter() .filter(|p| p.kind == SdfKind::Esfera && p.color[0] > 0.9 && p.color[2] > 0.9) .map(|p| p.a[1]) .fold(0.0f32, f32::max); assert!((y - domo_y).abs() < 1.5, "ojos a {y} vs domos a {domo_y}"); } #[test] fn aplastar_baja_y_ensancha_estirar_hace_lo_contrario() { let e = EstiloSer::default(); // Alto y ancho del ensamble, medidos sobre las primitivas (no sobre `bound`, // que es una esfera y mezcla los dos ejes). let caja = |sq: f32| -> (f32, f32) { let pose = Pose { squash: sq, ..Pose::default() }; let s = ser_sdf(&e, &pose, 46.0, col()); let mut alto: f32 = 0.0; let mut ancho: f32 = 0.0; for p in &s.prims { let r = p.ra.max(p.rb); alto = alto.max(p.a[1] + r).max(p.b[1] + r); ancho = ancho.max((p.a[0].abs() + r).max(p.b[0].abs() + r)); } (alto, ancho) }; let (a0, w0) = caja(0.0); let (a_ap, w_ap) = caja(0.25); let (a_es, w_es) = caja(-0.25); assert!(a_ap < a0 * 0.85, "aplastado baja: {a_ap} vs {a0}"); assert!(w_ap > w0 * 1.05, "aplastado ensancha: {w_ap} vs {w0}"); assert!(a_es > a0 * 1.15, "estirado sube: {a_es} vs {a0}"); assert!(w_es < w0 * 0.95, "estirado afina: {w_es} vs {w0}"); } #[test] fn el_squash_no_despega_la_cara_de_la_cabeza() { let e = EstiloSer::default(); // Con el ser aplastado, las pupilas tienen que seguir dentro del domo del ojo: // si la escala se aplicara sólo a la geometría, la cara quedaría flotando. let pose = Pose { squash: 0.3, ..Pose::default() }; let s = ser_sdf(&e, &pose, 46.0, col()); let domos: Vec<[f32; 3]> = s .prims .iter() .filter(|p| p.kind == SdfKind::Esfera && p.color[0] > 0.9 && p.color[2] > 0.9) .map(|p| p.a) .collect(); let er = s .prims .iter() .find(|p| p.kind == SdfKind::Esfera && p.color[0] > 0.9 && p.color[2] > 0.9) .unwrap() .ra; for pup in s .prims .iter() .filter(|p| p.kind == SdfKind::DecalEsfera && p.color[0] < 0.3) { let d = domos .iter() .map(|c| { let (dx, dy, dz) = (pup.a[0] - c[0], pup.a[1] - c[1], pup.a[2] - c[2]); (dx * dx + dy * dy + dz * dz).sqrt() }) .fold(f32::MAX, f32::min); assert!(d < er * 1.3, "pupila despegada del domo: {d} > {}", er * 1.3); } } #[test] fn posado_fija_pos_y_yaw_sin_tocar_primitivas() { let s = ser_sdf(&EstiloSer::default(), &Pose::default(), 46.0, col()); let t = s.clone().posado([10.0, 0.0, -5.0], 1.2); assert_eq!(t.pos, [10.0, 0.0, -5.0]); assert!((t.yaw - 1.2).abs() < 1e-6); // Las primitivas quedan en espacio LOCAL (el shader aplica pos/yaw). for (a, b) in s.prims.iter().zip(t.prims.iter()) { assert_eq!(a.a, b.a); assert_eq!(a.b, b.b); } } } // ============================================================================ // Atuendo: lo que hace que dos personas no sean la misma persona // ============================================================================ /// **Peinado.** El ser nació con un único casquete elipsoidal (`Casquete`), que es /// el pelo corto de siempre; el resto se agrega encima de ese casquete o lo /// reemplaza. Ninguno usa textura: son bultos con `smin` y el color del pelo. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] pub enum Peinado { /// Casquete corto pegado al cráneo (el original, frente despejada). #[default] Casquete, /// Casquete + patillas: pelo corto con volumen a los costados. Corto, /// Melena a la altura del mentón, con raya al medio. Melena, /// Pelo largo que cae por la espalda hasta los omóplatos. Larga, /// Rodete: pelo recogido en un bollo arriba. Rodete, /// Coleta: pelo recogido que cae por atrás. Coleta, /// Dos colas a los costados. DosColas, /// Afro: casquete grande y esférico. Afro, /// Cresta: una franja alta al medio, los costados al ras. Cresta, /// Sin pelo. Calvo, } impl Peinado { /// Todos, en el orden en que un editor los cicla. pub const TODOS: [Peinado; 10] = [ Peinado::Casquete, Peinado::Corto, Peinado::Melena, Peinado::Larga, Peinado::Rodete, Peinado::Coleta, Peinado::DosColas, Peinado::Afro, Peinado::Cresta, Peinado::Calvo, ]; /// El siguiente de [`TODOS`](Self::TODOS), dando la vuelta. pub fn next(self) -> Self { let i = Self::TODOS.iter().position(|&p| p == self).unwrap_or(0); Self::TODOS[(i + 1) % Self::TODOS.len()] } } /// **Lo que lleva en la cabeza.** #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] pub enum Sombrero { #[default] Ninguno, /// Gorra con visera. Gorra, /// Sombrero de ala (sol, campo). Ala, /// Gorro de lana ajustado. Gorro, /// Pañuelo/velo que cubre el pelo y baja a los hombros. Panuelo, /// Casco de obra / bici. Casco, /// Corona: aro sobre la cabeza con cinco puntas. Sin `color_sombrero` es de oro, /// no del color de la remera — una corona del color de la ropa no se lee. Corona, } impl Sombrero { /// Todos, en el orden en que un editor los cicla. pub const TODOS: [Sombrero; 7] = [ Sombrero::Ninguno, Sombrero::Gorra, Sombrero::Ala, Sombrero::Gorro, Sombrero::Panuelo, Sombrero::Casco, Sombrero::Corona, ]; /// El siguiente de [`TODOS`](Self::TODOS), dando la vuelta. pub fn next(self) -> Self { let i = Self::TODOS.iter().position(|&p| p == self).unwrap_or(0); Self::TODOS[(i + 1) % Self::TODOS.len()] } } /// **Atuendo**: lo que se le pone encima al cuerpo. Todo es opcional y el default /// reproduce el ser de siempre. /// /// Viaja en el documento del estudio (`CharSpec::atuendo`): todo campo tiene /// default, así un `.ron` escrito a mano nombra sólo lo que le pone. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Atuendo { pub peinado: Peinado, /// Barba `[0,1]`: `0` = sin, `0.4` = candado, `1` = barba entera. pub barba: f32, pub sombrero: Sombrero, /// Color del sombrero (`None` = el de la remera). pub color_sombrero: Option<[f32; 3]>, pub anteojos: bool, /// Falda/vestido `[0,1]`: `0` = pantalón; `>0` = campana desde la cadera, y /// cuánto más alto el número más larga. pub falda: f32, /// Color de la falda (`None` = el de la remera, o sea vestido de una pieza). pub color_falda: Option<[f32; 3]>, /// Manto/capa `[0,1]`: `0` = sin; `>0` = cae de los hombros por la espalda, y /// cuánto más alto el número más cerca del suelo llega. pub manto: f32, /// Color del manto (`None` = la remera oscurecida, para que se despegue del /// cuerpo). pub color_manto: Option<[f32; 3]>, } impl Default for Atuendo { fn default() -> Self { Self { peinado: Peinado::Casquete, barba: 0.0, sombrero: Sombrero::Ninguno, color_sombrero: None, anteojos: false, falda: 0.0, color_falda: None, manto: 0.0, color_manto: None, } } } impl Atuendo { /// Sólo un peinado (el atajo más usado por la multitud). pub fn con(peinado: Peinado) -> Self { Self { peinado, ..Self::default() } } /// El color con que se pinta el sombrero: el propio, o el de fábrica (oro para /// la corona, la remera para lo demás). Lo usan el pintor y el editor, que /// muestra este valor mientras el ser no tenga uno propio. pub fn color_sombrero_efectivo(&self, remera: [f32; 3]) -> [f32; 3] { self.color_sombrero.unwrap_or(match self.sombrero { Sombrero::Corona => ORO, _ => remera, }) } /// El color de la falda: el propio, o el de la remera (vestido de una pieza). pub fn color_falda_efectivo(&self, remera: [f32; 3]) -> [f32; 3] { self.color_falda.unwrap_or(remera) } /// El color del manto: el propio, o la remera oscurecida. pub fn color_manto_efectivo(&self, remera: [f32; 3]) -> [f32; 3] { self.color_manto.unwrap_or([remera[0] * 0.72, remera[1] * 0.72, remera[2] * 0.72]) } /// `true` si no le pone nada al ser (lo que el documento no necesita escribir). pub fn es_default(&self) -> bool { *self == Self::default() } /// **Cuántas primitivas le SUMA este atuendo a un ser** con el detalle dado, /// contra el ser sin vestir. El techo de [`DetalleSer::costo`] se calibró /// desnudo; sin esta cuenta, un reparto de seres vestidos se pasa del uniform y /// el empacador corta las últimas primitivas sin aviso. Se mide armando los dos /// seres: el atuendo no depende del gesto, así que la diferencia es exacta. pub fn costo_extra(&self, detalle: DetalleSer) -> usize { if self.es_default() { return 0; } let arma = |a: &Atuendo| { ser_sdf_atuendo( &EstiloSer::default(), &Pose::default(), 1.8, Colores::nuevos([0.8, 0.6, 0.5], [0.3, 0.4, 0.7], [0.2, 0.2, 0.3]), &Gesto::neutro(), detalle, a, ) .prims .len() }; arma(self).saturating_sub(arma(&Atuendo::default())) } } /// El pelo. El **casquete base** es el mismo elipsoide de siempre (corrido hacia /// atrás y arriba, frente despejada, junta casi dura para que lea como cáscara); /// cada peinado agrega bultos encima. No gira con el gesto: el pelo es del cráneo, /// y el cráneo está quieto (los que se deslizan son los rasgos). #[allow(clippy::too_many_arguments)] fn peinado( a: &Atuendo, head_c: Vec3, hr: Vec3, head_r: f32, shoulder_y: f32, hair: [f32; 3], detalle: DetalleSer, prims: &mut Vec, ) { let (head_rx, head_ry) = (hr.x, hr.y); let cap = |k: f32, dy: f32, dz: f32| SdfPrim { kind: SdfKind::Elipsoide, a: v3(head_c + Vec3::new(0.0, head_ry * dy, head_r * dz)), ra: 0.0, b: v3(Vec3::new(head_rx * 1.04 * k, head_ry * 0.76 * k, head_r * 0.94 * k)), rb: 0.0, color: hair, blend: head_r * 0.12, }; let mecha = |a: Vec3, ra: f32, b: Vec3, rb: f32| SdfPrim { kind: SdfKind::Capsula, a: v3(a), ra, b: v3(b), rb, color: hair, blend: head_r * 0.14, }; let lejos = detalle == DetalleSer::Lejos; match a.peinado { Peinado::Calvo => {} Peinado::Casquete => prims.push(cap(1.0, 0.44, -0.20)), Peinado::Corto => { prims.push(cap(1.06, 0.40, -0.16)); } Peinado::Afro => { prims.push(SdfPrim { kind: SdfKind::Elipsoide, a: v3(head_c + Vec3::new(0.0, head_ry * 0.42, -head_r * 0.10)), ra: 0.0, b: v3(Vec3::new(head_rx * 1.42, head_ry * 1.20, head_r * 1.34)), rb: 0.0, color: hair, blend: head_r * 0.30, }); } Peinado::Cresta => { prims.push(cap(0.96, 0.42, -0.20)); let alto = head_ry * 0.55; prims.push(SdfPrim { kind: SdfKind::Elipsoide, a: v3(head_c + Vec3::new(0.0, head_ry * 0.92 + alto * 0.35, -head_r * 0.10)), ra: 0.0, b: v3(Vec3::new(head_rx * 0.16, alto, head_r * 0.80)), rb: 0.0, color: hair, blend: head_r * 0.10, }); } Peinado::Melena => { prims.push(cap(1.04, 0.40, -0.18)); for s in [-1.0f32, 1.0] { prims.push(mecha( head_c + Vec3::new(s * head_rx * 0.86, head_ry * 0.35, -head_r * 0.10), head_r * 0.30, head_c + Vec3::new(s * head_rx * 0.92, -head_ry * 0.95, -head_r * 0.12), head_r * 0.26, )); } } Peinado::Larga => { prims.push(cap(1.05, 0.40, -0.18)); // La cortina de atrás: baja hasta media espalda. let largo = (head_c.y - shoulder_y) * 0.5 + head_ry * 1.5; prims.push(mecha( head_c + Vec3::new(0.0, head_ry * 0.30, -head_r * 0.72), head_rx * 0.86, head_c + Vec3::new(0.0, -largo, -head_r * 0.55), head_rx * 0.70, )); if !lejos { for s in [-1.0f32, 1.0] { prims.push(mecha( head_c + Vec3::new(s * head_rx * 0.80, head_ry * 0.30, 0.0), head_r * 0.26, head_c + Vec3::new(s * head_rx * 0.86, -head_ry * 1.1, -head_r * 0.10), head_r * 0.20, )); } } } Peinado::Rodete => { prims.push(cap(1.02, 0.42, -0.18)); prims.push(SdfPrim { kind: SdfKind::Esfera, a: v3(head_c + Vec3::new(0.0, head_ry * 1.05, -head_r * 0.42)), ra: head_r * 0.42, b: v3(head_c), rb: 0.0, color: hair, blend: head_r * 0.16, }); } Peinado::Coleta => { prims.push(cap(1.02, 0.42, -0.18)); prims.push(mecha( head_c + Vec3::new(0.0, head_ry * 0.55, -head_r * 0.85), head_r * 0.30, head_c + Vec3::new(0.0, -head_ry * 0.75, -head_r * 1.25), head_r * 0.22, )); } Peinado::DosColas => { prims.push(cap(1.02, 0.42, -0.18)); for s in [-1.0f32, 1.0] { prims.push(mecha( head_c + Vec3::new(s * head_rx * 0.90, head_ry * 0.45, -head_r * 0.30), head_r * 0.26, head_c + Vec3::new(s * head_rx * 1.30, -head_ry * 0.55, -head_r * 0.55), head_r * 0.20, )); } } } } /// Manto, falda, barba, anteojos y sombrero — en ese orden, que es el de adentro hacia /// afuera. Todos son geometría salvo los anteojos, que son decals: un aro fino de /// geometría a escala de escena (~2 voxels de alto) desaparecería bajo el `blend`. #[allow(clippy::too_many_arguments)] fn vestir( a: &Atuendo, head_c: Vec3, hr: Vec3, head_r: f32, shoulder_y: f32, hip_y: f32, torso_rx: f32, eoff: f32, er: f32, ex: f32, col: &Colores, detalle: DetalleSer, prims: &mut Vec, ) { let (head_rx, head_ry, head_rz) = (hr.x, hr.y, hr.z); let lejos = detalle == DetalleSer::Lejos; // --- Manto: cono redondeado que cuelga de los hombros por detrás. Va primero // porque es lo de más adentro: la falda y el pelo largo se apoyan encima. if a.manto > 0.01 { let color = a.color_manto_efectivo(col.shirt); let m = a.manto.clamp(0.0, 1.0); let pie = (shoulder_y * (0.55 - 0.50 * m)).max(head_r * 0.15); prims.push(SdfPrim { kind: SdfKind::Capsula, a: v3(Vec3::new(head_c.x, shoulder_y, head_c.z - torso_rx * 0.55)), ra: torso_rx * 0.80, b: v3(Vec3::new(head_c.x * 0.5, pie, -torso_rx * 0.90)), rb: torso_rx * (0.95 + 0.25 * m), color, blend: torso_rx * 0.20, }); } // --- Falda / vestido: campana desde la cadera. Las piernas siguen ahí abajo. if a.falda > 0.01 { let color = a.color_falda_efectivo(col.shirt); let largo = hip_y * (0.30 + 0.55 * a.falda.clamp(0.0, 1.0)); prims.push(SdfPrim { kind: SdfKind::Capsula, a: v3(Vec3::new(0.0, hip_y + torso_rx * 0.10, 0.0)), ra: torso_rx * 0.60, b: v3(Vec3::new(0.0, hip_y - largo, 0.0)), rb: torso_rx * (0.85 + 0.35 * a.falda), color, blend: torso_rx * 0.35, }); } // --- Barba: bultos por el mentón y las mejillas bajas. if a.barba > 0.01 && !lejos { let b = a.barba.clamp(0.0, 1.0); let barba_col = [col.hair[0] * 0.94, col.hair[1] * 0.92, col.hair[2] * 0.92]; let y = -head_ry * (0.52 + 0.12 * b); prims.push(SdfPrim { kind: SdfKind::Elipsoide, a: v3(head_c + Vec3::new(0.0, y, head_rz * 0.34)), ra: 0.0, b: v3(Vec3::new( head_rx * (0.58 + 0.24 * b), head_ry * (0.24 + 0.26 * b), head_rz * (0.60 + 0.20 * b), )), rb: 0.0, color: barba_col, blend: head_r * 0.14, }); if b > 0.55 { // Bigote: une la barba con la nariz. prims.push(SdfPrim { kind: SdfKind::DecalCapsula, a: v3(head_c + Vec3::new(-head_rx * 0.26, y + head_ry * 0.42, head_rz * 0.86)), ra: head_r * 0.10, b: v3(head_c + Vec3::new(head_rx * 0.26, y + head_ry * 0.42, head_rz * 0.86)), rb: head_r * 0.10, color: barba_col, blend: 0.1, }); } } // --- Anteojos: dos aros y el puente, como decals sobre la cara. if a.anteojos && !lejos { let marco = [0.20, 0.19, 0.22]; let y = eoff; for s in [-1.0f32, 1.0] { let n = 8; let pts: Vec = (0..=n) .map(|i| { let t = i as f32 / n as f32 * std::f32::consts::TAU; let lx = s * ex + t.cos() * er * 1.15; let ly = y + t.sin() * er * 1.15; let nx = (lx / head_rx).clamp(-0.95, 0.95); let ny = (ly / head_ry).clamp(-0.95, 0.95); let z = head_rz * (1.0 - nx * nx - ny * ny).max(0.04).sqrt(); head_c + Vec3::new(lx, ly, z + er * 0.12) }) .collect(); for w in pts.windows(2) { prims.push(SdfPrim { kind: SdfKind::DecalCapsula, a: v3(w[0]), ra: er * 0.10, b: v3(w[1]), rb: er * 0.10, color: marco, blend: 0.1, }); } } let puente = |lx: f32| { let nx: f32 = (lx / head_rx).clamp(-0.95, 0.95); let ny: f32 = (y / head_ry).clamp(-0.95, 0.95); head_c + Vec3::new(lx, y, head_rz * (1.0 - nx * nx - ny * ny).max(0.04).sqrt() + er * 0.12) }; prims.push(SdfPrim { kind: SdfKind::DecalCapsula, a: v3(puente(-ex * 0.35)), ra: er * 0.08, b: v3(puente(ex * 0.35)), rb: er * 0.08, color: marco, blend: 0.1, }); } // --- Sombrero. let color = a.color_sombrero_efectivo(col.shirt); match a.sombrero { Sombrero::Ninguno => {} Sombrero::Gorro => { prims.push(SdfPrim { kind: SdfKind::Elipsoide, a: v3(head_c + Vec3::new(0.0, head_ry * 0.42, -head_r * 0.08)), ra: 0.0, b: v3(Vec3::new(head_rx * 1.10, head_ry * 0.86, head_rz * 1.10)), rb: 0.0, color, blend: head_r * 0.10, }); prims.push(SdfPrim { kind: SdfKind::Esfera, a: v3(head_c + Vec3::new(0.0, head_ry * 1.28, -head_r * 0.06)), ra: head_r * 0.22, b: v3(head_c), rb: 0.0, color: [color[0] * 1.2, color[1] * 1.2, color[2] * 1.2], blend: head_r * 0.08, }); } Sombrero::Gorra => { prims.push(SdfPrim { kind: SdfKind::Elipsoide, a: v3(head_c + Vec3::new(0.0, head_ry * 0.46, -head_r * 0.12)), ra: 0.0, b: v3(Vec3::new(head_rx * 1.08, head_ry * 0.72, head_rz * 1.06)), rb: 0.0, color, blend: head_r * 0.10, }); // Visera: elipsoide chato que sale al frente. prims.push(SdfPrim { kind: SdfKind::Elipsoide, a: v3(head_c + Vec3::new(0.0, head_ry * 0.30, head_rz * 1.10)), ra: 0.0, b: v3(Vec3::new(head_rx * 0.90, head_ry * 0.10, head_rz * 0.62)), rb: 0.0, color: [color[0] * 0.82, color[1] * 0.82, color[2] * 0.82], blend: head_r * 0.08, }); } Sombrero::Ala => { prims.push(SdfPrim { kind: SdfKind::Elipsoide, a: v3(head_c + Vec3::new(0.0, head_ry * 0.70, -head_r * 0.06)), ra: 0.0, b: v3(Vec3::new(head_rx * 0.92, head_ry * 0.62, head_rz * 0.92)), rb: 0.0, color, blend: head_r * 0.10, }); prims.push(SdfPrim { kind: SdfKind::Elipsoide, a: v3(head_c + Vec3::new(0.0, head_ry * 0.62, -head_r * 0.04)), ra: 0.0, b: v3(Vec3::new(head_rx * 1.70, head_ry * 0.10, head_rz * 1.70)), rb: 0.0, color, blend: head_r * 0.08, }); } Sombrero::Panuelo => { prims.push(SdfPrim { kind: SdfKind::Elipsoide, a: v3(head_c + Vec3::new(0.0, head_ry * 0.30, -head_r * 0.14)), ra: 0.0, b: v3(Vec3::new(head_rx * 1.14, head_ry * 1.02, head_rz * 1.12)), rb: 0.0, color, blend: head_r * 0.12, }); prims.push(SdfPrim { kind: SdfKind::Capsula, a: v3(head_c + Vec3::new(0.0, -head_ry * 0.20, -head_r * 0.60)), ra: head_rx * 0.80, b: v3(head_c + Vec3::new(0.0, -head_ry * 1.60, -head_r * 0.50)), rb: head_rx * 0.70, color, blend: head_r * 0.16, }); } Sombrero::Casco => { prims.push(SdfPrim { kind: SdfKind::Elipsoide, a: v3(head_c + Vec3::new(0.0, head_ry * 0.52, -head_r * 0.06)), ra: 0.0, b: v3(Vec3::new(head_rx * 1.14, head_ry * 0.90, head_rz * 1.14)), rb: 0.0, color, blend: head_r * 0.08, }); prims.push(SdfPrim { kind: SdfKind::Elipsoide, a: v3(head_c + Vec3::new(0.0, head_ry * 0.34, head_rz * 0.75)), ra: 0.0, b: v3(Vec3::new(head_rx * 1.05, head_ry * 0.12, head_rz * 0.85)), rb: 0.0, color: [color[0] * 0.85, color[1] * 0.85, color[2] * 0.85], blend: head_r * 0.06, }); } Sombrero::Corona => { // El aro: un elipsoide chato apoyado en la coronilla. Junta casi dura // (blend chico) para que el oro se despegue del pelo en vez de fundirse. let aro_y = head_ry * 0.80; let (rx, rz) = (head_rx * 0.74, head_rz * 0.74); prims.push(SdfPrim { kind: SdfKind::Elipsoide, a: v3(head_c + Vec3::new(0.0, aro_y, -head_r * 0.06)), ra: 0.0, b: v3(Vec3::new(rx, head_ry * 0.18, rz)), rb: 0.0, color, blend: head_r * 0.03, }); // Las puntas: cinco conos que salen del aro, uno al frente. A lo lejos // tres alcanzan para que la silueta diga «corona». let puntas: &[f32] = if lejos { &[0.0, 2.2, -2.2] } else { &[0.0, 1.25, -1.25, 2.5, -2.5] }; for &ang in puntas { let base = head_c + Vec3::new(ang.sin() * rx * 0.92, aro_y + head_ry * 0.10, ang.cos() * rz * 0.92 - head_r * 0.06); prims.push(SdfPrim { kind: SdfKind::Capsula, a: v3(base), ra: head_r * 0.13, b: v3(base + Vec3::new(0.0, head_ry * 0.42, 0.0)), rb: head_r * 0.04, color, blend: head_r * 0.03, }); } } } } /// El oro de una corona cuando nadie le dio color. const ORO: [f32; 3] = [0.95, 0.74, 0.22];