Browse Source

feat(skills): Add threejs-ops skill

Application/game-scale three.js — the deliberate gap between genart-ops
(creative/shader three.js) and mapbox-ops (mapbox custom-layer bridge):

- ES-module reality: import maps, the r148 examples/js and r160 UMD
  removals, bundler vs no-bundler decision table
- GLTF pipeline: DRACO/KTX2/meshopt decoder wiring, gltf-transform +
  gltfpack recipes, CC0 sources (Quaternius, Kenney, Poly Pizza)
- AnimationMixer: crossfades, blend weights, one-shots, additive
  layers, morph targets, root-motion handling
- Game loops: fixed timestep + interpolation, dt clamping, visibility
  pause, determinism
- Physics: @dimforge/rapier3d (default, incl. -compat trap and the
  kinematic character controller) vs cannon-es
- react-three-fiber v9 + drei: when/when-not, pitfalls, ecosystem
- Scale: InstancedMesh, LOD, culling, draw-call budgets, full
  disposal discipline
- Actors: boids + spatial hash, steering, waypoint drivers; cites
  xt4d/GameBlocks for structured game-state patterns

Ships a resource-protocol §7 staleness verifier (check-three-facts.py,
--offline consistency / --live npm-registry drift; wired into
check-resources.sh and freshness.yml), an import-map starter asset, a
canonical three-facts.json, and a 29-assertion offline test suite.
Facts grounded against npm as of 2026-07-02 (three r185, rapier 0.19.3,
R3F 9.6.1, drei 10.7.7). Skill count 96 -> 97.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 1 week ago
parent
commit
8e4a9adcaa

+ 9 - 0
.github/workflows/freshness.yml

@@ -79,6 +79,15 @@ jobs:
           if [ "$rc" -eq 7 ]; then echo "::warning::mapbox-ops live check unreachable — skipped"; fi
           exit 0
 
+      - name: threejs-ops packages vs npm registry (existence + three major)
+        run: |
+          set +e
+          python skills/threejs-ops/scripts/check-three-facts.py --live
+          rc=$?
+          if [ "$rc" -eq 10 ]; then echo "::error::threejs-ops drift — a committed npm package is gone, or three left the 0.<release> scheme"; exit 1; fi
+          if [ "$rc" -eq 7 ]; then echo "::warning::threejs-ops live check unreachable (npm registry) — skipped"; fi
+          exit 0
+
       - name: r-ops recommended packages still on CRAN
         run: |
           set +e

+ 1 - 1
AGENTS.md

@@ -5,7 +5,7 @@
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **3 expert agents** for pure context-isolation/worker roles (git-agent, firecrawl-expert, project-organizer) - every domain-knowledge agent became an `-ops` skill (v3.0, skills-first)
 - **2 commands** for session management (/sync, /save)
-- **96 skills** for CLI tools, patterns, workflows, and development tasks (incl. `r-ops` for tidyverse-first modern R / data analysis, `loop-ops` for outer-loop design discipline, `ffmpeg-ops` for probe-first media processing and EDL-driven editing, `supply-chain-defense` for behavioural-first dependency security, `prompt-injection-defense` for instruction-integrity scanning, `pypi-ops` for OIDC Trusted Publishing to PyPI, `net-ops` for network troubleshooting, `windows-ops` / `mac-ops` for workstation diagnostics, `fleet-worker` for cheap parallel worker delegation)
+- **97 skills** for CLI tools, patterns, workflows, and development tasks (incl. `r-ops` for tidyverse-first modern R / data analysis, `loop-ops` for outer-loop design discipline, `ffmpeg-ops` for probe-first media processing and EDL-driven editing, `supply-chain-defense` for behavioural-first dependency security, `prompt-injection-defense` for instruction-integrity scanning, `pypi-ops` for OIDC Trusted Publishing to PyPI, `net-ops` for network troubleshooting, `windows-ops` / `mac-ops` for workstation diagnostics, `fleet-worker` for cheap parallel worker delegation)
 - **13 output styles** for response personality (Vesper, Spartan, Mentor, Executive, Pair, Atlas, Coach, Harbour, Meridian, Noir, Roast, Sage, Scout)
 - **13 hooks** for pre-commit linting, post-edit formatting, dangerous command warnings, uv enforcement, dependency-install + manifest-edit supply-chain advisories, hidden-Unicode scanning (session-start + pre-commit), live config-change + worktree guards, mid-session peer-writer guard + touched-files ledger, and pmail notifications - security set auto-wired via plugin hooks.json
 - **Pigeon** inter-session messaging (`pigeon send/read/reply`) - SQLite-backed pmail at `~/.claude/pmail.db`

+ 6 - 5
README.md

@@ -12,13 +12,13 @@
 
 > *A comprehensive extension toolkit that transforms Claude Code into a specialized development powerhouse.*
 
