RIGGED & ANIMATED
3D CAPYBARA

Build a fully rigged, skinned capybara from scratch in Three.js — a 19-bone skeleton, six looping animations, and one-click GLB export. No modeling software, all code.

Rigged, animated 3D capybara built with Three.js SkinnedMesh showing its 19-bone skeleton

LIVE DEMO

The capybara grazing behind this page is the live demo — a single SkinnedMesh driven by a 19-bone skeleton. Use the controls below to switch animations, toggle the skeleton, or let it roam. Click and drag to orbit. On mobile, just swipe to scroll the page.

loading…

WATCH IT IN ACTION

The rigged capybara running through its six animation clips — idle, walk, eat, sit, look around, and the happy yuzu bob.

Introduction

Most character tutorials start by loading a model somebody else made in Blender. This one is different: the whole capybara — body, head, legs, ears, eyes, and the little yuzu orange on its head — is built from Three.js primitives, then rigged, skinned, and animated entirely in code.

You will assemble a scaffold of spheres and rounded boxes, bake each part into model space, write per-vertex skinIndex and skinWeight attributes, build a 19-bone THREE.Skeleton, and bind it all to one SkinnedMesh. Then you generate six animation clips — idle, walk, eat, sit, look around, and a happy yuzu bob — from tiny pose functions sampled into keyframe tracks. Finally, a button exports the whole thing as a rigged .glb you can drop into any engine. Copy it, paste it, and it runs.

Frequently Asked Questions

What is a SkinnedMesh in Three.js?

A SkinnedMesh is a mesh that deforms with a skeleton of bones. Each vertex stores skinIndex and skinWeight attributes that say which bones move it and by how much. When a bone rotates, the vertices bound to it follow, so a single mesh can bend, walk, and chew without being split into separate rigid pieces.

How do you rig a 3D model built from primitives in Three.js?

Build the shape from primitives, bake every part into model space, then write skinIndex and skinWeight attributes per vertex — either a single bone for rigid parts or a weighted blend based on the vertex position for smooth joints. Create THREE.Bone objects in a hierarchy, build a THREE.Skeleton, merge the geometry, and bind it to a SkinnedMesh.

How do you create animation clips in code without an external file?

Sample small pose functions into keyframe tracks. For each bone you build QuaternionKeyframeTrack, VectorKeyframeTrack for position, or scale tracks over a list of times, wrap them in a THREE.AnimationClip, and play them through an AnimationMixer. The capybara's walk, eat, sit, idle, look and happy clips are all generated this way.

How do you export a rigged model to GLB from Three.js?

Use GLTFExporter from the Three.js addons. Call exporter.parse(object, onDone, onError, { binary: true, animations: clips }). Exporting from the rest pose at the origin produces a clean .glb that includes the skinned mesh, the skeleton, and every animation clip, ready to drop into any engine or viewer.

Is this rigged capybara Three.js tutorial free?

Yes. This is a completely free tutorial. The full source code is on the page, you can copy it or launch it in a new tab, and the capybara grazing behind this page is the live demo. No sign-in or membership is required.

How It Works

  1. 1Build a scaffold of primitives. Deformed spheres and RoundedBoxGeometry become the barrel body, blunt head, legs, ears, eyes and the yuzu. Every part is tagged with how it should be skinned.
  2. 2Place anchors for joints. Empty Object3D markers record where each bone should sit (hips, spine, neck, head, jaw, four legs, ears, eyes, yuzu).
  3. 3Create the skeleton. Build THREE.Bone objects in a parent/child hierarchy, positioned at the anchors with identity rest rotations.
  4. 4Skin every vertex. Bake parts to model space, then write skinIndex/skinWeight — rigid for simple parts, a smooth weighted blend for the neck, legs and body.
  5. 5Merge and bind. Merge geometry per material into one SkinnedMesh, build the Skeleton, and call body.bind(skeleton).
  6. 6Generate animation clips. Sample small pose functions into keyframe tracks for idle, walk, eat, sit, look-around, and the happy yuzu bob. Play them through an AnimationMixer with crossfades.
  7. 7Export to GLB. GLTFExporter writes the rest-pose rig and all six clips into a single downloadable .glb.

The Full Code

