Three.js Scenes skill for Claude Code
Correct color, compressed assets, zero GPU leaks. Claude sets up Three.js renderers with proper color spaces, tone mapping and environment lighting, compresses glTF assets with gltf-transform, and loads them with KTX2 and Meshopt decoders. It measures draw calls and triangles against mobile budgets, applies instancing or merging where it pays off, and disposes every GPU resource so single-page apps stop leaking memory.
- name
threejs-scenes - size226 lines · ≈2,400 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds and optimizes Three.js and React Three Fiber 3D scenes with correct renderer setup, color management, physically based lighting, shadows, compressed glTF loading (Draco, Meshopt, KTX2), animation mixers, instancing, draw-call budgets, and explicit disposal of GPU resources. Use when writing Three.js or R3F code, when a model looks washed out or too dark, a scene stutters or leaks memory after navigation, glTF files are huge, or when choosing between WebGLRenderer and WebGPURenderer. Try asking
- Our product model looks washed out and flat compared to Blender, fix the lighting
- The 3D configurator drops to 20 fps on phones, profile and optimize it
- Memory keeps growing every time users open and close the 3D viewer route
Use it when
- Writing vanilla Three.js or React Three Fiber code
- Models look too dark, washed out or wrongly shaded
- A 3D scene stutters on mobile or ships oversized glTF files
- GPU memory grows after navigation in a single-page app
Skip it when
- Writing custom GLSL, HLSL or WGSL shader code — use shader-programming
- 2D canvas games with sprites and tilemaps — use phaser-games
How to check the result
renderer.info stays inside the draw-call and triangle budget, frame time holds under 16.7 ms on a mid-range phone, and memory counters return to baseline after repeated unmounts.
The common mistake it prevents
Loading albedo textures manually without setting colorSpace to SRGBColorSpace, which makes every material look washed out.
Done well means: colors and lighting look like the reference in Blender or the glTF viewer, the scene holds 60 fps on a mid-range phone at a capped pixel ratio, assets ship compressed, and navigating away releases every geometry, material, and texture.
Ground rules
- Read
package.jsonforthree,@react-three/fiber, and@react-three/dreiversions. R3F v9 pairs with React 19; v8 with React 18. Never mix major versions. - Import addons from
three/addons/...(e.g.three/addons/loaders/GLTFLoader.js), not the oldthree/examples/jsmpath unless the project already does. - Default renderer:
WebGLRenderer(widest support, works with GLSLShaderMaterial). ChooseWebGPURenderer(import * as THREE from 'three/webgpu') for compute or TSL node materials; it falls back to WebGL2, but GLSLShaderMaterial/RawShaderMaterialdo not run on it, so port custom shaders to TSL first. - Units are meters; lights use physical intensities. Ask for or inspect the asset scale before tuning lights.
Workflow
- [ ] 1. Inspect the renderer setup, color space, tone mapping, and resize handling
- [ ] 2. Inspect assets: size, compression, texture resolutions (gltf-transform inspect)
- [ ] 3. Build or change the scene
- [ ] 4. Measure: renderer.info draw calls and triangles, frame time at DPR <= 2
- [ ] 5. Optimize the biggest cost first (table below)
- [ ] 6. Verify disposal: mount/unmount 10 times, GPU memory counts return to baseline
Renderer baseline (vanilla)
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const canvas = document.querySelector<HTMLCanvasElement>('#scene')!;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping; // or AgXToneMapping / NeutralToneMapping
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// outputColorSpace defaults to SRGBColorSpace; leave it.
const scene = new THREE.Scene();
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);
camera.position.set(3, 2, 5);
function resize() {
const { clientWidth: w, clientHeight: h } = canvas;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(canvas);
let mixer: THREE.AnimationMixer | undefined; // assigned after a glTF loads
let last = performance.now();
renderer.setAnimationLoop((now) => {
const dt = Math.min((now - last) / 1000, 0.1); // clamp after tab switches
last = now;
mixer?.update(dt);
renderer.render(scene, camera);
});
Color and lighting
| Symptom | Cause | Fix |
|---|---|---|
| Washed out, grey textures | Color texture treated as linear, or double sRGB | map.colorSpace = THREE.SRGBColorSpace for albedo/emissive only |
| Normal/roughness maps look wrong | Data texture marked sRGB | Leave data maps at default NoColorSpace |
| Everything too dark after upgrade | Physically based light units | Raise intensities, add scene.environment, check tone mapping exposure |
| Metal looks black | No environment to reflect | Set scene.environment (PMREM of an HDR or RoomEnvironment) |
| Blown-out highlights | No tone mapping | Set toneMapping and tune toneMappingExposure |
glTF loaders set texture color spaces correctly; manual TextureLoader loads do not.
Lighting recipe for product/hero scenes: environment map for ambient and reflections,
one shadow-casting DirectionalLight as key, nothing else until you can name what is
missing.
Shadows
- Only the key light casts; only meaningful meshes
castShadow; groundreceiveShadow. - Fit the shadow camera tightly around the visible area
(
light.shadow.camera.left/right/top/bottom,near/far). Loose bounds waste resolution. mapSize1024 or 2048; 4096 only on desktop.- Acne (striped surfaces) → small negative
shadow.bias(around -0.0005) andshadow.normalBias(0.02-0.05). Peter-panning (detached shadows) → bias too large. - Static scenes: bake shadows into lightmaps or use
ContactShadows(drei) instead.
glTF assets
npx @gltf-transform/cli inspect model.glb # sizes, texture dims, draw count
npx @gltf-transform/cli optimize model.glb model.opt.glb \
--compress meshopt --texture-compress ktx2 # or webp for simpler setups
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
const loader = new GLTFLoader().setKTX2Loader(ktx2).setMeshoptDecoder(MeshoptDecoder);
const gltf = await loader.loadAsync('/models/robot.opt.glb');
scene.add(gltf.scene);
mixer = new THREE.AnimationMixer(gltf.scene);
mixer.clipAction(THREE.AnimationClip.findByName(gltf.animations, 'Walk')!).play();
- Draco needs
DRACOLoaderwith a decoder path; Meshopt decodes faster and needs no WASM path. Pick one per project. - Copy the Basis transcoder files (from
three/examples/jsm/libs/basis/) to the public folder thatsetTranscoderPathpoints at. - Texture budget: 2048 max on mobile; KTX2 keeps textures compressed in GPU memory (a 2048 RGBA PNG costs about 16 MB of VRAM uncompressed, plus mipmaps).
Performance budget
| Target | Mobile | Desktop |
|---|---|---|
Draw calls (renderer.info.render.calls) | under 100-150 | under 500-1000 |
| Triangles on screen | under 300k | under 2-3M |
| Pixel ratio | cap at 1.5-2 | cap at 2 |
Fix the biggest cost first:
| Cost | Fix |
|---|---|
| Many copies of one mesh | InstancedMesh (set instanceMatrix, call needsUpdate) |
| Many different static meshes sharing a material | BufferGeometryUtils.mergeGeometries or BatchedMesh |
| Fill-rate bound (fullscreen effects, high DPR) | Lower DPR, fewer post passes, smaller shadow maps |
| Distant detail | LOD objects or simplified meshes (gltf-transform simplify) |
| Static scene redrawn every frame | Render on demand (R3F frameloop="demand", call invalidate()) |
| Raycasting dense meshes | three-mesh-bvh acceleration |
Never allocate Vector3, Matrix4, or materials inside the render loop; reuse module-level
scratch objects.
React Three Fiber
import { Canvas, useFrame } from '@react-three/fiber';
import { Environment, OrbitControls, useGLTF } from '@react-three/drei';
import { useRef } from 'react';
import type { Group } from 'three';
function Robot() {
const ref = useRef<Group>(null);
const { scene } = useGLTF('/models/robot.opt.glb');
useFrame((_, delta) => { if (ref.current) ref.current.rotation.y += delta * 0.5; });
return <primitive ref={ref} object={scene} />;
}
export function Viewer() {
return (
<Canvas dpr={[1, 2]} shadows camera={{ position: [3, 2, 5], fov: 50 }}>
<Environment preset="city" />
<directionalLight position={[5, 8, 3]} intensity={2} castShadow />
<Robot />
<OrbitControls makeDefault />
</Canvas>
);
}
useGLTF.preload('/models/robot.opt.glb');
- Mutate refs in
useFrame; never callsetStateper frame (re-renders the React tree). npx gltfjsx model.glb --transformgenerates a typed component and an optimized file.<primitive object={scene}>mounts the cached scene once; to place the same model twice,scene.clone()or use the generated JSX component.- Wrap loaders in
<Suspense>with a fallback; preload heavy assets.
Disposal
Three.js never frees GPU memory on its own in vanilla code:
function disposeObject(root: THREE.Object3D) {
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose();
const mats = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : [];
for (const m of mats) {
for (const v of Object.values(m)) if (v instanceof THREE.Texture) v.dispose();
m.dispose();
}
});
}
// on teardown: disposeObject(scene); pmrem.dispose(); renderer.dispose(); renderer.setAnimationLoop(null);
- R3F disposes objects it created on unmount, but not cached
useGLTFassets or objects passed via<primitive>; calluseGLTF.clear(url)when a model is gone for good. - Remove event listeners and ResizeObservers on teardown.
Gotchas
- Forgetting
camera.updateProjectionMatrix()after changing aspect or fov. - Near plane too small (0.001) with a large far plane causes z-fighting; keep far/near ratio modest.
- Transparent objects sort per object, not per triangle; overlapping transparent meshes
flicker. Use
alphaTestfor cutouts ordepthWrite: falsefor particles. renderer.setSize(w, h)without the thirdfalseargument overwrites CSS sizing.- Safari and low-end Android may lack WebGPU; feature-detect before choosing a code path.
Verification
- Visual check against the glTF reference viewer or Blender render: colors, exposure.
renderer.info.render.callsand.triangleswithin the budget table; frame time under 16.7 ms on a mid-range phone (Chrome remote debugging Performance panel).- Spector.js capture for unexplained draw calls or redundant state changes.
- Mount/unmount or navigate away and back 10 times:
renderer.info.memory.geometriesand.texturesreturn to baseline, JS heap does not climb.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.