Three.js / R3F Audit
Purpose
A curated catalog of WebGL performance and correctness checks for Three.js and React Three Fiber projects. Each check is one self-contained file describing what to look for, why it matters, and how to fix it. The skill walks you through running them against a target codebase and producing a structured findings report.
This skill reports; it never edits the scene. After a run the working tree is byte-identical — fixes are for the maintainer to apply.
This is not a linter — most checks need contextual judgment, not just pattern-matching. The agent is expected to read the offending code and reason about whether the pattern actually applies in this scene.
Detection commands assume rg (ripgrep) and jq are on PATH. Substitute
grep -rn and a manual read of package.json where they are not.
Workflow
Step 1 — Confirm the project actually uses Three.js / R3F
jq -r '
((.dependencies // {}) + (.devDependencies // {}) + (.peerDependencies // {}))
| to_entries
| map(select(.key | test("^(three$|@react-three/)")))
| .[].key
' package.json 2>/dev/null
If nothing matches, stop and tell the user the audit doesn't apply.
Step 2 — Find renderer entry points
These are the anchors most checks key off of:
# Vanilla Three.js renderer construction
rg -n 'new\s+(THREE\.)?WebGLRenderer\b' --type ts --type tsx --type js --type jsx
# React Three Fiber Canvas mounts
rg -n '<\s*Canvas\b' --type tsx --type jsx
# R3F imperative GL access
rg -n "useThree\(|gl\s*[:=]" --type tsx --type jsx
Note the file paths — each check refers back to them. Close the step with one
line: Anchors: 1 WebGLRenderer, 2 Canvas mounts, 6 useThree sites across 14 files.
Step 3 — Decide which conditional checks apply
The convention checks encode project conventions, not Three.js rules. Running them against a project that never adopted the convention produces noise, so activate each one only on its signal:
# useframe-priority — is a priority argument used anywhere?
rg -n 'useFrame\([^)]*\},\s*-?\d' --type tsx --type jsx
# scene-dom-separation — is there a scene directory distinct from components?
ls -d src/scene src/three src/webgl src/components 2>/dev/null
# store-wiring — is there an external atom store?
jq -r '((.dependencies // {}) + (.devDependencies // {})) | keys[]' package.json 2>/dev/null \
| rg 'nanostores|zustand|jotai|valtio'
No signal → skip that check and record it. Each check file restates its own activation condition; follow that over this summary if they disagree.
Keep a running not-run list from here to the report. Every check that does
not execute goes in it with its reason, whatever the reason: a conditional check
with no signal, a check --check=<name> excluded, a check whose detection needs
rg or jq where neither is on PATH, a check whose target files could not be
read. A run that names only the conditional skips reads as a full audit.
Step 4 — Run checks from the catalog
For each row in the Checks tables below, open
references/checks/<name>.md and follow its detection and assessment
guidance. If the user passed --check=<name>, run only that one.
A grep hit is a candidate, not a finding — most checks list legitimate uses of the pattern they detect. List every candidate site a check produced and open all of them in one response before writing any of its findings.
Close the step with one line: Checks: 11 of 14 run, 47 candidate sites read, 6 findings.
Step 5 — Report findings
Group findings by severity (high, medium, low) and emit one section per
check that produced a finding. Format:
### <check-name> — <severity>
**Where:** <file>:<line> [+ more]
**What's wrong:** <one sentence>
**Suggested fix:**
\`\`\`<lang>
<canonical pattern>
\`\`\`
**Why it matters:** <one sentence on the user-visible impact>
Each check file ends with a How to report this finding section — use its wording so findings from different checks read consistently.
Skip checks that produced no finding (don't pad the report). End with a tally,
<N> findings: <high> high, <medium> medium, <low> low, then a Not run
list — one line per entry on the not-run list from Step 3, with its reason.
A filled report:
### dpr-cap — high
**Where:** src/scene/Stage.tsx:41
**What's wrong:** `<Canvas dpr={window.devicePixelRatio}>` is uncapped, so a 2x
Retina display at fullscreen shades 4x the pixels of the dev preview.
**Suggested fix:**
\`\`\`tsx
<Canvas dpr={[1, Math.min(window.devicePixelRatio, 2)]}>
\`\`\`
**Why it matters:** This is the "smooth in the preview pane, choppy fullscreen"
regression — frame time roughly quadruples with no visible quality gain.
### render-loop-allocations — high
**Where:** src/scene/Orbiter.tsx:58, src/scene/Trail.tsx:24
**What's wrong:** `new THREE.Vector3()` inside `useFrame`, allocating two vectors
per frame per instance — 240 objects/s at 120 fps.
**Suggested fix:**
\`\`\`ts
const tmp = useMemo(() => new THREE.Vector3(), [])
useFrame(() => { tmp.set(x, y, z); mesh.current.position.copy(tmp) })
\`\`\`
**Why it matters:** Minor GC pauses land as visible hitches during motion.
2 findings: 2 high, 0 medium, 0 low
**Not run:**
- `store-wiring` — no atom store dependency in `package.json`
- `useframe-priority` — no `useFrame` priority argument anywhere in the tree
- `texture-budget` — `public/textures/` is git-lfs and the pointers did not
resolve; on-disk sizes could not be read
Then stop. Report the findings; do not apply a suggested fix, and do not start a second pass over the same catalog.
Lens Mode
review:changes-review invokes this skill as a stack lens when the diff touches Three.js or R3F
files. In that mode:
- The caller supplies the scope. Audit exactly the files it resolved and no more — a whole-scene audit returns findings the run cannot attribute to the change
- Skip Step 1's project detection; the caller already matched on the imports
- Return findings in the shape the caller asked for, not the report format above
- Conditional convention checks map to the caller's lowest severity
Standalone invocation is unaffected and stays the primary path.
Checks
Catalog of checks. Each one is defined in references/checks/<name>.md.
Performance and correctness
| Check | Topic | Severity |
|---|---|---|
dpr-cap |
Cap devicePixelRatio by a pixel budget so Retina/fullscreen doesn't quadruple GPU fillrate |
High (perf) |
fillrate-overdraw |
Stacked transparency and post-processing passes shading the same pixels many times over | High (perf) |
render-loop-allocations |
new Vector3 and friends inside useFrame, producing GC hitches |
High (perf) |
resource-disposal |
Geometries, materials, textures, and render targets leaking VRAM without .dispose() |
High (memory) |
useframe-workload |
setState, scene lookups, and raycasts running every frame when they don't need to |
High (perf) |
asset-reuse |
A distinct geometry/material per object where one shared instance would do | Medium (perf/memory) |
instancing |
Hundreds of identical meshes that should be one InstancedMesh |
Medium (perf) |
shadow-cost |
Shadow map resolution, casting light count, and shadow cameras fitted to nothing | Medium (perf) |
material-recompiles |
Shader-define changes at runtime causing main-thread GLSL recompiles | Medium (perf) |
texture-budget |
Oversized or uncompressed textures, wrong colour space, mipmaps disabled | Medium (memory/perf) |
frustum-culling |
Culling disabled, stale bounding volumes, or one merged mesh spanning the scene | Medium (perf) |
Project conventions (conditional)
Run only when Step 3 found the activation signal.
| Check | Topic | Severity |
|---|---|---|
store-wiring |
Atom store reads that don't subscribe, and unbatched related writes | Medium (perf/convention) |
useframe-priority |
Gameplay at priority 1, effects at priority 2, so effects don't sample stale transforms | Medium (correctness/convention) |
scene-dom-separation |
3D components under src/scene/, DOM/HUD under src/components/, never mixed in one file |
Low (convention) |
Severity Guide
- High — measurable frame-rate or memory impact under realistic load, or a correctness bug that ships visible artifacts. Worth fixing before release.
- Medium — perf hit on stress paths or older hardware, or a fragile pattern that's likely to break later. Worth scheduling.
- Low — minor cleanup, style nit, or a pattern that's only suspect in unusual scenes. Note it; don't block on it.
The severity in the table is the check's default. Adjust it for the scene you are looking at and say why — 300 unshared materials in a 12-object scene is still low.
Adding a New Check
- Create
references/checks/<name>.mdusing the existing checks as a template. Required sections: What it is, Why it matters, How to detect, Anti-patterns, Canonical fix, Notes, and How to report this finding. - Append a row to the appropriate Checks table above with topic and severity.
- If the check is conditional on a project convention, state the activation
signal in a
**Conditional:**line in its header block and add the detection command to Step 3. - If the check needs special detection (beyond grep + read), describe it in the check file itself rather than baking shell scripts into the skill — keeps each check portable across Claude / Codex / future hosts.
Out of Scope
- GLSL shader correctness (covered by shader-specific tooling)
- Model authoring — mesh topology, UV layout, rigging, DCC export settings
- Browser-level perf tracing (use Chrome DevTools / Spector.js directly)
- General React patterns — effects, memoization, state architecture (use
react-audit; this skill only covers React as it meets the Canvas)
The audit reads the JS/TS source. The one exception is
texture-budget, which also sizes the
texture files on disk, because how a texture is delivered — dimensions,
format, colour space — is a renderer concern that the source alone cannot
answer. How it was authored is not.