Paste this into a file named index.html and open it in any modern browser. Everything is self-contained — Three.js loads from a CDN via the import map.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Capybara — rigged Three.js</title>
<style>
  html, body { margin: 0; height: 100%; overflow: hidden;
    background: linear-gradient(to bottom, #8fd0f5 0%, #c4e7fa 55%, #eaf7ff 100%); }
  canvas { display: block; }
  #caption { position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
    font: 600 15px/1.2 system-ui, -apple-system, "Segoe UI", sans-serif; color: #3b2a1a;
    background: rgba(255,255,255,0.7); padding: 8px 16px; border-radius: 999px;
    box-shadow: 0 2px 10px rgba(0,0,0,0.12); pointer-events: none; user-select: none; }
  #ui { position: fixed; top: 14px; left: 14px; width: 220px; padding: 12px;
    font: 13px/1.3 system-ui, -apple-system, "Segoe UI", sans-serif; color: #3b2a1a;
    background: rgba(255,255,255,0.78); border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.15); }
  #ui h3 { margin: 0 0 8px; font-size: 14px; }
  #clips { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 10px; }
  #ui button { font: inherit; padding: 6px 4px; border: 1px solid #b98a5a; border-radius: 8px;
    background: #fff8ef; color: #3b2a1a; cursor: pointer; }
  #ui button.active { background: #9a6233; color: #fff; border-color: #6b4223; }
  #ui label { display: flex; align-items: center; gap: 6px; margin: 6px 0; }
  #ui input[type=range] { flex: 1; }
  #exportBtn { width: 100%; margin-top: 6px; }
</style>
<script type="importmap">
{ "imports": {
    "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
} }
</script>
</head>
<body>
<div id="ui">
  <h3>🐹 Capybara animations</h3>
  <div id="clips"></div>
  <label>Speed <input id="speed" type="range" min="0" max="2" step="0.05" value="1" /> <span id="speedVal">1.00×</span></label>
  <label><input id="skel" type="checkbox" /> Show skeleton</label>
  <label><input id="roam" type="checkbox" /> Walk around (roam)</label>
  <button id="exportBtn">Download rigged GLB</button>
</div>
<div id="caption">Capybara — drag to rotate, scroll to zoom</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';

const SHOW_YUZU = true;   // the classic capybara-with-an-orange-on-its-head meme

// ======================================================================
// Renderer / scene / camera / lights / ground
// ======================================================================
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(4.2, 2.6, 5.2);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.target.set(0, 1.0, 0.2);
controls.minDistance = 2.5;
controls.maxDistance = 16;
controls.maxPolarAngle = Math.PI * 0.49;
controls.update();

scene.add(new THREE.HemisphereLight(0xd6eeff, 0x8a9a5a, 1.3));
const sun = new THREE.DirectionalLight(0xfff1dc, 2.4);
sun.position.set(4, 8, 5);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -6, right: 6, top: 6, bottom: -6, near: 1, far: 25 });
sun.shadow.bias = -0.0005;
sun.shadow.radius = 4;
scene.add(sun);
const fill = new THREE.DirectionalLight(0xffe2c0, 0.5);
fill.position.set(-5, 3, -3);
scene.add(fill);

const ground = new THREE.Mesh(new THREE.CircleGeometry(12, 64),
  new THREE.MeshStandardMaterial({ color: 0x7cbf4f, roughness: 1 }));
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);

const rand = (a, b) => a + Math.random() * (b - a);
const tuftMat = new THREE.MeshStandardMaterial({ color: 0x5ea83a, roughness: 1 });
const bladeGeo = new THREE.ConeGeometry(0.035, 0.32, 4);
function addTuft(x, z, s = 1) {
  const tuft = new THREE.Group();
  for (let j = 0; j < 5; j++) {
    const b = new THREE.Mesh(bladeGeo, tuftMat);
    b.position.set(rand(-0.06, 0.06), 0.14, rand(-0.06, 0.06));
    b.rotation.set(rand(-0.4, 0.4), 0, rand(-0.4, 0.4));
    b.castShadow = true;
    tuft.add(b);
  }
  tuft.position.set(x, 0, z); tuft.scale.setScalar(s);
  scene.add(tuft);
}
for (let i = 0; i < 70; i++) { const r = rand(3.4, 9), t = rand(0, Math.PI * 2); addTuft(Math.cos(t) * r, Math.sin(t) * r, rand(0.7, 1.3)); }
addTuft(0.15, 2.05, 1.2); addTuft(-0.2, 2.2, 1.0);   // a snack right in front of the nose

// ======================================================================
// Materials
// ======================================================================
function furTexture() {
  const c = document.createElement('canvas'); c.width = c.height = 256;
  const g = c.getContext('2d');
  g.fillStyle = '#808080'; g.fillRect(0, 0, 256, 256);
  for (let i = 0; i < 4000; i++) {
    const v = 90 + Math.random() * 90 | 0;
    g.strokeStyle = `rgb(${v},${v},${v})`;
    const x = Math.random() * 256, y = Math.random() * 256;
    g.beginPath(); g.moveTo(x, y); g.lineTo(x + rand(-1.5, 1.5), y + rand(3, 7)); g.stroke();
  }
  const t = new THREE.CanvasTexture(c);
  t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(3, 3);
  return t;
}
const furBump = furTexture();
const furMat = (name, hex, extra = {}) => new THREE.MeshStandardMaterial({ name,
  color: hex, roughness: 0.92, metalness: 0, bumpMap: furBump, bumpScale: 0.3, ...extra });

