Animation

Inverse kinematics

meep provides three IK solvers - FABRIK for arbitrary chains, two-bone IK for limbs, and one-bone surface alignment for foot placement on terrain.

meep’s IK lives in two places:

  • engine/physics/inverse_kinematics/ - the solver algorithms, written as plain functions against Transform64 / Vector3 / Quaternion.
  • engine/ecs/ik/ - the ECS layer that binds those solvers to entities, to the transform hierarchy, and to terrain raycasts.

The algorithms are pure maths. In the ECS layer a bone is an entity, not an object inside a renderer’s skeleton, so the solvers read world Transform64s the hierarchy has already composed and write local ones back.

ECS components and system

An entity that needs IK carries an InverseKinematics component holding an array of IKConstraint objects - one per limb or surface-aligned bone. InverseKinematicsSystem runs each frame, resolves each constraint’s effector to an entity beneath the constrained entity, and dispatches a problem to the named solver.

import { InverseKinematics } from
    "@woosh/meep-engine/src/engine/ecs/ik/InverseKinematics.js";
import { InverseKinematicsSystem } from
    "@woosh/meep-engine/src/engine/ecs/ik/InverseKinematicsSystem.js";

await em.addSystem(new InverseKinematicsSystem());

const ik = new InverseKinematics();

ik.add({
    effector: "foot.L",     // node name in the model, resolved to an entity
    solver:   "2BIK",       // "2BIK" or "1BSA"
    offset:   0.05,         // fraction of limb length, above the surface
    distanceMin: 0,
    distanceMax: 0.3,       // influence fade-out band, same units
    strength: 1,
    limit:    Math.PI * 0.9 // radians; 1BSA only
});

InverseKinematicsSystem takes no constructor arguments and depends on [InverseKinematics, Transform64]. It declares Transform64 as read-write in components_used. The InverseKinematics component is serialized - InverseKinematicsSerializationAdapter is in populateEngineSerializationRegistry.

InverseKinematics.add({...}) builds an IKConstraint and pushes it onto constraints. Its defaults are offset: 0, distanceMin: 0, distanceMax: 0.1, strength: 1, limit: Math.PI * 0.9, solver: "2BIK".

IKConstraint fields

FieldTypeMeaning
effectorstringThe asset’s node name for the end bone, resolved against the entity’s transform hierarchy
solverstringSolver key: "2BIK" or "1BSA"
offsetnumberHow far off the surface the effector rests, as a fraction of the chain’s length scale. Positive keeps it above; negative penetrates
distanceNumericInterval[distanceMin, distanceMax], also relative to the length scale: hover distances mapped to full then no influence
strengthnumberInfluence multiplier. Read by "1BSA" only
limitnumberMaximum rotation angle in radians before the correction is scaled back or skipped. Read by "1BSA" only. Class default Math.PI

The effector string is a plain node name, looked up with transform_attachment_find_descendant_by_name - see Skeletons & skinning. Nothing maps names onto a humanoid taxonomy: HumanoidBoneType is not consulted.

How the system dispatches

Each frame InverseKinematicsSystem.update(dt):

  1. Obtains the terrain from the dataset. If there is no terrain entity, nothing is solved - both shipped solvers are terrain solvers.
  2. Traverses entities with both InverseKinematics and Transform64.
  3. For each constraint, resolves constraint.effector to an entity with transform_attachment_find_descendant_by_name. The result is cached per entity per constraint; a name that does not resolve is retried next frame, because the hierarchy arrives with the model rather than with the component.
  4. Creates a pooled IKProblem per resolved constraint and queues it under the solver key.
  5. Runs solver.solve(problem) for every queued problem, then releases it.

IKProblem carries constraint, dataset (an EntityComponentDataset - the chain is walked there rather than on a skeleton object), effector (the entity id of the end bone) and terrain. Instances come from IKProblem.pool.

There is no visibility skip: every constrained entity is solved every frame.

Solver errors propagate. A throwing solver fails the frame, which is what you want while a rig is being set up.

The two registered solvers are keyed "2BIK" (TwoBoneInverseKinematicsSolver) and "1BSA" (OneBoneSurfaceAlignmentSolver), both on the system’s solvers map. Add your own by putting an IKSolver subclass on that map under a new key.

Bone chains

Solvers do not receive a chain - they build one from the effector upwards:

import { IKBone, ik_bone_chain } from
    "@woosh/meep-engine/src/engine/ecs/ik/ik_bone_chain.js";

const CHAIN = [new IKBone(), new IKBone(), new IKBone()]; // effector, parent, grandparent

if (!ik_bone_chain(CHAIN, dataset, effector, 3)) {
    return; // fewer than 3 links, or one is not a full transform pair - not an error
}

IKBone is { entity, world: Transform64|null, local: Transform64|null }, where world is the transform TransformAttachmentSystem composed and local is the TransformAttachment’s own transform.

A solver reads world and writes local. Writing world directly would be overwritten by TransformAttachmentSystem the moment anything above the bone moved - and the local rotation is what a joint angle actually is.

ik_bone_chain returns false when the hierarchy holds fewer than count links or when a link lacks either a Transform64 or a TransformAttachment (a root has no joint angle to move). Neither is an error: a model that has not finished arriving looks exactly like that.

Because both solvers keep their chain in a module-level array, they are not re-entrant - one problem at a time, which is how the system calls them.

Terrain probing

Both solvers share ik_probe_terrain_contact(out_target, out_contact, terrain, origin, effector, constraint, length_scale) from engine/ecs/ik/ik_probe_terrain_contact.js. It makes two raycasts:

  1. From origin towards effector, establishing which surface the limb is over at all.
  2. From just outside that surface, directly above the effector along the contact normal, back down. This is what locates the point under the effector rather than under the limb’s root, and it is what out_contact and out_target describe on return.

