voxolith

Animation

@voxolith/engine/animation: rigs and quaternion clips, the animator, forward kinematics, baking posed models by inverse mapping, cover, wounds, severing.

Rigged entities are optional on the contract. A generator that authors one (such as gen-creature) gives the entity a rest model whose voxels know their bone, a rig and a set of clips. @voxolith/engine/animation plays the clips, turns a pose into bone matrices, bakes the posed voxel model, and damages the rest model. It is headless and pure: getting a posed model on screen is the host's job, through the stamper, a crowd or instances.

import { bakePose, makeAnimator, poseMatrices } from "@voxolith/engine/animation";

const anim = makeAnimator(rat, "walk");
anim.play("run", { fade: 0.1 });

// per frame:
for (const e of anim.update(dt)) if (e.name === "footfall") dust(e);
const matrices = poseMatrices(rat.rig!, anim.pose());
const posed = bakePose(rat.model, rat.rig!, matrices, { yaw: heading });

The data

fieldtypemeaning
model.bonesUint8Arraythe bone owning each voxel, indexed like model.data
entity.rigRigbones and an optional cover table
entity.clipsClip[]the animations

A Bone has an id, a parent index (-1 for the root; parents come before children), and a head and tail in rest-pose model voxels. A bone rotates about its head; the tail is where its children usually start.

A clip has a duration in seconds, loop, and one ClipTrack per animated bone: ascending times and four numbers per key, a unit quaternion (x, y, z, w) relative to rest. Voxolith's generators author clips at 12 frames a second, which reads right at voxel scale and keeps the pose cache small. A clip can also carry a root offset track (a bob, a crouch; three numbers per key, in voxels) and ClipEvents, named moments such as footfalls or a bite, for sound, dust and gameplay.

Playing

makeAnimator(entity, initial?) returns an Animator:

  • play(id, { fade?, restart?, speed? }) switches clip, crossfading from the current pose over fade seconds (default 0.2). Playing the current clip again does nothing unless restart.
  • update(dt) advances and returns the events crossed since the last update, including every crossing of a looping clip's wrap.
  • pose(), clip(), time() (wrapped for loops) and finished() (a non-looping clip at its end).

The pieces are available separately: sampleClip (slerp between keys), blendPoses, restPose, clipTime. A Pose is one rotation per bone plus a root offset.

Posing

poseMatrices(rig, pose) is forward kinematics: one rigid 3x4 matrix per bone (12 floats each) mapping rest model space to posed model space. Each bone rotates about its own head inside its parent's posed frame, and the root carries the pose's offset. The same matrices serve baking and hit-testing (a BakedPose from a crowd keeps them). Small quaternion and affine helpers (quatAxisAngle, quatMul, quatToMat, mulAffine, invertRigid, transformPoint) are exported for generators and hosts.

Baking

bakePose(model, rig, matrices, options?) produces the posed voxel model.

Inverse mapping. Forward-mapping every rest voxel to its posed position leaves holes as soon as a bone rotates (neighbours land two cells apart). Instead, for each bone every cell of its posed bounding box is taken back to rest space through the bone's inverse matrix and filled from the rest voxel there, if that voxel belongs to the bone. Every posed cell is sampled, so rotated limbs stay solid at any angle. Along a row the rest position is linear, so each row is clipped to the span that lands inside the bone's rest box rather than visiting every cell.

Weld (weld, default on). Each bone samples only its own voxels, so where a child turns against its parent they can end up touching at an edge or a corner only, and a thin limb comes off. Around each posed joint the empty cells of a 3x3x3 block are filled from the rest model through either bone's inverse. The rest model decides what is solid, so a weld never adds bulk the body did not have.

Fill (fill, default on) closes the one-voxel cracks that open on the outside of a bending joint.

Cover. When a limb swings, voxels that were buried at rest (flesh under a haunch) end up on the surface. With a cover table (role value to surface role, e.g. interior to fur; options.cover or rig.cover), those are drawn with the cover instead. Voxels already on the rest surface, including wounds carved into the rest model, are left alone, so damage still shows its inside.

yaw turns the model about the vertical axis through the anchor inside the same inverse map, so a creature turns at any angle. That is resampling, which is why scenery sticks to 90° orientations; a creature trades one-voxel exactness for motion. Baking the 3.5k-voxel rat takes about 0.6 ms.

The result is a dense model with its own size and anchor, and bones for the posed voxels.

Caching poses

Clips run at 12 fps and headings can be bucketed, so a herd sharing a few variants asks for the same few hundred poses repeatedly. makePoseCache(sizeOf, { maxBytes?, onEvict? }) is a least-recently-used cache with a byte budget (default 48 MB), keyed by the caller (variant, clip, frame, heading bucket, damage); drop(prefix) forgets one damaged creature's poses. makeCrowd builds on it.

Damage

Both damage functions work on the rest model, so damage follows the creature through every pose it is baked into afterwards. They are pure: a game calls them, keeps the results and throws the pieces around; nothing here knows about physics. A generator that layers its interior (bone, flesh, organs under the skin, as gen-kit's layerInterior does) gets gore for free: carving simply exposes what is underneath.

  • wound(model, point, radius, { rim? }) removes the voxels within radius of a rest-space point and optionally paints a rim role (blood) around the hole.
  • sever(model, rig, bone, { rim? }) cuts a bone and its subtree off. It returns the body (still on the original rig; the severed bones own no voxels) and the piece: its own model, anchored at the cut, with its own sub-rig and the origin where its anchor sat in the body's rest space, so a game can spawn it in place. Both cut faces get the rim role.
import { wound } from "@voxolith/engine/animation";
import { ROLE } from "@voxolith/gen-creature";

const hurt = wound(member.rest ?? rat.model, hitPoint, 2.5, { rim: ROLE.BLOOD });
member.rest = hurt;
member.damage = (member.damage ?? 0) + 1; // a crowd then stops reusing the undamaged poses

Authoring rigs

Generators author rigs with gen-kit's RiggedVolume, in which later fills own what they overwrite (so a child owns its joint), and layerInterior, which gives the mass an inside by depth. The creature generator documents the resolution rules that keep a rig animating cleanly: nothing animated thinner than 2 voxels (the tail tip aside), 4 or more voxels between joints, and the detail budget spent on the head. See Creatures and the toolkit.

On this page