Capítulo 22B. Animación 2D profesional: sprites, esqueletos y partículas

CAP 22B · Bevy 0.18/0.19
"Hacer que un personaje se mueva como un muñeco de trapo es el arte. Hacerlo en ECS es la ingeniería."

— Anónimo, Indie Game Dev Slack, 2023.

22B.1 Por qué un capítulo aparte de animación

El cap. 14 (render 2D) te enseñó a poner sprites en pantalla. El cap. 15 (tilemaps) te enseñó a poner miles de tiles. Pero ninguno te enseñó a darles vida: el personaje que corre, salta, ataca, y la explosión cuando pega un puñetazo. Eso es animación, y en 2D tiene tres niveles de sofisticación:

Nivel 1 — Sprite sheet por frame: tu sprite tiene N frames en una imagen.
            Cambias el frame cada X ms. Es lo que el cap. 14 asume.
Nivel 2 — Skeletal: el personaje tiene un esqueleto (huesos), y los sprites
            se deforman según el movimiento del esqueleto. Spine, DragonBones.
Nivel 3 — Animation graph: combinás animaciones proceduralmente.
            "Si estoy corriendo y disparo, hacer un blend del 60% correr
            + 40% disparar". Bevy no tiene uno maduro a 0.19.

Este capítulo cubre los dos primeros niveles con Bevy. El tercero es tema de "futura edición dedicada a skeletal animation en Bevy" (por ahora, mirá el GDC talk sobre Overwatch, que sigue siendo la mejor referencia pública).

22B.2 Sprite sheet + timer: la versión "Bevy pura"

La animación más simple: una textura con N frames en grid, y un sistema que cambia el TextureAtlas::index cada N milisegundos. Sin crates externos. Funciona para el 80% de los juegos indie.

//! cap-22B — sección 22B.2: animación por sprite sheet con timer.
use bevy::prelude::*;

#[derive(Component)]
struct AnimationConfig {
    first_index: usize,
    last_index: usize,
    fps: u8,
    timer: Timer,
}

impl AnimationConfig {
    fn new(first: usize, last: usize, fps: u8) -> Self {
        Self {
            first_index: first,
            last_index: last,
            fps,
            timer: Timer::from_seconds(1.0 / fps as f32, TimerMode::Repeating),
        }
    }
}

fn animate_sprites(
    time: Res<Time>,
    mut query: Query<(&mut AnimationConfig, &mut TextureAtlas)>,
) {
    for (mut config, mut atlas) in query.iter_mut() {
        // Tickear el timer con el delta del frame.
        config.timer.tick(time.delta());
        if config.timer.just_finished() {
            // Si estamos en el último frame, volver al primero.
            if atlas.index == config.last_index {
                atlas.index = config.first_index;
            } else {
                atlas.index += 1;
            }
        }
    }
}

Setup:

//! cap-22B — sección 22B.2: setup.
fn setup_player(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
    let texture = asset_server.load("player_walk.png");
    let layout = TextureAtlasLayout::from_grid(
        UVec2::new(32, 32),
        4,    // 4 frames por fila.
        1,
        None, None,
    );
    let layout_handle = layouts.add(layout);

    commands.spawn((
        Sprite::from_atlas_image(
            texture,
            TextureAtlas { layout: layout_handle, index: 0 },
        ),
        Transform::from_xyz(0.0, 0.0, 1.0),
        AnimationConfig::new(0, 3, 12),  // 12 fps, frames 0..3.
    ));
}

Detalles:

  1. fps no es la velocidad del juego: es la velocidad de la animación. Si tu juego corre a 60 fps y la animación a 12, el sprite cambia cada 5 frames del juego. Un valor típico: 8-24 fps para animaciones 2D; más bajo da "estilo retro", más alto da "estilo HD".
  2. Timer::Repeating: el timer se reinicia solo al llegar a 0. Si querés una animación que se reproduce una vez y se queda en el último frame (ej.: "morir"), usá TimerMode::Once y un sistema que detecte finished().
  3. Reversa: si querés que la animación vaya "atrás", simplemente restá al index. Es trivial.

El bug clásico: olvidar time.delta() en tick() y la animación se queda quieta. O configurar fps = 0 y obtener una división por cero. Sí, lo segundo pasa.

22B.3 Múltiples clips: idle, walk, jump

El paso siguiente: no una animación, sino varias (idle, walk, jump, attack), y el sistema elige cuál reproducir según el estado del personaje.