out_target is the contact point displaced along the normal by constraint.offset * length_scale.

The returned influence in [0, 1] answers “how much of this correction should apply”:

SituationInfluence
No surface found by either cast0
Effector below the surface (penetrating)1
Effector hovering above itfalls from 1 to 0 across constraint.distance, measured as a fraction of length_scale

Expressing both offset and distance relative to length_scale is what makes one constraint read the same way on a long limb and a short one.


FABRIK - arbitrary chains

fabrik_solve in engine/physics/inverse_kinematics/fabrik/fabrik_solve.js implements FABRIK (Forward And Backward Reaching Inverse Kinematics) for chains of arbitrary length. Neither registered solver uses it; it is there for chains you drive yourself.

import { fabrik_solve } from
    "@woosh/meep-engine/src/engine/physics/inverse_kinematics/fabrik/fabrik_solve.js";

fabrik_solve(
    joints,             // Transform64[] - updated in place
    lengths,            // number[] - distance to the next joint
    origin,             // Vector3 - where the root must stay
    target,             // Vector3 - where the tip should reach
    4,                  // max_iterations (default)
    1e-7                // distance_tolerance (default)
);

Parameters

ParameterTypeNotes
jointsTransform64[]Chain joints, root first. Translations, rotations and matrices are written - the announcement is not: call t64_announce_change(ecd, entity) per joint afterwards, or the meshes built on those transforms stay where they were
lengthsnumber[]lengths[i] is the distance from joint i to joint i+1
originVector3Root anchor - the first joint is pinned here
targetVector3Goal position for the tip joint
max_iterationsnumberDefault 4 - the solver exits early once the tolerance is met
distance_tolerancenumberSquared distance threshold for early exit; default 1e-7

The underlying primitive fabrik3d_solve_primitive works entirely on a flat Float32Array of packed XYZ positions to avoid allocation in the inner loop. Chains up to 64 joints use a pre-allocated scratch buffer; longer chains allocate a temporary array.

When the target is unreachable - the distance from origin to target exceeds the summed link lengths - the primitive stretches the chain in a straight line toward the target instead of iterating.

After the position pass, fabrik_solve computes each joint’s rotation delta by comparing the before-and-after bone direction with Quaternion.fromUnitVectors and applies it with multiplyQuaternions.


Two-bone IK

TwoBoneInverseKinematicsSolver (solver key "2BIK") handles limbs: the canonical three-joint chain of upper-arm/thigh (A), forearm/shin (B) and hand/foot (C). It builds a 3-link chain from the effector, probes the terrain between A and C, and calls two_joint_ik for the local-space rotation deltas.

Algorithm

two_joint_ik in engine/physics/inverse_kinematics/two_joint_ik.js is based on the analytic two-joint solution described in “Simple Two-Joint IK”, which the source cites.

import { two_joint_ik } from
    "@woosh/meep-engine/src/engine/physics/inverse_kinematics/two_joint_ik.js";

two_joint_ik(
    a, b, c,        // Vector3 world positions of root, mid, effector
    t,              // Vector3 target position
    0.01,           // epsilon for rounding compensation
    a_gr, b_gr,     // Quaternion global rotations of root and mid bone
    a_lr, b_lr      // Quaternion local rotations - updated in place
);

It clamps the distance to the target to the limb’s maximum extension, computes the required interior angles with the cosine rule, and applies the angular deltas as axis-angle rotations built from cross products of the current and target bone directions.

Contact and influence

The chain’s length scale is the limb’s own length, |A - B| + |B - C|, and the probe casts from A’s world position towards C’s. An influence of zero returns immediately - no contact, or the effector is far enough above the surface that the constraint has faded out.

Otherwise the solver copies A’s and B’s current local rotations, solves for the target rotations, and lerps between them by influence - this solver does not read constraint.strength or constraint.limit. Both lerps are taken before either write, because writing a bone recomposes every world transform below it, and reading B’s pose after moving A would read the corrected one.


One-bone surface alignment

OneBoneSurfaceAlignmentSolver (solver key "1BSA") aligns a single bone to the terrain normal - the usual case being a foot conforming to a slope. It needs a 2-link chain: the bone, and its parent, which supplies the direction the probe casts along.

How it works

  1. Probes the terrain casting from the parent’s world position towards the bone’s. The length scale here is the bone’s own world scale (scale.length() / sqrt(3)), not a limb length.
  2. Brings the contact normal into the bone’s own rotation frame, by applying the inverse of its world rotation, and builds an axis-angle rotation from the cross product of that with Vector3.forward ((0, 0, 1)) - the rotation that lays the bone onto the surface. It is composed onto the bone’s current local rotation to give the target.
  3. Measures the angle between that target and the parent’s local rotation. If it is at or beyond constraint.limit, the solver compares the target against the bone’s own current local rotation: still at or beyond the limit, and it returns without writing; inside it, and influence is scaled back towards the limit boundary.
  4. Lerps the bone’s local rotation towards the target by constraint.strength * influence and writes it back.

The offset field displaces the target point along the contact normal before alignment, so the bone’s pivot can sit a fixed fraction of its own scale above the surface.


  • Skeletons & skinning - how a bone is addressed, and the GPU skinning path IK writes into
  • Animation graphs - clip-driven animation that IK post-processes
  • Hierarchy - TransformAttachment, and why a solver writes local transforms
  • Terrain - the raycast surface both solvers probe
  • Source: engine/physics/inverse_kinematics/, engine/ecs/ik/