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.

  • namethreejs-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

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.

~/.claude/skills/threejs-scenes/SKILL.md

--- name: threejs-scenes description: Builds and optimizes Three.js and React Three Fiber 3D scenes with correct renderer setup, color management, physically based lighting,… license: MIT ---

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.json for three, @react-three/fiber, and @react-three/drei versions. 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 old three/examples/jsm path unless the project already does.
  • Default renderer: WebGLRenderer (widest support, works with GLSL ShaderMaterial). Choose WebGPURenderer (import * as THREE from 'three/webgpu') for compute or TSL node materials; it falls back to WebGL2, but GLSL ShaderMaterial/RawShaderMaterial do 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

SymptomCauseFix
Washed out, grey texturesColor texture treated as linear, or double sRGBmap.colorSpace = THREE.SRGBColorSpace for albedo/emissive only
Normal/roughness maps look wrongData texture marked sRGBLeave data maps at default NoColorSpace
Everything too dark after upgradePhysically based light unitsRaise intensities, add scene.environment, check tone mapping exposure
Metal looks blackNo environment to reflectSet scene.environment (PMREM of an HDR or RoomEnvironment)
Blown-out highlightsNo tone mappingSet 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; ground receiveShadow.
  • Fit the shadow camera tightly around the visible area (light.shadow.camera.left/right/top/bottom, near/far). Loose bounds waste resolution.
  • mapSize 1024 or 2048; 4096 only on desktop.
  • Acne (striped surfaces) → small negative shadow.bias (around -0.0005) and shadow.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 DRACOLoader with 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 that setTranscoderPath points 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

TargetMobileDesktop
Draw calls (renderer.info.render.calls)under 100-150under 500-1000
Triangles on screenunder 300kunder 2-3M
Pixel ratiocap at 1.5-2cap at 2

Fix the biggest cost first:

CostFix
Many copies of one meshInstancedMesh (set instanceMatrix, call needsUpdate)
Many different static meshes sharing a materialBufferGeometryUtils.mergeGeometries or BatchedMesh
Fill-rate bound (fullscreen effects, high DPR)Lower DPR, fewer post passes, smaller shadow maps
Distant detailLOD objects or simplified meshes (gltf-transform simplify)
Static scene redrawn every frameRender on demand (R3F frameloop="demand", call invalidate())
Raycasting dense meshesthree-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 call setState per frame (re-renders the React tree).
  • npx gltfjsx model.glb --transform generates 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 useGLTF assets or objects passed via <primitive>; call useGLTF.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 alphaTest for cutouts or depthWrite: false for particles.
  • renderer.setSize(w, h) without the third false argument overwrites CSS sizing.
  • Safari and low-end Android may lack WebGPU; feature-detect before choosing a code path.

Verification

  1. Visual check against the glTF reference viewer or Blender render: colors, exposure.
  2. renderer.info.render.calls and .triangles within the budget table; frame time under 16.7 ms on a mid-range phone (Chrome remote debugging Performance panel).
  3. Spector.js capture for unexplained draw calls or redundant state changes.
  4. Mount/unmount or navigate away and back 10 times: renderer.info.memory.geometries and .textures return 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.

esc