-**claude-mods** is a production-ready plugin that extends Claude Code with 96 specialized skills, 3 expert agents, 13 output styles, 13 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
+**claude-mods** is a production-ready plugin that extends Claude Code with 97 specialized skills, 3 expert agents, 13 output styles, 13 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
 
 Built on the [Agent Skills specification](https://agentskills.io/specification) (an open standard backed by Anthropic, Vercel, Google, Microsoft, and 40+ agent platforms), claude-mods fills critical gaps in Claude Code's capabilities: persistent session state that survives across machines, on-demand expert knowledge for specialized domains, token-efficient modern CLI tools (10-100x faster than traditional alternatives), and proven workflow patterns for TDD, code review, and feature development. The toolkit implements Anthropic's [recommended patterns for long-running agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents), ensuring your development context never vanishes when sessions end.
 
 From Python async patterns to Rust ownership models, from AWS Fargate deployments to Craft CMS development - claude-mods provides the specialized knowledge and tools that transform Claude from a general-purpose assistant into a domain expert who understands your stack, remembers your workflow, and ships production code.
 
-**3 agents. 96 skills. 13 styles. 13 hooks. 8 rules. One install.**
+**3 agents. 97 skills. 13 styles. 13 hooks. 8 rules. One install.**
 
 ## Recent Updates
 
@@ -82,7 +82,7 @@ Claude Code is powerful out of the box, but it has gaps. This toolkit fills them
 
 - **Session continuity** — Tasks vanish when sessions end. We fix that with `/save` and `/sync`, implementing Anthropic's [recommended pattern](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents) for long-running agents.
 
-- **Expert-level knowledge on demand** — 96 on-demand skills covering React, TypeScript, Python, Go, Rust, PostgreSQL, and more, plus 3 specialized agents reserved for genuine context-isolation/worker roles (git operations, web scraping, project reorganization). Skills-first: knowledge loads when relevant instead of living in heavyweight agent prompts.
+- **Expert-level knowledge on demand** — 97 on-demand skills covering React, TypeScript, Python, Go, Rust, PostgreSQL, and more, plus 3 specialized agents reserved for genuine context-isolation/worker roles (git operations, web scraping, project reorganization). Skills-first: knowledge loads when relevant instead of living in heavyweight agent prompts.
 
 - **Modern CLI tools** — Stop using `grep`, `find`, and `cat`. Our rules automatically prefer `ripgrep`, `fd`, `eza`, and `bat` — 10-100x faster and token-efficient.
 
@@ -107,7 +107,7 @@ claude-mods/
 ├── .claude-plugin/     # Plugin metadata
 ├── agents/             # Expert subagents (3)
 ├── commands/           # Slash commands (2)
-├── skills/             # Custom skills (96)
+├── skills/             # Custom skills (97)
 ├── output-styles/      # Response personalities
 ├── hooks/              # Hook examples & docs
 ├── rules/              # Claude Code rules
@@ -213,6 +213,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [tailwind-ops](skills/tailwind-ops/) | Tailwind CSS patterns, v4 migration, components, configuration |
 | [color-ops](skills/color-ops/) | Color spaces, WCAG/APCA contrast checker, palette + harmony generators, CSS color functions, design tokens, color converter |
 | [genart-ops](skills/genart-ops/) | Generative art - three.js scenes, p5.js sketches, SVG generation, GLSL shaders, procedural algorithms, colour theory |
+| [threejs-ops](skills/threejs-ops/) | App/game-scale three.js - import maps + ES-module reality, GLTF pipeline (DRACO/KTX2/meshopt, gltf-transform), AnimationMixer crossfades, fixed-timestep loops, rapier/cannon-es physics, R3F + drei, InstancedMesh/LOD/disposal discipline, boids/steering actors; npm staleness verifier |
 | [mapbox-ops](skills/mapbox-ops/) | Advanced Mapbox GL JS (web v3) - custom markers, thematic dataviz, 3D/terrain, cinematic camera, style composition, expressions, performance, gotchas; headless Playwright map verifier |
 | [unfold-admin](skills/unfold-admin/) | Django Unfold admin theme - ModelAdmin, dashboards, filters, widgets, theming |
 
@@ -573,7 +574,7 @@ When using multiple MCP servers (Chrome DevTools, Vibe Kanban, etc.), their tool
 
 ### Skill Description Budget
 
-With 90+ skills installed (this plugin alone ships 96), skill descriptions can overflow the listing budget. All skill names are always listed, but descriptions share a budget of **1% of the model context window** — on overflow, least-invoked skills lose their descriptions first and **silently stop auto-triggering** (explicit `/name` invocation still works). Each skill's combined `description` + `when_to_use` is also truncated at **1,536 chars**, so trigger phrases belong at the front.
+With 90+ skills installed (this plugin alone ships 97), skill descriptions can overflow the listing budget. All skill names are always listed, but descriptions share a budget of **1% of the model context window** — on overflow, least-invoked skills lose their descriptions first and **silently stop auto-triggering** (explicit `/name` invocation still works). Each skill's combined `description` + `when_to_use` is also truncated at **1,536 chars**, so trigger phrases belong at the front.
 
 - **Check:** run `/doctor` — it shows whether the budget is overflowing and which skills are affected.
 - **Fix:** demote or disable skills you don't use via `skillOverrides` in settings (`"on"` / `"name-only"` / `"user-invocable-only"` / `"off"` per skill, or `/skills` + `Space`). Plugin skills are managed via `/plugin` instead.

+ 1 - 1
docs/PLAN.md

@@ -16,7 +16,7 @@
 | Component | Count | Notes |
 |-----------|-------|-------|
 | Agents | 3 | Pure context-isolation/worker roles only: git-agent (background commits/PRs), firecrawl-expert (noisy scrapes), project-organizer (bulk restructure) |
-| Skills | 96 | Operational skills, CLI tools, workflows, diagnostics, security |
+| Skills | 97 | Operational skills, CLI tools, workflows, diagnostics, security |
 | Commands | 2 | Session management (sync, save) |
 | Rules | 8 | cli-tools, commit-style, naming-conventions, prompt-injection, skill-agent-updates, supply-chain, worktree-boundaries, loop-engineering |
 | Output Styles | 13 | Vesper, Spartan, Mentor, Executive, Pair, Atlas, Coach, Harbour, Meridian, Noir, Roast, Sage, Scout |

+ 259 - 0
skills/threejs-ops/SKILL.md

@@ -0,0 +1,259 @@
+---
+name: threejs-ops
+description: "Application/game-scale three.js — ES modules + import maps (UMD builds are dead), GLTF asset pipeline (DRACO/KTX2/meshopt, gltf-transform, gltfpack, CC0 sources), AnimationMixer crossfading, game loops (fixed timestep, dt clamp), physics (@dimforge/rapier3d vs cannon-es), react-three-fiber + drei, and scale (InstancedMesh, LOD, draw-call budgets, disposal, boids/steering). Use for: three.js app, three.js game, import map, three.min.js, UMD, GLTFLoader, DRACOLoader, KTX2Loader, meshopt, gltf-transform, gltfpack, glb optimize, CC0 models, Quaternius, Kenney, Poly Pizza, AnimationMixer, crossfade, animation blending, skeletal animation, morph targets, game loop, fixed timestep, rapier, rapier3d, cannon-es, character controller, kinematic body, react-three-fiber, r3f, drei, InstancedMesh, LOD, frustum culling, draw calls, dispose geometry, WebGL memory leak, boids, steering, waypoint, NPC ambient life."
+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 | [mapbox-ops](../mapbox-ops/references/three-custom-layer.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.
+
+---
+
+## 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,
+and tab-hidden handling 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, 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")'
+```

+ 90 - 0
skills/threejs-ops/assets/importmap-starter.html

@@ -0,0 +1,90 @@
+<!DOCTYPE html>
+<!--
+  threejs-ops import-map starter — complete no-bundler three.js app skeleton.
+
+  Serve over HTTP (any static server; ES modules don't load from file://):
+    npx serve .        # or: python -m http.server
+
+  ADAPT: [1] pin your three version (BOTH entries, same version)
+         [2] swap the demo scene for your own
+         [3] delete OrbitControls if you don't need it
+-->
+<html lang="en">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>three.js app</title>
+  <style>
+    html, body { margin: 0; height: 100%; overflow: hidden; }
+    canvas { display: block; }
+  </style>
+
+  <!-- Import map MUST come before any type="module" script.
+       [1] Pin the SAME exact version in both entries — mixing versions loads
+       two copies of three and breaks instanceof checks across modules. -->
+  <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>
+</head>
+<body>
+<script type="module">
+  import * as THREE from 'three';
+  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+
+  // --- renderer / scene / camera -----------------------------------------
+  const renderer = new THREE.WebGLRenderer({ antialias: true });
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+  renderer.setSize(window.innerWidth, window.innerHeight);
+  document.body.appendChild(renderer.domElement);
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color(0x10141c);
+
+  const camera = new THREE.PerspectiveCamera(
+    60, window.innerWidth / window.innerHeight, 0.1, 200);
+  camera.position.set(3, 2, 5);
+
+  const controls = new OrbitControls(camera, renderer.domElement);
+  controls.enableDamping = true;
+
+  // --- [2] demo scene — replace ------------------------------------------
+  scene.add(new THREE.AmbientLight(0xffffff, 0.4));
+  const sun = new THREE.DirectionalLight(0xffffff, 2.5);
+  sun.position.set(5, 10, 5);
+  scene.add(sun);
+
+  const cube = new THREE.Mesh(
+    new THREE.BoxGeometry(1, 1, 1),
+    new THREE.MeshStandardMaterial({ color: 0x4488ff, roughness: 0.4 }));
+  cube.position.y = 0.5;
+  scene.add(cube);
+
+  scene.add(new THREE.Mesh(
+    new THREE.PlaneGeometry(20, 20).rotateX(-Math.PI / 2),
+    new THREE.MeshStandardMaterial({ color: 0x222831 })));
+
+  // --- resize --------------------------------------------------------------
+  window.addEventListener('resize', () => {
+    camera.aspect = window.innerWidth / window.innerHeight;
+    camera.updateProjectionMatrix();
+    renderer.setSize(window.innerWidth, window.innerHeight);
+  });
+
+  // --- loop: render dt + clamped, fixed-step-ready (see game-loop.md) ------
+  let last = 0;
+  renderer.setAnimationLoop((now) => {
+    const dt = Math.min((now - last) / 1000, 0.1);   // clamp tab-switch spike
+    last = now;
+
+    cube.rotation.y += dt;                            // dt-scaled, never per-frame constants
+    controls.update();
+    renderer.render(scene, camera);
+  });
+</script>
+</body>
+</html>

+ 36 - 0
skills/threejs-ops/assets/three-facts.json

@@ -0,0 +1,36 @@
+{
+  "_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",
+    "_comment": "r148 (Dec 2022) dropped examples/js non-module loaders; r160 (Dec 2023) dropped build/three.js + build/three.min.js UMD builds. 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" }
+  ]
+}

+ 133 - 0
skills/threejs-ops/references/actors-and-steering.md

@@ -0,0 +1,133 @@
+# 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) §5).
+
+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.

+ 146 - 0
skills/threejs-ops/references/animation.md

@@ -0,0 +1,146 @@
+# 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.

+ 138 - 0
skills/threejs-ops/references/asset-pipeline.md

@@ -0,0 +1,138 @@
+# 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.

+ 130 - 0
skills/threejs-ops/references/game-loop.md

@@ -0,0 +1,130 @@
+# 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. 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)).
+
+## 6. 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.

+ 149 - 0
skills/threejs-ops/references/physics.md

@@ -0,0 +1,149 @@
+# 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.

+ 113 - 0
skills/threejs-ops/references/r3f-drei.md

@@ -0,0 +1,113 @@
+# 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`.

+ 160 - 0
skills/threejs-ops/references/scale-and-disposal.md

@@ -0,0 +1,160 @@
+# 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).

+ 310 - 0
skills/threejs-ops/scripts/check-three-facts.py

@@ -0,0 +1,310 @@
+#!/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 — version gates stated in SKILL.md exactly as the facts commit to them.
+    gates = facts.get("version_gates", {})
+    for key, gate in (("examples_js_removed", gates.get("examples_js_removed")),
+                      ("umd_builds_removed", gates.get("umd_builds_removed"))):
+        if not gate or not re.match(r"r\d{3}$", 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:]))

