ivãstival

ESP32 · 02-face

The head turn, and how a performance is verified

Note: This document was generated by an AI agent.

One 9.6-second character performance — and the harness built to prove it is right, which is the more interesting half.

The rig explains how a pose becomes pixels. This page is about the problem that appears once you can draw: a 9.6-second animation on a 128 × 64 panel cannot be judged through a USB cable. You cannot pause it, cannot step it, cannot compare today's frame 148 with yesterday's. Reviewing it by watching the device is how subtle regressions survive.

The answer here is that the preview is not an approximation of the firmware renderer. It is the firmware renderer, compiled for the host.


The performance

Four stacked plots over 9.6 seconds showing the gaze, yaw, pitch and roll tracks with their keyframes marked. Gaze reaches its target before yaw does in every move. Yaw swings to about −34 degrees, holds, crosses to +35 degrees, holds again, then returns to rest. Dashed lines mark three blinks and the point where the damped settling oscillation begins.

Four independent tracks, 51 keyframes between them, all interpolated with the quintic minimum-jerk ease. Nothing here is simulated; every value was placed by hand.

The craft is in the timing relationships rather than the values:

DeviceWhere to see itWhy
AnticipationGaze moves at 0.72 s, yaw at 0.91 sThe eyes go first. A head that leads its own gaze looks remote-controlled
OvershootYaw reaches −34, settles to −30Arriving exactly on target reads as mechanical
Delayed rollRoll starts at 1.85 s, after yaw is underwaySimultaneous axes read as one rigid rotation
Staggered blinksLeft at 0.98 s, right at 1.00 sTwenty milliseconds apart. Perfectly synchronous lids look like a shutter
The long holdYaw sits at −30 from 1.88 s to 3.30 sNearly a second and a half of stillness, which is what makes it read as looking at something rather than passing over it

Holds are expressed by repeating a value at a later time — {1.88, −30} then {3.30, −30} — so a hold is a first-class part of the track rather than a gap.

The settle is analytic, not integrated

After the big cross-body turn at 4.28 s, a small oscillation damps out. Four factors, each doing one job:

FactorEffect
dt²Starts at zero with zero slope, so the oscillation grows in rather than snapping on
expf(-5·dt)Exponential decay; amplitude falls to 1/e in 200 ms
sinf(17·dt)The oscillation itself — 17 rad/s, about 2.7 Hz
(1 - ease(dt/1.5))A quintic gate forcing it to zero, with zero derivatives, at 1.5 s

The important property is the one the source comment names: no numerical integration. A spring simulated by stepping velocity and position each frame accumulates error, and its behaviour then depends on the frame rate — the same performance would settle differently at 30 fps and at 31. This is a closed-form function of t. Sample it at any time, in any order, on any machine, and you get the same number. The host test suite checks exactly that by sampling out of order.


Scope and access

This is one additional 9.6-second character performance. It does not replace the twenty expressions, mochi tables, classic clock eyes, or encoded animation clips. FaceState and the existing base-feature renderer are unchanged by this test. The new scene follows the regular 3D collection sequence. Manual-only comedy clips are available by explicit selection but are skipped by unattended rotation. The next tap returns to the first scripted expression. Demo mode advances after one cycle, while manual mode repeats the performance.

The performance is entry 24 of the 55-entry 3D scene registry, and is selected the same way any other scene is — through the collection's manual, ambient and cue arbitration. There is no boot flag for it; an earlier startup setting has been removed. A test-only override still takes controller priority when enabled.


Geometry and timing

The head-turn module is now a six-line compatibility shim — its comment reads "the approved, now eyebrow-free test" — forwarding the elapsed-time sample to the shared rig's pose function. The timing, the projection and the rasteriser all live in the shared rig code, used by every other scene. Sampling by elapsed time returns head rotation, gaze, eyelids, stretch and smile; it is Arduino-free and owns no mutable global state. Its authored tracks use quintic minimum-jerk interpolation with zero velocity and acceleration at each endpoint. Holds repeat a value at a later time. The independent gaze track leads head rotation. Anticipation, overshoot, delayed roll, staggered eyelids, and the longer curious hold form one designed action. A time-gated damped sinusoid adds small settling; it has no frame-dependent numerical integration. Stretch uses reciprocal horizontal scaling.

This track evaluator is intentionally separate from the mochi keyframe engine: it introduces angular channels, independent channel timing and quintic interpolation for a new rig. Existing mochi timing and its authoring format are not changed.

The projection places local feature contours on the front of an ellipsoid with radii 80, 72, and 36. It applies shared yaw, pitch and roll around the head center, then projects through a camera 180 units away with focal length 144. The camera looks along positive Z, and the visible surface has negative Z. Positive yaw moves the face toward screen right. Eyes and mouth use this same mapping. Gaze offsets affect the eyes before projection; the mouth stays attached to the head.

Rounded eye contours use 32 samples. Eyelids clip the contour in local space before projection, so a blink follows head tilt. The mouth is a filled curved strip. This performance carries no brow at all — an earlier version retracted one into the white of the eye, and it was removed rather than kept. An even/odd scanline rasterizer samples pixel centers and clips horizontal spans to 128 by 64. Scratch arrays have fixed size and no allocation occurs in the firmware frame path.

