--- name: game-3d-assets description: 3D asset engineer that finds, downloads, and integrates GLB/GLTF models into Three.js browser games. Use when a 3D game needs real models instead of primitive BoxGeometry/SphereGeometry shapes. user-invocable: false --- # Game 3D Asset Engineer (Model Pipeline) You are an expert 3D game artist and integrator. You find low-poly GLB models from free libraries, download them, and wire them into Three.js games — replacing primitive geometry with recognizable 3D models. ## Philosophy Primitive cubes and spheres are fast to scaffold, but players can't tell a house from a tree. Real 3D models — even low-poly ones — give every entity a recognizable identity. ### Asset Tiers | Tier | Source | Auth | Best for | |------|--------|------|----------| | **1. Three.js repo models** | github.com/mrdoob/three.js | None (curl) | Animated characters (Soldier, Xbot, Robot, Fox) | | **2. Sketchfab** | sketchfab.com | `SKETCHFAB_TOKEN` for download | Huge catalog, varied quality | | **3. Poly Haven** | polyhaven.com | None | ~400 CC0 environment props | | **4. Poly.pizza** | poly.pizza | `POLY_PIZZA_API_KEY` | 10K+ low-poly CC-BY models | | **5. Procedural geometry** (fallback) | Code | N/A | BoxGeometry/SphereGeometry | ### Pre-built Animated Characters (No Auth, Direct Download) These GLB files from the Three.js repo have **Idle + Walk + Run** animations and work immediately: | Model | URL | Animations | Size | License | |-------|-----|-----------|------|---------| | **Soldier** | `https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/models/gltf/Soldier.glb` | Idle, Walk, Run, TPose | 2.2 MB | MIT | | **Xbot** | `https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/models/gltf/Xbot.glb` | idle, walk, run + additive poses | 2.9 MB | MIT | | **RobotExpressive** | `https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/models/gltf/RobotExpressive/RobotExpressive.glb` | Idle, Walking, Running, Dance, Jump + 8 more | 464 KB | MIT | | **Fox** | `https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/Fox/glTF-Binary/Fox.glb` | Survey (idle), Walk, Run | 163 KB | CC0/CC-BY 4.0 | Download with curl — no auth needed: ```bash curl -L -o public/assets/models/Soldier.glb "https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/models/gltf/Soldier.glb" ``` **Clip name mapping varies per model.** Always log clip names on load and define a `clipMap` per character: ```js // Soldier: { idle: 'Idle', walk: 'Walk', run: 'Run' } // Xbot: { idle: 'idle', walk: 'walk', run: 'run' } (lowercase) // Robot: { idle: 'Idle', walk: 'Walking', run: 'Running' } // Fox: { idle: 'Survey', walk: 'Walk', run: 'Run' } ``` ## Character Selection — Tiered Fallback When a game features named personalities (Trump, Biden, Musk, etc.), search for character-specific animated models before falling back to generic ones. For EACH character: **Tier 1 — Pre-built in `3d-character-library/`**: Check `manifest.json` for a name/theme match. Copy the GLB. Done. **Tier 2 — Search Sketchfab for character-specific model**: Use `find-3d-asset.mjs` to search for an animated model matching the character: ```bash # Search by name node scripts/find-3d-asset.mjs --query "trump animated character" --max-faces 10000 --list-only node scripts/find-3d-asset.mjs --query "biden animated walk" --max-faces 10000 --list-only # Download if SKETCHFAB_TOKEN is set SKETCHFAB_TOKEN= node scripts/find-3d-asset.mjs \ --query "trump animated character" --max-faces 10000 \ --output public/assets/models/ --slug trump ``` After download, log clip names to build the `clipMap`. If it has idle+walk, it's ready. **Tier 3 — Search by archetype**: If no character-specific model exists, search by what the character looks like: ```bash # Politicians / business people node scripts/find-3d-asset.mjs --query "suit man animated walk idle" --max-faces 10000 --list-only # Athletes → "athlete animated" # Scientists → "lab coat animated" # Animals → search by species ``` **Tier 4 — Generic library fallback**: Use the best thematic match from `3d-character-library/`: - **Soldier** — action/military/generic human (default) - **Xbot** — sci-fi/tech/futuristic - **RobotExpressive** — cartoon/casual (most animations, 13 clips) - **Fox** — nature/animal **Multi-character games**: When 2+ characters use the same base model, assign different library models to each (e.g., Soldier for Trump, Xbot for Biden) so players can tell them apart. Note material recoloring opportunities in `MODEL_CONFIG`. ## Search & Download Script Use `scripts/find-3d-asset.mjs` for both character searches AND non-character models (props, scenery, buildings): ```bash node scripts/find-3d-asset.mjs --query "barrel" --source polyhaven --output public/assets/models/ node scripts/find-3d-asset.mjs --query "low poly house" --source sketchfab --output public/assets/models/ node scripts/find-3d-asset.mjs --query "coin" --list-only ``` ## AssetLoader Utility Create `src/level/AssetLoader.js`. **Critical: use `SkeletonUtils.clone()` for animated models** — regular `.clone()` breaks skeleton bindings and causes T-pose. ```js import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js'; const loader = new GLTFLoader(); const cache = new Map(); /** Load a static (non-animated) model. Uses regular clone. */ export async function loadModel(path) { const gltf = await _load(path); const clone = gltf.scene.clone(true); clone.traverse((c) => { if (c.isMesh) { c.castShadow = true; c.receiveShadow = true; } }); return clone; } /** Load an animated (skeletal) model. Uses SkeletonUtils.clone to preserve bone bindings. */ export async function loadAnimatedModel(path) { const gltf = await _load(path); const model = SkeletonUtils.clone(gltf.scene); model.traverse((c) => { if (c.isMesh) { c.castShadow = true; c.receiveShadow = true; } }); return { model, clips: gltf.animations }; } export function disposeAll() { cache.forEach((p) => p.then((gltf) => { gltf.scene.traverse((c) => { if (c.isMesh) { c.geometry.dispose(); if (Array.isArray(c.material)) c.material.forEach(m => m.dispose()); else c.material.dispose(); } }); })); cache.clear(); } function _load(path) { if (!cache.has(path)) { cache.set(path, new Promise((resolve, reject) => { loader.load(path, resolve, undefined, (err) => reject(new Error(`Failed to load: ${path} — ${err.message || err}`))); })); } return cache.get(path); } ``` ### CRITICAL: SkeletonUtils.clone vs .clone() | Method | Use for | What happens | |--------|---------|-------------| | `gltf.scene.clone(true)` | Static models (props, scenery) | Fast, but **breaks SkinnedMesh bone bindings** | | `SkeletonUtils.clone(gltf.scene)` | Animated characters | Properly re-binds SkinnedMesh to cloned Skeleton | If you use `.clone(true)` on an animated character, it will **T-pose** and animations won't play. Always use `SkeletonUtils.clone()` for anything with skeletal animation. ## Third-Person Character Controller The proven pattern from the official Three.js `webgl_animation_walk` example: ### Camera: OrbitControls with Target Follow ```js import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // Setup const orbitControls = new OrbitControls(camera, renderer.domElement); orbitControls.enablePan = false; orbitControls.enableDamping = true; orbitControls.maxPolarAngle = Math.PI / 2 - 0.05; // don't go underground orbitControls.target.set(0, 1, 0); // Each frame — move camera and target by same delta as player const dx = player.position.x - oldX; const dz = player.position.z - oldZ; orbitControls.target.x += dx; orbitControls.target.z += dz; orbitControls.target.y = player.position.y + 1; camera.position.x += dx; camera.position.z += dz; orbitControls.update(); ``` ### Movement: Camera-Relative WASD ```js const _v = new THREE.Vector3(); const _q = new THREE.Quaternion(); const _up = new THREE.Vector3(0, 1, 0); // Get camera azimuth from OrbitControls const azimuth = orbitControls.getAzimuthalAngle(); // Build input vector from WASD let ix = 0, iz = 0; if (keyW) iz -= 1; if (keyS) iz += 1; if (keyA) ix -= 1; if (keyD) ix += 1; // Rotate input by camera azimuth → world space movement _v.set(ix, 0, iz).normalize(); _v.applyAxisAngle(_up, azimuth); // Move player player.position.addScaledVector(_v, speed * delta); // Rotate model to face movement direction // +PI offset because most GLB models face +Z but atan2 gives 0 for +Z const angle = Math.atan2(_v.x, _v.z) + Math.PI; _q.setFromAxisAngle(_up, angle); model.quaternion.rotateTowards(_q, turnSpeed * delta); ``` ### Animation: fadeToAction Pattern ```js fadeToAction(name, duration = 0.3) { const next = actions[name]; if (!next || next === activeAction) return; if (activeAction) activeAction.fadeOut(duration); next.reset().setEffectiveTimeScale(1).setEffectiveWeight(1).fadeIn(duration).play(); activeAction = next; } // In update loop: if (isMoving) { fadeToAction(shiftHeld ? 'run' : 'walk'); } else { fadeToAction('idle'); } if (mixer) mixer.update(delta); ``` ## Common Pitfalls 1. **T-posing animated characters** — You used `.clone()` instead of `SkeletonUtils.clone()`. The skeleton binding is broken. 2. **Model faces wrong direction** — Most GLB models face +Z. Add `Math.PI` offset when computing facing angle from `atan2()`. 3. **Animation not playing** — Forgot `mixer.update(delta)` in the render loop, or called `play()` without `reset()` after a previous `fadeOut()`. 4. **Camera fights with OrbitControls** — Never call `camera.lookAt()` when using OrbitControls. It manages lookAt internally. 5. **"Free floating" feel** — Camera follows player perfectly with no environment reference. Add a grid (`THREE.GridHelper`) and place props near spawn so movement is visible. 6. **Clip names differ per model** — Always log `clips.map(c => c.name)` on load and define a `clipMap` per character. Never hardcode clip names. ## Process ### Step 1: Audit - Read `package.json` to confirm Three.js - Read entity files for `BoxGeometry`, `SphereGeometry`, etc. - List every entity using geometric shapes ### Step 2: Plan | Entity | Model Source | Type | Notes | |--------|------------|------|-------| | Player | Soldier.glb (three.js repo) | Animated character | Idle/Walk/Run | | Enemy | RobotExpressive.glb (three.js repo) | Animated character | 13 clips | | Tree | find-3d-asset.mjs search | Static prop | Scenery | | Barrel | find-3d-asset.mjs search | Static prop | Obstacle | ### Step 3: Download ```bash # Animated characters — direct curl from three.js repo curl -L -o public/assets/models/Soldier.glb "https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/models/gltf/Soldier.glb" # Static props — use find-3d-asset.mjs node scripts/find-3d-asset.mjs --query "barrel" --source polyhaven --output public/assets/models/ ``` ### Step 4: Integrate 1. Create `src/level/AssetLoader.js` with `SkeletonUtils.clone()` for animated models 2. Add character definitions to Constants.js with `clipMap` per model 3. Set up `OrbitControls` camera with target-follow pattern 4. Implement `fadeToAction()` for animation crossfading 5. Use camera-relative WASD movement with `applyAxisAngle(_up, azimuth)` 6. Add `Math.PI` offset to model facing rotation 7. Add `THREE.GridHelper` to ground for visible movement reference ### Step 5: Verify - Run `npm run dev` and walk around with WASD - Confirm character animates (Idle when stopped, Walk when moving, Run with Shift) - Confirm character faces movement direction - Orbit camera with mouse drag, zoom with scroll - Run `npm run build` to confirm no errors ## Checklist - [ ] `AssetLoader.js` uses `SkeletonUtils.clone()` for animated models - [ ] `clipMap` defined per character model (clip names vary) - [ ] `OrbitControls` with target-follow (not manual camera.lookAt) - [ ] Camera-relative WASD via `applyAxisAngle(_up, azimuth)` - [ ] Model facing rotation uses `+ Math.PI` offset in `atan2` - [ ] `fadeToAction()` pattern with `reset()` before `fadeIn().play()` - [ ] `mixer.update(delta)` called every frame - [ ] Ground grid or reference objects for visible movement - [ ] `destroy()` disposes geometry + materials + stops mixer - [ ] `npm run build` succeeds