2D Animation

Key Points:

Thinking about momentum and responsiveness is necessary but not sufficient for establishing a feeling of game feel. To really sell the idea of a character moving in space, we need to animate it somehow. An experienced runner moves economically and carefully, and this smoothness is reflected in their acceleration curves; a poor runner, on the other hand, will flail their arms and accelerate and brake inconsistently. A blob of goo needs to squish and stretch, or it won't matter what its velocity envelope looks like—it will just read as "solid rolling ball." To maintain a sense of control, the player needs to see the character start to move somehow as soon as possible, for example by playing the beginning of a jumping animation while still rooted on the ground (or conversely, to immediately pop up into the air and switch to a mid-jump animation). If the character has two different jumps they can deploy, they had better look as differently as they work!

The Illusion of Motion

To achieve the illusion of an animated object, we can borrow techniques from cel animation. By either translating or rotating an image over a background little by little (paperdoll animation)—or by replacing the image with a newly drawn image (sprite animation)—we can trick the eye and brain into believing that they are looking at a moving object. For a simple 2D running cycle, a jittery pair of left leg forward/right leg forward images might suffice, but we can achieve a richer illusion of motion with four or more intermediate frames. Art direction is a powerful tool here: it's common to have one set of running frames for slow to moderate speeds and another (with the character more visibly leaned over) for high speeds; this can be coupled with other polish effects like puffs of dust or speed lines.

Animations might be short repeated cycles (as in our walking or running animations), one-shot sequences (a powerup acquisition or death animation), or a combination of the two (from a one-shot jump animation into a repeated legs-flailing air-time animation into a one-shot landing). Even the selection of animations is important to game feel: when falling but not moving horizontally, my character might stick an improbable superhero-pose landing on a knee and a fist; whereas if they are falling while moving horizontally, they might roll into a somersault and pop up in their running animation.

The programmatic interface we provide to animations must support triggering both types of animation, and it's helpful if animations can be sped up or slowed down by some time factor (Super Mario's run cycle rate is determined by his current velocity).

Animations and Time

Consider defining an animation module with types like these:

pub struct Animation {
    // Do this for the exercise today!
    // You'll want to know the frames involved and the timing for each frame
}

pub struct AnimationState {
    // Here you'll need to track how far along in the animation you are.
    // You can also choose to have an Rc<Animation> to refer to the animation in use.
    // But you could also decide to just pass around the animation and state together
    // where needed.
    // Could be ticked in-place with a function like tick(&mut self)
}

impl Animation {
    // Should hold some data...
    // Be used to decide what frame to use...
    // Could have a query function like sample(&self, start_time:usize, now:usize, speedup_factor:usize) -> [f32;4]
}

Animation defines the parameters of an animation but not the currently active frame; that's for AnimationState. As for connecting animations with sprites, one natural approach is to have a parallel Vec<AnimationState> lining up with your Vec<GPUSprite>, making sure they stay in the right order. One moderate drawback of this approach is that now every sprite needs some kind of current animation, even if it's a degenerate "do-nothing" animation.

Paperdoll Animations

Sometimes, animations need to synchronize both animation frames and other data like collision shapes. To achieve this with sprite animation, we need another set of animation frames dictating the positions and properties of each collider at each frame. Paperdoll animation, on the other hand, already is built around the idea of a character being made up of multiple parts (each of which might have animation frames and other data of its own). We see a simple form of paperdoll animation in Final Fantasy Tactics where the characters attack with empty hands, but a weapon image is superimposed over their arm.

2020-11-24_16-13-51_screenshot_20190425-053516_fft-wotl.jpg.jpeg

The weapon image is rotated (or its sprite is animated) in sync with the attacker's animation. This is not something the game engine guarantees innately, but something that requires communication between the people working on the art assets and implementing them in-game. Paperdoll animation can be a good way to get extra mileage out of a limited sprite animation budget (for example, a character with a separate head, arms, and body could "sigh" by lowering the positions of the head and arms on screen while keeping the body still), but it can also be used to achieve unique effects. This can be employed as part of a "character creator", too.

The classic example of paperdoll animation in older games is large boss enemies composed of multiple parts. Bosses in Metal Slug are famous for combining rich sprite animation with paperdoll movements of distinct arms and legs:

2020-11-24_16-22-05_743813-metal-slug-3-windows-screenshot-third-mission-boss.jpg

In code, this expands the idea of what a "game character" is. Are the arm segments each separate characters which are linked together somehow? Is the arm one character and the body another? Or is the body one giant character with many components or complex animations and colliders? There is no universally correct answer.

