ESP32 · 02-face
Animation asset formats
Note: This document was generated by an AI agent.
The character has two ways of storing an animation, and they are opposites. One records what the face does and re-renders it every frame; the other records what the panel showed and replays it. Both are compiled into the firmware — there is no filesystem — and the choice between them is a real design decision with a measurable cost.
| Keyframe tracks | RLE bitmap clips | |
|---|---|---|
| Stores | Channel values over time | Pixels, frame by frame |
| Rendered by | The 2.5D rig, live | Unpacked straight to the panel |
| Resolution | None — it is not pixels | Fixed at 128 × 32 |
| Shipped size | 0 bytes (no user scenes) | ~57 KB |
1. Keyframe tracks
Eight scene slots sit alongside the built-in collection, sampled from fixed-point keyframe data compiled straight in. The device never parses JSON, mounts a filesystem, or allocates at runtime for this.
The checked-in generated asset supplies zero scenes, so the firmware uses the built-in collection. It is included unconditionally — deleting it is a compile error rather than a silent fallback.
The record
Four bytes per keyframe, and each field is chosen:
uint16_t timeMscaps a scene at 65,535 ms — about 65 seconds. For comparison, the built-in head turn is 9.6 s and an ambient slot is 30 s, so the ceiling is six times the longest thing the device otherwise plays.int16_t valuewith a per-trackfloat scaleis the fixed-point arrangement. The scale lives on the track, not the key, so a yaw track in radians and a handX track in pixels each get the full ±32,767 of resolution across their own range. One shared scale would waste most of it on whichever channel had the smallest range.
Capacities and what they cost
| Limit | Value |
|---|---|
| User scene capacity | 8 scenes |
| Track capacity per scene | 24 tracks per scene |
| Authorable channels | 26 of the rig's 41 |
| Bytes per keyframe | 4 |
A fully-populated scene — 24 tracks of 20 keys — is 24 × 20 × 4 = 1,920
bytes. All eight slots filled that way would be 15,360 bytes — 0.8% of
the application partition. The format is cheap; the limit is authoring effort.
Two properties that are easy to miss
Tracks are additive, not absolute. Applying a user channel adds to a default Pose. A track with no keys contributes nothing, and a scene that animates only yaw leaves every other channel at its neutral value. This is why a sparse scene is a valid scene.
Only 26 of the 41 channels are exposed. The withheld fifteen are the eye shape channels — widthL, widthR, heightL, heightR, radius, spread, pinch, squint, slant — plus mouthVisible, spiral, the particle emitter (spray, emitX, emitY) and phase, which the sampler sets itself. A user scene can move the character and drive its effects, but cannot reshape its eyes into something unrecognisable. The face stays the same face.
Playback
Authored time runs 100/78 = 1.28× faster than real time at the tracked speed,
so a scene authored as 10 s plays in 7.8. Looping wraps; non-looping clamps,
holding the final pose indefinitely — the same distinction
the ambient scenes draw
between a loop and a one-shot.
Walking the keys uses the same quintic minimum-jerk curve the built-in scenes use, so an authored scene moves with the same character as the rest of the rig rather than looking imported.
2. RLE bitmap clips
The other format stores finished pixels. Two clips ship:
| Clip | Frames | Size | Authored rate |
|---|---|---|---|
anim_turbo_gauge | 256 | 128 × 32 | 16 ms/frame |
anim_bug_sneeze | 288 | 128 × 32 | 16 ms/frame |
The encoding
Each frame is a list of run lengths over the pixels in row-major order, starting with a black run, and every run flips the colour. One detail is worth pausing on:
A run of 255 followed by a zero continues the same colour.
A zero-length run of the other colour flips the colour twice, which lands back where it started — so the continuation needs no escape code and no special case in the decoder. The source comment says it "falls out of the flip naturally", which is exactly right.
What it saves
544 frames × (128 × 32 / 8) = 544 × 512 = 278,528 bytes raw
RLE run data = 56,462 bytes
offsets, (256+1) + (288+1) uint16 = 1,092 bytes
─────────────
ratio 4.9 : 1
About 57 KB, roughly 4% of the 1,399,376-byte firmware. Uncompressed they would not be worth shipping.
Why 128 × 32 on a 128 × 64 panel
The clips are half-height artwork. A fill-height setting doubles every row to fill the glass, which stretches every vertical proportion the animator chose; turning it off centres them at native size with a black band above and below. The tracked default fills.
The frame rate they cannot have
The clips are authored at 16 ms per frame — 62.5 fps. That is not achievable, and the reason is the bus rather than the CPU:
one full panel transfer ≈ 24.7 ms (derived in the hardware page)
authored frame interval 16.0 ms
At 100% speed every other frame would be dropped and the motion would rush. Doubling the playback speed to 200% doubles the interval to 32 ms — about 31 fps — which the panel can actually sustain, with ~7 ms of slack. Slower, and every frame drawn is a frame seen.
This is the clearest illustration in the project of the 25-of-33-milliseconds constraint: the artwork was authored for a display this device does not have.
Choosing between them
| Use keyframe tracks when | Use a bitmap clip when |
|---|---|
| The motion is the character's own face | The artwork is not a face at all |
| It should scale, tilt or compose with effects | It is a fixed illustration |
| You want it to move like the rest of the rig | It was drawn frame by frame elsewhere |
| Flash is tight | You have the flash and no way to parameterise it |
A face animation stored as pixels loses everything the rig gives it — the projection, the eased channels, composability with cues — and costs far more flash to do so. The bitmap path exists for artwork that was never parametric.
Producing the assets
The desktop authoring tool that generated the compiled-in keyframe asset has been removed from this tree. What remains is the contract a replacement must satisfy: the struct layout above, the per-track fixed-point scale, and times in milliseconds from scene start. Nothing in the runtime model needs to change for a new producer.
The bitmap-clip header names its generator script in a do-not-edit banner, but that script is not in this tree either. Both authoring tools have been removed and only their outputs remain. Regenerating either asset means writing a new producer against the documented layout; editing the generated headers by hand works but leaves the banner lying.
Failure modes
| Symptom | Cause |
|---|---|
| Compile error about a missing header | The generated user-animation header was deleted; it is included unconditionally by design |
| A user scene does nothing | It's marked disabled, or its duration is zero — both return the neutral pose |
| A user scene plays but the face is wrong | Tracks are additive; something assumed they were absolute |
| Animation runs 28% fast | The 3D playback speed setting applies to user scenes too |
| A non-looping scene freezes at the end | Working as designed — it clamps and holds the final pose |
| Clip playback rushes and skips | Bitmap-clip speed back at 100% against a 16 ms authored interval |
| Clip looks vertically stretched | Fill height is on, doubling 32 rows into 64 |
| Decoder output drifts after a long run | The 255 then 0 continuation is being treated as a colour change |
Configuration reference
| Setting | Default | Meaning |
|---|---|---|
| Bitmap-only test mode | off | Play only the bitmap clips — a bring-up switch |
| Fill height | on | Double the 128 × 32 artwork to fill the panel |
| Bitmap clip speed | 200% | 100% is authored timing; 200% is what the panel can sustain |
| 3D playback speed | 78% | Applies to user scenes as well as built-in ones |
| User scene capacity | 8 | Scene slots |
| Track capacity per scene | 24 | Tracks per scene |