Sistema de Animaciones (pixelsculp_anim)
Motor de animación tipado, determinista y sin dependencias para interfaces OpenGL 2D. Springs físicos, tweens con curvas CSS, flings con decay, keyframes y secuencias coreografiadas.
- Información general
- Fundamentos de la animación
- Curvas con diagramas
- Springs: física real
- Ejemplo fotograma a fotograma
- Filosofía de diseño
- Inicio rápido
- Modelo conceptual
- Tipos de movimiento
- Curvas y easing
- Interpolación genérica
- Keyframes
- Secuencias coreografiadas
- Control de reproducción
- El Animator en profundidad
- Integración con widgets
- Rendimiento
- Aplicaciones y recetas
- Referencia de API
- Glosario de bolsillo
- Buenas prácticas y limitaciones
Información general
pixelsculp_anim es un paquete Zig que se consume por separado y hace una sola cosa: animar valores tipados. Le dices al motor «este campo de mi struct va desde donde está hasta este objetivo», y en cada fotograma deja el valor interpolado escrito en ese campo. Tu código de render no participa en nada de esto: lee la variable y dibuja.
Tampoco renderiza ni mide el tiempo; el delta de tu bucle de render lo pasas tú. Con esas dos restricciones sale algo interesante gratis: casi cualquier propiedad numérica de tus objetos (posiciones, opacidades, colores, radios, ángulos, offsets de scroll…) se anima con el mismo mecanismo.
| Capacidad | Herramienta | Caso típico |
|---|---|---|
| Movimiento físico realista con rebote | SpringParams + Animator.spring_to | Botón que se hunde y regresa, paneles que entran con overshoot |
| Transiciones con curva de timing determinista | TweenSpec + Curve | Fade de opacidad, deslizamientos con ease_out |
| Inercia de arrastre que se apaga sola | Animator.decay_from | Fling de scroll táctil |
| Rutas multi-punto con easing por tramo | Track(T) | @keyframes: recorridos complejos |
| Coreografía paso a paso | Animator.sequence_to | Toast: entra → espera → sale |
| Control global del tiempo | time_scale, pause_all | Cámara lenta, depuración, pausa del juego |
Fundamentos de la animación
Esta sección es la rampa de entrada si nunca has programado animaciones. Explica qué es animar, cómo lo hace una computadora, y cada término técnico con analogías y números. Si ya dominas los conceptos, salta directo a Inicio rápido.
¿Qué significa «animar» para una computadora?
Una pantalla solo sabe mostrar imágenes quietas: la computadora no mueve nada, jamás. Lo que llamamos animación es un truco de cine anterior a las computadoras mismas.
Analogía del cuadernillo. Dibuja una pelota en la esquina inferior de un cuaderno; en la página siguiente, dibújala un poco más arriba; repite hasta la última página. Al pasar las páginas rápido, tus ojos ven una pelota que sube. Cada dibujo es un fotograma (frame): una imagen fija. La ilusión nace de mostrar muchos fotogramas seguidos a gran velocidad.
| Medio | Fotogramas por segundo (FPS) |
|---|---|
| Cine antiguo | 24 |
| Pantalla normal | 60 |
| Monitor de alta frecuencia | 120–144 |
A 60 FPS tu programa redibuja toda la interfaz 60 veces por segundo — una vez cada ~16 milisegundos. La animación se reduce a una sola idea:
La regla de oro. Cambiar un número entre cada dibujo, poco a poco, para que el objeto parezca moverse suavemente. Ese número es el atributo: la posición x de un botón, su opacidad (0 = invisible, 1 = sólido), su tamaño, su color, su ángulo.
Interpolación: calcular el camino entre dos puntos
Quieres mover un botón de x = 100 a x = 500. Conoces el inicio y el final; lo que falta saber es dónde ponerlo en cada fotograma intermedio. Calcular esos puntos se llama interpolar, y la fórmula más simple es el lerp (linear interpolation, interpolación lineal):
valor_actual = inicio + (fin − inicio) × t
donde t ∈ [0..1] significa «¿qué tan avanzado voy?»
t = 0 → al inicio (botón en x = 100)
t = 0.5 → a la mitad (botón en x = 300)
t = 1 → destino alcanzado (botón en x = 500)Analogía del viaje. Sales de Madrid hacia Barcelona. t es tu respuesta a «¿cuánto del camino llevo?»: con t = 0.25, vas por el primer cuarto del trayecto. La fórmula convierte «avance del viaje» en «posición en el mapa», nada más.
¿De dónde sale t? El reloj y el dt
Alguien debe decirte tu avance. En la vida real, el reloj: si la animación dura 1 segundo y han pasado 0.4, entonces t = 0.4 / 1.0 = 0.4. Tu librería funciona así:
- Llamas
animator.update(dt)una vez por fotograma. dt(delta de tiempo) es cuánto tardó el fotograma anterior — típicamente 0.016 s.- El animator suma ese tiempo a su contador interno y calcula
t. - Escribe el valor resultante directamente en tu variable (
boton.x = 347.2). Tu código de dibujo solo la lee.
Término técnico — determinista. La librería nunca consulta el reloj del sistema: tú le entregas el tiempo. Si grabas los dt y los reproduces, obtienes exactamente la misma animación — como una receta que da siempre el mismo pastel con los mismos ingredientes.
Curvas de easing: la personalidad del movimiento
Si usas t tal cual, el movimiento va a velocidad perfectamente constante y se ve robótico, como un ascensor barato. Analogía del auto: al arrancar aceleras (lento→rápido); al llegar frenas (rápido→suave). Nada natural viaja a velocidad constante de golpe.
Suavizar el movimiento se llama easing: en vez de usar t directo, se pasa por una función matemática que lo distorsiona. Con t = 0.5, el valor ya no avanza necesariamente el 50 % del camino — depende de la curva. Estas son las cinco familias que ofrece pixelsculp_anim, dibujadas (valor vertical, tiempo horizontal):
cubic-bezier() de CSS. Esta es la preset material-emphasized.Springs: física real, rebote incluido
Ninguna curva fija reproduce bien el rebote. Para eso están los springs (resortes), que simulan física real.
Analogía del muelle con pesa. Cuelgas un resorte con una pesa, la jalas hacia abajo y sueltas: sube, se pasa de su punto de equilibrio, vuelve, pasa menos, y oscila cada vez menos hasta quedarse quieta. Es exactamente lo que ves cuando un botón «rebota» al soltarlo:
Tres perillas controlan el comportamiento:
| Perilla | Analogía | Qué controla |
|---|---|---|
stiffness (rigidez) | Dureza del muelle | Alta = nervioso y rápido; baja = flotante y perezoso |
damping (amortiguación) | Amortiguador del auto | Baja = rebota mucho; alta = apenas oscila |
mass (masa) | Peso colgado | Pesada = lento y solemne; ligera = ágil |
// Forma física: constantes directas.
try an.spring_to(f32, &escala, 1.0, .{ .stiffness = 380, .damping = 26, .mass = 1 });
// Forma descriptiva: preguntas en español.
// "¿cuánto tarda en asentarse? ¿cuánto rebota?"
try an.spring_to(f32, &escala, 1.0, anim.SpringParams.duration_bounce(0.35, 0.25));Los presets snappy_spring / bouncy_spring / gentle_spring empaquetan personalidades listas (ver Tipos de movimiento). Y hacen algo que las curvas fijas no pueden imitar: si a mitad de vuelo cambias el destino (retargeting), el spring parte de donde está conservando su velocidad — como una pelota a la que le pegan otra vez mientras vuela. Por eso la regla práctica: springs para responder al usuario, curvas para coreografía planificada.
Decay: la fricción del mundo físico
El tercer movimiento simula fricción: le das una velocidad inicial y el valor frena por sí solo hasta parar.
Analogía del vaso. Empujas un vaso sobre la mesa: sale rápido, pierde velocidad por la fricción y se detiene gradualmente. Nunca rebota ni acelera — solo muere suavemente. Es exactamente el flick de tu teléfono: la lista sigue deslizándose después de soltar el dedo.
// Al soltar el gesto con velocidad del dedo:
_ = try an.decay_from(&scroll_y, velocidad_del_dedo, .{});
// λ mayor = frena antes:
_ = try an.decay_from(&scroll_y, velocidad_del_dedo, .{ .deceleration = 4.0 });Ejemplo numérico, fotograma a fotograma
Animemos la opacidad de una ventana de 0.0 (invisible) a 1.0 (visible), duración 1 segundo, con ease_out, viendo 10 fotogramas por simplicidad:
| Fotograma | t crudo | ease_out(t) | opacidad escrita | Lo que ves |
|---|---|---|---|---|
| 0 | 0.00 | 0.00 | 0.00 | invisible |
| 1 | 0.10 | 0.19 | 0.19 | ¡aparece de golpe! |
| 2 | 0.20 | 0.36 | 0.36 | bien visible |
| 3 | 0.30 | 0.51 | 0.51 | mitad, desacelerando |
| 5 | 0.50 | 0.75 | 0.75 | casi lista |
| 10 | 1.00 | 1.00 | 1.00 | sólida, fin |
Observa la columna de ease_out(t): con t crudo el fotograma 1 mostraría opacidad 0.10; con ease_out muestra 0.19. La curva adelanta el progreso al inicio y lo frena al final — ese es todo el secreto. El código completo cabe en tres líneas:
var opacidad: f32 = 0.0;
// Al abrir la ventana:
_ = try animator.tween_to(f32, &opacidad, 1.0,
.{ .duration_s = 1.0, .curve = anim.curve.ease_out });
// En cada fotograma del loop principal:
try animator.update(dt); // escribe opacidad sola
ventana.dibujar(opacidad); // tú solo leesFilosofía de diseño
- Tiempo inyectado por el caller. El motor nunca consulta el reloj:
Animator.update(dt)avanza todo con el dt que le pases. Esto lo hace determinista, testeable headless y compatible con cualquier bucle (GLFW, juego pausado, replay grabado). - Cero dependencias. Ni GL, ni libc, ni hilos, ni globales. Solo
std. Se consume como módulo Zig por sí mismo, sin necesidad de linkear el resto de pixelsculp. - Escribe directo en sus campos. No hay capa de nodos de escena ni observadores: el valor final vive en su variable, y su layout/hit-test/render existente lo lee gratis.
- Genérico por duck typing estructural. Cualquier tipo hecho de floats (los suyos incluidos) es animable vía
mix()recursivo en tiempo de compilación. UnVec2, unColoro su propioRectfuncionan igual. - Los springs animan el progreso, no el valor. Toda la maquinaria física mueve un parámetro [0..1]; el valor visible es
mix(base, objetivo, progreso). Así un solo mecanismo da overshoot correcto para escalares, vectores y colores a la vez.
Nota. Los números de rendimiento citados en este documento se midieron con compilación ReleaseFast sobre x86_64, con miles de animaciones concurrentes. En una UI típica (decenas de animaciones activas) el coste de simulación es inferior al 0,05 % del presupuesto de un fotograma de 16 ms.
Inicio rápido
1. Añadir la dependencia en build.zig.zon
.{
.name = .mi_app,
.version = "0.1.0",
.dependencies = .{
// Ruta relativa (o hash tras `zig fetch --save ../animations`)
.pixelsculp_anim = .{ .path = "../animations" },
},
}2. Importar el módulo en build.zig
const anim_dep = b.dependency("pixelsculp_anim", .{});
const anim_mod = anim_dep.module("pixelsculp_anim");
const exe = b.addExecutable(.{ .name = "mi_app", .root_module = exe_mod });
exe_mod.addImport("pixelsculp_anim", anim_mod);3. Animar algo
const std = @import("std");
const anim = @import("pixelsculp_anim");
var escala_boton: f32 = 1.0;
pub fn main() !void {
var animator = anim.Animator.init(std.heap.page_allocator);
defer animator.deinit();
// El usuario presiona un botón: pedimos un spring y ya está.
// El animator escribirá `escala_boton` en cada update(dt).
_ = try animator.spring_to(
f32,
&escala_boton,
0.94,
anim.snappy_spring, // preset físico (ver "Tipos de movimiento")
);
while (true) {
const dt = esperar_fotograma(); // su bucle de render
try animator.update(dt); // avanza TODO y escribe campos
dibujar_boton_con_escala(escala_boton);
}
}
fn esperar_fotograma() f32 {
// dt real de su plataforma (GLFW: glfwGetTime; juego: tick fijo).
return 1.0 / 60.0;
}Eso es todo el contrato: pedir una animación hacia un objetivo y llamar a update(dt) una vez por fotograma. Todo lo demás en este documento son variaciones sobre ese patrón.
Modelo conceptual
Las tres piezas
| Pieza | Qué es | Cuándo usarla |
|---|---|---|
Animation(T) |
Una animación concreta de un valor de tipo T, entre un punto base y un objetivo, gobernada por un MotionSpec. |
Uso explícito: tú posees la instancia, la avanzas a mano y lees value(). Útil para motores propios, tests o efectos muy personalizados. |
Animator |
Registro implícito de animaciones keyed por la dirección del campo destino. Tú pides «anima ese campo» y él se encarga de crear, retargetear, escribir y limpiar. | El 95 % de los casos en una UI. Modo recomendado. |
Chain(T) |
Secuencia de segmentos sobre un mismo campo, creada por sequence_to. Una sola Animation que se re-siembra tramo a tramo. |
Coreografías: entrar-esperar-salir, subida con rebote final, etc. |
MotionSpec: la descripción del movimiento
Un MotionSpec es una unión con tres familias:
.tween— duración fija + curva de easing. Determinista, predecible, estilo CSS/WAAPI..spring— oscilador amortiguado masa-resorte. Interrumpible con elegancia, respuesta natural..decay— fricción exponencial sobre una velocidad inicial. Para fling/inercia (solof32).
// Las tres formas de describir "ve a 240":
try an.spring_to(f32, &x, 240, .{ .stiffness = 300, .damping = 22 }); // física directa
try an.tween_to (f32, &x, 240, .{ .duration_s = 0.35 }); // duración + curva
try an.animate_to(f32, &x, 240, anim.snappy); // MotionSpec preset¿Tween o spring?
| Usa tween cuando… | Usa spring cuando… |
|---|---|
| La duración exacta importa (coreografías sincronizadas). | La interrupción a mitad de vuelo es probable (el usuario manda). |
| Quiere reproducibilidad total (mismo input → misma curva). | Busca sensación natural sin diseñar curvas. |
| Es una transición de estado simple (hover, fade). | Es una respuesta directa a un gesto (drag, press). |
Tipos de movimiento
Tween
El movimiento predecible: dura exactamente lo que pidas y sigue la curva que le pongas. Acepta delay_s y todas las opciones de reproducción (repeat, fill…). Por dentro, el solver de bézier resuelve con Newton y bisección reutilizando la raíz del fotograma anterior como semilla, lo que ahorra alrededor de un tercio del coste.
// Opacidad de 0 a 1 en 250 ms con la curva preset ease_out.
_ = try an.tween_to(f32, &opacidad, 1.0, .{
.duration_s = 0.25,
.curve = anim.curve.ease_out,
});
// Bézier cúbica custom (mismos puntos de control que cubic-bezier() de CSS).
_ = try an.tween_to(f32, &offset_y, -120, .{
.duration_s = 0.45,
.curve = .{ .bezier = .{ .x1 = 0.16, .y1 = 1, .x2 = 0.3, .y2 = 1 } },
});
// Las familias Penner que un bézier no puede expresar: bounce, elastic,
// back, circ, expo, quart, quint, sine, quad, cubic + smoothstep, siempre
// en tres direcciones (_in / _out / _in_out). Extremos exactos garantizados:
// eval(0) == 0 y eval(1) == 1 sin residuo de coma flotante.
_ = try an.tween_to(f32, &escala, 1.0, .{ .duration_s = 0.5, .curve = anim.curve.bounce_out });
_ = try an.tween_to(f32, &brillo, 1.0, .{ .duration_s = 0.7, .curve = anim.curve.elastic_in_out });Todas las curvas también existen como funciones puras en anim.curve.easings (mismo nombre: easings.bounce_out(t)), por si quieres muestrear fuera de una animación — pintar una preview, generar keyframes, probar valores. Y si ninguna combinación familia+dirección te vale, los adaptadores comptime de anim.curve.adapt fabrican funciones nuevas sin estado: time_flip mueve el efecto al otro extremo del tiempo, value_flip invierte la salida, pair(a, b) compone entrada+salida por mitades y blend(f, g, k) mezcla dos curvas con un peso fijo.
Spring
Oscilador amortiguado integrado con Euler semi-implícito a subpasos fijos de 1/240 s: estable ante cualquier dt (incluso hitchs de 200 ms) y con detección de reposo por subpaso, de modo que el tiempo sobrante no se pierde. Se puede parametrizar de dos formas equivalentes:
// Forma física: constantes directas del resorte.
try an.spring_to(f32, &escala, 1.0, .{ .stiffness = 380, .damping = 26, .mass = 1 });
// Forma descriptiva: ¿cuánto tarda en asentarse? ¿cuánto rebota?
try an.spring_to(f32, &escala, 1.0, anim.SpringParams.duration_bounce(
0.35, // response_s: tiempo aproximado de asentamiento
0.25, // bounce: 0 = sin rebote, 1 = rebote infinito
));Preset de SpringParams | Sensación | Ideal para… |
|---|---|---|
anim.snappy_spring | Rápida, mínima oscilación | Feedback de botones, toggles, tooltips |
anim.bouncy_spring | Juguetona, con rebote visible | Confirmaciones lúdicas, stickers, badges |
anim.gentle_spring | Lenta y amortiguada | Paneles grandes, cámara, fondos |
Sugerencia. ¿Llamas spring_to sobre un campo que ya vuela hacia otro lado? No pasa nada: el animator retargetea. Parte del valor visible actual, conserva la velocidad que llevaba el spring anterior y la transición continúa sin saltos. Funciona siempre, sin configurar nada.
Decay (fling)
Aplica fricción exponencial v' = −λv a una velocidad inicial: el valor se desplaza cada vez más despacio hasta parar, terminando exactamente en v₀/λ. Es el movimiento natural del scroll inercial. Solo admite f32 (usa init_decay o decay_from).
// Al soltar el dedo con velocidad vy (px/s):
_ = try an.decay_from(&scroll_offset, velocidad_y, .{});
// Con parámetros explícitos: deceleration = λ (1/s). Mayor λ, frena antes.
_ = try an.decay_from(&scroll_offset, velocidad_y, .{ .deceleration = 4.0 });Advertencia. decay_from exige puntero a f32 (compila y falla con otro tipo). Además, los decays no pueden formar parte de un sequence_to: el motor rechaza la secuencia con error.DecaySegmentUnsupported antes de tocar el registro.
Curvas y easing
Una Curve es una función t→t′ aplicada al tiempo normalizado del tween. La unión soporta:
| Variante | Comportamiento |
|---|---|
linear | Velocidad constante. |
hold | Nada hasta el final (útil en tracks como «mantener y saltar»). |
bezier | Bézier cúbica con puntos de control (x1,y1,x2,y2), igual que cubic-bezier() de CSS. |
steps | N escalones discretos, anclados al inicio o al final (estilo CSS steps()). |
Presets disponibles en anim.curve: ease_in, ease_out, ease_in_out, ease y linear (equivalentes exactos a los de CSS).
// Curva material-emphasized clásica.
const emphasized = anim.Curve{ .bezier = .{
.x1 = 0.2, .y1 = 0.0, .x2 = 0.0, .y2 = 1.0,
} };
_ = try an.tween_to(f32, &panel_x, 320, .{ .duration_s = 0.4, .curve = emphasized });
// Contador tipo máquina tragaperras: 10 saltos discretos.
_ = try an.tween_to(i32, &contador, 10, .{ .duration_s = 1.0, .curve = .{ .steps = .{
.count = 10, .jump_end = false,
} } });Interpolación genérica
El corazón tipado del sistema es mix(comptime T, a, b, t): interpolación recursiva en tiempo de compilación sobre estructuras hechas de floats. No necesita que el tipo declare nada especial:
f32/f64: lerp directo.- Enteros: conversión con redondeo y saturación (nunca undefined).
- Structs y arrays: mezcla campo a campo, en profundidad.
- No recorta t a [0,1]: con t>1 extrapola linealmente, requisito del overshoot de los springs.
const Color = struct { r: f32, g: f32, b: f32, a: f32 };
const negro = Color{ .r = 0, .g = 0, .b = 0, .a = 1 };
const azul = Color{ .r = 0.1, .g = 0.4, .b = 1.0, .a = 1 };
// Directo, sin animator:
const medio = anim.mix(Color, negro, azul, 0.5);
// Y animado — el struct completo viaja solo:
_ = try an.tween_to(Color, &tema_actual, azul, .{ .duration_s = 0.3 });Nota. De hecho, cualquiera de tus propios structs compuestos solo de floats y arrays de floats se anima igual. En cuanto aparece un campo no numérico (un enum, por ejemplo) el motor ya no sabe mezclarlo; en ese caso anima un f32 de progreso y deriva el resto tú, o mantiene el tipo plano.
Keyframes (Track(T))
Un track define paradas con offset normalizado [0..1] y easing por segmento, equivalente conceptual a @keyframes de CSS. No reserva memoria y se muestrea con búsqueda binaria.
const track = anim.Track(f32).init(&.{
.{ .offset = 0.0, .value = 0 }, // inicio en 0 (easing linear)
.{ .offset = 0.6, .value = 120, .easing = anim.curve.ease_out }, // llega pronto…
.{ .offset = 0.8, .value = 110, .easing = anim.curve.ease_in }, // …rebota atrás…
.{ .offset = 1.0, .value = 120 }, // …y se asienta.
});
// Muestreo manual dentro de su propio driver:
const valor = track.sample(progreso); // progreso ∈ [0,1]Un track vacío devuelve cero (nunca undefined), y puede combinarlo con tweens usando el track como fuente de valores.
Secuencias coreografiadas
sequence_to encadena tramos sobre un mismo campo. Internamente es una única Animation (una Chain) que se re-siembra en cada frontera de tramo; el delta de tiempo residual de un tramo que termina rueda hacia el siguiente sin cortes, así que no hay micro-parpadeos aunque los tramos cambien a mitad de fotograma.
// Ciclo de vida completo de un toast, una sola llamada:
_ = try an.sequence_to(f32, &toast_y, &.{
.{ .to = 64, .spec = anim.snappy }, // entra deslizándose
.{ .to = 64, .spec = .{ .tween = .{ .duration_s = 2.0 } },
.delay_s = 0.15 }, // descansa (0,15 s de respiro extra)
.{ .to = -80, .spec = anim.gentle_spring }, // sale suave
});
// Secuenciar sobre un campo que YA tiene una secuencia reemplaza la
// secuencia completa (no se mezclan ni se corrompen nodos).Importante. Ningún tramo de una secuencia puede ser decay (el motor valida antes de registrar y devuelve error.DecaySegmentUnsupported). Si necesita «fling y luego snap», encadénelo a mano: escuche el fin del decay en on_complete y lance allí el spring.
Control de reproducción
Toda Animation(T) —incluida la que vive dentro del animator— expone las mismas palancas. El handle devuelto por los métodos *_to es efímero pero válido para configurar justo después de crearla:
const h = try an.tween_to(f32, &opacidad, 1.0, .{ .duration_s = 0.5 });
h.delay_left = 0.3; // espera 300 ms antes de empezar
h.speed = 2.0; // al doble de velocidad
h.repeat = .{ .count = 4, .mode = .reverse }; // ida-y-vuelta ×4 (par → acaba en base)
h.repeat = .{ .count = .infinite, .mode = .reverse }; // pulso eterno
h.fill = .none; // al terminar vuelve al valor base
h.pause(); // congela
_ = h.resume_anim();
h.finish_now(); // salta al estado final y libera
const quedaba = an.cancel(&opacidad); // true si había algo vivo en ese campo| Opción | Valores | Semántica |
|---|---|---|
repeat.count | entero o .infinite | Repeticiones totales. Con modo reverse y count par, la animación termina en el valor base (regla alternate de CSS). |
repeat.mode | .restart / .reverse | Reinicia desde el principio o alterna dirección en cada ciclo. |
fill | .forwards (defecto) / .none | Mantener el valor final o restaurar el base al completar. |
delay_left | segundos | Cuenta atrás previa al arranque (asignable también después, para stagger). |
Nota. Los repeats infinitos acotan el reloj interno a dos ciclos (precisión f32 estable incluso tras horas de pulso continuo), y max_dt del animator limita el salto máximo por update (por defecto 100 ms) después de aplicar time_scale, evitando teletransportes tras una pausa del proceso.
El Animator en profundidad
Registro keyed por campo
El animator indexa cada animación por la dirección de memoria del campo destino. Consecuencias prácticas:
- Pedir dos veces animación sobre el mismo campo no duplica nodos: retargetea el existente conservando la velocidad del spring.
- Campos distintos animan en paralelo sin interferirse.
cancel(&campo),is_animating(&campo)y amigos operan por esa clave.
Handles y callbacks
const Ctx = struct { toast: *Toast };
fn al_terminar(ctx_ptr: ?*anyopaque) void {
const ctx: *Ctx = @ptrCast(@alignCast(ctx_ptr.?));
ctx.toast.padre.remove(ctx.toast);
}
var ctx = Ctx{ .toast = &toast };
var h = try an.sequence_to(f32, &toast.y, &segmentos);
h.on_complete_fn = al_terminar;
h.on_complete_ctx = &ctx;Advertencia (contrato de reentrada). Durante update() se disparan callbacks y el motor está en dos fases (avanzar/escribir primero, liberar nodos muertos después). Por eso: un callback puede cancelar otros campos (an.cancel(&otro)), pero no debe destruir su propio holder, llamar an.clear() ni an.deinit(). Si necesita eso, prográmelo para el siguiente fotograma. Las llamadas anidadas a update() desde un callback se ignoran de forma segura.
Control global
an.time_scale = 0.25; // cámara lenta al 25 % (depuración, replay, hit-stop)
an.max_dt = 0.05; // clamp anti-hitch (tras escalar time_scale)
an.pause_all(); // menú abierto: todo congelado
an.resume_all(); // …y sigue donde estaba
_ = an.is_animating(); // ¿queda algo vivo? (útil para sostener el repaint)Ciclo de vida y memoria
Los nodos usan reciclaje interno de memoria (sin churn por fotograma). Cuando una animación termina: con fill = .forwards el campo queda clavado en el objetivo y el nodo se libera; con fill = .none el campo vuelve al base. En ambos casos el callback on_complete se entrega una vez.
Integración con widgets de pixelsculp
WidgetContext expone un animator opcional compartido por toda la jerarquía de widgets:
// En el arranque de la app (opcional):
ctx.anim = try anim.Animator.init(gpa);
// En el paint de un widget — patrón recomendado:
if (ctx.anim) |an| {
_ = try an.spring_to(f32, &self.pression, 1.0, anim.snappy_spring);
} else {
self.pression = 1.0; // sin animator: salto directo (compatibilidad demos)
}null= sin animaciones. Todos los widgets degradan a cambio instantáneo, de modo que los demos y tests headless no necesitan pump.- Pump automático: mientras haya animación activa el widget se marca dirty y notifica al render loop (
dirty_notify), así no hay que repintar a ciegas a 60 fps. - Cambio de tema en caliente:
WidgetContext.themees un puntero reasignable; las variantes visuales cambian al siguiente repaint sin tocar animaciones en vuelo.
Rendimiento
Mediciones (ReleaseFast, x86_64, 10 000 animaciones concurrentes simuladas durante 10 s de tiempo virtual):
| Operación | Coste por nodo y fotograma |
|---|---|
| Paso de spring (subpaso 1/240 incluido, settle check) | ~28 ns |
| Muestreo de tween con bézier (warm-start activo) | ~34 ns |
| Muestreo de tween sin warm-start (referencia) | ~47 ns |
| Escritura + housekeeping del animator | < 5 ns |
Traducido: 100 springs simultáneos cuestan menos de 3 µs por fotograma (~0,02 % de 16 ms). La simulación nunca será tu cuello de botella; si un frame va lento, busca en el render, no aquí.
Decisiones de arquitectura relevantes
- Subpasos a 240 Hz con settle por subpaso: estabilidad numérica ante dt arbitrarios y reporte exacto del tiempo consumido (el residuo alimenta la siguiente fase de una secuencia).
- Búsqueda binaria en tracks, warm-start del solver bézier (hint persistente entre frames) y reloj acotado en loops infinitos: precisión f32 sostenida durante horas.
- Registro lineal O(n) por búsqueda: deliberado. Con cientos de nodos el coste es ruido; una tabla hash añadiría indirección sin beneficio medible a escala UI.
- No hay offload a GPU: se evaluó y descartó con datos. El coste de CPU es ínfimo y leer resultados de vuelta (para layout, hit-test, física de UI) obligaría a stalls de sincronización que cuestan órdenes de magnitud más que simular en CPU. Lo mismo aplica a SIMD: un kernel SoA con
@Vectorgana ~10× en el bucle puro, pero empaquetar/desempaquetar los AoS dispersos de una UI real se come la ganancia.
Aplicaciones y recetas
Patrones listos para adaptar, ordenados por frecuencia de uso en una UI real.
1. Botón con feedback de presión
fn set_pressed(self: *Button, an: ?*anim.Animator, pressed: bool) !void {
if (an) |a| {
// Retargetea solo si ya estaba en vuelo; conserva velocidad.
_ = try a.spring_to(f32, &self.scale, if (pressed) 0.96 else 1.0,
anim.snappy_spring);
} else {
self.scale = if (pressed) 0.96 else 1.0;
}
}2. Hover con transición determinista
_ = try an.tween_to(f32, &self.hover_mix, if (dentro) 1.0 else 0.0,
.{ .duration_s = 0.15 });
// En el draw: fondo = mix(normal_bg, hover_bg, self.hover_mix);3. Toast: entra, espera, sale
_ = try an.sequence_to(f32, &toast.offset_y, &.{
.{ .to = 0, .spec = anim.snappy_spring },
.{ .to = 0, .spec = .{ .tween = .{ .duration_s = 2.5 } }, .delay_s = 0.2 },
.{ .to = -64, .spec = anim.curve.ease_in },
});
// En on_complete: liberar el toast.4. Scroll con fling y rebote de borde
// Al soltar el gesto:
if (velocidad != 0) {
_ = try an.decay_from(&scroll.y, velocidad, .{});
}
// En on_complete del decay (o si el fling se pasó del límite):
if (scroll.y > 0 or scroll.y < -alto_contenido) {
_ = try an.spring_to(f32, &scroll.y, std.math.clamp(scroll.y, -alto_contenido, 0),
anim.gentle_spring); // rubber-band de vuelta
}5. Pulso de atención infinito
var h = try an.tween_to(f32, &badge.alpha, 1.0, .{ .duration_s = 0.6 });
h.repeat = .{ .count = .infinite, .mode = .reverse };
// Se cancela solo: _ = an.cancel(&badge.alpha);6. Transición de tema (color estructural)
// Color es {r,g,b,a}: mix() recursivo hace el resto.
_ = try an.tween_to(Color, &self.fondo_actual, nuevo_tema.fondo,
.{ .duration_s = 0.3, .curve = anim.curve.ease_in_out });7. Entrada escalonada de una lista (stagger)
for (items, 0..) |*item, i| {
item.alpha = 0;
var h = try an.tween_to(f32, &item.alpha, 1.0, .{ .duration_s = 0.25 });
h.delay_left = @as(f32, @floatFromInt(i)) * 0.04; // cascada de 40 ms
}8. Elemento que persigue al cursor (drag con elasticidad)
// Cada fotograma mientras arrastra: retarget continuo hacia el puntero.
// El spring preserva velocidad => seguimiento elástico, sin saltos.
_ = try an.spring_to(Vec2, &tarjeta.pos, cursor_pos, anim.snappy_spring);9. Barra de progreso que no vibra
// El progreso real cambia a saltos (red, disco…); el spring lo suaviza.
_ = try an.spring_to(f32, &barra.mostrado, barra.real, anim.gentle_spring);
// Al terminar la carga: an.finish_now(&barra.mostrado) para clavar el 100 %.10. Cámara lenta para depurar o para drama
an.time_scale = 0.1; // inspecciona visualmente cualquier transición
an.time_scale = 1.0; // …y vuelva a la realidad.
// Hit-stop de juego: 60 ms de congelación parcial tras un golpe.
an.time_scale = 0.05;
try programar(0.06, volver_a_1);Referencia de API
Superficie pública principal (todas las funciones siguen la convención snake_case; los errores son error.DescriptiveError).
Animator — modo implícito
| Firma | Descripción |
|---|---|
init(allocator) Animator | Crea el registro. Uno por app (o por jerarquía aislada). |
deinit() | Libera todo nodo vivo. No llamar desde callbacks. |
update(dt) !void | Avanza todas las animaciones y escribe los campos. Una llamada por fotograma. |
animate_to(T, *T, to, MotionSpec) !*Animation(T) | Anima con cualquier spec (retargetea si ya existía para ese campo). |
spring_to(T, *T, to, SpringParams) !*Animation(T) | Atajo de spring. Preserva velocidad en retarget. |
tween_to(T, *T, to, TweenSpec) !*Animation(T) | Atajo de tween (duración + curva). |
decay_from(*f32, velocity, DecayParams) !void | Fling: fricción exponencial desde una velocidad. Solo f32. |
sequence_to(T, *T, []Segment(T)) !void | Coreografía multi-tramo con delay_s opcional por tramo. Falla con error.DecaySegmentUnsupported si algún tramo es decay. |
cancel(*campo) bool | Cancela la animación de ese campo (true si había). |
finish_now(*campo) | Fuerza el estado final inmediato y libera. |
is_animating(*campo) bool / is_animating() bool | ¿Hay animación en ese campo / en alguna parte? |
pause_all() / resume_all() | Congela/reanuda globalmente (los nodos sobreviven al pump pausados). |
clear() | Elimina todas las animaciones (fuera de callbacks). |
time_scale: f32 / max_dt: f32 | Escala temporal global / clamp de dt (defecto 0.1 s). |
Animation(T) — modo explícito
| Miembro | Descripción |
|---|---|
init(base, target, spec) | Crea entre dos valores con un MotionSpec. |
advance(dt) ?f32 | Avanza; devuelve el dt residual si terminó (para encadenar a mano). |
value() T | Valor interpolado actual. No es idempotente para beziers (±tolerancia del solver entre lecturas consecutivas). |
retarget(to) / retarget_spec(to, spec) | Cambia el objetivo a mitad de vuelo partiendo del valor visible (preserva velocidad del spring). |
pause() / resume_anim() / cancel() / finish_now() | Playback básico. |
delay_left, speed, repeat, fill | Ver tabla de control de reproducción. |
on_update_fn/ctx, on_complete_fn/ctx | Callbacks con contexto *anyopaque. Sobreviven a retargets. |
Descripciones de movimiento
| Tipo | Campos clave |
|---|---|
TweenSpec | duration_s, curve (Curve, defecto linear), delay_s. |
SpringParams | stiffness/damping/mass o duration_bounce(response_s, bounce). Params no físicos se assertionan y saturan en init. |
DecayParams | deceleration (λ ≥ 0; ≤0 se assertiona y satura). |
Repeat | count (entero o .infinite), mode (.restart/.reverse). |
FillMode | .forwards (defecto) / .none. |
Segment(T) | to, spec, delay_s (para sequence_to). |
Utilidades
| Símbolo | Descripción |
|---|---|
mix(T, a, b, t) | Lerp comptime recursivo (structs/arrays de floats, enteros con saturación, extrapolación permitida). |
clamp01 / ilerp / remap / eerp | Aritmética de rangos: acotar, inverso del lerp, reescalar entre rangos, lerp exponencial (escalas). |
damp(dt, half_life_s) | Factor de suavizado exponencial independiente del framerate: valor += (objetivo − valor) * damp(dt, 0.1). |
Curve / CubicBezier / Penner | linear/hold/bezier/steps/penner; eval(x) y eval_seed(x, hint, &out) (warm-start). |
anim.curve.* | Presets: CSS (ease, ease_in…) + set Penner completo (bounce_out, elastic_in, circ_in_out… quad..bounce × in/out/in_out, smoothstep/smootherstep). |
anim.curve.easings.* | Las mismas curvas como funciones puras fn(f32) f32, extremos exactos garantizados. |
anim.curve.adapt.* | Combinadores comptime sobre funciones de easing: time_flip, value_flip, pair, blend. |
anim.snappy / bouncy / gentle | MotionSpec presets listos para animate_to. |
anim.snappy_spring / bouncy_spring / gentle_spring | SpringParams presets para spring_to. |
Track(T) | Keyframes con offset + easing por segmento; sample(t) binario; vacío ⇒ cero. |
Chain(T) | Driver de secuencias creado por sequence_to; rueda el dt residual entre tramos. |
Glosario de bolsillo
| Término | Significado |
|---|---|
| Fotograma (frame) | Una imagen completa mostrada por la pantalla. La animación es cambiar números entre fotogramas. |
| FPS | Fotogramas por segundo. A 60 FPS hay ~16 ms entre dibujo y dibujo. |
| dt (delta de tiempo) | Segundos que duró el último fotograma (~0.016 a 60 FPS). Es el único «reloj» que consume el motor. |
| Interpolación / lerp | Calcular los valores intermedios entre dos conocidos: inicio + (fin − inicio) × t. |
| t normalizado | El avance del viaje, de 0 (inicio) a 1 (fin). |
| Easing / curva | Función que distorsiona t para darle personalidad al movimiento (frenar, acelerar, escalar). |
| Overshoot | Pasarse del objetivo y volver — la gracia característica de los springs. |
| Settle | Momento en que un spring se aquieta y la animación se considera terminada. |
| Retargeting | Cambiar el destino a mitad de vuelo partiendo del valor visible actual, sin reiniciar ni saltar. |
| Keyframe | Parada intermedia definida por ti (como en After Effects). En esta librería: Track(T). |
| Fill mode | Qué pasa al terminar: .forwards clava el valor final; .none vuelve al base. |
Comprueba lo aprendido
Tres situaciones reales. Antes de desplegar la respuesta, decide qué usarías tú:
- Un toggle que el usuario acaba de tocar.
- Una ventana de confirmación que aparece al abrir un menú.
- Una lista que el usuario lanzó con un gesto rápido y soltó.
Ver respuestas
spring_toconanim.snappy_spring. Responde al dedo, con una vibración mínima; si el usuario cambia de opinión a mitad, el retargeting lo maneja solo.tween_toconease_out. Coreografía planificada: quieres duración exacta y aterrizaje suave, sin sorpresas físicas.decay_from(&scroll_y, velocidad, .{}). Inercia pura con fricción exponencial; si se pasa de los límites, ungentle_springlo devuelve con efecto goma.
Buenas prácticas y limitaciones
Hacer
- Un solo
Animatorpor aplicación (o por jerarquía aislada), actualizado una vez por fotograma desde el bucle de render. - Animar el dato mínimo y derivar lo demás: animar un
progresoy calcular el layout suele ser más barato y flexible que animar cinco campos. - Usar presets (
anim.snappy_spring,anim.curve.ease_out) como vocabulario común del equipo; definir presets propios para la identidad de movimiento del producto. - Preferir springs en todo lo que responda al usuario, tweens en todo lo coreografiado.
Evitar
- Guardar handles a largo plazo: son efímeros. Si existe nodo para ese campo, un nuevo
*_tolo retargetea (reseteando repeat/fill/speed del pedido anterior, aunque conserva callbacks). Vuelve a pedir el handle cuando necesites tocar opciones. - Destruir objetos animados sin cancelar antes: cancela (
an.cancel(&campo)) o completa (finish_now) en eldeinitdel dueño del campo. - Callbacks destructivos: dentro de
on_update/on_completeno destruyas el holder propio ni hagasclear()/deinit()del animator. - Dt gigantes tras pausar el proceso: confía en
max_dt; pero si tu juego pausa, considera ademáspause_all/resume_allpara semántica exacta.
Limitaciones conocidas
- Solo tipos compuestos de floats son animables directamente; enums/bools requieren animar un progreso intermedio.
decayestá limitado af32y no participa en secuencias.Animation.value()en tweens bezier no es bit-exacto entre lecturas consecutivas (el solver afina su semilla); para tests, compare con tolerancia o lea una vez por frame.- El motor anima valores: no reorganiza layouts. Si el tamaño de un panel anima, su sistema de layout debe releer el campo cada frame (en pixelsculp, marque dirty mientras
is_animating()). - Un
animate_tosobre un campo con una secuencia activa reemplaza la secuencia completa por diseño (evita corrupción de nodos); no hay fusión de chains.