const MAT = {
  fur:      furMat('fur', 0x9a6233),
  furHead:  furMat('fur_head', 0x9a6233, { bumpScale: 0.08 }),
  furLight: furMat('fur_light', 0xb98a5a),
  furDark:  furMat('fur_dark', 0x6b4223),
  feet:     furMat('feet', 0x3e2614),
  nose:     new THREE.MeshStandardMaterial({ name: 'nose', color: 0x3a2417, roughness: 0.5 }),
  black:    new THREE.MeshStandardMaterial({ name: 'eye', color: 0x120a06, roughness: 0.15, metalness: 0.1 }),
  white:    new THREE.MeshBasicMaterial({ name: 'eye_glint', color: 0xffffff }),
  peel:     new THREE.MeshStandardMaterial({ name: 'yuzu_peel', color: 0xffa21f, roughness: 0.55, bumpMap: furBump, bumpScale: 0.15 }),
  stem:     new THREE.MeshStandardMaterial({ name: 'yuzu_stem', color: 0x5b3a1a }),
  leaf:     new THREE.MeshStandardMaterial({ name: 'yuzu_leaf', color: 0x3f9b35, roughness: 0.6 }),
};

// ======================================================================
// 1) Build the capybara as a temporary "scaffold" of primitives. Every
//    part is tagged with how it should be skinned (rigid bone or a weight
//    function of its model-space position). Faces +Z, feet on y = 0.
// ======================================================================
const smooth = (a, b, x) => { const t = Math.min(Math.max((x - a) / (b - a), 0), 1); return t * t * (3 - 2 * t); };
const scaffold = new THREE.Group();
const sphere = new THREE.SphereGeometry(1, 48, 32);
const sphereLo = new THREE.SphereGeometry(1, 20, 14);

function part(geo, mat, parent, pos = [0, 0, 0], scale = [1, 1, 1], rot = [0, 0, 0], rig = 'root') {
  const m = new THREE.Mesh(geo, mat);
  m.position.set(...pos); m.scale.set(...scale); m.rotation.set(...rot);
  m.userData.rig = rig;
  parent.add(m);
  return m;
}
function anchor(parent, pos, name) {           // marks a joint location
  const o = new THREE.Object3D(); o.position.set(...pos); o.name = name; parent.add(o); return o;
}

// --- body: one deformed sphere -> chunky barrel, taller rump, flat belly
function barrelGeometry() {
  const g = new THREE.SphereGeometry(1, 72, 48);
  const p = g.attributes.position, v = new THREE.Vector3();
  for (let i = 0; i < p.count; i++) {
    v.fromBufferAttribute(p, i);
    let { x, y, z } = v;
    const sx = Math.sign(x) * Math.pow(Math.abs(x), 0.8);
    const sz = Math.sign(z) * Math.pow(Math.abs(z), 0.75);
    x = sx * 0.95 * (1 - 0.12 * smooth(0.2, 1, z));
    z = sz * 1.38;
    if (y > 0) y = y * (0.8 + 0.16 * smooth(0.6, -0.7, z / 1.38));
    else y = y * 0.66;
    p.setXYZ(i, x, y, z);
  }
  g.computeVertexNormals();
  return g;
}
// body skinning: hips at the back, spine at the front, a little neck near the shoulders
const bodyRig = (p) => {
  const s = smooth(-0.9, 0.1, p.z);
  const n = 0.7 * smooth(0.75, 1.2, p.z) * smooth(1.0, 1.5, p.y);
  return { hips: 1 - s, spine: s * (1 - n), neck: s * n };
};
part(barrelGeometry(), MAT.fur, scaffold, [0, 0.98, -0.15], [1, 1, 1], [0, 0, 0], bodyRig);
part(sphere, MAT.furLight, scaffold, [0, 0.7, -0.15], [0.76, 0.4, 1.15], [0, 0, 0], bodyRig);

// neck: bridges chest -> head and bends smoothly
part(sphere, MAT.fur, scaffold, [0, 1.33, 0.85], [0.52, 0.55, 0.58], [0, 0, 0], (p) => {
  const a = smooth(0.5, 0.85, p.z), b = smooth(0.95, 1.2, p.z);
  return { spine: 1 - a, neck: a * (1 - b), head: b };
});

