threejs-ops
Application/game-scale three.js: ES modules, GLTF pipeline (DRACO/KTX2/meshopt), AnimationMixer, physics (rapier/cannon-es), react-three-fiber, and performance at scale (InstancedMesh, LOD, draw calls). Triggers on: three.js, GLTFLoader, r3f, game loop, WebGL memory leak, boids.
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/threejs-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Three.js — application & game scale
Patterns for building three.js applications and games: module setup, asset pipelines, animation, simulation loops, physics, the React ecosystem, and staying fast (and leak-free) as scene complexity grows.
Scope split with sibling skills — do not duplicate them:
| Concern | Owner |
|---|---|
| Creative/generative three.js — scene scaffolding, GLSL shaders, particles, post-processing | genart-ops |
three.js inside a Mapbox GL custom layer (CustomLayerInterface, threebox) |
mapbox-ops |
| App/game-scale three.js — modules, assets, animation, loops, physics, R3F, scale | this skill |
1. ES-module reality (read this before writing any <script> tag)
three.js is ES-modules only. The legacy patterns are dead and will 404 or silently break on any modern release:
| Dead pattern | Removed | Use instead |
|---|---|---|
examples/js/* non-module loaders (js/loaders/GLTFLoader.js, THREE.OrbitControls globals) |
r148 | three/addons/ module imports |
build/three.js + build/three.min.js UMD builds (<script src=…> + global THREE) |
r160 | build/three.module.js via import map or bundler |
Versioning: npm publishes 0.<release>.<patch> — three@0.185.1 is r185.
Releases land monthly; pin exact versions.
No-bundler setup (import map)
Copy assets/importmap-starter.html — a complete,
runnable starter (import map + addons + resize + setAnimationLoop). The core:
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.185.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
</script>
Gotchas: both entries MUST pin the same version (mixed versions =
instanceof failures across module copies); the three/addons/ key needs the
trailing slash; import maps must appear before the first type="module"
script.
Bundler vs no-bundler
| Situation | Choice |
|---|---|
| Real app/game, npm deps, physics WASM, R3F | Vite (npm create vite@latest) — default answer |
| Demo, CodePen, teaching, drop-in page on an existing site | Import map — zero build |
@dimforge/rapier3d (WASM-bindgen) |
Needs a bundler; use rapier3d-compat without one (§5) |
With a bundler, import { X } from 'three/addons/…' resolves via the package's
exports map — same specifier both worlds.
WebGPU (know it exists; default to WebGL)
Since r167 three ships a parallel build: import { WebGPURenderer } from 'three/webgpu' plus the TSL node-shader language from three/tsl (import-map
users: point the "three" key at build/three.webgpu.js instead of
three.module.js — it re-exports core; never load both). WebGPURenderer
falls back to WebGL2 automatically and initializes async (await renderer.init(), or let setAnimationLoop defer for you). Default for
app/game work remains WebGLRenderer — reach for WebGPU when you need compute
(GPU crowds, particle sims) or TSL materials. Shader-level TSL/GLSL work is
genart-ops territory.
2. GLTF asset pipeline
glTF (.glb) is the format. Wire all three decoders once at startup so any
optimized asset loads; full setup, optimization CLI recipes (gltf-transform,
gltfpack), and CC0 model sources (Quaternius, Kenney, Poly Pizza) in
references/asset-pipeline.md.
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
const draco = new DRACOLoader().setDecoderPath(
'https://www.gstatic.com/draco/versioned/decoders/1.5.7/');
const ktx2 = new KTX2Loader()
.setTranscoderPath('https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/libs/basis/')
.detectSupport(renderer); // MUST pass the live renderer
const loader = new GLTFLoader()
.setDRACOLoader(draco).setKTX2Loader(ktx2).setMeshoptDecoder(MeshoptDecoder);
const { scene: model, animations } = await loader.loadAsync('hero.glb');
Cloning a skinned character for multiple instances needs
SkeletonUtils.clone() — a plain .clone() shares (and corrupts) the skeleton.
3. Animation system
AnimationMixer drives everything (skeletal + morph targets). One mixer per
model root; mixer.update(dt) every frame. Crossfades, blending weights,
one-shot clips, additive layers, and morph-target patterns in
references/animation.md.
const mixer = new THREE.AnimationMixer(model);
const idle = mixer.clipAction(THREE.AnimationClip.findByName(animations, 'Idle'));
const run = mixer.clipAction(THREE.AnimationClip.findByName(animations, 'Run'));
idle.play();
// smooth transition — never .stop() + .play()
run.reset().play();
idle.crossFadeTo(run, 0.3, /*warp*/ true);
4. Game loops
Render on rAF; simulate on a fixed timestep when gameplay/physics needs
determinism. Full accumulator pattern (with render interpolation), dt clamping,
tab-hidden handling, input sampling, and frame-rate-independent damping
(MathUtils.damp, follow cameras) in
references/game-loop.md.
const FIXED = 1 / 60;
let acc = 0, last = performance.now();
renderer.setAnimationLoop((now) => {
acc += Math.min((now - last) / 1000, 0.1); // clamp: tab-switch dt spike
last = now;
while (acc >= FIXED) { simulate(FIXED); acc -= FIXED; }
render(acc / FIXED); // interpolation alpha
});
Rules: use renderer.setAnimationLoop (not raw rAF — required for WebXR, and
it pauses cleanly); clamp dt or the first frame after a background tab
teleports everything; pause simulation on document.hidden
(visibilitychange); determinism = fixed step + seeded RNG + no per-frame
Math.random() in sim code.
5. Physics
Default: @dimforge/rapier3d (Rust/WASM — fast, deterministic, actively
developed, built-in kinematic character controller). cannon-es remains the
pure-JS fallback for zero-WASM constraints. Decision table, init patterns,
body/mesh sync, and character controllers in
references/physics.md.
The package-name trap:
| Package | When |
|---|---|
@dimforge/rapier3d |
Bundler with WASM support (Vite: works out of the box) |
@dimforge/rapier3d-compat |
No bundler / import map — WASM inlined as base64; await RAPIER.init() first |
import RAPIER from '@dimforge/rapier3d-compat';
await RAPIER.init();
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
// step inside the FIXED loop (§4), then copy body → mesh:
world.timestep = FIXED; world.step();
mesh.position.copy(body.translation());
mesh.quaternion.copy(body.rotation());
6. react-three-fiber + drei
R3F v9 (React 19) renders the three.js scene graph declaratively;
drei is its helper library. When: React
app, UI-heavy, want the pmndrs ecosystem (@react-three/rapier,
postprocessing). When not: no React on the page, engine-style tight control,
minimal bundle. Hooks, pitfalls (per-frame setState kills you — mutate refs in
useFrame), and the drei shortlist in
references/r3f-drei.md.
7. Scale patterns — staying fast, staying leak-free
Draw calls are the budget; GPU memory leaks are the debt. renderer.info is the
meter for both. InstancedMesh at count, LOD, culling, and the full disposal
discipline in references/scale-and-disposal.md.
Quick rules:
- Same mesh × N ≥ ~50 →
InstancedMesh(one draw call). Set matrices viasetMatrixAt(i, m), theninstanceMatrix.needsUpdate = true. - Watch
renderer.info.render.callsper frame — hundreds is fine, thousands is the problem.renderer.info.memory.{geometries,textures}must return to baseline after teardown. - Removing from the scene frees nothing. GPU memory needs explicit
geometry.dispose(),material.dispose(), every texture.dispose(), andrenderer.dispose()on full teardown (SPA route change = the classic leak).
function disposeObject(root) {
root.traverse((o) => {
o.geometry?.dispose();
for (const m of Array.isArray(o.material) ? o.material : [o.material]) {
if (!m) continue;
for (const v of Object.values(m)) v?.isTexture && v.dispose();
m.dispose();
}
});
root.removeFromParent();
}
8. Ambient life — boids, steering, waypoints
Crowd/wildlife/NPC motion is steering forces + a spatial hash, driven from the fixed loop and rendered via InstancedMesh. Seek/arrive/wander, the three boids rules, waypoint drivers, and library options in references/actors-and-steering.md. For structured game-state building blocks (actor motion controllers, waypoint drivers, camera rigs) see xt4d/GameBlocks — agent-oriented, self-explanatory reference implementations.
Bundled resources
| Resource | Load when |
|---|---|
| references/asset-pipeline.md | Loading/optimizing GLTF, decoder wiring, sourcing CC0 models |
| references/animation.md | Crossfades, blend weights, one-shots, morph targets |
| references/game-loop.md | Fixed timestep, interpolation, pause/visibility, input sampling, damping/follow-cam, determinism |
| references/physics.md | rapier vs cannon-es, init, sync, character controllers |
| references/r3f-drei.md | React Three Fiber apps, drei helpers, R3F pitfalls |
| references/scale-and-disposal.md | Instancing, LOD, culling, draw-call budgets, disposal/leaks |
| references/actors-and-steering.md | Boids, steering, waypoint actors, ambient life |
| assets/importmap-starter.html | Starting a no-bundler project — copy and edit |
| assets/three-facts.json | Canonical version gates + package facts (verifier input) |
Staleness verifier — this skill encodes fast-moving facts (three version gates, npm package names/versions). Check internal consistency (CI) or live drift against the npm registry:
python3 skills/threejs-ops/scripts/check-three-facts.py --offline # structural, no network — PR CI
python3 skills/threejs-ops/scripts/check-three-facts.py --live # npm registry probe — exit 10 drift, 7 unreachable
python3 skills/threejs-ops/scripts/check-three-facts.py --offline --json | jq '.data[] | select(.status!="ok")'
Files (claude-mods)
-
assets
-
importmap-starter.html 3.1 KB · in bundle
-
three-facts.json 2.2 KB
{ "_comment": "Canonical fast-moving facts the threejs-ops skill encodes. scripts/check-three-facts.py asserts SKILL.md + references state these consistently (--offline) and probes the npm registry for drift (--live). Edit deliberately: a change here is a skill-content decision, not housekeeping.", "schema": "claude-mods.threejs-ops.facts/v1", "as_of": "2026-07-02", "version_gates": { "examples_js_removed": "r148", "umd_builds_removed": "r160", "webgpu_build_added": "r167", "_comment": "r148 (Dec 2022) dropped examples/js non-module loaders; r160 (Dec 2023) dropped build/three.js + build/three.min.js UMD builds; r167 (Jul 2024) added the three.webgpu.js build + three/webgpu + three/tsl exports. The skill assumes r150+ and ES modules only." }, "npm_scheme": { "package": "three", "pattern": "0.<release>.<patch>", "example": "three@0.185.1 is r185", "major": 0, "_comment": "--live flags drift if the latest dist-tag ever leaves major 0 (a 1.x would signal an API-break release requiring a skill review pass)." }, "packages": { "three": { "sampled": "0.185.1", "role": "core" }, "@dimforge/rapier3d": { "sampled": "0.19.3", "role": "physics (bundler, wasm-bindgen)" }, "@dimforge/rapier3d-compat": { "sampled": "0.19.3", "role": "physics (no-bundler, inlined wasm)" }, "cannon-es": { "sampled": "0.20.0", "role": "physics (pure JS fallback)" }, "@react-three/fiber": { "sampled": "9.6.1", "role": "react renderer", "peer_react": ">=19", "peer_three": ">=0.156" }, "@react-three/drei": { "sampled": "10.7.7", "role": "r3f helpers" }, "@gltf-transform/cli": { "sampled": "4.4.1", "role": "asset optimizer" }, "gltfpack": { "sampled": "1.2.0", "role": "asset optimizer (meshoptimizer)" } }, "cc0_sources": [ { "id": "quaternius", "url": "https://quaternius.com" }, { "id": "kenney", "url": "https://kenney.nl/assets" }, { "id": "poly-pizza", "url": "https://poly.pizza" }, { "id": "ambientcg", "url": "https://ambientcg.com" } ], "reference_repos": [ { "id": "gameblocks", "url": "https://github.com/xt4d/GameBlocks", "note": "agent-oriented game building blocks: actor motion controllers, waypoint drivers, camera rigs" } ] }
-
-
references
-
actors-and-steering.md 5.4 KB
# Ambient life — boids, steering, waypoint actors Birds, fish, crowds, traffic, grazing animals: autonomous agents that make a scene feel inhabited. The recipe is always the same three layers: 1. **Steering forces** decide *where* each agent wants to go (this file). 2. **The fixed loop** integrates them deterministically ([game-loop.md](game-loop.md)). 3. **InstancedMesh** (or LOD'd clones for skinned characters) renders them ([scale-and-disposal.md](scale-and-disposal.md)). For a structured, engine-flavored take on the same layers — actor motion controllers, waypoint drivers, camera rigs as composable "blocks" — see [xt4d/GameBlocks](https://github.com/xt4d/GameBlocks): concise, self-explanatory building blocks written for agent-assisted prototyping; good reference implementations to crib state-shape from. ## 1. The steering core (Reynolds) Every behavior returns a desired-velocity correction; the agent sums, clamps, integrates: ```javascript class Agent { constructor() { this.position = new THREE.Vector3(); this.velocity = new THREE.Vector3(); this.maxSpeed = 4; // m/s this.maxForce = 8; // m/s² — lower = lazier turns } // steer toward a point at full speed seek(target, out) { out.subVectors(target, this.position).setLength(this.maxSpeed) .sub(this.velocity).clampLength(0, this.maxForce); return out; } // seek, but decelerate inside `slowRadius` (stops AT the target) arrive(target, slowRadius, out) { out.subVectors(target, this.position); const d = out.length(); out.setLength(this.maxSpeed * Math.min(1, d / slowRadius)) .sub(this.velocity).clampLength(0, this.maxForce); return out; } integrate(force, dt) { this.velocity.addScaledVector(force, dt).clampLength(0, this.maxSpeed); this.position.addScaledVector(this.velocity, dt); } } ``` **Wander** (idle meandering): project a point ahead of the agent, jitter a target around a small circle there, `seek` it. Deterministic if the jitter uses the seeded RNG ([game-loop.md](game-loop.md) §7). Facing: agents look along velocity — `mesh.quaternion.setFromRotationMatrix(m.lookAt(ZERO, velocity, UP))`, slerped for smoothness. For instanced rendering, compose this into the per-instance matrix instead. ## 2. Boids — flocks, schools, herds Three forces over neighbors within a radius: | Rule | Force | Typical weight | |---|---|---| | **Separation** | away from too-close neighbors (1/d falloff) | 1.5 — highest, prevents clumping | | **Alignment** | match average neighbor velocity | 1.0 | | **Cohesion** | toward average neighbor position | 1.0 | ```javascript // per agent, per fixed tick: sum weighted rules + bounds-return force force.set(0, 0, 0) .addScaledVector(separation(agent, neighbors, 2.0), 1.5) .addScaledVector(alignment(agent, neighbors, 6.0), 1.0) .addScaledVector(cohesion(agent, neighbors, 6.0), 1.0) .addScaledVector(containment(agent, WORLD_BOUNDS), 2.0); agent.integrate(force, FIXED); ``` **The O(N²) wall.** Naive neighbor search dies around ~500 agents. Spatial hash — a `Map<cellKey, Agent[]>` rebuilt each tick, cell size = neighbor radius — restores O(N·k): ```javascript const CELL = 6.0; // == largest neighbor radius const key = (p) => `${(p.x / CELL) | 0},${(p.y / CELL) | 0},${(p.z / CELL) | 0}`; // rebuild each tick (cheap); query = own cell + 26 neighbors ``` Tuning that reads as *life*: per-agent `maxSpeed` jitter (±15%), a small species-specific vertical damping for birds/fish, and a rare "startle" impulse propagating through neighbors. ## 3. Waypoint actors — patrols, traffic, grazing routes State machine + `arrive`: ```javascript const route = { points: [...Vector3], loop: true }; // states: TRAVELING → (reached, d < 0.5) → DWELLING(t) → next waypoint ``` - Use **`arrive`, not `seek`**, at each waypoint — seek orbits the point forever at max speed. - Dwell timers (graze, look around) come from the seeded RNG so replays hold. - Layer a small wander force on top of route-following so paths aren't rail-straight. - Skinned actors: drive locomotion blend weight from `velocity.length()` ([animation.md](animation.md) §4) — walk/run/idle picks itself. - Ground clamping on terrain: raycast down (or sample the heightfield) per tick; physics is overkill for ambient walkers — reserve real character controllers ([physics.md](physics.md) §5) for gameplay-relevant actors. ## 4. Scaling ambient life | Population | Rendering | Simulation | |---|---|---| | ≤ ~50 skinned | `SkeletonUtils.clone` + own mixers | full steering per tick | | ~50–500 | LOD tiers: skinned near, InstancedMesh imposters far | full steering, spatial hash | | 500–10k (birds/fish/particles-with-brains) | one InstancedMesh, matrix per agent | spatial hash; consider half-rate ticks for far agents | | beyond | GPU (compute in shader) | out of scope here — see [genart-ops](../../genart-ops/SKILL.md) particles | Half-rate trick: agents beyond N meters tick every 2nd–4th fixed step (stagger by `index % 4`) — invisible at distance, quarters the sim cost. ## 5. When to reach for a library [yuka](https://mugen87.github.io/yuka/) (by a three.js maintainer) ships steering, FSMs, pathfinding, fuzzy logic — engine-agnostic, you sync its entities to meshes exactly like a physics world. Worth it once you need pathfinding/navmesh (`three-pathfinding` for navmesh queries) or goal-driven AI; below that, the ~100 lines above stay easier to tune and debug. -
animation.md 5.7 KB
# Animation system — AnimationMixer, crossfades, blending The three.js animation system is player (`AnimationMixer`) + clips (`AnimationClip`) + per-clip playback state (`AnimationAction`). It drives both **skeletal** animation (bones/skinning) and **morph targets** (blend shapes) through the same API. ## 1. Setup — one mixer per model root ```javascript const mixer = new THREE.AnimationMixer(model); // model = gltf.scene (or a clone) const actions = {}; for (const clip of gltf.animations) { actions[clip.name] = mixer.clipAction(clip); // cached — same clip returns same action } actions.Idle.play(); ``` Every frame (from the game loop — see [game-loop.md](game-loop.md)): ```javascript mixer.update(dt); // dt in SECONDS. Forgetting this = frozen model, no error. ``` Rules: - **One mixer per animated model instance.** Clips are shared; mixers and actions are not. Cloned characters (`SkeletonUtils.clone`) each get their own mixer. - Clip lookup by name: `THREE.AnimationClip.findByName(gltf.animations, 'Run')`. Names come from the DCC tool/Mixamo — `gltf.animations.map(c => c.name)` to see what you actually have. - Mixer time scales globally: `mixer.timeScale = 0` is a clean pause; `action.timeScale` scales one clip (negative plays it backwards). ## 2. Crossfading — the state-transition workhorse Never `.stop()` one action and `.play()` the next — that pops. Fade: ```javascript function fadeTo(next, duration = 0.3) { if (next === current) return; next.enabled = true; next.reset().play(); // reset: re-entering a faded-out action current.crossFadeTo(next, duration, true); // true = warp (sync time scales) current = next; } ``` - `reset()` matters: a previously faded-out action has `weight = 0` and a stale time; without reset the "fade in" shows nothing. - **Warp** (`true`) time-stretches the outgoing clip to match the incoming one's pace during the fade — use it for locomotion (walk↔run keeps footfalls aligned); skip it for unrelated transitions (idle → jump). - For locomotion trees, keep walk and run cycles authored at the same phase (both start on left foot) or the crossfade will slide feet regardless. ## 3. One-shot clips (jump, attack, death) ```javascript const jump = actions.Jump; jump.setLoop(THREE.LoopOnce, 1); jump.clampWhenFinished = true; // hold last frame instead of snapping to T-pose jump.reset().play(); mixer.addEventListener('finished', (e) => { if (e.action === jump) fadeTo(actions.Idle, 0.2); }); ``` `clampWhenFinished` without the `finished` handler is how characters get stuck mid-air: the clip holds its final pose forever. Always pair them. ## 4. Manual blend weights (locomotion blending) Crossfade is A→B. For continuous blends (idle/walk/run by speed), run all actions at once and drive weights yourself: ```javascript for (const a of [idle, walk, run]) { a.play(); a.setEffectiveWeight(0); } function setLocomotion(speed01) { // 0 = idle, 0.5 = walk, 1 = run idle.setEffectiveWeight(Math.max(0, 1 - speed01 * 2)); walk.setEffectiveWeight(1 - Math.abs(speed01 - 0.5) * 2); run.setEffectiveWeight(Math.max(0, speed01 * 2 - 1)); // keep cycles in phase: scale run's timeScale toward walk's cadence as weight shifts } ``` Weights are normalized by the mixer per property track — they don't need to sum to 1, but keeping them roughly normalized avoids under/over-shooting poses. ## 5. Additive layers (breathing, recoil, look-at on top of locomotion) ```javascript const additiveClip = THREE.AnimationUtils.makeClipAdditive(gltf2.animations[0].clone()); const layer = mixer.clipAction(additiveClip); layer.blendMode = THREE.AdditiveAnimationBlendMode; layer.play(); // plays ON TOP of whatever else runs ``` `makeClipAdditive` mutates the clip (subtracts the first frame as reference pose) — clone first if the original is also used normally. ## 6. Morph targets (blend shapes — faces, corrective shapes) Morph influences live on the mesh, indexed via `morphTargetDictionary`: ```javascript const face = model.getObjectByName('Head'); const i = face.morphTargetDictionary['mouthSmile']; face.morphTargetInfluences[i] = 0.8; // 0..1, animate directly per frame ``` - glTF exports morph names when the exporter enables it (Blender: shape keys export automatically); if `morphTargetDictionary` is missing names you get numeric indices only. - Animation clips can drive influences too (exported blend-shape animation plays through the mixer like any other clip) — manual driving and mixer driving fight over the same array; pick one per target. - Lip-sync/ARKit-style pipelines are just 52 named influences set per frame. ## 7. Skeletal specifics - Bones are plain `Object3D`s in the hierarchy — grab one to attach props: `model.getObjectByName('mixamorigRightHand').add(sword)`. - Procedural bone control (head look-at) must run **after** `mixer.update(dt)` in the frame, or the mixer overwrites it. Set `bone.quaternion` post-update, and call `bone.updateMatrixWorld(true)` if you read it back. - Retargeting clips between skeletons: `SkeletonUtils.retargetClip` exists but is finicky about rest poses — prefer authoring/converting via Mixamo or Blender so all characters share one rig. ## 8. Root motion vs in-place Game locomotion normally uses **in-place** clips (Mixamo checkbox: "In Place") with movement applied by code/physics ([physics.md](physics.md)). If a clip has baked root motion and you move the character too, it double-translates. Either strip the root track: ```javascript clip.tracks = clip.tracks.filter(t => !t.name.startsWith('mixamorigHips.position')); ``` …or don't move the object while that clip plays. Rotation-only root tracks are usually fine to keep. -
asset-pipeline.md 6.2 KB
# GLTF asset pipeline glTF 2.0 (`.glb` binary) is three.js's first-class asset format: PBR materials, skeletal + morph animation, extensions for every compression scheme. Everything else (FBX, OBJ) gets converted **to** glTF in the pipeline, not loaded at runtime. ## 1. Loader wiring — all three decoders, once An optimized `.glb` may use any combination of DRACO (geometry), KTX2/BasisU (textures), and meshopt (geometry + animation). Wire all three at startup so the loader handles anything; unused decoders cost nothing until a file needs them. ```javascript import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'; import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; export function makeGLTFLoader(renderer) { const draco = new DRACOLoader() // Google-hosted WASM decoder; or self-host three's copy from // node_modules/three/examples/jsm/libs/draco/gltf/ .setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.7/'); const ktx2 = new KTX2Loader() .setTranscoderPath('https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/libs/basis/') .detectSupport(renderer); // REQUIRED, and requires the real renderer: // picks the GPU's compressed format (ASTC/BC7/ETC2) return new GLTFLoader() .setDRACOLoader(draco) .setKTX2Loader(ktx2) .setMeshoptDecoder(MeshoptDecoder); } ``` Gotchas: - `detectSupport(renderer)` **before** the first load, with the renderer you'll actually render with. Forgetting it = "KTX2Loader: no supported transcoder" at load time. - Decoder paths must end with a trailing `/`. - Self-hosting beats CDN for production (offline, CSP, version lock): copy `three/examples/jsm/libs/draco/gltf/` and `libs/basis/` into your static dir and point the paths there. - `loadAsync` returns `{ scene, animations, cameras, asset, parser }` — the model is `gltf.scene`, clips are `gltf.animations` (they are NOT attached to the scene). ## 2. Instantiating characters — SkeletonUtils `gltf.scene.clone()` on a skinned mesh produces clones whose bones still point at the original skeleton — animations play on one and glitch on the rest. Use: ```javascript import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js'; const soldier2 = SkeletonUtils.clone(gltf.scene); // deep clone incl. skeleton ``` Each clone needs its **own** `AnimationMixer` (see [animation.md](animation.md)); the `AnimationClip`s themselves are shared safely. For **many** copies of a static (non-skinned) model, don't clone at all — extract geometry + material and build an `InstancedMesh` (see [scale-and-disposal.md](scale-and-disposal.md)). ## 3. Optimizing assets — do it offline, not at runtime Two CLI tools; both read/write `.glb`. Run them in the asset build step, commit the optimized output. ### gltf-transform (`@gltf-transform/cli`) — the scriptable toolbox ```bash npm i -g @gltf-transform/cli # The 90% command — resample animation, prune, dedupe, instance, compress: gltf-transform optimize in.glb out.glb --compress draco --texture-compress ktx2 # Individual passes when you need control: gltf-transform draco in.glb out.glb # geometry → DRACO gltf-transform meshopt in.glb out.glb # geometry+anim → meshopt gltf-transform etc1s in.glb out.glb # textures → KTX2 (small, lossy-ish) gltf-transform uastc in.glb out.glb --slots "{normalTexture}" # normals need UASTC quality gltf-transform resize in.glb out.glb --width 1024 --height 1024 gltf-transform inspect in.glb # what's actually in this file ``` KTX2 encoding requires the KTX-Software `toktx` binary on PATH for some modes; `etc1s`/`uastc` commands bundle an encoder. Rule of thumb: **ETC1S** for color/albedo (4–8× smaller in GPU memory), **UASTC** for normal maps (ETC1S artifacts wreck lighting). ### gltfpack (meshoptimizer) — the one-shot compressor ```bash npm i -g gltfpack gltfpack -i in.glb -o out.glb -cc -tc # -cc meshopt compression, -tc KTX2 textures ``` Fastest path to a small file; meshopt decodes faster than DRACO at runtime. Trade-off: DRACO usually wins on wire size for dense static meshes; meshopt wins on decode speed and also compresses animation data. Either is a large win over raw; don't ship uncompressed `.glb` above ~1 MB. ### Choosing | Situation | Choice | |---|---| | Dense static scenery, wire size is king | DRACO | | Characters/animation, decode speed matters, mobile | meshopt (gltfpack) | | Texture-heavy anything | KTX2 always — GPU memory, not just download, shrinks | ## 4. CC0 / freely-licensed model sources | Source | What | License | |---|---|---| | [Quaternius](https://quaternius.com) | Stylized low-poly packs — characters, animals, buildings, many **rigged + animated** | CC0 | | [Kenney](https://kenney.nl/assets) | Huge coherent low-poly sets — city, nature, cars, UI | CC0 | | [Poly Pizza](https://poly.pizza) | Searchable aggregator (incl. ex-Google Poly); filter CC0 | per-model (mostly CC0/CC-BY) | | [Sketchfab](https://sketchfab.com) | Everything; filter "Downloadable" + CC0/CC-BY | per-model — check each | | [ambientCG](https://ambientcg.com) | PBR **texture** sets (albedo/normal/roughness) | CC0 | | [Mixamo](https://www.mixamo.com) | Auto-rigging + humanoid animation clips (FBX → convert to glb) | free with Adobe account, not CC0 | Pipeline for game-ready ambient life: Quaternius rigged animal pack → `gltf-transform optimize` → `SkeletonUtils.clone` per individual → mixer crossfades ([animation.md](animation.md)) → steering ([actors-and-steering.md](actors-and-steering.md)). ## 5. Loading UX - `loader.loadAsync(url)` + `Promise.all` for parallel loads; wrap in one `LoadingManager` for a progress bar (`manager.onProgress(url, n, total)`). - Show the scene only after first render, not after load — compile shaders up front with `await renderer.compileAsync(scene, camera)` to avoid the first-frame hitch when a big material graph compiles mid-gameplay. - Cache cross-page with normal HTTP caching; `THREE.Cache.enabled = true` only dedupes within a session and holds raw file data in memory — usually skip it. -
game-loop.md 7 KB
# Game loops — fixed timestep, dt discipline, determinism The loop is the spine of the app. Three decisions: how you schedule frames, how you measure time, and whether simulation runs on render time or its own clock. ## 1. Scheduling: `renderer.setAnimationLoop`, not raw rAF ```javascript renderer.setAnimationLoop(animate); // start renderer.setAnimationLoop(null); // stop (teardown!) ``` Why over `requestAnimationFrame(animate)` recursion: - **WebXR requires it** — in XR the browser's rAF is replaced by the XR session's frame callback; `setAnimationLoop` switches automatically. - One call site to stop the loop (`null`) — critical for SPA teardown (see [scale-and-disposal.md](scale-and-disposal.md)). - The callback receives a `DOMHighResTimeStamp` — use it; don't call `performance.now()` again inside the frame. ## 2. Delta time: measure, clamp, never trust ```javascript let last = 0; renderer.setAnimationLoop((now) => { const dt = Math.min((now - last) / 1000, 0.1); // seconds, clamped last = now; update(dt); renderer.render(scene, camera); }); ``` - **Clamp dt** (50–100 ms max). Background tabs throttle rAF to ~1 Hz or stop it entirely; on return, dt is seconds-to-minutes and one frame of `position += velocity * dt` teleports everything through walls. - Never assume 60 Hz. 120/144 Hz displays are common; uncapped `dt`-free code (`position.x += 0.01` per frame) runs 2.4× too fast there. - `THREE.Timer` (core since r163) packages this: `timer.update()` + `timer.getDelta()`, and `timer.connect(document)` auto-handles the tab-switch spike by listening to visibility itself. ## 3. Fixed timestep + interpolation (the Gaffer pattern) Physics and gameplay logic that must behave identically at 60 / 120 / 24 fps run on a **fixed step**, decoupled from render rate; rendering interpolates between the last two sim states: ```javascript const FIXED = 1 / 60; let acc = 0, last = 0; renderer.setAnimationLoop((now) => { acc += Math.min((now - last) / 1000, 0.1); last = now; while (acc >= FIXED) { previousState.copy(currentState); // snapshot for interpolation simulate(FIXED); // physics.step, AI, gameplay acc -= FIXED; } const alpha = acc / FIXED; // 0..1 — how far into the next tick mesh.position.lerpVectors(previousState.position, currentState.position, alpha); mesh.quaternion.slerpQuaternions(previousState.quaternion, currentState.quaternion, alpha); mixer.update(dtRender); // animation runs on RENDER time — smoother renderer.render(scene, camera); }); ``` Notes: - The `while` loop caps itself via the dt clamp — without the clamp, a long stall queues hundreds of sim ticks (the "spiral of death"). - Rapier: set `world.timestep = FIXED` once and call `world.step()` inside the while — never pass render dt to a physics step ([physics.md](physics.md)). - Skipping interpolation is fine when FIXED ≥ display rate; at 60 Hz sim on a 144 Hz display, uninterpolated motion visibly stutters. - Cosmetic systems (animation mixers, particles, camera smoothing) stay on render dt; only *stateful simulation* needs the fixed clock. ## 4. Pause / visibility rAF stops in hidden tabs but not in occluded or backgrounded *windows* consistently across platforms — pause explicitly: ```javascript document.addEventListener('visibilitychange', () => { paused = document.hidden; if (!paused) last = performance.now(); // swallow the away-time }); ``` - Resetting `last` on resume is the other half of the dt clamp — otherwise the first visible frame still sees the whole away period. - Pause = stop **simulating**, keep rendering one last frame; also mute audio and suspend `AudioContext`. - `THREE.Timer.connect(document)` implements exactly this for its delta. ## 5. Input sampling DOM input events fire asynchronously, between frames — never mutate game state in the handler. Handlers write to a state object; the loop reads it: ```javascript const input = { keys: new Set(), pointer: new THREE.Vector2(), pointerDown: false }; addEventListener('keydown', (e) => { if (!e.repeat) input.keys.add(e.code); }); addEventListener('keyup', (e) => input.keys.delete(e.code)); addEventListener('blur', () => input.keys.clear()); // alt-tab = stuck keys otherwise // inside the fixed tick: const forward = input.keys.has('KeyW') ? 1 : 0; ``` - Use `e.code` (physical key — `KeyW` works on AZERTY), not `e.key`. - The `blur` clear matters: without it, releasing a key while the window is unfocused leaves it "held" forever. - One-shot presses (jump) need edge detection: track `justPressed` by diffing against the previous tick's set, or consume the key on read. - Mouse-look wants the Pointer Lock API — drei's `<PointerLockControls>` or three's addon of the same name wrap it. - Sampling input in the **fixed tick** (not per rAF) keeps replays/determinism intact (§7): the same tick always sees the same input snapshot. ## 6. Smoothing — frame-rate-independent damping `value += (target - value) * 0.1` per frame is the classic bug: the smoothing speed changes with the frame rate (2.4× stiffer at 144 Hz). The correct, dt-aware form is exponential decay — built in as `THREE.MathUtils.damp`: ```javascript // λ ≈ 1/seconds-to-close-63%-of-the-gap; bigger = snappier camera.position.x = THREE.MathUtils.damp(camera.position.x, target.x, 4, dt); ``` The third-person follow camera in four lines: ```javascript const behind = player.localToWorld(new THREE.Vector3(0, 2.5, -5)); // offset in player space camera.position.lerp(behind, 1 - Math.exp(-4 * dt)); // same math as damp camera.lookAt(player.position); ``` Use damping for cameras, UI-ish motion, and audio params; never for physics bodies (the solver owns those — see [physics.md](physics.md)). ## 7. Determinism Fixed timestep is necessary but not sufficient. For replays, lockstep networking, or reproducible tests: - **Seeded RNG** in sim code — never `Math.random()`. A 10-line mulberry32 is enough: same seed → same run. - Keep sim state separate from render state (positions in your own structures or the physics world; meshes are a *view*). - Iterate collections in deterministic order (arrays, not Set/Map insertion assumptions across saves). - Float math is deterministic on one machine/build but NOT bit-identical across browsers/CPUs — cross-machine lockstep needs quantized state or accepting drift + correction. - Rapier is deterministic given identical inputs, same-order world construction, and the same WASM build — one of the reasons it's the default ([physics.md](physics.md)). ## 8. Frame budget quick reference At 60 fps the whole frame is **16.6 ms**; browsers need ~2 ms, leaving ~14 ms for sim + render. Measure before optimizing: ```javascript renderer.info.render; // { calls, triangles, points, lines } — per frame ``` Draw calls, not triangles, are usually the ceiling — see [scale-and-disposal.md](scale-and-disposal.md) for the budget playbook. -
physics.md 6 KB
# Physics — rapier (default) vs cannon-es three.js has no physics; you bolt on an engine and mirror its bodies into meshes. Two realistic choices in 2026: | | `@dimforge/rapier3d` | `cannon-es` | |---|---|---| | Implementation | Rust → WASM | Pure JS | | Performance | Fast; hundreds of dynamic bodies | OK for dozens | | Determinism | Yes (same inputs + build) | No guarantee | | Character controller | **Built-in kinematic controller** | Roll your own | | Trimesh/convex support | Strong (colliders from any mesh) | Weak trimesh; prefer primitives | | Setup | WASM init step | `import` and go | | Maintenance | Active (Dimforge) | Community fork of dead cannon.js; low activity | | R3F wrapper | `@react-three/rapier` | `@react-three/cannon` | **Default to rapier.** Pick cannon-es only when WASM is genuinely unacceptable (exotic CSP, some embedded webviews) or the sim is trivial (a few boxes). ## 1. Rapier init — the package-name trap Two npm packages, same API, different WASM delivery: ```javascript // A) Bundler (Vite/webpack) — WASM as a real .wasm file, streamed + cached: import RAPIER from '@dimforge/rapier3d'; // B) No bundler / import map / CDN — WASM inlined as base64 (bigger, simpler): import RAPIER from '@dimforge/rapier3d-compat'; await RAPIER.init(); // compat REQUIRES this before any use ``` Vite handles (A) out of the box. If a bundler chokes on the wasm import, (B) works everywhere at ~1.5× the download. Symptom of forgetting `RAPIER.init()`: `undefined` errors deep inside the first `new RAPIER.World`. ## 2. World + stepping (inside the fixed loop) ```javascript const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 }); world.timestep = FIXED; // match the fixed step — set once // inside the fixed-timestep while-loop (see game-loop.md): world.step(); ``` Never step with render dt: rapier tuning (CCD, solver iterations) assumes a stable timestep, and variable stepping breaks determinism. ## 3. Body types — which one for what | Type | Moves by | Pushed by others? | Use for | |---|---|---|---| | `fixed` | never | no | Ground, walls, static scenery | | `dynamic` | solver (forces/impulses) | yes | Crates, ragdolls, projectiles, debris | | `kinematicPositionBased` | you set next position | no (it pushes *them*) | Player characters, moving platforms, elevators | | `kinematicVelocityBased` | you set velocity | no | Conveyor-ish movers where velocity is the natural control | The classic mistake is a **dynamic player capsule**: it trips on edges, gets shoved by props, and fights the controller. Player characters are kinematic; the environment reacts dynamically. ```javascript // dynamic crate const body = world.createRigidBody( RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 5, 0)); world.createCollider(RAPIER.ColliderDesc.cuboid(0.5, 0.5, 0.5), body); // fixed ground world.createCollider(RAPIER.ColliderDesc.cuboid(50, 0.1, 50), world.createRigidBody(RAPIER.RigidBodyDesc.fixed())); ``` Colliders from loaded models: `ColliderDesc.trimesh(vertices, indices)` for static scenery (never for dynamic bodies — use `convexHull(vertices)` or primitive approximations there). ## 4. Syncing bodies → meshes The physics world is the source of truth; meshes are the view: ```javascript // after world.step(), for each (body, mesh) pair: mesh.position.copy(body.translation()); mesh.quaternion.copy(body.rotation()); ``` - Keep an explicit `pairs: Array<{body, mesh}>` — don't hang references off `mesh.userData` and traverse the scene per frame. - With interpolation (fixed step < render rate), copy into `currentState` instead and lerp in the render pass ([game-loop.md](game-loop.md)). - Scale is NOT synced — physics has no scale; bake scale into collider sizes at creation. ## 5. Character controller (rapier's built-in) Kinematic body + `KinematicCharacterController` = walking, slopes, steps, and sliding along walls without hand-rolled raycasts: ```javascript const controller = world.createCharacterController(0.01); // skin offset controller.enableAutostep(0.4, 0.2, true); // max step height, min width, dynamic-ok controller.enableSnapToGround(0.4); // stick to ramps when walking down controller.setMaxSlopeClimbAngle(50 * Math.PI / 180); // per fixed tick: const desired = inputDirection.multiplyScalar(speed * FIXED); desired.y = verticalVelocity * FIXED; // integrate your own gravity/jump controller.computeColliderMovement(collider, desired); const corrected = controller.computedMovement(); // slid along obstacles const p = body.translation(); body.setNextKinematicTranslation({ x: p.x + corrected.x, y: p.y + corrected.y, z: p.z + corrected.z }); const grounded = controller.computedGrounded(); // gate jumping on this ``` You own gravity and jump velocity (kinematic bodies ignore world gravity): `verticalVelocity -= 9.81 * FIXED` each tick, zero it when `computedGrounded()`. ## 6. cannon-es essentials (when you must) ```javascript import * as CANNON from 'cannon-es'; const world = new CANNON.World({ gravity: new CANNON.Vec3(0, -9.82, 0) }); const body = new CANNON.Body({ mass: 1, shape: new CANNON.Box(new CANNON.Vec3(.5, .5, .5)) }); world.addBody(body); // fixed tick: world.fixedStep(); // internal 1/60 accumulator mesh.position.copy(body.position); mesh.quaternion.copy(body.quaternion); ``` Stick to primitive shapes (Box/Sphere/Cylinder) and compound bodies; `Trimesh` in cannon-es only reliably collides against spheres/planes. No character controller — kinematic body + manual raycast for ground checks. ## 7. Debugging Render the physics world, not your assumptions: - Rapier: `world.debugRender()` returns line vertices/colors — feed a `LineSegments` with `BufferGeometry` each frame (or use `@react-three/rapier`'s `<Debug />`). - Mismatched visuals ↔ colliders are 90% of "physics is broken": check half-extents (rapier cuboids take **half** sizes), baked scale, and center offsets. - Bodies falling asleep: `RigidBodyDesc.setCanSleep(false)` while debugging, re-enable for perf. -
r3f-drei.md 5.4 KB
# react-three-fiber + drei R3F expresses the three.js scene graph as React components. It is **not a wrapper library** — every three.js class is available as a lowercase JSX element (`<mesh>`, `<boxGeometry>`), constructor args via `args`, properties as props. Version reality: **R3F v9 (`@react-three/fiber`) requires React 19** (v8 ↔ React 18); drei v10 (`@react-three/drei`) pairs with R3F v9; `three` peer is `>=0.156`. ## 1. When R3F, when vanilla | Signal | Choice | |---|---| | Page is already a React app; 3D is a feature within it | **R3F** | | Heavy HTML UI ↔ scene interplay (state, routing, forms) | **R3F** | | You want the pmndrs stack (drei, @react-three/rapier, postprocessing, xr) | **R3F** | | No React on the page; a widget/embed; minimal bundle | **vanilla** | | Engine-style control, custom render pipeline, non-React team | **vanilla** | | Generative art sketch | vanilla ([genart-ops](../../genart-ops/SKILL.md)) | R3F renders outside React's reconciler per frame — the framework overhead is at mount/update time, not per frame. Performance is not the deciding axis until you're re-rendering the React tree needlessly (see pitfalls). ## 2. Core model ```jsx import { Canvas, useFrame, useThree } from '@react-three/fiber'; function SpinningBox(props) { const ref = useRef(); useFrame((state, delta) => { ref.current.rotation.y += delta; }); // the game loop return ( <mesh ref={ref} {...props}> <boxGeometry args={[1, 1, 1]} /> {/* args = constructor arguments */} <meshStandardMaterial color="tomato" /> </mesh> ); } export default () => ( <Canvas camera={{ position: [0, 2, 5], fov: 60 }} shadows> <ambientLight intensity={0.4} /> <directionalLight position={[5, 10, 5]} castShadow /> <SpinningBox position={[0, 1, 0]} /> </Canvas> ); ``` - `<Canvas>` owns renderer/scene/camera/loop; it fills its parent — **size the parent** (`<div style={{height:'100vh'}}>`), the eternal "blank canvas" bug. - Dashed props set nested properties: `position-y={2}`, `rotation-x={Math.PI/2}`, `material-color="hotpink"`. - `useThree()` exposes `{ gl, scene, camera, size, ... }` inside the Canvas. - Loading: `const { scene, animations } = useGLTF('/model.glb')` (drei) — Suspense-based, wrap in `<Suspense fallback={...}>`. ## 3. The pitfalls that actually bite 1. **Never `setState` per frame.** `useFrame` + mutate refs. React state is for discrete changes (mode, selection), not continuous motion. Per-frame `setState` re-renders the tree at 60 Hz and craters. 2. **No `new THREE.X()` in render without memo.** Inline `new Vector3()` in JSX props allocates every render; hoist or `useMemo`. Prop shorthand (`position={[0,1,0]}`) is fine — arrays are diffed. 3. **Disposal is automatic — mostly.** Unmounting disposes objects R3F created from JSX. Objects you created imperatively (`useMemo(() => new Texture(...))`) or loaded manually are yours to dispose ([scale-and-disposal.md](scale-and-disposal.md)). `useGLTF` caches globally — cached assets survive unmount by design. 4. **`useLoader`/`useGLTF` cache by URL.** Two components loading the same URL share one instance — mutate a material in one and both change. Clone (or drei's `<Clone>`) for independent copies; skinned characters still need `SkeletonUtils.clone` semantics (drei `useGLTF` + `<Clone>` handles it). 5. **Events are built in** — `<mesh onClick={e => ...} onPointerOver={...}>` does raycasting for you; `e.stopPropagation()` respects occlusion. Don't hand-roll a `Raycaster` in R3F. 6. **Frameloop control:** static scenes set `<Canvas frameloop="demand">` + `invalidate()` on change — stops burning battery at idle. ## 4. drei shortlist (the ones worth knowing exist) | Helper | Replaces hand-rolling | |---|---| | `<OrbitControls makeDefault />` | controls wiring + camera event plumbing | | `useGLTF` / `useTexture` / `useKTX2` | loader + Suspense + caching (incl. DRACO/KTX2 paths) | | `<Environment preset="sunset" />` | HDRI IBL lighting setup | | `<Instances>` / `<Merged>` | InstancedMesh bookkeeping as JSX children | | `<Html>` | DOM elements tracking 3D positions (labels, health bars) | | `<Text>` (troika SDF) | crisp 3D text without geometry fonts | | `<KeyboardControls>` | input state map for games | | `<PerspectiveCamera makeDefault>` | camera-as-component, animatable | | `<ContactShadows>` / `<AccumulativeShadows>` | cheap grounded-look shadows | | `<Stats>` / `<Perf>` (r3f-perf) | fps/draw-call HUD | | `<Detailed distances={[0,10,20]}>` | THREE.LOD as JSX | | `<Bvh>` | three-mesh-bvh accelerated raycasting for big scenes | ## 5. Ecosystem for games - **`@react-three/rapier`** — declarative rapier ([physics.md](physics.md)): `<Physics>` + `<RigidBody type="dynamic">` around meshes; `<Debug />` renders colliders. Runs the fixed-step loop for you. - **`@react-three/postprocessing`** — effect composer as components. - **`ecctrl`** (pmndrs) — ready-made character controller on @react-three/rapier: capsule + camera rig + WASD. - **zustand** — the pmndrs state library; game state lives outside React, `useFrame` reads it via `getState()` without subscribing (no re-renders). Vanilla knowledge transfers 1:1 — R3F components are the same objects; a ref gives you the real `THREE.Mesh`, and everything in the other references (animation mixers, fixed timestep, disposal) applies unchanged inside `useFrame`/`useEffect`. -
scale-and-disposal.md 7.1 KB
# Scale patterns — draw calls, instancing, LOD, and disposal discipline Two failure modes as scenes grow: **frame time** (too many draw calls) and **memory** (GPU resources never freed). Both are measurable — never optimize blind. ## 1. The meter: `renderer.info` ```javascript renderer.info.render; // { calls, triangles, points, lines } — resets per frame renderer.info.memory; // { geometries, textures } — live GPU allocations ``` - **Draw calls** are the usual ceiling, not triangles. A modern GPU eats millions of triangles; a thousand `drawElements` calls of CPU overhead is what drops frames. Budget order-of-magnitude: **≤ ~200 calls mobile, ≤ ~1000 desktop** — then measure. - `info.memory` is the leak detector: after any teardown (level unload, route change), `geometries`/`textures` must return to baseline. If they climb per reload, you have a disposal bug (§5). Each visible `Mesh` = ≥1 draw call (×N for multi-material). The scale toolkit below is all about collapsing that count. ## 2. InstancedMesh — same geometry × N in one draw call The single highest-leverage tool. Threshold: **~50+ copies** of the same geometry+material → instance them; at 1,000+ it's not optional. ```javascript const mesh = new THREE.InstancedMesh(geometry, material, COUNT); mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); // if updated per frame const m = new THREE.Matrix4(), p = new THREE.Vector3(), q = new THREE.Quaternion(), s = new THREE.Vector3(1, 1, 1); for (let i = 0; i < COUNT; i++) { p.set(rand(), 0, rand()); q.setFromAxisAngle(UP, rand() * Math.PI * 2); mesh.setMatrixAt(i, m.compose(p, q, s)); } mesh.instanceMatrix.needsUpdate = true; // after ANY setMatrixAt batch scene.add(mesh); ``` - Forgetting `instanceMatrix.needsUpdate = true` = nothing moves, no error. - Per-instance color: `setColorAt(i, color)` + `instanceColor.needsUpdate` (material color must be white to show through). - Frustum culling is all-or-nothing per InstancedMesh: three culls the whole batch by its bounding sphere. Compute it (`mesh.computeBoundingSphere()`) after placing instances, or set `frustumCulled = false` if instances span the whole world. - "Remove" an instance by scaling its matrix to 0 or compacting `count` (draw first N only: `mesh.count = liveCount`). - Raycasting works (`intersection.instanceId`); at large counts add three-mesh-bvh. - Skinned meshes can't be instanced this way — crowds of characters use a few LOD tiers of real clones, or vertex-animation-texture techniques. Related: `BufferGeometryUtils.mergeGeometries([...])` bakes *different static* geometries sharing one material into one draw call (loses per-object visibility/movement — right for scenery, wrong for actors). ## 3. LOD ```javascript const lod = new THREE.LOD(); lod.addLevel(highMesh, 0); // used when distance < 25 lod.addLevel(midMesh, 25); lod.addLevel(lowMesh, 60); lod.addLevel(new THREE.Object3D(), 150); // empty = culled beyond 150 scene.add(lod); // lod.update(camera) is automatic in the renderer ``` - Generate levels offline: `gltf-transform simplify` (meshoptimizer under the hood) at ~50% / ~15% ratios ([asset-pipeline.md](asset-pipeline.md)). - LOD multiplies draw calls per object (only one level renders, but each LOD object is still its own call) — combine with instancing by keeping one InstancedMesh **per LOD tier** and re-bucketing instances by camera distance every few hundred ms, not per frame. - Texture LOD is free (mipmaps); geometry LOD is what you manage. ## 4. Culling - **Frustum culling is on by default** per object (`frustumCulled = true`), tested against `geometry.boundingSphere`. Objects whose vertices move in a shader (GPU wind, displacement) pop out at screen edges — their CPU-side bounds are stale; fix bounds or disable culling for those. - **Occlusion culling does not exist built-in.** Options, in effort order: don't need it (most games) → cells/rooms you toggle by player zone → distance fog + far-plane pull-in → `WebGLRenderer` occlusion queries via raycast heuristics or three-mesh-bvh visibility checks. If genuinely occlusion-bound indoors, structure the level into portals/zones manually. - Shadows have their own scene walk: set `castShadow`/`receiveShadow` deliberately, keep the shadow camera tight, and give distant scenery `castShadow = false` — shadow draw calls count double. ## 5. Disposal discipline — GPU memory is manual **`scene.remove(obj)` frees nothing.** JS GC cannot see GPU buffers; three holds them until you call `.dispose()`. The contract: | Resource | Free with | |---|---| | `BufferGeometry` | `geometry.dispose()` | | `Material` | `material.dispose()` — does **not** dispose its textures | | `Texture` (every map: `map`, `normalMap`, `envMap`, …) | `texture.dispose()` | | Render targets | `renderTarget.dispose()` | | Skeletons (skinned) | `mesh.skeleton.dispose()` (frees boneTexture) | | The renderer itself | `renderer.dispose()` + `renderer.setAnimationLoop(null)` | The traversal that gets all of it: ```javascript function disposeObject(root) { root.traverse((o) => { o.geometry?.dispose(); o.skeleton?.dispose?.(); const mats = Array.isArray(o.material) ? o.material : [o.material]; for (const m of mats) { if (!m) continue; for (const v of Object.values(m)) v?.isTexture && v.dispose(); m.dispose(); } }); root.removeFromParent(); } ``` - **Shared resources**: dispose only when the *last* user goes away — either refcount, or (simpler) treat shared assets as app-lifetime and dispose only per-level objects. - **SPA teardown** (React/Vue route unmount) is the classic leak: the full sequence is stop loop → `disposeObject(scene)` → `renderer.dispose()` → remove canvas → drop all references. Verify with `renderer.info.memory` before/after. Losing the WebGL context entirely: `renderer.forceContextLoss()` as the nuclear last step. - Materials replaced at runtime (`mesh.material = newMat`) leak the old one — dispose what you swap out. - R3F: JSX-created objects auto-dispose on unmount; anything you `new` or load imperatively is still yours ([r3f-drei.md](r3f-drei.md)). ## 6. Texture memory (the other half of "why is GPU memory huge") - A 4096² RGBA texture is **64 MB+ with mipmaps** — before compression. KTX2/BasisU stays compressed *on the GPU* (4–8× less) ([asset-pipeline.md](asset-pipeline.md)); PNG/JPG decompress to full size. - Cap sizes: 1024² covers most game props; 2048² for hero assets. - `renderer.capabilities.maxTextureSize` on mobile can be 4096 — larger inputs get silently downscaled (slow) or fail. ## 7. Scaling checklist (in order) 1. Measure: `renderer.info.render.calls` + a frame profiler. 2. Instance repeated meshes; merge static same-material scenery. 3. Compress textures to KTX2; cap resolutions. 4. LOD (or delete) distant detail; pull in the far plane + fog. 5. Tighten shadows (map size, camera bounds, who casts). 6. `frameloop="demand"` / render-on-change for non-game scenes. 7. Only then: material/shader-level work (see [genart-ops](../../genart-ops/SKILL.md) for the shader side).
-
-
scripts
-
check-three-facts.py 13.9 KB
#!/usr/bin/env python3 # Staleness verifier for the fast-moving facts the threejs-ops skill encodes. # # Two modes (SKILL-RESOURCE-PROTOCOL.md §7): # --offline (default): NO network. Asserts the skill is internally consistent — # assets/three-facts.json parses, the version gates # (examples/js removed r148, UMD builds removed r160) are # stated in SKILL.md, the npm 0.<release> scheme example is # arithmetically coherent, every package the facts file # commits to is documented somewhere in the skill, and the # importmap-starter.html import map parses with both "three" # entries pinned to the same version. Runs in PR CI, MAY block. # --live: network. Probes the npm registry: every committed package # must still resolve (a 404 means renamed/removed = drift), # and three's latest dist-tag must still use major 0 # (a 1.x would be the API-break signal that the whole skill # needs a review pass). Runs in the scheduled freshness # workflow and NEVER blocks a PR: transient network failure # is UNAVAILABLE (exit 7); only confirmed change is DRIFT (10). # # Usage: check-three-facts.py [--offline|--live] [--json] [-q] [--timeout SEC] # Input: none (reads the skill's own assets/ + references/ relative to this file) # Output: stdout = data only (TSV findings, or the --json envelope) # Stderr: headers, progress, warnings, errors # Exit: 0 ok, 2 usage, 3 not-found (skill files missing), 4 validation # (offline inconsistency), 7 unavailable (live network), 10 drift # # Examples: # check-three-facts.py --offline # check-three-facts.py --offline --json | jq '.data[] | select(.status!="ok")' # check-three-facts.py --live --timeout 15 """Staleness verifier for threejs-ops (see header comment).""" from __future__ import annotations import argparse import json import re import sys from pathlib import Path from urllib.parse import quote EXIT_OK = 0 EXIT_USAGE = 2 EXIT_NOT_FOUND = 3 EXIT_VALIDATION = 4 EXIT_UNAVAILABLE = 7 EXIT_DRIFT = 10 SCHEMA = "claude-mods.threejs-ops.facts/v1" SKILL_ROOT = Path(__file__).resolve().parent.parent FACTS = SKILL_ROOT / "assets" / "three-facts.json" STARTER = SKILL_ROOT / "assets" / "importmap-starter.html" REFS = SKILL_ROOT / "references" SKILL_MD = SKILL_ROOT / "SKILL.md" REGISTRY = "https://registry.npmjs.org" class Finding: __slots__ = ("check", "status", "detail") def __init__(self, check: str, status: str, detail: str) -> None: self.check = check self.status = status # ok | fail | drift | unavailable self.detail = detail def as_dict(self) -> dict: return {"check": self.check, "status": self.status, "detail": self.detail} class _NotFound(Exception): pass def read_text(path: Path) -> str: return path.read_text(encoding="utf-8", errors="replace") def load_facts(findings: list[Finding]) -> dict | None: try: facts = json.loads(read_text(FACTS)) findings.append(Finding("facts-json", "ok", "three-facts.json parses")) return facts except json.JSONDecodeError as exc: findings.append(Finding("facts-json", "fail", f"invalid JSON: {exc}")) return None # --------------------------------------------------------------------------- # # Offline checks # # --------------------------------------------------------------------------- # def run_offline(findings: list[Finding]) -> None: missing = [p for p in (FACTS, STARTER, SKILL_MD, REFS) if not p.exists()] if missing: for p in missing: findings.append(Finding("files-present", "fail", f"missing: {p}")) raise _NotFound() facts = load_facts(findings) if facts is None: return # nothing else is checkable skill_md = read_text(SKILL_MD) all_docs = skill_md + "".join(read_text(p) for p in sorted(REFS.glob("*.md"))) # O1 — schema + as_of stamped. if facts.get("schema") == SCHEMA and re.match(r"\d{4}-\d{2}-\d{2}$", facts.get("as_of", "")): findings.append(Finding("facts-meta", "ok", f"schema {SCHEMA}, as_of {facts['as_of']}")) else: findings.append(Finding("facts-meta", "fail", f"schema={facts.get('schema')!r} as_of={facts.get('as_of')!r}")) # O2 — every version gate the facts commit to is stated in SKILL.md. gates = facts.get("version_gates", {}) gate_items = [(k, v) for k, v in gates.items() if k != "_comment"] if len(gate_items) < 2: findings.append(Finding("gates", "fail", f"expected >= 2 version gates, got {len(gate_items)}")) for key, gate in gate_items: if not gate or not re.match(r"r\d{3}$", str(gate)): findings.append(Finding(f"gate:{key}", "fail", f"malformed gate {gate!r} in facts")) elif re.search(rf"\*\*{gate}\*\*|\b{gate}\b", skill_md): findings.append(Finding(f"gate:{key}", "ok", f"{gate} stated in SKILL.md")) else: findings.append(Finding(f"gate:{key}", "fail", f"{gate} not stated in SKILL.md")) # O3 — npm scheme example arithmetic: "three@0.NNN.p is rNNN". scheme = facts.get("npm_scheme", {}) m = re.match(r"three@0\.(\d+)\.\d+ is r(\d+)$", scheme.get("example", "")) if m and m.group(1) == m.group(2) and scheme.get("major") == 0: findings.append(Finding("npm-scheme", "ok", f"example coherent ({scheme['example']})")) else: findings.append(Finding("npm-scheme", "fail", f"example {scheme.get('example')!r} incoherent or major != 0")) # O4 — every committed package is documented somewhere in the skill prose. pkgs = facts.get("packages", {}) if not pkgs: findings.append(Finding("packages", "fail", "facts commit to zero packages")) for name in pkgs: if name in all_docs: findings.append(Finding(f"pkg-documented:{name}", "ok", "mentioned in SKILL.md/references")) else: findings.append(Finding(f"pkg-documented:{name}", "fail", "in facts but never mentioned in skill prose")) # O5 — importmap starter: the import map parses, has both entries, pins one version. starter = read_text(STARTER) im = re.search(r'<script type="importmap">\s*(\{.*?\})\s*</script>', starter, re.S) if not im: findings.append(Finding("importmap", "fail", "no importmap block in starter")) else: try: imports = json.loads(im.group(1))["imports"] three_url = imports.get("three", "") addons_url = imports.get("three/addons/", "") v_three = re.search(r"three@(0\.\d+\.\d+)/", three_url) v_addons = re.search(r"three@(0\.\d+\.\d+)/", addons_url) if not (v_three and v_addons): findings.append(Finding("importmap", "fail", "starter import map missing pinned three/addons entries")) elif v_three.group(1) != v_addons.group(1): findings.append(Finding("importmap", "fail", f"version mismatch: three@{v_three.group(1)} vs addons@{v_addons.group(1)}")) elif not addons_url.endswith("/"): findings.append(Finding("importmap", "fail", "three/addons/ URL missing trailing slash")) else: findings.append(Finding("importmap", "ok", f"both entries pinned to three@{v_three.group(1)}")) except (json.JSONDecodeError, KeyError) as exc: findings.append(Finding("importmap", "fail", f"import map does not parse: {exc}")) # O6 — every cc0 source / reference repo has an https url. for group in ("cc0_sources", "reference_repos"): bad = [e.get("id", "?") for e in facts.get(group, []) if not str(e.get("url", "")).startswith("https://")] if bad: findings.append(Finding(group, "fail", "no https url: " + ", ".join(bad))) else: findings.append(Finding(group, "ok", f"{len(facts.get(group, []))} entries addressable")) # --------------------------------------------------------------------------- # # Live checks # # --------------------------------------------------------------------------- # def run_live(findings: list[Finding], timeout: float) -> None: import urllib.error import urllib.request facts = load_facts(findings) if facts is None: raise _NotFound() def fetch_latest(pkg: str) -> tuple[str, dict | None]: """Return (resolved|notfound|unavailable, latest-manifest-or-None).""" url = f"{REGISTRY}/{quote(pkg, safe='')}/latest" req = urllib.request.Request(url, headers={"User-Agent": "threejs-ops-staleness/1", "Accept": "application/json"}) try: with urllib.request.urlopen(req, timeout=timeout) as resp: if resp.status >= 400: return "unavailable", None return "resolved", json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: if e.code in (404, 410): return "notfound", None return "unavailable", None except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError): return "unavailable", None # L1 — every committed package still resolves on npm. for name in facts.get("packages", {}): res, manifest = fetch_latest(name) if res == "resolved": findings.append(Finding(f"npm:{name}", "ok", f"latest {(manifest or {}).get('version', '?')}")) elif res == "notfound": findings.append(Finding(f"npm:{name}", "drift", "package gone from npm — renamed/removed, review skill")) else: findings.append(Finding(f"npm:{name}", "unavailable", "registry unreachable")) # L2 — three's versioning scheme still major-0 (a 1.x = API-break signal). res, manifest = fetch_latest("three") if res == "resolved": ver = (manifest or {}).get("version", "") if ver.startswith("0."): findings.append(Finding("three-major", "ok", f"three@{ver} still 0.<release> scheme")) else: findings.append(Finding("three-major", "drift", f"three@{ver} left major 0 — review the whole skill")) elif res == "notfound": findings.append(Finding("three-major", "drift", "npm has no 'three' package (!)")) else: findings.append(Finding("three-major", "unavailable", "registry unreachable")) # --------------------------------------------------------------------------- # # Main # # --------------------------------------------------------------------------- # def main(argv: list[str]) -> int: ap = argparse.ArgumentParser(add_help=True, description="threejs-ops staleness verifier") mode = ap.add_mutually_exclusive_group() mode.add_argument("--offline", action="store_true", help="structural/internal-consistency only (default)") mode.add_argument("--live", action="store_true", help="probe the npm registry (network)") ap.add_argument("--json", action="store_true", help="emit the JSON envelope on stdout") ap.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress") ap.add_argument("--timeout", type=float, default=10.0, help="per-request timeout for --live (seconds)") try: args = ap.parse_args(argv) except SystemExit as e: # argparse exits 2 on bad args (matches USAGE); 0 on --help. return EXIT_USAGE if e.code not in (0, None) else EXIT_OK mode_name = "live" if args.live else "offline" def emit(msg: str) -> None: if not args.quiet: print(msg, file=sys.stderr) findings: list[Finding] = [] emit(f"== check-three-facts ({mode_name}) ==") try: if args.live: run_live(findings, args.timeout) else: run_offline(findings) except _NotFound: if args.json: print(json.dumps({"error": {"code": "NOT_FOUND", "message": "skill files missing", "details": [f.as_dict() for f in findings]}})) for f in findings: emit(f" [{f.status.upper()}] {f.check}: {f.detail}") return EXIT_NOT_FOUND n_fail = sum(1 for f in findings if f.status == "fail") n_drift = sum(1 for f in findings if f.status == "drift") n_unavail = sum(1 for f in findings if f.status == "unavailable") # Output: stdout is data only. if args.json: print(json.dumps({ "data": [f.as_dict() for f in findings], "meta": {"mode": mode_name, "count": len(findings), "fail": n_fail, "drift": n_drift, "unavailable": n_unavail, "schema": SCHEMA}, }, indent=2)) else: for f in findings: print(f"{f.check}\t{f.status}\t{f.detail}") # Progress summary to stderr. for f in findings: if f.status != "ok": emit(f" [{f.status.upper()}] {f.check}: {f.detail}") emit(f"-- {len(findings)} checks: {n_fail} fail, {n_drift} drift, {n_unavail} unavailable") # Exit precedence: inconsistency (offline) beats drift beats unavailable; # if the ONLY non-ok results are unavailable, exit 7, never 0. if n_fail: return EXIT_VALIDATION if n_drift: return EXIT_DRIFT if n_unavail: return EXIT_UNAVAILABLE return EXIT_OK if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
-
-
tests
-
run.sh 5.6 KB
#!/usr/bin/env bash # Offline self-test for the threejs-ops skill — structure, frontmatter, script contract. # # Usage: tests/run.sh # Input: none (self-contained; no network, no browser) # Output: TAP-ish progress on stderr; final PASS/FAIL line. # Exit: 0 all pass (or skipped on unsupported platform), 1 any failure. # # Examples: # tests/run.sh # bash skills/threejs-ops/tests/run.sh set -uo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" fail=0 pass=0 note() { printf ' %s %s\n' "$1" "$2" >&2; } ok() { pass=$((pass+1)); note "ok " "$1"; } bad() { fail=$((fail+1)); note "FAIL" "$1"; } # Resolve a *working* python (python3, else python). The bare `command -v` is not # enough on Windows, where `python3` is a Microsoft Store stub that exits nonzero. PY="" for cand in python3 python; do if command -v "$cand" >/dev/null 2>&1 && "$cand" --version >/dev/null 2>&1; then PY="$cand"; break fi done if [ -z "$PY" ]; then echo "SKIP: no working python interpreter on this platform" >&2 exit 0 fi # 1. Required directories exist for d in scripts references assets tests; do [ -d "$here/$d" ] && ok "dir $d/ exists" || bad "missing dir $d/" done # 2. SKILL.md frontmatter house rules (SKILL-SUBAGENT-REFERENCE) skill="$here/SKILL.md" if [ -f "$skill" ]; then ok "SKILL.md present" grep -q '^name: threejs-ops$' "$skill" && ok "name matches directory" || bad "name != threejs-ops" grep -q '^license: MIT$' "$skill" && ok "license: MIT" || bad "missing license: MIT" grep -q '^ author: claude-mods$' "$skill" && ok "metadata.author" || bad "missing metadata.author" else bad "SKILL.md missing" fi # 3. Every reference on disk is cited from SKILL.md (no dead weight) for ref in "$here"/references/*.md; do base="references/$(basename "$ref")" grep -qF "$base" "$skill" && ok "cited: $base" || bad "uncited reference: $base" done # 4. Every SKILL.md-cited bundled resource exists on disk for res in assets/importmap-starter.html assets/three-facts.json scripts/check-three-facts.py; do [ -f "$here/$res" ] && ok "resource present: $res" || bad "missing resource: $res" done # 5. check-three-facts.py — staleness verifier contract (§7, §10), offline-safe facts="$here/scripts/check-three-facts.py" if [ -f "$facts" ]; then "$PY" -m py_compile "$facts" && ok "facts: py_compile clean" || bad "facts: py_compile failed" # Captured, not `head | grep -q`: under `set -o pipefail` grep -q's early exit # SIGPIPEs the producer (141) and flakes the assert even on a match. facts_head="$(head -35 "$facts")" grep -Eq '^# +Examples:' <<<"$facts_head" && ok "facts: has Examples block" || bad "facts: no Examples block" "$PY" "$facts" --help >/dev/null 2>&1 && ok "facts: --help exits 0" || bad "facts: --help nonzero" # Offline mode must pass on the skill's own content (internal consistency). "$PY" "$facts" --offline >/dev/null 2>&1 && ok "facts: --offline consistent (exit 0)" || bad "facts: --offline found inconsistency" # Bad flag → USAGE (exit 2); stays offline. "$PY" "$facts" --bogus >/dev/null 2>&1 [ "$?" -eq 2 ] && ok "facts: bad flag → exit 2 (USAGE)" || bad "facts: bad flag did not exit 2" # --offline and --live are mutually exclusive → USAGE. "$PY" "$facts" --offline --live >/dev/null 2>&1 [ "$?" -eq 2 ] && ok "facts: --offline --live → exit 2 (USAGE)" || bad "facts: conflicting modes did not exit 2" # stdout is data-only: --offline --json must emit parseable JSON with no stderr leakage. "$PY" "$facts" --offline --json -q 2>/dev/null | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert d["meta"]["schema"].startswith("claude-mods.threejs-ops")' \ && ok "facts: --json envelope parses (stdout clean)" || bad "facts: --json envelope broken" # cited from SKILL.md grep -qF "scripts/check-three-facts.py" "$skill" && ok "facts: cited from SKILL.md" || bad "facts: uncited" else bad "check-three-facts.py missing" fi # 6. three-facts.json — parses, carries schema + gates the verifier depends on fj="$here/assets/three-facts.json" "$PY" -c " import json, sys d = json.load(open(sys.argv[1], encoding='utf-8')) assert d['schema'] == 'claude-mods.threejs-ops.facts/v1' assert d['version_gates']['examples_js_removed'] == 'r148' assert d['version_gates']['umd_builds_removed'] == 'r160' assert d['packages'], 'no packages committed' " "$fj" && ok "three-facts.json schema + gates" || bad "three-facts.json invalid" # 7. importmap-starter.html — import map parses, same pinned version both entries "$PY" -c " import json, re, sys html = open(sys.argv[1], encoding='utf-8').read() m = re.search(r'<script type=\"importmap\">\s*(\{.*?\})\s*</script>', html, re.S) imports = json.loads(m.group(1))['imports'] v = re.search(r'three@(0\.\d+\.\d+)/', imports['three']).group(1) va = re.search(r'three@(0\.\d+\.\d+)/', imports['three/addons/']).group(1) assert v == va, f'{v} != {va}' " "$here/assets/importmap-starter.html" && ok "starter import map pinned + consistent" || bad "starter import map broken" # 8. Negative test: verifier catches a broken facts file (exit 4 VALIDATION) tmpd="$(mktemp -d)" cp -r "$here/scripts" "$here/assets" "$here/references" "$tmpd/" cp "$skill" "$tmpd/SKILL.md" "$PY" -c " import json, sys p = sys.argv[1] d = json.load(open(p, encoding='utf-8')) d['version_gates']['umd_builds_removed'] = 'r999' # gate no longer stated in SKILL.md json.dump(d, open(p, 'w', encoding='utf-8')) " "$tmpd/assets/three-facts.json" "$PY" "$tmpd/scripts/check-three-facts.py" --offline >/dev/null 2>&1 [ "$?" -eq 4 ] && ok "negative: broken gate → exit 4 (VALIDATION)" || bad "negative: broken gate not caught" rm -rf "$tmpd" echo "threejs-ops self-test: $pass passed, $fail failed" >&2 [ "$fail" -eq 0 ]
-
-
SKILL.md 11.5 KB
--- name: threejs-ops description: "Application/game-scale three.js: ES modules, GLTF pipeline (DRACO/KTX2/meshopt), AnimationMixer, physics (rapier/cannon-es), react-three-fiber, and performance at scale (InstancedMesh, LOD, draw calls). Triggers on: three.js, GLTFLoader, r3f, game loop, WebGL memory leak, boids." license: MIT compatibility: "Web three.js r150+ (ES modules only — UMD builds removed in r160). check-three-facts.py is stdlib-only Python 3.10+." metadata: author: claude-mods related-skills: "genart-ops, mapbox-ops, react-ops, javascript-ops, perf-ops" --- # Three.js — application & game scale Patterns for building three.js **applications and games**: module setup, asset pipelines, animation, simulation loops, physics, the React ecosystem, and staying fast (and leak-free) as scene complexity grows. **Scope split with sibling skills** — do not duplicate them: | Concern | Owner | |---|---| | Creative/generative three.js — scene scaffolding, GLSL shaders, particles, post-processing | [genart-ops](../genart-ops/SKILL.md) | | three.js inside a Mapbox GL custom layer (`CustomLayerInterface`, threebox) | [mapbox-ops](../mapbox-ops/SKILL.md) | | App/game-scale three.js — modules, assets, animation, loops, physics, R3F, scale | **this skill** | --- ## 1. ES-module reality (read this before writing any `<script>` tag) three.js is **ES-modules only**. The legacy patterns are dead and will 404 or silently break on any modern release: | Dead pattern | Removed | Use instead | |---|---|---| | `examples/js/*` non-module loaders (`js/loaders/GLTFLoader.js`, `THREE.OrbitControls` globals) | **r148** | `three/addons/` module imports | | `build/three.js` + `build/three.min.js` UMD builds (`<script src=…>` + global `THREE`) | **r160** | `build/three.module.js` via import map or bundler | Versioning: npm publishes `0.<release>.<patch>` — `three@0.185.1` **is** r185. Releases land monthly; pin exact versions. ### No-bundler setup (import map) Copy [assets/importmap-starter.html](assets/importmap-starter.html) — a complete, runnable starter (import map + addons + resize + `setAnimationLoop`). The core: ```html <script type="importmap"> { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.185.1/build/three.module.js", "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/" } } </script> <script type="module"> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; </script> ``` Gotchas: both entries MUST pin the **same version** (mixed versions = `instanceof` failures across module copies); the `three/addons/` key needs the trailing slash; import maps must appear **before** the first `type="module"` script. ### Bundler vs no-bundler | Situation | Choice | |---|---| | Real app/game, npm deps, physics WASM, R3F | **Vite** (`npm create vite@latest`) — default answer | | Demo, CodePen, teaching, drop-in page on an existing site | **Import map** — zero build | | `@dimforge/rapier3d` (WASM-bindgen) | Needs a bundler; use `rapier3d-compat` without one (§5) | With a bundler, `import { X } from 'three/addons/…'` resolves via the package's `exports` map — same specifier both worlds. ### WebGPU (know it exists; default to WebGL) Since **r167** three ships a parallel build: `import { WebGPURenderer } from 'three/webgpu'` plus the TSL node-shader language from `three/tsl` (import-map users: point the `"three"` key at `build/three.webgpu.js` **instead of** `three.module.js` — it re-exports core; never load both). `WebGPURenderer` falls back to WebGL2 automatically and initializes async (`await renderer.init()`, or let `setAnimationLoop` defer for you). Default for app/game work remains `WebGLRenderer` — reach for WebGPU when you need compute (GPU crowds, particle sims) or TSL materials. Shader-level TSL/GLSL work is [genart-ops](../genart-ops/SKILL.md) territory. --- ## 2. GLTF asset pipeline glTF (`.glb`) is the format. Wire **all three decoders** once at startup so any optimized asset loads; full setup, optimization CLI recipes (`gltf-transform`, `gltfpack`), and CC0 model sources (Quaternius, Kenney, Poly Pizza) in [references/asset-pipeline.md](references/asset-pipeline.md). ```javascript import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'; import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; const draco = new DRACOLoader().setDecoderPath( 'https://www.gstatic.com/draco/versioned/decoders/1.5.7/'); const ktx2 = new KTX2Loader() .setTranscoderPath('https://cdn.jsdelivr.net/npm/three@0.185.1/examples/jsm/libs/basis/') .detectSupport(renderer); // MUST pass the live renderer const loader = new GLTFLoader() .setDRACOLoader(draco).setKTX2Loader(ktx2).setMeshoptDecoder(MeshoptDecoder); const { scene: model, animations } = await loader.loadAsync('hero.glb'); ``` Cloning a skinned character for multiple instances needs `SkeletonUtils.clone()` — a plain `.clone()` shares (and corrupts) the skeleton. --- ## 3. Animation system `AnimationMixer` drives everything (skeletal + morph targets). One mixer per model root; `mixer.update(dt)` every frame. Crossfades, blending weights, one-shot clips, additive layers, and morph-target patterns in [references/animation.md](references/animation.md). ```javascript const mixer = new THREE.AnimationMixer(model); const idle = mixer.clipAction(THREE.AnimationClip.findByName(animations, 'Idle')); const run = mixer.clipAction(THREE.AnimationClip.findByName(animations, 'Run')); idle.play(); // smooth transition — never .stop() + .play() run.reset().play(); idle.crossFadeTo(run, 0.3, /*warp*/ true); ``` --- ## 4. Game loops Render on rAF; simulate on a **fixed timestep** when gameplay/physics needs determinism. Full accumulator pattern (with render interpolation), dt clamping, tab-hidden handling, input sampling, and frame-rate-independent damping (`MathUtils.damp`, follow cameras) in [references/game-loop.md](references/game-loop.md). ```javascript const FIXED = 1 / 60; let acc = 0, last = performance.now(); renderer.setAnimationLoop((now) => { acc += Math.min((now - last) / 1000, 0.1); // clamp: tab-switch dt spike last = now; while (acc >= FIXED) { simulate(FIXED); acc -= FIXED; } render(acc / FIXED); // interpolation alpha }); ``` Rules: use `renderer.setAnimationLoop` (not raw rAF — required for WebXR, and it pauses cleanly); **clamp dt** or the first frame after a background tab teleports everything; pause simulation on `document.hidden` (`visibilitychange`); determinism = fixed step + seeded RNG + no per-frame `Math.random()` in sim code. --- ## 5. Physics **Default: `@dimforge/rapier3d`** (Rust/WASM — fast, deterministic, actively developed, built-in kinematic character controller). `cannon-es` remains the pure-JS fallback for zero-WASM constraints. Decision table, init patterns, body/mesh sync, and character controllers in [references/physics.md](references/physics.md). The package-name trap: | Package | When | |---|---| | `@dimforge/rapier3d` | Bundler with WASM support (Vite: works out of the box) | | `@dimforge/rapier3d-compat` | No bundler / import map — WASM inlined as base64; `await RAPIER.init()` first | ```javascript import RAPIER from '@dimforge/rapier3d-compat'; await RAPIER.init(); const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 }); // step inside the FIXED loop (§4), then copy body → mesh: world.timestep = FIXED; world.step(); mesh.position.copy(body.translation()); mesh.quaternion.copy(body.rotation()); ``` --- ## 6. react-three-fiber + drei R3F v9 (React 19) renders the three.js scene graph declaratively; [drei](https://github.com/pmndrs/drei) is its helper library. **When**: React app, UI-heavy, want the pmndrs ecosystem (`@react-three/rapier`, postprocessing). **When not**: no React on the page, engine-style tight control, minimal bundle. Hooks, pitfalls (per-frame `setState` kills you — mutate refs in `useFrame`), and the drei shortlist in [references/r3f-drei.md](references/r3f-drei.md). --- ## 7. Scale patterns — staying fast, staying leak-free Draw calls are the budget; GPU memory leaks are the debt. `renderer.info` is the meter for both. InstancedMesh at count, LOD, culling, and the **full disposal discipline** in [references/scale-and-disposal.md](references/scale-and-disposal.md). Quick rules: - **Same mesh × N ≥ ~50 → `InstancedMesh`** (one draw call). Set matrices via `setMatrixAt(i, m)`, then `instanceMatrix.needsUpdate = true`. - Watch `renderer.info.render.calls` per frame — hundreds is fine, thousands is the problem. `renderer.info.memory.{geometries,textures}` must return to baseline after teardown. - **Removing from the scene frees nothing.** GPU memory needs explicit `geometry.dispose()`, `material.dispose()`, **every texture** `.dispose()`, and `renderer.dispose()` on full teardown (SPA route change = the classic leak). ```javascript function disposeObject(root) { root.traverse((o) => { o.geometry?.dispose(); for (const m of Array.isArray(o.material) ? o.material : [o.material]) { if (!m) continue; for (const v of Object.values(m)) v?.isTexture && v.dispose(); m.dispose(); } }); root.removeFromParent(); } ``` --- ## 8. Ambient life — boids, steering, waypoints Crowd/wildlife/NPC motion is steering forces + a spatial hash, driven from the fixed loop and rendered via InstancedMesh. Seek/arrive/wander, the three boids rules, waypoint drivers, and library options in [references/actors-and-steering.md](references/actors-and-steering.md). For structured game-state building blocks (actor motion controllers, waypoint drivers, camera rigs) see [xt4d/GameBlocks](https://github.com/xt4d/GameBlocks) — agent-oriented, self-explanatory reference implementations. --- ## Bundled resources | Resource | Load when | |---|---| | [references/asset-pipeline.md](references/asset-pipeline.md) | Loading/optimizing GLTF, decoder wiring, sourcing CC0 models | | [references/animation.md](references/animation.md) | Crossfades, blend weights, one-shots, morph targets | | [references/game-loop.md](references/game-loop.md) | Fixed timestep, interpolation, pause/visibility, input sampling, damping/follow-cam, determinism | | [references/physics.md](references/physics.md) | rapier vs cannon-es, init, sync, character controllers | | [references/r3f-drei.md](references/r3f-drei.md) | React Three Fiber apps, drei helpers, R3F pitfalls | | [references/scale-and-disposal.md](references/scale-and-disposal.md) | Instancing, LOD, culling, draw-call budgets, disposal/leaks | | [references/actors-and-steering.md](references/actors-and-steering.md) | Boids, steering, waypoint actors, ambient life | | [assets/importmap-starter.html](assets/importmap-starter.html) | Starting a no-bundler project — copy and edit | | [assets/three-facts.json](assets/three-facts.json) | Canonical version gates + package facts (verifier input) | **Staleness verifier** — this skill encodes fast-moving facts (three version gates, npm package names/versions). Check internal consistency (CI) or live drift against the npm registry: ```bash python3 skills/threejs-ops/scripts/check-three-facts.py --offline # structural, no network — PR CI python3 skills/threejs-ops/scripts/check-three-facts.py --live # npm registry probe — exit 10 drift, 7 unreachable python3 skills/threejs-ops/scripts/check-three-facts.py --offline --json | jq '.data[] | select(.status!="ok")' ```
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.