Clay avatars, rebuilt in 3D
Last time I rebuilt a clay avatar as one array of 64 numbers, a distance-from-centre reading at 64 angles around the shape. It morphs, it blinks, it tracks your cursor, and it's 4KB of inline SVG. Head-on it's completely convincing.
Turn it more than about twenty degrees and it falls apart. There's no volume
behind the outline to turn. The "head" is a flat polar function pretending to
be a sphere, and the ears are two more polar functions pretending to sit on
its surface. Nothing back there has an actual position in space, so there's
nothing for a rotation to act on. I wanted a head that turns because it's
really there, not because I'm faking the appearance of turning. So I started
over with real 3D geometry, and this time I finished it: @claykit/core and
@claykit/react are on npm now.
The first rebuild was worse, not better
The obvious next move from "flat shape" is "3D shapes blended into one surface": a signed-distance field per primitive, combined with a smooth-minimum, traced with marching squares. I built that first, and it was worse. Blending produces a single fused blob. The ears melt into the head instead of reading as ears, and the whole thing warps as it turns, because the field's isosurface shifts with every rotation, not just the primitives that make it up.
Two more bugs rode along with that approach, both worth naming because they
pass a glance and fail a screenshot: eye angle was plumbed through as
radians when the animation data was authored in degrees, so every expression
with a tilted eye was off by a factor of about 57; and the eyes themselves
were flat 2D ellipses translated around the face, which reads as a sticker
stuck on rather than a part of the head.
Field-blending was the wrong tool for this job. A head with ears is several separate shapes that happen to touch, and blending erases the seam that's supposed to keep them apart.
Draw every primitive on its own, and never blend them
The engine that shipped does the opposite: every primitive (the head, every attached node) is drawn as its own silhouette, never merged into anything.
sample its superquadric surface as a 3D point cloud
→ orient and place it (position, rotation)
→ rotate the whole assembly by the head pose
→ perspective-project every point to 2D
→ convex hull the projected points
→ that hull is the primitive's outlineThe trick that makes this cheap is that every primitive claykit supports (rounded box, sphere, cylinder, capsule) is convex. The convex hull of a convex shape's projected points is its exact silhouette, not an approximation of one. No marching squares, no field to sample, no seam to worry about. An ear stays a distinct hull sitting next to the head's hull, because that's literally what it is, two shapes that happen to touch rather than one shape trying to blend into the other.
Layering follows the same don't-compute-what-you-already-know rule: z-order
comes from how the definition authored each node (behind the head, or
front of it), never a per-frame depth sort. A sort looks fine until a
node's projected depth crosses the head's mid-rotation, at which point it
pops through to the wrong side for one frame. Deciding it once, at author
time, means it can't flicker.
The eyes are still the interesting part
Same failure as the flat version, and it costs more here if you skip fixing
it. Translate an eye around a face and it reads as a sticker stuck on top,
never like it belongs to the head. Here the eyes are capsules sitting on the
head's actual front face in 3D, a forward/right/up frame that gets rotated by
the head pose and by the eye's own gaze, then projected exactly like the
primitives are.
They foreshorten and roll with the head because they're genuinely attached to
it, not aimed at a 2D point that happens to look right from one angle.
(angle is degrees this time. I checked.)
Two constants that came from a ruler, not a guess
Perspective strength is one number, BASE_CAMERA_DISTANCE = 800, fitted by
rendering a turn and measuring how much the far side should compress.
The more interesting one is a superquadric exponent that had to be split in
two. profileExponent controls the front-on corner rounding you actually
look at; footprintExponent controls how much extra width the head presents
as it turns away from you. Rounding both with a single shared value looked completely right at rest,
which is what made it hard to catch. The head only moved at about 60% of the
amplitude it should have, and that reads as a stiff, lifeless avatar, not as
an obvious bug. It took measuring bounding-box width across a turn,
not just eyeballing a still frame, to see that one exponent was quietly
fighting the other.
The data has one deliberate wart
An Expression's head pose is stored as {x, y, z}, while every position and
rotation elsewhere in the file (a node's placement, its rotation) is a
[x, y, z] tuple. It's the same kind of value, rotation in degrees, spelled
two different ways in the same file. The mismatch is intentional. The real
avatar definitions were already being authored this way by the time the
engine existed to read them, and converting between the two happens exactly
once, at the geometry boundary, rather than picking one shape and
normalising every file to match it.
Expressions can also carry a partial colour override, just body, just
eyes, or both, which the playback state machine lerps smoothly through a
transition (hex → RGB → lerp → hex) rather than snapping. angry-brows
flashes the whole palette red; a smaller expression might only tint the body
half a shade darker.
Try it
This is Freddy, the one avatar defined so far: 28 expressions, 6 animations,
running the actual @claykit/react package against its own
freddy.avatar.json. His poses and animation set take direct inspiration
from avatars.bible-strong.app↗, which is
where I went looking for a real range of expressions to author against
before writing a single one myself.
<AvatarCanvas definition={freddy} finish="clay" animation="idle" size={220} />It's two packages on npm
pnpm add @claykit/core @claykit/reactimport { AvatarCanvas } from "@claykit/react";
import "@claykit/react/styles.css";
import freddy from "./freddy.avatar.json";
<AvatarCanvas definition={freddy} finish="clay" animation="idle" size={240} />;@claykit/core is the framework-agnostic engine: the avatar definition
schema, the playback state machine, and the geometry pipeline above. It has
no React import anywhere in it. @claykit/react is the binding: one
AvatarCanvas component, in a clay finish (gradient, bevel, grain, contact
shadow) or a flat two-tone plastic one. Both are MIT-licensed, and the
repo↗ has the full
avatar-definition spec↗
for anyone who wants to author their own creature instead of borrowing
Freddy's.
The 64-number trick from last time still has a place. It's genuinely the right answer for a 4KB, no-dependency avatar that only ever needs to face front. This one is for when "faces front" stops being good enough.