// --- legs: upper + lower bones, smooth blend at the knee
const legGeo = new THREE.CapsuleGeometry(0.16, 0.32, 8, 16);
const LEGS = { FL: [0.46, 0.78], FR: [-0.46, 0.78], BL: [0.52, -0.95], BR: [-0.52, -0.95] };
for (const [id, [x, z]] of Object.entries(LEGS)) {
  const back = id[0] === 'B';
  const up = `leg_${id}_upper`, lo = `leg_${id}_lower`;
  part(legGeo, MAT.furDark, scaffold, [x, 0.38, z], back ? [1.3, 1, 1.45] : [1, 1, 1.05], [0, 0, 0],
       (p) => { const u = smooth(0.36, 0.52, p.y); return { [up]: u, [lo]: 1 - u }; });
  part(sphereLo, MAT.feet, scaffold, [x, 0.06, z + 0.08], [0.2, 0.07, 0.26], [0, 0, 0], lo);
  for (let k = -1; k <= 1; k++)
    part(sphereLo, MAT.feet, scaffold, [x + k * 0.085, 0.05, z + 0.3], [0.055, 0.045, 0.06], [0, 0, 0], lo);
  anchor(scaffold, [x, back ? 0.78 : 0.75, z], up);
  anchor(scaffold, [x, back ? 0.38 : 0.4, z], lo);
}

// --- head: big, blunt, boxy, narrowing to a flat squarish snout
function headGeometry() {
  const L = 1.4;
  const g = new RoundedBoxGeometry(0.82, 0.9, L, 8, 0.32);
  const p = g.attributes.position, v = new THREE.Vector3();
  for (let i = 0; i < p.count; i++) {
    v.fromBufferAttribute(p, i);
    const f = smooth(-0.2, L / 2, v.z);
    v.x *= 1 - 0.1 * f;
    if (v.y < 0) v.y *= 1 - 0.1 * f;
    v.x *= 1 + 0.12 * smooth(0, -0.42, v.y) * (1 - f);
    p.setXYZ(i, v.x, v.y, v.z);
  }
  g.computeVertexNormals();
  return g;
}
const headG = new THREE.Group();
headG.position.set(0, 1.5, 1.05);
headG.rotation.x = 0.3;
scaffold.add(headG);
anchor(headG, [0, 0, 0], 'head');
part(headGeometry(), MAT.furHead, headG, [0, 0, 0.35], [1, 1, 1], [0, 0, 0], 'head');
// jaw: lighter chin + mouth, hinged near the back of the chin
anchor(headG, [0, -0.24, 0.42], 'jaw');
part(new RoundedBoxGeometry(0.5, 0.2, 0.5, 5, 0.09), MAT.furLight, headG, [0, -0.31, 0.72], [1, 1, 1], [0, 0, 0], 'jaw');
part(new THREE.TorusGeometry(0.09, 0.012, 6, 20, Math.PI), MAT.nose, headG, [0, -0.11, 1.035], [1.3, 0.7, 1], [0, 0, Math.PI], 'jaw');
// nose, nostrils, split lip
part(sphere, MAT.nose, headG, [0, 0.12, 1.03], [0.21, 0.12, 0.045], [0, 0, 0], 'head');
part(sphereLo, MAT.black, headG, [ 0.09, 0.14, 1.068], [0.045, 0.022, 0.015], [0, 0, -0.5], 'head');
part(sphereLo, MAT.black, headG, [-0.09, 0.14, 1.068], [0.045, 0.022, 0.015], [0, 0,  0.5], 'head');
part(new THREE.BoxGeometry(0.022, 0.16, 0.02), MAT.nose, headG, [0, -0.04, 1.045], [1, 1, 1], [0, 0, 0], 'head');
// eyes: small, dark, high on the sides (own bones so they can blink)
const eyeGeo = new THREE.SphereGeometry(0.07, 24, 16);
for (const [side, s] of [['L', 1], ['R', -1]]) {
  const pos = [s * 0.385, 0.22, 0.18];
  anchor(headG, pos, `eye_${side}`);
  part(sphereLo, MAT.furDark, headG, [pos[0] - s * 0.025, pos[1], pos[2]], [0.05, 0.095, 0.105], [0, 0, 0], 'head');
  part(eyeGeo, MAT.black, headG, pos, [1, 1, 1], [0, 0, 0], `eye_${side}`);
  part(new THREE.SphereGeometry(0.018, 12, 8), MAT.white, headG, [pos[0] + s * 0.048, pos[1] + 0.03, pos[2] + 0.03], [1, 1, 1], [0, 0, 0], `eye_${side}`);
}
// ears: small and rounded at the top back of the head
for (const [side, s] of [['L', 1], ['R', -1]]) {
  const ear = new THREE.Group();
  ear.position.set(s * 0.29, 0.38, -0.2);
  ear.rotation.set(-0.3, 0, -s * 0.3);
  headG.add(ear);
  anchor(ear, [0, 0, 0], `ear_${side}`);
  part(sphereLo, MAT.furDark, ear, [0, 0.06, 0], [0.085, 0.09, 0.055], [0, 0, 0], `ear_${side}`);
  part(sphereLo, MAT.nose, ear, [0, 0.065, 0.032], [0.055, 0.062, 0.028], [0, 0, 0], `ear_${side}`);
}
// yuzu
const yuzuG = new THREE.Group();
yuzuG.position.set(0, 0.56, 0.05);
headG.add(yuzuG);
anchor(yuzuG, [0, -0.17, 0], 'yuzu');          // pivot where it sits on the head
if (SHOW_YUZU) {
  part(new THREE.SphereGeometry(0.2, 32, 24), MAT.peel, yuzuG, [0, 0, 0], [1, 0.88, 1], [0, 0, 0], 'yuzu');
  part(new THREE.CylinderGeometry(0.012, 0.016, 0.06, 8), MAT.stem, yuzuG, [0, 0.19, 0], [1, 1, 1], [0, 0, 0], 'yuzu');
  part(sphereLo, MAT.leaf, yuzuG, [0.08, 0.205, 0.02], [0.1, 0.012, 0.045], [0, 0.4, 0.25], 'yuzu');
}
anchor(scaffold, [0, 0, 0], 'root');
anchor(scaffold, [0, 1.0, -0.7], 'hips');
anchor(scaffold, [0, 1.05, 0.25], 'spine');
anchor(scaffold, [0, 1.3, 0.8], 'neck');
scaffold.updateMatrixWorld(true);

