ivãstival

ESP32 · 02-face

Architecture: Mochi Face Animation

Note: This document was generated by an AI agent.

Overview

The character's eyes are soft filled blobs rather than rounded rectangles, and its idle behaviour is a set of scenes: the blobs merge and part, the eyes rove around with a sense of depth, the whole shape bounces, it peeks behind itself, and it turns into a pair of analog watch dials. The look is named after mochi — soft rice cake — because the personality comes from the squish, not from any particular pose.

Three pieces produce it, all driven from the same seven FaceState eye fields: a drawing routine that shapes one eye (and a variant that turns it into a clock face) — every expression uses it; a composition step that adds the depth turn per eye; and a keyframe engine that says where a scene is at a given time, feeding both the idle mochi loop and the clock-eyes mode.

The blob shape applies to all twenty expressions. The scenes are idle-only. Neither depends on the other.


Why a rounded rectangle was already most of the way there

The eye-drawing routine has always drawn a filled shape — one rounded rectangle. What made it read as a rectangle was a modest corner radius: 8px on a 30x30 box.

Adafruit_GFX builds fillRoundRect from a centre rectangle plus quarter-circle corner arcs, so at r = min(w,h)/2 it degenerates into exactly a stadium, and into a circle when w == h. The whole softness change is therefore one line — stop taking the radius from a field and always use the full one.

This matters for a 1-bit 128x64 panel: there is no anti-aliasing, no bezier primitive and no spare CPU for a curve rasteriser. The soft look had to come from a primitive the library already draws well.

The freed field

With the corner radius no longer per-expression, FaceState's eyeR had nothing left to do, so it was renamed to eyePinch and given a new job: waisting the blob into an hourglass. Renaming rather than deleting keeps the struct layout stable, so the float-array tween and its host-test guards needed no changes — and the pinch interpolates for free.

When eyePinch > 0, the eye routine switches from one fillRoundRect call to two round end caps with a per-column profile between them, the same scanline idiom the mouth routine and the lidArc squint carve already use. The profile divides by span - 1, so it reaches +1 on its final column and meets both caps flush; dividing by span leaves a one-pixel notch against the right cap.


The three motion axes

Travel — eyeX / eyeY

Saccades used to be horizontal only: one scalar applied to eyeX, ±11px at most. Gaze scheduling now picks a target on both axes, and the ranges widened (±16 horizontal, ±7 vertical under the look expression), so the eyes wander rather than sweep. Brow drawing follows eyeY at half weight — full weight reads as the whole face sliding rather than the eyes moving within it.

Depth — eyeTurn

One float, -1 (turned left) to +1 (turned right). A head turning is read almost entirely from two cues, and both are cheap:

  • Foreshortening — the far eye narrows toward the turn
  • Parallax — both eyes shift toward the near side

eyeW/eyeH are shared by both eyes (only lidL/lidR are per-eye), so composing the frame builds a per-eye copy of the pose rather than widening the eye routine's signature. That keeps the drawing unit a pure function of what it is handed, per the architecture rules.

Squash — squashStretch()

The four places that animated eye size before all scaled uniformly — both eyeW and eyeH growing by the same amount — which reads as the eye growing, not being compressed. squashStretch() counter-scales instead: +1 squashes flat and wide, -1 stretches tall and thin. It lives beside the other pure pose helpers, in a module that is free of the Arduino API and therefore host-testable.

The hiccup expression now drives it from the existing damped-oscillator impulse, turning a uniform swell into a real jolt.


The one number the merge turns on

The animation engine draws the eyes mirrored around their centres, so with the left and right centre constants set 48 pixels apart, they sit 48 + 2*eyeSpread pixels apart. Against the default eyeW of 30:

eyeSpreadCentre distanceReads as
048two separate blobs
-440two close blobs, a 10px gap
-930circles exactly touching — a figure-8
-1322overlapped into one lozenge

