
Threejs
- 81 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
threejs is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- threejs
- AI & Agent Building
- AI-coding skill
Threejs by the numbers
- 81 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,179 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill threejsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 81 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Three.js
Overview
Guides building high-performance 3D web experiences with Three.js, WebGPU-first rendering, TSL (Three Shader Language), and React Three Fiber. Covers scene architecture, asset compression, draw call budgets, and React 19 / Next.js integration patterns.
When to use: Creating 3D scenes, WebGPU rendering setup, TSL shader authoring, asset optimization (Draco/KTX2), React Three Fiber composition, Next.js streaming for 3D content, loading GLTF models, setting up lighting and shadows, animation playback and blending.
When NOT to use: 2D canvas animations (use Canvas API), simple SVG graphics, server-side rendering without client hydration, projects targeting pre-2022 hardware exclusively.
Quick Reference
| Pattern | API / Approach | Key Points |
|---|---|---|
| WebGPU renderer | import * as THREE from 'three/webgpu' | Must await renderer.init() before first render |
| R3F canvas | <Canvas gl={...}> with Suspense | Wrap in <Suspense> for streaming support |
| Frame updates | useFrame((state, delta) => ...) | Mutate refs directly; never use setState |
| TSL shaders | import { ... } from 'three/tsl' | Node-based; compiles to WGSL or GLSL |
| Instancing | <instancedMesh> with matrix updates | Single draw call for repeated geometry |
| Batched mesh | BatchedMesh (r156+) | Different geometries sharing one material |
| Draco compression | gltf-pipeline -i in.gltf -o out.glb -d | Up to 90% geometry size reduction |
| KTX2 textures | Basis Universal via toktx | Stays compressed in VRAM |
| LOD | THREE.LOD with distance thresholds | Swap detail levels by camera distance |
| On-demand render | <Canvas frameloop="demand"> | Only render when scene state changes |
| Cleanup | .dispose() on unmount | Geometries, materials, and textures |
| Compute shaders | Fn(() => {...})().compute(count) | GPU-side physics, particles, flocking |
| Lighting | DirectionalLight, SpotLight, HemisphereLight | Enable shadowMap on renderer + castShadow on light |
| GLTF loading | GLTFLoader + DRACOLoader | Draco for geometry compression; traverse for shadows |
| Animation | AnimationMixer + clipAction() | Update with clock.getDelta() every frame |
| Crossfade | action.fadeOut() / action.fadeIn() | Weight-based blending between walk/run/idle |
| Environment maps | RGBELoader + PMREMGenerator | Set scene.environment for PBR reflections |
| Raycasting | Raycaster.setFromCamera(pointer, cam) | Mouse/touch picking; use recursive: true for GLTF |
| Morph targets | mesh.morphTargetInfluences[index] | Facial animation, blend shapes from GLTF |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Allocating new THREE.Vector3() or new THREE.Color() inside useFrame | Pre-allocate outside the loop to avoid GC pressure every frame |
Using requestAnimationFrame manually in React projects | Use R3F's useFrame hook for frame-by-frame updates |
Not awaiting renderer.init() for WebGPU | Always await renderer.init() before the first render to avoid race conditions |
Loading assets without <Suspense> boundaries | Wrap <Canvas> in <Suspense> to prevent main thread blocking |
| Using high-poly models for background or distant elements | Use LOD (Level of Detail) or Impostors to reduce draw calls |
Using setState inside render loop for animations | Mutate refs directly via useFrame for frame-by-frame updates |
Speed tied to frame rate (rotation += 0.01) | Multiply by delta for frame-rate-independent motion |
Not using clock.getDelta() for mixer.update() | Always pass delta time to mixer.update(delta) for correct animation speed |
| Forgetting to dispose loaded GLTF models | Traverse and dispose geometries, materials, and textures on removal |
| Shadow map enabled on renderer but not on the light | Set both renderer.shadowMap.enabled and light.castShadow = true |
Large far/near ratio on camera | Keep ratio small to avoid z-fighting; set near as large as possible |
Delegation
- Asset and scene graph exploration: Use
Exploreagent - Multi-file scene refactoring and optimization passes: Use
Taskagent - 3D architecture and rendering pipeline planning: Use
Planagent
References
- Scene, lighting, and model loading
- Animation system and blending
- Performance and asset optimization
- WebGPU and TSL shader patterns
- React Three Fiber patterns and Next.js integration
Animation System
Three.js provides a complete animation system built on AnimationMixer, AnimationClip, and AnimationAction. Supports skeletal animation from GLTF models, morph targets, procedural animation, and weight-based blending.
Core Architecture
| Class | Role |
|---|---|
AnimationMixer | Drives playback for a specific scene object; call .update(delta) each frame |
AnimationClip | Reusable animation data containing keyframe tracks |
AnimationAction | Playback controller for a single clip on a mixer (play, pause, fade, loop) |
KeyframeTrack | Single animated property (position, rotation, scale, morph weight) |
AnimationMixer
Create one mixer per animated object. Update it in the render loop with clock.getDelta():
import * as THREE from 'three';
const clock = new THREE.Clock();
const mixer = new THREE.AnimationMixer(model);
function animate(): void {
const delta = clock.getDelta();
mixer.update(delta);
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);Using getDelta() ensures frame-rate-independent playback. Never use a fixed timestep like mixer.update(1/60).
GLTF Animation Loading
GLTF models store animations in gltf.animations. Create actions from clips via the mixer:
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
let mixer: THREE.AnimationMixer;
const actions: Map<string, THREE.AnimationAction> = new Map();
loader.load('/character.glb', (gltf) => {
const model = gltf.scene;
scene.add(model);
mixer = new THREE.AnimationMixer(model);
for (const clip of gltf.animations) {
const action = mixer.clipAction(clip);
actions.set(clip.name, action);
}
actions.get('Idle')?.play();
});Find a specific clip by name:
const walkClip = THREE.AnimationClip.findByName(gltf.animations, 'Walk');
const walkAction = mixer.clipAction(walkClip);AnimationAction Controls
const action = mixer.clipAction(clip);
action.play();
action.stop();
action.paused = true;
action.paused = false;
action.timeScale = 2;
action.setLoop(THREE.LoopRepeat, Infinity);
action.setLoop(THREE.LoopOnce, 1);
action.clampWhenFinished = true;
action.setEffectiveWeight(0.5);
action.setEffectiveTimeScale(1.5);| Method / Property | Purpose |
|---|---|
play() | Start playback from current time |
stop() | Stop and reset to beginning |
reset() | Reset time and weight, keep in play state |
fadeIn(duration) | Ramp weight from 0 to 1 |
fadeOut(duration) | Ramp weight from current to 0 |
crossFadeTo(other, duration, warp) | Fade out this action while fading in another |
setLoop(mode, count) | LoopOnce, LoopRepeat, LoopPingPong |
clampWhenFinished | Hold last frame when LoopOnce completes |
timeScale | Playback speed multiplier (negative = reverse) |
weight | Blend influence (0-1) |
Animation Blending
Crossfade Between Animations
Smooth transition between two actions (e.g., walk to run):
function crossFade(
from: THREE.AnimationAction,
to: THREE.AnimationAction,
duration: number,
): void {
from.fadeOut(duration);
to.reset().fadeIn(duration).play();
}
crossFade(actions.get('Walk')!, actions.get('Run')!, 0.5);Weight-Based Blending
Blend multiple animations simultaneously using weights:
const idleAction = mixer.clipAction(idleClip);
const walkAction = mixer.clipAction(walkClip);
const runAction = mixer.clipAction(runClip);
idleAction.play();
walkAction.play();
runAction.play();
function setMovementBlend(speed: number): void {
const idleWeight = Math.max(0, 1 - speed * 2);
const walkWeight = speed < 0.5 ? speed * 2 : 2 - speed * 2;
const runWeight = Math.max(0, speed * 2 - 1);
idleAction.setEffectiveWeight(idleWeight);
walkAction.setEffectiveWeight(walkWeight);
runAction.setEffectiveWeight(runWeight);
}
setMovementBlend(0.0);
setMovementBlend(0.5);
setMovementBlend(1.0);Additive Animation
Layer animations on top of a base pose (e.g., breathing on top of idle):
THREE.AnimationUtils.makeClipAdditive(breathingClip);
const breathingAction = mixer.clipAction(breathingClip);
breathingAction.setEffectiveWeight(0.5);
breathingAction.play();For pose clips (single-frame), extract a sub-clip:
const poseClip = THREE.AnimationUtils.subclip(clip, clip.name, 2, 3, 30);
THREE.AnimationUtils.makeClipAdditive(poseClip);KeyframeTrack Types
Build custom animations programmatically:
const positionTrack = new THREE.VectorKeyframeTrack(
'.position',
[0, 1, 2], // times (seconds)
[0, 0, 0, 2, 3, 0, 0, 0, 0], // values (x,y,z per keyframe)
);
const rotationTrack = new THREE.QuaternionKeyframeTrack(
'.quaternion',
[0, 1, 2],
[0, 0, 0, 1, 0, 0.707, 0, 0.707, 0, 0, 0, 1],
);
const scaleTrack = new THREE.VectorKeyframeTrack(
'.scale',
[0, 1, 2],
[1, 1, 1, 2, 2, 2, 1, 1, 1],
);
const opacityTrack = new THREE.NumberKeyframeTrack(
'.material.opacity',
[0, 1],
[1, 0],
);
const clip = new THREE.AnimationClip('BounceAndFade', 2, [
positionTrack,
rotationTrack,
scaleTrack,
opacityTrack,
]);
const action = mixer.clipAction(clip);
action.setLoop(THREE.LoopRepeat, Infinity);
action.play();| Track Type | Property Path | Values Per Keyframe |
|---|---|---|
VectorKeyframeTrack | .position, .scale | 3 (x, y, z) |
QuaternionKeyframeTrack | .quaternion | 4 (x, y, z, w) |
NumberKeyframeTrack | .material.opacity, custom | 1 |
BooleanKeyframeTrack | .visible | 1 |
ColorKeyframeTrack | .material.color | 3 (r, g, b) |
StringKeyframeTrack | Custom properties | 1 |
Skeletal Animation
GLTF models with armatures export Bone, Skeleton, and SkinnedMesh objects automatically. The mixer drives bone transforms through clips.
loader.load('/character.glb', (gltf) => {
const model = gltf.scene;
scene.add(model);
const skeleton = new THREE.SkeletonHelper(model);
skeleton.visible = true;
scene.add(skeleton);
model.traverse((child) => {
if ((child as THREE.SkinnedMesh).isSkinnedMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
mixer = new THREE.AnimationMixer(model);
const action = mixer.clipAction(gltf.animations[0]);
action.play();
});Access individual bones for procedural adjustments:
const head = model.getObjectByName('Head') as THREE.Bone;
if (head) {
head.rotation.y = Math.sin(elapsedTime) * 0.3;
}Morph Targets
Morph targets (blend shapes) deform geometry between preset shapes. Common for facial animation.
loader.load('/face.glb', (gltf) => {
const mesh = gltf.scene.getObjectByName('Face') as THREE.Mesh;
const influences = mesh.morphTargetInfluences!;
const dict = mesh.morphTargetDictionary!;
influences[dict['smile']] = 0.8;
influences[dict['blink']] = 1.0;
});Animate morph targets over time:
function animate(): void {
const t = clock.getElapsedTime();
influences[dict['blink']] = Math.sin(t * 3) > 0.9 ? 1 : 0;
influences[dict['smile']] = (Math.sin(t * 0.5) + 1) / 2;
mixer.update(clock.getDelta());
renderer.render(scene, camera);
}GLTF animations can also drive morph targets via NumberKeyframeTrack targeting morphTargetInfluences[index].
Procedural Animation
Spring Physics (Smooth Damp)
Smooth interpolation that avoids abrupt starts and stops:
const _current = new THREE.Vector3();
const _velocity = new THREE.Vector3();
const _temp = new THREE.Vector3();
function smoothDamp(
current: THREE.Vector3,
target: THREE.Vector3,
velocity: THREE.Vector3,
smoothTime: number,
delta: number,
): void {
const omega = 2 / smoothTime;
const x = omega * delta;
const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
_temp.copy(current).sub(target);
const change = _temp.clone();
current.copy(target).add(change.multiplyScalar(exp));
velocity.copy(current).sub(target).divideScalar(delta);
}Oscillation with Trigonometric Functions
const _position = new THREE.Vector3();
function animate(): void {
const t = clock.getElapsedTime();
mesh.position.y = Math.sin(t * 2) * 0.5 + 1;
mesh.rotation.y = t * 0.5;
mesh.scale.setScalar(1 + Math.sin(t * 3) * 0.1);
renderer.render(scene, camera);
}Look-At with Damping
const _targetQuat = new THREE.Quaternion();
const _lookAtMatrix = new THREE.Matrix4();
function smoothLookAt(
object: THREE.Object3D,
target: THREE.Vector3,
speed: number,
delta: number,
): void {
_lookAtMatrix.lookAt(object.position, target, object.up);
_targetQuat.setFromRotationMatrix(_lookAtMatrix);
object.quaternion.slerp(_targetQuat, 1 - Math.exp(-speed * delta));
}Performance Tips
| Practice | Reason |
|---|---|
Pre-allocate Vector3, Quaternion, Matrix4 | Avoids GC pressure every frame |
Use clock.getDelta() for mixer updates | Frame-rate-independent playback |
| Share mixers per root object, not per mesh | One mixer drives all animations for a model |
Dispose unused actions with mixer.uncacheAction(clip) | Frees memory for removed animations |
Use LoopOnce + clampWhenFinished for one-shot animations | Prevents looping after completion |
| Stop actions when off-screen | action.paused = true saves CPU |
Listen for 'finished' event on mixer | Trigger state changes when one-shot animations end |
mixer.addEventListener('finished', (event) => {
const finishedAction = event.action as THREE.AnimationAction;
finishedAction.fadeOut(0.25);
actions.get('Idle')?.reset().fadeIn(0.25).play();
});Performance and Asset Optimization
Maintaining high frame rates requires disciplined asset management, draw call budgets, and render loop efficiency.
Geometry Compression with Draco
Draco reduces .glb geometry size by up to 90%. Always compress models for production.
bun x gltf-pipeline -i scene.gltf -o scene.glb -dLoad Draco-compressed models in React Three Fiber using the useDraco option:
import { useGLTF } from '@react-three/drei';
function Model() {
const { scene } = useGLTF('/model.glb');
return <primitive object={scene} />;
}
useGLTF.preload('/model.glb');InstancedMesh for Repeated Geometry
Render thousands of identical objects with a single draw call:
import { useRef, useEffect } from 'react';
import * as THREE from 'three';
function Forest() {
const count = 1000;
const meshRef = useRef<THREE.InstancedMesh>(null!);
const tempObject = new THREE.Object3D();
useEffect(() => {
for (let i = 0; i < count; i++) {
tempObject.position.set(Math.random() * 100, 0, Math.random() * 100);
tempObject.updateMatrix();
meshRef.current.setMatrixAt(i, tempObject.matrix);
}
meshRef.current.instanceMatrix.needsUpdate = true;
}, []);
return (
<instancedMesh ref={meshRef} args={[null!, null!, count]}>
<coneGeometry args={[1, 5, 8]} />
<meshStandardMaterial color="green" />
</instancedMesh>
);
}BatchedMesh for Diverse Geometries
Use BatchedMesh (r156+) when different geometries share the same material. Draws them in a single call without requiring identical geometry.
Texture Optimization with KTX2
PNG and JPG textures decompress into raw bitmaps in VRAM. KTX2 (Basis Universal) stays compressed on the GPU.
When to use KTX2: Any texture larger than 512x512 pixels in production. Use toktx or online converters to generate KTX2 files.
When PNG is acceptable: Small UI textures, sprites under 256x256, textures that need transparency with sharp edges.
Frame-Rate Independent Motion
Always multiply animation values by delta from useFrame:
import { useFrame } from '@react-three/fiber';
import { useRef } from 'react';
import * as THREE from 'three';
function SpinningCube() {
const meshRef = useRef<THREE.Mesh>(null!);
useFrame((_state, delta) => {
meshRef.current.rotation.x += delta * 1.0;
meshRef.current.rotation.y += delta * 0.5;
});
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="royalblue" />
</mesh>
);
}Without delta, animation speed varies with frame rate (faster on 120Hz displays, slower on 30Hz).
On-Demand Rendering
For static or infrequently changing scenes, avoid continuous rendering:
<Canvas frameloop="demand">
{/* Scene only renders when invalidate() is called or state changes */}
</Canvas>Trigger a re-render manually when needed:
import { useThree } from '@react-three/fiber';
function Controller() {
const invalidate = useThree((state) => state.invalidate);
function handleChange() {
invalidate();
}
return null;
}Object Pre-Allocation
Never allocate temporary objects inside useFrame. Pre-allocate them at module or component scope:
const _tempVec = new THREE.Vector3();
const _tempColor = new THREE.Color();
function Particle() {
const ref = useRef<THREE.Mesh>(null!);
useFrame(() => {
_tempVec.set(0, 1, 0);
ref.current.position.add(_tempVec);
});
return <mesh ref={ref}>{/* ... */}</mesh>;
}Allocating inside useFrame creates garbage every frame, triggering GC pauses that cause visible stuttering.
Draw Call Budget
| Scene Type | Target Draw Calls |
|---|---|
| Mobile AR/VR | < 50 |
| Desktop interactive | < 100 |
| Desktop cinematic | < 200 |
Monitor with renderer.info.render.calls or the stats-gl helper.
Resource Cleanup
Explicitly dispose of GPU resources when components unmount:
import { useEffect } from 'react';
function DisposableScene({ geometry, material, texture }) {
useEffect(() => {
return () => {
geometry.dispose();
material.dispose();
texture.dispose();
};
}, [geometry, material, texture]);
return null;
}Failing to dispose causes VRAM leaks that degrade performance over time.
React Three Fiber Patterns
React Three Fiber (R3F) provides a declarative React interface for Three.js. It uses React's component model for scene composition and hooks for frame-by-frame logic.
Canvas Setup
The <Canvas> component creates a Three.js renderer, scene, and camera:
'use client';
import { Canvas } from '@react-three/fiber';
import { Suspense } from 'react';
export default function Scene() {
return (
<div className="h-screen w-full">
<Suspense fallback={<div>Loading...</div>}>
<Canvas shadows camera={{ position: [0, 0, 5], fov: 75 }} dpr={[1, 2]}>
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} castShadow />
{/* Scene children */}
</Canvas>
</Suspense>
</div>
);
}Required wrapper: Always wrap <Canvas> in <Suspense> for asset loading support and streaming compatibility.
useFrame Hook
The primary hook for per-frame updates. Receives renderer state and delta time:
import { useFrame } from '@react-three/fiber';
import { useRef } from 'react';
import * as THREE from 'three';
function RotatingMesh() {
const meshRef = useRef<THREE.Mesh>(null!);
useFrame((_state, delta) => {
meshRef.current.rotation.y += delta;
});
return (
<mesh ref={meshRef}>
<boxGeometry />
<meshStandardMaterial color="hotpink" />
</mesh>
);
}Rules:
- Never call
setStateinsideuseFrame-- it triggers React re-renders at 60fps - Mutate refs directly for position, rotation, scale, and material properties
- Pre-allocate temporary objects (Vector3, Color) outside the callback
useThree Hook
Access the R3F state store (camera, renderer, scene, size, etc.):
import { useThree } from '@react-three/fiber';
function CameraLogger() {
const { camera, size } = useThree();
console.log(camera.position, size.width, size.height);
return null;
}drei Helpers
@react-three/drei provides common abstractions:
import {
OrbitControls,
Environment,
useGLTF,
Html,
Float,
} from '@react-three/drei';
function Scene() {
return (
<>
<OrbitControls enableDamping />
<Environment preset="city" />
<Float speed={2} rotationIntensity={0.5}>
<mesh>
<sphereGeometry />
<meshStandardMaterial color="coral" />
</mesh>
</Float>
<Html position={[0, 2, 0]} center>
<div className="rounded bg-white p-2 text-sm shadow">Label</div>
</Html>
</>
);
}| Helper | Purpose |
|---|---|
OrbitControls | Camera orbit with damping |
Environment | HDR environment maps and lighting |
useGLTF | Load and cache GLTF/GLB models |
Html | Overlay HTML elements in 3D space |
Float | Gentle floating animation |
ContactShadows | Ground-plane soft shadows |
Bounds | Auto-fit camera to scene bounds |
Next.js Integration
Client Component Boundary
Three.js requires browser APIs. Mark scene components with 'use client':
'use client';
import { Canvas } from '@react-three/fiber';
export function ThreeScene() {
return <Canvas>{/* ... */}</Canvas>;
}Partial Prerendering (PPR)
Next.js streams the static shell immediately while the 3D scene loads:
import { Suspense } from 'react';
import { ThreeScene } from './three-scene';
export default function Page() {
return (
<main>
<h1>Product Viewer</h1>
<Suspense fallback={<div className="h-96 animate-pulse bg-muted" />}>
<ThreeScene />
</Suspense>
</main>
);
}The page shell (heading, layout) renders instantly. The 3D canvas streams in when ready.
Dynamic Import
For pages where the 3D scene is below the fold:
import dynamic from 'next/dynamic';
const ThreeScene = dynamic(() => import('./three-scene'), {
ssr: false,
loading: () => <div className="h-96 animate-pulse bg-muted" />,
});Scene Composition Pattern
Organize complex scenes into focused components:
function ProductViewer() {
return (
<Canvas shadows camera={{ position: [0, 2, 5] }}>
<Lighting />
<Environment preset="studio" />
<Suspense fallback={null}>
<ProductModel url="/product.glb" />
</Suspense>
<Floor />
<CameraControls />
</Canvas>
);
}
function Lighting() {
return (
<>
<ambientLight intensity={0.4} />
<directionalLight position={[5, 5, 5]} castShadow intensity={1} />
</>
);
}
function Floor() {
return (
<mesh rotation-x={-Math.PI / 2} receiveShadow>
<planeGeometry args={[20, 20]} />
<meshStandardMaterial color="#f0f0f0" />
</mesh>
);
}Each component handles one concern: lighting, model loading, ground plane, camera controls.
WebGPU with React Three Fiber
R3F v9 supports WebGPU through an async gl prop. You must import from three/webgpu and call extend() to register the node-based elements:
'use client';
import * as THREE from 'three/webgpu';
import * as TSL from 'three/tsl';
import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber';
declare module '@react-three/fiber' {
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
}
extend(THREE as any);
export default function Scene() {
return (
<Canvas
gl={async (props) => {
const renderer = new THREE.WebGPURenderer(props as any);
await renderer.init();
return renderer;
}}
>
<mesh>
<boxGeometry />
<meshBasicNodeMaterial />
</mesh>
</Canvas>
);
}Note: In R3F v9, state.gl is renamed to state.renderer.
React Compiler Considerations
The React Compiler (React 19) handles memoization automatically. Avoid manual useMemo for geometries and materials unless profiling reveals a specific leak. The compiler optimizes re-render paths without explicit memoization hints.
Scene, Lighting, and Model Loading
Covers the foundational Three.js vanilla API for scene graph construction, camera configuration, lighting, environment maps, model loading, and interaction via raycasting.
Coordinate System
Three.js uses a right-handed coordinate system with Y-up:
| Axis | Direction |
|---|---|
| +X | Right |
| +Y | Up |
| +Z | Toward camera (out of screen) |
| -Z | Into screen |
Rotations follow the right-hand rule. GLTF models export in this convention by default.
Scene Setup
import * as THREE from 'three';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x222244);
scene.fog = new THREE.Fog(0x222244, 50, 100);| Property | Purpose |
|---|---|
scene.background | Color, Texture, or CubeTexture |
scene.environment | Environment map applied to all PBR materials that lack their own envMap |
scene.fog | Fog(color, near, far) for linear fog, FogExp2(color, density) for exponential |
scene.backgroundBlurriness | Blur the background texture (0-1), useful with HDR environments |
Camera Types
PerspectiveCamera
The standard camera for most 3D scenes. Mimics how the human eye perceives depth.
const camera = new THREE.PerspectiveCamera(
45, // fov (vertical, degrees)
window.innerWidth / window.innerHeight, // aspect ratio
0.1, // near clipping plane
1000, // far clipping plane
);
camera.position.set(0, 10, 30);
camera.lookAt(0, 0, 0);| Parameter | Guidance |
|---|---|
fov | 45-75 for most scenes; lower for architectural, higher for VR |
near | As large as possible to preserve depth buffer precision |
far | As small as possible; large far/near ratio causes z-fighting |
OrthographicCamera
No perspective foreshortening. Used for 2D overlays, isometric views, and UI elements.
const aspect = window.innerWidth / window.innerHeight;
const frustumSize = 10;
const camera = new THREE.OrthographicCamera(
(-frustumSize * aspect) / 2, // left
(frustumSize * aspect) / 2, // right
frustumSize / 2, // top
-frustumSize / 2, // bottom
0.1, // near
1000, // far
);Update on resize:
window.addEventListener('resize', () => {
const aspect = window.innerWidth / window.innerHeight;
camera.left = (-frustumSize * aspect) / 2;
camera.right = (frustumSize * aspect) / 2;
camera.top = frustumSize / 2;
camera.bottom = -frustumSize / 2;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});Renderer Configuration
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);| Setting | Options | Default |
|---|---|---|
toneMapping | NoToneMapping, LinearToneMapping, ReinhardToneMapping, ACESFilmicToneMapping, AgXToneMapping | NoToneMapping |
outputColorSpace | SRGBColorSpace, LinearSRGBColorSpace | SRGBColorSpace |
shadowMap.type | BasicShadowMap, PCFShadowMap, PCFSoftShadowMap, VSMShadowMap | PCFShadowMap |
setPixelRatio | Clamp to Math.min(window.devicePixelRatio, 2) on high-DPI displays for performance | — |
Lighting Types
AmbientLight
Uniform illumination with no direction or shadows. Prevents completely black areas.
const ambient = new THREE.AmbientLight(0x444444, 1);
scene.add(ambient);When to use: Base fill light in every scene. Keep intensity low to avoid washed-out appearance.
HemisphereLight
Two-color gradient light simulating sky/ground bounce. More natural than AmbientLight for outdoor scenes.
const hemi = new THREE.HemisphereLight(
0xffffff, // sky color
0x8d8d8d, // ground color
3,
);
hemi.position.set(0, 20, 0);
scene.add(hemi);When to use: Outdoor scenes where sky illumination differs from ground bounce.
DirectionalLight
Parallel rays from an infinitely distant source. The primary shadow-casting light for most scenes.
const dirLight = new THREE.DirectionalLight(0xffffff, 3);
dirLight.position.set(3, 10, 10);
dirLight.castShadow = true;
dirLight.shadow.camera.top = 10;
dirLight.shadow.camera.bottom = -10;
dirLight.shadow.camera.left = -10;
dirLight.shadow.camera.right = 10;
dirLight.shadow.camera.near = 0.1;
dirLight.shadow.camera.far = 40;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
dirLight.shadow.bias = -0.0005;
scene.add(dirLight);When to use: Sun/moon simulation, primary scene illumination with shadows.
PointLight
Emits light in all directions from a single point. Intensity decays with distance.
const point = new THREE.PointLight(0xff9900, 100, 50);
point.position.set(0, 5, 0);
point.castShadow = true;
scene.add(point);When to use: Light bulbs, candles, torches. The second parameter is intensity; the third is distance (0 = infinite, no decay cutoff).
SpotLight
Cone-shaped light with configurable angle and soft edges.
const spot = new THREE.SpotLight(0xff8888, 400);
spot.angle = Math.PI / 5;
spot.penumbra = 0.3;
spot.position.set(8, 10, 5);
spot.castShadow = true;
spot.shadow.mapSize.width = 1024;
spot.shadow.mapSize.height = 1024;
spot.shadow.camera.near = 1;
spot.shadow.camera.far = 200;
spot.shadow.bias = -0.002;
spot.shadow.radius = 4;
scene.add(spot);When to use: Stage lighting, flashlights, focused illumination. penumbra (0-1) controls edge softness.
Shadow Setup Checklist
1. renderer.shadowMap.enabled = true 2. light.castShadow = true on shadow-casting lights 3. mesh.castShadow = true on objects that cast shadows 4. mesh.receiveShadow = true on surfaces that receive shadows 5. Adjust light.shadow.camera bounds to tightly fit the scene (smaller = sharper shadows)
Environment Maps
CubeTextureLoader
Six-image cube maps for reflections and scene backgrounds:
const cubeLoader = new THREE.CubeTextureLoader();
const envMap = cubeLoader
.setPath('/textures/cubemap/')
.load(['px.jpg', 'nx.jpg', 'py.jpg', 'ny.jpg', 'pz.jpg', 'nz.jpg']);
scene.background = envMap;
scene.environment = envMap;HDR with RGBELoader
Higher dynamic range for physically-based reflections:
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
const pmremGenerator = new THREE.PMREMGenerator(renderer);
pmremGenerator.compileEquirectangularShader();
new RGBELoader()
.setPath('/textures/hdr/')
.load('environment.hdr', (hdrTexture) => {
const envMap = pmremGenerator.fromEquirectangular(hdrTexture).texture;
scene.environment = envMap;
scene.background = envMap;
hdrTexture.dispose();
pmremGenerator.dispose();
});PMREMGenerator converts equirectangular HDR images into prefiltered mipmap cube maps suitable for PBR materials. Always dispose both the source texture and the generator after use.
Model Loading
GLTFLoader with Draco
GLTF/GLB is the standard format for 3D web content. Draco compression reduces geometry size by up to 90%.
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(
'https://www.gstatic.com/draco/versioned/decoders/1.5.7/',
);
dracoLoader.setDecoderConfig({ type: 'js' });
const gltfLoader = new GLTFLoader();
gltfLoader.setDRACOLoader(dracoLoader);
gltfLoader.load(
'/models/scene.glb',
(gltf) => {
const model = gltf.scene;
model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
scene.add(model);
},
(progress) => {
const percent = (progress.loaded / progress.total) * 100;
console.log(`Loading: ${percent.toFixed(0)}%`);
},
(error) => {
console.error('GLTF load failed:', error);
},
);Loading Manager Pattern
Coordinate multiple asset loads with a single progress tracker:
const manager = new THREE.LoadingManager();
manager.onProgress = (_url, loaded, total) => {
const progress = (loaded / total) * 100;
document.getElementById('progress')!.style.width = `${progress}%`;
};
manager.onLoad = () => {
document.getElementById('loading-screen')!.style.display = 'none';
};
const textureLoader = new THREE.TextureLoader(manager);
const gltfLoader = new GLTFLoader(manager);Disposal Pattern
GPU resources from loaded models must be explicitly freed:
function disposeModel(model: THREE.Object3D): void {
model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh;
mesh.geometry.dispose();
if (Array.isArray(mesh.material)) {
mesh.material.forEach((mat) => mat.dispose());
} else {
mesh.material.dispose();
}
}
});
}Raycasting
Raycaster tests intersections between a ray and scene objects. Used for mouse/touch picking.
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
window.addEventListener('pointermove', (event) => {
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
});
function checkIntersections(
camera: THREE.Camera,
objects: THREE.Object3D[],
): THREE.Intersection[] {
raycaster.setFromCamera(pointer, camera);
return raycaster.intersectObjects(objects, true);
}| Property | Purpose |
|---|---|
intersections[0].object | The intersected mesh |
intersections[0].point | World-space intersection point |
intersections[0].face | The intersected face (normal, vertex indices) |
intersections[0].distance | Distance from camera to intersection |
The second argument to intersectObjects (recursive: true) traverses children, which is required for GLTF models where meshes are nested in groups.
Practical Example: Complete Scene
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
0.1,
100,
);
camera.position.set(0, 2, 5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.target.set(0, 1, 0);
new RGBELoader().load('/env.hdr', (hdr) => {
const pmrem = new THREE.PMREMGenerator(renderer);
const envMap = pmrem.fromEquirectangular(hdr).texture;
scene.environment = envMap;
scene.background = envMap;
scene.backgroundBlurriness = 0.3;
hdr.dispose();
pmrem.dispose();
});
const hemi = new THREE.HemisphereLight(0xffffff, 0x8d8d8d, 1);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xffffff, 3);
sun.position.set(5, 10, 5);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 0.1;
sun.shadow.camera.far = 30;
const d = 10;
sun.shadow.camera.left = -d;
sun.shadow.camera.right = d;
sun.shadow.camera.top = d;
sun.shadow.camera.bottom = -d;
scene.add(sun);
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(50, 50),
new THREE.MeshStandardMaterial({ color: 0xcccccc }),
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
const draco = new DRACOLoader();
draco.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.7/');
const loader = new GLTFLoader();
loader.setDRACOLoader(draco);
loader.load('/model.glb', (gltf) => {
gltf.scene.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
scene.add(gltf.scene);
});
function animate(): void {
controls.update();
renderer.render(scene, camera);
}
renderer.setAnimationLoop(animate);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});WebGPU and TSL Shader Patterns
WebGPU is the production standard for modern Three.js. TSL (Three Shader Language) is a node-based shader system that compiles to WGSL (WebGPU) or GLSL (WebGL fallback). Import from three/webgpu for the WebGPU renderer and from three/tsl for TSL utilities.
Why WebGPU
| Advantage | Detail |
|---|---|
| Lower CPU overhead | Draw calls processed faster than WebGL |
| Compute shaders | Move physics, particles, and flocking to GPU |
| Modern GPU features | Bind groups, storage buffers, indirect draws |
| Multiplexed pipelines | Multiple render passes without state reset |
Mandatory Async Initialization
WebGPU requires asynchronous setup. Rendering before initialization produces black screens or race conditions.
import * as THREE from 'three/webgpu';
const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);In React Three Fiber, pass a custom renderer factory to <Canvas>:
'use client';
import * as THREE from 'three/webgpu';
import { Canvas, extend } from '@react-three/fiber';
import { Suspense } from 'react';
extend(THREE as any);
export default function Scene() {
return (
<Suspense fallback={<div>Loading 3D Scene...</div>}>
<Canvas
shadows
camera={{ position: [0, 0, 5], fov: 75 }}
gl={async (props) => {
const renderer = new THREE.WebGPURenderer({
...props,
antialias: true,
});
await renderer.init();
return renderer;
}}
>
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} castShadow />
{/* Scene content */}
</Canvas>
</Suspense>
);
}TSL Basics
TSL looks like JavaScript but describes shader operations as a node graph. It compiles to WGSL for WebGPU or GLSL for WebGL.
Animated Color
import * as THREE from 'three/webgpu';
import { color, mix, oscSine, timerLocal } from 'three/tsl';
const material = new THREE.MeshStandardNodeMaterial();
const time = timerLocal();
const animatedColor = mix(color(0xff0000), color(0x0000ff), oscSine(time));
material.colorNode = animatedColor;Vertex Displacement (Wave Shader)
import * as THREE from 'three/webgpu';
import { positionLocal, timerLocal, sin, float, vec3 } from 'three/tsl';
const material = new THREE.MeshStandardNodeMaterial();
const time = timerLocal();
const pos = positionLocal;
const wave = sin(pos.x.add(time)).mul(0.5);
const newPos = vec3(pos.x, pos.y.add(wave), pos.z);
material.positionNode = newPos;UV-Based Texture Mixing
import * as THREE from 'three/webgpu';
import { texture, uv, mix, timerLocal, oscSine } from 'three/tsl';
const tex1 = new THREE.TextureLoader().load('/texture1.jpg');
const tex2 = new THREE.TextureLoader().load('/texture2.jpg');
const material = new THREE.MeshStandardNodeMaterial();
const t = oscSine(timerLocal());
material.colorNode = mix(texture(tex1, uv()), texture(tex2, uv()), t);Compute Shaders
Run arbitrary GPU calculations without rendering. Define compute functions with Fn and dispatch with renderer.computeAsync():
import * as THREE from 'three/webgpu';
import {
Fn,
instancedArray,
instanceIndex,
float,
vec3,
hash,
} from 'three/tsl';
const count = 10000;
const positionBuffer = instancedArray(count, 'vec3');
const computeInit = Fn(() => {
const i = float(instanceIndex);
positionBuffer.element(instanceIndex).assign(
vec3(hash(i), hash(i.add(1)), hash(i.add(2)))
.mul(10)
.sub(5),
);
})().compute(count);
await renderer.computeAsync(computeInit);Use cases: particle systems, physics simulations, flocking algorithms, procedural generation.
WebGL Fallback Strategy
WebGPURenderer automatically falls back to WebGL 2 when WebGPU is not available. No separate code path is needed -- ship one renderer and Three.js handles compatibility. To force WebGL for testing:
import * as THREE from 'three/webgpu';
const renderer = new THREE.WebGPURenderer({
canvas,
antialias: true,
forceWebGL: true,
});
await renderer.init();TSL automatically compiles to GLSL when the renderer falls back to WebGL.
TSL vs GLSL Comparison
| GLSL Concept | TSL Equivalent |
|---|---|
uniform float time | timerLocal() |
varying vec2 vUv | uv() |
gl_Position | positionNode |
gl_FragColor | colorNode |
sin(x) | sin(x) (same name) |
mix(a, b, t) | mix(a, b, t) |
texture2D(tex, uv) | texture(tex, uv()) |
TSL advantages: automatic cross-compilation, type safety through the node graph, no string-based shader code, easier debugging.