// ======================================================================
// 2) Skeleton: bones with identity rest rotation, positioned at anchors
// ======================================================================
const HIERARCHY = {
  root: null, hips: 'root', spine: 'hips', neck: 'spine', head: 'neck',
  jaw: 'head', ear_L: 'head', ear_R: 'head', eye_L: 'head', eye_R: 'head', yuzu: 'head',
  leg_FL_upper: 'spine', leg_FL_lower: 'leg_FL_upper', leg_FR_upper: 'spine', leg_FR_lower: 'leg_FR_upper',
  leg_BL_upper: 'hips',  leg_BL_lower: 'leg_BL_upper', leg_BR_upper: 'hips',  leg_BR_lower: 'leg_BR_upper',
};
const anchorPos = {};
scaffold.traverse(o => { if (o.name && !o.isMesh) anchorPos[o.name] = o.getWorldPosition(new THREE.Vector3()); });
const bones = {}, boneList = [];
for (const [name, parent] of Object.entries(HIERARCHY)) {
  const b = new THREE.Bone(); b.name = name;
  b.position.copy(anchorPos[name]);
  if (parent) { b.position.sub(anchorPos[parent]); bones[parent].add(b); }
  bones[name] = b; boneList.push(b);
}
const boneIndex = Object.fromEntries(boneList.map((b, i) => [b.name, i]));

// ======================================================================
// 3) Bake parts to model space, write skinIndex/skinWeight, merge per material
// ======================================================================
const byMat = new Map();
const v = new THREE.Vector3();
scaffold.traverse(m => {
  if (!m.isMesh) return;
  let g = m.geometry.clone().applyMatrix4(m.matrixWorld);
  if (!g.index) g.setIndex([...Array(g.attributes.position.count).keys()]);   // keep everything indexed for merging
  for (const k of Object.keys(g.attributes)) if (!['position', 'normal', 'uv'].includes(k)) g.deleteAttribute(k);
  const n = g.attributes.position.count;
  const si = new Uint16Array(n * 4), sw = new Float32Array(n * 4);
  const rig = m.userData.rig;
  for (let i = 0; i < n; i++) {
    v.fromBufferAttribute(g.attributes.position, i);
    const w = typeof rig === 'string' ? { [rig]: 1 } : rig(v);
    const top = Object.entries(w).filter(([, x]) => x > 1e-4).sort((a, b) => b[1] - a[1]).slice(0, 4);
    const sum = top.reduce((s, [, x]) => s + x, 0);
    top.forEach(([bn, x], j) => { si[i * 4 + j] = boneIndex[bn]; sw[i * 4 + j] = x / sum; });
  }
  g.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(si, 4));
  g.setAttribute('skinWeight', new THREE.Float32BufferAttribute(sw, 4));
  if (!byMat.has(m.material)) byMat.set(m.material, []);
  byMat.get(m.material).push(g);
});

const capybara = new THREE.Group();
capybara.name = 'capybara';
scene.add(capybara);
capybara.add(bones.root);
capybara.updateMatrixWorld(true);
const skeleton = new THREE.Skeleton(boneList);
// one SkinnedMesh, one material group per material -> a single skin in the exported GLB
const mats = [...byMat.keys()];
const merged = mergeGeometries(mats.map(m => mergeGeometries(byMat.get(m), false)), true);
merged.computeBoundingSphere();
const body = new THREE.SkinnedMesh(merged, mats);
body.name = 'capybara_mesh';
body.castShadow = body.receiveShadow = true;
body.frustumCulled = false;
capybara.add(body);
body.bind(skeleton);
const skelHelper = new THREE.SkeletonHelper(capybara);
skelHelper.visible = false;
skelHelper.material.linewidth = 2;
scene.add(skelHelper);

