Characters v2: a character is a config, not a mesh
One engine turns a config into a rigged low-poly humanoid. Garments name a station on the body and ask it for a cross-section, so a coat written once fits a child and an elder. Here is the rule that makes it work and the ones that had to be enforced by a gate.
A 3D character used to be the least reusable thing we sold. You bought a farmer and you owned a farmer: that age, that build, that shirt. The same person ten years older, or heavier, or in a coat, meant opening Blender and doing modelling work on a mesh somebody else had already finished.
Every Polyfork humanoid is now built by one engine from a config: an age, a figure, a build, an expression, and a garment in each of fourteen slots. Change any of them and the model is rebuilt from scratch, in the browser, in about 14 milliseconds.
Four published characters, mounted live from their configs by the same engine bundle their store modules embed. Not a video and not a sprite sheet: your browser is running createCharacter() four times and playing a Mixamo clip on each. The costumes differ; the body, the skeleton and the code do not.
55 of our 93 published characters are on the engine today and the rest are being rebuilt: the finished ones are filtered at /assets?cat=characters-v2, and you can drive all of it at polyfork.dev/create.
What a config actually is
createCharacter({
age: 'adult', figure: 'masculine', build: 0.85, expression: 'neutral',
palette: { coat: 0x9E3B33, sash: 0x2E6E8E, boot: 0x4A3527, skin: 0xE0A579 },
wear: {
hat: { name: 'pirate-hat', plume: true },
coat: { name: 'coat', style: 'frock', length: 'knee' },
feet: { name: 'tall-boots', shaft: 0.42 },
},
});That is the whole interface. There is no mesh in it, no vertex data, no measurements. The engine owns the body and the fitting; the config owns the choices.
The rule that generates everything else
A garment never sees a measurement. It names a station on the body and asks for its cross-section there:
const brimY = 1.650; // wrong: the brim height of exactly one adult
const brim = fit.station('hatBand'); // right: of every character the engine can produceAny literal distance in a garment is a bug that has not fired yet. fit.station() returns a section with a height, a shape, a width, a depth and the bone it belongs to, for whichever body is being built.
Stations answer where. The second half is how far out, and that is a five-rung ladder rather than a number per garment:
| rung | ease | what sits there |
|---|---|---|
skin | 0 mm | the body itself |
next | 13 mm | hair, a trouser seat under a shirt |
mid | 23 mm | tops, trouser legs, hats |
outer | 36 mm | a jacket, a dungaree bib, straps |
carried | 50 mm | a pack, a bag, a tool on a belt |
One rule generates the whole table: a garment sits on the rung above whatever it covers. A trouser seat is covered by a shirt, so it is thinner than one.
The ladder exists because nothing else caught the failure it prevents. A trouser waistband authored at outer sat 36 mm out while the shirt hanging over it sat at 13, so the waistband came through the shirt on every character wearing both. No gate saw it: the geometry was perfectly valid, and the check we had measured skin against cloth, not cloth against cloth.
Where the rule is not enough
Two cases needed more than a rung, and both were found by looking rather than by measuring.
A hat that deletes the hair is not on the hat rung. Hats sit at mid because they go over hair, and hair is a shell about 11 mm proud of the skull. An aviator's leather helmet declares that it covers the hair, so hair.mjs draws none, and the shell was then standing 23 mm off bare skin. It also runs down past the ear to the jaw, so that 23 mm was widest exactly where the head is narrowest: at the cheekbone the leather was 41 mm wider than the skull inside it, on a face 199 mm across. It read as a bucket. It now sits at next, by the ladder's own rule, because what it covers is skin.
The head is a box and it does not taper. The same helmet's crown was sized off station('crown'), which narrows to 79.7 mm. That station is a hat-fitting ring, not the mesh: body.mjs builds the head as one 199 x 164 x 184 mm box at full width right up to its top edge. Sized off the station, the crown came in to 92.7 mm at a height where the skull is still 99.5, and put the back-top corner of every adult head through the leather.
Both of those are the same lesson twice. The abstraction is right and it is still an abstraction: when a garment looks wrong, measure the geometry, not the ladder that was written for something else.
Age is not a scale factor
You cannot build an age knob by scaling. An elder is 1.64 m tall with a full-size adult head. A child is five heads tall; an adult is a bit over seven.
Anything measured against the body has to be measured against the part it touches: a hat lap against the head, a hem against the leg, a glove against the hand's own box. Never against a global factor.
We got this wrong once in a way only a gate caught. A hat liner scaled by the global factor cleared the neck on the reference build and was 3% short on the elder. It looked fine in every render anybody would have thought to take.
The skeleton is frozen, the bones are not
Same bone names, same hierarchy, same strict T-pose bind, same origin on the ground between the heels. That is the interface to Mixamo, Unity Humanoid, Unreal's IK Retargeter and our own clip library, and it does not move. Change a bone name and everything breaks at once with no fallback, so we do not.
Bone lengths vary freely with age and build, and one detail in the bake is what makes that safe. A clip carries a quaternion track per bone plus a single translation track for the hips, and the hips track is baked against a 0.95 m reference hip height. Playing it on a character re-derives that track from the character's own hip position:
const ourHip = hips.position.clone(), ratio = (ourHip.y || 0.95) / 0.95; pv[i + 1] = ourHip.y + raw.hips[i + 1] * ratio;
So a child's hips rise and fall by a child's proportion of the motion, not an adult's. Rotations need no such treatment: a shoulder turning 40 degrees turns 40 degrees on any arm length. It is why a clip recorded on a grown adult plays correctly on a five-head child, and why every animation you already own still works on every new body.
What the gates measure
A generator that fits garments automatically will fit them wrongly, quietly, on a body nobody rendered. So the interesting output of this project is not the engine, it is the set of things a build has to survive. Each of these runs across every age, both figures and the build range, not on the one body the author was looking at:
- fit-check fires each skin vertex's own normal outward and asks whether cloth is in front of it. Skin outside cloth is a defect; cloth outside skin is a garment doing its job.
- cull-check builds the character twice, with and without culling, and compares silhouettes. A garment may declare it
coversa region only if hiding what is under it is invisible. - pose-check bakes the real walk clip and asks the same question at every frame, because rest pose is where every skinning scheme agrees. A dungaree bib rigid on
Spine2and a shirt soft-skinned across the whole spine sit perfectly on each other in bind pose and grind together on the first step. - seam-check looks for open boundary rims, loose islands and swallowed limbs where two garments meet.
- retrofit-check holds a rebuilt character to a silhouette IoU against the published v1 and to a 4,000 triangle budget.
The gates are also where the honest failures live. Nine of our long-coat characters fail a walk baseline today, seven of them with the trouser leg passing through the coat, and that is visible on the build board rather than filed away.
What it buys
Across the 55 characters on the engine, that is 21,120 bodies before anyone picks a colour:
| knob | values |
|---|---|
| character | 55 configs |
| age | kid, teen, adult, elder |
| figure | masculine, feminine |
| build | 0.85 to 1.20 in 8 steps |
| expression | 6 |
Colour sits on top of all of it: a v2 character carries about 27 named zones, each its own knob.
The number matters less than what it is made of. These are not 21,120 files on a disk. They are one function called with different arguments, and the arguments are published as JSON, so an agent can read what a model can change and write the call itself.
A custom one is just a config nobody has written yet
The interesting consequence of all this is what it does to the cost of a character that does not exist.
If a character were a mesh, a custom one would be modelling work. Because a character is a config, a custom one is an authoring job against a fixed interface: a palette, a wardrobe list, and at most one or two new garments in lib/char/wardrobe/. The body, the rig, the fitting and the gates are already there and are not part of the price.
That is what /skin is. Send pictures of an outfit, and the same builder-and-judge lane that rebuilds our own characters authors a config for it, runs it through every gate above, and publishes it. $9, because the expensive part was building the engine, not using it.
It is a skin and the word is exact: the body is ours and does not change beyond age, figure and build. What you are commissioning is the costume, and the honest limit follows from the same fact. There is one rig, it has two arms and two legs, and these heads are boxes with a nose and two eyes on them. If what makes your character recognisable is their face, the engine cannot help you.
Frequently asked questions
Do my existing animations still work?
Yes. The skeleton is unchanged from v1: same bone names, same hierarchy, same bind pose. Bone lengths vary with age and build, and clips still play correctly because rotations are proportion-independent and the one translation track, the hips, is re-derived from each character's own hip height at play time.
Can I use these outside three.js?
Yes. Every character ships as a GLB as well as an ES module, and the rig is built to the conventions Mixamo, Unity Humanoid and Unreal's IK Retargeter expect.
Is the engine a dependency I have to install?
No. A published character is one file that imports nothing but three. The engine is bundled into the module at publish time, which is the same "one import, no loader, no dependencies" contract every other asset in the store ships under. The cost is that an engine change does not reach a character until it is republished.
How many combinations are there really?
21,120 across the 55 characters currently on the engine: 55 configs x 4 ages x 2 figures x 8 builds x 6 expressions. That is before colour, and each character carries about 27 tintable zones on top.
Can you build a character from my own reference?
Yes, at /skin, for $9. It is a skin on our shared humanoid rather than a new body: garments, hair, palette and props, humanoid only. It is published to the catalogue like every other character, which is what makes the price possible.
What happens to the v1 characters I already bought?
Nothing. They keep working. When a character is rebuilt the previous module is kept beside it, and the rebuild has to pass a silhouette check against the published v1 before it can go live.


