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 againstTransform64/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
| Field | Type | Meaning |
|---|---|---|
effector | string | The asset’s node name for the end bone, resolved against the entity’s transform hierarchy |
solver | string | Solver key: "2BIK" or "1BSA" |
offset | number | How far off the surface the effector rests, as a fraction of the chain’s length scale. Positive keeps it above; negative penetrates |
distance | NumericInterval | [distanceMin, distanceMax], also relative to the length scale: hover distances mapped to full then no influence |
strength | number | Influence multiplier. Read by "1BSA" only |
limit | number | Maximum 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):
- Obtains the terrain from the dataset. If there is no terrain entity, nothing is solved - both shipped solvers are terrain solvers.
- Traverses entities with both
InverseKinematicsandTransform64. - For each constraint, resolves
constraint.effectorto an entity withtransform_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. - Creates a pooled
IKProblemper resolved constraint and queues it under the solver key. - 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:
- From
origintowardseffector, establishing which surface the limb is over at all. - 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_contactandout_targetdescribe 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”:
| Situation | Influence |
|---|---|
| No surface found by either cast | 0 |
| Effector below the surface (penetrating) | 1 |
| Effector hovering above it | falls 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
| Parameter | Type | Notes |
|---|---|---|
joints | Transform64[] | 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 |
lengths | number[] | lengths[i] is the distance from joint i to joint i+1 |
origin | Vector3 | Root anchor - the first joint is pinned here |
target | Vector3 | Goal position for the tip joint |
max_iterations | number | Default 4 - the solver exits early once the tolerance is met |
distance_tolerance | number | Squared 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
- 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. - 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. - 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. - Lerps the bone’s local rotation towards the target by
constraint.strength * influenceand 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.
Related
- 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/