+ 122 - 0
skills/threejs-ops/tests/run.sh

@@ -0,0 +1,122 @@
+#!/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"
+  head -35 "$facts" | grep -Eq '^# +Examples:' && 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 ]

+ 6 - 1
tests/check-resources.sh

@@ -74,13 +74,18 @@ echo "== r-ops: R-stack staleness verifier"
 run "r-facts --offline consistent" 0 "$PY" skills/r-ops/scripts/check-r-facts.py --offline
 run "r-facts --help"               0 "$PY" skills/r-ops/scripts/check-r-facts.py --help
 
+echo "== threejs-ops: three.js fact/staleness verifier"
+run "three-facts --offline consistent" 0 "$PY" skills/threejs-ops/scripts/check-three-facts.py --offline
+run "three-facts --help"               0 "$PY" skills/threejs-ops/scripts/check-three-facts.py --help
+
 echo "== protocol: every new verifier is executable + compiles"
 for s in skills/claude-api-ops/scripts/check-model-table.py \
          skills/claude-code-ops/scripts/validate-hooks-json.py \
          skills/playwright-ops/scripts/triage-flakes.py \
          skills/mapbox-ops/scripts/check-mapbox-facts.py \
          skills/loop-ops/scripts/check-pricing-sync.py \
-         skills/r-ops/scripts/check-r-facts.py; do
+         skills/r-ops/scripts/check-r-facts.py \
+         skills/threejs-ops/scripts/check-three-facts.py; do
     "$PY" -m py_compile "$s" 2>/dev/null && pass "py_compile $(basename "$s")" || bad "py_compile $(basename "$s")"
 done
 bash -n skills/terraform-ops/scripts/check-action-refs.sh 2>/dev/null \