BUILD A RIGGED LOW-POLY
SOLDIER IN THREE.JS
By Shane Brumback · · Free Three.js tutorial
To build a rigged low-poly soldier in Three.js, assemble the body from primitive shapes, bind it to a bone skeleton as a SkinnedMesh, drive its action clips with an AnimationMixer, and move it with WASD keys — blending Walk, Run, Jump, and Fire as the player presses keys. This free tutorial includes a live demo, a one-click rigged GLB export, and the full copy-paste source.
Meet the Vanguard Trooper — a low-poly soldier built entirely in code from boxes, cylinders and spheres, rigged to a 19-bone skeleton and animated with seven action clips. Load nothing, download nothing: it's assembled live in Three.js, controlled with WASD — Shift to run, Space to jump, F to fire — and exportable as a rigged .glb.
LIVE DEMO
The trooper standing behind this page is a live Three.js scene — built from primitives, not loaded from a file. Launch the full-screen demo and walk him around with WASD / arrow keys — hold Shift to run, Space to jump, F to fire. Drag to orbit, scroll to zoom, tap the clip buttons to preview each animation, show the skeleton, or export a rigged GLB.
Questions about rigging or skinning in Three.js? Contact Me and I'll help.
What You'll Build
This tutorial covers the whole pipeline for a code-built character: model it from primitives, rig it to a skeleton, animate it, and control it with the keyboard — no external model file required.
- Build the body in code from boxes, cylinders and spheres — a chunky, low-poly silhouette with armor plating, a visor and a carbine.
- Rig it to a 19-bone skeleton — assign each piece to a bone with
skinIndex/skinWeightand bind aSkinnedMesh. - Author 7 action clips with an
AnimationMixer— Idle, Walk, Run, Jump, Fire, Reload, Death — and crossfade between them. - WASD / arrow-key movement — camera-relative, with the character turning to face the way it's heading.
- Shift to run, Space to jump, F to fire — one-shot clips that return to idle when they finish.
- One-click rigged GLB export —
GLTFExporterwrites a standard glTF 2.0 file for Three.js, Unity, Blender or Godot.
Controls
Launch the live demo above and try them. The same controls are implemented in the code below.
FAQ
How do you build a rigged character in Three.js without a GLB?
Build the body from primitive geometries, give each piece a skinIndex/skinWeight pointing at one bone, merge the geometries, and bind them to a THREE.Skeleton with a SkinnedMesh. The Vanguard Trooper is built entirely in code this way, with a 19-bone skeleton and no external file.
How do you play a character's animations in Three.js?
Create a THREE.AnimationMixer bound to the model, then make an action for each clip with mixer.clipAction(clip). Call action.play() to start one and action.crossFadeTo(next, dur) to blend. Advance it each frame with mixer.update(delta).
How do you add WASD movement to a character in Three.js?
Track pressed keys with keydown/keyup. Each frame, build a direction from the camera's forward and right vectors (flattened to the ground), scale by speed × delta, and add it to the model's position. Rotate the model to face travel, and switch to Walk/Run while moving, Idle when stopped.
How do you make the character jump or fire on a key press?
Give the Jump and Fire clips THREE.LoopOnce with clampWhenFinished, then play them on a single keydown (Space / F), ignoring auto-repeat with event.repeat. Listen for the mixer's finished event to return to Idle or resume walking.
Can I export the Vanguard Trooper as a rigged GLB?
Yes. The live demo has a one-click Export GLB button that runs GLTFExporter in your browser and downloads a standard binary glTF 2.0 file with the skeleton, the skinned mesh, and all seven clips. It imports into Blender, Unity, Godot, Babylon.js, or any viewer that reads glTF.
Is this rigged soldier tutorial free?
Yes. It's completely free. The trooper walking behind this page is a live demo, the full Three.js build + controls source is on the page to copy or launch in one click, and you can export a rigged GLB for your own projects. No sign-in or membership required.
How It Works
- 1Set up a scene. A renderer, a camera,
OrbitControls, a couple of lights, and a ground plane — the usual Three.js starting point. - 2Build + rig the body. Primitive geometries are positioned into a trooper, each tagged to one bone, merged per material, and bound to a
THREE.Skeletonas aSkinnedMesh. - 3Author the clips. Each action (Idle, Walk, Run, Jump, Fire, Reload, Death) is a function of time that returns bone rotations, baked into
QuaternionKeyframeTracks. Jump/Fire/Death areLoopOnce; the rest loop. - 4Read the keyboard.
keydown/keyuplisteners track WASD + Shift as held state, and fire Space (jump) and F (fire) once per press. - 5Move the character. Each frame, build a camera-relative direction, step the model by speed × delta, clamp it to the ground, and turn it to face travel. Switch to Walk/Run while moving and Idle when stopped.
- 6Export it.
GLTFExporterserializes the skinned mesh, skeleton and clips to a binary.glbyou can download and reuse.
The Full Code
Paste this into a file named index.html and open it in any modern browser — or hit Launch. Three.js loads from a CDN via the import map and the trooper is built in code, so it runs as-is. It's a complete, minimal character controller: build + rig the model, play its clips, and move it with WASD (Shift run, Space jump, F fire).
<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rigged Low-Poly Soldier (WASD) · Three.js</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#12100c;font-family:system-ui,Segoe UI,sans-serif;color:#f0e6d8}
#hud{position:fixed;left:14px;top:14px;padding:12px 14px;background:rgba(20,16,10,.82);border:1px solid #ffb24a55;border-radius:12px;font-size:13px;line-height:1.7}
#hud b{color:#ffb24a}
kbd{display:inline-block;min-width:16px;text-align:center;padding:1px 6px;background:#2a2318;border:1px solid #4a3d26;border-bottom-width:2px;border-radius:5px;font:600 12px/1 ui-monospace,Menlo,Consolas,monospace;color:#f0e6d8}
#loading{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#12100c;color:#ffb24a;font-size:16px;letter-spacing:1px}
</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="loading">Building trooper...</div>
<div id="hud" style="display:none">
<div><b>Move</b> <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> <b>Run</b> <kbd>Shift</kbd></div>
<div><b>Jump</b> <kbd>Space</kbd> <b>Fire</b> <kbd>F</kbd> <b>Orbit</b> drag</div>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
// ---------------------------------------------------------------------------
// 1. SCENE / CAMERA / RENDERER
// ---------------------------------------------------------------------------
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x12100c);
scene.fog = new THREE.Fog(0x12100c, 9, 26);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.05, 100);
camera.position.set(2.6, 1.8, 4.0);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1, 0);
controls.enableDamping = true;
controls.maxPolarAngle = Math.PI * 0.52;
controls.update();
scene.add(new THREE.HemisphereLight(0xffe6bf, 0x2a2518, 0.9));
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
sun.position.set(3, 6, 4); sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -12, right: 12, top: 12, bottom: -12, near: 0.5, far: 40 });
sun.shadow.bias = -0.0005;
scene.add(sun);
const rim = new THREE.DirectionalLight(0xffb24a, 1.4);
rim.position.set(-3, 2.5, -3); scene.add(rim);
const ground = new THREE.Mesh(new THREE.CircleGeometry(12, 48),
new THREE.MeshStandardMaterial({ color: 0x2a2820, roughness: 0.95 }));
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);
scene.add(new THREE.GridHelper(24, 48, 0xffb24a, 0x3a362a));
// ---------------------------------------------------------------------------
// 2. BUILD + RIG THE TROOPER (primitives -> SkinnedMesh on a 19-bone skeleton)
// ---------------------------------------------------------------------------
const MAT = {
armor: new THREE.MeshStandardMaterial({ color: 0x8a7a52, metalness: 0.3, roughness: 0.55 }),
cloth: new THREE.MeshStandardMaterial({ color: 0x4a4330, metalness: 0.1, roughness: 0.85 }),
dark: new THREE.MeshStandardMaterial({ color: 0x24211a, metalness: 0.2, roughness: 0.6 }),
visor: new THREE.MeshStandardMaterial({ color: 0x3a1f05, emissive: 0xffb24a, emissiveIntensity: 2.2, roughness: 0.3 }),
};
const BONES = {
Root: [null, [0, 0, 0]], Hips: ['Root', [0, 0.98, 0]], Spine: ['Hips', [0, 1.1, 0]],
Spine1: ['Spine', [0, 1.28, 0]], Neck: ['Spine1', [0, 1.52, 0]], Head: ['Neck', [0, 1.6, 0]],
};
for (const [S, s] of [['Left', 1], ['Right', -1]]) {
BONES[S + 'UpperArm'] = ['Spine1', [s * 0.26, 1.45, 0]];
BONES[S + 'LowerArm'] = [S + 'UpperArm', [s * 0.26, 1.17, 0]];
BONES[S + 'Hand'] = [S + 'LowerArm', [s * 0.26, 0.93, 0]];
BONES[S + 'UpLeg'] = ['Hips', [s * 0.1, 0.94, 0]];
BONES[S + 'Leg'] = [S + 'UpLeg', [s * 0.1, 0.53, 0]];
BONES[S + 'Foot'] = [S + 'Leg', [s * 0.1, 0.12, 0]];
}
BONES.Weapon = ['RightHand', [-0.26, 0.89, 0]];
function buildTrooper() {
const parts = [];
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _e = new THREE.Euler();
const add = (g, mat, bone, pos, rot = [0, 0, 0], scl = [1, 1, 1]) => {
_q.setFromEuler(_e.set(...rot));
g.applyMatrix4(_m.compose(new THREE.Vector3(...pos), _q, new THREE.Vector3(...scl)));
parts.push({ g, mat, bone });
};
const box = (x, y, z) => new THREE.BoxGeometry(x, y, z);
const cyl = (r1, r2, h, n = 8) => new THREE.CylinderGeometry(r1, r2, h, n);
const sph = (r, w = 10, h = 8) => new THREE.SphereGeometry(r, w, h);
// torso + heavy chest plate
add(box(0.36, 0.2, 0.24), MAT.cloth, 'Hips', [0, 0.97, 0]);
add(box(0.4, 0.07, 0.27), MAT.dark, 'Hips', [0, 1.06, 0]);
add(cyl(0.16, 0.15, 0.2), MAT.cloth, 'Spine', [0, 1.18, 0]);
add(box(0.46, 0.34, 0.3), MAT.armor, 'Spine1', [0, 1.37, 0]);
add(box(0.34, 0.22, 0.05), MAT.armor, 'Spine1', [0, 1.37, 0.15], [-0.08, 0, 0]);
add(box(0.14, 0.14, 0.02), MAT.visor, 'Spine1', [0, 1.4, 0.18]); // chest emblem
add(box(0.32, 0.34, 0.14), MAT.dark, 'Spine1', [0, 1.34, -0.2]); // backpack
// shoulder pauldrons (bulky, low-poly)
add(sph(0.13, 8, 6), MAT.armor, 'Spine1', [0.3, 1.5, 0]);
add(sph(0.13, 8, 6), MAT.armor, 'Spine1', [-0.3, 1.5, 0]);
// head + helmet
add(cyl(0.07, 0.075, 0.12, 8), MAT.dark, 'Neck', [0, 1.55, 0]);
add(box(0.17, 0.12, 0.13), MAT.cloth, 'Head', [0, 1.62, 0.04]);
add(sph(0.15, 10, 8), MAT.armor, 'Head', [0, 1.68, -0.01], [0, 0, 0], [1, 0.95, 1.1]);
add(box(0.24, 0.07, 0.07), MAT.visor, 'Head', [0, 1.665, 0.12]); // visor glow
add(box(0.07, 0.16, 0.07), MAT.dark, 'Head', [0.12, 1.73, 0]); // antenna fin
// limbs
for (const [S, s] of [['Left', 1], ['Right', -1]]) {
const x = s * 0.26;
add(box(0.18, 0.12, 0.23), MAT.armor, S + 'UpperArm', [s * 0.28, 1.48, 0], [0, 0, -s * 0.35]);
add(cyl(0.062, 0.056, 0.26), MAT.cloth, S + 'UpperArm', [x, 1.31, 0]);
add(cyl(0.062, 0.052, 0.22), MAT.armor, S + 'LowerArm', [x, 1.05, 0]);
add(box(0.08, 0.1, 0.09), MAT.dark, S + 'Hand', [x, 0.89, 0]);
const lx = s * 0.1;
add(cyl(0.085, 0.07, 0.38), MAT.cloth, S + 'UpLeg', [lx, 0.73, 0]);
add(box(0.11, 0.14, 0.07), MAT.armor, S + 'UpLeg', [s * 0.17, 0.78, 0.02]); // thigh plate
add(cyl(0.07, 0.06, 0.36), MAT.cloth, S + 'Leg', [lx, 0.33, 0]);
add(box(0.1, 0.18, 0.05), MAT.armor, S + 'Leg', [lx, 0.3, 0.07]); // shin guard
add(box(0.14, 0.1, 0.28), MAT.dark, S + 'Foot', [lx, 0.06, 0.05]); // boot
}
// carbine (compact)
const wx = -0.26;
add(box(0.055, 0.28, 0.09), MAT.dark, 'Weapon', [wx, 0.82, 0.08]);
add(box(0.057, 0.16, 0.012), MAT.visor, 'Weapon', [wx, 0.82, 0.128]);
add(box(0.035, 0.07, 0.13), MAT.dark, 'Weapon', [wx, 0.75, -0.02], [0.2, 0, 0]);
add(cyl(0.016, 0.016, 0.2, 6), MAT.dark, 'Weapon', [wx, 0.56, 0.08]);
add(cyl(0.024, 0.024, 0.05, 6), MAT.armor, 'Weapon', [wx, 0.46, 0.08]);
// bones
const bones = {}, boneList = [];
for (const [name, [parent, p]] of Object.entries(BONES)) {
const b = new THREE.Bone(); b.name = name; b.position.set(...p);
if (parent) { b.position.sub(new THREE.Vector3(...BONES[parent][1])); bones[parent].add(b); }
bones[name] = b; boneList.push(b);
}
const boneIndex = Object.fromEntries(boneList.map((b, i) => [b.name, i]));
const byMat = new Map();
for (const { g, mat, bone } of parts) {
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);
for (let i = 0; i < n; i++) { si[i * 4] = boneIndex[bone]; sw[i * 4] = 1; }
g.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(si, 4));
g.setAttribute('skinWeight', new THREE.Float32BufferAttribute(sw, 4));
if (!byMat.has(mat)) byMat.set(mat, []);
byMat.get(mat).push(g);
}
const mats = [...byMat.keys()];
const merged = mergeGeometries(mats.map(m => mergeGeometries(byMat.get(m), false)), true);
const root = new THREE.Group(); root.name = 'vanguard_trooper';
root.add(bones.Root); root.updateMatrixWorld(true);
const skeleton = new THREE.Skeleton(boneList);
const mesh = new THREE.SkinnedMesh(merged, mats);
mesh.castShadow = true; mesh.frustumCulled = false;
root.add(mesh); mesh.bind(skeleton);
return { root, mesh, skeleton, bones };
}
// ---------------------------------------------------------------------------
// 3. AUTHOR THE ANIMATION CLIPS (bone rotations baked to keyframe tracks)
// ---------------------------------------------------------------------------
const TAU = Math.PI * 2, lerp = (a, b, t) => a + (b - a) * t, smooth = t => t * t * (3 - 2 * t);
function keyframe(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, v0] = ks[i - 1], [t1, v1] = ks[i]; return lerp(v0, v1, smooth((t - t0) / (t1 - t0))); }
return ks[ks.length - 1][1];
}
const AIM = {
RightUpperArm: [-0.35, 0, 0.12], RightLowerArm: [-1.2, 0, 0],
LeftUpperArm: [-0.95, 0, -0.55], LeftLowerArm: [-0.85, 0, -0.1],
};
const add3 = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
const withAim = (extra = {}) => { const o = {}; for (const k in AIM) o[k] = add3(AIM[k], extra[k] || [0, 0, 0]); return o; };
function makeClip(name, dur, poseFn) {
const n = Math.round(dur * 30), times = [];
for (let i = 0; i <= n; i++) times.push((i / n) * dur);
const samples = times.map(t => poseFn(t, t / dur));
const q = new THREE.Quaternion(), e = new THREE.Euler();
const tracks = Object.keys(samples[0]).map(bone => {
const vals = [];
for (const s of samples) { q.setFromEuler(e.set(...s[bone])); vals.push(q.x, q.y, q.z, q.w); }
return new THREE.QuaternionKeyframeTrack(bone + '.quaternion', times, vals);
});
return new THREE.AnimationClip(name, dur, tracks);
}
function buildClips() {
const clips = [];
clips.push(makeClip('Idle', 3, (t, u) => {
const b = Math.sin(u * TAU), b2 = Math.sin(u * TAU * 2);
return { Hips: [0, 0.03 * b, 0], Spine: [0.02, 0, 0], Spine1: [0.03 * b2, 0.08, 0],
Neck: [0, -0.06, 0], Head: [-0.03 * b2, 0.05 * b, 0],
...withAim({ RightUpperArm: [0.03 * b2, 0, 0], LeftUpperArm: [0.03 * b2, 0, 0] }),
LeftUpLeg: [0, 0, 0.04], RightUpLeg: [0, 0, -0.04], LeftLeg: [0.03, 0, 0], RightLeg: [0.03, 0, 0] };
}));
clips.push(makeClip('Walk', 1.1, (t, u) => {
const p = u * TAU, s = Math.sin(p), c = Math.cos(p), knee = ph => 0.1 + 0.6 * Math.max(0, Math.sin(ph));
return { Hips: [0, 0.08 * s, 0], Spine: [0.05, -0.05 * s, 0], Spine1: [0.02, 0.04 - 0.04 * s, 0],
...withAim({ RightUpperArm: [0.03 * c, 0, 0], LeftUpperArm: [0.03 * c, 0, 0] }),
LeftUpLeg: [-0.42 * s, 0, 0], RightUpLeg: [0.42 * s, 0, 0],
LeftLeg: [knee(p + Math.PI * 0.5), 0, 0], RightLeg: [knee(p + Math.PI * 1.5), 0, 0],
LeftFoot: [0.2 * c, 0, 0], RightFoot: [-0.2 * c, 0, 0] };
}));
clips.push(makeClip('Run', 0.7, (t, u) => {
const p = u * TAU, s = Math.sin(p), c = Math.cos(p), knee = ph => 0.25 + 1.1 * Math.max(0, Math.sin(ph));
return { Hips: [0, 0.12 * s, 0], Spine: [0.18, -0.08 * s, 0], Spine1: [0.05, -0.06 * s, 0],
...withAim({ RightUpperArm: [0.06 * c, 0, 0], LeftUpperArm: [0.06 * c, 0, 0] }),
LeftUpLeg: [-0.75 * s, 0, 0], RightUpLeg: [0.75 * s, 0, 0],
LeftLeg: [knee(p + Math.PI * 0.45), 0, 0], RightLeg: [knee(p + Math.PI * 1.45), 0, 0],
LeftFoot: [0.3 * c, 0, 0], RightFoot: [-0.3 * c, 0, 0] };
}));
clips.push(makeClip('Jump', 0.9, t => {
const thigh = keyframe(t, [[0, 0], [0.12, -0.6], [0.25, 0.1], [0.5, -0.9], [0.9, -0.3]]);
const knee = keyframe(t, [[0, 0], [0.12, 1.0], [0.25, 0.1], [0.5, 1.4], [0.9, 0.5]]);
const lean = keyframe(t, [[0, 0], [0.12, 0.35], [0.25, -0.05], [0.5, 0.25], [0.9, 0.15]]);
return { Spine: [lean, 0, 0], Spine1: [0.05, 0, 0], Neck: [-lean * 0.6, 0, 0],
...withAim({ LeftUpperArm: [-0.2 * lean, 0, 0] }),
LeftUpLeg: [thigh, 0, 0.05], RightUpLeg: [thigh * 0.8, 0, -0.05],
LeftLeg: [knee, 0, 0], RightLeg: [knee * 0.9, 0, 0] };
}));
clips.push(makeClip('Fire', 0.24, t => {
const k = keyframe(t, [[0, 0], [0.03, 1], [0.24, 0]]);
return { Spine1: [-0.14 * k, 0.08, 0], Neck: [0.05 * k, -0.06, 0],
...withAim({ RightUpperArm: [0.18 * k, 0, 0], RightLowerArm: [0.25 * k, 0, 0],
LeftUpperArm: [0.15 * k, 0, 0], LeftLowerArm: [0.15 * k, 0, 0] }) };
}));
clips.push(makeClip('Reload', 1.6, t => {
const tilt = keyframe(t, [[0, 0], [0.25, 1], [1.3, 1], [1.6, 0]]);
const reach = keyframe(t, [[0, 0], [0.3, 0], [0.55, 1], [0.8, 1], [1.05, 0.4], [1.15, 0], [1.6, 0]]);
return { Spine1: [0.06 * tilt, 0.08 - 0.15 * tilt, 0], Neck: [0.25 * tilt, -0.06, 0],
...withAim({ RightUpperArm: [0.1 * tilt, 0, 0.15 * tilt], RightLowerArm: [0.15 * tilt, 0.2 * tilt, 0],
LeftUpperArm: [0.85 * reach, 0, 0.35 * reach], LeftLowerArm: [-0.4 * reach, 0, 0] }) };
}));
clips.push(makeClip('Death', 1.6, t => {
const fall = keyframe(t, [[0, 0], [0.15, -0.05], [1.1, -1.5], [1.25, -1.42], [1.4, -1.5]]);
const fling = keyframe(t, [[0, 0], [0.4, 0.3], [1.1, 1], [1.6, 1]]);
const buckle = keyframe(t, [[0, 0], [0.35, 0.9], [1.1, 0.2], [1.6, 0.25]]);
return { Root: [fall, 0, 0], Spine: [-0.2 * fling, 0, 0.05 * fling], Neck: [-0.3 * fling, 0.2 * fling, 0],
RightUpperArm: [lerp(-0.35, -2.2, fling), 0, lerp(0.12, -0.4, fling)], RightLowerArm: [lerp(-1.2, -0.3, fling), 0, 0],
LeftUpperArm: [lerp(-0.95, -2.4, fling), 0, lerp(-0.55, 0.5, fling)], LeftLowerArm: [lerp(-0.85, -0.4, fling), 0, 0],
LeftUpLeg: [-0.9 * buckle, 0, 0], RightUpLeg: [-0.6 * buckle, 0, 0],
LeftLeg: [1.6 * buckle, 0, 0], RightLeg: [1.2 * buckle, 0, 0] };
}));
return clips;
}
// ---------------------------------------------------------------------------
// 4. ASSEMBLE + MIXER
// ---------------------------------------------------------------------------
const trooper = buildTrooper();
scene.add(trooper.root);
const mixer = new THREE.AnimationMixer(trooper.root);
const actions = {};
for (const c of buildClips()) {
const a = mixer.clipAction(c);
if (/Jump|Fire|Death/.test(c.name)) { a.setLoop(THREE.LoopOnce); a.clampWhenFinished = true; }
actions[c.name] = a;
}
let current = null, currentName = null, locked = false;
const LOCO = { Idle: 1, Walk: 1, Run: 1 };
function play(name, fade = 0.25) {
const next = actions[name]; if (!next || current === next) return;
currentName = name; next.reset().setEffectiveWeight(1).play();
if (current) current.crossFadeTo(next, fade, false);
current = next;
}
mixer.addEventListener('finished', () => {
locked = false;
if (currentName === 'Death') return;
const moving = keys.fwd || keys.back || keys.left || keys.right;
play(moving ? (keys.run ? 'Run' : 'Walk') : 'Idle');
});
play('Idle', 0);
document.getElementById('loading').style.display = 'none';
document.getElementById('hud').style.display = 'block';
// ---------------------------------------------------------------------------
// 5. KEYBOARD CONTROLS — WASD / arrows move, Shift runs, Space jumps, F fires
// ---------------------------------------------------------------------------
const keys = { fwd: false, back: false, left: false, right: false, run: false };
function setKey(code, down) {
switch (code) {
case 'KeyW': case 'ArrowUp': keys.fwd = down; return true;
case 'KeyS': case 'ArrowDown': keys.back = down; return true;
case 'KeyA': case 'ArrowLeft': keys.left = down; return true;
case 'KeyD': case 'ArrowRight': keys.right = down; return true;
case 'ShiftLeft': case 'ShiftRight': keys.run = down; return true;
}
return false;
}
function oneShot(name) { if (actions[name]) { locked = true; play(name, 0.08); } }
addEventListener('keydown', (e) => {
if (setKey(e.code, true)) { e.preventDefault(); return; }
if (e.repeat) return;
if (e.code === 'Space') { e.preventDefault(); oneShot('Jump'); }
if (e.code === 'KeyF') { e.preventDefault(); oneShot('Fire'); }
});
addEventListener('keyup', (e) => { if (setKey(e.code, false)) e.preventDefault(); });
// ---------------------------------------------------------------------------
// 6. MOVEMENT — camera-relative, faces travel direction, clamped to ground
// ---------------------------------------------------------------------------
const SPEED = { walk: 1.6, run: 3.4, radius: 10.5 };
const fwd = new THREE.Vector3(), right = new THREE.Vector3(), dir = new THREE.Vector3(), tmp = new THREE.Vector3();
function updateMovement(dt) {
const kx = (keys.right ? 1 : 0) - (keys.left ? 1 : 0);
const kz = (keys.fwd ? 1 : 0) - (keys.back ? 1 : 0);
if (kx !== 0 || kz !== 0) {
camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize();
right.crossVectors(fwd, camera.up).normalize();
dir.set(0, 0, 0).addScaledVector(fwd, kz).addScaledVector(right, kx).normalize();
const speed = keys.run ? SPEED.run : SPEED.walk;
trooper.root.position.addScaledVector(dir, speed * dt);
const r = Math.hypot(trooper.root.position.x, trooper.root.position.z);
if (r > SPEED.radius) { trooper.root.position.x *= SPEED.radius / r; trooper.root.position.z *= SPEED.radius / r; }
const yaw = Math.atan2(dir.x, dir.z);
let d = yaw - trooper.root.rotation.y; while (d > Math.PI) d -= TAU; while (d < -Math.PI) d += TAU;
trooper.root.rotation.y += d * Math.min(1, dt * 10);
controls.target.lerp(tmp.set(trooper.root.position.x, 1, trooper.root.position.z), Math.min(1, dt * 6));
if (!locked) { const want = keys.run ? 'Run' : 'Walk'; if (LOCO[currentName] && currentName !== want) play(want, 0.15); }
} else if (!locked && LOCO[currentName] && currentName !== 'Idle') { play('Idle'); }
}
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const dt = Math.min(clock.getDelta(), 0.05);
updateMovement(dt);
mixer.update(dt);
controls.update();
renderer.render(scene, camera);
});
</script>
</body></html>
More Tutorials
Rigged Super Soldier (WASD)
The original: load a rigged soldier GLB and drive WASD / jump / fire controls.
Rigged Capybara with Grok Bot
The same SkinnedMesh + GLB export approach, built the no-code way with Grok Bot AI.
Browse All Tutorials
Dozens of free Three.js and WebGL walkthroughs with live demos.