Skip to content

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.

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

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.

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.

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.

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.

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.

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

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

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.

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 images
UPDATE_SCHEMAS=1 cargo test -p sparklight-3d --test hair_groom --test character_runtime # rewrites the schema files

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.

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