pixelsculp módulo pixelsculp_anim

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

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.

CapacidadHerramientaCaso típico
Movimiento físico realista con reboteSpringParams + Animator.spring_toBotón que se hunde y regresa, paneles que entran con overshoot
Transiciones con curva de timing deterministaTweenSpec + CurveFade de opacidad, deslizamientos con ease_out
Inercia de arrastre que se apaga solaAnimator.decay_fromFling de scroll táctil
Rutas multi-punto con easing por tramoTrack(T)@keyframes: recorridos complejos
Coreografía paso a pasoAnimator.sequence_toToast: entra → espera → sale
Control global del tiempotime_scale, pause_allCá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.

MedioFotogramas por segundo (FPS)
Cine antiguo24
Pantalla normal60
Monitor de alta frecuencia120–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í:

  1. Llamas animator.update(dt) una vez por fotograma.
  2. dt (delta de tiempo) es cuánto tardó el fotograma anterior — típicamente 0.016 s.
  3. El animator suma ese tiempo a su contador interno y calcula t.
  4. 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):

tiempo 0 1
linear. Pendiente constante: misma velocidad todo el viaje. Se ve mecánico. Útil para contadores, ruedas o barras de progreso honestas.
tiempo 0 1
ease_in. El auto arrancando: duda al salir y embiste al final. Para cosas que salen de pantalla o desaparecen acelerando.
tiempo 0 1
ease_out. El auto frenando: respuesta inmediata y aterrizaje elegante. La curva reina de la UI: menús, tooltips, fades de entrada.
tiempo 0 1
ease_in_out. El tren bala: acelera, cruza veloz, frena. Para viajar entre dos puntos visibles de la pantalla.
tiempo 0 1
steps(n). Escalera digital: saltos discretos sin transición, como un reloj LCD o sprites de videojuego retro.
tiempo 0 1
bézier custom. La familia que contiene a todas: dos manijas (puntos verdes) jalan la curva, igual que la pluma de Figma o 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:

objetivo tiempo 0 overshoot settle
Un spring con poca amortiguación: sobrepasa el objetivo (overshoot), regresa, vuelve a pasarse cada vez menos, y se asienta (settle) justo sobre la línea punteada.

Tres perillas controlan el comportamiento:

PerillaAnalogíaQué controla
stiffness (rigidez)Dureza del muelleAlta = nervioso y rápido; baja = flotante y perezoso
damping (amortiguación)Amortiguador del autoBaja = rebota mucho; alta = apenas oscila
mass (masa)Peso colgadoPesada = 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.

tiempo 0
Decay: arranca empinado (rápido) y se aplana asintóticamente — se acerca al reposo sin un final brusco. Parará exactamente en v₀/λ.
// 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:

Fotogramat crudoease_out(t)opacidad escritaLo que ves
00.000.000.00invisible
10.100.190.19¡aparece de golpe!
20.200.360.36bien visible
30.300.510.51mitad, desacelerando
50.500.750.75casi lista
101.001.001.00só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 lees

Filosofía de diseño

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

PiezaQué esCuá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:

// 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 SpringParamsSensaciónIdeal para…
anim.snappy_springRápida, mínima oscilaciónFeedback de botones, toggles, tooltips
anim.bouncy_springJuguetona, con rebote visibleConfirmaciones lúdicas, stickers, badges
anim.gentle_springLenta y amortiguadaPaneles 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:

VarianteComportamiento
linearVelocidad constante.
holdNada hasta el final (útil en tracks como «mantener y saltar»).
bezierBézier cúbica con puntos de control (x1,y1,x2,y2), igual que cubic-bezier() de CSS.
stepsN 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:

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ónValoresSemántica
repeat.countentero o .infiniteRepeticiones totales. Con modo reverse y count par, la animación termina en el valor base (regla alternate de CSS).
repeat.mode.restart / .reverseReinicia desde el principio o alterna dirección en cada ciclo.
fill.forwards (defecto) / .noneMantener el valor final o restaurar el base al completar.
delay_leftsegundosCuenta 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:

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)
}

Rendimiento

Mediciones (ReleaseFast, x86_64, 10 000 animaciones concurrentes simuladas durante 10 s de tiempo virtual):

OperaciónCoste 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

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

FirmaDescripción
init(allocator) AnimatorCrea el registro. Uno por app (o por jerarquía aislada).
deinit()Libera todo nodo vivo. No llamar desde callbacks.
update(dt) !voidAvanza 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) !voidFling: fricción exponencial desde una velocidad. Solo f32.
sequence_to(T, *T, []Segment(T)) !voidCoreografía multi-tramo con delay_s opcional por tramo. Falla con error.DecaySegmentUnsupported si algún tramo es decay.
cancel(*campo) boolCancela 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: f32Escala temporal global / clamp de dt (defecto 0.1 s).

Animation(T) — modo explícito

MiembroDescripción
init(base, target, spec)Crea entre dos valores con un MotionSpec.
advance(dt) ?f32Avanza; devuelve el dt residual si terminó (para encadenar a mano).
value() TValor 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, fillVer tabla de control de reproducción.
on_update_fn/ctx, on_complete_fn/ctxCallbacks con contexto *anyopaque. Sobreviven a retargets.

Descripciones de movimiento

TipoCampos clave
TweenSpecduration_s, curve (Curve, defecto linear), delay_s.
SpringParamsstiffness/damping/mass o duration_bounce(response_s, bounce). Params no físicos se assertionan y saturan en init.
DecayParamsdeceleration (λ ≥ 0; ≤0 se assertiona y satura).
Repeatcount (entero o .infinite), mode (.restart/.reverse).
FillMode.forwards (defecto) / .none.
Segment(T)to, spec, delay_s (para sequence_to).

Utilidades

SímboloDescripción
mix(T, a, b, t)Lerp comptime recursivo (structs/arrays de floats, enteros con saturación, extrapolación permitida).
clamp01 / ilerp / remap / eerpAritmé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 / Pennerlinear/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 / gentleMotionSpec presets listos para animate_to.
anim.snappy_spring / bouncy_spring / gentle_springSpringParams 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érminoSignificado
Fotograma (frame)Una imagen completa mostrada por la pantalla. La animación es cambiar números entre fotogramas.
FPSFotogramas 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 / lerpCalcular los valores intermedios entre dos conocidos: inicio + (fin − inicio) × t.
t normalizadoEl avance del viaje, de 0 (inicio) a 1 (fin).
Easing / curvaFunción que distorsiona t para darle personalidad al movimiento (frenar, acelerar, escalar).
OvershootPasarse del objetivo y volver — la gracia característica de los springs.
SettleMomento en que un spring se aquieta y la animación se considera terminada.
RetargetingCambiar el destino a mitad de vuelo partiendo del valor visible actual, sin reiniciar ni saltar.
KeyframeParada intermedia definida por ti (como en After Effects). En esta librería: Track(T).
Fill modeQué 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ú:

  1. Un toggle que el usuario acaba de tocar.
  2. Una ventana de confirmación que aparece al abrir un menú.
  3. Una lista que el usuario lanzó con un gesto rápido y soltó.
Ver respuestas
  1. spring_to con anim.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.
  2. tween_to con ease_out. Coreografía planificada: quieres duración exacta y aterrizaje suave, sin sorpresas físicas.
  3. decay_from(&scroll_y, velocidad, .{}). Inercia pura con fricción exponencial; si se pasa de los límites, un gentle_spring lo devuelve con efecto goma.

Buenas prácticas y limitaciones

Hacer

Evitar

Limitaciones conocidas