// ======================================================================
// 4) Animation clips (keyframes sampled from small pose functions)
// ======================================================================
const TAU = Math.PI * 2;
const _q = new THREE.Quaternion(), _e = new THREE.Euler();
const times = (T, fps = 30) => { const n = Math.round(T * fps); return Array.from({ length: n + 1 }, (_, i) => (i / n) * T); };
// smooth piecewise interpolation through [[t, value], ...]
function keys(t, ks) {
  if (t <= ks[0][0]) return ks[0][1];
  for (let i = 1; i < ks.length; i++) if (t <= ks[i][0]) {
    const [t0, a] = ks[i - 1], [t1, b] = ks[i];
    const s = smooth(0, 1, (t - t0) / (t1 - t0));
    return a + (b - a) * s;
  }
  return ks[ks.length - 1][1];
}
function makeClip(name, T, spec) {      // spec: { bone: { rot?: t=>[x,y,z], pos?: t=>[dx,dy,dz], scale?: t=>[sx,sy,sz] } }
  const ts = times(T), tracks = [];
  for (const [bn, s] of Object.entries(spec)) {
    if (s.rot) tracks.push(new THREE.QuaternionKeyframeTrack(`${bn}.quaternion`, ts,
      ts.flatMap(t => { _q.setFromEuler(_e.set(...s.rot(t))); return [_q.x, _q.y, _q.z, _q.w]; })));
    if (s.pos) { const r = bones[bn].position; tracks.push(new THREE.VectorKeyframeTrack(`${bn}.position`, ts,
      ts.flatMap(t => { const d = s.pos(t); return [r.x + d[0], r.y + d[1], r.z + d[2]]; }))); }
    if (s.scale) tracks.push(new THREE.VectorKeyframeTrack(`${bn}.scale`, ts, ts.flatMap(t => s.scale(t))));
  }
  return new THREE.AnimationClip(name, T, tracks);
}
const blink = (t, at) => { const k = (t - at) / 0.18; return k > 0 && k < 1 ? 1 - Math.sin(k * Math.PI) * 0.9 : 1; };

// Idle: breathing, a slow head sway, two blinks and an ear flick
const idle = makeClip('Idle', 4, {
  spine: { scale: t => { const b = Math.sin(t / 4 * TAU * 2); return [1 + b * 0.012, 1 + b * 0.025, 1]; } },
  neck:  { rot: t => [Math.sin(t / 4 * TAU) * 0.04, Math.sin(t / 4 * TAU) * 0.08, 0] },
  eye_L: { scale: t => [1, Math.min(blink(t, 0.9), blink(t, 3.1)), 1] },
  eye_R: { scale: t => [1, Math.min(blink(t, 0.9), blink(t, 3.1)), 1] },
  ear_L: { rot: t => { const k = (t - 2.0) / 0.6; return [k > 0 && k < 1 ? Math.sin(k * TAU * 2) * 0.5 * (1 - k) : 0, 0, 0]; } },
  ear_R: { rot: t => { const k = (t - 2.25) / 0.5; return [k > 0 && k < 1 ? -Math.sin(k * TAU * 2) * 0.35 * (1 - k) : 0, 0, 0]; } },
});

// Walk: lateral-sequence 4-beat gait (BL, FL, BR, FR), body bob and roll
const WT = 1.1;
const PHASE = { BL: 0, FL: 0.25, BR: 0.5, FR: 0.75 };
const walkSpec = {
  hips:  { pos: t => [0, 0.025 * Math.cos(t / WT * TAU * 2), 0], rot: t => [0, Math.sin(t / WT * TAU) * 0.04, Math.sin(t / WT * TAU) * 0.035] },
  spine: { rot: t => [0, -Math.sin(t / WT * TAU) * 0.05, -Math.sin(t / WT * TAU) * 0.03] },
  neck:  { rot: t => [0.05 + Math.sin(t / WT * TAU * 2) * 0.04, 0, 0] },
  ear_L: { rot: t => [Math.sin(t / WT * TAU * 2) * 0.1, 0, 0] },
  ear_R: { rot: t => [Math.sin(t / WT * TAU * 2 + 1) * 0.1, 0, 0] },
};
for (const [id, ph] of Object.entries(PHASE)) {
  const back = id[0] === 'B';
  const a = (t) => (t / WT + ph) * TAU;
  walkSpec[`leg_${id}_upper`] = { rot: t => [-(back ? 0.38 : 0.32) * Math.sin(a(t)), 0, 0] };
  walkSpec[`leg_${id}_lower`] = { rot: t => [(back ? 0.9 : 0.8) * Math.pow(Math.max(0, Math.cos(a(t))), 1.5), 0, 0] };
}
const walk = makeClip('Walk', WT, walkSpec);

