// A capsule, built as a Shade `Geometry`.
//
// meep 3 ships seven primitive generators (box, plane, polyhedron, octahedron,
// torus, cylinder, torus knot) and a capsule is not one of them, so the demos
// that draw capsule-shaped bodies carry this local builder. Same convention as
// the physics `CapsuleShape3D` it usually accompanies: `radius` is the cap
// radius, `height` is the length of the cylindrical middle (so the total tip to
// tip length is `height + 2 * radius`), and the axis is Y.
import { Attribute } from "@woosh/meep-engine/src/shade/renderer/geometry/Attribute.js";
import { Geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/Geometry.js";
import { StandardAttributes } from "@woosh/meep-engine/src/shade/renderer/geometry/StandardAttributes.js";
/**
* @param {number} [radius] cap radius
* @param {number} [height] length of the cylindrical middle section
* @param {number} [capSegments] rings per hemisphere
* @param {number} [radialSegments] segments around the axis
* @returns {Geometry}
*/
export function make_capsule_geometry(radius = 0.5, height = 1, capSegments = 8, radialSegments = 16) {
const positions = [];
const normals = [];
const uvs = [];
const indices = [];
const halfHeight = height / 2;
// Rings from the top pole to the bottom pole. The two hemispheres are swept
// by polar angle; the middle is a single pair of rings offset along Y, so
// the seam between cap and cylinder shares its vertices' normals.
const rings = [];
for (let i = 0; i <= capSegments; i++) {
const v = (i / capSegments) * (Math.PI / 2); // 0 at the pole, PI/2 at the equator
rings.push({ y: halfHeight + Math.cos(v) * radius, r: Math.sin(v) * radius, ny: Math.cos(v), offset: halfHeight });
}
for (let i = 0; i <= capSegments; i++) {
const v = (Math.PI / 2) + (i / capSegments) * (Math.PI / 2);
rings.push({ y: -halfHeight + Math.cos(v) * radius, r: Math.sin(v) * radius, ny: Math.cos(v), offset: -halfHeight });
}
const totalLength = height + 2 * radius;
for (let ri = 0; ri < rings.length; ri++) {
const ring = rings[ri];
for (let s = 0; s <= radialSegments; s++) {
const u = s / radialSegments;
const theta = u * Math.PI * 2;
const cos = Math.cos(theta);
const sin = Math.sin(theta);
positions.push(ring.r * cos, ring.y, ring.r * sin);
// The normal of a capsule is the normal of the sphere its point
// belongs to, which is the point taken relative to that cap's centre.
const nx = ring.r * cos;
const nz = ring.r * sin;
const ny = ring.ny * radius;
const len = Math.hypot(nx, ny, nz) || 1;
normals.push(nx / len, ny / len, nz / len);
uvs.push(u, (ring.y + totalLength / 2) / totalLength);
}
}
const perRing = radialSegments + 1;
for (let ri = 0; ri < rings.length - 1; ri++) {
for (let s = 0; s < radialSegments; s++) {
const a = ri * perRing + s;
const b = a + 1;
const c = a + perRing;
const d = c + 1;
// Counter-clockwise seen from outside, matching every Shade
// primitive: the winding order is what the renderer uses to tell a
// front face from a back one, and getting it backwards leaves the
// capsule lit as if it were inside out.
indices.push(a, b, c);
indices.push(b, d, c);
}
}
const geometry = new Geometry();
geometry.name = "Capsule";
geometry.index = Attribute.from(new Uint32Array(indices), 1, StandardAttributes.Index);
geometry.addAttribute(Attribute.from(new Float32Array(positions), 3, StandardAttributes.Position));
geometry.addAttribute(Attribute.from(new Float32Array(normals), 3, StandardAttributes.Normal));
geometry.addAttribute(Attribute.from(new Float32Array(uvs), 2, StandardAttributes.TextureCoordinates0));
return geometry;
}
// 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
// `Transform64`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 { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { Light } from "@woosh/meep-engine/src/engine/graphics/ecs/light/Light.js";
import { LightType } from "@woosh/meep-engine/src/engine/graphics/ecs/light/LightType.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { t64_announce_change } from "@woosh/meep-engine/src/engine/ecs/transform/t64_announce_change.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
import { make_box_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_box_geometry.js";
import { make_cylinder_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_cylinder_geometry.js";
import { make_octahedron_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_octahedron_geometry.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { StandardAttributes } from "@woosh/meep-engine/src/shade/renderer/geometry/StandardAttributes.js";
import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { Color } from "@woosh/meep-engine/src/core/color/Color.js";
// Shade ships seven primitive generators and a capsule is not one of them, so
// the bones carry a local builder (src/lib/make_capsule_geometry.js).
import { make_capsule_geometry } from "./lib/make_capsule_geometry.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 = "#9aa6b2"; // brushed steel
const TARGET_COLOR = "#4ef0a8"; // Meep brand green — the thing being chased
// ─── §2 Engine bootstrap ────────────────────────────────────────────────────
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
// ShadedGeometrySystem draws every entity carrying a ShadedGeometry. It
// takes the graphics facade and the one Shade scene the harness renders,
// which `EngineHarness.shadeScene` hands out (one per engine).
config.addSystem(new ShadedGeometrySystem(engine.graphics, EngineHarness.shadeScene(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,
});
// Exposure and key light, the same in every demo on this site. Shade's automatic
// exposure is off: these scenes are lit to be looked at, not adapted to, and an
// adapting frame makes two screenshots of the same demo disagree. That leaves
// the light doing the work - and a directional light's intensity is read as lux,
// where the harness's default of 0.9 is a value from the three.js era that only
// ever looked lit because auto-exposure opened up to meet it.
engine.graphics.renderer.feature_automatic_exposure_enabled = false;
engine.entityManager.dataset.traverseEntities([Light], (light) => {
if (light.type.getValue() === LightType.DIRECTION) {
light.intensity.set(6);
}
});
const ecd = engine.entityManager.dataset;
// Shade's PBR material. Hex colours here are sRGB (what a designer types) and
// the renderer works in linear light, so decode once.
function standardMaterial(hexColor, roughness, metalness) {
const material = new StandardShadeMaterial();
material.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse(hexColor)));
material.roughness_factor = roughness;
material.metallic_factor = metalness;
return material;
}
// ─── §3 Decor — floor + pedestal ────────────────────────────────────────────
function decor(geometry, material, x, y, z) {
const t = new Transform64();
t.setTranslation(x, y, z);
new Entity().add(t).add(ShadedGeometry.from(geometry, material)).build(ecd);
}
decor(
meshlet_geometry_build_from_geometry(make_box_geometry(30, 0.5, 22)),
standardMaterial("#161c24", 1, 0),
0, -0.25, 0,
);
decor(
meshlet_geometry_build_from_geometry(make_cylinder_geometry(0.6, 0.9, BASE.y, 24)),
standardMaterial("#2a313b", 0.9, 0.1),
BASE.x, BASE.y / 2, BASE.z,
);
// ─── §4 The arm ─────────────────────────────────────────────────────────────
//
// A chain for `fabrik_solve` is just two arrays: `joints` (Transform64 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 = [];
// One entity per joint, in the same order: `fabrik_solve` writes the transforms
// in place and a Transform64 has no signals, so §7 announces each of them.
const jointEntities = [];
for (let i = 0; i <= BONES; i++) {
const t = new Transform64();
t.setTranslation(BASE.x, BASE.y + i * BONE_LENGTH, BASE.z);
joints.push(t);
if (i < BONES) lengths.push(BONE_LENGTH);
}
const armMaterial = standardMaterial(ARM_COLOR, 0.4, 0.7);
// Shade's `Geometry` has no `translate` — it is a plain attribute container —
// so shifting a shape off the origin means walking its position data. Bounds
// are computed lazily on first use, so doing this before the meshlet build is
// all it takes.
function translateY(geometry, dy) {
const position = geometry.getAttribute(StandardAttributes.Position);
const data = position.data;
for (let i = 1; i < data.length; i += 3) {
data[i] += dy;
}
return geometry;
}
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 = meshlet_geometry_build_from_geometry(
translateY(make_capsule_geometry(r, BONE_LENGTH - 2 * r, 6, 12), BONE_LENGTH / 2),
);
jointEntities.push(new Entity()
.add(joints[i]) // ← the solver's own Transform64
.add(ShadedGeometry.from(geometry, armMaterial))
.build(ecd));
}
// A small claw-tip on the last joint so the end of the chain reads clearly.
// Shade has no UV sphere; a subdivided octahedron is the round primitive.
jointEntities.push(new Entity()
.add(joints[BONES])
.add(ShadedGeometry.from(
meshlet_geometry_build_from_geometry(make_octahedron_geometry(RADIUS_TIP * 1.6, 12)),
standardMaterial(TARGET_COLOR, 0.3, 0.4),
))
.build(ecd));
// ─── §5 The target marker ───────────────────────────────────────────────────
const targetTransform = new Transform64();
targetTransform.setTranslation(2.5, 3, 0);
// Emissive is a colour multiplied into the emissive texture (white when there
// is none), so the factor alone makes the marker glow — and, being additive
// radiance, it also survives the auto-exposure that settles over the scene.
const targetMaterial = standardMaterial(TARGET_COLOR, 0.3, 0);
targetMaterial.emissive_factor.copy(Color.from_sRGB_to_linear(Color.parse(TARGET_COLOR)));
targetMaterial.emissive_factor.multiplyScalar(0.7);
const targetEntity = new Entity()
.add(targetTransform)
.add(ShadedGeometry.from(
meshlet_geometry_build_from_geometry(make_octahedron_geometry(0.14, 12)),
targetMaterial,
))
.build(ecd);
// Where the marker is, as a Vector3: `fabrik_solve` reads `.x/.y/.z` off it, and
// §7 copies it into the marker's Transform64 once it has eased toward the goal.
const target = new Vector3(
targetTransform.translation_x, targetTransform.translation_y, targetTransform.translation_z
);
// ─── §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));
targetTransform.setTranslation(target.x, target.y, target.z);
t64_announce_change(ecd, targetEntity);
// ── 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 Transform64 are updated in place, matrices included - but a
// Transform64 announces nothing, so each joint entity says so here and the
// bone meshes built on those transforms in §4 follow.
fabrik_solve(joints, lengths, BASE, target);
for (let i = 0; i < jointEntities.length; i++) {
t64_announce_change(ecd, jointEntities[i]);
}
// 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": "3.41.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: `Transform64[]` 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 `Transform64`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({
// Vite's dependency pre-bundling rewrites the engine's own asset imports and
// loses their URLs, so a dev server ends up serving ".../deps/undefined" for
// the renderer's data files. The engine is source-only ESM and needs no
// pre-bundling - but its CommonJS dependencies do, or the import of them from inside
// the excluded engine finds no default export.
optimizeDeps: {
exclude: ["@woosh/meep-engine"],
include: ["dat.gui", "opentype.js"],
},
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",
},
});