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.

The idea. A face is two keyed channels on the same track as the body clip: expression (continuous morph weights with easing and duration per key) and gaze (where the eyes and head aim), with a seeded idle layer underneath. They are versioned, diffable and agent-drivable like everything else. This is the animator's face — hand-keyed, on a stylized character — not a face-capture substitute.
What you'll need. A scene with two rigged characters that carry face morph targets (ARKit-style blendshapes, as on VRoid / VRM models) and eye and head bones, each with a body clip on the timeline — for example a cut made in Editing captured motion as data. Everything is pasted into the Shell tab; nothing needs the viewport.

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 });

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Honest scope. This is for stylized targets and for the face you want to author. Speech (audio-derived mouth shapes) is not built. And if a character's face was reshaped to a photo by Face bind, its morph targets are carried through that reshape untouched, so authored expressions on a strongly reshaped face are approximate — see the authored-face guide for the measured numbers.

Credits and licences

People

Assets

None used.

Tools

None used.