//! cap-22B — sección 22B.3: máquina simple de clips.
use bevy::prelude::*;

#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
enum AnimationClip { Idle, Walk, Jump, Attack }

impl AnimationClip {
    fn config(self) -> AnimationConfig {
        match self {
            Self::Idle  => AnimationConfig::new(0, 3, 8),    // 4 frames.
            Self::Walk  => AnimationConfig::new(4, 11, 12),  // 8 frames.
            Self::Jump  => AnimationConfig::new(12, 15, 12),
            Self::Attack => AnimationConfig::new(16, 19, 16),
        }
    }
}

#[derive(Component)]
struct CurrentClip(AnimationClip);

// El sistema de gameplay cambia el clip.
fn set_animation_clip(
    keyboard: Res<ButtonInput<KeyCode>>,
    mut q: Query<&mut CurrentClip, With<Player>>,
) {
    for mut clip in q.iter_mut() {
        let new = if keyboard.pressed(KeyCode::Space) {
            AnimationClip::Jump
        } else if keyboard.pressed(KeyCode::KeyJ) {
            AnimationClip::Attack
        } else if keyboard.pressed(KeyCode::KeyA) || keyboard.pressed(KeyCode::KeyD) {
            AnimationClip::Walk
        } else {
            AnimationClip::Idle
        };
        if clip.0 != new {
            clip.0 = new;
        }
    }
}

// El sistema de animación actualiza el sprite.
fn update_animation(
    mut commands: Commands,
    mut q: Query<(&CurrentClip, &mut AnimationConfig, Entity), Changed<CurrentClip>>,
) {
    for (clip, mut config, entity) in q.iter_mut() {
        *config = clip.0.config();
        // Resetear al primer frame del nuevo clip.
        // (lo hace AnimationConfig::new; pero podríamos resetear el timer acá)
    }
}

Changed<CurrentClip> es un filtro de query: solo procesás la entidad cuando su CurrentClip cambió. Bevy se encarga. Sin esto, el sistema correría en todas las entidades con CurrentClip en cada frame, y aunque sería correcto, sería ineficiente.

Truco: para la mayoría de los juegos, esta combinación de enum + match + AnimationConfig es suficiente. Cuando necesites algo más complejo (blending, capas, eventos de fin de animación), pasá a bevy_aseprite_ultra.

22B.4 bevy_aseprite_ultra: hot-reload de Aseprite y eventos

Aseprite es el editor de pixel art estándar de la industria. Tiene un formato JSON y un formato binario propio. bevy_aseprite_ultra lee los dos y los convierte en componentes Bevy listos para usar.

//! cap-22B — sección 22B.4: Cargo.toml.
[dependencies]
bevy_aseprite_ultra = "0.6"
bevy = { version = "0.19", features = ["2d", "file_watcher"] }
//! cap-22B — sección 22B.4: declarar un Aseprite como módulo.
use bevy_aseprite_ultra::prelude::*;

aseprite!(pub Player, "characters/player.ase");

Eso genera un módulo Player con los paths y tags del archivo Aseprite. Ahora podés spawnear un sprite animado:

//! cap-22B — sección 22B.4: spawn desde Aseprite.
fn spawn_player(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn((
        AseSpriteSlice {
            aseprite: asset_server.load(Player::PATH),
            slice: "idle".into(),  // nombre de un tag o slice.
            ..default()
        },
        Transform::from_xyz(0.0, 0.0, 1.0),
    ));
}

Y para cambiar de animación:

//! cap-22B — sección 22B.4: cambiar clip.
fn switch_anim(
    keyboard: Res<ButtonInput<KeyCode>>,
    mut q: Query<&mut AseSpriteSlice>,
) {
    for mut slice in q.iter_mut() {
        let new_anim = if keyboard.pressed(KeyCode::Space) { "jump" }
                       else if keyboard.pressed(KeyCode::KeyA) { "walk" }
                       else { "idle" };
        if slice.slice != new_anim {
            slice.slice = new_anim.into();
        }
    }
}

bevy_aseprite_ultra viene con un procesador de assets que convierte el .ase (o .aseprite) a un formato binario propio. Ventaja: el juego carga instantáneo, y soporta hot-reload completo del archivo. Estás editando el Aseprite, guardás, y el sprite cambia en el juego. Para un animator, esto es un cambio de vida.

Caveat: necesitás habilitar el asset processor con la feature asset_processing y configurar AssetPlugin con mode: AssetMode::Processed. Algo de boilerplate inicial; después funciona solo.