// Eat: dip to the grass, nibble, lift a little and chew
const ET = 3.2;
const chew = t => 0.5 + 0.5 * Math.sin(t * TAU * 3.75);       // 12 chews per loop -> seamless
const dip = [[0, 0.62], [1.4, 0.68], [1.8, 0.28], [2.8, 0.28], [3.2, 0.62]];
const eat = makeClip('Eat', ET, {
  spine: { rot: t => [0.05 + 0.12 * keys(t, dip), 0, 0] },
  neck:  { rot: t => [keys(t, dip), Math.sin(t / ET * TAU) * 0.08, 0] },
  head:  { rot: t => [0.05 * keys(t, dip) / 0.62 + 0.03 * chew(t), 0, 0] },
  jaw:   { rot: t => [0.16 * chew(t), Math.sin(t * TAU * 3.75) * 0.05, 0] },
  leg_FL_upper: { rot: t => [-0.05 - 0.12 * keys(t, dip), 0, 0.06] }, leg_FR_upper: { rot: t => [-0.05 - 0.12 * keys(t, dip), 0, -0.06] },
  ear_L: { rot: t => [-0.25, 0, 0] }, ear_R: { rot: t => [-0.25, 0, 0] },
});

// Sit: hind legs fold, rump settles to the ground, front legs stay planted (plays once and holds)
const ST = 3;
const sitK = t => keys(t, [[0, 0], [0.3, 0.05], [1.4, 1], [ST, 1]]);
const breathe = t => t > 1.4 ? Math.sin((t - 1.4) * TAU * 0.6) : 0;
const sit = makeClip('Sit', ST, {
  hips:  { pos: t => [0, -0.34 * sitK(t), -0.12 * sitK(t)], rot: t => [-0.4 * sitK(t), 0, 0] },
  spine: { rot: t => [-0.05 * sitK(t), 0, 0], scale: t => [1 + 0.01 * breathe(t), 1 + 0.02 * breathe(t), 1] },
  neck:  { rot: t => [0.3 * sitK(t), 0, 0] },
  head:  { rot: t => [0.1 * sitK(t), 0, 0] },
  leg_FL_upper: { rot: t => [0.47 * sitK(t), 0, 0], scale: t => [1, 1 + 0.3 * sitK(t), 1] },
  leg_FR_upper: { rot: t => [0.47 * sitK(t), 0, 0], scale: t => [1, 1 + 0.3 * sitK(t), 1] },
  leg_BL_upper: { rot: t => [-0.95 * sitK(t), 0, 0.08 * sitK(t)] },
  leg_BR_upper: { rot: t => [-0.95 * sitK(t), 0, -0.08 * sitK(t)] },
  leg_BL_lower: { rot: t => [1.75 * sitK(t), 0, 0] },
  leg_BR_lower: { rot: t => [1.75 * sitK(t), 0, 0] },
});

// LookAround: turns its head left, right, a little curious tilt
const LT = 5;
const yaw = [[0, 0], [0.8, 0.6], [1.8, 0.6], [2.8, -0.6], [3.8, -0.6], [5, 0]];
const look = makeClip('LookAround', LT, {
  neck:  { rot: t => [-0.06 * Math.abs(keys(t, yaw)) / 0.6, keys(t, yaw), 0] },
  head:  { rot: t => [-0.05, keys(t, yaw) * 0.25, keys(t, yaw) * 0.18] },
  spine: { rot: t => [0, keys(t, yaw) * 0.12, 0] },
  ear_L: { rot: t => [keys(t, yaw) > 0.3 ? 0.25 : -0.1, 0, 0] },
  ear_R: { rot: t => [keys(t, yaw) < -0.3 ? 0.25 : -0.1, 0, 0] },
});

// Happy: bouncy little yuzu bob with a head wobble
const HT = 1.2;
const happy = makeClip('Happy', HT, {
  hips:  { pos: t => [0, 0.05 * Math.abs(Math.sin(t / HT * TAU)), 0] },
  neck:  { rot: t => [-0.08, 0, Math.sin(t / HT * TAU) * 0.12] },
  head:  { rot: t => [-0.05 + 0.05 * Math.sin(t / HT * TAU * 2), 0, Math.sin(t / HT * TAU) * 0.1] },
  yuzu:  { pos: t => [0, 0.06 * Math.abs(Math.sin(t / HT * TAU)), 0], rot: t => [0, t / HT * TAU, Math.sin(t / HT * TAU) * 0.15] },
  ear_L: { rot: t => [Math.sin(t / HT * TAU * 2) * 0.35, 0, 0] },
  ear_R: { rot: t => [-Math.sin(t / HT * TAU * 2) * 0.35, 0, 0] },
  leg_FL_upper: { rot: t => [-0.12 * Math.max(0, Math.sin(t / HT * TAU)), 0, 0] },
  leg_FR_upper: { rot: t => [-0.12 * Math.max(0, -Math.sin(t / HT * TAU)), 0, 0] },
});

