跪拜 Guibai
← Back to the summary

A JSON Recipe Generates Every Vinyl Doll: Head, Hair, and Floating Gloves at Runtime

I Built a "Vinyl Doll" 3D Character Editor with React + Three.js: Recipe-Driven, Procedurally Generated, and It Can Dance

Not a single modeling texture, not a single external model file. The entire character—oversized head, painted-on eyes, one-piece molded hair, floating gloves and shoes—is computed at runtime from a JSON "recipe." This article fully dissects the editor's design: from the superellipsoid head, facial patch system, and sculpted hair blocks, to Rayman-style anatomy and studio-grade lighting.

Tech stack: React 19 + @react-three/fiber + three.js + zustand + Vite + TypeScript.


image.png

image.png

1. First, the Conclusion: Cuteness Has a Formula

Before starting this project, I repeatedly asked one question: why do some procedural characters look "ugly" at first glance, while others instantly look "cute"? After thoroughly dissecting the reference designs (Ferriz-style vinyl dolls), I arrived at a few hard rules:

  1. The head is the star. The head accounts for about 3/4 of the total height; the body is merely the base from which the head grows.
  2. The face must fill the space. Small features huddled in the center of a large face are the primary source of "ugliness"; eyes must be large enough to just fit within the head.
  3. Eyes are "painted on." Ink outlines + white base + large pupil in three layered patches, not two spheres.
  4. No arms or legs. Hands are gloves floating beside the body, shoes float beneath it—the gaps themselves create the character feel.
  5. Gloss comes from the environment. The vinyl texture lies not in the geometry, but in the large softboxes reflected by the clearcoat.

Below, each point is expanded, all corresponding to real code.


2. Recipe-Driven: One Seed Determines One Character

The entire system's input is a CharacterRecipe:

export interface CharacterRecipe {
  name: string;
  seed: number;          // The same seed always generates the same character
  skin: string; blush: string;
  age: 'child' | 'teen' | 'adult';
  expression: 'default' | 'happy' | 'surprised' | 'angry' | 'sad';
  head:  { size; width; height; depth };
  eyes:  { style; size; spacing; color; pupilY; lid; wink };
  mouth: { style; size; teeth; tongue };
  hair:  { style; color; volume };
  body:  { height; width; limb };
  outfit: { top; bottom; shoes; topStyle; bottomStyle; sleeves };
  accessory: { earrings; hat; hatColor };
}

Random generation uses mulberry32(seed) as a deterministic random source, and the weight table is the art direction:

const EYE_DEAL: [EyeStyleId, number][] = [
  ['pupil', 20], ['bead', 14], ['oval', 9], ['googly', 8], ['slab', 8],
  ['round', 5], ['sleepy', 6], ['happy', 6], ['orb', 5],
  ['sparkle', 3], ['diamond', 2], ['star', 1],
];

White-base pupil types carry the main line; exotic eye shapes are just accents. Blush is also not just any pink picked at random, but rather the skin tone brightened toward a warm pink, so neither dark nor light skin looks bruised:

const blush = tint(mix(skin, '#E8705A', 0.45), 0.22);

The benefit of recipe-driven generation: a character library of 48 presets only needs to store 48 seeds, thumbnails are baked on demand, and a save file stores only a snippet of JSON.


3. Layout: The "Single Source of Truth" for All Parts

Procedural characters most often die from "each part calculating its own position": eyes calculate their own position, the mouth calculates its own, hair calculates yet another set, and in the end they all clip through each other. The solution is to centralize all measurements into a single computeLayout(), and parts only ask it for answers.

The head is a superellipsoid:

const r  = 0.45 * recipe.head.size;
const rx = r * Math.max(0.8, recipe.head.width);   // width
const ry = r * Math.min(tall, wide);               // never taller than wide: flat heads are cute

Facial parts are placed via a facial coordinate system: at(ax, ay) maps (−1…1) face coordinates to a point on the superellipsoid surface + normal; the part never knows how the head is constructed:

const at: FaceAt = (ax, ay) => {
  const u = ax * SPREAD, v = ay * SPREAD, cv = Math.cos(v);
  const d = [Math.sin(u) * cv, Math.sin(v), Math.cos(u) * cv];
  const t = surfT(d[0], d[1], d[2], rx, ry, rz, exp); // push to implicit surface
  return { p: [d[0]*t, d[1]*t, d[2]*t], n: surfN(...) }; // normal is gradient, not center direction
};

Note that the normal uses the gradient of the implicit surface rather than "the direction pointing out from the sphere center"—otherwise, on a flattened head, all facial features would be skewed.

Eye "Containment Clamping"

Eyes can be arbitrarily large, but must be contained by the head. Each eye type publishes its own reach/span (how far it extends, how wide it spans), and the layout clamps the size to the minimum of four constraints:

eyeSize = Math.min(
  eyeSize,
  Math.min(roomUp, roomDn) / reachY,        // does not exceed top of head and chin
  (hwAt(y) * 0.95 - cxOff) / spanX,         // does not exceed cheeks
  cxOff / (spanX * 1.1),                    // does not collide with the other eye
);

The mouth follows the same logic: it "pushes" upward from below the eyes, using binary search to solve for mouthY on the actual curved surface; if it doesn't fit, mouthFit shrinks it. Large eyes and a large mouth can coexist because they never fight each other.


4. The Face is "Painted On": Three-Layer Patches + Expressions as Offsets

Eyes: Ink Line + White Base + Large Pupil

The primary eye type pupil is not a sphere, but three extruded patches layered on the head surface:

pupil: { outline: 'ellipse', wf: .92, hf: 1.14, sclera: true, pupilF: .62, border: .3 }

Expressions as Offsets, Not Rebuilt Geometry

Five expressions do not rebuild any mesh; they are just an offset table:

happy: { eyeSY: .78, browLift: .16, mouthSY: 1.14, sagMul: 1.5 }

Smile = white base Y-axis squash (curved eyes) + brow lift + mouth pulled up + smile line sag deepened. Blinking works the same way: white base Y compression + pupil recentered + eyelid band lowered, all written as scale/position per frame.

Autonomous Blinking and Saccades

The character in the editor is "alive": the closed-form solution of a critically damped spring drives saccades (attracted → fixated → released), with blinking at random intervals of 1.4~4.8 seconds. The spring uses a closed-form solution rather than integration, so it won't jitter even at low frame rates:

const step = (x, v, tgt, om, h) => {
  const dx = x - tgt, e = Math.exp(-om * h), b = v + om * dx;
  return [tgt + (dx + b * h) * e, (v - om * b * h) * e];
};

5. Hair: One-Piece "Sculpted Hair Block"

Hair is not inserted cards, nor particles, but rather a single sculpted shell:

The hairstyle table contains only a few numbers:

bob:  { front: .22, side: -.95, back: -1.15, vol: .19, n: 16, jag: .09, open: .34 },
long: { front: .16, side: -1.95, back: -2.25, vol: .18, n: 16, jag: .10, open: .38 },

front/side/back are the hairline positions on the head height (+1 crown, −1 head base, <−1 cascading down); "a horizontal ring is a hat; ordering is the reading feel." 19 hairstyles share one generator; the differences lie entirely in this table.

The control cage uses Catmull-Clark subdivision: a few control points + two subdivision levels = a rounded yet cheap mesh. Gloves and shoes are also made with the same cage process—"any named part (palm, thumb, toe cap, heel) deserves a control cage."


6. Rayman Anatomy: Why There Are No Arms or Legs

This is the most counter-intuitive and most important lesson in the entire project. In my first version, I gave the character a realistic pear-shaped torso and two-segment arms/legs (2-bone IK), and the result was user feedback: ugly.

Going back to read the reference design, I understood:

THE FRAME — RAYMAN anatomy: no arms, no legs, no neck. The torso is an upright pill, the hands are balls FLOATING beside it, the feet are shoes floating under it, and the air in the gaps is what makes it a character and not a figurine. (The gaps are load-bearing walls—it says "character," not "figurine.")

So the final anatomy is:

const rxT = 0.27 * r;            // torso half-width: only 1/4 of head width
const ryT = 0.31 * r;            // torso half-height
const footTop = footS * 1.42;    // shoe top
const tCy = footTop + gap + ryT; // torso center: floats a segment of "air" above shoes
const headBase = tCy + ryT - ry * 0.22; // head sinks into torso by 0.22ry: the only socket joint

How to express waving or walking? Through displacement channels for floating limbs: in an action clip, hands floating upward is waving, shoes alternately moving forward is walking. Proportionally, the head occupies ~3/4 of the total height; the face always carries the entire character.


7. Clothing: Shell Wrapped Around the Pill, Puff Sleeves Wrapped Around the Gloves

Clothing is an "expanded shell" of the torso profile: widthAt(y) linearly interpolates the half-width at any height; shell ring = width × 1.1 + 4mm, nested radius difference prevents z-fighting. The top wraps the entire pill (crop style to bust line + hem ribbing ring), shorts/flared skirt hang on the lower half, and the flared skirt is turned out using LatheGeometry.

Two pitfalls:

  1. The y values of the ring profile must be ascending; descending order flips the face normals, making the entire mesh invisible;
  2. LatheGeometry profile points must be given bottom-up, otherwise the skirt flips and disappears.

Puff sleeves are not attached to the shoulder (they would float), but rather attach: 'handL' wrapped outside the floating glove—when the hand waves, the sleeve follows, naturally correct.


8. Half the Gloss is in the Environment: PMREM Softboxes

The texture of a vinyl doll is not achieved by tweaking roughness. The project replicates a "photo studio": three overexposed softboxes + a gray room + two dark pillars (to give specular reflections structure), pre-filtered into an environment map using PMREMGenerator.fromScene, shared by all clearcoat materials:

box(9, 5, 5.2, 5.1, 4.9, 0, 8.5, 2, 0, Math.PI / 2); // large overhead softbox
box(4, 7, 2.4, 2.3, 2.2, -8, 3, 4, Math.PI / 3);     // warm fill light
box(3, 6, 1.4, 1.5, 1.7, 8, 2, 3, -Math.PI / 3);     // cool rim light

"Chrome with nothing to reflect reads like flat plastic"—the pillars and bright bands are the reflection content prepared for polished surfaces. The same environment map is also fed to the thumbnail baker, ensuring the character library thumbnails and the main viewport are "what you see is what you get."


9. Animation: Phase Keyframes + Timeline

Actions are a keyframe model of Clip = Phase[]: each Phase is { at, pose, move }, pose writes bone Euler angles, move writes the displacement of floating hands and feet. Phases are smoothly interpolated during sampling, and the timeline UI directly edits diamond-shaped keyframes. Four built-in clips: idle / wave / walk / dance. During idle, the character also autonomously blinks and saccades—the animation layer and the "life layer" superimpose, and only then does the character come alive.


10. Character Library: A Hidden Renderer Bakes 48 Thumbnails

The character library does not instantiate 48 Canvases. There is only a single 192px hidden WebGLRenderer, using pure three.js's buildStaticCharacter() (sharing the same pure functions faceBuild/outfitBuild/ghair with the editor) to sequentially bake dataURLs, yielding the main thread every 4 images so the grid appears progressively. The editor components and the baker share the same set of pure functions, which is the engineering guarantee of "what you see is what you get."


11. Pitfall Notes

  1. Normal direction: superellipsoid surface normals use the implicit gradient; toroidal mesh vertex order determines normal orientation; if the Lathe/ring profile direction is wrong, the entire piece disappears.
  2. Determinism: per-strand jitter can only come from a hash of index + seed, not from rng, otherwise it flickers on rebuild.
  3. Clamping over caution: the "containment clamping" for eyes/mouth allows the style table to dare write exaggerated values; both small and large heads remain safe.

Conclusion

What fascinates me most about this editor is not a particular technique, but a kind of order: the recipe is data, layout is measurement, parts are style tables, the environment is the lighting artist—each layer does only one thing, and cuteness thus becomes engineerable.

The project is based on React 19 + R3F + three.js, with about 6000 lines of core code, no external models or textures. If you are also working on procedural characters, I hope this breakdown helps you avoid the detours I took: first understand the structure of "cuteness," then write the code.

Feel free to share your procedural generation experiences in the comments.