An animation, then, describes what parts go where—how their positions change over time and (possibly) how they're rotated. In simple cases, we might just have an arm sprite moving up and down near the body; in more complex cases we may arrange parts of a character in a tree rooted at the character's position, and each part's movement will be defined in terms of the difference from its parent part. We'll explore this more when it reappears as 3D skeletal animation in the second half of the course.

If you want to implement nested objects, you have a couple of low-friction options:

  1. Have a parallel children:Vec<Vec<usize>> where each element corresponds with an element of sprites; each vector would describe which sprites are children of the corresponding sprite, so that when a sprite is moved its children are moved by a corresponding amount
  2. Instead of using our Vec<GPUSprite> as the "source of truth" for sprite positions, use it as a scratchpad for rendering. Have a sprite_dat:Vec<Sprite> vector parallel to sprites:Vec<GPUSprite>, where Sprite has "local" positions and rotations while GPUSprite has the global positions, computed every frame
  3. As in (2), but have a separate hierarchy of sprites (maybe a sprite has a children: Vec<Sprite> field) and traverse this hierarchy each frame, writing out their world-space positions into the Vec<GPUSprite>

Either way, you'll need to think about how to translate between coordinate spaces up and down the chain.

Your Animation struct in this situation would need keyframe data, describing where each (possibly deeply nested!) child object should be at which times relative to some reference position. These translations, scales, and rotations would be interpolated (between the previous and upcoming keyframe) and applied on top of the sprite's actual location every frame to obtain the animated position (this could be tricky with approach (1) above). You could instead send the animation data to the GPU along with the sprite data, and sample animations in the shader. Sounds cool!

Sprite Sheets

From a technical standpoint, there are benefits and drawbacks to composing a character's animations out of dozens or hundreds of distinct images. Certainly, if we kept the images in separate files and loaded them up as needed, we'd be making far too many I/O requests to meet our framerate goals. When we get into our 3D segment, this problem will be even worse, since even switching which image texture we want to render is very expensive on a GPU. Instead, we'd like to load up all the animation frames we need for a character into a spritesheet, and at drawing time just select the right section of the spritesheet to draw the animation frame we need. So if animation frames are laid out on a uniform grid, we can identify each grid rectangle with a number and use that to describe the current animation frame.

2020-11-24_16-30-17_50365.png

(Note that the real Super Mario was made out of 8x8 pixel hardware sprites, which supported horizontal mirroring; no sprite sheet like this was used in that game.) Imagine that our run cycle, when large and moving rightwards, includes the second, third, and fourth cells of the top row. If we got a fire flower or star, we'd switch to a different row completely.

When we load up a spritesheet, we need to have some extra information:

  1. What are the bounds of each sprite? (E.g. is it on a regular grid? Do we have a JSON file with rectangles listed out?)
  2. What does each sprite mean? (For character sheets, is one row always walking animations and another always running?)

It can be convenient if your sprite sheet is a regular grid, because a single number is enough to identify which sprite to use (the \(n\)th sprite is the one at \(n % w, n / w\)).

Besides this sheet-specific information, we also need to define animations from multiple sprites:

  1. Is it a looping animation?
  2. Which frames are used, and for how long is each frame used?
  3. Can the speed be multiplied by some factor?
  4. What other things happen in sync with the animation? (Do hitboxes or hurtboxes activate, deactivate, or move around?)

In this regime, animations determine what the current spritesheet rectangle is for each GPUSprite. You can update the spritesheet_rect of each sprite every frame, or just when the animation state changes. You could instead send the animation data to the GPU along with the sprite data, and sample animations in the shader. Sounds cool!

Triggering Animations

Now that our animation data structures are all set up, we can think about how animations should be triggered. Game characters often have "idle" animations rather than simply staying stuck on one frame. We might imagine that when sprites are initialized, they begin playing the first animation defined for them. But how about the other animations?

Some animations happen because the player has pressed some button (e.g., the "jump" or "take action" buttons), others because the player is controlling the character (e.g. a walking animation), while still others happen because of the character's dynamics (e.g., a falling animation or a run cycle that changes based on the character's speed). Any of these may be looping or one-off animations. Things become even more complicated when there may be reasons to play multiple animations at once, or play a one-shot and return to a looping one afterwards!

Procedural Triggering

