Characters
Morph targets and facial animation, hair, character cloth, retargeting, foot placement and a sample character, all headless first and
all driven through character.* commands. Reference for agents; the animation basics (skeletons, clips, graphs, IK) are in
animation3d.md.
| Part | Crate and module | Data file (schema in schemas/ or the crate’s schemas/) |
|---|---|---|
| Morph targets (blend shapes), the active limit, weight clips | sparklight-skeletal::morph, GPU blend in sparklight-3d::morph_gpu |
inside the glTF (targets, extras.targetNames, weights animation channels) |
| Face: expressions, visemes from phoneme timings, blink, eye look-at | sparklight-skeletal::face |
face.json (FaceDef, schemas/face.schema.json) |
| Hair: groom, guide simulation, strand and card renderer | sparklight-3d::character::hair and shaders/hair.wgsl |
hair.json (GroomDef, crates/sparklight-3d/schemas/groom.schema.json) |
| Character cloth: cape, skirt | sparklight-skeletal::character_cloth on sparklight-physics-fx::Cloth |
cape.json (CharacterClothDef, charcloth.schema.json) |
| Retargeting between skeletons | sparklight-skeletal::retarget |
RetargetDef (retarget.schema.json) |
| Foot placement on uneven ground | sparklight-skeletal::foot_ik |
footik.json (FootIkDef, footik.schema.json) |
| Simulated bone chains (ponytail: the authored hair cards follow) | sparklight-3d::character::chain |
ponytail.json (ChainDef, crates/sparklight-3d/schemas/chain.schema.json) |
| The character that ties them together | sparklight-3d::character::{runtime, render} |
*.character.json (CharacterDef, character.schema.json) |
| The commands | sparklight-control character.* |
schemas/commands/character.*.schema.json |
Everything is deterministic on one machine: no wall clock, seeded generators (SplitMix64), the engine’s own dmath for trigonometry and powers, fixed
iteration orders, no threads in the simulation. Character::state_hash() and the state_hash of character.step cover pose, morph weights, face, cloth, hair and chains. The same
inputs give the same hashes on every run (character.scenario.run repeats the whole scenario and compares). Across CPU architectures they differ: the sample scenario’s four
differently), so scenario files carry no expected hashes, only the repeat check and ranges.
Morph targets
Section titled “Morph targets”import_gltf_* reads morph targets (positions, normals, tangents), extras.targetNames (else target_<i>), the default weights
(node.weights, else mesh.weights) and weights animation channels (ImportedRig::morph_clips, one MorphClip per animation, a track per
mesh and target). A SkinnedMesh has tangents and morph: Option<MorphSet>.
A MorphSet stores each target sparsely (only vertices it moves) and transposed per vertex (CSR: vertex_offsets, entry_targets,
entry_deltas, nine floats per entry). MorphSet::apply is the CPU reference: per vertex base + sum(w * delta), normal and tangent
renormalised, vertices no target touches are bit-identical to the base.
| Limit | Value | When it is exceeded |
|---|---|---|
MAX_MORPH_TARGETS |
256 per mesh | the import fails with E_ANIM_MORPH_LIMIT; the message names every target over the limit |
ACTIVE_MORPH_LIMIT |
32 non-zero weights per mesh and frame | MorphSet::select_active keeps the strongest (ties: lower index) and returns every dropped target; MorphDropLog / GpuMorph log each mesh/target pair once and character.step lists new drops (strict_limits turns them into E_CHAR_MORPH_LIMIT) |
MORPH_WEIGHT_LIMIT |
weights clamp to +-2 (counted in MorphStats::clamped) |
|
MAX_MORPH_VERTICES |
4 194 240 per mesh | GpuMorph::new fails |
GPU: GpuMorph::new(renderer, model, mesh, name, set, base_vertices, base_tangents) uploads the CSR data once; apply(&renderer, weights)
selects the active targets, writes the weights, dispatches one compute pass (a thread per vertex walks its own entries, so no atomics and
no order dependence) and copies the blended bind-pose vertices into the mesh’s vertex buffer. Skinning then runs in the vertex shader on the
morphed vertices (morph first, skin second). The GPU result matches the CPU reference within 1e-5 (tests/morph_gpu.rs).
Mesh bounds (and the per-joint influence bounds of skinned meshes) are grown once by MorphSet::max_possible_displacement(32, 2.0).
Known limits: the blend writes the model’s own vertex buffer, so every draw item of that ModelId shows the same face: upload a character
once per instance. Tangent deltas are blended and readable (GpuMorph::tangents), but the surface shaders derive their tangent frame from
screen-space derivatives and read no vertex tangent, so nothing in the frame consumes the blended tangents yet.
FaceDef (schemas/face.schema.json): expressions and visemes map names to {morph target: weight}, phonemes maps phoneme symbols to
visemes (empty means the ARPABET default default_phoneme_map(): 15 visemes sil PP FF TH DD kk CH SS nn RR aa E ih oh ou, stress digits
ignored), viseme_timing (attack 0.07 s, release 0.09 s, lead 0.03 s: the mouth moves slightly before the sound), blink
(lid targets, interval range, duration, close fraction, seed), look (eye bones, forward and up axes in the bone’s own frame, limits,
speed, optional look morphs), expression_speed.
FaceController (per character): set_expression(name, weight) fades at expression_speed; play_visemes(VisemeTrack, intensity);
set_look_at(point, weight) turns each eye bone on its own (so the eyes converge) within the yaw and pitch limits at the speed limit;
blink_now, set_blink_enabled, set_override(target, value); update(dt, skeleton, pose) rotates the eye bones of pose and returns the
morph weights (clamped to 0..1; the blink moves lid targets towards closed by the blink amount).
Lip sync is data: VisemeTrack::from_phonemes(def, &[PhonemeTiming{phoneme, start, end}]). Each segment’s envelope rises from
start - lead over attack, holds, and falls over release after end; overlapping segments are scaled so their weights never sum
above 1. Unknown phonemes, backwards times and unsorted starts are errors (E_ANIM_FACE_PHONEME, all unknown symbols named). No audio is
read or played anywhere. FaceDef::check_targets names every morph target the definition uses that no mesh has (E_ANIM_FACE_TARGET);
Character::new runs it against the model.
The eye bones’ rest frames come from the glTF exporter: bone-local +Y is the bone’s length direction. The sample’s report
(Sparkle_export.json, bones.eye_l.forward_local and up_local) gives the numbers face.json uses (forward [0,1,0], up [0,0,1]).
GroomDef (groom.schema.json): bone (the head bone: roots and rest shapes are in its local space), scalp (ellipsoid center, radius,
scale, up, forward, max_polar_deg, face_exclusion), guides, children_per_guide, segments, length and length_variance, comb
direction, droop, curl, clump (child offset radius at root and tip), thickness, color (root, tip, per-strand variation), shading
(Marschner parameters), tier (strands or cards), card_width, sim, colliders (bone-fixed capsules), seed.
Groom::build makes guide strands (a Fibonacci spiral over the scalp cap, rejected on the face, grown along the surface normal and turned
to the comb direction, curled, pushed out of the skull ellipsoid) and a ChildSpec per rendered strand: three guides, weights, a clump
direction and share, a width and a tint (all byte-quantised the way the GPU data is). The first strand of each guide is the guide itself.
| Limit | Value | When it is exceeded |
|---|---|---|
MAX_STRANDS |
65 536 rendered strands | E_CHAR_HAIR_LIMIT with the numbers |
MAX_GUIDES |
4 096 | E_CHAR_HAIR_DEF |
MAX_SEGMENTS |
24 per strand | E_CHAR_HAIR_DEF |
HairSim (guide simulation, world space, deterministic): the root is pinned to the head, the other points move by damped velocity, gravity
and wind drag (only the wind across the strand pushes), are pulled back to the animated rest shape (stiff at the root, loose at the tip), keep
their segment lengths root-outwards and are pushed out of the colliders. teleport(delta) moves the hair without velocity. Character::set_transform calls it
(and ChainSim::teleport for the chains) when a hair’s head bone or a chain’s root bone moves farther than sim.teleport_distance (metres, default 3, must be
positive; a field of the groom’s and the chain’s sim) in one call; shorter moves are a walk the hair lags behind. A matrix that is not finite is ignored with a warning.
Groom::guide_report() says how many guides the definition asked for and how many fit on the allowed scalp (GuideReport { requested, built }, short_by()); a short
groom still builds, logs a warning once, and character.validate, character.info, character.hair and character.load report guides_requested and guides_short_by.
packing_range(def) is the edge of the cube around the head in which the renderer packs the guide points; a point outside it is clamped and counted (HairStats::clamped_points,
character.capture field render_stats.hair_points_clamped), and a test drives the sample hard to show it stays inside.
Renderer (HairGpu, shaders/hair.wgsl, shader id character::hair): one static mesh of two-vertex-wide ribbons per rendered strand with
uv = (strand * points + point, side); the vertex stage expands every vertex from two data textures. The guide texture (dynamic, Rgba8Unorm,
two texels per point, 16-bit fixed point relative to the head within range metres) is rewritten once per frame with one write_texture; the
strand texture (static, four texels per strand) holds the three guides, weights, clump direction, width and tint. So only the few hundred guides are
uploaded per frame and the thousands of strands are interpolated, clumped, turned to face the camera and widened to min_pixels on the GPU.
Points outside the fixed-point range are clamped, counted (HairStats::clamped_points, zero in normal use) and logged once per guide.
Shading: Marschner-style after Karis (SIGGRAPH 2016): R, TT and TRT lobes with a Gaussian longitudinal term from the strand tangent, approximate
azimuthal terms, a Kajiya-Kay diffuse term, environment irradiance darkened towards the roots, the light list and shadow visibility of the
renderer. Sub-pixel strands are widened to a pixel and the lost coverage goes into a hashed alpha test (interleaved gradient noise that changes every
frame) that temporal anti-aliasing resolves. The renderer has no multisampling, so this is the stand-in for alpha-to-coverage; order-independent
transparency was not needed because the strands are cut out, not blended. Cards (tier: cards, or the authored hair_cards mesh of the model) are wide
camera-facing strips with strand lines in the alpha. Hair casts shadows through the same cut-out. Wind that bends the hair is a change of the guide
points, so the frame’s motion vectors only carry the head’s motion (the hair’s own motion is not in the velocity pass).
Character cloth
Section titled “Character cloth”CharacterClothDef (charcloth.schema.json): mesh (name of a skinned glTF mesh), pin_height (vertices within this height of the mesh’s top in bind pose are
pinned), max_distance_near / max_distance_far (how far a free vertex may stray from its skinned place, from just below the pins to the hem),
material (mass, stretch and bend compliance, damping, substeps, iterations, thickness, friction, gravity, wind drag), bone-fixed capsule
colliders, motion_steps (1 to 8 interpolated sub-updates per call), teleport_distance.
CharacterCloth::update(dt, skeleton, pose, to_world, wind): the skin weights give every vertex’s animated world place (skin_position); pinned
vertices follow it exactly, free ones are anchored within their maximum distance (Cloth::set_anchor), body capsules push the cloth out, and the
capsules and places between two frames are walked in motion_steps interpolated pieces (ClothCollider::lerp) so a fast arm does not pass through.
A pin jump over teleport_distance carries the cloth along (Cloth::teleport); pins faster than about 15 m/s (below that distance) stretch the cloth while the burst lasts and it settles within two seconds afterwards (tests/character_cloth.rs). There is no self-collision.
Additions to sparklight-physics-fx::Cloth: set_anchor, clear_anchor, anchor, teleport, and ClothCollider::lerp. A cloth that uses no anchors hashes
exactly as before.
Retargeting
Section titled “Retargeting”RetargetDef (retarget.schema.json): bones (source, target, translate), optional scale and model_rotation.
Retargeter::new(def, source_skeleton, target_skeleton) fails with all offending names (E_ANIM_RETARGET) and builds a RetargetReport (mapped,
unmapped target bones, unmapped source bones, scale).
Method: per mapped bone the source’s model-space rotation relative to its own rest pose is applied to the target bone’s own rest orientation
(the rest-pose correction: T-pose and A-pose agree on “arm raised 60 degrees”, and the source’s rest pose always gives the target’s rest pose).
Bone lengths and unmapped bones never change. Only translate bones (the hips) carry translation, scaled by the hip-height ratio (or scale); that
factor also scales root motion (retarget_root_motion). retarget_pose maps one pose, retarget_clip(clip, fps) bakes a clip (linear keys at fps, events
kept). fps is a finite rate within 1 and MAX_RETARGET_FPS (240) and a bake needs at most MAX_RETARGET_POSES (20 000) poses (ceil(duration * fps) + 1); anything else is E_ANIM_RETARGET with the numbers, never an allocation or a cut clip. RetargetDef::auto maps by normalised name (case, separators, mixamorig:/Bip01_/CC_Base_ prefixes, side markers, pelvis=hips, UpLeg=thigh,
ForeArm=lowerarm, Calf=shin). Mapping is one to one: extra target bones (twist, fingers) follow their parents at rest and are listed, never dropped
silently; require_complete() turns any unmapped bone into an error.
Foot placement
Section titled “Foot placement”FootIkDef (footik.schema.json): legs (hip, knee, ankle bones, ankle_height, knee bend hint), pelvis, probe distances, max_drop, max_raise,
smooth_rate, max_slope_deg, align_feet. FootPlacer::update(dt, weight, skeleton, pose, to_world, ground) casts a ray straight down from above each
animated ankle through the closure ground(origin, direction, max_distance) -> Option<GroundHit> (world space), shifts each foot by the height of the
ground under it relative to the ground under the character (so a swinging foot keeps its clearance) but never below the ground plus the ankle height,
lowers the pelvis with the lowest foot, smooths all offsets exponentially, solves each leg with two-bone IK (knee kept by the animated knee or the
bend hint) and tilts the foot to the ground normal. Steep ground (over max_slope_deg) and missing ground are ignored and reported
(LegReport::grounded); a foot that stays where the animation put it is left alone.
Simulated bone chains
Section titled “Simulated bone chains”ChainDef: bones (root first, each the child of the one before), tip_length (the last bone has no child to give it a length), sim (the same parameters as a groom’s: stiffness root and tip, damping, gravity, wind response, substeps, collision margin) and bone-fixed capsule colliders.
ChainSim::apply(dt, skeleton, pose, to_world, wind) runs the guide-strand physics on the heads of the chain’s bones (root pinned to the animation, the others pulled back to their animated places, pushed by gravity and wind, kept out of the capsules, segment lengths kept) and writes the result back as bone rotations: each bone turns by the arc from its animated direction to its simulated direction. Translations and the rest of the pose are untouched, so everything skinned to the chain (the sample’s back hair cards on tail_0..2) sways. Character runs its chains after foot placement and before the skin palette.
The character
Section titled “The character”CharacterDef (character.schema.json): model (glTF or GLB), face, hair (list), cloth (list), foot_ik, chains (list), locomotion (idle, walk, walk_speed);
paths are relative to the definition. CharacterSources::load(path) reads everything; Character::new(sources, to_world) checks the parts against each other and fails
with all problems named at once (E_CHAR_BUILD): missing clips, a face that names morph targets no mesh has, missing eye bones, a cloth mesh the model lacks, hair or collider
bones that do not exist, a foot definition with missing bones.
Character::update(dt, env) order: clips (idle and walk blended by locomotion speed, the walk playing faster with speed) and morph weight clips, face (weights and eye
bones), the morph limit check, foot placement on env.ground, model matrices and the skin palette, cloth, hair. Read the results from pose(), palette(),
morph_weights(), cloth(), hair_guides(i), state_hash().
CharacterRender::new(renderer, &character, id_base) uploads the skinned body (materials and textures from the renderer’s own glTF loader, skin weights from
sparklight-skeletal), the authored hair_cards as a second model, a GpuMorph per mesh with targets, an unskinned model per cloth piece (rewritten in
world space each frame) and a HairGpu per groom. render(renderer, &character) blends the morphs, rewrites the cloth, uploads the guides and pushes the draw
items; hair_lod is Strands, Cards or Off. One upload per character instance.
The sample character: Sparkle
Section titled “The sample character: Sparkle”samples/characters/ (README rows below), built by Tools/blender/characters/sample_character.py from sparkle.spec.json:
A stylised humanoid, 1.78 m, T-pose bind, 23 bones (root, hips, spine, chest, neck, head, two eyes, legs, arms, a three-bone ponytail chain). head has real eye and
mouth openings and 11 shape keys (eyeBlinkL, eyeBlinkR, jawOpen, mouthClose, mouthFunnel, mouthPucker, mouthSmile, mouthFrown, browUp,
browDown, cheekPuff), eyes skinned to the eye bones, mouth_inner, body, hair_cap, hair_cards (bangs, side locks and back cards with a strand-line alpha texture; the
back cards are weighted to the ponytail bones), cape (a skinned sheet for the cloth) and three animations (idle, walk in place, wave). The script writes
Sparkle.glb and Sparkle_export.json (per mesh vertices, triangles, shape keys, bounds; every bone with head, tail and rest frame; glTF facts and warnings).
| File | What it is |
|---|---|
assets/Sparkle.glb |
the built model (about 850 KB) |
sparkle.character.json |
the character: model, face, hair, cloth, foot placement, locomotion |
face.json, hair.json, cape.json, footik.json, ponytail.json |
the data files |
scenarios/sparkle.charscenario.json, sparkle.test.json |
the headless scenario with deterministic hashes and its spark test script |
Commands
Section titled “Commands”All in schemas/commands/character.*.schema.json; every command takes name when more than one character is loaded.
| Command | What it does |
|---|---|
character.load |
load a definition file, optionally placed (position, yaw_degrees); returns what the character has |
character.unload, character.list, character.info |
remove, list with hashes, describe (bones, meshes and their morph targets, clips, face, hair, cloth, warnings) |
character.step |
frames steps of dt (default 1/60), wind, velocity (the character walks), strict_limits, morph_limit (1 to 32: a stricter active-morph budget for this character from now on); returns state_hash, morph_weights, morph_dropped, morph_limit, foot report, cloth stretch |
character.animate |
a clip (looping, speed) or locomotion_speed (idle blended with walk) |
character.transform |
position, yaw_degrees or both (neither is an error); a jump past the cloth’s teleport_distance (cloth) or the hair’s and chain’s sim.teleport_distance is a teleport for them |
character.face |
expression + weight, clear, blink_enabled, blink_now, look_at (null releases), morph + value override |
character.say |
lip sync from phoneme timings; returns the duration |
character.morphs, character.pose |
final weights and the limit; world positions of bones, cloth stretch, hair tips |
character.ground |
the ground foot placement meets: none, flat, step, slope |
character.hair |
lod strands, cards or off; groom statistics |
character.capture, character.golden |
render headless to a PNG; compare with or write goldens/<name>.png (GPU); the result carries render_stats (morph_weights_clamped, morph_dropped, hair_points_clamped) and warnings for anything the renderer clamped or dropped |
character.validate |
check a face, groom, cloth, foot_ik, chain, retarget or character file without a model (a groom is built, so limits show) |
character.retarget |
map two skeletons (loaded characters or glTF files), report mapped and unmapped bones and the scale, optionally bake a clip |
character.scenario.run |
run a *.charscenario.json (checked against schemas/charscenario.schema.json first) and repeat it from scratch: recorded hashes must match |
Scenario steps: step (frames, dt, wind, velocity), animate, transform, ground, face, say, hash (name), assert (kind: morph, bone_y, cloth_max_stretch,
time, hash; min, max, equals) and golden (name plus capture parameters). A failed assertion is listed in failures and passed is false.
The scenario format is strict (schemas/charscenario.schema.json, JSON Schema 2020-12, checked with the jsonschema crate before any step runs): an unknown key
anywhere (a misspelled min), an unknown step or assertion kind, an assert with neither min nor max (a hash assertion needs equals), an animate with neither clip
nor locomotion_speed, a transform with neither position nor yaw_degrees, a face that does nothing, frames outside 1 to 100 000, dt outside 0 (exclusive) to 1,
or an assertion whose min is above its max is E_PARAMS_INVALID with a JSON pointer to the step; nothing is clamped or ignored. A golden step’s name is the golden’s
(goldens/<name>.png).
Error codes
Section titled “Error codes”| Code | Meaning |
|---|---|
E_CHAR_DEF |
a character definition is invalid, or a file it names cannot be read |
E_CHAR_BUILD |
the parts do not fit together (all problems named), a GPU upload for the character failed, or the renderer was asked for a character with more than 5 cloth pieces or 8 grooms (its 16 draw ids) |
E_CHAR_UNKNOWN |
an unknown character, clip, or an operation the character lacks (no face, no locomotion) |
E_CHAR_HAIR_DEF |
a groom definition is invalid, or its scalp leaves no room for a guide |
E_CHAR_HAIR_LIMIT |
more than 65 536 rendered strands (numbers in the message) |
E_CHAR_HAIR_GPU |
the hair shader, material or data textures could not be created, or the guides do not match the groom |
E_CHAR_MORPH_LIMIT |
character.step with strict_limits dropped morph targets (they are listed) |
E_CHAR_RENDERER |
character.capture or character.golden found no GPU adapter |
The animation-side codes (E_ANIM_MORPH, E_ANIM_MORPH_LIMIT, E_ANIM_FACE*, E_ANIM_RETARGET, E_ANIM_FOOT_IK, E_ANIM_CLOTH) are in animation3d.md.
Tests and goldens
Section titled “Tests and goldens”| What | Where |
|---|---|
| morph import, blend, limits | sparklight-skeletal/tests/morph.rs |
| face, retargeting, foot placement, character cloth | tests/face.rs, retarget.rs, foot_ik.rs, character_cloth.rs |
| cloth anchors, teleport, collider interpolation | sparklight-physics-fx/tests/cloth.rs |
| GPU morph against the CPU reference | sparklight-3d/tests/morph_gpu.rs |
| groom and guide simulation, hair on the GPU | tests/hair_groom.rs, hair_gpu.rs |
| the runtime on the sample, error codes | tests/character_runtime.rs, character_codes.rs |
goldens character_expressions, character_hair_wind, character_cape_motion, character_foot_ik |
tests/character_goldens.rs (goldens/3d/README.md says what each also checks) |
| the commands and the scenario | sparklight-control/tests/character.rs, samples/characters/sparkle.test.json |
SPARKLIGHT_UPDATE_GOLDENS=1 cargo test -p sparklight-3d --release --test character_goldens # on the Mac (Metal); inspect the imagesUPDATE_SCHEMAS=1 cargo test -p sparklight-3d --test hair_groom --test character_runtime # rewrites the schema filesMeasured
Section titled “Measured”cargo run -p sparklight-3d --release --example character_bench (Apple M5 Max, 2026-10-22): Character::update of the sample (walking, wind, 285-vertex cape, 320 guides, ponytail chain) 676 us; hair 320 guides, 11 200 rendered strands of 11 points, 246 400 vertices, 224 000 triangles, 0.21 MB of data textures; one 1280x720 frame with readback 5.50 ms with strand hair, 2.28 ms with cards, 2.18 ms without hair.
Not done
Section titled “Not done”- A rendered face has no teeth or tongue; the mouth interior is a dark shell.
- Hair has no hair-to-hair collision or self-shadowing beyond the shadow maps; its own motion is not in the velocity pass.
- Cloth has no self-collision.
- Retargeting maps bones one to one (no distribution of one source bone over several target bones).
- Morph blending and cloth write the uploaded vertex buffers: one upload per character instance; blended tangents are not read by the shaders.