Authoring a face: gaze and idle
Two characters watch each other for 53 seconds. Most of the acting is where they look and not looking dead — both are timeline data you can edit.
1 Make them look at each other
A gaze key whose target is a selector follows that character as it moves; a point holds. Start both fighters on each other:
const A = $S('#a'), B = $S('#b');
A.gaze(0, '#b', { duration: 0 });
B.gaze(0, '#a', { duration: 0 });
Eyes and head are separate. The head weight (default 0.35) is the share of the aim the head takes — 0 is a glance, higher is a turn. When the target changes, the eyes lead and the head lags: it starts 0.1 s later and takes 40% longer to arrive. Past about ±30° the eyes stop at their anatomical limit and the head takes the rest, so a target behind a character becomes a head turn, never doll eyes.
2 Look away between exchanges
A glance away and back is two keys. 'away' is a fixed averted look (35° to the character's left, 10° down):
for (const t of [6.4, 14.2, 28, 34.4, 40.2, 44.1])
A.gaze(t, 'away', { duration: 250, head: 0.2 }).gaze(t + 0.9, '#b', { duration: 300 });
for (const t of [6.9, 14.7, 28.6, 34.9, 40.6, 44.6])
B.gaze(t, 'away', { duration: 250, head: 0.2 }).gaze(t + 0.9, '#a', { duration: 300 });
A.gazeKeys(); // [{ index, time, target, head, duration, easing, label }]
Measured on a real 52.9 s cut: wherever the other fighter is inside the eyes' range, the eyes sit within about 1° of that fighter's head, and sampling the whole face costs about 0.6 ms per frame.
3 Don't look dead: the idle layer
Fully keyed faces read as corpses; real faces are never still. The idle layer adds a blink, a faint breath and a little gaze micro-noise:
A.face.idle({ seed: 11 });
B.face.idle({ seed: 12 });
- Seeded. The same scene renders the same blinks on every run and every machine.
- An underlayer. It is never baked into the keys;
A.face.idle(false)removes it and every authored key is untouched. - It never fights an authored beat. An authored value owns a shape it drives; a held beat (
hold: true) keeps blinks and noise out of it.
4 Add an expression, with timing
Keys are continuous values with easing and duration — a slow fall and a hard snap are the same two keys with different timing:
A.face.keyAt(20, { shapes: { browInnerUp: 1, eyeWideLeft: 1, eyeWideRight: 1 }, duration: 0 }); // snap to alarm
A.face.keyAt(24, { shapes: { browInnerUp: 0, eyeWideLeft: 0, eyeWideRight: 0 }, duration: 2000, easing: 'ease-in-out' }); // slow fall
B.face.applyPreset('focus', 30, { duration: 500, hold: true }); // a held beat
A.face.keys(); // the explicit per-shape keys — a preset is never stored, only its values
A preset expands to explicit keys you can then edit, move or delete (moveKey(i, …), deleteKey(i)).
5 The same keys in the GUI
- Open the Customize tab. Under Expression, tick Key at playhead (set a duration and an easing): the preset Apply button and the sliders now write keys that arrive at the playhead.
- Under Face performance, pick a target and Key at playhead to key a gaze; tick Idle layer and set a seed. The key list (with ×) and the command text update live, whichever surface made the change.
- Open the Animations tab: face and gaze keys are blocks on the character's row (a key arrives at the block's right edge) and the code panel shows them — edit the text and save to change the same keys.
- Undo is one stack for all of it.
6 Reproduce it exactly
console.log(A.face.asCommand()); // the commands that rebuild this face byte for byte
console.log(B.face.asCommand());
Same keys, same face, every run — no viewport needed, so an agent can author a performance deterministically. Morph weights, and the eye and head rotations, are baked into a glTF export by running the real sampler, so the file matches what you saw.
Credits and licences
People
- Tejaswi Gowda (script)
Assets
None used.
Tools
None used.