Claude Skill

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.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_threejs-ops-3dfaf0b.zip · 36 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/threejs-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git 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 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).
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.

No comments yet.

Reviews (0)

No reviews yet.

Related