Four panels showing the two eyes at different eyeSpread values, drawn to scale: at zero the centres are 48 pixels apart and read as separate blobs; at minus four, 40 apart with a ten pixel gap; at minus nine, 30 apart and exactly touching, since an eye width of 30 gives each a radius of 15; at minus thirteen, 22 apart and overlapped into one lozenge.

This is the load-bearing insight and it is not obvious from the code: two touching circles already are an hourglass. No compositing, no union operator, no second drawing path — just two overlapping filled shapes on a monochrome buffer.


Scenes are data

Composing the frame already carries around eight special-case branches, and adding an expression costs four files. That does not scale to "many distinct scenes", so scenes are tables rather than code:

Each scene is a named list of keyframes; every keyframe is a full eye pose (the same seven fields as before) plus a hold time — how long to ease from this key to the next.

The pose sampler walks the table given a scene, elapsed time and speed. Adding a scene means adding a table and one line to the registry; nothing in the drawing or controller layers learns about it. The four shipped are merge, lookAround, bounce and peek, and ambient mode advances to the next one each time it selects the mochi scene.

Two authoring rules, both enforced by the host test suite:

  • The last key must repeat the first, or the loop pops on wrap.
  • Vary the dwells. Equal beats are what made the first version read as mechanical; character animation holds a pose then moves quickly between them. Per-key holdMs is the whole reason the table replaced a global beat length.

A speed setting scales every dwell together, so a scene's internal timing keeps its shape at any speed.

Why the waist releases before the blobs separate

In the merge scene, keys 3 and 4 look redundant — same spread, same size, only the pinch differs. They are not. Releasing the waist while separating draws each blob independently pinched as they pull apart, which reads as two small hourglasses drifting away rather than one shape relaxing. The extra key holds them merged until the waist is gone. A test caught this, not the eye: it checks that whenever the eyes are pinched, their centres stay closer together than the eye's own width.

Phase wraps, it does not clamp

The one-shot timelines elsewhere in the face code settle on a resting value past their end, because a held pose must stay valid indefinitely. A scene is a loop, so past the end means back to the start. It still cannot run away — the property that convention protects.



Timing: one walker, four tables

The pose sampler is the whole engine, and it does four things: it scales the scene's total cycle length by the speed setting, wraps elapsed time into that cycle rather than clamping it, walks forward through keys until it finds the current segment, then eases the position within that segment.

The cycle length excludes the last key's dwell. The final key is a landing point, not a departure — and by convention it repeats the first key, so the loop closes. Including its holdMs would add a pause at the seam.

Phase wraps rather than clamping, and the header explains the distinction against the one-shot timeline convention: a one-shot timeline settles on a resting value past its end, because a held pose must stay valid indefinitely. A scene is a loop, so past the end means back to the start. Either way it cannot run away, which is the property the convention protects.

Speed scales every dwell identically, both the total and each segment:

total = Σ holdMs × speed / 100          and       dwell = holdMs × speed / 100

Because the same factor divides out of phase / dwell, the shape of the motion is untouched — only the clock changes. A speed setting of 200 is half speed for a sleepier character, 60 is livelier, and neither redistributes time between the keys.

The four scenes

SceneKeysCycle at 100%at 200%at 60%
Mochi merge63,500 ms7,000 ms2,100 ms
Mochi look around73,800 ms7,600 ms2,280 ms
Mochi bounce61,660 ms3,320 ms996 ms
Mochi peek74,260 ms8,520 ms2,556 ms

Twenty-six keyframes in total describe every idle animation the character has.

The ambient cycle

A flow from the last touch through sixty seconds of AMBIENT_IDLE_MS, into shuffle which picks a new scene order each idle session, then a scene lasting thirty seconds, looping until the device is touched. Below, the four mochi scenes and how many times each loops in one thirty-second slot at the tracked speed.

The ambient scheduler holds a scene for 30,000 ms before choosing another, and the controller only enters ambient after 60,000 ms without touch. So one mochi slot plays its scene:

30,000 ms / 3,500 ms = 8.6 loops of Mochi merge
30,000 ms / 1,660 ms = 18.1 loops of Mochi bounce

The partial loop at the end is not a problem, because the scene is abandoned mid-phase rather than being run to completion — and since every scene closes its loop, any phase is a valid pose to leave from.

Shuffling reseeds from the device clock at each idle session, so the order differs between sessions but never repeats a first scene back to back. Scenes needing live data (clock, weather, clock-eyes) are excluded from the shuffle until that data is valid; the mochi scene needs nothing and is always available.

Clock eyes

Rendering one eye as a watch dial draws a circular bezel at r=12, twelve ticks via the same i * (PI/6) idiom used elsewhere for icon rays, and hour and minute hands from the local time.

It is drawn at the composed centre — eyeX + faceX, eyeY + faceY — so the dials inherit gaze and the breathing bob for free, the same way the spiral-eye effect does.

The dial never invents a time. The clock-eyes scene is gated on having valid time exactly as the ordinary clock scene is, so with no NTP sync the scene is skipped rather than shown wrong; a validity flag on the dial parks the hands at twelve as a second line of defence. This is the project's honesty rule — a plausible-looking wrong clock is worse than no clock.


Deliberate exception

The dizzy expression keeps its stroked spiral. The spiral-eye effect walks an outward spiral with individual line segments and bypasses the normal eye routine entirely. Filling a self-intersecting curve needs a rasteriser with no precedent here, and the spiral is the point of the expression. The exception is commented at the branch in the animation engine.

The surprised expression changed most visibly when the blob shape landed. At eyeW == eyeH == 34 it used to carry a 14px radius and rendered noticeably boxier than a circle; it is now a full circle.

An earlier version of this document claimed a "comma/spiral twist" beat from the reference video was deliberately out of scope. That was wrong: the frame in question was the analog clock face, now implemented above. The claim came from reading eight sparse stills and is recorded here so the error is not repeated.


Extending it

To add a scene, add a table and an entry to the scene registry. Do not add another special-case branch to frame composition — that is the growth point the table system exists to avoid. The host test suite checks every registered scene automatically for wrap, bounds and pinch confinement, so a new table is covered the moment it is listed.

To give an ordinary expression a waist or a turn, set eyePinch or eyeTurn in its preset definition; both tween like every other field. Keep eyePinch below eyeH / 2 or the waist erases the middle of the blob.

To add another data-dependent ambient scene, follow the pattern the clock-eyes scene set: add the new scene before the count marker, extend the ordering and availability logic, route it in the controller, and cover the gating with a host test.



Failure modes

SymptomCause
The loop pauses at the seamThe last key's holdMs is being included in the cycle length
The scene jumps at the wrapThe last key no longer repeats the first
Changing the speed changes the motion's characterSomething scales the total but not each dwell, or vice versa
The eyes never mergeeyeSpread not reaching −9 against the current eyeW; the threshold moves with eye size
Merged eyes show a seamTwo shapes are being drawn with different fills, or one is being erased
Ambient never startsAmbient is disabled in the configuration, or something keeps resetting the idle timer
Ambient always shows the same scene firstThe shuffle was reseeded from a constant instead of the clock
Clock or weather scenes never appearTheir data is not valid; they are excluded from the shuffle until it is
A scene runs away past its endPhase clamped instead of wrapped

Configuration reference

SettingDefaultMeaning
Ambient enabledonWhether idle scenes run at all
Idle time before ambient60,000 msUntouched time before ambient begins
Scene hold time30,000 msHow long one scene is held
Mochi speed100%Scales every dwell; 200% is half speed, 60% is livelier

The eye geometry the merge depends on lives with the model rather than the settings, because it's geometry rather than a preference: the left eye centre sits at x = 40, the right at x = 88, and both sit at y = 26.