This is a bounded, moderate-angle test, not a general 3D engine. The authored yaw stays near ±35 degrees. There is no hidden-surface solver for profile or back-of-head views, lighting, grayscale antialiasing, or motion blur. Larger angles need silhouette clipping and visibility handling before being supported.


Rendering boundary

The elapsed-time sample is turned into a pose, projected, and rasterised into contour scanline spans. Those spans then reach two different sinks: on the device, they draw directly onto the OLED and flush to the panel; on the host, they write raw grayscale frames that become the HTML, GIF and PNG preview.

The experimental scene has its own branch in scene dispatch, outside the old expression renderer. The animation engine owns selection and timing, and its local sink adapts spans to the existing display. Character cues retain precedence; when a cue clears, elapsed-time sampling resumes the scene at its current loop phase. There is no new transition blending between this rig and legacy expressions.


Verification

Three layers, each catching what the others cannot.

1. The preview is the firmware renderer

Running the preview tool compiles the frame exporter with the host C++ compiler, runs the performance, and writes an HTML player, a GIF and a contact sheet.

The reason this is trustworthy is structural. The polygon fill emits spans through a template sink interface: the firmware's sink calls the hardware's line-drawing routine; the host's sink writes into a raw buffer. Neither renderer has its own rasteriser. There is no second implementation to drift, no approximation, and no "close enough" — the preview shows the pixels the panel will light, or the build is broken.

The HTML player opens offline with no external assets, and offers pause, seek, named pose buttons, native-size and enlarged canvases, and reduced-motion handling. Native size matters more than it sounds: a 128 × 64 animation judged at 4× is a different animation, and flaws that vanish when enlarged are exactly the ones that will annoy you on the device.

2. The sampling interval is chosen to close the loop

The preview samples at 32 ms, not the firmware's 33 ms gate. That is deliberate:

9,600 ms / 32 ms = 300.00 frames     the loop closes exactly
9,600 ms / 33 ms = 290.91 frames     it does not

A GIF that does not close its loop stutters at the wrap, and you would spend the afternoon looking for a bug in the animation rather than in the exporter. The firmware keeps its 33 ms frame gate and samples from real elapsed time, so it never needs the interval to divide anything.

At the tracked playback speed of 78%, the exporter emits (9600 × 0.78 + 31) / 32 = 234 frames, and asserts that count rather than trusting it.

GIF cannot express 32 ms. Its delay field is in centiseconds, so the available values are 30 ms and 40 ms. The exporter cycles a five-frame pattern:

[3, 3, 4, 3, 3] centiseconds  ->  (30 + 30 + 40 + 30 + 30) / 5 = 32 ms exactly

Four short frames and one long one, averaging precisely the sampling interval. An alternating 30/40 pattern would average 35 ms and run the performance 9% slow.

3. Host tests over the whole performance

The host test suite — 4,407 checks — covers properties an eye cannot reliably judge:

Property checkedWhat it would catch
Projection symmetryAn asymmetric rotation matrix
Mouth attachmentThe mouth drifting off the head under yaw
Gaze leadAnticipation lost in a retiming
Blink closureLids that never fully meet
Settling directionAn inverted oscillation
Deterministic out-of-order samplingHidden state, or accidental integration
Identical pixels at the loop boundaryA performance that does not actually loop
Frame bounds across the performanceAny landmark leaving the 128 × 64 canvas

The contact sheet samples twelve moments for a visual scan; the tests cover the 297 frames between them.

What none of this establishes

Host tests prove geometry and timing. They say nothing about how it looks on the panel — real frame rate, the OLED's response, or whether the performance is any good. Those need the device. Keep the distinction: a green test run and a convincing character are different claims.


Scope and limits

This is a bounded, moderate-angle performance, not a general 3D engine. Authored yaw stays near ±35°, and that bound is load-bearing: there is no hidden-surface solver, so nothing knows how to stop drawing the far eye when the head reaches profile. There is also no lighting, no greyscale anti-aliasing and no motion blur. Larger angles need silhouette clipping and visibility handling first.

The scene is reached through ordinary collection arbitration rather than a boot flag. A test-only override still takes controller priority. Panels, ambient scheduling, OTA servicing and character cues keep their existing routes.

Failure modes

SymptomCause
The GIF stutters at the wrapSampling interval no longer divides the duration
The preview runs slower than the deviceThe GIF delay pattern no longer averages the interval
Preview and hardware disagree by a pixelSomething bypassed Sink::span()
The performance drifts over minutesA secondary motion was integrated instead of evaluated from t
Out-of-order sampling gives different framesState leaked into what must be a pure function of t
The head reads as remote-controlledGaze lead lost — check the track offsets, not the values
Lids look like a camera shutterThe 20 ms blink stagger was flattened

Tuning it

  1. Edit the independent key tracks in the sampler.
  2. Regenerate the preview and review at native size.
  3. Run the host test suite.
  4. Update the keyframe data behind the timeline figure above if you changed the tracks, or the figure stops describing the performance it claims to show.

Do not alter the mochi scene tables or the Classic expression presets to tune this rig; they are separate vocabularies with separate documents.