const clips = [idle, walk, eat, sit, look, happy];
const mixer = new THREE.AnimationMixer(capybara);
const actions = {};
for (const c of clips) {
  const a = mixer.clipAction(c);
  if (c.name === 'Sit') { a.setLoop(THREE.LoopOnce); a.clampWhenFinished = true; }
  actions[c.name] = a;
}
let current = actions.Idle;
current.play();

function play(name, fade = 0.45) {
  const next = actions[name];
  if (!next || next === current) return;
  next.reset().setEffectiveTimeScale(1).setEffectiveWeight(1).play();
  current.crossFadeTo(next, fade, false);
  current = next;
  document.querySelectorAll('#clips button').forEach(b => b.classList.toggle('active', b.dataset.clip === name));
}

// ======================================================================
// 5) UI
// ======================================================================
const LABELS = { Idle: 'Idle', Walk: 'Walk', Eat: 'Eat / chew', Sit: 'Sit', LookAround: 'Look around', Happy: 'Yuzu bob' };
const clipBox = document.getElementById('clips');
for (const c of clips) {
  const b = document.createElement('button');
  b.textContent = LABELS[c.name]; b.dataset.clip = c.name;
  if (c.name === 'Idle') b.classList.add('active');
  b.onclick = () => play(c.name);
  clipBox.appendChild(b);
}
const speedEl = document.getElementById('speed'), speedVal = document.getElementById('speedVal');
speedEl.oninput = () => { mixer.timeScale = +speedEl.value; speedVal.textContent = (+speedEl.value).toFixed(2) + '×'; };
document.getElementById('skel').onchange = e => { skelHelper.visible = e.target.checked; };
const roamEl = document.getElementById('roam');

function exportGLB() {
  // export from the rest pose, standing at the origin
  const state = { pos: capybara.position.clone(), rot: capybara.rotation.y, cur: current.getClip().name };
  mixer.stopAllAction();
  capybara.position.set(0, 0, 0); capybara.rotation.set(0, 0, 0);
  capybara.updateMatrixWorld(true);
  return new Promise((resolve, reject) => {
    new GLTFExporter().parse(capybara, (glb) => {
      capybara.position.copy(state.pos); capybara.rotation.y = state.rot;
      current = actions[state.cur]; current.reset().play();
      resolve(glb);
    }, reject, { binary: true, animations: clips, onlyVisible: true });
  });
}
document.getElementById('exportBtn').onclick = async () => {
  const glb = await exportGLB();
  const a = document.createElement('a');
  a.href = URL.createObjectURL(new Blob([glb], { type: 'model/gltf-binary' }));
  a.download = 'capybara_rigged.glb'; a.click();
};

// ======================================================================
// 6) Loop
// ======================================================================
const clock = new THREE.Clock();
let roamAngle = 0, frozen = false;
function animate() {
  requestAnimationFrame(animate);
  const dt = Math.min(clock.getDelta(), 0.05);
  if (!frozen) {
    mixer.update(dt);
    if (roamEl.checked && current === actions.Walk) {
      roamAngle += dt * mixer.timeScale * 0.32;          // ~0.8 units/s on a 2.5 radius loop
      const R = 2.5;
      capybara.position.set(Math.sin(roamAngle) * R - 0, 0, Math.cos(roamAngle) * R - R);
      capybara.rotation.y = roamAngle + Math.PI / 2;
    }
  }
  controls.update();
  renderer.render(scene, camera);
}
animate();

addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

// hooks for automated previews / export
window.__capy = {
  THREE, camera, controls, capybara, skeleton, skelHelper, mixer, actions, clips, play, exportGLB,
  pose(name, t) {                  // freeze a clip at time t (deterministic screenshots)
    frozen = true; mixer.stopAllAction();
    const a = actions[name]; a.reset().setEffectiveWeight(1).play(); a.time = t; mixer.update(0);
  },
  unfreeze() { frozen = false; },
  ready: true,
};
</script>
</body>
</html>

Grab the Rigged Model

Want the finished asset without running the code? The exported .glb includes the skinned mesh, the 19-bone skeleton, and all six animation clips. Drop it straight into Blender, Unity, Godot, or any <model-viewer>.

What To Try Next

SEE 3D AND 2D BROWSER GAMES IN ACTION

More Tutorials