Capas: Aseprite tiene "layers" (capas dentro de un mismo frame). bevy_aseprite_ultra los respeta: podés renderizar una capa encima de otra, o usar una capa como "máscara". Útil para personajes con pelo/capa que se mueve independientemente.

22B.5 Skeletal animation con bevy_spine (con caveat)

bevy_spine es el binding Bevy para el formato Spine (esqueleto + animación, usado por juegos como Hades, Dead Cells). Ofrece:

Estado de versiones a fecha de esta edición (julio 2026): el último release publicado en crates.io de bevy_spine es la v0.10.1 (anclada a Bevy 0.14). Sin embargo, el main branch del repositorio ya está en 0.12, compatible con Bevy 0.19, y se espera que se publique como tal pronto. Esto significa:

  1. Si necesitas Spine estable hoy, usa el release de crates.io (0.10.1) con Bevy 0.14. Pierdes las mejoras de 0.18/0.19, pero tienes skeletal animation funcionando.
  2. Si estás en Bevy 0.19, puedes depender del branch main vía git hasta que salga el release 0.12:
# cap-22B — sección 22B.5: usar main de bevy_spine contra Bevy 0.19.
[dependencies]
bevy = "0.19"
bevy_spine = { git = "https://github.com/jabuwu/bevy_spine", branch = "main" }
# Cuando se publique 0.12 en crates.io, sustituye por:  bevy_spine = "0.12"
//! cap-22B — sección 22B.5: ejemplo de bevy_spine.
use bevy_spine::prelude::*;

fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn(SpineBundle {
        skeleton: asset_server.load("spine/goblin.json"),
        ..default()
    });
}

Recomendación editorial: el soporte para 0.19 ya está en curso (main branch). Si tu proyecto no sale mañana, lo razonable es esperar al release 0.12 de crates.io y entonces seguir el ejemplo de arriba sin depender de git. Para juegos que salen ya, usar 0.10.1 con Bevy 0.14 sigue siendo válido.

22B.6 bevy_tweening: tweens y easing

A veces "animación" no es cambiar un frame, sino mover un valor suavemente de A a B. Para eso sirve bevy_tweening: interpolaciones con curvas de easing (linear, ease-in-out, bounce, etc.).

//! cap-22B — sección 22B.6: Cargo.toml.
[dependencies]
bevy_tweening = "0.13"
//! cap-22B — sección 22B.6: tween de un Transform.
use bevy::prelude::*;
use bevy_tweening::{Tween, EaseFunction, EaseMethod, TweenCompleted, lens::TransformPositionLens};

fn spawn_with_tween(mut commands: Commands) {
    let tween = Tween::new(
        EaseMethod::EaseFunction(EaseFunction::QuadraticInOut),
        std::time::Duration::from_secs(1),
        TransformPositionLens {
            start: Vec3::new(0.0, 0.0, 0.0),
            end:   Vec3::new(100.0, 50.0, 0.0),
        },
    );

    commands.spawn((
        Transform::default(),
        Animator::new(tween),
    ));
}

Casos de uso: menú que aparece, transición entre niveles, efecto de "salto del player" (un arco en lugar de una línea recta), UI que se desliza. Cubre el 80% de las animaciones no-sprite.

22B.7 Partículas con bevy_hanabi (compute shader)

Las partículas son "muchas entidades pequeñas que viven poco y tienen comportamiento procedural". Explosiones, sangre, chispas, magia, lluvia, polvo. El cap. 14C los mencionó como "GPU, por favor". Veamos cómo se hace en serio.

bevy_hanabi es un plugin de la comunidad (creado por djeedai) que usa compute shaders para simular y renderizar millones de partículas a la vez.

//! cap-22B — sección 22B.7: Cargo.toml.
[dependencies]
bevy_hanabi = "0.19"

La versión vigente a fecha de esta edición (julio 2026) es 0.19.0, publicada el 27 de junio de 2026 y compatible directamente con Bevy 0.19. El mito de "hanabi siempre va por detrás de Bevy" quedó atrás: las últimas releases (0.17 para Bevy 0.16, 0.18 para Bevy 0.17/0.18, 0.19 para Bevy 0.19) salen casi al mismo tiempo que el core.

