three.ws

@three-ws/motion

Text and structure to full-body 3D animation. No motion capture, no model weights, no renderer.

npm downloads license node dependencies

Install · Quick start · Motion Score · API · CLI · How it works · three.ws/motion


Every other way to get a new animation needs data you do not have: a motion capture stage, a licensed clip library, or a diffusion model trained on one. This package needs a sentence.

import { motionFromText } from '@three-ws/motion';

const { clip } = motionFromText('wave hello twice, excitedly');
// clip is a three.js AnimationClip.toJSON() document, ready to play

It works because a human body is a solved kinematics problem. Give a solver the places a body should be at a few moments in time, plus how it should get there, and it can produce the joint rotations for every frame between, with the feet on the floor, the weight over them, and the shoulders doing what shoulders do. The hard part was never the math, it was the authoring format, and that is what a Motion Score is: an animation written in anatomy and timing rather than in quaternions, which means a language model can write one and a person can read the diff.

Why

Install

npm install @three-ws/motion

Quick start

From a prompt

import { motionFromText } from '@three-ws/motion';
import { AnimationClip, AnimationMixer } from 'three';

const { clip, score, matched } = motionFromText('sit down heavily');

const mixer = new AnimationMixer(avatar);
mixer.clipAction(AnimationClip.parse(clip)).play();

motionFromText uses the built-in lane, which recognizes about twenty actions with their modifiers (side, direction, manner, repetition) and needs no network and no API key. It throws code: 'unrecognized_motion' for anything outside that vocabulary, which is the signal to hand the prompt to a model instead.

From a model

Give any tool-calling model scoreSchema() and let it author the score. Every enum in the schema is generated from the same vocabulary the solver reads, so a model cannot invent a word the compiler will reject.

import { compileScore, scoreSchema } from '@three-ws/motion';

const score = await yourModel.json({
  system: 'Write a Motion Score for the described movement.',
  user: 'She looks up from her desk, considers it, then shakes her head slowly.',
  schema: scoreSchema(),
});

const { clip, warnings } = compileScore(score);

By hand

import { compileScore } from '@three-ws/motion';

const { clip } = compileScore({
  name: 'thinking it over',
  beats: [
    { label: 'still', posture: 'easy', hold: 0.4 },
    {
      label: 'hand to chin',
      posture: 'easy',
      arms: { right: { at: 'chin', palm: 'in', hand: 'loose', elbow: 'down' } },
      gaze: 'aside',
      face: 'focused',
      in: 0.5,
      hold: 1.2,
    },
    { label: 'decides', posture: 'proud', gaze: 'forward', face: 'smile', in: 0.45, hold: 0.5 },
  ],
});

The Motion Score

A score is a list of beats. A beat is where the body is at one instant, how long it takes to get there (in), and how long it stays (hold). Nothing in it is a bone name.

{
  "name": "greeting",
  "loop": false,
  "effort": "playful",
  "beats": [
    { "posture": "easy", "hold": 0.2 },
    {
      "label": "hand up",
      "posture": "easy",
      "arms": { "right": { "at": "overhead", "up": -0.08, "palm": "forward", "hand": "open", "elbow": "out" } },
      "gaze": "forward",
      "face": "smile",
      "in": 0.36,
      "hold": 0.1
    },
    { "label": "lower", "arms": { "right": { "at": "side", "hand": "relaxed" } }, "in": 0.5, "hold": 0.2 }
  ]
}
Field What it says
posture a whole-body shape (stand, crouch, sit, lunge, bow, tiptoe, …) that seeds the rest
root hip height as a fraction of standing, plus forward / side travel, rise, and turn
torso lean, twist, sideBend, in degrees, distributed along the spine
arms per side: an anchor (chin, sternum, overhead, thigh, front, …), offsets, palm, hand
legs a stance, or per-foot placement with plant (flat, toe, heel, lift)
gaze a named look, or explicit yaw / pitch
face a named expression, mapped to ARKit blendshape weights
effort how it is performed: weight, time, flow, or a preset like sharp / weary / sustained

Effort is not decoration. It picks the easing curve, scales the travel time, and sets how far a limb overshoots before it settles, which is what makes the same three shapes read as a wave, a swat, or a salute.

The full field reference is specs/MOTION_SCORE.md.

API

Export What it does
motionFromText(prompt, opts) prompt to { clip, score, matched, warnings }, no model needed
compileScore(score, opts) score to { clip, score, warnings }
validateScore(input) { ok, score, error } with the exact path that failed
normalizeScore(input) the same, but throws
scoreSchema() JSON Schema for a tool call, generated from the live vocabulary
motionCapabilities() schema, limits, and known actions in one object
composeScore(prompt, opts) the model-free lane on its own, returning a raw score
describeScore(score) one readable line: duration, beats, and what happens
solveBeat(beat) one beat to a solved Pose
buildClip(keys, opts) solved poses to a clip document

compileScore options: idle (breathing and micro-sway, default on), rootMotion (the hip translation track, default on, turn off for a clip that must play in place), fingers (default on).

The kinematics layer is exported too, for callers building an editor or a different authoring format:

import { restPose, solveArm, solveLeg, solveSpine, anchorPoint, centreOfMass } from '@three-ws/motion';

const pose = restPose();
solveArm(pose, 'Right', { wrist: anchorPoint(pose, 'chin', { side: 'Right' }), palm: [0, 0, 1] });

Or import it on its own, with none of the score machinery:

import { Pose, solveTwoBone, BODY_FORWARD } from '@three-ws/motion/rig';

CLI

npx @three-ws/motion "wave hello twice, excitedly" -o wave.json
npx @three-ws/motion --score sit.json --no-idle --no-root -o sit.json
npx @three-ws/motion --schema      # the score schema, for a tool call
npx @three-ws/motion --actions     # what the model-free lane knows

How it works

Each beat is solved in an order that matters:

root      where the hips are, which everything below hangs from
turn      the body's heading, so limbs solve in the turned frame
torso     lean, twist, side-bend, shared along the spine
legs      ankles placed in WORLD space, so dropping the hips bends the knees
balance   the hips nudged until the centre of mass sits over the feet
arms      wrists placed against anchors that ride the posed chest
hands     shape, after the wrist is where it belongs
gaze      last, so a look is relative to the chest it ended up on

Reverse any two and the result is subtly wrong in a way that is impossible to fix downstream. Solve the arms before the torso and every gesture sits where the chest used to be. Solve the legs before the root and a crouch drives the feet through the floor.

Four details do most of the work:

The reference skeleton is measured from a real rig's bind pose rather than assumed: bone lengths, segment directions, the body's own forward axis, and the hand frame are all read from the data, so re-measuring a different rig changes the numbers and not the code.

Playing the clip on a different rig

The output uses canonical bone names (Hips, Spine2, LeftForeArm, RightUpLeg, …). To play it on a rig with different names, run it through a retargeter: three.ws ships one that maps Mixamo, Avaturn, Unreal, VRM/VRoid, Daz, MakeHuman and Blender conventions onto the same canonical set.

<script type="module" src="https://three.ws/agent-3d.js"></script>
<agent-3d src="agent://base/42" clip="https://example.com/wave.json"></agent-3d>

Related

License

Apache-2.0


Part of three.ws, the open-source platform for 3D AI agents. This repository is exported from the monorepo, which is the source of truth: open issues here, and send larger changes there.