// FABRIK arm — a Meep example for the FABRIK inverse-kinematics solver.
//
// A nine-bone arm stands on a pedestal. Every frame, ONE call does all the
// work of this example:
//
// fabrik_solve(joints, lengths, origin, target);
//
// FABRIK (Forward And Backward Reaching Inverse Kinematics) bends the whole
// chain so the tip reaches the target while the root stays pinned at `origin`.
// The target glides along a slow never-repeating weave on its own; move your
// cursor and the target follows it instead. Leave the cursor alone for a
// couple of seconds and the auto path takes over again.
//
// The trick that keeps this file short: `fabrik_solve` works directly on
// `Transform`s — the same component the renderer reads. Each bone entity is
// built around one of the solver's joint transforms, so when the solver
// writes new positions and rotations the meshes simply follow. No skeleton,
// no scene graph, no copying.
//
// Sections:
// §1 Tuning constants
// §2 Engine bootstrap
// §3 Decor — floor + pedestal
// §4 The arm joint transforms, bone lengths, capsule meshes
// §5 The target marker
// §6 Pointer → target cursor ray ∩ a camera-facing plane
// §7 Per-frame animate the target, solve, HUD
import * as THREE from "three";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
import { CapsuleGeometry } from "@woosh/meep-engine/src/engine/graphics/geometry/CapsuleGeometry.js";
import { fabrik_solve } from "@woosh/meep-engine/src/engine/physics/inverse_kinematics/fabrik/fabrik_solve.js";
import { plane3_compute_ray_intersection } from "@woosh/meep-engine/src/core/geom/3d/plane/plane3_compute_ray_intersection.js";
import Vector2 from "@woosh/meep-engine/src/core/geom/Vector2.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
// ─── §1 Tuning constants ────────────────────────────────────────────────────
const BONES = 9;
const BONE_LENGTH = 0.7;
const REACH = BONES * BONE_LENGTH; // 6.3 m, fully stretched
const BASE = new Vector3(0, 1.0, 0); // joint 0 is pinned here (pedestal top)
// The arm tapers: fat at the root, slim at the tip.
const RADIUS_ROOT = 0.17;
const RADIUS_TIP = 0.06;
const POINTER_IDLE = 2.5; // seconds without cursor movement → back to auto
const SMOOTHING = 8; // target chase rate, 1/s — higher = snappier
const ARM_COLOR = 0x9aa6b2; // brushed steel
const TARGET_COLOR = 0x4ef0a8; // Meep brand green — the thing being chased
// ─── §2 Engine bootstrap ────────────────────────────────────────────────────
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
config.addSystem(new ShadedGeometrySystem(engine));
},
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: true,
shadowmapResolution: 2048,
focus: new Vector3(0, 2.6, 0),
distance: 13,
pitch: 0.35,
yaw: 0.5,
cameraFarDistance: 200,
showFps: false,
});
const ecd = engine.entityManager.dataset;
// ─── §3 Decor — floor + pedestal ────────────────────────────────────────────
function decor(geometry, material, x, y, z) {
const t = new Transform();
t.position.set(x, y, z);
new Entity().add(t).add(ShadedGeometry.from(geometry, material)).build(ecd);
}
decor(
new THREE.BoxGeometry(30, 0.5, 22),
new THREE.MeshStandardMaterial({ color: 0x161c24, roughness: 1 }),
0, -0.25, 0,
);
decor(
new THREE.CylinderGeometry(0.6, 0.9, BASE.y, 24),
new THREE.MeshStandardMaterial({ color: 0x2a313b, roughness: 0.9, metalness: 0.1 }),
BASE.x, BASE.y / 2, BASE.z,
);
// ─── §4 The arm ─────────────────────────────────────────────────────────────
//
// A chain for `fabrik_solve` is just two arrays: `joints` (Transform per
// joint, root first — BONES + 1 of them) and `lengths` (distance from joint i
// to joint i+1). We start the chain pointing straight up with identity
// rotations, and hang each bone's capsule off its joint, offset +Y by half a
// bone so the mesh spans from this joint to the next. Because the rest pose
// points up and the geometry points up, the solver's rotation updates keep
// every capsule aimed at its successor from then on.
const joints = [];
const lengths = [];
for (let i = 0; i <= BONES; i++) {
const t = new Transform();
t.position.set(BASE.x, BASE.y + i * BONE_LENGTH, BASE.z);
joints.push(t);
if (i < BONES) lengths.push(BONE_LENGTH);
}
const armMaterial = new THREE.MeshStandardMaterial({ color: ARM_COLOR, roughness: 0.4, metalness: 0.7 });
for (let i = 0; i < BONES; i++) {
// Taper from root to tip.
const r = RADIUS_ROOT + (RADIUS_TIP - RADIUS_ROOT) * (i / (BONES - 1));
// Capsule total height = cylinder + two hemispherical caps = BONE_LENGTH,
// shifted so it spans [0, BONE_LENGTH] along the joint's local +Y.
const geometry = new CapsuleGeometry(r, BONE_LENGTH - 2 * r, 6, 12);
geometry.translate(0, BONE_LENGTH / 2, 0);
new Entity()
.add(joints[i]) // ← the solver's own Transform
.add(ShadedGeometry.from(geometry, armMaterial))
.build(ecd);
}
// A small claw-tip on the last joint so the end of the chain reads clearly.
new Entity()
.add(joints[BONES])
.add(ShadedGeometry.from(
new THREE.SphereGeometry(RADIUS_TIP * 1.6, 16, 12),
new THREE.MeshStandardMaterial({ color: TARGET_COLOR, roughness: 0.3, metalness: 0.4 }),
))
.build(ecd);
// ─── §5 The target marker ───────────────────────────────────────────────────
const targetTransform = new Transform();
targetTransform.position.set(2.5, 3, 0);
new Entity()
.add(targetTransform)
.add(ShadedGeometry.from(
new THREE.SphereGeometry(0.14, 20, 14),
new THREE.MeshStandardMaterial({
color: TARGET_COLOR,
emissive: TARGET_COLOR,
emissiveIntensity: 0.7,
roughness: 0.3,
}),
))
.build(ecd);
const target = targetTransform.position; // fabrik_solve reads this Vector3
// ─── §6 Pointer → target ────────────────────────────────────────────────────
//
// The cursor is a 2D point; the target lives in 3D. We bridge the gap by
// intersecting the cursor's world ray with a viewport-aligned plane through
// the arm's base — normal facing the camera, so the target tracks the cursor
// 1:1 on screen no matter how the camera is orbited. Drags belong to the
// orbital camera, so we ignore moves while one is in progress.
const goal = new Vector3(); // where the target wants to be
goal.copy(target);
let pointerActive = false; // cursor mode vs auto mode
let lastPointerMove = -Infinity;
let orbiting = false;
let time = 0;
const ndc = new Vector2();
const raySource = new Vector3();
const rayDirection = new Vector3();
const planeNormal = new Vector3();
const cameraPosition = new Vector3();
const pointer = engine.devices.pointer;
pointer.on.dragStart.add(() => { orbiting = true; });
pointer.on.dragEnd.add(() => { orbiting = false; });
pointer.on.move.add((position) => {
if (orbiting) return;
// Pointer position (viewport pixels) → normalised clip coords → world ray.
engine.graphics.normalizeViewportPoint(position, ndc);
engine.graphics.viewportProjectionRay(ndc.x, ndc.y, raySource, rayDirection);
// The plane normal is the camera's forward — the ray through the centre of
// the viewport. Re-derived every move, so orbiting re-orients the plane.
engine.graphics.viewportProjectionRay(0, 0, cameraPosition, planeNormal);
// Cursor ray ∩ the camera-facing plane through BASE. The plane is
// dot(n, p) + d = 0, so d = -dot(n, BASE). A grazing or backwards ray
// misses and leaves the goal where it was.
const hit = plane3_compute_ray_intersection(
goal,
raySource.x, raySource.y, raySource.z,
rayDirection.x, rayDirection.y, rayDirection.z,
planeNormal.x, planeNormal.y, planeNormal.z,
-planeNormal.dot(BASE),
);
if (!hit) return;
goal.setY(Math.max(0.15, goal.y)); // keep it above the floor
// Out-of-reach goals are fine — FABRIK stretches the chain straight toward
// them instead of failing, so the arm "points" at a cursor it can't touch.
pointerActive = true;
lastPointerMove = time;
});
// ─── §7 Per-frame: animate the target, solve, HUD ───────────────────────────
document.getElementById("bones").textContent = String(BONES);
const modeEl = document.getElementById("mode");
const fpsEl = document.getElementById("fps");
let fpsWindow = 0, fpsFrames = 0;
let lastFrameMs = performance.now();
engine.graphics.on.postRender.add(() => {
const nowMs = performance.now();
const dt = Math.min((nowMs - lastFrameMs) / 1000, 0.1);
lastFrameMs = nowMs;
time += dt;
// Cursor gone quiet? Hand the target back to the auto path.
if (pointerActive && time - lastPointerMove > POINTER_IDLE) {
pointerActive = false;
}
// Auto mode: three sines at incommensurate frequencies — a slow weave
// through the arm's reach that never quite repeats.
if (!pointerActive) {
goal.set(
2.9 * Math.sin(0.43 * time),
2.4 + 1.7 * Math.sin(0.61 * time + 1.7),
2.2 * Math.sin(0.89 * time),
);
}
// The marker eases toward the goal rather than teleporting, which also
// smooths the hand-off between cursor and auto modes.
target.lerp(goal, 1 - Math.exp(-SMOOTHING * dt));
// ── The actual IK ── joints + lengths in, bent chain out. Root pinned at
// BASE, tip on (or pointed at) the target. Positions AND rotations of
// every joint Transform are updated in place; the bone meshes built on
// those transforms in §4 follow automatically.
fabrik_solve(joints, lengths, BASE, target);
// HUD
modeEl.textContent = pointerActive ? "cursor" : "auto";
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
fpsWindow = 0;
fpsFrames = 0;
}
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FABRIK arm · Meep</title>
<meta name="robots" content="noindex">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
overflow: hidden;
background: #07090c;
color: #e6edf3;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.panel {
position: fixed;
z-index: 100;
background: rgba(7, 9, 12, 0.72);
border: 1px solid #1f2731;
border-radius: 10px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 12px 32px rgba(0,0,0,0.4);
}
.hud {
top: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.82rem;
line-height: 1.7;
color: #9aa5b1;
min-width: 180px;
}
.hud .label {
color: #6b7785;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.65rem;
margin-right: 0.5rem;
}
.hud .value { color: #4ef0a8; }
.legend {
bottom: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-size: 0.82rem;
line-height: 1.55;
max-width: 420px;
color: #9aa5b1;
}
.legend strong { color: #e6edf3; }
</style>
</head>
<body>
<div class="panel hud">
<div><span class="label">fps</span><span class="value" id="fps">--</span></div>
<div><span class="label">bones</span><span class="value" id="bones">--</span></div>
<div><span class="label">target</span><span class="value" id="mode">auto</span></div>
</div>
<div class="panel legend">
A nine-bone <strong>arm</strong> chases the glowing target — one
<code>fabrik_solve</code> call per frame, no skeleton, no scene graph.
<strong>Move your cursor</strong> and the target follows it; hold still for a
couple of seconds and it wanders off on its own again. Drag to orbit ·
scroll to zoom.
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "FABRIK arm",
"description": "A nine-bone arm chases a glowing target with one fabrik_solve call per frame. The target weaves on its own until your cursor takes over.",
"category": "Animation",
"status": "live",
"order": 1,
"tags": ["animation", "ik", "fabrik", "procedural", "input"],
"sourceHint": "examples-src/fabrik-arm/",
"demoUrl": "/examples/fabrik-arm/demo.html",
"defaultFile": "src/main.js"
}
{
"name": "@meep-examples/fabrik-arm",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A nine-bone arm driven by the FABRIK inverse-kinematics solver chases a moving target — or your cursor.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "2.158.0",
"three": "0.136.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# FABRIK arm
A nine-bone arm anchored to a pedestal chases a glowing target — one
`fabrik_solve` call per frame does all the work. The target weaves along a slow
never-repeating path on its own; move your cursor and the target follows it
instead (projected onto the arm's working plane). Hold still for a couple of
seconds and the auto path resumes.
What it demonstrates:
- `fabrik_solve` from `engine/physics/inverse_kinematics/fabrik/` — FABRIK IK
for chains of arbitrary length: `Transform[]` joints + bone lengths in, bent
chain out, root pinned at an origin.
- Solver output driving renderable entities directly: each bone entity is
built around one of the solver's joint `Transform`s, so meshes follow the
solve with no skeleton or copying.
- Cursor picking via `normalizeViewportPoint` + `viewportProjectionRay`,
intersected with a fixed plane.
## Run
```sh
npm install
npm run dev
```
## Build
`npm run build` emits the static demo into `public/examples/fabrik-arm/`.
import { defineConfig } from "vite";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import strip from "@rollup/plugin-strip";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
{
// Copy the committed source thumbnail into the generated gallery folder.
// public/examples/<id>/ is build output (gitignored); thumbnail.png is
// kept in source here and copied through on every build so the gallery
// (src/data/examples.ts) can resolve it.
name: "copy-thumbnail",
apply: "build",
closeBundle() {
const thumb = resolve(__dirname, "thumbnail.png");
const dst = resolve(__dirname, "../../public/examples/fabrik-arm");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/fabrik-arm"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});