Fundamentally, any approach to triggering animations will need to modify state on Sprite and create new AnimationState values in the setup we're using above. We could ask that game programmers just use those low-level features directly. For example, when the character begins jumping, the one-shot jump animation could be played (which should segue into falling), and when the player applies a directional input while the character is on the ground the walking animation can be played. But what if the player character is hurt while walking? We'd like to play the complete hurt animation before transitioning back to the walking animation. One mechanism we can use for this is retriggering.

It's convenient to be able to write code like this:

if xaxis > 0.0 && vx >= 0.0 {
    play_animation(walk_right);
} else if xaxis > 0.0 && vx < 0.0 {
    play_animation(skid_right);
} else if xaxis < 0.0 && vx > 0.0 {
    play_animation(skid_left);
} else if xaxis < 0.0 && vx < 0.0 {
    play_animation(walk_left);
}

But if implemented that naively, we'd always be playing just the very first frame of the walking or skidding animation. We could have some rule like "don't retrigger an animation if it's already playing", but sometimes we actually want that behavior (for example, a getting-hurt animation should perhaps trigger every time a new injury occurs). So we want an extra boolean on our play_animation function, or a separate play_retrigger:

if touching_enemy && hitstun_time == 0 {
    play_retrigger(hurt);
}

As a supplement to retriggering, we could use a system of animation priority, where a new animation won't play if a higher-priority animation is currently playing. If we wanted to be able to play the getting-hurt animation while the player was running, we'd want to make sure that the hurt animation weren't immediately canceled out by the running animation.

play_animation(idle, 0.0);
if xaxis > 0.0 && vx >= 0.0 {
    play_animation(walk_right, 1.0);
} else if xaxis > 0.0 && vx < 0.0 {
    play_animation(skid_right, 1.0);
} else if xaxis < 0.0 && vx > 0.0 {
    play_animation(skid_left, 1.0);
} else if xaxis < 0.0 && vx < 0.0 {
    play_animation(walk_left, 1.0);
}
if touching_enemy && hitstun_time == 0 {
    play_retrigger(hurt, 2.0);
}

This is just one approach, but it can be effective for many games. A simple generalization of priority schemes that supports automatically resuming previously triggered animations is to allow for multiple running animations, but put them into a stack ordered by priority. This way, low-priority animations can either be paused or allowed to continue ticking, but only the topmost animation will be used for drawing. Then when the topmost animation is done, the lower-priority ones will resume. If your base animation is an idle animation which is never "finished", it will always stay at the bottom for you.

Animation State Machines

In your engines, you might want to support defining characters in terms of state machines—but whether you do or not, you should think seriously about at least allowing for animation state machines. Animation states can be structured in similar ways to character states and help ensure that only reasonable animation transitions happen and that they only happen at reasonable times. You can also use progress between animation states to trigger other events like particle or sound effects. If you allow for concurrent animation states, you can assign states priority values and always be sure to play the highest priority animation (or, if you're using paperdoll animations, somehow combine or weight the animations together).

For scheduling animations and synchronizing them between multiple sprites, you might want to put all animations on a single global clock—then, playing an animation is a matter of setting its start time and determining which frame to show based on how long it has been since the animation started (and the animation's time scaling factor, if any). When we get into 3D games (or if you are using paperdoll animation) synchronization will be especially important to make sure that blends between animations look good.

Lab: Animated Character

If you have an engine in progress already, try to build this on top of that. It could save you some time later!

  • Set up an Animation data type; it could store a vec of frame indices or rectangles (sections of the spritesheet) and a vec of frame timings, along with a boolean indicating whether this animation loops; or you could set it up another way (perhaps as a paperdoll animation, or even do both!).
  • Create or find a spritesheet image, then set up at least three looping animations and one one-shot animation by constructing values of your Animation type and putting them into an array.
    • Or make a paperdoll animation

For full credit, do one of the following:

  1. Animation Viewer:
    • Make it so that pressing left and right arrow keys cycle between different animations in your animation data.
    • Then, progress the animations over time; your animation data format should define how many frames an animation frame lasts for.
    • Finally, use the up and down arrow keys to increase or decrease a time scaling factor to make the animations run faster or slower.
    • Try to add other fun features: if you want to draw all the character's different animations side by side, or have animations automatically transition into other animations, or something else that might be useful or cool.
  2. Animated Character:
    • Make your character move around in two dimensions with the arrow keys, or one dimension plus jumping
    • Have your character play different animations in different circumstances (accelerating either in or against the current direction of motion, falling, jumping, striking a cool pose, braking to a stop, et cetera)
    • Try to make it feel cool!