//! cap-22B — sección 22B.7: configurar Hanabi (API moderna, 0.19).
// La API actual toma TRES argumentos: capacidad, spawner y un `Module`.
// Los `MovePattern::None` / `ParticleLayout::new()` de versiones antiguas
// YA NO EXISTEN. El layout de las partículas se define con modifiers.
use bevy::prelude::*;
use bevy_hanabi::prelude::*;

fn setup_particles(mut commands: Commands, mut effects: ResMut<Assets<EffectAsset>>) {
    let mut module = Module::default();

    // Definir un emisor.
    let effect = effects.add(
        EffectAsset::new(
            32768,  // capacidad (hasta 32k partículas).
            SpawnerSettings::rate(200.0.into()),  // 200 partículas por segundo.
            module,  // tercer argumento: el Module. Si no usas Expr, vale `Module::default()`.
        )
        .init(InitPositionCone {
            height: 0.0,
            base_radius: 0.5,
            top_radius: 0.0,
            dimension: ShapeDimension::Volume,
        })
        .init(InitVelocity {
            center: Vec3::new(0.0, 100.0, 0.0).into(),  // hacia arriba.
            speed: 50.0.into(),
        })
        .update(AccelModifier {
            accel: Vec3::new(0.0, -100.0, 0.0).into(),  // gravedad.
        })
        .render(ColorOverLifetimeModifier {
            gradient: Gradient::linear(
                Vec4::new(1.0, 0.5, 0.0, 1.0),  // empieza naranja.
                Vec4::new(0.0, 0.0, 0.0, 0.0),  // termina transparente.
            ),
        }),
    );

    commands.spawn((
        ParticleEffectBundle {
            effect: ParticleEffect::new(effect),
            transform: Transform::from_xyz(0.0, 0.0, 0.0),
            ..default()
        },
    ));
}

Eso crea un efecto de "fuego hacia arriba" en (0,0,0). Para disparar el efecto en respuesta a un evento (impacto, explosión), cambiás el transform de la entidad.

Por qué compute: en CPU, simular 32.000 partículas es 32.000 entidades Bevy con sus sistemas. Frame rate: lloroso. En compute, un shader de fragmento + compute shader actualiza la simulación en GPU. Frame rate: 60+ fps en hardware modesto.

Limitaciones:

22B.8 Optimización: animaciones que se desactivan

No todos los sprites necesitan animarse cada frame. Una entidad fuera de cámara no necesita actualizar su TextureAtlas::index. Una animación de UI que está oculta (porque el menú está cerrado) no necesita tickear.

//! cap-22B — sección 22B.8: animar solo lo que está visible.
fn animate_visible_sprites(
    time: Res<Time>,
    visible: Query<(&mut AnimationConfig, &mut TextureAtlas), With<Visible>>,
) {
    for (mut config, mut atlas) in visible.iter_mut() {
        // ...
    }
}

El filtro With<Visible> (Bevy trae ese componente, se actualiza con el frustum culling) hace que solo se animen las entidades visibles. Para un juego con miles de enemigos en el mapa (bullet hell, RTS), esto marca la diferencia entre 60 fps y 15.

Truco: en un bullet hell, congelá las animaciones de los enemigos lejanos a la cámara. La física sigue corriendo, pero el TextureAtlas::index no se actualiza. El jugador no lo nota (no los ve) y la CPU respira.

22B.9 Glosario del capítulo

Sprite sheet: imagen única con N frames en grid.
Frame (animación): un "paso" de la animación. Cambia cada X ms.
FPS de animación: frames por segundo de la animación (no del juego). Típico: 8-24.
Clip: una animación completa, identificada por nombre (Idle, Walk, Attack).
Skeleton (esqueleto): jerarquía de huesos para deformar sprites.
Bone: un hueso del esqueleto. Tiene posición, rotación, escala.
Slot: punto del esqueleto donde se "engancha" un sprite.
IK (Inverse Kinematics): cálculo automático de rotación de huesos para que un extremo llegue a un target.
Spine: editor de animación esquelética 2D. Estándar de la industria.
DragonBones: alternativa a Spine. Mismo concepto.
Aseprite: editor de pixel art + animación. Standard de la comunidad indie.
Tag (Aseprite): nombre de un rango de frames en Aseprite (ej: "walk", "attack").
Slice (Aseprite): un sprite suelto dentro de un atlas de Aseprite.
Hot-reload: recargar un asset sin reiniciar el juego.
Tween: interpolación suave de un valor entre dos puntos.
Ease function: curva que define cómo se interpola (linear, ease-in-out, bounce, elastic).
Particle: entidad visual con vida corta y comportamiento procedural.
Emitter: sistema que genera partículas con una configuración.
EffectAsset (hanabi): definición de un emisor en bevy_hanabi.
Compute shader: shader que corre en GPU para cálculo sin render.
Gradient: rampa de color de un valor a otro (típico para fade in/out).

