Skip to content

Keyframes

The emotion table turned seven expressions into seven rows of data. This lesson does the same trick to something that feels much harder to write down: motion.

Every animation you have built so far was hand-written. A blink was some drawing, then a sleep, then some more drawing. Change the timing and you edit code. But an animation is really just a list of poses and how long each one holds — and a list of things is a table.

Poses on a timeline

Pixel waving welcome Animators have called these poses keyframes for a hundred years. The idea works exactly as well on a four-dollar microcontroller as it does in a cartoon studio.

What a Keyframe Is

A keyframe is a single important pose in a motion, along with how long to sit on it. In hand drawn animation, the lead artist draws only the keyframes — the poses that define the movement — and the in-between drawings follow from those.

On this display you skip the in-betweens entirely and just snap from pose to pose, which at blink speed looks completely convincing.

Each frame here is three numbers:

Value What it means
eye_height How tall the eyes are — 10 is wide open, 1 is shut
eyebrow_lift How far the brows rise above their resting position
hold_ms How long to sit on this pose before moving to the next

And a whole blink is five of them:

1
2
3
4
5
6
7
8
#         eye  brow   ms
BLINK = (
    (10,   2,  60),
    (6,    2,  40),
    (1,    2,  70),
    (6,    2,  40),
    (10,   2,   0),
)

Read it top to bottom and you can see the blink: open, half shut, closed for 70 milliseconds, half open, open again. The closed pose holds longest because that is what makes a blink read as a blink instead of a flicker.

Played back, those five poses look like this:

The five poses of a blink: eyes open, half shut, closed, half open, open again

More Animations, Same Format

Once the format exists, new animations cost nothing but numbers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
SURPRISE = (
    (10,  2,  80),
    (15,  8, 500),
    (13,  6, 180),
    (10,  2,   0),
)

DOZE_OFF = (
    (10,  1, 350),
    (7,   0, 350),
    (4,  -2, 400),
    (1,  -3, 900),
    (10,  1,   0),
)

SURPRISE snaps the eyes wide and the brows up, holds half a second, then settles. DOZE_OFF closes the eyes in four slow stages with the brows sinking the whole way. Neither one required a single new line of drawing code.

Here are the four poses of SURPRISE. The eyes jump wide and the brows leap up, and the second pose is the one that holds for half a second:

The four poses of the surprise animation

And here are the five poses of DOZE_OFF, with the eyes closing and the brows sinking until the last pose wakes the face back up:

The five poses of the doze-off animation

And because animations are now ordinary Python tuples, you can build new ones out of old ones:

1
DOUBLE_BLINK = BLINK[:-1] + BLINK   # two blinks, built from the first one

That line trims the last frame off a blink and glues a second blink onto the end. Data you can slice and join is data you can compose — something that is simply not possible when your animation is buried inside a function.

The Player Doesn't Know What a Blink Is

Pixel thinking That's the whole point. A player that knows nothing about blinking can play a blink, a doze, and every animation you invent next year — because all it knows how to do is walk a list in time.

The Player

The player has four variables of memory and two functions. start() begins an animation; update() advances it if the current pose has held long enough.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
playing = None
frame_index = 0
frame_started = 0
current_name = ""


def start(name, animation):
    """Begin an animation. Draws pose zero and starts the clock."""
    global playing, frame_index, frame_started, current_name
    playing = animation
    current_name = name
    frame_index = 0
    frame_started = ticks_ms()
    draw_frame(playing[0])


def update():
    """Advance the animation if the current pose has held long enough."""
    global playing, frame_index, frame_started

    if playing is None:
        return

    hold_ms = playing[frame_index][2]
    if ticks_diff(ticks_ms(), frame_started) < hold_ms:
        return

    frame_index += 1
    if frame_index >= len(playing):
        playing = None      # finished; last pose stays on screen
        return

    frame_started = ticks_ms()
    draw_frame(playing[frame_index])

The critical detail is that update() returns instantly when there is nothing to do. It never sleeps and never waits. That is the non-blocking timer idea from an earlier lesson, applied to something far more interesting than a single blink — and it is what keeps the buttons alive while an animation runs.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
while True:
    update()

    if face.pressed(button_a):
        face.wait_for_release(button_a)
        start(*ANIMATIONS[selected])

    if face.pressed(button_b):
        face.wait_for_release(button_b)
        selected = (selected + 1) % len(ANIMATIONS)
        start(*ANIMATIONS[selected])

    sleep_ms(5)

Press A halfway through DOZE_OFF and it restarts immediately. Try doing that with an animation built from sleep() calls — you cannot, because the program is not listening.

Closing an Eye by Shrinking It

Pixel giving a tip An eye squeezed down to one pixel tall isn't a thin eye — it's a shut one. face.eye() notices when the height drops to 2 or less and draws the closed-eye arc instead, so an animation can close an eye just by shrinking it.

Why This Matters Beyond Robot Faces

Separating a player from the thing it plays is one of the most reusable structures in software. You have met it before without noticing:

Player What it plays
A music app A playlist
A game engine's animation system A set of keyframed poses
A CNC machine or 3D printer A list of moves and speeds
This lesson's update() A tuple of eye heights and hold times

In every case the player is written once and is boring on purpose, and all the creativity lives in the data. That is a good sign you have decomposed a problem well.

Things to Try

  1. Make the blink heavy. Change the 70 in the middle of BLINK to 400. A snappy reflex becomes a tired droop, and you never touched the player.
  2. Build a triple blink in one line, using the same slicing trick as DOUBLE_BLINK.
  3. Add a fourth number to every frame — a mouth width — so the mouth animates too. You will change draw_frame() once and every animation gains a moving mouth at the same time.
  4. Play one backward by reversing the list. Does DOZE_OFF reversed read as waking up? Some motions reverse convincingly and some do not, and noticing which is a real design question.
  5. Interrupt an animation on purpose. Press A halfway through a doze and watch it restart cleanly — proof the player never blocks.

Motion is data now

Pixel celebrating You can now describe a new movement in three lines of numbers, hand it to a teammate who has never read your player, and have it just work.

References