component
Person
A sculpted, clothed, skinned figure generated from a seed: one continuous body with a sculpted face, lidded eyes that follow you, and hands with fingers, in three style templates (stylized by default, realistic, anime). Every part is configurable as plain JSON: the body (build, tone, height), the face (eyes, brows, nose, lips, blush), hair, and clothing as ordered layers (shirts, sweaters, vests, coats, dresses, robes, trousers, jeans, skirts, shoes, boots, belts, scarves, capes) that fit any body. Role presets, swappable cosmetic skins, hats and accessories, poses, idle breathing and blinking. Give it a patrol or a wander radius and it walks: a route that is a pure function of the clock, following the ground, with a walk cycle and a step aside for the player and other walkers. A route with a departure time is a one-way trip (to a desk, to bed) that reports its arrival, and actions put a key prompt over it (Talk, Info) when the player comes near. Meshes build in the background, coarse first, with full detail near the camera. Place one at a spawn point, or use it as the Player's third-person body.
⌖ Explore the library in 3D → · View as Markdown
Add it
npx @runek/cli add person
Pulls interactable, sign, @react-three/drei@^10.7.7, @react-three/fiber@^9.6.1, @react-three/rapier@^2.2.0, @runek/core@^0.15.0, three@^0.184.0.
Use it
import { Person } from './runek/Person'
<Person position={[0, 0, 0]} seed={1} />
Or as a node in a world file (worlds as data):
{ "type": "Person", "props": { "position": [0, 0, 0], "seed": 1 } }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
style |
"stylized" | "realistic" | "anime" |
"stylized" |
Proportion and face template: stylized (default) is an animated-film look with a big head and eyes, small features, and slender limbs; realistic keeps canonical human proportions; anime pushes the eyes and legs further. |
kind |
"villager" | "merchant" | "guard" | "sailor" | "farmer" | "noble" | "scholar" | "traveler" |
"villager" |
Role preset: fills outfit, hat, and accessories unless you set them. |
gender |
"feminine" | "masculine" | "neutral" |
Silhouette cues only; every trait it influences stays overridable. Seeded when unset. | |
age |
"child" | "adult" | "elder" |
"adult" |
Head-to-body ratio, stature, and (for elder) a stooped spine. |
body |
PersonBodySpec |
The body: build, skin tone, and height. The flat props below are shorthands for the same fields; when both are set, this object wins. |
|
face |
PersonFaceSpec |
The face, part by part: eye color and size, brow color and weight, nose shape, lip color and fullness, blush. | |
clothes |
PersonGarment[] |
Clothing layers, inner to outer, each with its own type, color, and options. The order decides the fit: a shirt listed before the jeans is tucked in. Replaces outfit; [] is the bare base body (a smooth mannequin). |
|
height |
number |
Standing height, in units. Every other measurement derives from it. | |
build |
"slim" | "average" | "stocky" |
Limb thickness and torso depth. Seeded when unset. | |
skinTone |
color |
Skin color; seeded from a curated set of tones when unset. | |
skin |
"sailor" | "hoodie" | "festival" | "chef" | "harvest" | "winter" | "voyage" | "ceremony" | "atelier" | "pirate" | "bartender" | "office" | "casual" | "preppy" | PersonSkin |
Cosmetic loadout: a preset name from PERSON_SKINS (e.g. "winter") or a custom PersonSkin bundle. Individual props win over it. Plain JSON either way, so an editor or an in-world UI can swap a figure’s whole look by writing this one prop. |
|
hair |
"short" | "long" | "swept" | "bun" | "ponytail" | "braid" | "cropped" | "bald" | PersonHairSpec |
A style name, or { style, color }. |
|
hairColor |
color |
||
facialHair |
"none" | "stubble" | "moustache" | "beard" |
||
outfit |
"tshirt" | "shirt" | "sweater" | "hoodie" | "blazer" | "vest" | "tunic" | "coat" | "dress" | "robe" | "apron" | "uniform" |
One-word outfit; expands to a clothes stack (e.g. tunic: trousers, tunic, shoes). |
|
topColor |
color |
Outermost top’s color; defaults to a seeded shade of a palette slot. A garment’s own color wins over it. |
|
bottomColor |
color |
Bottoms’ color; defaults to a seeded shade of the palette’s woodDark, stone, or metal. |
|
shoeColor |
color |
Footwear color; defaults to the palette’s metal. |
|
hat |
"none" | "cap" | "straw" | "brim" | "bandana" | "hood" | "chef" |
||
accessories |
"belt" | "scarf" | "cape" | "bag" | "glasses" | "staff"[] |
||
pose |
"stand" | "sit" | "lean" | "work" | "wave" | "type" | "play" | "drink" | "lie" |
"stand" |
What the figure is doing when it isn’t walking. sit and type need a seat under them (type is seated at a desk, fingers tapping); work leans over a counter; play works controls at waist height; drink holds a mug and sips from it now and then; lie lies on its back centered on position, head toward local -Z, so it takes a Bed’s position and rotation at mattress height. |
patrol |
[x, y, z][] |
Walk these waypoints, relative to position and turning with rotation, in units. Height follows the ground; a waypoint’s y lifts the figure above it. |
|
wander |
number |
Wander a seeded loop of five points within this radius of position, in units. patrol wins when both are set. |
|
speed |
number |
Walking speed, in units per second. Defaults by age: child 1.0, adult 1.3, elder 0.8. | |
pause |
number |
1.5 |
Seconds held at each waypoint. Walking always stands; the authored pose plays here. |
loop |
"loop" | "pingpong" |
"loop" |
loop walks from the last waypoint back to the first; pingpong retraces the route. |
route |
[x, y, z][] |
A one-way trip through these waypoints, relative to position like patrol, setting off at departAt. Before then the figure waits at the first point facing its rotation; after the last it stays there, facing the way it came, in its pose. Wins over patrol and wander. Give a new trip a new position (the old one’s end) and departAt. |
|
departAt |
number |
0 |
When the route trip sets off, in epoch milliseconds (Date.now()). Plain data, so every viewer sees the figure at the same point of its trip. Unset, the trip is long over. |
onArrive |
function (code only) |
Called once per trip (route + departAt) when the figure has arrived, on the first frame it has, so a trip that ended while the world was paused or unmounted still reports. |
|
gait |
number |
Walk-cycle speed, in units per second, for a figure something else moves (a cart, a script). Unset, a route drives it, and a Player’s body walks at the avatar’s speed. |
|
idle |
boolean |
true |
Breathing, weight shift, and blinking. |
lookAt |
boolean |
true |
Turn the head (and eyes) toward the player’s avatar (or the camera, when no Player is mounted) when it comes within lookRadius. |
lookRadius |
number |
5 |
How close the avatar must be for lookAt to engage, in units. |
label |
string |
Floating name above the head. | |
bubble |
string |
A short line in a speech bubble over the head (what the figure is saying or doing right now). Long lines wrap and are cut after a few. | |
emote |
"sleep" | "alert" | "think" | "happy" | "coffee" |
A small animated sign over the head: sleep (rising z’s), alert (a bouncing !), think (pulsing dots), happy (a heart), coffee (a steaming cup). |
|
actions |
InteractionAction[] |
What the player can do with this figure (Talk, Info). When the avatar comes within actionRadius a prompt shows them over the head, with the key each world controls action is bound to; pressing one calls onAction with its id. Only the nearest figure (or other Interactable) in range shows a prompt and takes the key. |
|
actionRadius |
number |
2 |
How close the avatar must come for actions, in units. |
onAction |
function (code only) |
||
collider |
boolean |
true |
Capsule collider, so the figure is something you bump into. |
physics |
boolean |
true |
Render as a bare visual with no RigidBody: for a parent that owns the physics (e.g. as Player’s third-person avatar). When false, collider is ignored. |
detail |
"low" | "high" | "auto" |
"auto" |
auto builds the full-detail mesh only within ~10 units of the camera; high always; low never. |
position |
[x, y, z] |
Position [x, y, z] in units (1 unit = 1 m, Y-up). | |
rotation |
[x, y, z] |
[0, 0, 0] |
Euler rotation [x, y, z] in radians. |
seed |
number |
1 |
Seed for the deterministic variation: same seed, same result. |
PersonBodySpec
| Prop | Type | Default | Description |
|---|---|---|---|
build |
"slim" | "average" | "stocky" |
Limb thickness and torso depth. | |
tone |
color |
Skin color. | |
height |
number |
Standing height, in units. |
PersonFaceSpec
| Prop | Type | Default | Description |
|---|---|---|---|
eyes |
eyes |
||
brows |
brows |
Brow color defaults to the hair color; weight thickens them. |
|
nose |
"button" | "straight" | "broad" |
||
lips |
lips |
||
blush |
number |
Cheek flush, 0 (none) to 1. |
Person.eyes
| Prop | Type | Default | Description |
|---|---|---|---|
color |
color |
||
size |
number |
Person.brows
| Prop | Type | Default | Description |
|---|---|---|---|
color |
color |
||
weight |
number |
Person.lips
| Prop | Type | Default | Description |
|---|---|---|---|
color |
color |
||
fullness |
number |
PersonGarment
| Prop | Type | Default | Description |
|---|---|---|---|
type |
"tshirt" | "shirt" | "sweater" | "hoodie" | "blazer" | "vest" | "tunic" | "coat" | "dress" | "robe" | "apron" | "trousers" | "jeans" | "shorts" | "skirt" | "shoes" | "boots" | "belt" | "scarf" | "cape" |
required | |
color |
color |
||
sleeves |
"short" | "none" | "long" |
Tops only. | |
neck |
"crew" | "v" | "collar" |
Tops only. | |
length |
number |
0 to 1: how far it reaches (a top’s hem, a leg’s length, a skirt or boot’s drop). |
PersonSkin
| Prop | Type | Default | Description |
|---|---|---|---|
outfit |
"tshirt" | "shirt" | "sweater" | "hoodie" | "blazer" | "vest" | "tunic" | "coat" | "dress" | "robe" | "apron" | "uniform" |
||
clothes |
PersonGarment[] |
Full layer stack; wins over outfit. |
|
topColor |
color |
||
bottomColor |
color |
||
shoeColor |
color |
||
hat |
"none" | "cap" | "straw" | "brim" | "bandana" | "hood" | "chef" |
||
hair |
"short" | "long" | "swept" | "bun" | "ponytail" | "braid" | "cropped" | "bald" |
||
hairColor |
color |
||
accessories |
"belt" | "scarf" | "cape" | "bag" | "glasses" | "staff"[] |
PersonHairSpec
| Prop | Type | Default | Description |
|---|---|---|---|
style |
"short" | "long" | "swept" | "bun" | "ponytail" | "braid" | "cropped" | "bald" |
||
color |
color |
InteractionAction
| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
required | Passed back to onAction. |
label |
string |
required | Shown in the prompt, e.g. Talk. |
control |
string |
required | The world controls action that triggers it (declare talk: ['KeyT'] in the world’s controls). The prompt shows the key bound to it, so a remap relabels it. |
Migrate
v0.13.0 → v0.14.0. The figure is rebuilt as one sculpted, skinned body with clothes as layers, and the default style is now stylized. No prop was removed: every flat prop still works, and the new part objects (body, face, hair, clothes) are optional.
<Person
seed={4}
outfit="shirt"
topColor="#e8e2d2"
skinTone="#c98d63"
hairColor="#2b1d14"
/>
<Person
seed={4}
body={{ tone: '#c98d63' }}
hair={{ style: 'short', color: '#2b1d14' }}
clothes={[
{ type: 'shirt', sleeves: 'short', color: '#e8e2d2' },
{ type: 'trousers' },
{ type: 'shoes' },
]}
/>
- Nothing to change to keep a world rendering: the flat props still resolve. The part objects are the long form when you want to set each piece yourself. Pass
style="anime"for the closest match to the old default look. - Any trait set as a flat prop no longer shifts the seeded rolls of the others, so a few figures with explicit colors may roll a different hairstyle than before.
clotheslists layers inner to outer; a top listed before the trousers is tucked in.clothes={[]}is the bare base body.
Registry manifest
registry manifest /r/components/person.json Self-contained JSON: inlined source plus resolved dependencies, exactly whatrunek add person fetches.
Browse the whole catalog in the gallery →.
Source (13 files)
Person.tsx
import { useFrame } from '@react-three/fiber'
import { CapsuleCollider, type RapierRigidBody, RigidBody } from '@react-three/rapier'
import {
type InteractionAction,
PlayerMotionContext,
rng,
sub,
useInteraction,
useWorld,
type Vec3,
type WorldComponentProps,
} from '@runek/core'
import { useContext, useEffect, useMemo, useRef, useState } from 'react'
import {
CylinderGeometry,
type Group,
Mesh,
MeshPhysicalMaterial,
type Object3D,
Quaternion,
Group as ThreeGroup,
TorusGeometry,
Vector3,
} from 'three'
import { InteractionPrompt } from './Interactable'
import { Emote, SpeechBubble } from './person/Bubble'
import type { BuiltFigure, Lod } from './person/build'
import {
type GaitState,
PhysicsRouteDriver,
RouteDriver,
type WalkRoute,
} from './person/RouteDriver'
import { attach, createRig, skinnedMeshes } from './person/rig'
import { buildRoute, type RouteLoop, wanderPoints } from './person/route'
import { figureKey, requestFigure } from './person/scheduler'
import { ARM_SPREAD, shapeFigure } from './person/shape'
import { type PersonSkinName, resolveFigure } from './person/spec'
import type {
PersonAccessory,
PersonAge,
PersonBodySpec,
PersonBuild,
PersonDetail,
PersonEmote,
PersonFaceSpec,
PersonFacialHair,
PersonGarment,
PersonGender,
PersonHair,
PersonHairSpec,
PersonHat,
PersonKind,
PersonOutfit,
PersonPose,
PersonSkin,
PersonStyle,
} from './person/types'
import { Sign } from './Sign'
export {
type PersonTrait,
type PersonTraitSource,
type ResolvedGarment,
type ResolvedPerson,
resolvePerson,
} from './person/resolve'
export { tripAt } from './person/route'
export { PERSON_COLORS, PERSON_SKINS, type PersonSkinName } from './person/spec'
export type {
PersonAccessory,
PersonAge,
PersonBodySpec,
PersonBuild,
PersonDetail,
PersonEmote,
PersonFaceSpec,
PersonFacialHair,
PersonGarment,
PersonGarmentType,
PersonGender,
PersonHair,
PersonHairSpec,
PersonHat,
PersonKind,
PersonNose,
PersonOutfit,
PersonPose,
PersonSkin,
PersonStyle,
} from './person/types'
export interface PersonProps extends WorldComponentProps {
/** Proportion and face template: `stylized` (default) is an animated-film look with a big
* head and eyes, small features, and slender limbs; `realistic` keeps canonical human
* proportions; `anime` pushes the eyes and legs further. */
style?: PersonStyle
/** Role preset: fills `outfit`, `hat`, and `accessories` unless you set them. */
kind?: PersonKind
/** Silhouette cues only; every trait it influences stays overridable. Seeded when unset. */
gender?: PersonGender
/** Head-to-body ratio, stature, and (for `elder`) a stooped spine. */
age?: PersonAge
/** The body: `build`, skin `tone`, and `height`. The flat props below are shorthands for
* the same fields; when both are set, this object wins. */
body?: PersonBodySpec
/** The face, part by part: eye color and size, brow color and weight, nose shape, lip
* color and fullness, blush. */
face?: PersonFaceSpec
/** Clothing layers, inner to outer, each with its own type, color, and options. The order
* decides the fit: a shirt listed before the jeans is tucked in. Replaces `outfit`; `[]`
* is the bare base body (a smooth mannequin). */
clothes?: PersonGarment[]
/** Standing height, in units. Every other measurement derives from it. */
height?: number
/** Limb thickness and torso depth. Seeded when unset. */
build?: PersonBuild
/** Skin color; seeded from a curated set of tones when unset. */
skinTone?: string
/** Cosmetic loadout: a preset name from `PERSON_SKINS` (e.g. `"winter"`) or a custom
* `PersonSkin` bundle. Individual props win over it. Plain JSON either way, so an editor
* or an in-world UI can swap a figure's whole look by writing this one prop. */
skin?: PersonSkinName | PersonSkin
/** A style name, or `{ style, color }`. */
hair?: PersonHair | PersonHairSpec
hairColor?: string
facialHair?: PersonFacialHair
/** One-word outfit; expands to a `clothes` stack (e.g. `tunic`: trousers, tunic, shoes). */
outfit?: PersonOutfit
/** Outermost top's color; defaults to a seeded shade of a palette slot. A garment's own
* `color` wins over it. */
topColor?: string
/** Bottoms' color; defaults to a seeded shade of the palette's `woodDark`, `stone`, or `metal`. */
bottomColor?: string
/** Footwear color; defaults to the palette's `metal`. */
shoeColor?: string
hat?: PersonHat
accessories?: PersonAccessory[]
/** What the figure is doing when it isn't walking. `sit` and `type` need a seat under them
* (`type` is seated at a desk, fingers tapping); `work` leans over a counter; `play` works
* controls at waist height; `drink` holds a mug and sips from it now and then; `lie` lies on its
* back centered on `position`, head toward local -Z, so it takes a `Bed`'s position and
* rotation at mattress height. */
pose?: PersonPose
/** Walk these waypoints, relative to `position` and turning with `rotation`, in units.
* Height follows the ground; a waypoint's y lifts the figure above it. */
patrol?: Vec3[]
/** Wander a seeded loop of five points within this radius of `position`, in units. `patrol`
* wins when both are set. */
wander?: number
/** Walking speed, in units per second. Defaults by age: child 1.0, adult 1.3, elder 0.8. */
speed?: number
/** Seconds held at each waypoint. Walking always stands; the authored `pose` plays here. */
pause?: number
/** `loop` walks from the last waypoint back to the first; `pingpong` retraces the route. */
loop?: RouteLoop
/** A one-way trip through these waypoints, relative to `position` like `patrol`, setting off
* at `departAt`. Before then the figure waits at the first point facing its `rotation`; after
* the last it stays there, facing the way it came, in its `pose`. Wins over `patrol` and
* `wander`. Give a new trip a new `position` (the old one's end) and `departAt`. */
route?: Vec3[]
/** When the `route` trip sets off, in epoch milliseconds (`Date.now()`). Plain data, so every
* viewer sees the figure at the same point of its trip. Unset, the trip is long over. */
departAt?: number
/** Called once per trip (`route` + `departAt`) when the figure has arrived, on the first
* frame it has, so a trip that ended while the world was paused or unmounted still reports. */
onArrive?: () => void
/** Walk-cycle speed, in units per second, for a figure something else moves (a cart, a
* script). Unset, a route drives it, and a `Player`'s body walks at the avatar's speed. */
gait?: number
/** Breathing, weight shift, and blinking. */
idle?: boolean
/** Turn the head (and eyes) toward the player's avatar (or the camera, when no `Player` is
* mounted) when it comes within `lookRadius`. */
lookAt?: boolean
/** How close the avatar must be for `lookAt` to engage, in units. */
lookRadius?: number
/** Floating name above the head. */
label?: string
/** A short line in a speech bubble over the head (what the figure is saying or doing right
* now). Long lines wrap and are cut after a few. */
bubble?: string
/** A small animated sign over the head: `sleep` (rising z's), `alert` (a bouncing !), `think`
* (pulsing dots), `happy` (a heart), `coffee` (a steaming cup). */
emote?: PersonEmote
/** What the player can do with this figure (`Talk`, `Info`). When the avatar comes within
* `actionRadius` a prompt shows them over the head, with the key each world `controls`
* action is bound to; pressing one calls `onAction` with its `id`. Only the nearest figure
* (or other `Interactable`) in range shows a prompt and takes the key. */
actions?: InteractionAction[]
/** How close the avatar must come for `actions`, in units. */
actionRadius?: number
onAction?: (id: string) => void
/** Capsule collider, so the figure is something you bump into. */
collider?: boolean
/** Render as a bare visual with no `RigidBody`: for a parent that owns the physics
* (e.g. as `Player`'s third-person avatar). When false, `collider` is ignored. */
physics?: boolean
/** `auto` builds the full-detail mesh only within ~10 units of the camera; `high` always;
* `low` never. */
detail?: PersonDetail
}
const WALK_SPEED: Record<PersonAge, number> = { child: 1, adult: 1.3, elder: 0.8 }
/** Walking speed (m/s) at which the walk cycle reaches full swing. */
const CRUISE = 1.3
/** Full detail builds inside LOD_IN units of the camera and drops past LOD_OUT. */
const LOD_IN = 10
const LOD_OUT = 13
/** Seconds between sips for `drink`, and how long a sip takes. */
const SIP_EVERY = 7
const SIP = 1.8
const MAX_YAW = 1.2
const MAX_PITCH = 0.4
const EYE_REACH = 0.35
const NO_ACTIONS: InteractionAction[] = []
const isInside = (o: Object3D, ancestor: Object3D) => {
for (let p = o.parent; p; p = p.parent) if (p === ancestor) return true
return false
}
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
/** Keep a build of `lod` while `want` holds; `null` until it's ready. */
function useBuild(
spec: Parameters<typeof requestFigure>[0],
key: string,
lod: Lod,
want: boolean,
distance: { current: number },
) {
const [built, setBuilt] = useState<BuiltFigure | null>(null)
useEffect(() => {
if (!want) {
setBuilt(null)
return
}
let live = true
const cancel = requestFigure(
spec,
key,
lod,
() => distance.current,
(b) => {
if (live) setBuilt(b)
},
)
return () => {
live = false
cancel()
}
// `spec` is identified by `key`.
}, [key, lod, want, distance])
return built && want ? built : null
}
/**
* A procedural person: a sculpted, clothed, skinned figure generated from a seed.
*
* The body is one continuous surface grown from a few dozen smooth primitives on an 18-bone
* skeleton (no seams at the joints), with a sculpted face, real eyelids, and eyeballs that
* follow you. Clothes, hair, and hats are layers over it, each fitted to the body by offsetting
* its surface, so any garment fits any build, age, or style. Skin weights come from the same
* primitives, so the whole figure bends as one.
*
* Every trait resolves the same way: a part object (`body`, `face`, `hair`, `clothes`) wins, then
* the flat shorthand prop, then the `skin` bundle, then the `kind` preset, then a seeded roll.
* So `<Person seed={3} />` is a complete villager and each prop narrows it.
*
* `position` is the spawn point. Give it a `patrol` (waypoints) or a `wander` radius and it
* walks: where it is on the route is a function of the clock and the seed, so every viewer sees
* it in the same place, and it steps around the player and other walkers in its way. A `route`
* with a `departAt` time is a one-way trip instead (to a desk, to bed), with `onArrive` when it
* ends. It stands
* on one capsule collider, kinematic while it walks. Pass `physics={false}` for a bare visual,
* which is how it becomes `Player`'s third-person body (walking in step with the avatar):
* `<Player><Person physics={false} height={1.3} position={[0, -0.65, 0]} /></Player>`.
*
* Meshes build in the background a few milliseconds per frame, coarse first; the full-detail
* mesh only builds near the camera. Identical figures share one build.
*
* `resolvePerson(props)` reports what every trait resolves to and where it came from (prop,
* skin, kind, seed, or default), so an editor can show what the seed chose; `PERSON_COLORS`
* holds the curated skin, hair, and eye colors the seed rolls from.
*/
export function Person({
position,
rotation = [0, 0, 0],
seed = 1,
style = 'stylized',
kind = 'villager',
gender,
age = 'adult',
body,
face,
clothes,
height,
build,
skinTone,
skin,
hair,
hairColor,
facialHair,
outfit,
topColor,
bottomColor,
shoeColor,
hat,
accessories,
pose = 'stand',
patrol,
wander,
speed,
pause = 1.5,
loop = 'loop',
route: trip,
departAt = 0,
onArrive,
gait,
idle = true,
lookAt = true,
lookRadius = 5,
label,
bubble,
emote,
actions,
actionRadius = 2,
onAction,
collider = true,
physics = true,
detail = 'auto',
}: PersonProps) {
const { unit, palette, ground, player } = useWorld()
const avatarMotion = useContext(PlayerMotionContext)
// A placed figure stands on the world's ground baseline; a bare visual is positioned by
// whatever parent owns it (a `Player` capsule, a vehicle), so it starts at its own origin.
const at: Vec3 = position ?? (physics ? [0, ground, 0] : [0, 0, 0])
const traits = JSON.stringify([
seed,
style,
kind,
gender,
age,
body,
face,
clothes,
height,
build,
skinTone,
skin,
hair,
hairColor,
facialHair,
outfit,
topColor,
bottomColor,
shoeColor,
hat,
accessories,
])
const { spec, motion } = useMemo(
() =>
resolveFigure(
{
seed,
style,
kind,
gender,
age,
body,
face,
clothes,
height,
build,
skinTone,
skin,
hair,
hairColor,
facialHair,
outfit,
topColor,
bottomColor,
shoeColor,
hat,
accessories,
},
palette,
unit,
),
[traits, palette, unit],
)
const key = useMemo(() => figureKey(spec), [spec])
// Joints and landmarks only: cheap, and the same for every level of detail.
const shape = useMemo(() => shapeFigure(spec), [key])
const rig = useMemo(() => createRig(shape.joints), [shape])
const marks = shape.marks
// The route is node-local geometry like any other: waypoints scale by `unit` and turn with the
// node. Wander points come from their own seed stream so they don't reshuffle the figure. A
// trip's clock starts at `departAt`; a cycle's at a seeded point, so walkers don't march in step.
const patrolKey = patrol && JSON.stringify(patrol)
const tripKey = trip && JSON.stringify(trip)
const route = useMemo<WalkRoute | null>(() => {
const points = trip ?? patrol ?? (wander ? wanderPoints(wander, sub(seed, 1)) : undefined)
if (!points || (!trip && points.length < 2)) return null
const built = buildRoute(
points.map(([x, y, z]) => [x * unit, y * unit, z * unit]),
{ speed: (speed ?? WALK_SPEED[age]) * unit, pause, loop: trip ? 'once' : loop },
)
if (trip) return { ...built, phase: -departAt / 1000 }
if (!built.period) return null
return { ...built, phase: rng(sub(seed, 2))() * built.period }
}, [tripKey, departAt, patrolKey, wander, seed, speed, age, pause, loop, unit])
const arrive = useRef(onArrive)
arrive.current = onArrive
const handleArrive = useMemo(() => () => arrive.current?.(), [])
// ---- level of detail -------------------------------------------------------------------------
const distance = useRef(Number.POSITIVE_INFINITY)
const [close, setClose] = useState(false)
const wantNear = detail === 'high' || (detail === 'auto' && close)
// The coarse build always comes first (it's quick), so a figure is never missing for long.
const far = useBuild(spec, key, 'far', true, distance)
const near = useBuild(spec, key, 'near', wantNear, distance)
const active = near ?? far
const meshes = useMemo(() => (active ? skinnedMeshes(active, rig) : null), [active, rig])
useEffect(() => {
if (!meshes) return
for (const m of meshes.meshes) rig.group.add(m)
return meshes.dispose
}, [meshes, rig])
const parts = useMemo(
() =>
attach(
rig,
shape,
shape.joints,
spec.colors.eye,
spec.colors.skin,
spec.colors.metal,
spec.colors.wood,
),
[rig, shape, spec.colors],
)
useEffect(() => parts.dispose, [parts])
const detailed = active?.lod === 'near'
const shown = active !== null
useEffect(() => {
for (const l of parts.lids) l.visible = detailed
for (const e of parts.eyes) e.visible = shown
for (const o of parts.props) o.visible = shown
}, [parts, detailed, shown])
const root = useRef<Group>(null)
const focused = useInteraction(root, {
actions: actions ?? NO_ACTIONS,
radius: actionRadius,
onAction,
})
const nameTag = useRef<Group>(null)
const nodeFrame = useRef<Group>(null)
const walker = useRef<Group>(null)
const facing = useRef<Group>(null)
const bodyRef = useRef<RapierRigidBody>(null)
const scratch = useMemo(() => new Vector3(), [])
const walk = useRef<GaitState>({ speed: 0, distance: 0 })
// Eased blend weights: `stride` is how far into the walk cycle the limbs are (0 standing, 1
// full swing); `posed` how far into the authored pose (walking always stands).
const blend = useRef({ stride: 0, posed: 1 })
const H = spec.height
const p = useMemo(() => {
const sit = pose === 'sit' || pose === 'type'
const lean: Partial<Record<PersonPose, number>> = {
work: 0.42,
lean: -0.14,
type: 0.14,
play: 0.1,
sit: 0.06,
}
const armX: Partial<Record<PersonPose, number>> = {
work: -0.7,
type: -0.5,
play: -0.45,
sit: -0.25,
drink: -0.12,
}
const elbow: Partial<Record<PersonPose, number>> = {
work: -0.6,
type: -1.15,
play: -1.25,
sit: -0.5,
drink: -0.35,
lie: -0.08,
}
return {
sit,
lie: pose === 'lie',
drop: sit ? -(marks.hipY - marks.thigh * 0.92) : 0,
spineX: motion.stoop * (pose === 'lie' ? 0 : 1) + (lean[pose] ?? 0),
armX: armX[pose] ?? 0,
armZ: pose === 'lean' ? 0.1 : pose === 'lie' ? 0.16 : 0.02,
elbow: elbow[pose] ?? -0.18,
thighX: sit ? -Math.PI / 2 : 0,
shinX: sit ? Math.PI / 2.1 : 0,
wave: pose === 'wave',
drink: pose === 'drink',
/** Finger tapping (keys, buttons): how fast. */
tap: pose === 'type' ? 17 : pose === 'play' ? 9 : 0,
}
}, [pose, marks, motion.stoop])
// A mug for `drink`, held in the right hand and kept upright whatever the wrist does.
const mug = useMemo(() => {
const k = H / 1.7
const body = new CylinderGeometry(0.036 * k, 0.032 * k, 0.09 * k, 14)
const handle = new TorusGeometry(0.022 * k, 0.006 * k, 6, 12, Math.PI)
const mat = new MeshPhysicalMaterial({ color: '#efe9df', roughness: 0.35, clearcoat: 0.4 })
const g = new ThreeGroup()
const cup = new Mesh(body, mat)
const ear = new Mesh(handle, mat)
ear.rotation.z = -Math.PI / 2
ear.position.set(0, 0, -0.036 * k)
ear.rotation.y = Math.PI / 2
cup.castShadow = true
g.add(cup, ear)
g.position.set(0.012 * k, -0.075 * k, 0.03 * k)
const dispose = () => {
body.dispose()
handle.dispose()
mat.dispose()
}
return { group: g, dispose }
}, [H])
useEffect(() => {
if (!p.drink) return
const hand = rig.bones['hand.R']
hand.add(mug.group)
return () => {
hand.remove(mug.group)
}
}, [p.drink, rig, mug])
useEffect(() => mug.dispose, [mug])
const quat = useMemo(() => ({ a: new Quaternion(), b: new Quaternion() }), [])
useFrame((state, dt) => {
const t = state.clock.elapsedTime
const k = 1 - Math.exp(-7 * dt)
const b = rig.bones
if (root.current) {
distance.current =
root.current.getWorldPosition(scratch).distanceTo(state.camera.position) / unit
if (detail === 'auto') {
if (!close && distance.current < LOD_IN) setClose(true)
else if (close && distance.current > LOD_OUT) setClose(false)
}
}
// Walking speed comes from the route, else an explicit `gait`, else the carrying `Player`.
// Off-route, distance is integrated so the stride phase still tracks the feet.
const w = walk.current
if (!route) {
w.speed = gait ?? avatarMotion?.current.speed ?? 0
w.distance += w.speed * dt
}
const bl = blend.current
const moving = w.speed > 0.05 * unit
bl.stride += (Math.min(w.speed / (CRUISE * unit), 1) - bl.stride) * (1 - Math.exp(-10 * dt))
bl.posed += ((moving ? 0 : 1) - bl.posed) * (1 - Math.exp(-8 * dt))
const s = bl.stride
const q = bl.posed
const waving = p.wave && q > 0.5
// Lying down turns the whole figure onto its back, centered on the origin, back on the
// surface, head toward -Z.
const lie = p.lie ? q : 0
rig.group.rotation.x = -(Math.PI / 2) * lie
rig.group.position.set(0, 0.075 * H * lie, (H / 2) * lie)
const tap = (side: number) => (p.tap ? Math.sin(t * p.tap + side) * 0.035 * q : 0)
const cyc = (t + motion.phase * 3) % SIP_EVERY
const sip = p.drink && cyc < SIP ? Math.sin((cyc / SIP) * Math.PI) * q : 0
// Stride phase from distance walked: one full cycle (two steps) per 0.8 body heights.
const phase = (w.distance / (0.8 * H)) * Math.PI * 2
const swing = Math.sin(phase) * s * Math.min(0.3 + 0.2 * (w.speed / unit), 0.7)
const bob = s * 0.012 * H * (Math.abs(Math.cos(phase)) - 0.6)
const breath = idle ? Math.sin(t * 1.6 + motion.phase) : 0
const sway = idle ? Math.sin(t * 0.42 + motion.phase) * (1 - s) : 0
b.root.position.y = p.drop * q + breath * 0.003 * H + bob
b.pelvis.rotation.z = sway * 0.022
const lean = motion.stoop + (p.spineX - motion.stoop) * q + s * 0.05
b.spine.rotation.x = lean * 0.45
b.chest.rotation.set(lean * 0.55 + breath * 0.012, swing * 0.18, -sway * 0.012)
// Opposite arm to leg: an arm swings back as its leg reaches forward.
b['upperArm.L'].rotation.set(p.armX * q - sway * 0.06 + swing * 0.7, 0, p.armZ - ARM_SPREAD)
b['foreArm.L'].rotation.set(p.elbow * q - 0.18 * (1 - q) - s * 0.25 + tap(0), 0, 0)
if (p.drink && q > 0.05) {
// Mug at the chest, lifted to the lips for a sip.
b['upperArm.R'].rotation.set((-0.35 - 0.3 * sip) * q, 0, ARM_SPREAD + (0.4 + 0.12 * sip) * q)
b['foreArm.R'].rotation.set((-1.75 - 0.6 * sip) * q - 0.18 * (1 - q), 0, 0)
} else if (waving) {
// Upper arm out just above level, forearm raised and rocking at the elbow.
b['upperArm.R'].rotation.set(-0.25, 0, ARM_SPREAD - 1.75)
b['foreArm.R'].rotation.set(0, 0, -1.15 - Math.sin(t * 6) * 0.35)
} else {
b['upperArm.R'].rotation.set(p.armX * q + sway * 0.06 - swing * 0.7, 0, ARM_SPREAD - p.armZ)
b['foreArm.R'].rotation.set(p.elbow * q - 0.18 * (1 - q) - s * 0.25 + tap(2.1), 0, 0)
}
if (p.drink) {
// Undo the wrist's turn so the mug stays upright, tipping only as it reaches the lips.
rig.group.updateMatrixWorld()
rig.group.getWorldQuaternion(quat.a).invert()
b['hand.R'].getWorldQuaternion(quat.b)
mug.group.quaternion.copy(quat.a.multiply(quat.b)).invert()
mug.group.rotateX(0.5 * sip)
}
// Legs: the thigh swings (negative X is forward), and the knee flexes while that leg travels
// forward, which is what lifts the foot clear of the ground.
const knee = (side: number) => s * (0.12 + 0.6 * Math.max(0, Math.cos(phase + side)))
b['thigh.L'].rotation.x = p.thighX * q - swing
b['thigh.R'].rotation.x = p.thighX * q + swing
b['shin.L'].rotation.x = p.shinX * q + knee(0)
b['shin.R'].rotation.x = p.shinX * q + knee(Math.PI)
b['foot.L'].rotation.x = -knee(0) * 0.3
b['foot.R'].rotation.x = -knee(Math.PI) * 0.3
// Blink: the lid sweeps down over the eye and back.
if (parts.lids.length) {
const blink = (t + motion.blinkPhase) % motion.blinkEvery
const closed = idle && blink < 0.14 ? Math.sin((blink / 0.14) * Math.PI) : 0
for (const lid of parts.lids) lid.rotation.x = -0.95 + closed * 1.75
}
// Head tracking: the target in the head's own parent space gives yaw and pitch directly. The
// target is the player's avatar when a `Player` is mounted (in third person the camera trails
// behind it), else the camera. Outside the radius, or behind the shoulder, the head eases back
// to neutral; the eyes lead the head and cover what it can't. A figure that *is* the avatar's
// body has nobody to watch.
const h = b.head
if (h.rotation.order !== 'YXZ') h.rotation.order = 'YXZ'
let yaw = idle ? sway * 0.05 : 0
let pitch = 0
let wanted = 0
let tracking = false
const avatar = player?.current
const self = avatar?.parent != null && isInside(h, avatar.parent)
if (lookAt && h.parent && !self && !p.lie) {
if (avatar) avatar.getWorldPosition(scratch)
else scratch.copy(state.camera.position)
h.parent.worldToLocal(scratch).sub(h.position)
const flat = Math.hypot(scratch.x, scratch.z)
wanted = Math.atan2(scratch.x, scratch.z)
if (scratch.length() < lookRadius * unit && Math.abs(wanted) < MAX_YAW + EYE_REACH) {
tracking = true
yaw = clamp(wanted, -MAX_YAW, MAX_YAW)
pitch = clamp(-Math.atan2(scratch.y, flat), -MAX_PITCH, MAX_PITCH)
}
}
h.rotation.y += (yaw - h.rotation.y) * k
h.rotation.x += (pitch - 0.25 * sip - h.rotation.x) * k
const eyeYaw = tracking ? clamp(wanted - h.rotation.y, -EYE_REACH, EYE_REACH) : 0
for (const e of parts.eyes) e.rotation.y += (eyeYaw - e.rotation.y) * Math.min(1, k * 3)
// Keep the name legible from wherever it's read.
if (nameTag.current?.parent) {
scratch.copy(state.camera.position)
nameTag.current.parent.worldToLocal(scratch)
nameTag.current.rotation.y = Math.atan2(scratch.x, scratch.z)
}
})
// Where floating things go: over the head, which for a lying figure is near the surface at -Z.
const overhead: Vec3 = p.lie ? [0, 0.32 * H, -0.42 * H] : [0, H + marks.headH * 0.55 + p.drop, 0]
// Stacked over the head, bottom to top: name, bubble, emote, action prompt.
const [bubbleH, setBubbleH] = useState(0)
const above = (y: number): Vec3 => [overhead[0], overhead[1] + y, overhead[2]]
const bubbleY = (label ? 0.2 : 0.05) * unit
const emoteY = bubbleY + (bubble ? bubbleH + 0.05 * unit : 0.08 * unit)
const promptY = emoteY + (emote ? 0.3 * unit : 0) + (bubble || emote ? 0.04 * unit : 0)
const figure = (
<group ref={root}>
<primitive object={rig.group} />
{label && (
<group ref={nameTag} position={overhead}>
<Sign variant="body" size={0.13} color={palette.accent}>
{label}
</Sign>
</group>
)}
{bubble && (
<SpeechBubble
text={bubble}
position={above(bubbleY)}
onHeight={(h) => setBubbleH((prev) => (Math.abs(prev - h) > 0.002 ? h : prev))}
/>
)}
{emote && <Emote kind={emote} position={above(emoteY)} />}
{focused && actions && (
<InteractionPrompt
actions={actions}
position={above(bubble || emote ? promptY : (label ? 0.26 : 0.08) * unit)}
/>
)}
</group>
)
// One capsule spanning the standing (or seated) body: an obstacle you bump into, not a
// collider per limb (CONTRACT §5). A walking figure keeps the standing one throughout.
const seated = p.sit && !route
const bottom = seated ? H * 0.06 : 0
const top = H + (seated ? p.drop : 0)
const radius = Math.min(0.105 * H, (top - bottom) / 2)
const half = Math.max((top - bottom) / 2 - radius, 0.01 * H)
const driven = route && {
route,
frame: nodeFrame,
facing,
gait: walk,
radius,
waist: marks.waistY,
unit,
onArrive: handleArrive,
}
if (!physics) {
return (
<group ref={nodeFrame} position={at} rotation={rotation}>
<group ref={walker}>
<group ref={facing}>{figure}</group>
</group>
{driven && (
<RouteDriver
{...driven}
move={(to) => walker.current?.position.copy(nodeFrame.current?.worldToLocal(to) ?? to)}
/>
)}
</group>
)
}
// A walking figure's body is kinematic, moved by its route; the node frame it walks in is a
// bare sibling, since the body itself leaves the spawn point. A standing one stays fixed.
// `excludeEcctrlRay` keeps the avatar from treating a walker as a moving platform to ride.
return (
<>
{driven && (
<>
<group ref={nodeFrame} position={at} rotation={rotation} />
<PhysicsRouteDriver {...driven} body={bodyRef} />
</>
)}
<RigidBody
key={driven ? 'walk' : 'stand'}
ref={bodyRef}
type={driven ? 'kinematicPosition' : 'fixed'}
userData={driven ? { excludeEcctrlRay: true } : undefined}
colliders={false}
position={at}
rotation={rotation}
>
{collider &&
(p.lie && !route ? (
<CapsuleCollider
args={[Math.max(H / 2 - radius, 0.01 * H), radius]}
rotation={[Math.PI / 2, 0, 0]}
position={[0, radius, 0]}
/>
) : (
<CapsuleCollider args={[half, radius]} position={[0, (top + bottom) / 2, 0]} />
))}
<group ref={facing}>{figure}</group>
</RigidBody>
</>
)
}
Person.groundSitting = true person/Bubble.tsx
import { Billboard } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useWorld, type Vec3 } from '@runek/core'
import { useMemo, useRef, useState } from 'react'
import { type Group, type Mesh, Shape, ShapeGeometry } from 'three'
import { Sign } from '../Sign'
import type { PersonEmote } from './types'
const PAPER = '#fbf8f1'
const INK = '#2b2a2e'
const MAX_CHARS = 90
const ALERT = '#e0573f'
const THINK = '#56607a'
/** A rounded box with a little tail at the bottom middle, its origin at the tail's tip. */
function bubbleShape(w: number, h: number, r: number, tail: number) {
const s = new Shape()
const x0 = -w / 2
const y0 = tail
s.moveTo(0, 0)
s.lineTo(-tail * 0.8, y0)
s.lineTo(x0 + r, y0)
s.quadraticCurveTo(x0, y0, x0, y0 + r)
s.lineTo(x0, y0 + h - r)
s.quadraticCurveTo(x0, y0 + h, x0 + r, y0 + h)
s.lineTo(-x0 - r, y0 + h)
s.quadraticCurveTo(-x0, y0 + h, -x0, y0 + h - r)
s.lineTo(-x0, y0 + r)
s.quadraticCurveTo(-x0, y0, -x0 - r, y0)
s.lineTo(tail * 0.8, y0)
s.closePath()
return new ShapeGeometry(s, 6)
}
export interface SpeechBubbleProps {
text: string
/** Where the tail points to, the bubble rising above it. */
position?: Vec3
/** Cap height of the text, in units. */
size?: number
/** Wrap width, in units. */
maxWidth?: number
/** Reports the bubble's height so things can stack above it. */
onHeight?: (height: number) => void
}
/** A short line of speech in a rounded bubble, always facing the camera. Long text wraps, and
* past a few lines it's cut with an ellipsis: a bubble is a glance, not a page. */
export function SpeechBubble({
text,
position,
size = 0.075,
maxWidth = 1.5,
onHeight,
}: SpeechBubbleProps) {
const { unit } = useWorld()
const [box, setBox] = useState<[number, number] | null>(null)
const line = text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS - 1)}…` : text
const pad = size * unit * 0.9
const tail = size * unit * 0.9
const w = (box?.[0] ?? 0) + pad * 2
const h = (box?.[1] ?? 0) + pad * 1.4
const geo = useMemo(
() => (box ? bubbleShape(w, h, Math.min(h / 2, pad * 1.4), tail) : null),
[box, w, h, pad, tail],
)
return (
<Billboard position={position}>
{geo && (
<mesh geometry={geo} renderOrder={1}>
<meshBasicMaterial color={PAPER} toneMapped={false} />
</mesh>
)}
<Sign
position={[0, tail + h / 2, 0.002 * unit]}
variant="body"
size={size}
color={INK}
maxWidth={maxWidth}
onBounds={(bw, bh) => {
setBox((prev) => (prev && prev[0] === bw && prev[1] === bh ? prev : [bw, bh]))
onHeight?.(bh + pad * 1.4 + tail)
}}
>
{line}
</Sign>
</Billboard>
)
}
function heartGeometry(s: number) {
const shape = new Shape()
shape.moveTo(0, -s * 0.9)
shape.bezierCurveTo(-s * 0.2, -s * 0.6, -s * 1.1, -s * 0.2, -s * 1.0, s * 0.35)
shape.bezierCurveTo(-s * 0.9, s * 0.95, -s * 0.15, s * 1.0, 0, s * 0.45)
shape.bezierCurveTo(s * 0.15, s * 1.0, s * 0.9, s * 0.95, s * 1.0, s * 0.35)
shape.bezierCurveTo(s * 1.1, -s * 0.2, s * 0.2, -s * 0.6, 0, -s * 0.9)
return new ShapeGeometry(shape, 8)
}
export interface EmoteProps {
kind: PersonEmote
position?: Vec3
/** Overall size, in units. */
size?: number
}
/**
* A small animated sign of a state, drawn from primitives and the world font (no icons):
* `sleep` rising z's, `alert` a bouncing !, `think` three pulsing dots, `happy` a beating heart,
* `coffee` a steaming cup.
*/
export function Emote({ kind, position, size = 0.17 }: EmoteProps) {
const { unit, palette } = useWorld()
const s = size * unit
const parts = useRef<(Group | Mesh | null)[]>([])
const heart = useMemo(() => (kind === 'happy' ? heartGeometry(s * 0.5) : null), [kind, s])
useFrame(({ clock }) => {
const t = clock.elapsedTime
const ps = parts.current
if (kind === 'sleep') {
ps.forEach((g, i) => {
if (!g) return
const k = (t * 0.45 + i / 3) % 1
g.position.set(Math.sin(k * 5 + i) * s * 0.1 + k * s * 1.3, k * s * 2.4, 0)
g.scale.setScalar(0.5 + k * 0.7)
g.visible = k < 0.92
})
} else if (kind === 'alert') {
const g = ps[0]
if (g) g.position.y = Math.abs(Math.sin(t * 5)) * s * 0.35
} else if (kind === 'think') {
ps.forEach((g, i) => {
g?.scale.setScalar(0.7 + 0.45 * Math.max(0, Math.sin(t * 4 - i * 0.9)))
})
} else if (kind === 'happy') {
const b = 1 + 0.12 * Math.max(0, Math.sin(t * 7)) * Math.max(0, Math.sin(t * 3.5))
ps[0]?.scale.setScalar(b)
} else if (kind === 'coffee') {
ps.forEach((g, i) => {
if (!g || i === 0) return
const k = (t * 0.5 + i / 3) % 1
g.position.set(Math.sin(k * 6 + i * 2) * s * 0.08, s * 0.35 + k * s * 0.9, 0)
g.scale.setScalar((1 - k) * 0.9 + 0.1)
})
}
})
const set = (i: number) => (o: Group | Mesh | null) => {
parts.current[i] = o
}
return (
<Billboard position={position}>
{kind === 'sleep' &&
[0, 1, 2].map((i) => (
<group key={i} ref={set(i)}>
<Sign variant="display" size={size} color="#9cc1f2" outline="#1d2a44">
z
</Sign>
</group>
))}
{kind === 'alert' && (
<group ref={set(0)}>
<Sign variant="display" size={size * 1.6} color={ALERT} outline="#3a1610">
!
</Sign>
</group>
)}
{kind === 'think' &&
[-1, 0, 1].map((x, i) => (
<mesh key={x} ref={set(i)} position={[x * s * 0.42, 0, 0]}>
<sphereGeometry args={[s * 0.13, 12, 8]} />
<meshBasicMaterial color={THINK} toneMapped={false} />
</mesh>
))}
{kind === 'happy' && heart && (
<mesh ref={set(0)} geometry={heart}>
<meshBasicMaterial color="#e2566f" toneMapped={false} />
</mesh>
)}
{kind === 'coffee' && (
<>
<group ref={set(0)}>
<mesh position={[0, s * 0.12, 0]}>
<cylinderGeometry args={[s * 0.24, s * 0.2, s * 0.32, 16]} />
<meshBasicMaterial color={PAPER} toneMapped={false} />
</mesh>
<mesh position={[s * 0.27, s * 0.13, 0]}>
<torusGeometry args={[s * 0.09, s * 0.03, 6, 12]} />
<meshBasicMaterial color={PAPER} toneMapped={false} />
</mesh>
<mesh position={[0, s * 0.27, 0.001 * unit]}>
<planeGeometry args={[s * 0.44, s * 0.05]} />
<meshBasicMaterial color={palette.woodDark} toneMapped={false} />
</mesh>
</group>
{[1, 2, 3].map((i) => (
<mesh key={i} ref={set(i)}>
<sphereGeometry args={[s * 0.07, 8, 6]} />
<meshBasicMaterial color="#e9eef2" transparent opacity={0.75} toneMapped={false} />
</mesh>
))}
</>
)}
</Billboard>
)
} person/RouteDriver.tsx
import { useFrame } from '@react-three/fiber'
import { type RapierRigidBody, useRapier } from '@react-three/rapier'
import { useGround, useWorld, type Walker } from '@runek/core'
import { type RefObject, useEffect, useMemo, useRef } from 'react'
import { type Object3D, Vector3 } from 'three'
import type { Route } from './route'
/** A route plus the seconds added to the wall clock to sample it: a seeded offset into a cycle,
* so figures sharing a route don't march in step, or minus the departure time for a trip. */
export interface WalkRoute extends Route {
phase: number
}
/** What the driver tells the body each frame: how fast it moves and how far it has walked. */
export interface GaitState {
speed: number
distance: number
}
/** True when something solid lies within `reach` of `from` along the horizontal (dx, dz). */
export type SideCheck = (from: Vector3, dx: number, dz: number, reach: number) => boolean
export interface RouteDriverProps {
route: WalkRoute
/** The node's own frame (spawn point + rotation); waypoints are local to it. */
frame: RefObject<Object3D | null>
/** Put the figure's origin at this world point. */
move: (world: Vector3) => void
/** Turned toward the heading, in the node frame. */
facing: RefObject<Object3D | null>
gait: { current: GaitState }
/** Body radius and the height side checks run at, in world units. */
radius: number
waist: number
unit: number
blocked?: SideCheck
/** Called once when a trip is over, on the first frame it is (even if that was long ago). */
onArrive?: () => void
}
// How far ahead a walker looks for someone in its way, and the gap it keeps (in meters).
const LOOK_AHEAD = 2.5
const MARGIN = 0.25
const MAX_DETOUR = 1.6
const AVATAR_RADIUS = 0.3
// Route seconds a held-up figure makes back per second, once the way clears.
const CATCH_UP = 0.25
// Seconds between side checks while a detour holds.
const RECHECK = 0.2
// Closest a walker lets itself come to the avatar beyond touching: a kinematic body never gives
// way, so walking into the player would shove it (into the ground, on a slope).
const PERSONAL_SPACE = 0.1
const wrap = (a: number) => Math.atan2(Math.sin(a), Math.cos(a))
/**
* Walk a figure along its route. The base position is the route sampled at wall-clock time, so
* every viewer agrees on it; height follows the ground under it (relative to the spawn point, so a
* figure placed on a deck the ground index doesn't know stays on it). Detours ride on top: the
* figure arcs around the avatar and other walkers ahead, passing on the right when head-on, and
* stops (falling behind its clock, then catching up) only when both sides are blocked.
*/
export function RouteDriver({
route,
frame,
move,
facing,
gait,
radius,
waist,
unit,
blocked,
onArrive,
}: RouteDriverProps) {
const { player, walkers } = useWorld()
const groundAt = useGround()
const self = useMemo<Walker>(() => ({ x: 0, z: 0, radius }), [radius])
const state = useRef({
lag: 0,
offset: 0,
target: 0,
checked: 0,
started: false,
arrived: null as Route | null,
})
const last = useMemo(() => new Vector3(), [])
const v = useMemo(
() => ({
spawn: new Vector3(),
at: new Vector3(),
dir: new Vector3(),
other: new Vector3(),
avatar: new Vector3(),
}),
[],
)
useEffect(() => {
if (!walkers) return
walkers.add(self)
return () => {
walkers.delete(self)
}
}, [walkers, self])
useFrame((_, delta) => {
const node = frame.current
if (!node) return
const dt = Math.min(delta, 0.1)
const s = state.current
const t = Date.now() / 1000 + route.phase - s.lag
const p = route.sample(t)
node.updateWorldMatrix(true, false)
v.spawn.setFromMatrixPosition(node.matrixWorld)
v.at.set(p.x, p.y, p.z)
node.localToWorld(v.at)
self.x = v.at.x
self.z = v.at.z
v.dir.set(Math.sin(p.heading), 0, Math.cos(p.heading)).transformDirection(node.matrixWorld)
v.dir.y = 0
v.dir.normalize()
const dx = v.dir.x
const dz = v.dir.z
// The obstacle that needs the widest berth: its lateral position (right is +), the gap to
// keep, and whether it's close enough that a blocked detour means stopping.
let want = 0
let side = 0
let clear = 0
let weight = 0
let close = false
const consider = (ox: number, oz: number, oradius: number) => {
const rx = ox - v.at.x
const rz = oz - v.at.z
const ahead = rx * dx + rz * dz
const lateral = -rx * dz + rz * dx
const gap = radius + oradius + MARGIN * unit
const reach = LOOK_AHEAD * unit
if (Math.abs(lateral) >= gap || ahead > reach || ahead < -gap) return
// Full berth by the time it draws level, released once it's a body length past.
const w = ahead > gap ? (reach - ahead) / (reach - gap) : ahead >= 0 ? 1 : 1 + ahead / gap
const ease = w * w * (3 - 2 * w)
const o = (lateral > 0.15 * gap ? lateral - gap : lateral + gap) * ease
if (Math.abs(o) > Math.abs(want)) {
want = o
side = lateral
clear = gap
weight = ease
close = ahead < gap + 0.3 * unit
}
}
const avatar = player?.current
if (avatar) avatar.getWorldPosition(v.avatar)
if (p.speed > 0) {
if (avatar) consider(v.avatar.x, v.avatar.z, AVATAR_RADIUS * unit)
if (walkers) for (const w of walkers) if (w !== self) consider(w.x, w.z, w.radius)
}
let target = Math.min(Math.max(want, -MAX_DETOUR * unit), MAX_DETOUR * unit)
let hold = false
if (blocked && Math.abs(target) > 0.05 * unit) {
s.checked -= dt
if (s.checked <= 0 || Math.sign(target) !== Math.sign(s.target)) {
s.checked = RECHECK
v.other.set(v.at.x, v.at.y + waist, v.at.z)
const sign = Math.sign(target)
if (blocked(v.other, -dz * sign, dx * sign, Math.abs(target) + radius)) {
const alt = (target > 0 ? side - clear : side + clear) * weight
const altSign = Math.sign(alt)
target = blocked(v.other, -dz * altSign, dx * altSign, Math.abs(alt) + radius) ? 0 : alt
hold = target === 0 && close
}
s.target = target
} else {
target = s.target
hold = target === 0 && close
}
} else {
s.target = target
}
s.offset += (target - s.offset) * (1 - Math.exp(-5 * dt))
v.at.x += -dz * s.offset
v.at.z += dx * s.offset
v.at.y += groundAt(v.at.x, v.at.z) - groundAt(v.spawn.x, v.spawn.z)
// Never step into the avatar: if this frame would close on it inside touching range, stay
// put and let the route clock fall behind instead.
if (avatar && s.started) {
const touch = radius + (AVATAR_RADIUS + PERSONAL_SPACE) * unit
const next = Math.hypot(v.at.x - v.avatar.x, v.at.z - v.avatar.z)
const now = Math.hypot(last.x - v.avatar.x, last.z - v.avatar.z)
if (next < touch && next < now) {
hold = true
v.at.copy(last)
}
}
// Held time is real frame time, not the clamped step, so the route can't creep on while held.
s.lag = hold ? s.lag + delta : Math.max(0, s.lag - CATCH_UP * dt)
last.copy(v.at)
move(v.at)
const f = facing.current
if (f) {
if (!s.started) f.rotation.y = p.heading
else f.rotation.y += wrap(p.heading - f.rotation.y) * (1 - Math.exp(-6 * dt))
}
s.started = true
gait.current.speed = hold ? 0 : p.speed
gait.current.distance = p.distance
if (route.once && t >= route.period && s.arrived !== route) {
s.arrived = route
onArrive?.()
}
})
return null
}
/** `RouteDriver` for a figure with its own kinematic body: it moves the body, and checks the
* side it steps to against the physics world. */
export function PhysicsRouteDriver({
body,
...props
}: Omit<RouteDriverProps, 'move' | 'blocked'> & { body: RefObject<RapierRigidBody | null> }) {
const { world, rapier } = useRapier()
const move = useMemo(() => (at: Vector3) => body.current?.setNextKinematicTranslation(at), [body])
const blocked = useMemo<SideCheck>(
() => (from, dx, dz, reach) => {
const ray = new rapier.Ray(from, { x: dx, y: 0, z: dz })
// Static geometry only: people in the way are the detour's business, and a ray starting
// inside the avatar would call both sides blocked.
const flags = rapier.QueryFilterFlags.ONLY_FIXED | rapier.QueryFilterFlags.EXCLUDE_SENSORS
return world.castRay(ray, reach, true, flags) !== null
},
[world, rapier],
)
return <RouteDriver {...props} move={move} blocked={blocked} />
} person/build.ts
// Turn a FigureSpec into mesh data: each part meshed, skin-weighted, and painted (base color,
// baked occlusion, skin tint). A generator, so the scheduler can spread it over frames. Output is
// plain typed arrays, shareable between every figure with the same spec.
import { clamp01, type Field, lattice, len, noise3, type Prim, surfaceNets, type V3 } from './sdf'
import { BONES, type PartDef, type Shape, shapeFigure } from './shape'
import type { FigureSpec, MaterialKind } from './spec'
export type Lod = 'near' | 'far'
export interface BuiltPart {
name: string
material: MaterialKind
positions: Float32Array
normals: Float32Array
indices: Uint32Array
/** Linear RGB, final color (base x occlusion x tint). */
colors: Float32Array
skinIndex: Uint16Array
skinWeight: Float32Array
}
export interface BuiltFigure {
lod: Lod
joints: V3[]
height: number
eyes: Shape['eyes']
lidRadius: number
marks: Shape['marks']
props: Shape['props']
parts: BuiltPart[]
triangles: number
}
/** Reference height the part cell sizes are tuned for. */
const REF_HEIGHT = 1.65
const VERTS_PER_SLICE = 512
export function* buildFigure(spec: FigureSpec, lod: Lod): Generator<void, BuiltFigure> {
const shape = shapeFigure(spec)
const scale = shape.height / REF_HEIGHT
const parts: BuiltPart[] = []
let triangles = 0
// Occlusion reads a coarse cached copy of the whole figure's field, shared by every part.
const occluder = lattice(shape.occluder, (lod === 'near' ? 0.012 : 0.03) * scale)
for (const def of shape.parts) {
if (lod === 'near' ? def.farOnly : def.nearOnly || def.far === null) continue
const h = (lod === 'near' ? def.near : (def.far ?? def.near)) * scale
const raw = yield* surfaceNets(def.field, def.min, def.max, h)
if (raw.indices.length === 0) continue
const { skinIndex, skinWeight } = yield* weigh(
raw.positions,
def.weightBy,
def.sigma,
def.below,
)
const colors = yield* paint(raw.positions, raw.normals, def, shape, spec, scale, occluder)
parts.push({ name: def.name, material: def.material, ...raw, colors, skinIndex, skinWeight })
triangles += raw.indices.length / 3
}
return {
lod,
joints: shape.joints,
height: shape.height,
eyes: shape.eyes,
lidRadius: shape.lidRadius,
marks: shape.marks,
props: shape.props,
parts,
triangles,
}
}
/** Skin weights from distance to each bone's primitives: soft-min falloff, best four kept. */
export function* weigh(
positions: Float32Array,
prims: readonly Prim[],
sigma: number,
below?: { y: number; maxX: number; prims: readonly Prim[]; sigma: number },
) {
const count = positions.length / 3
const skinIndex = new Uint16Array(count * 4)
const skinWeight = new Float32Array(count * 4)
const dist = new Float64Array(BONES.length)
const above = prims.filter((q) => !q.sub)
const under = below ? below.prims.filter((q) => !q.sub) : above
const top: number[] = []
for (let v = 0; v < count; v++) {
if (v % VERTS_PER_SLICE === 0) yield
const x = positions[v * 3]
const y = positions[v * 3 + 1]
const z = positions[v * 3 + 2]
dist.fill(1e9)
const low = below !== undefined && y < below.y && Math.abs(x) < below.maxX
const adds = low ? under : above
const fall = low && below ? below.sigma : sigma
for (const q of adds) {
if (len(x - q.c[0], y - q.c[1], z - q.c[2]) - q.R > dist[q.bone]) continue
const d = q.d(x, y, z)
if (d < dist[q.bone]) dist[q.bone] = d
}
let dmin = 1e9
for (let b = 0; b < dist.length; b++) dmin = Math.min(dmin, dist[b])
top.length = 0
for (let b = 0; b < dist.length; b++) {
if (dist[b] > 1e8) continue
const w = Math.exp(-(dist[b] - dmin) / fall)
if (w > 0.01) top.push(b, w)
}
// Keep the four heaviest (pairs of [bone, weight]); a vertex rarely has more than three.
const order = Array.from({ length: top.length / 2 }, (_, i) => i).sort(
(a, b) => top[b * 2 + 1] - top[a * 2 + 1],
)
let sum = 0
for (let i = 0; i < Math.min(4, order.length); i++) sum += top[order[i] * 2 + 1]
for (let i = 0; i < Math.min(4, order.length); i++) {
skinIndex[v * 4 + i] = top[order[i] * 2]
skinWeight[v * 4 + i] = top[order[i] * 2 + 1] / sum
}
if (order.length === 0) skinWeight[v * 4] = 1
}
return { skinIndex, skinWeight }
}
const linearCache = new Map<string, V3>()
/** sRGB hex to linear RGB (vertex colors are linear). */
export function linear(hex: string): V3 {
let c = linearCache.get(hex)
if (!c) {
const v = Number.parseInt(hex.replace('#', '').padEnd(6, '0').slice(0, 6), 16)
const ch = (s: number) => {
const x = ((v >> s) & 255) / 255
return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
}
c = [ch(16), ch(8), ch(0)]
linearCache.set(hex, c)
}
return c
}
/** iq-style field occlusion: how much closer the field gets than a ray along the normal. */
function occlusion(
f: Field,
x: number,
y: number,
z: number,
nx: number,
ny: number,
nz: number,
s: number,
) {
let occ = 0
let w = 1
for (let i = 1; i <= 3; i++) {
const h = 0.006 * s * i * i
occ += (h - Math.max(f(x + nx * h, y + ny * h, z + nz * h), 0)) * w
w *= 0.55
}
return Math.max(0.35, Math.min(1, 1 - (occ * 18) / s))
}
function* paint(
positions: Float32Array,
normals: Float32Array,
def: PartDef,
shape: Shape,
spec: FigureSpec,
s: number,
occluder: Field,
) {
const out = new Float32Array(positions.length)
const n = noise3(spec.seed + 7)
const isSkin = def.material === 'skin'
const lip = linear(spec.colors.lip)
const hair = linear(spec.colors.hair)
const flat = typeof def.paint === 'string' ? linear(def.paint) : null
for (let i = 0; i < positions.length; i += 3) {
if ((i / 3) % VERTS_PER_SLICE === 0) yield
const x = positions[i]
const y = positions[i + 1]
const z = positions[i + 2]
const ao = occlusion(occluder, x, y, z, normals[i], normals[i + 1], normals[i + 2], s)
let base = flat
if (!base) {
const hex = (def.paint as (x: number, y: number, z: number) => string | null)(x, y, z)
base = linear(hex ?? '#808080')
}
let r = base[0]
let g = base[1]
let b = base[2]
const grain =
1 +
(n((x * 40) / s, (y * 40) / s, (z * 40) / s) - 0.5) *
(isSkin ? 0.05 : def.material === 'hair' ? 0.22 : 0.08)
if (isSkin) {
const t = shape.skinPaint(x, y, z)
r = r * (1 + t.flush * 0.06)
g = g * (1 - t.flush * 0.14)
b = b * (1 - t.flush * 0.12)
const lw = clamp01(t.lip * 0.85)
r += (lip[0] - r) * lw
g += (lip[1] - g) * lw
b += (lip[2] - b) * lw
const sw = t.stubble * 0.35
r += (hair[0] - r) * sw
g += (hair[1] - g) * sw
b += (hair[2] - b) * sw
}
out[i] = r * ao * grain
out[i + 1] = g * ao * grain
out[i + 2] = b * ao * grain
}
return out
} person/materials.ts
// Materials shared by every figure. Colors are baked into vertex colors, so one material per
// surface kind serves the whole crowd (and one shader program each).
import {
BufferAttribute,
Color,
MeshPhysicalMaterial,
type MeshPhysicalMaterialParameters,
ShaderChunk,
SphereGeometry,
} from 'three'
import { linear } from './build'
import type { MaterialKind } from './spec'
const SKIN_DIFFUSE =
'reflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseContribution );'
/**
* Skin: sheen for the fine fuzz, and a red-shifted wrap on the direct diffuse term as a cheap
* stand-in for subsurface scattering (light bleeds past the terminator, warmer as it goes).
* If a three.js release renames the patched line, the skin falls back to plain physical shading.
*/
function skin() {
const m = new MeshPhysicalMaterial({
vertexColors: true,
roughness: 0.55,
specularIntensity: 0.4,
sheen: 0.35,
sheenColor: new Color('#ff9a7a'),
sheenRoughness: 0.55,
})
m.customProgramCacheKey = () => 'runek-person-skin'
m.onBeforeCompile = (shader) => {
const chunk = ShaderChunk.lights_physical_pars_fragment
if (!chunk.includes(SKIN_DIFFUSE)) return
shader.fragmentShader = shader.fragmentShader.replace(
'#include <lights_physical_pars_fragment>',
chunk.replace(
SKIN_DIFFUSE,
`vec3 wrapW = vec3( 0.45, 0.2, 0.12 );
vec3 wrapNL = clamp( ( vec3( dot( geometryNormal, directLight.direction ) ) + wrapW ) / ( 1.0 + wrapW ), 0.0, 1.0 );
reflectedLight.directDiffuse += wrapNL * directLight.color * BRDF_Lambert( material.diffuseContribution );`,
),
)
}
return m
}
const SPECS: Record<Exclude<MaterialKind, 'skin'>, MeshPhysicalMaterialParameters> = {
cloth: { roughness: 0.88, sheen: 0.6, sheenColor: new Color('#ffffff'), sheenRoughness: 0.8 },
knit: { roughness: 0.95, sheen: 0.9, sheenColor: new Color('#ffffff'), sheenRoughness: 0.6 },
denim: { roughness: 0.82, sheen: 0.45, sheenColor: new Color('#b8c6d6'), sheenRoughness: 0.7 },
leather: { roughness: 0.42, clearcoat: 0.35, clearcoatRoughness: 0.35 },
hair: { roughness: 0.5, sheen: 0.6, sheenColor: new Color('#ffffff'), sheenRoughness: 0.32 },
}
let shared: Record<MaterialKind | 'eye', MeshPhysicalMaterial> | null = null
export function personMaterials() {
if (!shared) {
shared = {
skin: skin(),
cloth: new MeshPhysicalMaterial({ vertexColors: true, ...SPECS.cloth }),
knit: new MeshPhysicalMaterial({ vertexColors: true, ...SPECS.knit }),
denim: new MeshPhysicalMaterial({ vertexColors: true, ...SPECS.denim }),
leather: new MeshPhysicalMaterial({ vertexColors: true, ...SPECS.leather }),
hair: new MeshPhysicalMaterial({ vertexColors: true, ...SPECS.hair }),
eye: new MeshPhysicalMaterial({
vertexColors: true,
roughness: 0.25,
clearcoat: 1,
clearcoatRoughness: 0.03,
}),
}
}
return shared
}
/** An eyeball looking down +z: pupil, a streaked iris, a dark limbal ring, warm sclera. */
export function eyeGeometry(radius: number, iris: string, detail: number) {
const g = new SphereGeometry(radius, detail, Math.round(detail * 0.66))
g.rotateX(Math.PI / 2)
const p = g.attributes.position
const col = new Float32Array(p.count * 3)
const ir = linear(iris)
const sclera = linear('#efe9e2')
const vein = linear('#e3c0b8')
for (let i = 0; i < p.count; i++) {
const ang = Math.acos(Math.max(-1, Math.min(1, p.getZ(i) / radius)))
const around = Math.atan2(p.getY(i), p.getX(i))
let c: [number, number, number]
if (ang < 0.2) c = [0.002, 0.002, 0.002]
else if (ang < 0.56) {
const k =
(0.75 + 0.25 * Math.sin(around * 37) * Math.sin(around * 11 + 1)) *
(0.65 + (ang - 0.2) * 1.1)
c = [ir[0] * k, ir[1] * k, ir[2] * k]
} else if (ang < 0.62) c = [0.01, 0.008, 0.007]
else {
const t = Math.min(1, (ang - 0.62) / 1.2) * 0.6
c = [
sclera[0] + (vein[0] - sclera[0]) * t,
sclera[1] + (vein[1] - sclera[1]) * t,
sclera[2] + (vein[2] - sclera[2]) * t,
]
}
col.set(c, i * 3)
}
g.setAttribute('color', new BufferAttribute(col, 3))
return g
}
/** The upper lid for blinking: a hemispherical cap just outside the eyeball, in skin. */
export function lidGeometry(radius: number, skinHex: string) {
const g = new SphereGeometry(radius, 28, 10, 0, Math.PI * 2, 0, Math.PI / 2)
const c = linear(skinHex)
const col = new Float32Array(g.attributes.position.count * 3)
for (let i = 0; i < col.length; i += 3) col.set([c[0] * 0.85, c[1] * 0.8, c[2] * 0.8], i)
g.setAttribute('color', new BufferAttribute(col, 3))
return g
} person/resolve.ts
// What a Person's props resolve to, trait by trait, and where each value came from. Built on the
// same `resolveFigure` the component renders with, so the answer always matches the figure.
import { DEFAULT_PALETTE, type WorldPalette } from '@runek/core'
import { KINDS, PERSON_SKINS, resolveFigure, type TraitInput } from './spec'
import type {
PersonAccessory,
PersonAge,
PersonBuild,
PersonFacialHair,
PersonGarment,
PersonGender,
PersonHair,
PersonHat,
PersonKind,
PersonNose,
PersonOutfit,
PersonSkin,
PersonStyle,
} from './types'
/** Where a resolved trait came from: a prop (flat or part object), the `skin` bundle, the `kind`
* preset, the seeded roll, or the component's fixed default. */
export type PersonTraitSource = 'prop' | 'skin' | 'kind' | 'seed' | 'default'
export interface PersonTrait<T> {
value: T
from: PersonTraitSource
}
/** A clothing layer with every option settled. `tucked` comes from the stack order. */
export interface ResolvedGarment extends Required<PersonGarment> {
tucked: boolean
}
export interface ResolvedPerson {
style: PersonTrait<PersonStyle>
kind: PersonTrait<PersonKind>
gender: PersonTrait<PersonGender>
age: PersonTrait<PersonAge>
build: PersonTrait<PersonBuild>
/** Standing height, in units. */
height: PersonTrait<number>
tone: PersonTrait<string>
hair: PersonTrait<PersonHair>
hairColor: PersonTrait<string>
facialHair: PersonTrait<PersonFacialHair>
eyeColor: PersonTrait<string>
eyeSize: PersonTrait<number>
browColor: PersonTrait<string>
browWeight: PersonTrait<number>
nose: PersonTrait<PersonNose>
lipColor: PersonTrait<string>
lipFullness: PersonTrait<number>
blush: PersonTrait<number>
/** The one-word outfit the layers expanded from; `null` when `clothes` were given outright. */
outfit: PersonTrait<PersonOutfit | null>
clothes: PersonTrait<ResolvedGarment[]>
topColor: PersonTrait<string>
bottomColor: PersonTrait<string>
shoeColor: PersonTrait<string>
hat: PersonTrait<PersonHat>
accessories: PersonTrait<PersonAccessory[]>
}
const trait = <T>(value: T, from: PersonTraitSource): PersonTrait<T> => ({ value, from })
/**
* Resolve a `Person`'s props the way the component does and report every trait with its source,
* so an editor (or an agent) can tell what the seed chose: `resolvePerson({ seed: 3 }).hair` is
* `{ value: 'braid', from: 'seed' }`. Pass the world's palette and unit to match a figure in a
* world that sets them. Pure and cheap: no geometry is built.
*/
export function resolvePerson(
props: Partial<TraitInput>,
palette: WorldPalette = DEFAULT_PALETTE,
unit = 1,
): ResolvedPerson {
const t: TraitInput = {
...props,
seed: props.seed ?? 1,
style: props.style ?? 'stylized',
kind: props.kind ?? 'villager',
age: props.age ?? 'adult',
}
const { spec, slots } = resolveFigure(t, palette, unit)
const sk: PersonSkin | undefined = typeof t.skin === 'string' ? PERSON_SKINS[t.skin] : t.skin
const preset = KINDS[t.kind]
const hairObj = typeof t.hair === 'object' ? t.hair : undefined
const hairName = typeof t.hair === 'string' ? t.hair : undefined
const f = t.face ?? {}
const given = (v: unknown) => v !== undefined
const chain = (prop: unknown, skin: unknown, fallback: PersonTraitSource): PersonTraitSource =>
given(prop) ? 'prop' : given(skin) ? 'skin' : fallback
const explicit = t.clothes !== undefined ? 'prop' : sk?.clothes !== undefined ? 'skin' : null
const outfitFrom = chain(t.outfit, sk?.outfit, 'kind')
return {
style: trait(t.style, given(props.style) ? 'prop' : 'default'),
kind: trait(t.kind, given(props.kind) ? 'prop' : 'default'),
gender: trait(spec.gender, given(t.gender) ? 'prop' : 'seed'),
age: trait(t.age, given(props.age) ? 'prop' : 'default'),
build: trait(spec.build, given(t.body?.build ?? t.build) ? 'prop' : 'seed'),
height: trait(spec.height / unit, given(t.body?.height ?? t.height) ? 'prop' : 'default'),
tone: trait(spec.colors.skin, given(t.body?.tone ?? t.skinTone) ? 'prop' : 'seed'),
hair: trait(spec.hair, chain(hairObj?.style ?? hairName, sk?.hair, 'seed')),
hairColor: trait(spec.colors.hair, chain(hairObj?.color ?? t.hairColor, sk?.hairColor, 'seed')),
facialHair: trait(spec.facialHair, given(t.facialHair) ? 'prop' : 'seed'),
eyeColor: trait(spec.colors.eye, given(f.eyes?.color) ? 'prop' : 'seed'),
eyeSize: trait(f.eyes?.size ?? 1, given(f.eyes?.size) ? 'prop' : 'default'),
browColor: trait(spec.colors.brow, given(f.brows?.color) ? 'prop' : 'default'),
browWeight: trait(spec.face.browWeight, given(f.brows?.weight) ? 'prop' : 'default'),
nose: trait(
spec.face.noseKind,
given(f.nose) ? 'prop' : t.style === 'realistic' ? 'seed' : 'default',
),
lipColor: trait(spec.colors.lip, given(f.lips?.color) ? 'prop' : 'default'),
lipFullness: trait(f.lips?.fullness ?? 1, given(f.lips?.fullness) ? 'prop' : 'default'),
blush: trait(spec.face.blush, given(f.blush) ? 'prop' : 'default'),
outfit: explicit
? trait(null, explicit)
: trait(t.outfit ?? sk?.outfit ?? preset.outfit, outfitFrom),
clothes: trait(
spec.layers.map((l) => ({
type: l.type,
color: l.color,
sleeves: l.sleeves,
neck: l.neck,
length: l.length,
tucked: l.tucked,
})),
explicit ?? outfitFrom,
),
topColor: trait(slots.top, chain(t.topColor, sk?.topColor, 'seed')),
bottomColor: trait(slots.bottom, chain(t.bottomColor, sk?.bottomColor, 'seed')),
shoeColor: trait(slots.shoe, chain(t.shoeColor, sk?.shoeColor, 'default')),
hat: trait(spec.hat, chain(t.hat, sk?.hat, 'kind')),
accessories: trait(
t.accessories ?? sk?.accessories ?? preset.accessories,
chain(t.accessories, sk?.accessories, 'kind'),
),
}
} person/rig.ts
// The three.js side of a figure: a skeleton from the spec's joints, skinned meshes bound to it, and
// the rigid pieces that ride a bone (eyeballs, blink lids, glasses, the staff).
import {
Bone,
BufferAttribute,
BufferGeometry,
CylinderGeometry,
Group,
Matrix4,
Mesh,
MeshPhysicalMaterial,
Object3D,
Skeleton,
SkinnedMesh,
Sphere,
TorusGeometry,
Vector3,
} from 'three'
import type { BuiltFigure } from './build'
import { eyeGeometry, lidGeometry, personMaterials } from './materials'
import type { V3 } from './sdf'
import { BONES, type BoneName, PARENT, type Shape } from './shape'
export interface Rig {
group: Group
bones: Record<BoneName, Bone>
skeleton: Skeleton
}
export function createRig(joints: V3[]): Rig {
const group = new Group()
const list = BONES.map((name) => {
const b = new Bone()
b.name = name
return b
})
list.forEach((b, i) => {
const j = joints[i]
const p = PARENT[i] >= 0 ? joints[PARENT[i]] : [0, 0, 0]
b.position.set(j[0] - p[0], j[1] - p[1], j[2] - p[2])
if (PARENT[i] >= 0) list[PARENT[i]].add(b)
})
group.add(list[0])
group.updateMatrixWorld(true)
const skeleton = new Skeleton(list)
const bones = Object.fromEntries(BONES.map((n, i) => [n, list[i]])) as Record<BoneName, Bone>
return { group, bones, skeleton }
}
/** Skinned meshes for one build, bound to the rig's skeleton (the caller adds them to `rig.group`). */
export function skinnedMeshes(built: BuiltFigure, rig: Rig) {
const materials = personMaterials()
const bounds = new Sphere(new Vector3(0, built.height * 0.5, 0), built.height * 0.85)
const meshes = built.parts.map((part) => {
const g = new BufferGeometry()
g.setAttribute('position', new BufferAttribute(part.positions, 3))
g.setAttribute('normal', new BufferAttribute(part.normals, 3))
g.setAttribute('color', new BufferAttribute(part.colors, 3))
g.setAttribute('skinIndex', new BufferAttribute(part.skinIndex, 4))
g.setAttribute('skinWeight', new BufferAttribute(part.skinWeight, 4))
g.setIndex(new BufferAttribute(part.indices, 1))
g.boundingSphere = bounds.clone()
const mesh = new SkinnedMesh(g, materials[part.material])
mesh.name = part.name
mesh.castShadow = true
mesh.receiveShadow = true
mesh.boundingSphere = bounds.clone()
mesh.bind(rig.skeleton, new Matrix4())
return mesh
})
return {
meshes,
dispose: () => {
for (const m of meshes) {
m.removeFromParent()
m.geometry.dispose()
}
},
}
}
export interface Attachments {
eyes: Object3D[]
lids: Object3D[]
/** Glasses and staff. */
props: Object3D[]
dispose: () => void
}
/** Eyeballs and blink lids on the head bone, glasses on the face, the staff in the right hand. */
export function attach(
rig: Rig,
shape: Shape,
joints: V3[],
eyeColor: string,
skinColor: string,
metal: string,
wood: string,
): Attachments {
const head = rig.bones.head
const hj = joints[BONES.indexOf('head')]
const local = (p: V3): V3 => [p[0] - hj[0], p[1] - hj[1], p[2] - hj[2]]
const owned: { obj: Object3D; geo: BufferGeometry[]; mat?: MeshPhysicalMaterial }[] = []
const props: Object3D[] = []
const materials = personMaterials()
const eyes: Object3D[] = []
const lids: Object3D[] = []
for (const e of shape.eyes) {
const pivot = new Object3D()
pivot.position.set(...local(e.center))
const eyeGeo = eyeGeometry(e.radius, eyeColor, 40)
pivot.add(new Mesh(eyeGeo, materials.eye))
head.add(pivot)
eyes.push(pivot)
const lidPivot = new Object3D()
lidPivot.position.set(...local(e.center))
const lidGeo = lidGeometry(shape.lidRadius, skinColor)
lidPivot.add(new Mesh(lidGeo, materials.skin))
head.add(lidPivot)
lids.push(lidPivot)
owned.push({ obj: pivot, geo: [eyeGeo] }, { obj: lidPivot, geo: [lidGeo] })
}
const g = shape.props.glasses
if (g) {
const frame = new Group()
frame.position.set(...local(g.center))
const mat = new MeshPhysicalMaterial({ color: metal, roughness: 0.4, metalness: 0.6 })
const ring = new TorusGeometry(g.ring, g.tube, 6, 24)
const bridge = new CylinderGeometry(g.tube, g.tube, g.spacing * 2 - g.ring * 2, 6)
for (const s of [-1, 1]) {
const m = new Mesh(ring, mat)
m.position.x = s * g.spacing
frame.add(m)
}
const b = new Mesh(bridge, mat)
b.rotation.z = Math.PI / 2
b.position.y = g.ring * 0.35
frame.add(b)
head.add(frame)
props.push(frame)
owned.push({ obj: frame, geo: [ring, bridge], mat })
}
const st = shape.props.staff
if (st) {
const hand = rig.bones['hand.R']
const wrist = joints[BONES.indexOf('hand.R')]
const geo = new CylinderGeometry(st.radius, st.radius, st.length, 7)
const mat = new MeshPhysicalMaterial({ color: wood, roughness: 0.85 })
const m = new Mesh(geo, mat)
// Held through the fist, its foot on the ground.
m.position.set(-st.radius * 0.5, st.length / 2 - wrist[1], st.radius * 2.6)
m.castShadow = true
hand.add(m)
props.push(m)
owned.push({ obj: m, geo: [geo], mat })
}
return {
eyes,
lids,
props,
dispose: () => {
for (const o of owned) {
o.obj.removeFromParent()
for (const geo of o.geo) geo.dispose()
o.mat?.dispose()
}
},
}
} person/route.ts
// A walking route as a pure function of time: where a figure is, which way it faces, and how
// fast it moves at any moment, with no integrated state. Two viewers sampling the same route at
// the same clock see the same thing. Plain TS (no three.js, no React) so it tests in isolation.
import { range, rng, type Vec3 } from '@runek/core'
/** `loop` closes the route (last point walks back to the first); `pingpong` retraces it. */
export type RouteLoop = 'loop' | 'pingpong'
export interface RouteOptions {
/** Walking speed, in units per second. */
speed: number
/** Seconds held at each waypoint before setting off again. */
pause: number
/** How the route repeats, or `once` for a single trip that sets off at `t = 0` and holds the
* last point from `t = period` on. */
loop: RouteLoop | 'once'
/** Seconds to reach full speed from a standstill (and to stop again). */
accel?: number
}
export interface RouteSample {
x: number
y: number
z: number
/** Yaw of travel (0 faces +z). While paused, the heading the figure arrived with. */
heading: number
/** Current speed along the route, in units per second. */
speed: number
/** Distance walked since the start of the cycle, for a walk cycle that doesn't slide. */
distance: number
}
export interface Route {
/** Seconds for one full cycle, pauses included; for a trip, the arrival time. Zero for a route
* that never moves. */
period: number
/** A single trip rather than a repeating cycle. */
once: boolean
/** Where the figure is at `t` seconds (any real number; a cycle repeats every `period`, a trip
* waits at its first point before 0 and holds its last after `period`). */
sample: (t: number) => RouteSample
}
interface Leg {
from: Vec3
to: Vec3
length: number
heading: number
/** Seconds held at the leg's start, then seconds spent moving. */
wait: number
move: number
/** Ramp time at each end: the full `accel`, or half the move for a leg too short to cruise. */
ramp: number
/** Cycle time the leg's pause starts, and distance walked before it. */
start: number
before: number
}
const DEFAULT_ACCEL = 0.4
/**
* Build a route through node-local waypoints. The legs join them in order (closing back to the
* first for `loop`, retracing for `pingpong`, stopping at the last for `once`); each leg pauses,
* then eases up to `speed`, cruises, and eases to a stop. A trip sets off without the first
* pause. Fewer than two distinct points stand still at the first.
*/
export function buildRoute(points: Vec3[], options: RouteOptions): Route {
const { speed, pause, loop, accel = DEFAULT_ACCEL } = options
const once = loop === 'once'
const order = loop === 'pingpong' ? [...points, ...points.slice(1, -1).reverse()] : points
const count = once ? order.length - 1 : order.length
const legs: Leg[] = []
let clock = 0
let walked = 0
for (let i = 0; i < count && order.length > 1; i++) {
const from = order[i]
const to = order[(i + 1) % order.length]
const wait = once && i === 0 ? 0 : pause
const length = Math.hypot(to[0] - from[0], to[1] - from[1], to[2] - from[2])
const ramp = speed > 0 ? Math.min(accel, length / speed) : 0
const move = speed > 0 && length > 0 ? length / speed + ramp : 0
legs.push({
from,
to,
length,
heading: Math.atan2(to[0] - from[0], to[2] - from[2]),
wait,
move,
ramp,
start: clock,
before: walked,
})
clock += wait + move
walked += length
}
const first = points[0] ?? [0, 0, 0]
if (!legs.some((leg) => leg.move > 0)) {
const still: RouteSample = {
x: first[0],
y: first[1],
z: first[2],
heading: 0,
speed: 0,
distance: 0,
}
return { period: 0, once, sample: () => still }
}
// The heading a figure arrives at each leg's start with: the last leg before it that moves. A
// trip hasn't arrived anywhere before it sets off, so it faces the node's own way.
const arrival = legs.map((_, i) => {
for (let k = 1; k <= (once ? i : legs.length); k++) {
const prev = legs[(i - k + legs.length) % legs.length]
if (prev.length > 0) return prev.heading
}
return 0
})
const period = clock
const last = order[order.length - 1]
const end: RouteSample = {
x: last[0],
y: last[1],
z: last[2],
heading: legs.findLast((leg) => leg.length > 0)?.heading ?? 0,
speed: 0,
distance: walked,
}
const sample = (t: number): RouteSample => {
if (once && t >= period) return end
const at = once ? t : ((t % period) + period) % period
let i = legs.length - 1
while (i > 0 && legs[i].start > at) i--
const leg = legs[i]
const tau = at - leg.start - leg.wait
if (tau <= 0 || leg.move === 0) {
return {
x: leg.from[0],
y: leg.from[1],
z: leg.from[2],
heading: arrival[i],
speed: 0,
distance: leg.before,
}
}
// Trapezoidal speed: ramp up, cruise, ramp down, so starts and stops never pop.
const { ramp, move, length } = leg
let s: number
let v: number
if (tau < ramp) {
s = (speed * tau * tau) / (2 * ramp)
v = (speed * tau) / ramp
} else if (tau < move - ramp) {
s = (speed * ramp) / 2 + speed * (tau - ramp)
v = speed
} else {
const left = Math.max(move - tau, 0)
s = length - (speed * left * left) / (2 * ramp)
v = (speed * left) / ramp
}
const u = Math.min(Math.max(s / length, 0), 1)
return {
x: leg.from[0] + (leg.to[0] - leg.from[0]) * u,
y: leg.from[1] + (leg.to[1] - leg.from[1]) * u,
z: leg.from[2] + (leg.to[2] - leg.from[2]) * u,
heading: leg.heading,
speed: v,
distance: leg.before + s,
}
}
return { period, once, sample }
}
/**
* Where a `route` trip has got to at `now` (epoch ms), in the node's local frame and ignoring
* detours: for an app starting a new trip from wherever the last one had reached. `speed` and
* `pause` are the figure's own (an adult walks 1.3 m/s; the default pause is 1.5 s).
*/
export function tripAt(
points: Vec3[],
departAt: number,
now: number,
{ speed = 1.3, pause = 1.5 }: { speed?: number; pause?: number } = {},
): Vec3 {
const p = buildRoute(points, { speed, pause, loop: 'once' }).sample((now - departAt) / 1000)
return [p.x, p.y, p.z]
}
/**
* A seeded wander loop inside `radius`: the origin, then `count - 1` points around it sorted by
* angle, so the loop never crosses itself. Same seed, same loop.
*/
export function wanderPoints(radius: number, seed: number, count = 5): Vec3[] {
const r = rng(seed)
const n = Math.max(count - 1, 2)
const offset = r() * Math.PI * 2
const points: Vec3[] = [[0, 0, 0]]
for (let i = 0; i < n; i++) {
const angle = offset + ((i + range(r, 0.15, 0.85)) / n) * Math.PI * 2
const reach = radius * range(r, 0.4, 1)
points.push([Math.sin(angle) * reach, 0, Math.cos(angle) * reach])
}
return points
} person/scheduler.ts
// One shared queue for figure builds: short tasks between frames (never more than a few
// milliseconds at once, so input and rendering stay smooth), coarse builds before detailed ones,
// nearest first. Finished builds are cached by spec, so identical figures
// (and a figure that walks out of detail range and back) never build twice.
import { type BuiltFigure, buildFigure, type Lod } from './build'
import type { FigureSpec } from './spec'
/** Longest stretch of build work before yielding back to the page, in milliseconds. */
const BUDGET = 6
/** Finished builds kept for reuse (a full-detail one is ~10 MB of arrays). */
const KEEP: Record<Lod, number> = { near: 4, far: 48 }
type Listener = (built: BuiltFigure) => void
interface Job {
key: string
lod: Lod
gen: Generator<void, BuiltFigure>
listeners: Map<Listener, () => number>
}
const cache: Record<Lod, Map<string, BuiltFigure>> = { near: new Map(), far: new Map() }
const jobs = new Map<string, Job>()
let ticking = false
export function figureKey(spec: FigureSpec) {
return JSON.stringify(spec)
}
/** A finished build, if there is one (refreshing its place in the cache). */
export function peekFigure(key: string, lod: Lod): BuiltFigure | null {
const hit = cache[lod].get(key)
if (!hit) return null
cache[lod].delete(key)
cache[lod].set(key, hit)
return hit
}
/**
* Ask for a build. `onReady` fires once it's done (synchronously when cached); `distance` is read
* whenever the queue picks its next job. Returns a cancel: a build nobody waits for is dropped.
*/
export function requestFigure(
spec: FigureSpec,
key: string,
lod: Lod,
distance: () => number,
onReady: Listener,
): () => void {
const hit = peekFigure(key, lod)
if (hit) {
onReady(hit)
return () => {}
}
const id = `${lod}:${key}`
let job = jobs.get(id)
if (!job) {
job = { key, lod, gen: buildFigure(spec, lod), listeners: new Map() }
jobs.set(id, job)
}
job.listeners.set(onReady, distance)
schedule()
const mine = job
return () => {
mine.listeners.delete(onReady)
if (mine.listeners.size === 0 && jobs.get(id) === mine) jobs.delete(id)
}
}
function priority(job: Job) {
let d = Number.POSITIVE_INFINITY
for (const read of job.listeners.values()) d = Math.min(d, read())
return (job.lod === 'far' ? 0 : 1e6) + d
}
// A message-channel task runs as soon as the page is idle, without setTimeout's clamping.
let channel: MessageChannel | null = null
function schedule() {
if (ticking || jobs.size === 0) return
ticking = true
if (typeof MessageChannel === 'function') {
if (!channel) {
channel = new MessageChannel()
channel.port1.onmessage = tick
}
channel.port2.postMessage(null)
} else setTimeout(tick, 0)
}
function tick() {
ticking = false
const deadline = performance.now() + BUDGET
while (jobs.size > 0 && performance.now() < deadline) {
let best: [string, Job] | null = null
let bestP = Number.POSITIVE_INFINITY
for (const entry of jobs) {
const p = priority(entry[1])
if (p < bestP) {
bestP = p
best = entry
}
}
if (!best) break
const [id, job] = best
let done: BuiltFigure | null = null
while (performance.now() < deadline) {
const r = job.gen.next()
if (r.done) {
done = r.value
break
}
}
if (!done) break
jobs.delete(id)
const store = cache[job.lod]
store.set(job.key, done)
while (store.size > KEEP[job.lod]) {
const oldest = store.keys().next().value
if (oldest === undefined) break
store.delete(oldest)
}
for (const listener of job.listeners.keys()) listener(done)
}
schedule()
} person/sdf.ts
// Signed-distance building blocks for the figure: primitives, an ordered smooth "program" over
// tagged primitives, and a narrow-band surface-nets mesher written as a generator so a build can
// be spread over many frames. Plain TS (no three.js, no React), so it tests in isolation.
export type V3 = [number, number, number]
export type Field = (x: number, y: number, z: number) => number
export const len = (a: number, b: number, c = 0) => Math.sqrt(a * a + b * b + c * c)
export const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v)
/** Polynomial smooth minimum: `k` is the blend radius (0 is a hard union). */
export function smin(a: number, b: number, k: number) {
if (k <= 0) return a < b ? a : b
const h = Math.max(k - Math.abs(a - b), 0) / k
return Math.min(a, b) - h * h * k * 0.25
}
export const smax = (a: number, b: number, k: number) => -smin(-a, -b, k)
/** One tagged piece of a figure: its field, a bounding sphere, and how it blends in. */
export interface Prim {
d: Field
c: V3
R: number
/** Blend radius into everything before it. */
k: number
/** Carves instead of adds. */
sub?: boolean
/** Index of the bone it rides, which is where skin weights come from. */
bone: number
tag?: string
}
/** Rotation as an inverse 3x3 (row-major), from XYZ Euler angles. */
function inverseRotation(rot?: V3) {
if (!rot) return null
const [ax, ay, az] = rot
const cx = Math.cos(ax)
const sx = Math.sin(ax)
const cy = Math.cos(ay)
const sy = Math.sin(ay)
const cz = Math.cos(az)
const sz = Math.sin(az)
// R = Rz Ry Rx; its inverse is the transpose.
return [
cz * cy,
sz * cy,
-sy,
cz * sy * sx - sz * cx,
sz * sy * sx + cz * cx,
cy * sx,
cz * sy * cx + sz * sx,
sz * sy * cx - cz * sx,
cy * cx,
]
}
/** iq's ellipsoid bound: exact on the surface, a good estimate near it. */
export function ellipsoid(c: V3, r: V3, rot?: V3): Field {
const m = inverseRotation(rot)
const [rx, ry, rz] = r
return (x, y, z) => {
let px = x - c[0]
let py = y - c[1]
let pz = z - c[2]
if (m) {
const qx = m[0] * px + m[1] * py + m[2] * pz
const qy = m[3] * px + m[4] * py + m[5] * pz
const qz = m[6] * px + m[7] * py + m[8] * pz
px = qx
py = qy
pz = qz
}
const k0 = len(px / rx, py / ry, pz / rz)
const k1 = len(px / (rx * rx), py / (ry * ry), pz / (rz * rz))
return k1 === 0 ? -Math.min(rx, ry, rz) : (k0 * (k0 - 1)) / k1
}
}
export function sphere(c: V3, r: number): Field {
return (x, y, z) => len(x - c[0], y - c[1], z - c[2]) - r
}
/** iq's exact round cone from `a` (radius `r1`) to `b` (radius `r2`). */
export function roundCone(a: V3, b: V3, r1: number, r2: number): Field {
const bx = b[0] - a[0]
const by = b[1] - a[1]
const bz = b[2] - a[2]
const l2 = bx * bx + by * by + bz * bz
const rr = r1 - r2
const a2 = l2 - rr * rr
const il2 = 1 / l2
return (x, y, z) => {
const px = x - a[0]
const py = y - a[1]
const pz = z - a[2]
const t = px * bx + py * by + pz * bz
const u = t - l2
const qx = px * l2 - bx * t
const qy = py * l2 - by * t
const qz = pz * l2 - bz * t
const x2 = qx * qx + qy * qy + qz * qz
const y2 = t * t * l2
const z2 = u * u * l2
const k = Math.sign(rr) * rr * rr * x2
if (Math.sign(u) * a2 * z2 > k) return Math.sqrt(x2 + z2) * il2 - r2
if (Math.sign(t) * a2 * y2 < k) return Math.sqrt(x2 + y2) * il2 - r1
return (Math.sqrt(x2 * a2 * il2) + t * rr) * il2 - r1
}
}
/** Rounded box centred at `c` with half-extents `h`. */
export function box(c: V3, h: V3, round = 0): Field {
return (x, y, z) => {
const qx = Math.abs(x - c[0]) - h[0] + round
const qy = Math.abs(y - c[1]) - h[1] + round
const qz = Math.abs(z - c[2]) - h[2] + round
return (
len(Math.max(qx, 0), Math.max(qy, 0), Math.max(qz, 0)) +
Math.min(Math.max(qx, qy, qz), 0) -
round
)
}
}
/** A primitive from a field and its bounding sphere. */
export function prim(d: Field, c: V3, R: number, bone: number, k = 0, extra?: Partial<Prim>): Prim {
return { d, c, R, k, bone, ...extra }
}
/**
* Fold primitives into one field, in order: each smooth-unions into (or carves from) everything
* before it. A primitive whose bounding sphere can't reach the running distance plus its blend is
* skipped, which is exact for the polynomial blend and makes far pieces free.
*/
export function program(prims: readonly Prim[], inflate = 0): Field {
const list = prims.slice()
return (x, y, z) => {
let d = 1e9
for (let i = 0; i < list.length; i++) {
const p = list[i]
const lb = len(x - p.c[0], y - p.c[1], z - p.c[2]) - p.R - inflate
if (p.sub) {
if (lb >= p.k - d) continue
d = smax(d, -(p.d(x, y, z) - inflate), p.k)
} else {
if (lb >= d + p.k) continue
d = smin(d, p.d(x, y, z) - inflate, p.k)
}
}
return d
}
}
/** Only the primitives that can affect points inside the box (plus a margin). */
export function within(prims: readonly Prim[], min: V3, max: V3, margin = 0): Prim[] {
return prims.filter((p) => {
const dx = Math.max(min[0] - p.c[0], 0, p.c[0] - max[0])
const dy = Math.max(min[1] - p.c[1], 0, p.c[1] - max[1])
const dz = Math.max(min[2] - p.c[2], 0, p.c[2] - max[2])
return len(dx, dy, dz) <= p.R + p.k + margin
})
}
export interface Raw {
positions: Float32Array
normals: Float32Array
indices: Uint32Array
}
/** Cells processed between yields, so a scheduler can stop a build at a frame boundary. */
const SLICE = 384
/**
* Narrow-band surface nets. A coarse pass keeps only blocks near the surface; their fine cells
* are sampled lazily. Each vertex starts at its cell centre and is projected onto the surface
* along the gradient, so the mesh follows the true isosurface instead of the voxel staircase.
* Yields every few hundred cells; the return value is the mesh.
*/
export function* surfaceNets(
f: Field,
min: V3,
max: V3,
h: number,
block = 6,
): Generator<void, Raw> {
const nx = Math.ceil((max[0] - min[0]) / h) + 1
const ny = Math.ceil((max[1] - min[1]) / h) + 1
const nz = Math.ceil((max[2] - min[2]) / h) + 1
const total = nx * ny * nz
const dense = total <= 4_000_000 ? new Float32Array(total).fill(Number.NaN) : null
const sparse = dense ? null : new Map<number, number>()
const val = (i: number, j: number, k: number) => {
const key = i + nx * (j + ny * k)
let v = dense ? dense[key] : sparse?.get(key)
if (v === undefined || Number.isNaN(v)) {
v = f(min[0] + i * h, min[1] + j * h, min[2] + k * h)
if (dense) dense[key] = v
else sparse?.set(key, v)
}
return v
}
const H = h * block
const reach = H * 0.9 + h * 2
const cells: number[] = []
let work = 0
for (let bk = 0; bk * block < nz - 1; bk++)
for (let bj = 0; bj * block < ny - 1; bj++)
for (let bi = 0; bi * block < nx - 1; bi++) {
if (++work % SLICE === 0) yield
const cx = min[0] + (bi + 0.5) * H
const cy = min[1] + (bj + 0.5) * H
const cz = min[2] + (bk + 0.5) * H
if (Math.abs(f(cx, cy, cz)) > reach) continue
for (let k = bk * block; k < Math.min((bk + 1) * block, nz - 1); k++)
for (let j = bj * block; j < Math.min((bj + 1) * block, ny - 1); j++)
for (let i = bi * block; i < Math.min((bi + 1) * block, nx - 1); i++)
cells.push(i, j, k)
}
// Vertex: the mean of the cell's edge crossings (from cached samples), then one step along
// the sampled gradient onto the true surface. Normals come from the faces afterwards, so
// placing a vertex costs one extra field evaluation.
const verts = new Map<number, number>()
const pos: number[] = []
const c = new Float64Array(8)
const EDGES = [0, 1, 2, 3, 4, 5, 6, 7, 0, 2, 1, 3, 4, 6, 5, 7, 0, 4, 1, 5, 2, 6, 3, 7]
const vertex = (i: number, j: number, k: number) => {
const key = i + nx * (j + ny * k)
const got = verts.get(key)
if (got !== undefined) return got
for (let q = 0; q < 8; q++) c[q] = val(i + (q & 1), j + ((q >> 1) & 1), k + ((q >> 2) & 1))
let sx = 0
let sy = 0
let sz = 0
let count = 0
for (let e = 0; e < 24; e += 2) {
const a = EDGES[e]
const b = EDGES[e + 1]
if (c[a] < 0 === c[b] < 0) continue
const t = c[a] / (c[a] - c[b])
sx += (a & 1) + ((b & 1) - (a & 1)) * t
sy += ((a >> 1) & 1) + (((b >> 1) & 1) - ((a >> 1) & 1)) * t
sz += ((a >> 2) & 1) + (((b >> 2) & 1) - ((a >> 2) & 1)) * t
count++
}
const fx = count ? sx / count : 0.5
const fy = count ? sy / count : 0.5
const fz = count ? sz / count : 0.5
// Trilinear gradient of the samples at that point.
const gx =
(c[1] - c[0]) * (1 - fy) * (1 - fz) +
(c[3] - c[2]) * fy * (1 - fz) +
(c[5] - c[4]) * (1 - fy) * fz +
(c[7] - c[6]) * fy * fz
const gy =
(c[2] - c[0]) * (1 - fx) * (1 - fz) +
(c[3] - c[1]) * fx * (1 - fz) +
(c[6] - c[4]) * (1 - fx) * fz +
(c[7] - c[5]) * fx * fz
const gz =
(c[4] - c[0]) * (1 - fx) * (1 - fy) +
(c[5] - c[1]) * fx * (1 - fy) +
(c[6] - c[2]) * (1 - fx) * fy +
(c[7] - c[3]) * fx * fy
const gl = len(gx, gy, gz) || 1
let x = min[0] + (i + fx) * h
let y = min[1] + (j + fy) * h
let z = min[2] + (k + fz) * h
const d = f(x, y, z)
const lx = min[0] + i * h
const ly = min[1] + j * h
const lz = min[2] + k * h
x = Math.min(Math.max(x - (d * gx) / gl, lx - h * 0.25), lx + h * 1.25)
y = Math.min(Math.max(y - (d * gy) / gl, ly - h * 0.25), ly + h * 1.25)
z = Math.min(Math.max(z - (d * gz) / gl, lz - h * 0.25), lz + h * 1.25)
const idx = pos.length / 3
pos.push(x, y, z)
verts.set(key, idx)
return idx
}
const tris: number[] = []
const quad = (a: number, b: number, c: number, d: number, flip: boolean) => {
if (flip) tris.push(a, c, b, a, d, c)
else tris.push(a, b, c, a, c, d)
}
for (let n = 0; n < cells.length; n += 3) {
if ((n / 3) % SLICE === 0) yield
const i = cells[n]
const j = cells[n + 1]
const k = cells[n + 2]
if (i < 1 || j < 1 || k < 1) continue
const inside = val(i, j, k) < 0
// Each sign change along a cell's lower edges joins the four cells sharing that edge.
if (inside !== val(i + 1, j, k) < 0)
quad(
vertex(i, j - 1, k - 1),
vertex(i, j, k - 1),
vertex(i, j, k),
vertex(i, j - 1, k),
!inside,
)
if (inside !== val(i, j + 1, k) < 0)
quad(
vertex(i - 1, j, k - 1),
vertex(i - 1, j, k),
vertex(i, j, k),
vertex(i, j, k - 1),
!inside,
)
if (inside !== val(i, j, k + 1) < 0)
quad(
vertex(i - 1, j - 1, k),
vertex(i, j - 1, k),
vertex(i, j, k),
vertex(i - 1, j, k),
!inside,
)
}
const positions = new Float32Array(pos)
const indices = new Uint32Array(tris)
return { positions, normals: faceNormals(positions, indices), indices }
}
/** Area-weighted vertex normals from the faces. */
export function faceNormals(p: Float32Array, idx: Uint32Array) {
const out = new Float32Array(p.length)
for (let t = 0; t < idx.length; t += 3) {
const a = idx[t] * 3
const b = idx[t + 1] * 3
const c = idx[t + 2] * 3
const ux = p[b] - p[a]
const uy = p[b + 1] - p[a + 1]
const uz = p[b + 2] - p[a + 2]
const vx = p[c] - p[a]
const vy = p[c + 1] - p[a + 1]
const vz = p[c + 2] - p[a + 2]
const nx = uy * vz - uz * vy
const ny = uz * vx - ux * vz
const nz = ux * vy - uy * vx
for (const v of [a, b, c]) {
out[v] += nx
out[v + 1] += ny
out[v + 2] += nz
}
}
for (let v = 0; v < out.length; v += 3) {
const l = len(out[v], out[v + 1], out[v + 2]) || 1
out[v] /= l
out[v + 1] /= l
out[v + 2] /= l
}
return out
}
/** A field sampled lazily on a lattice and trilinearly interpolated: cheap, smooth, approximate. */
export function lattice(f: Field, h: number): Field {
const cache = new Map<number, number>()
const at = (i: number, j: number, k: number) => {
const key = i + 4096 + 8192 * (j + 4096 + 8192 * (k + 4096))
let v = cache.get(key)
if (v === undefined) {
v = f(i * h, j * h, k * h)
cache.set(key, v)
}
return v
}
return (x, y, z) => {
const gx = x / h
const gy = y / h
const gz = z / h
const i = Math.floor(gx)
const j = Math.floor(gy)
const k = Math.floor(gz)
const tx = gx - i
const ty = gy - j
const tz = gz - k
const l = (a: number, b: number, t: number) => a + (b - a) * t
return l(
l(l(at(i, j, k), at(i + 1, j, k), tx), l(at(i, j + 1, k), at(i + 1, j + 1, k), tx), ty),
l(
l(at(i, j, k + 1), at(i + 1, j, k + 1), tx),
l(at(i, j + 1, k + 1), at(i + 1, j + 1, k + 1), tx),
ty,
),
tz,
)
}
}
/** Run a generator to completion (tests and synchronous callers). */
export function finish<T>(gen: Generator<void, T>): T {
for (;;) {
const r = gen.next()
if (r.done) return r.value
}
}
/** Seeded value noise in [0, 1], for cloth folds, hair clumps, and skin mottling. */
export function noise3(seed: number): Field {
const hash = (i: number, j: number, k: number) => {
let h = (i * 374761393 + j * 668265263 + k * 2147483647 + seed * 974634281) | 0
h = Math.imul(h ^ (h >>> 13), 1274126177)
return ((h ^ (h >>> 16)) >>> 0) / 4294967295
}
const s = (t: number) => t * t * (3 - 2 * t)
const l = (a: number, b: number, t: number) => a + (b - a) * t
return (x, y, z) => {
const i = Math.floor(x)
const j = Math.floor(y)
const k = Math.floor(z)
const fx = s(x - i)
const fy = s(y - j)
const fz = s(z - k)
return l(
l(
l(hash(i, j, k), hash(i + 1, j, k), fx),
l(hash(i, j + 1, k), hash(i + 1, j + 1, k), fx),
fy,
),
l(
l(hash(i, j, k + 1), hash(i + 1, j, k + 1), fx),
l(hash(i, j + 1, k + 1), hash(i + 1, j + 1, k + 1), fx),
fy,
),
fz,
)
}
} person/shape.ts
// A FigureSpec as signed-distance fields: the anatomy (tagged primitives on a skeleton), and every
// layer over it (clothes, hair, hats, accessories) as offsets of or shells around the body field.
import {
box,
clamp01,
ellipsoid,
type Field,
len,
noise3,
type Prim,
prim,
program,
roundCone,
smax,
smin,
sphere,
type V3,
} from './sdf'
import type { FigureSpec, Layer, MaterialKind } from './spec'
export const BONES = [
'root',
'pelvis',
'spine',
'chest',
'neck',
'head',
'upperArm.L',
'foreArm.L',
'hand.L',
'upperArm.R',
'foreArm.R',
'hand.R',
'thigh.L',
'shin.L',
'foot.L',
'thigh.R',
'shin.R',
'foot.R',
] as const
export type BoneName = (typeof BONES)[number]
export const PARENT = [-1, 0, 1, 2, 3, 4, 3, 6, 7, 3, 9, 10, 1, 12, 13, 1, 15, 16]
const B = (name: BoneName) => BONES.indexOf(name)
/** How far the arms stand out from the body in the bind pose, in radians. */
export const ARM_SPREAD = 0.26
/** `p` turned by `a` about the z axis through `pivot`. */
function turn(p: V3, pivot: V3, a: number): V3 {
const dx = p[0] - pivot[0]
const dy = p[1] - pivot[1]
return [
pivot[0] + dx * Math.cos(a) - dy * Math.sin(a),
pivot[1] + dx * Math.sin(a) + dy * Math.cos(a),
p[2],
]
}
/** A primitive turned about the z axis through `pivot` (distances are unchanged by rotation). */
function swing(q: Prim, pivot: V3, a: number): Prim {
const c = Math.cos(a)
const s = Math.sin(a)
const d = q.d
return {
...q,
c: turn(q.c, pivot, a),
d: (x, y, z) => {
const dx = x - pivot[0]
const dy = y - pivot[1]
return d(pivot[0] + dx * c + dy * s, pivot[1] - dx * s + dy * c, z)
},
}
}
/** How a part is colored: a flat color, or a function of the surface point. */
export type Paint = string | ((x: number, y: number, z: number) => string | null)
export interface PartDef {
name: string
material: MaterialKind
field: Field
min: V3
max: V3
/** Cell size at full detail, in world units. `far` is the coarse size, or `null` to skip. */
near: number
far: number | null
/** Primitives the skin weights are measured against, and the falloff. */
weightBy: Prim[]
sigma: number
/** Below `y`, weigh against `prims` with `sigma` instead (a skirt follows hips and thighs
* loosely while the bodice above follows the torso and arms tightly). */
below?: { y: number; maxX: number; prims: Prim[]; sigma: number }
paint: Paint
/** Only at full detail (the coarse build folds these into `skin`). */
nearOnly?: boolean
farOnly?: boolean
}
export interface Shape {
joints: V3[]
height: number
eyes: { center: V3; radius: number }[]
/** Radius of the blink lid shell (between the eyeball and the lid skin). */
lidRadius: number
parts: PartDef[]
/** Everything solid, for baked occlusion. */
occluder: Field
/** Skin shading inputs (lips, flush, stubble) for the skin parts. */
skinPaint: (x: number, y: number, z: number) => { lip: number; flush: number; stubble: number }
/** Landmarks the rig and the far-detail fallbacks need. */
marks: { hipY: number; thigh: number; shin: number; eyeY: number; headH: number; waistY: number }
/** Glasses and the staff are rigid three.js primitives, positioned here. */
props: {
glasses?: { center: V3; ring: number; spacing: number; tube: number }
staff?: { length: number; radius: number }
}
}
/** Reference figure the measurements below were sculpted at: height below the chin, head height. */
const BODY0 = 1.385
const HEAD0 = 0.265
export function shapeFigure(spec: FigureSpec): Shape {
const st = spec.st
const fem = spec.fem
const H = spec.height
const headH = H / spec.headsTall
const hu = headH / HEAD0
const eyeY = H - headH * 0.548
const chinY = eyeY - (0.116 + spec.face.chin) * hu
const Bh = chinY
const u = Bh / BODY0
const girth = spec.build === 'slim' ? 0.86 : spec.build === 'stocky' ? 1.2 : 1
const W = st.mass * girth * (spec.age === 'child' ? 0.92 : 1)
const wide = 1.06 - 0.13 * fem
const hipMul = 1 + 0.1 * fem
const legs = st.legs
const n = noise3(spec.seed)
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
// ---- skeleton --------------------------------------------------------------------------------
const shW = st.shoulder * Bh * wide * (0.94 + girth * 0.06)
const hipX = 0.0505 * Bh * hipMul * (0.9 + girth * 0.1)
const y = (f: number) => f * Bh
const J: Record<BoneName, V3> = {
root: [0, 0, 0],
pelvis: [0, y(0.65 + legs), 0],
spine: [0, y(0.736 + legs * 0.6), 0],
chest: [0, y(0.837 + legs * 0.3), 0],
neck: [0, y(0.96), -0.018 * u],
head: [0, chinY + 0.017 * hu, -0.012 * hu],
'upperArm.L': [shW, y(0.949), -0.012 * u],
'foreArm.L': [shW + 0.0217 * Bh, y(0.765), -0.03 * u],
'hand.L': [shW + 0.0318 * Bh, y(0.599), -0.008 * u],
'upperArm.R': [-shW, y(0.949), -0.012 * u],
'foreArm.R': [-(shW + 0.0217 * Bh), y(0.765), -0.03 * u],
'hand.R': [-(shW + 0.0318 * Bh), y(0.599), -0.008 * u],
'thigh.L': [hipX, y(0.617 + legs), 0],
'shin.L': [hipX + 0.003 * Bh, y(0.339 + legs * 0.55), 0.01 * u],
'foot.L': [hipX + 0.0043 * Bh, y(0.0505), -0.018 * u],
'thigh.R': [-hipX, y(0.617 + legs), 0],
'shin.R': [-(hipX + 0.003 * Bh), y(0.339 + legs * 0.55), 0.01 * u],
'foot.R': [-(hipX + 0.0043 * Bh), y(0.0505), -0.018 * u],
}
const shoulderY = J['upperArm.L'][1]
const hipY = J['thigh.L'][1]
const kneeY = J['shin.L'][1]
const ankleY = J['foot.L'][1]
const pelvisY = J.pelvis[1]
const waistY = pelvisY + 0.04 * u
const neckTop = shoulderY + 0.055 * u
// ---- primitive helpers -----------------------------------------------------------------------
const body: Prim[] = []
const head: Prim[] = []
const hands: Prim[] = []
/** Ellipsoid with radii scaled: x/z by girth, y by body scale. */
const ell = (
list: Prim[],
bone: BoneName,
c: V3,
r: V3,
k: number,
extra?: Partial<Prim> & { rot?: V3 },
) => {
list.push(prim(ellipsoid(c, r, extra?.rot), c, Math.max(...r), B(bone), k, extra))
}
const cone = (
list: Prim[],
bone: BoneName,
a: V3,
b: V3,
r1: number,
r2: number,
k: number,
extra?: Partial<Prim>,
) => {
const c: V3 = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2]
const R = len(b[0] - a[0], b[1] - a[1], b[2] - a[2]) / 2 + Math.max(r1, r2)
list.push(prim(roundCone(a, b, r1, r2), c, R, B(bone), k, extra))
}
const sph = (
list: Prim[],
bone: BoneName,
c: V3,
r: number,
k: number,
extra?: Partial<Prim>,
) => {
list.push(prim(sphere(c, r), c, r, B(bone), k, extra))
}
const bu = (x: number, yy: number, z: number): V3 => [x * u * W, yy, z * u * W]
// ---- torso -----------------------------------------------------------------------------------
ell(
body,
'pelvis',
[0, pelvisY + 0.005 * u, -0.005 * u],
[0.122 * u * W * hipMul, 0.08 * u, 0.088 * u * W],
0,
)
for (const x of [-1, 1])
ell(
body,
'pelvis',
[x * 0.05 * u * W * hipMul, pelvisY - 0.05 * u, -0.038 * u * W],
[0.064 * u * W * hipMul, 0.072 * u, 0.058 * u * W * hipMul],
0.04 * u,
)
ell(
body,
'spine',
[0, J.spine[1] + 0.01 * u, 0.004 * u],
[0.104 * u * W * (1 - 0.06 * fem), 0.13 * u, 0.078 * u * W],
0.07 * u,
)
ell(
body,
'chest',
[0, J.chest[1] + 0.03 * u, -0.006 * u],
[0.118 * u * W * wide, 0.145 * u, 0.082 * u * W],
0.06 * u,
)
if (spec.age !== 'child') {
for (const x of [-1, 1]) {
if (fem > 0.5)
ell(
body,
'chest',
bu(x * 0.05, J.chest[1] + 0.012 * u, 0.052),
[0.05 * u * W, 0.046 * u, 0.044 * u * W],
0.04 * u,
)
else if (fem < 0.5)
ell(
body,
'chest',
bu(x * 0.052, J.chest[1] + 0.045 * u, 0.04),
[0.06 * u * W, 0.045 * u, 0.04 * u * W],
0.035 * u,
)
}
}
for (const x of [-1, 1])
cone(
body,
'chest',
[0, shoulderY + 0.03 * u, -0.03 * u],
[x * (shW - 0.025 * u), shoulderY + 0.003 * u, -0.018 * u],
0.033 * u * W,
0.028 * u * W,
0.04 * u,
)
const neckR = 0.037 * u * (1 - 0.1 * fem) * Math.sqrt(W)
cone(
body,
'neck',
[0, J.neck[1] - 0.03 * u, -0.02 * u],
[0, J.head[1] + 0.025 * hu, -0.008 * hu],
neckR,
neckR * 0.92,
0.03 * u,
)
// ---- limbs -----------------------------------------------------------------------------------
for (const tag of ['L', 'R'] as const) {
const x = tag === 'L' ? 1 : -1
const sh = J[`upperArm.${tag}`]
const el = J[`foreArm.${tag}`]
const wr = J[`hand.${tag}`]
ell(
body,
`upperArm.${tag}`,
[sh[0] + x * 0.002 * u, sh[1] - 0.015 * u, sh[2]],
[0.038 * u * W, 0.048 * u, 0.04 * u * W],
0.03 * u,
)
cone(body, `upperArm.${tag}`, sh, el, 0.033 * u * W, 0.026 * u * W, 0.015 * u)
cone(body, `foreArm.${tag}`, el, wr, 0.027 * u * W, 0.019 * u * W, 0.01 * u)
const hp = J[`thigh.${tag}`]
const kn = J[`shin.${tag}`]
const an = J[`foot.${tag}`]
cone(body, `thigh.${tag}`, hp, kn, 0.066 * u * W * (1 + 0.05 * fem), 0.042 * u * W, 0.045 * u)
cone(body, `shin.${tag}`, kn, an, 0.041 * u * W, 0.024 * u * W, 0.015 * u)
ell(
body,
`shin.${tag}`,
[kn[0] + x * 0.002 * u, lerp(an[1], kn[1], 0.68), -0.018 * u * W],
[0.038 * u * W, 0.09 * u, 0.036 * u * W],
0.03 * u,
)
ell(
body,
`foot.${tag}`,
[an[0], 0.04 * u, an[2] - 0.015 * u],
[0.028 * u, 0.038 * u, 0.04 * u],
0.025 * u,
)
ell(
body,
`foot.${tag}`,
[an[0] + x * 0.003 * u, 0.028 * u, an[2] + 0.075 * u],
[0.036 * u, 0.024 * u, 0.068 * u],
0.035 * u,
)
// hand: palm toward the thigh, four slim fingers and a thumb
const hs = 0.82 * u * W ** 0.3
const [hx, hy, hz] = wr
cone(
hands,
`foreArm.${tag}`,
[hx - x * 0.002 * u, hy + 0.05 * u, hz],
wr,
0.021 * u * W,
0.019 * u * W,
0.008 * u,
)
ell(
hands,
`hand.${tag}`,
[hx, hy - 0.04 * hs, hz + 0.004 * hs],
[0.013 * hs, 0.04 * hs, 0.034 * hs],
0.01 * hs,
)
for (const [fz, fl] of [
[0.022, 0.07],
[0.0075, 0.078],
[-0.007, 0.074],
[-0.021, 0.06],
] as const) {
const a: V3 = [hx - x * 0.002 * hs, hy - 0.075 * hs, hz + fz * hs]
const m: V3 = [hx - x * 0.008 * hs, hy - (0.075 + fl * 0.5) * hs, hz + (fz + 0.005) * hs]
const b: V3 = [hx - x * 0.018 * hs, hy - (0.075 + fl) * hs, hz + (fz + 0.012) * hs]
cone(hands, `hand.${tag}`, a, m, 0.0072 * hs, 0.0066 * hs, 0.003 * hs)
cone(hands, `hand.${tag}`, m, b, 0.0066 * hs, 0.0057 * hs, 0.002 * hs)
}
const ta: V3 = [hx - x * 0.006 * hs, hy - 0.022 * hs, hz + 0.02 * hs]
const tb: V3 = [hx - x * 0.022 * hs, hy - 0.075 * hs, hz + 0.042 * hs]
cone(hands, `hand.${tag}`, ta, tb, 0.0105 * hs, 0.0068 * hs, 0.008 * hs)
}
// The arms are sculpted hanging, then swung out to the bind pose (a slight A), so cloth and
// skin never bridge the gap between arm and torso. The rig swings them back down.
for (const tag of ['L', 'R'] as const) {
const pivot = J[`upperArm.${tag}`]
const a = tag === 'L' ? ARM_SPREAD : -ARM_SPREAD
const bones = new Set([B(`upperArm.${tag}`), B(`foreArm.${tag}`), B(`hand.${tag}`)])
for (const list of [body, hands])
for (let i = 0; i < list.length; i++)
if (bones.has(list[i].bone)) list[i] = swing(list[i], pivot, a)
J[`foreArm.${tag}`] = turn(J[`foreArm.${tag}`], pivot, a)
J[`hand.${tag}`] = turn(J[`hand.${tag}`], pivot, a)
}
const joints = BONES.map((b) => J[b])
const elbowY = J['foreArm.L'][1]
const wristY = J['hand.L'][1]
// ---- head (in head units around the eye-line centre) --------------------------------------
const fc = spec.face
const E = (x: number, yy: number, z: number): V3 => [x * hu, eyeY + yy * hu, z * hu]
const hk = (k: number) => k * hu
ell(head, 'head', E(0, 0.035, -0.012), [0.083 * hu, 0.11 * hu, 0.105 * hu], 0)
ell(head, 'head', E(0, -0.03, 0.024), [0.073 * fc.cheek * hu, 0.066 * hu, 0.071 * hu], hk(0.04))
const chinDrop = fc.chin
for (const x of [-1, 1]) {
cone(
head,
'head',
E(x * 0.05 * fc.jaw, -0.045, -0.005),
E(x * 0.016 * fc.jaw, -0.094 - chinDrop, 0.052),
0.022 * fc.jaw * hu,
0.017 * hu,
hk(0.04),
)
cone(
head,
'head',
E(x * 0.012, 0.031, 0.086),
E(x * 0.058, 0.028, 0.066),
0.008 * fc.brow * hu,
0.007 * fc.brow * hu,
hk(0.02),
)
ell(
head,
'head',
E(x * 0.083, -0.012, -0.004),
[0.011 * hu, 0.026 * hu, 0.017 * hu],
hk(0.004),
{ rot: [0, 0, -x * 0.15], tag: 'ear' },
)
}
ell(
head,
'head',
E(0, -0.097 - chinDrop, 0.052),
[0.022 * fc.jaw * hu, 0.019 * hu, 0.02 * hu],
hk(0.03),
)
const eyeR = 0.0186 * fc.eye
const eyeX = (0.031 + 0.006 * fc.eye) * fc.eyeX
const eyeZ = 0.088 - eyeR
for (const x of [-1, 1]) {
ell(
head,
'head',
E(x * eyeX, 0.003, eyeZ + eyeR * 1.13),
[eyeR * 1.18 * hu, eyeR * 0.97 * hu, eyeR * 0.65 * hu],
hk(eyeR * 0.65),
{ sub: true },
)
sph(head, 'head', E(x * eyeX, 0, eyeZ), eyeR * 1.065 * hu, hk(0.004), { tag: 'lid' })
ell(
head,
'head',
E(x * eyeX, eyeR * 0.67, eyeZ + eyeR * 0.65),
[eyeR * 1.02 * hu, eyeR * 0.24 * hu, eyeR * 0.43 * hu],
hk(0.005),
{ tag: 'lid' },
)
ell(
head,
'head',
E(x * eyeX, -eyeR * 0.12, eyeZ + eyeR * 1.13),
[eyeR * 1.065 * hu, eyeR * fc.lid * hu, eyeR * 0.6 * hu],
hk(0.0022),
{ sub: true, rot: [0, 0, x * 0.05] },
)
}
const ns = fc.nose
const kind = fc.noseKind
const tipR = (kind === 'broad' ? 0.0112 : kind === 'straight' ? 0.0086 : 0.0092) * ns
const tipY = (kind === 'straight' ? -0.046 : -0.041) * ns
const tipZ = 0.088 + (kind === 'straight' ? 0.016 : 0.012) * ns
cone(
head,
'head',
E(0, kind === 'straight' ? 0.004 : -0.004, 0.087),
E(0, tipY + 0.003 * ns, tipZ + 0.003 * ns),
0.0055 * ns * hu,
(kind === 'straight' ? 0.0074 : 0.0082) * ns * hu,
hk(0.012),
{ tag: 'nose' },
)
sph(head, 'head', E(0, tipY, tipZ), tipR * hu, hk(0.006), { tag: 'nose' })
const alaX = (kind === 'broad' ? 0.0135 : 0.0105) * ns
for (const x of [-1, 1]) {
ell(
head,
'head',
E(x * alaX, tipY - 0.004 * ns, 0.088 + 0.004 * ns),
[0.0062 * ns * hu, 0.0058 * ns * hu, 0.0068 * ns * hu],
hk(0.005),
{ tag: 'nose' },
)
ell(
head,
'head',
E(x * alaX * 0.54, tipY - 0.0085 * ns, 0.088 + 0.0085 * ns),
[0.0026 * ns * hu, 0.0016 * ns * hu, 0.0034 * ns * hu],
hk(0.002),
{ sub: true },
)
}
const m = fc.mouth
const lips = fc.lips
const my = -0.0635 - chinDrop * 0.4
ell(
head,
'head',
E(0, my + 0.0035, 0.0875),
[0.015 * m * hu, 0.003 * lips * hu, 0.005 * hu],
hk(0.006),
{ tag: 'lip' },
)
ell(
head,
'head',
E(0, my - 0.004, 0.0855),
[0.012 * m * hu, 0.0038 * lips * hu, 0.0055 * hu],
hk(0.006),
{ tag: 'lip' },
)
ell(head, 'head', E(0, my, 0.091), [0.0155 * m * hu, 0.001 * hu, 0.009 * hu], hk(0.0025), {
sub: true,
})
for (const x of [-1, 1]) {
sph(head, 'head', E(x * 0.0148 * m, my + 0.0003, 0.0835), 0.0014 * hu, hk(0.003), { sub: true })
ell(head, 'head', E(x * 0.091, -0.015, 0), [0.006 * hu, 0.011 * hu, 0.008 * hu], hk(0.003), {
sub: true,
})
}
head.push(...body.filter((p) => p.bone === B('neck')))
// ---- skin fields -------------------------------------------------------------------------------
const neckCut = chinY - 0.016 * u
const wristCut = wristY + 0.008 * u
const bodyF = program(body)
const headF = program(head)
const handF = program(hands)
const armX = (shW + 0.118 * u * W * wide) / 2
// Skin under opaque clothes is never seen, so it isn't meshed (keeps a clothed figure cheap).
const tops = spec.layers.filter((l) => l.region === 'top' && l.type !== 'apron')
const bottoms = spec.layers.filter((l) => l.region === 'bottom' && l.type !== 'skirt')
const feet = spec.layers.filter((l) => l.region === 'feet')
const margin = 0.035 * u
const sleeveEnd = (l: Layer) =>
l.sleeves === 'long'
? wristY + 0.012 * u
: l.sleeves === 'short'
? shoulderY - 0.49 * (shoulderY - elbowY)
: shoulderY + 1
const hemOf = (l: Layer) =>
l.tucked
? waistY - 0.03 * u
: l.type === 'dress' || l.type === 'robe'
? waistY - l.length * (waistY - ankleY)
: waistY - l.length * (waistY - kneeY)
const legEnd = (l: Layer) => hipY - l.length * (hipY - ankleY - 0.025 * u)
const torsoFrom = tops.length ? Math.min(...tops.map(hemOf)) + margin : Number.POSITIVE_INFINITY
const torsoTo = tops.length
? Math.min(
...tops.map((l) =>
l.neck === 'v' && !tops.some((o) => o !== l && o.neck !== 'v')
? shoulderY - 0.13 * u
: shoulderY - 0.02 * u,
),
)
: Number.NEGATIVE_INFINITY
const armFrom = tops.length ? Math.min(...tops.map(sleeveEnd)) + margin : Number.POSITIVE_INFINITY
const legFrom = bottoms.length
? Math.min(...bottoms.map(legEnd)) + margin
: Number.POSITIVE_INFINITY
const legTo = bottoms.length ? waistY - margin : Number.NEGATIVE_INFINITY
const shoeTop = feet.length ? Math.min(...feet.map(() => ankleY + 0.04 * u)) - 0.015 * u : -1
const hidden = (x: number, yy: number) => {
const ax = Math.abs(x)
if (yy < shoeTop) return true
if (yy > legFrom && yy < legTo && ax < armX - 0.01 * u) return true
if (ax < armX - 0.005 * u) return yy > torsoFrom && yy < torsoTo
return yy > armFrom && yy < shoulderY - 0.03 * u
}
const skinBody: Field = (x, yy, z) => {
if (hidden(x, yy)) return Math.max(bodyF(x, yy, z), 0.004 * u)
let d = smax(bodyF(x, yy, z), yy - neckCut, 0.004 * u)
if (Math.abs(x) > armX) d = Math.max(d, wristCut - 0.012 * u - yy)
return d
}
const skinHead: Field = (x, yy, z) =>
smax(headF(x, yy, z) - 0.0004, neckCut - 0.018 * u - yy, 0.004 * u)
const skinHands: Field = (x, yy, z) => smax(handF(x, yy, z) - 0.0003, yy - wristCut, 0.004 * u)
const skinAll: Field = (x, yy, z) => {
const d = Math.min(bodyF(x, yy, z), headF(x, yy, z), handF(x, yy, z))
return hidden(x, yy) ? Math.max(d, 0.004 * u) : d
}
// ---- garments ----------------------------------------------------------------------------------
const isLeg = (p: Prim) => /thigh|shin|foot/.test(BONES[p.bone])
const torsoPrims = body.filter((p) => !isLeg(p) && !/Arm|hand|neck/.test(BONES[p.bone]))
const upperArmPrims = body.filter((p) => /upperArm/.test(BONES[p.bone]))
const foreArmPrims = body.filter((p) => /foreArm/.test(BONES[p.bone]))
const legPrims = body.filter((p) => /pelvis|thigh|shin/.test(BONES[p.bone]))
const footPrims = body.filter((p) => /foot|shin/.test(BONES[p.bone]))
const pelvisPrims = body.filter((p) => /pelvis|spine/.test(BONES[p.bone]))
const torsoF = program(torsoPrims)
const legsF = program(legPrims)
const feetF = program(footPrims)
const pelvisF = program(pelvisPrims)
const zc = -0.005 * u
const hipRx = Math.max(0.122 * u * W * hipMul, hipX + 0.066 * u * W) + 0.01 * u
const hipRz = 0.095 * u * W
/** A flared elliptical drop from `top` to `hem`, wide enough to clear the thighs. */
const skirt =
(top: number, hem: number, off: number, flare: number): Field =>
(x, yy, z) => {
const t = Math.max(0, top - yy)
const rx = hipRx + off + t * flare
const rz = hipRz + off + t * flare * 0.8
const d = (len(x / rx, (z - zc) / rz) - 1) * Math.min(rx, rz)
return Math.max(d, yy - top, hem - yy)
}
const folds = (x: number, yy: number, z: number, amp: number) =>
((n((x * 13) / u, (yy * 6) / u, (z * 13) / u) - 0.5) * amp +
(n((x * 31) / u + 9, (yy * 12) / u, (z * 31) / u) - 0.5) * amp * 0.3) *
u
const parts: PartDef[] = []
const anatomy = [...body, ...head.filter((p) => p.bone === B('head')), ...hands]
const pad = 0.05 * u
const span = (x0: number, x1: number, y0: number, y1: number, z0: number, z1: number) => ({
min: [x0, y0, z0] as V3,
max: [x1, y1, z1] as V3,
})
const wideX = Math.max(shW + 0.09 * u * W, J['hand.L'][0] + 0.07 * u)
const deepZ = 0.16 * u * W
const garmentField = (
l: Layer,
): {
f: Field
collar?: Field | null
y0: number
y1: number
weightBy: Prim[]
sigma: number
below?: PartDef['below']
flat: boolean
} => {
const off = l.offset * u
const knit = l.material === 'knit'
if (l.region === 'top') {
const arms =
l.sleeves === 'none'
? []
: l.sleeves === 'long'
? [...upperArmPrims, ...foreArmPrims]
: upperArmPrims
const F = program([...torsoPrims, ...arms])
const armHole = program(upperArmPrims, off + 0.016 * u)
const sleeve = sleeveEnd(l)
const hem = hemOf(l)
const long = hem < hipY - 0.02 * u
const flare =
l.type === 'dress'
? 0.34
: l.type === 'robe'
? 0.3
: l.type === 'coat'
? 0.16
: l.type === 'blazer'
? 0.1
: l.type === 'apron'
? 0.06
: 0.12
const drop = long ? skirt(hipY + 0.03 * u, hem, off, flare) : null
const loose = l.tucked ? 0 : 0.006
const vBottom = shoulderY - 0.095 * u
const f: Field = (x, yy, z) => {
const inArm = Math.abs(x) > armX
const ease =
off + (inArm ? 0.0015 * u : clamp01((waistY + 0.1 * u - yy) / (0.3 * u)) * loose * u)
let rib = 0
if (knit && (yy < hem + 0.035 * u || (inArm && yy < sleeve + 0.03 * u)))
rib = Math.sin(Math.atan2(x, z - zc) * 48) * 0.0012 * u
let d = F(x, yy, z) - ease - rib + folds(x, yy, z, knit ? 0.002 : 0.005)
if (l.sleeves === 'none') d = smax(d, -armHole(x, yy, z), 0.006 * u)
d = smax(d, (inArm ? sleeve : long ? hipY : hem) - yy, 0.003 * u)
if (drop) d = smin(d, drop(x, yy, z) + folds(x, yy, z, 0.006), 0.03 * u)
// neckline
d = smax(d, -(len(x, (z + 0.01 * u) * 1.15) - (neckR + 0.014 * u + off)), 0.008 * u)
// A blazer's collar sits over a V opening: the lapels.
if ((l.neck === 'v' || l.type === 'blazer') && z > 0)
d = smax(
d,
yy - (vBottom - (l.type === 'blazer' ? 0.05 * u : 0) + Math.abs(x) * 2.2),
0.004 * u,
)
d = smax(d, yy - neckTop, 0.006 * u)
if (l.type === 'apron')
d = Math.max(d, Math.abs(x) - 0.105 * u * W, 0.02 * u - z, yy - (shoulderY - 0.06 * u))
return d
}
const y0 = (long ? hem : Math.min(hem, inArmMin(l))) - 0.02 * u
// A fold-down collar: a band that flares as it drops, open at the front in a V so its two
// points lie on the chest. Thin, so it meshes as its own finer part.
// A hoodie's hood, down: a thick soft roll lying round the back of the neck, open at the
// front where the drawstrings would hang.
const hood: Field | null =
l.type === 'hoodie'
? (x, yy, z) => {
const r = neckR + 0.03 * u + off
const ring = len(len(x, (z + 0.02 * u) * 1.1) - r, (yy - (neckTop - 0.012 * u)) * 1.3)
return Math.max(ring - 0.024 * u, z - 0.03 * u + Math.abs(x) * 0.35)
}
: null
const collar: Field | null = hood
? hood
: l.neck === 'collar'
? (x, yy, z) => {
const drop = clamp01((neckTop + 0.006 * u - yy) / (0.03 * u))
const r = neckR + 0.016 * u + off + drop * 0.02 * u
let c = Math.max(
Math.abs(len(x, (z + 0.01 * u) * 1.12) - r) - 0.0035 * u,
yy - (neckTop + 0.006 * u),
neckTop - 0.026 * u - yy,
)
if (z > 0) c = Math.max(c, 0.006 * u + drop * 0.022 * u - Math.abs(x))
return c
}
: null
return {
f,
collar,
y0,
y1: neckTop + 0.03 * u,
weightBy: [...torsoPrims, ...arms],
sigma: 0.016 * u,
below: long
? {
y: hipY + 0.02 * u,
maxX: armX,
prims: [...pelvisPrims, ...legPrims.filter((p) => /thigh/.test(BONES[p.bone]))],
sigma: 0.12 * u,
}
: undefined,
flat: false,
}
}
if (l.region === 'bottom') {
if (l.type === 'skirt') {
const hem = waistY - l.length * (waistY - ankleY)
const drop = skirt(waistY, hem, off, 0.25)
const f: Field = (x, yy, z) =>
Math.min(
drop(x, yy, z) + folds(x, yy, z, 0.008),
Math.max(pelvisF(x, yy, z) - off, yy - waistY, hipY - yy),
)
return {
f,
y0: hem - 0.02 * u,
y1: waistY + 0.02 * u,
weightBy: [...pelvisPrims, ...legPrims.filter((p) => /thigh/.test(BONES[p.bone]))],
sigma: 0.12 * u,
flat: false,
}
}
const end = legEnd(l)
const cuff = l.type === 'jeans'
const f: Field = (x, yy, z) => {
const ease =
off +
clamp01((kneeY + 0.1 * u - yy) / (0.3 * u)) * 0.004 * u +
(cuff && yy < end + 0.03 * u ? 0.004 * u : 0)
let d = legsF(x, yy, z) - ease + folds(x, yy, z, 0.004)
d = smax(d, yy - waistY, 0.003 * u)
d = smax(d, end - yy, 0.003 * u)
return d
}
return {
f,
y0: end - 0.02 * u,
y1: waistY + 0.02 * u,
weightBy: legPrims,
sigma: 0.016 * u,
flat: false,
}
}
if (l.region === 'feet') {
const top =
l.type === 'boots'
? ankleY + 0.04 * u + l.length * (kneeY - ankleY - 0.08 * u)
: ankleY + 0.04 * u
const f: Field = (x, yy, z) => {
let d = feetF(x, yy, z) - off
d = Math.max(d, yy - top)
const sole = Math.max(feetF(x, yy, z) - off - 0.003 * u, yy - 0.012 * u)
return Math.max(Math.min(d, sole), -yy)
}
return {
f,
y0: -0.01,
y1: top + 0.02 * u,
weightBy: footPrims,
sigma: 0.016 * u,
flat: false,
}
}
// extras
if (l.type === 'belt') {
const by = waistY - 0.015 * u
const front = frontZ(pelvisF, by, off) + 0.002 * u
const buckle = box([0, by, front], [0.016 * u, 0.013 * u, 0.003 * u], 0.002 * u)
const f: Field = (x, yy, z) => {
// a thin band, not a solid disc through the waist
const d = pelvisF(x, yy, z) - off - 0.002 * u
return Math.min(
Math.max(d, -d - 0.005 * u, Math.abs(yy - by) - 0.011 * u),
buckle(x, yy, z),
)
}
return {
f,
y0: by - 0.03 * u,
y1: by + 0.03 * u,
weightBy: pelvisPrims,
sigma: 0.02 * u,
flat: true,
}
}
if (l.type === 'scarf') {
const sy = neckTop - 0.008 * u
const tailA: V3 = [0.03 * u, sy - 0.01 * u, 0.07 * u * W]
const tailB: V3 = [0.045 * u, sy - 0.05 * u - l.length * 0.2 * u, 0.1 * u * W]
const tail = roundCone(tailA, tailB, 0.02 * u, 0.017 * u)
const f: Field = (x, yy, z) => {
const ring = len(len(x, (z + 0.01 * u) * 1.05) - (neckR + 0.03 * u), yy - sy) - 0.022 * u
return (
smin(ring, Math.max(tail(x, yy, z), -0.004 * u - (z - 0.04 * u)), 0.01 * u) +
folds(x, yy, z, 0.006)
)
}
return {
f,
y0: tailB[1] - 0.04 * u,
y1: sy + 0.05 * u,
weightBy: [...torsoPrims, ...body.filter((p) => p.bone === B('neck'))],
sigma: 0.03 * u,
flat: false,
}
}
// cape: a thin shell hanging behind, from the shoulders toward the knees
const top = shoulderY + 0.02 * u
const hem = top - l.length * (top - kneeY)
const f: Field = (x, yy, z) => {
const t = clamp01((top - yy) / (top - hem))
const rx = shW + 0.05 * u + t * 0.12 * u
const rz = 0.11 * u * W + 0.02 * u + t * 0.08 * u
const d = Math.abs((len(x / rx, (z + 0.01 * u) / rz) - 1) * Math.min(rx, rz)) - 0.004 * u
return (
Math.max(d, yy - top, hem - yy, z + 0.035 * u - (top - yy) * 0.05) + folds(x, yy, z, 0.006)
)
}
return {
f,
y0: hem - 0.02 * u,
y1: top + 0.05 * u,
weightBy: [...torsoPrims, ...pelvisPrims],
sigma: 0.06 * u,
flat: false,
}
}
function inArmMin(l: Layer) {
return l.sleeves === 'none' ? shoulderY : sleeveEnd(l)
}
// Layers render from the stack; colors are flat apart from shoe soles and belt buckles.
const garmentFields: Field[] = []
spec.layers.forEach((l, i) => {
const g = garmentField(l)
garmentFields.push(g.f)
const sole = l.region === 'feet' ? darken(l.color) : null
const by = waistY - 0.015 * u
parts.push({
name: `${l.type}#${i}`,
material: l.material,
field: g.f,
...span(-wideX - pad, wideX + pad, Math.max(-0.01, g.y0), g.y1, -deepZ - pad, deepZ + pad),
near:
l.material === 'knit' || l.type === 'belt' ? 0.0048 : l.region === 'feet' ? 0.005 : 0.0068,
far: l.type === 'belt' ? 0.012 : 0.024,
weightBy: g.weightBy,
sigma: g.sigma,
below: g.below,
paint:
l.region === 'feet'
? (_x, yy) => (yy < 0.013 * u ? sole : null)
: l.type === 'belt'
? (x, yy) =>
Math.abs(x) < 0.017 * u && Math.abs(yy - by) < 0.014 * u ? spec.colors.metal : null
: l.color,
})
parts[parts.length - 1].paint = paintWith(l.color, parts[parts.length - 1].paint)
if (g.collar) {
parts.push({
name: `${l.type}#${i}.collar`,
material: l.material,
field: g.collar,
min: [-0.12 * u, neckTop - 0.04 * u, -0.12 * u],
max: [0.12 * u, neckTop + 0.02 * u, 0.12 * u],
near: 0.0022,
far: null,
nearOnly: true,
weightBy: [...torsoPrims, ...body.filter((p) => p.bone === B('neck'))],
sigma: 0.02 * u,
paint: l.color,
})
}
})
// ---- hair, brows, beard ----------------------------------------------------------------------
const headOnly = head.filter((p) => p.bone === B('head'))
const hairParts = hairFields(spec, E, hu, n, chinY)
const headBox = (below: number, h0: number) => ({
min: [-0.13 * hu, eyeY - below * hu, -0.16 * hu] as V3,
max: [0.13 * hu, eyeY + h0 * hu, 0.15 * hu] as V3,
})
if (hairParts.hair) {
const long = spec.hair === 'long' || spec.hair === 'braid' || spec.hair === 'ponytail'
parts.push({
name: 'hair',
material: 'hair',
field: hairParts.hair,
...headBox(long ? (spec.hair === 'braid' ? 0.75 : 0.5) : 0.12, 0.2),
near: 0.0034,
far: 0.012,
weightBy: long
? [...headOnly, ...body.filter((p) => /neck|chest/.test(BONES[p.bone]))]
: headOnly,
sigma: long ? 0.05 * u : 0.02 * u,
paint: spec.colors.hair,
})
}
parts.push({
name: 'brows',
material: 'hair',
field: hairParts.brows,
...headBox(-0.01, 0.07),
near: 0.0015,
far: null,
weightBy: headOnly,
sigma: 0.02 * u,
paint: spec.colors.brow,
nearOnly: true,
})
if (hairParts.beard) {
parts.push({
name: 'beard',
material: 'hair',
field: hairParts.beard,
...headBox(0.16, -0.02),
near: 0.0028,
far: 0.012,
weightBy: headOnly,
sigma: 0.02 * u,
paint: spec.colors.hair,
})
}
// ---- hats ------------------------------------------------------------------------------------
const hat = hatField(spec, E, hu)
if (hat) {
parts.push({
name: 'hat',
material: spec.hat === 'straw' ? 'knit' : 'cloth',
field: hat,
min: [-0.27 * hu, eyeY - 0.2 * hu, -0.27 * hu],
max: [0.27 * hu, eyeY + (spec.hat === 'chef' ? 0.32 : 0.26) * hu, 0.27 * hu],
near: 0.0045,
far: 0.014,
weightBy:
spec.hat === 'hood' ? [...headOnly, ...body.filter((p) => p.bone === B('neck'))] : headOnly,
sigma: 0.03 * u,
paint: spec.colors.hat,
})
}
// ---- bag ---------------------------------------------------------------------------------------
if (spec.accessories.includes('bag')) {
const sx = -(shW + 0.02 * u)
const sat = box(
[-(shW + 0.035 * u), hipY + 0.01 * u, 0.0],
[0.022 * u, 0.065 * u, 0.075 * u],
0.012 * u,
)
const strapPlane = (x: number, yy: number) => (x - sx) * 0.62 - (yy - shoulderY) * 0.78
const outer = spec.layers
.filter((l) => l.region === 'top')
.reduce((a, l) => Math.max(a, l.offset), 0)
const f: Field = (x, yy, z) => {
const strap = Math.max(
Math.abs(torsoF(x, yy, z) - (outer + 0.006) * u) - 0.003 * u,
Math.abs(strapPlane(x, yy)) - 0.018 * u,
)
return Math.min(strap, sat(x, yy, z))
}
parts.push({
name: 'bag',
material: 'leather',
field: f,
...span(-wideX - 0.06 * u, wideX, hipY - 0.1 * u, neckTop, -deepZ, deepZ),
near: 0.0055,
far: 0.016,
weightBy: [...torsoPrims, ...pelvisPrims],
sigma: 0.05 * u,
paint: spec.colors.bag,
})
}
// ---- skin parts (first, so they draw under everything) -----------------------------------------
const skin: PartDef[] = [
{
name: 'body',
material: 'skin',
field: skinBody,
...span(-wideX - pad, wideX + pad, -0.01, neckCut + 0.01 * u, -deepZ, deepZ),
near: 0.0085,
far: null,
weightBy: anatomy,
sigma: 0.016 * u,
paint: spec.colors.skin,
nearOnly: true,
},
{
name: 'head',
material: 'skin',
field: skinHead,
...headBox(0.116 + spec.face.chin + 0.07, 0.14),
near: 0.0024,
far: null,
weightBy: anatomy,
sigma: 0.016 * u,
paint: spec.colors.skin,
nearOnly: true,
},
{
name: 'hands',
material: 'skin',
field: skinHands,
...span(-wideX - pad, wideX + pad, wristY - 0.2 * u, wristCut + 0.01 * u, -0.1 * u, 0.1 * u),
near: 0.0022,
far: null,
weightBy: anatomy,
sigma: 0.016 * u,
paint: spec.colors.skin,
nearOnly: true,
},
{
name: 'skin',
material: 'skin',
field: skinAll,
...span(-wideX - pad, wideX + pad, -0.01, H + 0.02, -deepZ - 0.04 * u, deepZ + 0.04 * u),
near: 0.02,
far: 0.02,
weightBy: anatomy,
sigma: 0.02 * u,
paint: spec.colors.skin,
farOnly: true,
},
]
parts.unshift(...skin)
// ---- shading inputs ------------------------------------------------------------------------------
const tagged = (tag: string) => program(head.filter((p) => p.tag === tag))
const lipF = tagged('lip')
const noseF = tagged('nose')
const earF = tagged('ear')
const cheeks = program(
[-1, 1].map((x) =>
prim(
sphere(E(x * 0.048, -0.035, 0.075), 0.014 * hu),
E(x * 0.048, -0.035, 0.075),
0.014 * hu,
0,
),
),
)
const near = (f: Field, x: number, yy: number, z: number, w: number) =>
clamp01(1 - f(x, yy, z) / (w * hu))
const stubble = spec.facialHair === 'stubble'
const jawLine = chinY + 0.02 * hu
const skinPaint = (x: number, yy: number, z: number) => {
if (yy < neckCut - 0.03 * u) return { lip: 0, flush: 0, stubble: 0 }
return {
lip: near(lipF, x, yy, z, 0.002),
flush:
spec.face.blush *
Math.max(
near(cheeks, x, yy, z, 0.03) * 0.8,
near(noseF, x, yy, z, 0.005) * 0.5,
near(earF, x, yy, z, 0.004) * 0.6,
),
stubble: stubble
? clamp01((eyeY - 0.055 * hu - yy) / (0.02 * hu)) *
clamp01((yy - (jawLine - 0.05 * hu)) / (0.02 * hu)) *
clamp01((z + 0.02 * hu) / (0.04 * hu)) *
(1 - near(lipF, x, yy, z, 0.004))
: 0,
}
}
const occluder: Field = (x, yy, z) => {
let d = Math.min(bodyF(x, yy, z), headF(x, yy, z), handF(x, yy, z))
for (const l of spec.layers) {
if (l.region === 'top') d = Math.min(d, torsoF(x, yy, z) - l.offset * u)
else if (l.region === 'bottom') d = Math.min(d, legsF(x, yy, z) - l.offset * u)
}
return d
}
const glasses = spec.accessories.includes('glasses')
? {
center: E(0, 0.002, 0.088 + 0.012),
ring: eyeR * 1.45 * hu,
spacing: eyeX * hu,
tube: 0.0018 * hu,
}
: undefined
const staff = spec.accessories.includes('staff')
? { length: H * 1.05, radius: 0.011 * u }
: undefined
return {
joints,
height: H,
eyes: [-1, 1].map((x) => ({ center: E(x * eyeX, 0, eyeZ), radius: eyeR * 0.985 * hu })),
lidRadius: eyeR * 1.035 * hu,
parts,
occluder,
skinPaint,
marks: { hipY, thigh: hipY - kneeY, shin: kneeY - ankleY, eyeY, headH, waistY },
props: { glasses, staff },
}
}
/** z of the surface of `f` straight ahead of the midline at height `y`, plus `off`. */
function frontZ(f: Field, y: number, off: number) {
let lo = 0
let hi = 0.5
for (let i = 0; i < 24; i++) {
const mid = (lo + hi) / 2
if (f(0, y, mid) - off < 0) lo = mid
else hi = mid
}
return lo
}
function darken(hex: string) {
const v = Number.parseInt(hex.slice(1), 16)
const c = (s: number) => Math.round(((v >> s) & 255) * 0.45)
return `#${((c(16) << 16) | (c(8) << 8) | c(0)).toString(16).padStart(6, '0')}`
}
function paintWith(base: string, p: Paint): Paint {
if (typeof p === 'string') return p
return (x, y, z) => p(x, y, z) ?? base
}
type HeadPoint = (x: number, y: number, z: number) => V3
/** Hair, brows, and facial hair over the cranium. */
function hairFields(spec: FigureSpec, E: HeadPoint, hu: number, n: Field, chinY: number) {
const style = spec.hair
const eye = E(0, 0, 0)
const toLocal = (x: number, y: number, z: number) => [x / hu, (y - eye[1]) / hu, z / hu] as const
const fc = spec.face
const eyeX = (0.031 + 0.006 * fc.eye) * fc.eyeX
const bw = fc.browWeight
const brows = program(
[-1, 1].map((x) => {
const a = E(x * (eyeX - 0.022), 0.038, 0.095)
const b = E(x * (eyeX + 0.027), 0.042, 0.074)
return prim(
roundCone(a, b, 0.0046 * bw * hu, 0.0028 * bw * hu),
a,
len(b[0] - a[0], b[1] - a[1], b[2] - a[2]) + 0.006 * hu,
0,
)
}),
)
let hair: Field | null = null
if (style !== 'bald') {
const thick = style === 'cropped' ? 0.004 : 0.01
const cap = ellipsoid(E(0, 0.04, -0.014), [
(0.083 + thick) * hu,
(0.11 + thick) * hu,
(0.105 + thick) * hu,
])
const extras: Field[] = []
if (style === 'swept' || style === 'short') {
const lobes: [V3, V3, V3][] =
style === 'swept'
? [
[
[-0.05, 0.1, 0.07],
[0.05, 0.035, 0.035],
[0.3, 0, -0.5],
],
[
[0.0, 0.11, 0.075],
[0.06, 0.03, 0.035],
[0.35, 0, -0.35],
],
[
[0.05, 0.095, 0.065],
[0.04, 0.03, 0.03],
[0.3, 0, -0.2],
],
[
[0.07, 0.07, 0.03],
[0.03, 0.04, 0.04],
[0, 0, 0.3],
],
[
[-0.075, 0.07, 0.02],
[0.03, 0.045, 0.045],
[0, 0, -0.3],
],
]
: [
[
[-0.035, 0.085, 0.075],
[0.045, 0.022, 0.03],
[0.5, 0, -0.25],
],
[
[0.025, 0.088, 0.078],
[0.05, 0.022, 0.03],
[0.5, 0, -0.1],
],
]
extras.push(
program(
lobes.map(([c, r, rot]) =>
prim(
ellipsoid(E(...c), [r[0] * hu, r[1] * hu, r[2] * hu], rot),
E(...c),
Math.max(...r) * hu,
0,
0.02 * hu,
),
),
),
)
}
if (style === 'long') {
const back = ellipsoid(E(0, -0.07, -0.05), [0.098 * hu, 0.15 * hu, 0.075 * hu])
const sides = program(
[-1, 1].map((x) => {
const a = E(x * 0.075, 0.02, 0.02)
const b = E(x * 0.085, -0.15, -0.01)
return prim(
roundCone(a, b, 0.03 * hu, 0.026 * hu),
a,
len(0.01, 0.17, 0.03) * hu + 0.03 * hu,
0,
0.02 * hu,
)
}),
)
extras.push((x, y, z) => smin(back(x, y, z), sides(x, y, z), 0.03 * hu))
}
if (style === 'bun') extras.push(sphere(E(0, 0.1, -0.1), 0.045 * hu))
if (style === 'ponytail') {
const a = E(0, 0.04, -0.11)
const m = E(0, -0.04, -0.14)
const b = E(0, -0.2, -0.13)
const t1 = roundCone(a, m, 0.03 * hu, 0.026 * hu)
const t2 = roundCone(m, b, 0.026 * hu, 0.012 * hu)
extras.push((x, y, z) => smin(t1(x, y, z), t2(x, y, z), 0.01 * hu))
}
if (style === 'braid') {
const links = Array.from({ length: 9 }, (_, i) =>
sphere(E(0, 0.02 - i * 0.032, -0.115 - Math.min(i, 2) * 0.008), (0.026 - i * 0.0015) * hu),
)
extras.push((x, y, z) => {
let d = 1e9
for (const l of links) d = smin(d, l(x, y, z), 0.006 * hu)
return d
})
}
const longHair = style === 'long'
hair = (x, y, z) => {
const [lx, ly, lz] = toLocal(x, y, z)
const front = clamp01((lz + 0.02) / 0.09)
// Hairline: high over the forehead, down at the nape, lifted over the ears.
let line =
0.06 * front -
0.055 * (1 - front) +
Math.max(0, 0.03 - Math.abs(Math.abs(lx) - 0.082) * 1.5) * 0.9
if (longHair) line = Math.min(line, lz < 0.035 ? -0.3 : line)
const ang = Math.atan2(lx, lz)
const clumps =
Math.sin(ang * 18 + ly * 30 + n(lx * 8, ly * 8, lz * 8) * 4) *
(style === 'cropped' ? 0.0008 : 0.0026)
let d = cap(x, y, z)
for (const e of extras) d = smin(d, e(x, y, z), 0.015 * hu)
d += clumps * hu
d = smax(d, (line - ly) * hu, 0.01 * hu)
if (longHair) {
// keep the face clear and stop above the shoulders' front
d = smax(
d,
-(len(lx * 1.1, (ly + 0.03) * 0.85) - 0.072) * hu - (lz > 0.03 ? 0 : 1),
0.008 * hu,
)
d = smax(d, chinY - 0.09 * hu - y, 0.01 * hu)
}
return d
}
}
let beard: Field | null = null
if (spec.facialHair === 'beard' || spec.facialHair === 'moustache') {
const chin = spec.face.chin
const my = -0.0635 - chin * 0.4
const stache = program(
[-1, 1].map((x) => {
const a = E(x * 0.002, my + 0.009, 0.092)
const b = E(x * 0.022, my + 0.001, 0.084)
return prim(roundCone(a, b, 0.0055 * hu, 0.0035 * hu), a, 0.03 * hu, 0)
}),
)
if (spec.facialHair === 'moustache')
beard = (x, y, z) => stache(x, y, z) + (n(x * 300, y * 300, z * 300) - 0.5) * 0.0008
else {
const jaw = ellipsoid(E(0, -0.075 - chin, 0.035), [
0.07 * spec.face.jaw * hu,
0.06 * hu,
0.07 * hu,
])
beard = (x, y, z) => {
const [, ly, lz] = toLocal(x, y, z)
let d = jaw(x, y, z) - 0.004 * hu
d = smax(d, ly - (-0.03 - Math.max(0, -lz) * 0.4), 0.01 * hu)
d = smax(d, -(lz + 0.02), 0.01 * hu)
// mouth stays clear
d = smax(d, -(len((x / hu) * 0.8, ly - my, (lz - 0.09) * 0.6) - 0.016) * hu, 0.004 * hu)
d += (n(x * 200, y * 200, z * 200) - 0.5) * 0.0012 * hu
return Math.min(d, stache(x, y, z))
}
}
}
return { hair, brows, beard }
}
function hatField(spec: FigureSpec, E: HeadPoint, hu: number): Field | null {
const h = spec.hat
if (h === 'none') return null
const crown = ellipsoid(E(0, 0.045, -0.014), [0.097 * hu, 0.124 * hu, 0.12 * hu])
const eye = E(0, 0, 0)[1]
const ly = (y: number) => (y - eye) / hu
if (h === 'cap') {
const visor = ellipsoid(E(0, 0.072, 0.1), [0.075 * hu, 0.008 * hu, 0.07 * hu])
return (x, y, z) => {
const shell = Math.max(crown(x, y, z), (0.068 - ly(y)) * hu)
return Math.min(shell, Math.max(visor(x, y, z), -(z / hu - 0.06) * hu))
}
}
if (h === 'chef') {
// A band round the head, a straight tube above it, and a soft puff on top.
const puff = ellipsoid(E(0, 0.21, -0.02), [0.118 * hu, 0.072 * hu, 0.118 * hu])
const cz = E(0, 0, -0.016)[2]
return (x, y, z) => {
const band = Math.max(crown(x, y, z) - 0.004 * hu, (0.06 - ly(y)) * hu)
const tube = Math.max(len(x / hu, (z - cz) / hu) - 0.1, 0.06 - ly(y), ly(y) - 0.2) * hu
return smin(Math.min(band, tube), puff(x, y, z), 0.025 * hu)
}
}
if (h === 'bandana') {
return (x, y, z) => {
const tight = ellipsoid(E(0, 0.04, -0.014), [0.094 * hu, 0.12 * hu, 0.116 * hu])(x, y, z)
const knot = sphere(E(0, 0.02, -0.12), 0.018 * hu)(x, y, z)
return smin(
Math.max(tight, (0.05 - ly(y) - Math.max(0, -z / hu) * 0.35) * hu),
knot,
0.01 * hu,
)
}
}
if (h === 'straw' || h === 'brim') {
const wide = h === 'straw' ? 0.25 : 0.17
const brimY = h === 'straw' ? 0.065 : 0.075
const top = h === 'straw' ? 0.2 : 0.21
return (x, y, z) => {
const r = len(x / hu, (z - E(0, 0, -0.012)[2]) / hu)
const crownTop = Math.max(crown(x, y, z) - 0.006 * hu, ly(y) - top, (brimY - ly(y)) * 1)
const brim =
Math.max(
r - wide,
Math.abs(ly(y) - brimY - (r - 0.1) * (h === 'straw' ? -0.05 : 0.12) * (r > 0.1 ? 1 : 0)) -
0.004,
) * hu
return Math.min(crownTop, brim)
}
}
// hood: a shell over the head, open at the face, falling to the shoulders
return (x, y, z) => {
const outer = ellipsoid(E(0, 0.0, -0.03), [0.12 * hu, 0.175 * hu, 0.15 * hu])(x, y, z)
const shell = Math.abs(outer + 0.006 * hu) - 0.006 * hu
const [lx, lyy, lz] = [x / hu, ly(y), z / hu]
let d = smax(
shell,
-(len(lx * 1.05, (lyy + 0.01) * 0.82) - 0.085) * hu - (lz > 0.02 ? 0 : 1),
0.01 * hu,
)
d = smax(d, (-0.14 - lyy) * hu, 0.01 * hu)
return d
}
} person/spec.ts
// Resolve a Person's props into a plain `FigureSpec`: every trait settled, every measurement
// derived. Pure, so the same props always give the same spec (and the same mesh).
import { pick, type Rng, range, rng, type WorldPalette } from '@runek/core'
import { Color } from 'three'
import type {
PersonAccessory,
PersonAge,
PersonBodySpec,
PersonBuild,
PersonFaceSpec,
PersonFacialHair,
PersonGarment,
PersonGarmentType,
PersonGender,
PersonHair,
PersonHairSpec,
PersonHat,
PersonKind,
PersonNose,
PersonOutfit,
PersonSkin,
PersonStyle,
} from './types'
/**
* The named skins that ship with the component: wardrobe, not wigs. Each sets clothes, colors,
* headwear, and accessories but never hair, so a figure keeps its identity across skin swaps.
* Colors are fixed on purpose: a named skin looks the same in every world (an explicit color
* always beats the palette, CONTRACT §7). Pass the name to `skin`, or spread one into your own
* bundle to tweak it.
*/
export const PERSON_SKINS = {
/** Bright tunic and scarf for a market day or a fair. */
festival: {
outfit: 'tunic',
topColor: '#c8563b',
bottomColor: '#3f4a63',
shoeColor: '#5a4632',
hat: 'none',
accessories: ['scarf', 'belt'],
},
/** Field clothes under a straw hat. */
harvest: {
outfit: 'shirt',
topColor: '#c9a05a',
bottomColor: '#6b5138',
shoeColor: '#4a3a2a',
hat: 'straw',
accessories: ['belt'],
},
/** Hooded coat and scarf for the cold. */
winter: {
outfit: 'coat',
topColor: '#54626f',
bottomColor: '#38393f',
shoeColor: '#2e2a26',
hat: 'hood',
accessories: ['scarf', 'belt'],
},
/** Deck shirt, bandana, and a shoulder bag for the crossing. */
voyage: {
outfit: 'shirt',
topColor: '#e8e2d2',
bottomColor: '#31456b',
shoeColor: '#3a3128',
hat: 'bandana',
accessories: ['belt', 'bag'],
},
/** A deep-dyed robe and cape for rites and audiences. */
ceremony: {
outfit: 'robe',
topColor: '#5b3a5e',
bottomColor: '#2e2633',
shoeColor: '#241f29',
hat: 'none',
accessories: ['cape'],
},
/** Workshop apron and cap. */
atelier: {
outfit: 'apron',
topColor: '#7a6a55',
bottomColor: '#4c4238',
shoeColor: '#33302c',
hat: 'cap',
accessories: ['belt'],
},
/** Oxblood longcoat, dark bandana, and a plunder bag. */
pirate: {
outfit: 'coat',
topColor: '#5c2a2e',
bottomColor: '#2b2622',
shoeColor: '#1f1b18',
hat: 'bandana',
accessories: ['belt', 'bag'],
},
/** Rolled white sleeves under the bar apron, black trousers. */
bartender: {
outfit: 'apron',
topColor: '#e6e0d4',
bottomColor: '#26242a',
shoeColor: '#1d1b1f',
hat: 'none',
accessories: ['belt'],
},
/** Navy deck uniform with white trousers and a cap. */
sailor: {
outfit: 'uniform',
topColor: '#31456b',
bottomColor: '#e8e4d8',
shoeColor: '#26221e',
hat: 'cap',
accessories: ['belt'],
},
/** Pale shirt under a charcoal blazer, dark trousers, polished shoes: a desk job. */
office: {
clothes: [
{ type: 'shirt', sleeves: 'long', neck: 'collar', color: '#e9edf1' },
{ type: 'trousers', color: '#2f3238' },
{ type: 'blazer', color: '#3d4350' },
{ type: 'belt', color: '#2a2220' },
{ type: 'shoes', color: '#1f1b19' },
],
hat: 'none',
},
/** A plain tee, jeans, and sneakers. */
casual: {
clothes: [
{ type: 'tshirt', color: '#d9d4c8' },
{ type: 'jeans', color: '#4a5d78' },
{ type: 'shoes', color: '#e8e6e0' },
],
hat: 'none',
},
/** A soft hoodie over jeans and sneakers. */
hoodie: {
clothes: [
{ type: 'jeans', color: '#3c4658' },
{ type: 'hoodie', color: '#6c7a5c' },
{ type: 'shoes', color: '#e3e0d8' },
],
hat: 'none',
},
/** Kitchen whites: a chef's jacket, checked-dark trousers, an apron, and the tall hat. */
chef: {
clothes: [
{ type: 'trousers', color: '#34363b' },
{ type: 'shirt', sleeves: 'long', neck: 'crew', length: 0.4, color: '#f3f1ec' },
{ type: 'apron', color: '#efece4' },
{ type: 'shoes', color: '#26231f' },
],
hat: 'chef',
},
/** White short-sleeved shirt under a green sweater vest, slim jeans, brown shoes. */
preppy: {
clothes: [
{ type: 'shirt', sleeves: 'short', neck: 'collar', color: '#f1eee7' },
{ type: 'vest', color: '#7f9a3f' },
{ type: 'jeans', color: '#45576a' },
{ type: 'belt', color: '#5a3522' },
{ type: 'shoes', color: '#9a5236' },
],
hat: 'none',
},
} satisfies Record<string, PersonSkin>
export type PersonSkinName = keyof typeof PERSON_SKINS
/** Everything a style template controls. Face metrics are multipliers over the stylized face. */
export interface StyleSpec {
/** Heads per body height for an adult. */
heads: number
/** Shoulder joint half-width, as a fraction of the height below the chin. */
shoulder: number
/** Torso and limb girth. */
mass: number
/** Extra leg length, as a fraction of the height below the chin. */
legs: number
eye: number
/** Lid opening height over eye radius. */
lid: number
nose: number
mouth: number
jaw: number
/** Lower-face length added, in head units. */
chin: number
cheek: number
brow: number
nose0: PersonNose
}
export const STYLES: Record<PersonStyle, StyleSpec> = {
stylized: {
heads: 6.2,
shoulder: 0.11,
mass: 1,
legs: 0,
eye: 1,
lid: 0.44,
nose: 1,
mouth: 1,
jaw: 1,
chin: 0,
cheek: 1,
brow: 1,
nose0: 'button',
},
realistic: {
heads: 7.5,
shoulder: 0.122,
mass: 1.18,
legs: 0,
eye: 0.74,
lid: 0.36,
nose: 1.25,
mouth: 1.2,
jaw: 1.06,
chin: 0.012,
cheek: 0.8,
brow: 1.3,
nose0: 'straight',
},
anime: {
heads: 6.8,
shoulder: 0.106,
mass: 0.92,
legs: 0.03,
eye: 1.22,
lid: 0.52,
nose: 0.62,
mouth: 0.78,
jaw: 0.86,
chin: 0.008,
cheek: 0.9,
brow: 0.8,
nose0: 'button',
},
}
const GENDERS: readonly PersonGender[] = ['feminine', 'masculine', 'neutral']
const BUILDS: readonly PersonBuild[] = ['slim', 'average', 'stocky']
const NOSES: readonly PersonNose[] = ['button', 'straight', 'broad']
// Skin and hair have no palette slot and no honest mapping onto one, so they roll from curated
// sets instead (the seeded book-spine precedent, CONTRACT §7). Any explicit color wins.
/** The curated colors a seeded `Person` rolls from: skin tones, hair colors, and eye colors. Handy
* as swatches in an editor; any other color works too. */
export const PERSON_COLORS = {
skin: [
'#f6ddc3',
'#efc9a4',
'#e9b896',
'#e0aa7e',
'#c98d63',
'#a9714a',
'#8a5a3b',
'#65402a',
'#48301f',
],
hair: [
'#171310',
'#2b1d14',
'#4a3122',
'#6b4a2a',
'#6e3d24',
'#8c6b3f',
'#b08d57',
'#c9b18a',
'#9a9a9a',
'#7d3320',
],
eyes: ['#4a3424', '#2f2a26', '#3d7ab8', '#3f6a48', '#6b5a2e', '#6b7a85'],
} as const
const SKIN_TONES = PERSON_COLORS.skin
const HAIR_COLORS = PERSON_COLORS.hair
const EYE_COLORS = PERSON_COLORS.eyes
const HAIR_STYLES: Record<PersonGender, readonly PersonHair[]> = {
feminine: ['long', 'bun', 'ponytail', 'braid', 'swept', 'long', 'ponytail'],
masculine: ['short', 'swept', 'cropped', 'short', 'swept', 'bald'],
neutral: ['short', 'swept', 'cropped', 'ponytail', 'bun', 'long'],
}
export interface KindPreset {
outfit: PersonOutfit
hat: PersonHat
accessories: PersonAccessory[]
}
export const KINDS: Record<PersonKind, KindPreset> = {
villager: { outfit: 'tunic', hat: 'none', accessories: ['belt'] },
merchant: { outfit: 'apron', hat: 'cap', accessories: ['bag', 'belt'] },
guard: { outfit: 'uniform', hat: 'cap', accessories: ['belt'] },
sailor: { outfit: 'shirt', hat: 'bandana', accessories: ['belt'] },
farmer: { outfit: 'shirt', hat: 'straw', accessories: ['belt'] },
noble: { outfit: 'coat', hat: 'brim', accessories: ['cape', 'belt'] },
scholar: { outfit: 'robe', hat: 'hood', accessories: ['glasses', 'staff'] },
traveler: { outfit: 'coat', hat: 'brim', accessories: ['bag', 'scarf'] },
}
/** What a one-word outfit expands to. `top`/`bottom`/`shoe` mark the layers the flat color
* props recolor; `light` is an inner layer in the palette's light `wall` tone. */
type Slot = 'top' | 'bottom' | 'shoe' | 'light' | 'apron'
const OUTFITS: Record<PersonOutfit, Array<PersonGarment & { slot: Slot }>> = {
tunic: [
{ type: 'trousers', slot: 'bottom' },
{ type: 'tunic', slot: 'top' },
{ type: 'shoes', slot: 'shoe' },
],
shirt: [
{ type: 'shirt', sleeves: 'short', neck: 'collar', slot: 'top' },
{ type: 'trousers', slot: 'bottom' },
{ type: 'shoes', slot: 'shoe' },
],
dress: [
{ type: 'dress', slot: 'top' },
{ type: 'shoes', slot: 'shoe' },
],
robe: [
{ type: 'robe', slot: 'top' },
{ type: 'shoes', slot: 'shoe' },
],
coat: [
{ type: 'shirt', neck: 'collar', sleeves: 'long', slot: 'light' },
{ type: 'trousers', slot: 'bottom' },
{ type: 'coat', slot: 'top' },
{ type: 'boots', slot: 'shoe' },
],
apron: [
{ type: 'shirt', sleeves: 'short', neck: 'crew', slot: 'top' },
{ type: 'trousers', slot: 'bottom' },
{ type: 'apron', slot: 'apron' },
{ type: 'shoes', slot: 'shoe' },
],
uniform: [
{ type: 'trousers', slot: 'bottom' },
{ type: 'tunic', neck: 'collar', length: 0.45, slot: 'top' },
{ type: 'boots', slot: 'shoe' },
],
vest: [
{ type: 'shirt', sleeves: 'long', neck: 'collar', slot: 'light' },
{ type: 'trousers', slot: 'bottom' },
{ type: 'vest', slot: 'top' },
{ type: 'shoes', slot: 'shoe' },
],
tshirt: [
{ type: 'tshirt', slot: 'top' },
{ type: 'jeans', slot: 'bottom' },
{ type: 'shoes', slot: 'shoe' },
],
sweater: [
{ type: 'shirt', sleeves: 'long', neck: 'collar', slot: 'light' },
{ type: 'trousers', slot: 'bottom' },
{ type: 'sweater', slot: 'top' },
{ type: 'shoes', slot: 'shoe' },
],
hoodie: [
{ type: 'jeans', slot: 'bottom' },
{ type: 'hoodie', slot: 'top' },
{ type: 'shoes', slot: 'shoe' },
],
blazer: [
{ type: 'shirt', sleeves: 'long', neck: 'collar', slot: 'light' },
{ type: 'trousers', slot: 'bottom' },
{ type: 'blazer', slot: 'top' },
{ type: 'shoes', slot: 'shoe' },
],
}
export type GarmentRegion = 'top' | 'bottom' | 'feet' | 'extra'
export type MaterialKind = 'skin' | 'cloth' | 'knit' | 'denim' | 'leather' | 'hair'
export const GARMENT_REGION: Record<PersonGarmentType, GarmentRegion> = {
tshirt: 'top',
shirt: 'top',
sweater: 'top',
hoodie: 'top',
blazer: 'top',
vest: 'top',
tunic: 'top',
coat: 'top',
dress: 'top',
robe: 'top',
apron: 'top',
trousers: 'bottom',
jeans: 'bottom',
shorts: 'bottom',
skirt: 'bottom',
shoes: 'feet',
boots: 'feet',
belt: 'extra',
scarf: 'extra',
cape: 'extra',
}
export const GARMENT_MATERIAL: Record<PersonGarmentType, MaterialKind> = {
tshirt: 'cloth',
shirt: 'cloth',
sweater: 'knit',
hoodie: 'knit',
blazer: 'cloth',
vest: 'knit',
tunic: 'cloth',
coat: 'cloth',
dress: 'cloth',
robe: 'cloth',
apron: 'cloth',
trousers: 'cloth',
jeans: 'denim',
shorts: 'cloth',
skirt: 'cloth',
shoes: 'leather',
boots: 'leather',
belt: 'leather',
scarf: 'knit',
cape: 'cloth',
}
/** Per-type defaults: sleeves, neckline, and reach (0..1, meaning depends on the region). */
const GARMENT_DEFAULTS: Record<
PersonGarmentType,
{ sleeves: 'none' | 'short' | 'long'; neck: 'crew' | 'v' | 'collar'; length: number }
> = {
tshirt: { sleeves: 'short', neck: 'crew', length: 0.3 },
shirt: { sleeves: 'long', neck: 'collar', length: 0.3 },
sweater: { sleeves: 'long', neck: 'crew', length: 0.3 },
hoodie: { sleeves: 'long', neck: 'crew', length: 0.36 },
blazer: { sleeves: 'long', neck: 'collar', length: 0.42 },
vest: { sleeves: 'none', neck: 'v', length: 0.15 },
tunic: { sleeves: 'long', neck: 'crew', length: 0.6 },
coat: { sleeves: 'long', neck: 'collar', length: 0.85 },
dress: { sleeves: 'short', neck: 'crew', length: 0.6 },
robe: { sleeves: 'long', neck: 'v', length: 1 },
apron: { sleeves: 'none', neck: 'crew', length: 0.6 },
trousers: { sleeves: 'none', neck: 'crew', length: 1 },
jeans: { sleeves: 'none', neck: 'crew', length: 1 },
shorts: { sleeves: 'none', neck: 'crew', length: 0.4 },
skirt: { sleeves: 'none', neck: 'crew', length: 0.55 },
shoes: { sleeves: 'none', neck: 'crew', length: 0 },
boots: { sleeves: 'none', neck: 'crew', length: 0.45 },
belt: { sleeves: 'none', neck: 'crew', length: 0 },
scarf: { sleeves: 'none', neck: 'crew', length: 0.5 },
cape: { sleeves: 'none', neck: 'crew', length: 0.8 },
}
/** A garment with every option settled, its place in the stack, and how far it stands off. */
export interface Layer {
type: PersonGarmentType
region: GarmentRegion
material: MaterialKind
color: string
sleeves: 'none' | 'short' | 'long'
neck: 'crew' | 'v' | 'collar'
length: number
/** A top tucked into a bottom listed after it. */
tucked: boolean
/** Base offset off the skin, in meters (scaled with the figure). */
offset: number
}
export interface FaceMetrics {
eye: number
eyeX: number
lid: number
nose: number
noseKind: PersonNose
mouth: number
lips: number
jaw: number
chin: number
cheek: number
brow: number
browWeight: number
blush: number
}
/** Everything the shape builder needs. Plain data: it is also the build cache key. */
export interface FigureSpec {
seed: number
style: PersonStyle
gender: PersonGender
age: PersonAge
build: PersonBuild
/** Standing height in world units. */
height: number
/** Feminine (1) to masculine (0) silhouette blend. */
fem: number
st: StyleSpec
headsTall: number
face: FaceMetrics
hair: PersonHair
facialHair: PersonFacialHair
hat: PersonHat
accessories: PersonAccessory[]
layers: Layer[]
colors: {
skin: string
lip: string
eye: string
brow: string
hair: string
hat: string
bag: string
accent: string
metal: string
wood: string
}
}
/** The colors the flat `topColor` / `bottomColor` / `shoeColor` props stand for, settled. */
export interface SlotColors {
top: string
bottom: string
shoe: string
}
/** Per-figure animation phases; never part of the mesh. */
export interface MotionSpec {
phase: number
blinkPhase: number
blinkEvery: number
stoop: number
}
export interface TraitInput {
seed: number
style: PersonStyle
kind: PersonKind
gender?: PersonGender
age: PersonAge
height?: number
build?: PersonBuild
skinTone?: string
skin?: PersonSkinName | PersonSkin
body?: PersonBodySpec
face?: PersonFaceSpec
hair?: PersonHair | PersonHairSpec
hairColor?: string
facialHair?: PersonFacialHair
outfit?: PersonOutfit
clothes?: PersonGarment[]
topColor?: string
bottomColor?: string
shoeColor?: string
hat?: PersonHat
accessories?: PersonAccessory[]
}
const AGES: Record<PersonAge, { heads: number; stature: number; stoop: number; mass: number }> = {
child: { heads: 0.74, stature: 0.72, stoop: 0, mass: 0.92 },
adult: { heads: 1, stature: 1, stoop: 0, mass: 1 },
elder: { heads: 0.98, stature: 0.97, stoop: 0.16, mass: 1 },
}
const BASE_HEIGHT: Record<PersonGender, number> = { feminine: 1.64, masculine: 1.76, neutral: 1.7 }
/** Nudge a palette color per figure, so a crowd defaulting to one slot isn't a uniform. */
function tint(hex: string, r: Rng): string {
const c = new Color(hex)
c.offsetHSL((r() - 0.5) * 0.05, (r() - 0.5) * 0.14, (r() - 0.5) * 0.16)
return `#${c.getHexString()}`
}
function shade(hex: string, h: number, s: number, l: number) {
return `#${new Color(hex).offsetHSL(h, s, l).getHexString()}`
}
/**
* Every trait resolves the same way: the part object (`body`, `face`, `hair`, `clothes`) wins,
* then the flat shorthand prop, then the skin, then the `kind` preset, then a seeded roll.
*/
export function resolveFigure(
t: TraitInput,
palette: WorldPalette,
unit: number,
): { spec: FigureSpec; motion: MotionSpec; slots: SlotColors } {
const r = rng(t.seed)
const sk: PersonSkin | undefined = typeof t.skin === 'string' ? PERSON_SKINS[t.skin] : t.skin
const hairObj = typeof t.hair === 'object' ? t.hair : undefined
const hairName = typeof t.hair === 'string' ? t.hair : undefined
// Every roll is drawn before any override is applied, so setting one trait never shifts the
// rolls behind it.
const roll = {
gender: pick(r, GENDERS),
build: pick(r, BUILDS),
tone: pick(r, SKIN_TONES),
hair: r(),
hairColor: pick(r, HAIR_COLORS),
beard: r(),
}
const gender = t.gender ?? roll.gender
const build = t.body?.build ?? t.build ?? roll.build
const tone = t.body?.tone ?? t.skinTone ?? roll.tone
const styles = HAIR_STYLES[gender]
const hair =
hairObj?.style ?? hairName ?? sk?.hair ?? styles[Math.floor(roll.hair * styles.length)]
const hairColor = hairObj?.color ?? t.hairColor ?? sk?.hairColor ?? roll.hairColor
const beardRoll = roll.beard
const facialHair =
t.facialHair ??
(gender === 'masculine' && t.age !== 'child' && beardRoll > 0.55
? beardRoll > 0.85
? 'beard'
: beardRoll > 0.7
? 'moustache'
: 'stubble'
: 'none')
// Clothes stay palette-driven (one palette swap re-themes the crowd), but each figure rolls
// which slot it wears and a shade of it, or a village ends up in uniform.
const topRoll = tint(pick(r, [palette.fabric, palette.accent, palette.wall, palette.foliage]), r)
const bottomRoll = tint(pick(r, [palette.woodDark, palette.stone, palette.metal]), r)
const eyeRoll = pick(r, EYE_COLORS)
const noseRoll = pick(r, NOSES)
const jitter = Array.from({ length: 6 }, () => range(r, -1, 1))
const motion: MotionSpec = {
phase: r() * Math.PI * 2,
blinkPhase: r() * 6,
blinkEvery: range(r, 3.4, 6.5),
stoop: AGES[t.age].stoop,
}
const st = STYLES[t.style]
const a = AGES[t.age]
const preset = KINDS[t.kind]
const height = (t.body?.height ?? t.height ?? BASE_HEIGHT[gender] * a.stature) * unit
const child = t.age === 'child'
const f = t.face ?? {}
const face: FaceMetrics = {
eye: st.eye * (child ? 1.12 : 1) * (f.eyes?.size ?? 1) * (1 + jitter[0] * 0.05),
eyeX: 1 + jitter[1] * 0.035,
lid: st.lid,
nose: st.nose * (child ? 0.8 : 1) * (1 + jitter[2] * 0.08),
noseKind: f.nose ?? (t.style === 'realistic' ? noseRoll : st.nose0),
mouth: st.mouth * (1 + jitter[3] * 0.06),
lips: (f.lips?.fullness ?? 1) * (gender === 'feminine' ? 1.2 : 1),
jaw:
st.jaw *
(gender === 'masculine' ? 1.06 : gender === 'feminine' ? 0.93 : 1) *
(1 + jitter[4] * 0.04),
chin: st.chin + (child ? -0.008 : 0) + jitter[5] * 0.003,
cheek: st.cheek * (child ? 1.1 : 1),
brow: st.brow * (gender === 'masculine' ? 1.2 : 0.85),
browWeight: f.brows?.weight ?? (gender === 'feminine' ? 0.7 : 0.85),
blush: f.blush ?? (gender === 'feminine' || child ? 0.6 : 0.35),
}
const colors = {
skin: tone,
lip: f.lips?.color ?? shade(tone, -0.02, 0.1, -0.1),
eye: f.eyes?.color ?? eyeRoll,
brow: f.brows?.color ?? hairColor,
hair: hairColor,
hat: palette.accent,
bag: palette.wood,
accent: palette.accent,
metal: palette.metal,
wood: palette.wood,
}
const hat = t.hat ?? sk?.hat ?? preset.hat
const slots: SlotColors = {
top: t.topColor ?? sk?.topColor ?? topRoll,
bottom: t.bottomColor ?? sk?.bottomColor ?? bottomRoll,
shoe: t.shoeColor ?? sk?.shoeColor ?? palette.metal,
}
const layers = resolveLayers(t, sk, preset, palette, slots)
const outerTop = [...layers].reverse().find((l) => l.region === 'top' && l.type !== 'apron')
colors.hat =
hat === 'straw'
? palette.sand
: hat === 'brim'
? (layers.find((l) => l.region === 'bottom')?.color ?? palette.woodDark)
: hat === 'bandana'
? palette.fabric
: hat === 'hood'
? (outerTop?.color ?? palette.fabric)
: hat === 'chef'
? '#f4f2ec'
: palette.accent
return {
spec: {
seed: t.seed,
style: t.style,
gender,
age: t.age,
build,
height,
fem: gender === 'feminine' ? 1 : gender === 'masculine' ? 0 : 0.5,
st,
headsTall: st.heads * a.heads,
face,
hair,
facialHair,
hat,
accessories: (t.accessories ?? sk?.accessories ?? preset.accessories).filter(
(x) => x === 'bag' || x === 'glasses' || x === 'staff',
),
layers,
colors,
},
motion,
slots,
}
}
function resolveLayers(
t: TraitInput,
sk: PersonSkin | undefined,
preset: KindPreset,
palette: WorldPalette,
slot: SlotColors,
): Layer[] {
const accessories = t.accessories ?? sk?.accessories ?? preset.accessories
let stack: Array<PersonGarment & { slot?: Slot }>
const explicit = t.clothes ?? sk?.clothes
if (explicit) stack = explicit.map((g) => ({ ...g }))
else {
stack = OUTFITS[t.outfit ?? sk?.outfit ?? preset.outfit].map((g) => ({ ...g }))
// Worn accessories that are really clothing become layers on the outside.
for (const extra of ['belt', 'scarf', 'cape'] as const)
if (accessories.includes(extra)) stack.push({ type: extra })
}
// The flat color props (already folded into `slot`) recolor the outermost top, every
// bottom, and the footwear.
let outerTop = -1
stack.forEach((g, i) => {
if (GARMENT_REGION[g.type] === 'top' && g.slot !== 'light' && g.type !== 'apron') outerTop = i
})
const light = shade(palette.wall, 0, -0.05, 0.12)
const defaultColor = (g: PersonGarment & { slot?: Slot }, i: number, region: GarmentRegion) => {
if (g.slot === 'light') return light
if (g.type === 'apron') return palette.wall
if (g.type === 'belt') return palette.woodDark
if (g.type === 'scarf') return palette.accent
if (g.type === 'cape') return slot.bottom
if (region === 'feet') return slot.shoe
if (region === 'bottom') return slot.bottom
// The outermost top wears the top color; anything under it a light tone.
return i === outerTop ? slot.top : light
}
const layers: Layer[] = stack.map((g, i) => {
const d = GARMENT_DEFAULTS[g.type]
const region = GARMENT_REGION[g.type]
return {
type: g.type,
region,
material: GARMENT_MATERIAL[g.type],
// The garment's own color beats the shorthand color props, which beat the defaults.
color: g.color ?? defaultColor(g, i, region),
sleeves: g.sleeves ?? d.sleeves,
neck: g.neck ?? d.neck,
length: g.length ?? d.length,
tucked: false,
offset: 0,
}
})
// Order decides the fit. A top listed before a bottom is tucked in; each layer stands just
// proud of every earlier layer it overlaps, so inner never pokes through outer.
const base: Record<GarmentRegion, number> = {
top: 0.006,
bottom: 0.008,
feet: 0.009,
extra: 0.01,
}
// How far past its base offset a layer can bulge (ease toward the hem, cuffs, folds).
const slack = (l: Layer) =>
l.region === 'bottom' ? 0.009 : l.region === 'top' ? (l.tucked ? 0.004 : 0.009) : 0.003
const overlaps = (x: Layer, y: Layer) =>
x.region === y.region ||
(x.region === 'top' && y.region === 'bottom') ||
(x.region === 'bottom' && y.region === 'top') ||
(x.region === 'bottom' && y.region === 'feet') ||
(x.region === 'feet' && y.region === 'bottom') ||
x.region === 'extra' ||
y.region === 'extra'
layers.forEach((l, i) => {
if (l.region === 'top' && !['dress', 'robe', 'coat', 'blazer', 'apron'].includes(l.type))
l.tucked = layers.slice(i + 1).some((o) => o.region === 'bottom' && o.type !== 'skirt')
let off =
base[l.region] +
(l.material === 'knit' ? 0.003 : 0) +
(l.type === 'coat' || l.type === 'blazer' ? 0.008 : 0)
for (let j = 0; j < i; j++)
if (overlaps(l, layers[j])) off = Math.max(off, layers[j].offset + slack(layers[j]))
l.offset = off
})
return layers
} person/types.ts
// Public trait types for `Person`. All plain JSON, so a whole look round-trips through world data.
/** Preset bundles: clothes, hat, and accessories for a role. */
export type PersonKind =
| 'villager'
| 'merchant'
| 'guard'
| 'sailor'
| 'farmer'
| 'noble'
| 'scholar'
| 'traveler'
/** Silhouette cues only (shoulder-to-hip ratio, jaw, brow, default hair weights). */
export type PersonGender = 'feminine' | 'masculine' | 'neutral'
export type PersonAge = 'child' | 'adult' | 'elder'
export type PersonBuild = 'slim' | 'average' | 'stocky'
export type PersonHair =
| 'short'
| 'swept'
| 'long'
| 'bun'
| 'ponytail'
| 'braid'
| 'cropped'
| 'bald'
export type PersonFacialHair = 'none' | 'stubble' | 'moustache' | 'beard'
/** One-word outfit shorthand; it expands to a `clothes` stack. */
export type PersonOutfit =
| 'tunic'
| 'shirt'
| 'dress'
| 'robe'
| 'coat'
| 'apron'
| 'uniform'
| 'vest'
| 'tshirt'
| 'sweater'
| 'hoodie'
| 'blazer'
export type PersonHat = 'none' | 'cap' | 'straw' | 'brim' | 'bandana' | 'hood' | 'chef'
export type PersonAccessory = 'bag' | 'belt' | 'scarf' | 'glasses' | 'cape' | 'staff'
export type PersonPose =
| 'stand'
| 'sit'
| 'lean'
| 'work'
| 'wave'
| 'type'
| 'play'
| 'drink'
| 'lie'
/** A small animated sign over the head: asleep, something went wrong, thinking, pleased, on a break. */
export type PersonEmote = 'sleep' | 'alert' | 'think' | 'happy' | 'coffee'
/** `auto` builds the full-detail mesh only within `LOD_NEAR` units of the camera. */
export type PersonDetail = 'auto' | 'high' | 'low'
/** Proportion and face template: `stylized` is an animated-film look (big head and eyes,
* small features, slender limbs), `realistic` keeps canonical human proportions, `anime`
* pushes the eyes and legs further and sharpens the chin. */
export type PersonStyle = 'stylized' | 'realistic' | 'anime'
/** The body itself. Every field is optional and seeded when unset. */
export interface PersonBodySpec {
/** Limb thickness and torso depth. */
build?: PersonBuild
/** Skin color. */
tone?: string
/** Standing height, in units. */
height?: number
}
export type PersonNose = 'button' | 'straight' | 'broad'
/** The face, part by part. Sizes are multipliers around 1. */
export interface PersonFaceSpec {
eyes?: { color?: string; size?: number }
/** Brow color defaults to the hair color; `weight` thickens them. */
brows?: { color?: string; weight?: number }
nose?: PersonNose
lips?: { color?: string; fullness?: number }
/** Cheek flush, 0 (none) to 1. */
blush?: number
}
export interface PersonHairSpec {
style?: PersonHair
color?: string
}
export type PersonGarmentType =
| 'tshirt'
| 'shirt'
| 'sweater'
| 'hoodie'
| 'blazer'
| 'vest'
| 'tunic'
| 'coat'
| 'dress'
| 'robe'
| 'apron'
| 'trousers'
| 'jeans'
| 'shorts'
| 'skirt'
| 'shoes'
| 'boots'
| 'belt'
| 'scarf'
| 'cape'
/**
* One layer of clothing. Layers are listed inner to outer, and the order decides the fit: a
* top listed before the trousers is tucked into them, one listed after hangs over them.
*/
export interface PersonGarment {
type: PersonGarmentType
color?: string
/** Tops only. */
sleeves?: 'none' | 'short' | 'long'
/** Tops only. */
neck?: 'crew' | 'v' | 'collar'
/** 0 to 1: how far it reaches (a top's hem, a leg's length, a skirt or boot's drop). */
length?: number
}
/**
* A cosmetic loadout (a "skin" in the game sense): clothes, colors, headwear, hair, and
* body accessories bundled as one plain-JSON object, so a whole look swaps in a single
* prop write. It never touches the body itself (tone, build, age, gender). Precedence per
* trait: an explicit component prop > this skin > the `kind` preset > the seeded roll.
*/
export interface PersonSkin {
outfit?: PersonOutfit
/** Full layer stack; wins over `outfit`. */
clothes?: PersonGarment[]
topColor?: string
bottomColor?: string
shoeColor?: string
hat?: PersonHat
hair?: PersonHair
hairColor?: string
accessories?: PersonAccessory[]
}