22B.10 Trivia histórica

Prince of Persia (Jordan Mechner, 1989) — uno de los primeros juegos mainstream con animación por rotoscopio. Cada frame era un dibujo a mano basado en filmaciones de su hermano. La animación 2D profesional nace acá, en parte.
Spine (Esoteric Software, 2010) — inventó la animación esquelética accesible para 2D. Antes, los esqueletos eran dominio del 3D (Maya, 3ds Max). Spine democratizó la cosa.
Hades (Supergiant Games, 2020) — la animación esquelética con Spine de este juego es un referente. Combinan "combos" (mezcla procedural) con "scripts" de animación (Spine timelines). Lo que Bevy todavía no tiene maduro.
Celeste (Matt Thorson, 2018) — pixel art animado a 8 fps con hand-tweaked frames. Sin skeletal. Eligieron el look retro a propósito; un skeletal les habría "sobre-suavizado" el movimiento.
bevy_aseprite_ultra (Lommix, 2024) — el plugin que popularizó Aseprite en Bevy. Antes, la gente usaba `bevy_aseprite` (más viejo) o rotaba el atlas a mano.
bevy_hanabi (djeedai, 2021) — un puerto del "Hanabi" de Unity a Rust. Una de las primeras demostraciones de "Bevy puede hacer lo que Unity, pero en Rust".
Smear frames (Disney, 1930) — técnica de animación 2D que inserta frames "estirados" en transiciones rápidas (un puñetazo deja una estela). Bevy no lo soporta automáticamente, pero podés hacerlo con sprites extras en una animación específica de "ataque".

22B.11 Metedura de pata

❌ Hacer la animación a 60 fps "para que se vea fluido".
✅ 8-24 fps para pixel art, 30 fps para "estilo HD", 60 fps si es muy cartoon.
💡 Por qué: 60 fps de animación hace que un pixel art se vea "baboso". Los viejos juegos SNES usaban 6-12 fps; se veían nítidos. El framerate de animación es una decisión estética, no de performance.
❌ Olvidar resetear el timer al cambiar de clip.
✅ Llamar a `config.timer.reset()` cuando cambiás de clip.
💡 Por qué: si no reseteás, la nueva animación arranca en un punto intermedio del timer. El sprite se ve raro.
❌ Usar `bevy_spine` esperando que funcione en 0.18/0.19 porque está en bevy.org.
✅ Verificar la versión real. Hoy: solo 0.14.
💡 Por qué: el listado de bevy.org a veces lista versiones desactualizadas. El `Cargo.toml` de tu juego manda.
❌ 50.000 partículas con `bevy_hanabi` en WASM/WebGL2.
✅ Reducir a 5.000, o usar un sistema de partículas CPU en web.
💡 Por qué: WebGL2 no soporta compute shaders. Hanabi corre, pero usando el path de compatibilidad (lento). Para web serio, esperá WebGPU o usá un sistema CPU.

🎯 22B.12 Patrón del capítulo: "el clip correcto en el momento correcto"

Problema: tu personaje tiene 4 animaciones (Idle, Walk, Attack, Death) y 8 estados de gameplay. ¿Cómo asegurás que se reproduzca la animación correcta en el momento correcto, sin que el sistema de animación se vuelva un if infernal?

Solución: separá los estados de gameplay (qué quiere hacer el personaje) de los clips de animación (cómo se muestra). Un sistema de gameplay elige el clip; un sistema de animación lo reproduce.

[Input]    → [State Machine]   → [CurrentClip]  → [AnimationSystem]  → [TextureAtlas::index]
            (qué quiero hacer)  (qué muestro)    (cómo lo muestro)

Cuándo sí:

Cuándo no:

Lo que vimos

En el siguiente

Capítulo 23 (intermedio): UI en profundidad. El cap. 23 te enseñó lo básico de bevy_ui v2. Ahora vamos a ver: cómo integrar i18n con bevy_simple_i18n, cómo hacer un menú de pausa, cómo hacer un HUD que sigue al player. Spoiler: el 80% de la UI de un juego indie se resuelve con un stack de "estados de UI" y un cambio de State<MenuState>.