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.

one primitive, start to finish
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 outline

The 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.

Finish
Animate
Pose
<AvatarCanvas definition={freddy} finish="clay" animation="idle" size={220} />

It's two packages on npm

pnpm add @claykit/core @claykit/react
import { 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.