Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
{
"name": "suaveplan-threejs",
"version": "1.0.0",
"description": "React Three Fiber development skills for Suaveplan 3D packages"
}

View File

@@ -0,0 +1,538 @@
---
name: threejs-animation
description: Three.js animation systems adapted for React Three Fiber and Suaveplan R3F packages. Covers useFrame-driven animation loops, AnimationMixer integration, skeletal animation with @suaveplan/r3f-character, keyframe tracks, morph targets, and declarative animation patterns in the R3F component model.
---
This skill covers animation in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All animation code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to animate 3D objects, characters, cameras, or properties in an R3F scene. They may need frame-loop animations, clip-based skeletal animations, morph target blending, or physics-driven motion.
## Core Concepts
### Three.js Animation System
Three.js provides several animation primitives:
- **AnimationClip**: A reusable set of keyframe tracks (position, rotation, scale, morph targets)
- **AnimationMixer**: The playback controller that drives clips on a specific object
- **AnimationAction**: Controls a single clip's playback (play, pause, crossfade, weight, speed)
- **KeyframeTrack**: Raw keyframe data (times + values) for a single property
- **Clock**: High-resolution timer for delta time calculation
### R3F Animation Approach
In R3F, animation integrates with React's component lifecycle:
| Three.js (Imperative) | R3F (Declarative) | When to Use |
|---|---|---|
| `requestAnimationFrame` loop | `useFrame` hook | Per-frame updates |
| `new AnimationMixer(model)` | `useAnimations` from drei | GLTF clip playback |
| Manual clock management | `useFrame((state, delta) => {})` | Delta time access |
| `mixer.update(delta)` | Automatic in `useAnimations` | Mixer ticking |
| Event listeners | React refs + useEffect | Lifecycle binding |
### Suaveplan R3F Packages for Animation
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core utilities, scene context | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-character` | Skeletal animation, character controllers | `import { useCharacterAnimation } from '@suaveplan/r3f-character'` |
| `@suaveplan/r3f-performance` | Animation performance monitoring | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
| `@suaveplan/r3f-shaders` | Animated shader materials | `import { useAnimatedShader } from '@suaveplan/r3f-shaders'` |
## useFrame: The R3F Animation Loop
`useFrame` is the primary animation hook. It runs every frame inside the R3F render loop and provides access to the Three.js state and delta time.
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import type { Mesh } from 'three';
interface SpinningBoxProps {
speed?: number;
axis?: 'x' | 'y' | 'z';
}
export function SpinningBox({ speed = 1, axis = 'y' }: SpinningBoxProps) {
const meshRef = useRef<Mesh>(null);
useFrame((_state, delta) => {
if (!meshRef.current) return;
meshRef.current.rotation[axis] += delta * speed;
});
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="tomato" />
</mesh>
);
}
```
### useFrame Priority System
R3F processes `useFrame` callbacks in priority order. Lower numbers run first. This is critical for animation ordering (e.g., physics before rendering).
```tsx
// Physics runs first (priority -1)
useFrame(() => { /* update physics */ }, -1);
// Animation runs at default priority (0)
useFrame(() => { /* update animations */ });
// Camera follows after animation (priority 1)
useFrame(() => { /* update camera */ }, 1);
```
### Conditional useFrame
Avoid creating/destroying hooks dynamically. Instead, gate logic inside the callback:
```tsx
export function ConditionalAnimation({ active }: { active: boolean }) {
const meshRef = useRef<Mesh>(null);
useFrame((_state, delta) => {
if (!active || !meshRef.current) return;
meshRef.current.rotation.y += delta;
});
return (
<mesh ref={meshRef}>
<sphereGeometry args={[0.5, 32, 32]} />
<meshStandardMaterial color="dodgerblue" />
</mesh>
);
}
```
## AnimationMixer in R3F
For GLTF models with embedded animations, use `useAnimations` from `@react-three/drei` which wraps `AnimationMixer`.
```tsx
import { useRef, useEffect } from 'react';
import { useGLTF, useAnimations } from '@react-three/drei';
import type { Group } from 'three';
interface AnimatedModelProps {
url: string;
clipName: string;
playing?: boolean;
}
export function AnimatedModel({ url, clipName, playing = true }: AnimatedModelProps) {
const groupRef = useRef<Group>(null);
const { scene, animations } = useGLTF(url);
const { actions, mixer } = useAnimations(animations, groupRef);
useEffect(() => {
const action = actions[clipName];
if (!action) return;
if (playing) {
action.reset().fadeIn(0.3).play();
} else {
action.fadeOut(0.3);
}
return () => {
action.fadeOut(0.3);
};
}, [clipName, playing, actions]);
return <primitive ref={groupRef} object={scene} />;
}
```
### Crossfading Between Clips
```tsx
import { useRef, useEffect, useState } from 'react';
import { useGLTF, useAnimations } from '@react-three/drei';
import type { Group, AnimationAction } from 'three';
interface CrossfadeModelProps {
url: string;
currentClip: string;
fadeDuration?: number;
}
export function CrossfadeModel({ url, currentClip, fadeDuration = 0.4 }: CrossfadeModelProps) {
const groupRef = useRef<Group>(null);
const { scene, animations } = useGLTF(url);
const { actions } = useAnimations(animations, groupRef);
const [previousClip, setPreviousClip] = useState<string | null>(null);
useEffect(() => {
const nextAction = actions[currentClip];
if (!nextAction) return;
if (previousClip && actions[previousClip]) {
const prevAction = actions[previousClip] as AnimationAction;
nextAction.reset().fadeIn(fadeDuration).play();
prevAction.fadeOut(fadeDuration);
} else {
nextAction.reset().fadeIn(fadeDuration).play();
}
setPreviousClip(currentClip);
}, [currentClip, actions, fadeDuration]);
return <primitive ref={groupRef} object={scene} />;
}
```
## @suaveplan/r3f-character: Skeletal Animation
The `@suaveplan/r3f-character` package provides higher-level character animation utilities built on top of Three.js's `AnimationMixer`.
### Character Animation Controller
```tsx
import { useCharacterAnimation } from '@suaveplan/r3f-character';
import type { CharacterAnimationConfig } from '@suaveplan/r3f-character';
const animationConfig: CharacterAnimationConfig = {
clips: {
idle: { url: '/models/character/idle.glb', loop: true },
walk: { url: '/models/character/walk.glb', loop: true },
run: { url: '/models/character/run.glb', loop: true },
jump: { url: '/models/character/jump.glb', loop: false },
},
transitions: {
idle_to_walk: { duration: 0.3 },
walk_to_run: { duration: 0.2 },
any_to_jump: { duration: 0.1 },
},
defaultClip: 'idle',
};
export function Character() {
const { groupRef, playClip, currentClip } = useCharacterAnimation(animationConfig);
return (
<group ref={groupRef}>
{/* Character mesh loaded via the hook */}
</group>
);
}
```
### Blending Mixamo Animations
When working with Mixamo animations on a shared skeleton:
```tsx
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { useGLTF, useAnimations } from '@react-three/drei';
import { AnimationUtils } from 'three';
import type { Group } from 'three';
interface MixamoCharacterProps {
modelUrl: string;
animationUrls: Record<string, string>;
currentAnimation: string;
}
export function MixamoCharacter({
modelUrl,
animationUrls,
currentAnimation,
}: MixamoCharacterProps) {
const groupRef = useRef<Group>(null);
const { scene, animations: baseAnimations } = useGLTF(modelUrl);
// Load additional animation files
const additionalAnims = Object.entries(animationUrls).map(([_name, url]) => {
return useGLTF(url).animations;
});
const allAnimations = useMemo(() => {
const merged = [...baseAnimations];
Object.keys(animationUrls).forEach((name, index) => {
const clips = additionalAnims[index];
if (clips?.[0]) {
const clip = AnimationUtils.subclip(clips[0], name, 0, clips[0].duration * 30, 30);
merged.push(clip);
}
});
return merged;
}, [baseAnimations, additionalAnims]);
const { actions } = useAnimations(allAnimations, groupRef);
useFrame(() => {
const action = actions[currentAnimation];
if (action && !action.isRunning()) {
Object.values(actions).forEach((a) => a?.fadeOut(0.3));
action.reset().fadeIn(0.3).play();
}
});
return <primitive ref={groupRef} object={scene} />;
}
```
## Morph Target Animation
Morph targets (blend shapes) animate vertex positions for facial expressions, deformations, etc.
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { useGLTF } from '@react-three/drei';
import type { Mesh } from 'three';
interface MorphAnimationProps {
url: string;
/** Map of morph target name to weight (0-1) */
morphWeights: Record<string, number>;
/** Transition speed for weight interpolation */
lerpSpeed?: number;
}
export function MorphAnimation({ url, morphWeights, lerpSpeed = 5 }: MorphAnimationProps) {
const { scene } = useGLTF(url);
const meshRef = useRef<Mesh>(null);
useFrame((_state, delta) => {
if (!meshRef.current?.morphTargetDictionary || !meshRef.current.morphTargetInfluences) return;
const dict = meshRef.current.morphTargetDictionary;
const influences = meshRef.current.morphTargetInfluences;
for (const [name, targetWeight] of Object.entries(morphWeights)) {
const index = dict[name];
if (index !== undefined) {
const current = influences[index] ?? 0;
influences[index] = current + (targetWeight - current) * Math.min(delta * lerpSpeed, 1);
}
}
});
return <primitive ref={meshRef} object={scene} />;
}
```
## Procedural Animation Patterns
### Spring-Based Animation
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { MathUtils, Vector3 } from 'three';
import type { Mesh } from 'three';
interface SpringConfig {
stiffness: number;
damping: number;
}
export function SpringFollower({ target, config }: { target: Vector3; config: SpringConfig }) {
const meshRef = useRef<Mesh>(null);
const velocity = useRef(new Vector3());
useFrame((_state, delta) => {
if (!meshRef.current) return;
const pos = meshRef.current.position;
const force = target.clone().sub(pos).multiplyScalar(config.stiffness);
velocity.current.add(force.multiplyScalar(delta));
velocity.current.multiplyScalar(Math.max(0, 1 - config.damping * delta));
pos.add(velocity.current.clone().multiplyScalar(delta));
});
return (
<mesh ref={meshRef}>
<sphereGeometry args={[0.2, 16, 16]} />
<meshStandardMaterial color="limegreen" />
</mesh>
);
}
```
### Easing with MathUtils
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { MathUtils } from 'three';
import type { Mesh } from 'three';
export function SmoothRotation({ targetAngle }: { targetAngle: number }) {
const meshRef = useRef<Mesh>(null);
useFrame((_state, delta) => {
if (!meshRef.current) return;
meshRef.current.rotation.y = MathUtils.damp(
meshRef.current.rotation.y,
targetAngle,
4, // lambda (smoothing factor)
delta
);
});
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshNormalMaterial />
</mesh>
);
}
```
## Performance Considerations
### Animation Budget
Use `@suaveplan/r3f-performance` to monitor animation cost:
```tsx
import { usePerformanceMonitor } from '@suaveplan/r3f-performance';
export function MonitoredScene() {
usePerformanceMonitor({
onDecline: ({ fps }) => {
// Reduce animation quality when FPS drops
if (fps < 30) {
// Switch to simpler animations, reduce bone count, etc.
}
},
});
return <>{/* scene content */}</>;
}
```
### Optimization Guidelines
1. **Avoid allocations in useFrame**: Never create `new Vector3()` or `new Quaternion()` inside `useFrame`. Allocate in `useRef` or module scope.
2. **Use delta time**: Always multiply by `delta` for frame-rate independent animation.
3. **Limit active mixers**: Each `AnimationMixer` updates every frame. Pool or disable off-screen mixers.
4. **LOD-aware animation**: Reduce bone count or switch to procedural animation for distant characters.
5. **Pause when invisible**: Use `useIntersect` from drei or Intersection Observer to pause off-screen animations.
```tsx
// Pre-allocate reusable objects outside useFrame
const _tempVec = new Vector3();
const _tempQuat = new Quaternion();
useFrame(() => {
// Reuse allocated objects
_tempVec.set(0, 1, 0);
mesh.current?.position.lerp(_tempVec, 0.1);
});
```
### Three.js KeyframeTrack Types
When building animation clips programmatically:
| Track Type | Property | Value Type |
|---|---|---|
| `VectorKeyframeTrack` | `.position`, `.scale` | `[x,y,z]` per keyframe |
| `QuaternionKeyframeTrack` | `.quaternion` | `[x,y,z,w]` per keyframe |
| `NumberKeyframeTrack` | `.opacity`, morph targets | Single float per keyframe |
| `BooleanKeyframeTrack` | `.visible` | Boolean per keyframe |
| `ColorKeyframeTrack` | `.color` | `[r,g,b]` per keyframe |
| `StringKeyframeTrack` | Any string property | String per keyframe |
### Programmatic Clip Creation in R3F
```tsx
import { useMemo } from 'react';
import { AnimationClip, VectorKeyframeTrack, NumberKeyframeTrack } from 'three';
import { useAnimations } from '@react-three/drei';
function useBounceClip(name: string, height: number, duration: number) {
return useMemo(() => {
const times = [0, duration * 0.5, duration];
const posValues = [0, 0, 0, 0, height, 0, 0, 0, 0];
const scaleValues = [1, 1, 1, 1, 0.8, 1.2, 1, 1, 1];
return new AnimationClip(name, duration, [
new VectorKeyframeTrack('.position', times, posValues),
new VectorKeyframeTrack('.scale', times, scaleValues),
]);
}, [name, height, duration]);
}
```
## Testing Animations
### Vitest Unit Tests
```tsx
import { describe, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react';
describe('useBounceClip', () => {
it('creates a clip with correct duration', () => {
const { result } = renderHook(() => useBounceClip('bounce', 2, 1));
expect(result.current.duration).toBe(1);
expect(result.current.tracks).toHaveLength(2);
});
it('includes position and scale tracks', () => {
const { result } = renderHook(() => useBounceClip('bounce', 2, 1));
const trackNames = result.current.tracks.map((t) => t.name);
expect(trackNames).toContain('.position');
expect(trackNames).toContain('.scale');
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { SpinningBox } from './spinning-box';
const meta: Meta<typeof SpinningBox> = {
title: 'R3F/Animation/SpinningBox',
component: SpinningBox,
decorators: [
(Story) => (
<Canvas style={{ height: 400 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} />
<Story />
</Canvas>
),
],
};
export default meta;
type Story = StoryObj<typeof SpinningBox>;
export const Default: Story = {};
export const FastSpin: Story = {
args: { speed: 5, axis: 'y' },
};
export const XAxis: Story = {
args: { speed: 2, axis: 'x' },
};
```
## Checklist
When implementing R3F animations:
1. Use `useFrame` with delta time for all per-frame updates
2. Pre-allocate math objects outside `useFrame` callbacks
3. Use `useAnimations` for GLTF clip playback rather than manual mixer management
4. Implement crossfade transitions between animation clips
5. Gate animation logic on `active` flags rather than conditionally calling hooks
6. Monitor performance with `@suaveplan/r3f-performance`
7. Write Vitest tests for animation clip creation and hook behavior
8. Write Storybook stories with Canvas decorator for visual verification
9. Keep animation component files under 250 lines
10. Document morph target names and clip names in `.md` files

View File

@@ -0,0 +1,522 @@
---
name: threejs-fundamentals
description: Three.js fundamentals adapted for React Three Fiber and Suaveplan R3F packages. Covers the Canvas component, declarative scene graph composition, Object3D hierarchy as JSX, coordinate systems, camera setup, and @suaveplan/r3f core utilities.
---
This skill covers Three.js fundamentals within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to set up 3D scenes, understand the scene graph, work with coordinate systems, or configure the rendering pipeline in an R3F application.
## Core Concepts
### Three.js Scene Architecture
Every Three.js application has three fundamental objects:
- **Scene**: The root container (a tree of `Object3D` nodes)
- **Camera**: Defines the viewpoint (perspective or orthographic)
- **Renderer**: Draws the scene from the camera's perspective using WebGL/WebGPU
### R3F Declarative Model
R3F replaces imperative Three.js setup with a React component tree. The `<Canvas>` component creates the renderer, scene, and default camera automatically.
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new THREE.Scene()` | `<Canvas>` (implicit) |
| `new THREE.PerspectiveCamera(75, aspect, 0.1, 1000)` | `<Canvas camera={{ fov: 75, near: 0.1, far: 1000 }}>` |
| `new THREE.WebGLRenderer({ antialias: true })` | `<Canvas gl={{ antialias: true }}>` |
| `scene.add(mesh)` | `<mesh>` as JSX child |
| `renderer.setSize(w, h)` | Automatic resize handling |
| `requestAnimationFrame(render)` | Automatic render loop |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core utilities, scene context, helpers | `import { SceneProvider } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-camera` | Camera controllers | `import { OrbitControls } from '@suaveplan/r3f-camera'` |
| `@suaveplan/r3f-performance` | FPS monitoring, adaptive quality | `import { PerformanceMonitor } from '@suaveplan/r3f-performance'` |
## Canvas: The R3F Entry Point
The `<Canvas>` component is the root of every R3F scene. It creates a WebGL context, sets up the render loop, and provides React context for all Three.js state.
### Basic Scene Setup
```tsx
import { Canvas } from '@react-three/fiber';
export function BasicScene() {
return (
<Canvas
camera={{ position: [0, 2, 5], fov: 60 }}
gl={{ antialias: true, toneMapping: 3 /* ACESFilmic */ }}
style={{ width: '100%', height: '100vh' }}
>
<ambientLight intensity={0.4} />
<directionalLight position={[5, 5, 5]} intensity={1} />
<mesh position={[0, 0.5, 0]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#e74c3c" />
</mesh>
<gridHelper args={[10, 10]} />
</Canvas>
);
}
```
### Canvas Props Reference
```tsx
<Canvas
// Camera configuration
camera={{
position: [0, 5, 10], // World position
fov: 50, // Field of view (degrees)
near: 0.1, // Near clipping plane
far: 1000, // Far clipping plane
zoom: 1, // Zoom level (orthographic)
}}
// Renderer configuration
gl={{
antialias: true,
alpha: false, // Transparent background
toneMapping: 3, // ACESFilmicToneMapping
toneMappingExposure: 1.0,
outputColorSpace: 'srgb',
}}
// Performance
dpr={[1, 2]} // Device pixel ratio range
frameloop="demand" // 'always' | 'demand' | 'never'
performance={{ min: 0.5 }} // Adaptive DPR threshold
// Behavior
shadows // Enable shadow maps
flat // Disable tone mapping
orthographic // Use orthographic camera
linear // Disable sRGB color management
// Sizing
style={{ width: '100%', height: '100vh' }}
/>
```
## Scene Graph as JSX
In R3F, the Three.js scene graph maps directly to JSX elements. Every Three.js class that extends `Object3D` can be used as a JSX element in lowercase.
### Object3D Hierarchy
```tsx
{/* Three.js: const group = new Group(); scene.add(group); */}
<group position={[0, 1, 0]} rotation={[0, Math.PI / 4, 0]}>
{/* Three.js: const mesh = new Mesh(geometry, material); group.add(mesh); */}
<mesh position={[0, 0, 0]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="blue" />
</mesh>
{/* Nested groups create parent-child transforms */}
<group position={[2, 0, 0]}>
<mesh>
<sphereGeometry args={[0.5, 32, 32]} />
<meshStandardMaterial color="green" />
</mesh>
</group>
</group>
```
### JSX to Three.js Mapping
R3F automatically maps JSX elements to Three.js constructors:
| JSX Element | Three.js Class | Constructor Args |
|---|---|---|
| `<mesh>` | `new Mesh()` | None (geometry + material as children) |
| `<group>` | `new Group()` | None |
| `<points>` | `new Points()` | None |
| `<line>` | `new Line()` | None |
| `<lineSegments>` | `new LineSegments()` | None |
| `<sprite>` | `new Sprite()` | None |
| `<instancedMesh args={[null, null, count]}>` | `new InstancedMesh(g, m, count)` | Geometry, Material, Count |
| `<primitive object={obj}>` | Direct insertion | Existing Object3D |
### Transform Props
Every R3F element that maps to an `Object3D` accepts transform props:
```tsx
<mesh
position={[x, y, z]} // Vector3 or [number, number, number]
rotation={[x, y, z]} // Euler or [number, number, number]
scale={[x, y, z]} // Vector3 or number (uniform scale)
quaternion={[x, y, z, w]} // Quaternion rotation
visible={true} // Visibility toggle
frustumCulled={true} // Frustum culling
renderOrder={0} // Render ordering
castShadow // Cast shadows
receiveShadow // Receive shadows
name="myMesh" // Object name for debugging
userData={{ id: 'custom' }} // Custom data
/>
```
## Coordinate System
Three.js uses a **right-handed coordinate system**:
```text
Y (up)
|
|
+---- X (right)
/
Z (toward viewer)
```
- **X**: Positive to the right
- **Y**: Positive upward
- **Z**: Positive toward the viewer (out of the screen)
### Units and Scale
Three.js is unit-agnostic. By convention, 1 unit = 1 meter for physics and VR compatibility:
```tsx
// A 2m tall character standing on a 10m x 10m ground plane
<group>
<mesh position={[0, 1, 0]}> {/* Center at 1m (half height) */}
<boxGeometry args={[0.5, 2, 0.3]} /> {/* 0.5m wide, 2m tall, 0.3m deep */}
<meshStandardMaterial color="tan" />
</mesh>
<mesh rotation={[-Math.PI / 2, 0, 0]}> {/* Rotate to be flat */}
<planeGeometry args={[10, 10]} />
<meshStandardMaterial color="forestgreen" />
</mesh>
</group>
```
## Accessing Three.js State
### useThree Hook
Access the full Three.js state from any R3F child component:
```tsx
import { useThree } from '@react-three/fiber';
export function SceneInfo() {
const { scene, camera, gl, size, viewport, clock } = useThree();
// scene: THREE.Scene instance
// camera: Active camera
// gl: WebGLRenderer instance
// size: { width, height } in pixels
// viewport: { width, height } in Three.js units at camera distance
// clock: THREE.Clock instance
return null;
}
```
### Imperative Escape Hatch with Refs
When you need direct Three.js API access:
```tsx
import { useRef, useEffect } from 'react';
import type { Mesh, MeshStandardMaterial } from 'three';
export function ImperativeMesh() {
const meshRef = useRef<Mesh>(null);
useEffect(() => {
if (!meshRef.current) return;
// Full imperative Three.js API access
const mesh = meshRef.current;
mesh.geometry.computeBoundingBox();
const box = mesh.geometry.boundingBox;
const material = mesh.material as MeshStandardMaterial;
material.needsUpdate = true;
// Traverse children
mesh.traverse((child) => {
if (child instanceof Mesh) {
child.castShadow = true;
}
});
}, []);
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="coral" />
</mesh>
);
}
```
## @suaveplan/r3f Core Utilities
The `@suaveplan/r3f` package provides scene-level context and shared utilities:
```tsx
import { SceneProvider, useSceneContext } from '@suaveplan/r3f';
import { Canvas } from '@react-three/fiber';
export function App() {
return (
<Canvas>
<SceneProvider config={{ units: 'meters', upAxis: 'y' }}>
<SceneContent />
</SceneProvider>
</Canvas>
);
}
function SceneContent() {
const { config } = useSceneContext();
// Access shared scene configuration
return <>{/* scene objects */}</>;
}
```
## Render Modes
### On-Demand Rendering
For scenes that only change on interaction, use `frameloop="demand"` to save GPU cycles:
```tsx
import { Canvas, invalidate } from '@react-three/fiber';
export function OnDemandScene() {
return (
<Canvas frameloop="demand">
<InteractiveObject />
</Canvas>
);
}
function InteractiveObject() {
return (
<mesh
onClick={(e) => {
e.object.rotation.y += 0.5;
invalidate(); // Request a re-render
}}
>
<boxGeometry />
<meshStandardMaterial color="hotpink" />
</mesh>
);
}
```
### Adaptive Performance
```tsx
import { Canvas } from '@react-three/fiber';
import { PerformanceMonitor } from '@suaveplan/r3f-performance';
export function AdaptiveScene() {
return (
<Canvas dpr={[1, 2]} performance={{ min: 0.5 }}>
<PerformanceMonitor
onDecline={() => { /* reduce quality */ }}
onIncline={() => { /* increase quality */ }}
/>
{/* scene content */}
</Canvas>
);
}
```
## Scene Organization Patterns
### Layer-Based Architecture
```tsx
export function GameScene() {
return (
<Canvas shadows camera={{ position: [0, 10, 20], fov: 50 }}>
{/* Environment layer */}
<group name="environment">
<ambientLight intensity={0.3} />
<directionalLight position={[10, 10, 5]} castShadow />
<Sky />
<Ground />
</group>
{/* Static geometry layer */}
<group name="static">
<Buildings />
<Terrain />
</group>
{/* Dynamic objects layer */}
<group name="dynamic">
<Characters />
<Vehicles />
<Projectiles />
</group>
{/* UI overlay layer */}
<group name="ui">
<HUD />
<Markers />
</group>
</Canvas>
);
}
```
### Suspense for Loading
```tsx
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
function LoadingFallback() {
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial color="gray" wireframe />
</mesh>
);
}
export function SceneWithLoading() {
return (
<Canvas>
<ambientLight />
<Suspense fallback={<LoadingFallback />}>
<HeavyModel url="/models/scene.glb" />
</Suspense>
</Canvas>
);
}
```
## Color Management
R3F defaults to sRGB color management. Colors specified in JSX are treated as sRGB and converted to linear for rendering.
```tsx
// sRGB colors (default) - what you see in design tools
<meshStandardMaterial color="#ff6600" />
<meshStandardMaterial color="tomato" />
<meshStandardMaterial color={new Color(1, 0.4, 0)} /> // Linear!
// For linear workflow (textures already linear)
<Canvas linear>
{/* Colors not converted */}
</Canvas>
// Tone mapping options
<Canvas gl={{ toneMapping: ACESFilmicToneMapping, toneMappingExposure: 1.2 }}>
{/* Scene with filmic tone mapping */}
</Canvas>
```
## Disposal and Cleanup
R3F automatically disposes of geometries, materials, and textures when components unmount. For manual control:
```tsx
import { useEffect } from 'react';
import { useThree } from '@react-three/fiber';
export function CleanupExample() {
const { scene } = useThree();
useEffect(() => {
return () => {
// Manual cleanup for imperatively created resources
scene.traverse((object) => {
if (object instanceof Mesh) {
object.geometry.dispose();
if (Array.isArray(object.material)) {
object.material.forEach((m) => m.dispose());
} else {
object.material.dispose();
}
}
});
};
}, [scene]);
return null;
}
```
## Testing Fundamentals
### Vitest Scene Tests
```tsx
import { describe, it, expect } from 'vitest';
import { createRoot } from '@react-three/fiber';
import { Scene } from 'three';
describe('BasicScene', () => {
it('renders without crashing', () => {
const scene = new Scene();
// Use @react-three/test-renderer for component testing
expect(scene.children).toHaveLength(0);
});
});
```
### Storybook Scene Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
function SceneTemplate() {
return (
<Canvas camera={{ position: [3, 3, 3] }} style={{ height: 400 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} />
<mesh>
<boxGeometry />
<meshStandardMaterial color="royalblue" />
</mesh>
<gridHelper />
<OrbitControls />
</Canvas>
);
}
const meta: Meta = {
title: 'R3F/Fundamentals/Scene',
component: SceneTemplate,
};
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When setting up an R3F scene:
1. Use `<Canvas>` as the root with appropriate camera and renderer config
2. Structure scene graph with named `<group>` layers
3. Set `dpr={[1, 2]}` for responsive pixel ratio
4. Wrap heavy models in `<Suspense>` with fallback geometry
5. Use `useThree` for state access, refs for imperative escape hatches
6. Enable shadows on Canvas and on individual lights/meshes
7. Configure tone mapping and color space for the project
8. Use `@suaveplan/r3f` SceneProvider for shared configuration
9. Use `@suaveplan/r3f-performance` for adaptive quality
10. Keep scene component files under 250 lines, split into sub-components

View File

@@ -0,0 +1,576 @@
---
name: threejs-geometry
description: Three.js geometry creation and manipulation adapted for React Three Fiber and Suaveplan R3F packages. Covers declarative mesh/geometry JSX, BufferGeometry with React refs, InstancedMesh patterns for high-performance rendering, custom geometry construction, and @react-three/drei geometry helpers.
---
This skill covers geometry creation and manipulation in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to create, modify, or optimize 3D geometry in an R3F scene. This includes built-in primitives, custom BufferGeometry, instanced rendering, and parametric shapes.
## Core Concepts
### Three.js Geometry System
Three.js geometry is based on `BufferGeometry`, which stores vertex data in typed arrays:
- **position**: `Float32Array` of `[x, y, z]` per vertex
- **normal**: `Float32Array` of `[nx, ny, nz]` per vertex
- **uv**: `Float32Array` of `[u, v]` per vertex
- **index**: `Uint16Array` or `Uint32Array` of triangle vertex indices
- **color**: `Float32Array` of `[r, g, b]` per vertex (optional)
### R3F Geometry Approach
In R3F, geometries are declared as JSX children of `<mesh>`:
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new BoxGeometry(1, 1, 1)` | `<boxGeometry args={[1, 1, 1]} />` |
| `mesh.geometry = geometry` | `<mesh><boxGeometry /></mesh>` |
| `geometry.setAttribute(...)` | Via ref or attach prop |
| `new InstancedMesh(g, m, n)` | `<instancedMesh args={[null, null, n]}>` |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core utilities, geometry helpers | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-performance` | Geometry LOD, draw call monitoring | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
## Built-in Geometry Primitives
All Three.js geometry classes are available as lowercase JSX elements. Constructor arguments pass via `args`.
### Box
```tsx
<mesh>
<boxGeometry args={[
1, // width
1, // height
1, // depth
1, // widthSegments (optional)
1, // heightSegments (optional)
1, // depthSegments (optional)
]} />
<meshStandardMaterial color="tomato" />
</mesh>
```
### Sphere
```tsx
<mesh>
<sphereGeometry args={[
0.5, // radius
32, // widthSegments
32, // heightSegments
0, // phiStart (optional)
Math.PI * 2, // phiLength (optional)
0, // thetaStart (optional)
Math.PI, // thetaLength (optional)
]} />
<meshStandardMaterial color="dodgerblue" />
</mesh>
```
### Common Primitives Reference
| Geometry | JSX | Key Args |
|---|---|---|
| Box | `<boxGeometry args={[w, h, d]} />` | width, height, depth |
| Sphere | `<sphereGeometry args={[r, wSeg, hSeg]} />` | radius, segments |
| Cylinder | `<cylinderGeometry args={[rTop, rBot, h, rSeg]} />` | radii, height, segments |
| Cone | `<coneGeometry args={[r, h, rSeg]} />` | radius, height |
| Torus | `<torusGeometry args={[r, tube, rSeg, tSeg]} />` | radius, tube radius |
| Plane | `<planeGeometry args={[w, h, wSeg, hSeg]} />` | width, height |
| Ring | `<ringGeometry args={[inner, outer, tSeg]} />` | inner/outer radius |
| Circle | `<circleGeometry args={[r, seg]} />` | radius, segments |
| TorusKnot | `<torusKnotGeometry args={[r, tube, tSeg, rSeg, p, q]} />` | radius, tube, knot params |
| Dodecahedron | `<dodecahedronGeometry args={[r, detail]} />` | radius, detail level |
| Icosahedron | `<icosahedronGeometry args={[r, detail]} />` | radius, detail level |
| Octahedron | `<octahedronGeometry args={[r, detail]} />` | radius, detail level |
| Tetrahedron | `<tetrahedronGeometry args={[r, detail]} />` | radius, detail level |
## Custom BufferGeometry
### Declarative with Refs
```tsx
import { useRef, useEffect, useMemo } from 'react';
import { BufferAttribute } from 'three';
import type { BufferGeometry } from 'three';
interface CustomTriangleProps {
size?: number;
color?: string;
}
export function CustomTriangle({ size = 1, color = 'limegreen' }: CustomTriangleProps) {
const geometryRef = useRef<BufferGeometry>(null);
const { positions, normals, uvs } = useMemo(() => {
const s = size;
return {
positions: new Float32Array([
-s, -s, 0, // bottom-left
s, -s, 0, // bottom-right
0, s, 0, // top-center
]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
]),
uvs: new Float32Array([
0, 0,
1, 0,
0.5, 1,
]),
};
}, [size]);
return (
<mesh>
<bufferGeometry ref={geometryRef}>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
/>
<bufferAttribute
attach="attributes-normal"
args={[normals, 3]}
/>
<bufferAttribute
attach="attributes-uv"
args={[uvs, 2]}
/>
</bufferGeometry>
<meshStandardMaterial color={color} side={2} />
</mesh>
);
}
```
### Indexed Geometry
For geometry with shared vertices (reduces memory and improves performance):
```tsx
import { useMemo } from 'react';
export function IndexedQuad({ size = 1 }: { size?: number }) {
const { positions, indices, uvs } = useMemo(() => {
const s = size / 2;
return {
positions: new Float32Array([
-s, -s, 0, // 0: bottom-left
s, -s, 0, // 1: bottom-right
s, s, 0, // 2: top-right
-s, s, 0, // 3: top-left
]),
indices: new Uint16Array([
0, 1, 2, // first triangle
0, 2, 3, // second triangle
]),
uvs: new Float32Array([
0, 0,
1, 0,
1, 1,
0, 1,
]),
};
}, [size]);
return (
<mesh>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
<bufferAttribute attach="attributes-uv" args={[uvs, 2]} />
<bufferAttribute attach="index" args={[indices, 1]} />
</bufferGeometry>
<meshStandardMaterial color="coral" />
</mesh>
);
}
```
## Dynamic Geometry Updates
### Animating Vertices in useFrame
```tsx
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import type { BufferGeometry } from 'three';
export function WavingPlane({ width = 5, segments = 32 }: { width?: number; segments?: number }) {
const geometryRef = useRef<BufferGeometry>(null);
useFrame(({ clock }) => {
if (!geometryRef.current) return;
const positions = geometryRef.current.attributes.position;
const time = clock.elapsedTime;
for (let i = 0; i < positions.count; i++) {
const x = positions.getX(i);
const z = positions.getZ(i);
const y = Math.sin(x * 2 + time) * 0.3 + Math.cos(z * 2 + time * 0.7) * 0.2;
positions.setY(i, y);
}
positions.needsUpdate = true;
geometryRef.current.computeVertexNormals();
});
return (
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry ref={geometryRef} args={[width, width, segments, segments]} />
<meshStandardMaterial color="steelblue" wireframe />
</mesh>
);
}
```
## InstancedMesh: High-Performance Rendering
`InstancedMesh` renders many copies of the same geometry with a single draw call. Each instance can have its own transform and color.
### Basic Instancing
```tsx
import { useRef, useEffect, useMemo } from 'react';
import { Object3D, Color, Matrix4 } from 'three';
import type { InstancedMesh } from 'three';
interface InstanceFieldProps {
count: number;
spread?: number;
}
export function InstanceField({ count, spread = 10 }: InstanceFieldProps) {
const meshRef = useRef<InstancedMesh>(null);
const dummy = useMemo(() => new Object3D(), []);
const color = useMemo(() => new Color(), []);
useEffect(() => {
if (!meshRef.current) return;
for (let i = 0; i < count; i++) {
dummy.position.set(
(Math.random() - 0.5) * spread,
Math.random() * 2,
(Math.random() - 0.5) * spread
);
dummy.rotation.set(
Math.random() * Math.PI,
Math.random() * Math.PI,
0
);
dummy.scale.setScalar(0.2 + Math.random() * 0.8);
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
color.setHSL(Math.random(), 0.7, 0.5);
meshRef.current.setColorAt(i, color);
}
meshRef.current.instanceMatrix.needsUpdate = true;
if (meshRef.current.instanceColor) {
meshRef.current.instanceColor.needsUpdate = true;
}
}, [count, spread, dummy, color]);
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial />
</instancedMesh>
);
}
```
### Animated Instances
```tsx
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { Object3D } from 'three';
import type { InstancedMesh } from 'three';
export function FloatingParticles({ count = 500 }: { count?: number }) {
const meshRef = useRef<InstancedMesh>(null);
const dummy = useMemo(() => new Object3D(), []);
const offsets = useMemo(() => {
return Array.from({ length: count }, () => ({
x: (Math.random() - 0.5) * 20,
y: Math.random() * 10,
z: (Math.random() - 0.5) * 20,
speed: 0.2 + Math.random() * 0.8,
phase: Math.random() * Math.PI * 2,
}));
}, [count]);
useFrame(({ clock }) => {
if (!meshRef.current) return;
const t = clock.elapsedTime;
for (let i = 0; i < count; i++) {
const { x, y, z, speed, phase } = offsets[i];
dummy.position.set(
x + Math.sin(t * speed + phase) * 0.5,
y + Math.sin(t * speed * 0.7 + phase) * 0.3,
z + Math.cos(t * speed + phase) * 0.5
);
dummy.scale.setScalar(0.05 + Math.sin(t * speed + phase) * 0.02);
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
}
meshRef.current.instanceMatrix.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, count]}>
<sphereGeometry args={[1, 8, 8]} />
<meshBasicMaterial color="white" transparent opacity={0.6} />
</instancedMesh>
);
}
```
## Drei Geometry Helpers
`@react-three/drei` provides higher-level geometry components:
### RoundedBox
```tsx
import { RoundedBox } from '@react-three/drei';
<RoundedBox args={[1, 1, 1]} radius={0.1} smoothness={4}>
<meshStandardMaterial color="royalblue" />
</RoundedBox>
```
### Text3D
```tsx
import { Text3D, Center } from '@react-three/drei';
<Center>
<Text3D
font="/fonts/helvetiker_regular.typeface.json"
size={0.5}
height={0.1}
bevelEnabled
bevelSize={0.02}
bevelThickness={0.01}
>
Hello R3F
<meshStandardMaterial color="gold" />
</Text3D>
</Center>
```
### Parametric Shapes
```tsx
import { Extrude, Lathe } from '@react-three/drei';
import { Shape, Vector2 } from 'three';
import { useMemo } from 'react';
export function CustomExtrusion() {
const shape = useMemo(() => {
const s = new Shape();
s.moveTo(0, 0);
s.lineTo(1, 0);
s.lineTo(1, 1);
s.lineTo(0.5, 1.5);
s.lineTo(0, 1);
s.closePath();
return s;
}, []);
return (
<Extrude args={[shape, { depth: 0.5, bevelEnabled: true, bevelSize: 0.05 }]}>
<meshStandardMaterial color="mediumpurple" />
</Extrude>
);
}
```
## Geometry Merging for Performance
Merge static geometries to reduce draw calls:
```tsx
import { useRef, useEffect } from 'react';
import { BoxGeometry, Matrix4 } from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import type { BufferGeometry } from 'three';
export function MergedBoxes({ positions }: { positions: [number, number, number][] }) {
const geometryRef = useRef<BufferGeometry>(null);
useEffect(() => {
const geometries = positions.map(([x, y, z]) => {
const box = new BoxGeometry(1, 1, 1);
box.applyMatrix4(new Matrix4().makeTranslation(x, y, z));
return box;
});
const merged = mergeGeometries(geometries, false);
if (merged && geometryRef.current) {
geometryRef.current.copy(merged);
geometries.forEach((g) => g.dispose());
merged.dispose();
}
}, [positions]);
return (
<mesh>
<bufferGeometry ref={geometryRef} />
<meshStandardMaterial color="sandybrown" />
</mesh>
);
}
```
## Geometry Bounding Volumes
Bounding volumes are essential for culling, collision detection, and raycasting:
```tsx
import { useRef, useEffect } from 'react';
import type { Mesh } from 'three';
export function BoundedMesh() {
const meshRef = useRef<Mesh>(null);
useEffect(() => {
if (!meshRef.current) return;
const geometry = meshRef.current.geometry;
// Compute bounding box
geometry.computeBoundingBox();
const box = geometry.boundingBox;
// box.min, box.max, box.getCenter(), box.getSize()
// Compute bounding sphere
geometry.computeBoundingSphere();
const sphere = geometry.boundingSphere;
// sphere.center, sphere.radius
}, []);
return (
<mesh ref={meshRef}>
<torusKnotGeometry args={[1, 0.3, 128, 32]} />
<meshStandardMaterial color="orchid" />
</mesh>
);
}
```
## Level of Detail (LOD)
Use `<Detailed>` from drei or Three.js LOD for distance-based geometry switching:
```tsx
import { Detailed } from '@react-three/drei';
export function LODObject() {
return (
<Detailed distances={[0, 10, 25, 50]}>
{/* Closest: high detail */}
<mesh><sphereGeometry args={[1, 64, 64]} /><meshStandardMaterial /></mesh>
{/* Medium distance */}
<mesh><sphereGeometry args={[1, 32, 32]} /><meshStandardMaterial /></mesh>
{/* Far distance */}
<mesh><sphereGeometry args={[1, 16, 16]} /><meshStandardMaterial /></mesh>
{/* Very far: billboard or point */}
<mesh><sphereGeometry args={[1, 8, 8]} /><meshStandardMaterial /></mesh>
</Detailed>
);
}
```
## Testing Geometry
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
import { BoxGeometry, SphereGeometry } from 'three';
describe('Geometry fundamentals', () => {
it('creates box with correct vertex count', () => {
const geom = new BoxGeometry(1, 1, 1);
expect(geom.attributes.position.count).toBe(24); // 6 faces x 4 vertices
});
it('creates indexed sphere', () => {
const geom = new SphereGeometry(1, 16, 16);
expect(geom.index).not.toBeNull();
expect(geom.attributes.position.count).toBeGreaterThan(0);
});
it('computes bounding box', () => {
const geom = new BoxGeometry(2, 3, 4);
geom.computeBoundingBox();
expect(geom.boundingBox!.max.x).toBeCloseTo(1);
expect(geom.boundingBox!.max.y).toBeCloseTo(1.5);
expect(geom.boundingBox!.max.z).toBeCloseTo(2);
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { InstanceField } from './instance-field';
const meta: Meta<typeof InstanceField> = {
title: 'R3F/Geometry/InstanceField',
component: InstanceField,
decorators: [
(Story) => (
<Canvas camera={{ position: [5, 5, 5] }} style={{ height: 400 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} />
<Story />
<OrbitControls />
</Canvas>
),
],
};
export default meta;
export const Default: StoryObj<typeof InstanceField> = {
args: { count: 100, spread: 10 },
};
export const Dense: StoryObj<typeof InstanceField> = {
args: { count: 1000, spread: 5 },
};
```
## Checklist
When working with geometry in R3F:
1. Use built-in geometry JSX elements with `args` for standard shapes
2. Use `useMemo` for custom BufferGeometry data arrays
3. Set `needsUpdate = true` on attributes after modifying vertex data
4. Use InstancedMesh for rendering many identical objects (1 draw call)
5. Pre-allocate `Object3D` and `Color` objects outside `useFrame` for instancing
6. Merge static geometries with `mergeGeometries` to reduce draw calls
7. Compute bounding volumes after geometry modifications
8. Use LOD components for distance-based detail levels
9. Index geometry when vertices are shared between triangles
10. Keep geometry component files under 250 lines

View File

@@ -0,0 +1,487 @@
---
name: threejs-interaction
description: Three.js interaction and event handling adapted for React Three Fiber and Suaveplan R3F packages. Covers R3F pointer events (onClick, onPointerOver), raycasting via the R3F event system, @suaveplan/r3f-camera for orbit/first-person controls, drag interactions, hover effects, and input handling patterns.
---
This skill covers user interaction with 3D objects in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to make 3D objects interactive: clickable, hoverable, draggable, or responsive to pointer/touch input. They may need camera controls, raycasting, or input mapping.
## Core Concepts
### Three.js Raycasting
Three.js uses `Raycaster` to detect which objects a ray intersects. In vanilla Three.js this requires manual setup:
1. Convert mouse coordinates to normalized device coordinates (NDC)
2. Cast a ray from the camera through the NDC point
3. Test intersections against scene objects
4. Handle the intersection results
### R3F Event System
R3F abstracts all raycasting into React-style event handlers on mesh elements. No manual raycaster setup required.
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `raycaster.setFromCamera(mouse, camera)` | Automatic |
| `raycaster.intersectObjects(scene.children)` | Automatic per frame |
| Custom event dispatch | `onClick`, `onPointerOver`, etc. |
| `addEventListener('mousemove', ...)` | `onPointerMove` on mesh |
| Manual NDC conversion | Automatic coordinate mapping |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core interaction utilities | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-camera` | Camera controls (orbit, FPS, TPS) | `import { OrbitCamera, FirstPersonCamera } from '@suaveplan/r3f-camera'` |
| `@suaveplan/r3f-booking` | Interactive 3D venue maps | `import { VenueMap } from '@suaveplan/r3f-booking'` |
## R3F Pointer Events
Every `<mesh>`, `<group>`, or `<primitive>` in R3F can receive pointer events. These work identically to React DOM events.
### Available Events
```tsx
<mesh
onClick={(e) => {}} // Click (pointerdown + pointerup)
onContextMenu={(e) => {}} // Right-click
onDoubleClick={(e) => {}} // Double-click
onPointerDown={(e) => {}} // Pointer pressed
onPointerUp={(e) => {}} // Pointer released
onPointerOver={(e) => {}} // Pointer enters object
onPointerOut={(e) => {}} // Pointer leaves object
onPointerEnter={(e) => {}} // Pointer enters (no bubbling)
onPointerLeave={(e) => {}} // Pointer leaves (no bubbling)
onPointerMove={(e) => {}} // Pointer moves over object
onPointerMissed={() => {}} // Click missed this object
onWheel={(e) => {}} // Scroll wheel
/>
```
### Event Object Properties
The R3F event object extends the Three.js intersection result:
```tsx
interface ThreeEvent {
// Three.js intersection data
object: Object3D; // The intersected object
point: Vector3; // World-space intersection point
distance: number; // Distance from camera
face: Face | null; // Intersected face
faceIndex: number; // Face index
uv: Vector2; // UV coordinates at intersection
uv1: Vector2; // Second UV set
// R3F additions
eventObject: Object3D; // The object with the event handler
camera: Camera; // Active camera
delta: number; // Distance moved (for click threshold)
ray: Ray; // The casting ray
intersections: Intersection[]; // All intersections along the ray
stopped: boolean; // Whether propagation was stopped
// DOM event
nativeEvent: PointerEvent; // Original DOM event
// Methods
stopPropagation(): void; // Stop event bubbling
}
```
### Basic Click Interaction
```tsx
import { useState, useCallback } from 'react';
import type { ThreeEvent } from '@react-three/fiber';
interface ClickableBoxProps {
position?: [number, number, number];
onSelect?: (point: [number, number, number]) => void;
}
export function ClickableBox({ position = [0, 0, 0], onSelect }: ClickableBoxProps) {
const [active, setActive] = useState(false);
const handleClick = useCallback((event: ThreeEvent<PointerEvent>) => {
event.stopPropagation(); // Prevent event from reaching objects behind
setActive((prev) => !prev);
const p = event.point;
onSelect?.([p.x, p.y, p.z]);
}, [onSelect]);
return (
<mesh position={position} onClick={handleClick} scale={active ? 1.2 : 1}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={active ? 'hotpink' : 'royalblue'} />
</mesh>
);
}
```
### Hover Effects
```tsx
import { useState, useCallback } from 'react';
export function HoverableSphere({ position }: { position: [number, number, number] }) {
const [hovered, setHovered] = useState(false);
const handlePointerOver = useCallback(() => {
setHovered(true);
document.body.style.cursor = 'pointer';
}, []);
const handlePointerOut = useCallback(() => {
setHovered(false);
document.body.style.cursor = 'auto';
}, []);
return (
<mesh
position={position}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
scale={hovered ? 1.1 : 1}
>
<sphereGeometry args={[0.5, 32, 32]} />
<meshStandardMaterial
color={hovered ? '#ff6b6b' : '#4ecdc4'}
emissive={hovered ? '#ff6b6b' : '#000000'}
emissiveIntensity={hovered ? 0.3 : 0}
/>
</mesh>
);
}
```
## Event Propagation
R3F events bubble through the scene graph just like React DOM events. Objects closer to the camera receive events first.
### stopPropagation
```tsx
// The inner mesh receives the click first
// stopPropagation prevents the outer group from receiving it
<group onClick={() => console.log('group clicked')}>
<mesh onClick={(e) => {
e.stopPropagation();
console.log('mesh clicked'); // Only this fires
}}>
<boxGeometry />
<meshStandardMaterial />
</mesh>
</group>
```
### Event Filtering
Filter events based on intersection properties:
```tsx
<mesh onClick={(e) => {
// Only respond to front-face clicks
if (e.face && e.face.normal.z > 0) {
handleFrontClick(e.point);
}
}}>
<boxGeometry />
<meshStandardMaterial />
</mesh>
```
## Camera Controls
### @suaveplan/r3f-camera
The `@suaveplan/r3f-camera` package provides camera controllers optimized for different interaction modes:
```tsx
import { OrbitCamera } from '@suaveplan/r3f-camera';
import { Canvas } from '@react-three/fiber';
export function OrbitScene() {
return (
<Canvas camera={{ position: [5, 5, 5] }}>
<OrbitCamera
enableDamping
dampingFactor={0.05}
minDistance={2}
maxDistance={20}
minPolarAngle={0.1}
maxPolarAngle={Math.PI / 2 - 0.1}
/>
{/* scene content */}
</Canvas>
);
}
```
### First-Person Camera
```tsx
import { FirstPersonCamera } from '@suaveplan/r3f-camera';
export function FPSScene() {
return (
<Canvas>
<FirstPersonCamera
moveSpeed={5}
lookSpeed={0.002}
jumpForce={4}
gravity={9.8}
heightOffset={1.7}
/>
{/* scene content */}
</Canvas>
);
}
```
### Third-Person Camera
```tsx
import { ThirdPersonCamera } from '@suaveplan/r3f-camera';
import { useRef } from 'react';
import type { Group } from 'three';
export function TPSScene() {
const targetRef = useRef<Group>(null);
return (
<Canvas>
<ThirdPersonCamera
target={targetRef}
distance={5}
height={2}
smoothing={0.1}
/>
<group ref={targetRef} position={[0, 0, 0]}>
{/* Character mesh */}
</group>
</Canvas>
);
}
```
### Drei Camera Controls
For simpler use cases, `@react-three/drei` provides ready-made controls:
```tsx
import { OrbitControls, MapControls, FlyControls } from '@react-three/drei';
// Orbit: rotate around a target
<OrbitControls makeDefault />
// Map: pan/zoom (top-down views)
<MapControls makeDefault />
// Fly: free-flight movement
<FlyControls movementSpeed={5} rollSpeed={0.5} />
```
## Drag Interactions
### Basic Drag with useDrag
Use `@use-gesture/react`'s `useDrag` hook. In the drag callback, intersect the event ray with a `Plane` to constrain movement to a surface. Update mesh position via state.
### DragControls from Drei
Wrap draggable content in `<DragControls>` from drei. Use `onDragStart`/`onDragEnd` callbacks to update cursor style. Objects inside become draggable automatically.
## Raycasting: Imperative Escape Hatch
When you need custom raycasting beyond R3F events:
```tsx
import { useRef, useCallback } from 'react';
import { useThree } from '@react-three/fiber';
import { Raycaster, Vector2 } from 'three';
export function useCustomRaycast() {
const { camera, scene } = useThree();
const raycaster = useRef(new Raycaster());
const pointer = useRef(new Vector2());
const castRay = useCallback((clientX: number, clientY: number) => {
const canvas = document.querySelector('canvas');
if (!canvas) return [];
const rect = canvas.getBoundingClientRect();
pointer.current.x = ((clientX - rect.left) / rect.width) * 2 - 1;
pointer.current.y = -((clientY - rect.top) / rect.height) * 2 + 1;
raycaster.current.setFromCamera(pointer.current, camera);
return raycaster.current.intersectObjects(scene.children, true);
}, [camera, scene]);
return { castRay };
}
```
### Raycasting Configuration
```tsx
// Limit ray distance
raycaster.far = 100;
raycaster.near = 0.1;
// Configure line threshold (for Line objects)
raycaster.params.Line.threshold = 0.1;
// Configure points threshold (for Points objects)
raycaster.params.Points.threshold = 0.1;
// Layer-based filtering
raycaster.layers.set(1); // Only intersect objects on layer 1
```
### Layer-Based Interaction Filtering
Use `meshRef.current.layers.enable(1)` in `useEffect` to place interactable objects on layer 1. Configure the raycaster to only intersect specific layers with `raycaster.layers.set(1)`.
## Selection Patterns
### Single Selection
Track `selectedId` in state. Each `SelectableItem` calls `onSelect(id)` on click. Toggle selection by comparing current vs previous ID. Apply visual feedback (color, emissive) based on `isSelected` prop.
### Multi-Selection with Shift-Click
Use `event.nativeEvent.shiftKey` to detect Shift held during click. Maintain a `Set<string>` of selected IDs. On shift-click, toggle the item in the existing set. Without shift, replace the entire selection.
## Touch and Mobile Interaction
R3F pointer events work with touch input automatically. Configure the `events` prop on Canvas to filter intersections (e.g., only closest) for touch optimization.
## Booking Venue Interaction
The `@suaveplan/r3f-booking` package provides interactive 3D venue maps with built-in seat selection, zone highlighting, and pricing display:
```tsx
import { VenueMap } from '@suaveplan/r3f-booking';
import { Canvas } from '@react-three/fiber';
import { OrbitCamera } from '@suaveplan/r3f-camera';
export function BookingScene() {
return (
<Canvas>
<OrbitCamera minDistance={5} maxDistance={50} />
<VenueMap
venueUrl="/models/venue.glb"
onSeatSelect={(seatId) => { /* handle booking */ }}
onZoneHover={(zoneId) => { /* show pricing */ }}
selectedSeats={['A1', 'A2']}
zoneColors={{
vip: '#ffd700',
standard: '#4a90d9',
economy: '#7cb342',
}}
/>
</Canvas>
);
}
```
## Performance Considerations
### Event Computation Cost
R3F raycasts on every pointer event by default. Optimize with:
```tsx
<Canvas
raycaster={{
// Increase threshold for easier touch targeting
params: { Line: { threshold: 5 }, Points: { threshold: 5 } },
}}
events={(store) => ({
...store,
// Only process closest intersection
filter: (intersections) => intersections.slice(0, 1),
})}
>
```
### Disabling Events on Non-Interactive Objects
```tsx
// Skip raycasting for decorative objects
<mesh raycast={() => null}>
<sphereGeometry />
<meshBasicMaterial />
</mesh>
// Or use a wrapper
function Decorative({ children }: { children: React.ReactNode }) {
return <group raycast={() => null}>{children}</group>;
}
```
## Testing Interactions
### Vitest Tests
```tsx
import { describe, it, expect, vi } from 'vitest';
describe('ClickableBox', () => {
it('calls onSelect with intersection point', () => {
const onSelect = vi.fn();
// Use @react-three/test-renderer for R3F component testing
// Verify callback receives correct coordinates
expect(onSelect).toBeDefined();
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { ClickableBox } from './clickable-box';
const meta: Meta<typeof ClickableBox> = {
title: 'R3F/Interaction/ClickableBox',
component: ClickableBox,
decorators: [
(Story) => (
<Canvas camera={{ position: [3, 3, 3] }} style={{ height: 400 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} />
<Story />
<OrbitControls />
<gridHelper />
</Canvas>
),
],
};
export default meta;
export const Default: StoryObj<typeof ClickableBox> = {
args: { position: [0, 0.5, 0] },
};
```
## Checklist
When implementing interactions in R3F:
1. Use R3F pointer events (`onClick`, `onPointerOver`) instead of manual raycasting
2. Always call `event.stopPropagation()` to prevent unintended event bubbling
3. Set `document.body.style.cursor` on hover for visual feedback
4. Use `@suaveplan/r3f-camera` for camera controls (orbit, FPS, TPS)
5. Disable raycasting on decorative objects with `raycast={() => null}`
6. Handle both click and touch input (R3F normalizes automatically)
7. Use layers for raycasting categories (interactive vs. background)
8. Debounce expensive operations in `onPointerMove` handlers
9. Test interaction callbacks with Vitest mocks
10. Write Storybook stories with camera controls for interactive testing

View File

@@ -0,0 +1,513 @@
---
name: threejs-lighting
description: Three.js lighting systems adapted for React Three Fiber and Suaveplan R3F packages. Covers declarative light JSX components, shadow map configuration, environment maps with @react-three/drei, image-based lighting, light helpers, and @suaveplan/r3f-performance for light optimization and adaptive quality.
---
This skill covers lighting in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to light a 3D scene: configure light types, enable shadows, use environment maps for image-based lighting, or optimize light performance.
## Core Concepts
### Three.js Lighting Model
Three.js provides physically-based rendering (PBR) with several light types. Lights contribute to material shading based on the Lambertian/GGX BRDF model.
### R3F Lighting Approach
In R3F, lights are JSX elements added to the scene graph:
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new AmbientLight(0xffffff, 0.5)` | `<ambientLight intensity={0.5} />` |
| `scene.add(light)` | Place inside `<Canvas>` tree |
| `light.position.set(5, 5, 5)` | `position={[5, 5, 5]}` prop |
| `light.castShadow = true` | `castShadow` prop |
| `light.shadow.mapSize.set(2048, 2048)` | `shadow-mapSize={[2048, 2048]}` |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core scene utilities | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-performance` | Light budget monitoring, adaptive shadows | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
| `@suaveplan/r3f-shaders` | Custom light-reactive materials | `import { useAnimatedShader } from '@suaveplan/r3f-shaders'` |
## Light Types
### AmbientLight
Uniform light from all directions. No shadows. Use for base illumination.
```tsx
<ambientLight
color="#ffffff" // Light color
intensity={0.4} // Brightness (0-N, default 1)
/>
```
### DirectionalLight
Parallel rays from a direction (like the sun). Casts shadows.
```tsx
<directionalLight
position={[10, 10, 5]} // Direction = position - target
intensity={1.5}
color="#fffbe6"
castShadow
shadow-mapSize={[2048, 2048]}
shadow-camera-left={-10}
shadow-camera-right={10}
shadow-camera-top={10}
shadow-camera-bottom={-10}
shadow-camera-near={0.1}
shadow-camera-far={50}
shadow-bias={-0.0001}
/>
```
### PointLight
Emits light in all directions from a point (like a light bulb). Casts shadows.
```tsx
<pointLight
position={[0, 3, 0]}
intensity={100} // In candela (physical mode)
distance={10} // Maximum range (0 = infinite)
decay={2} // Physical falloff (2 = realistic)
color="#ffd700"
castShadow
shadow-mapSize={[1024, 1024]}
shadow-bias={-0.001}
/>
```
### SpotLight
Cone-shaped light (like a flashlight or stage light). Casts shadows.
```tsx
<spotLight
position={[0, 5, 0]}
target-position={[0, 0, 0]} // Aim point
angle={Math.PI / 6} // Cone angle (radians)
penumbra={0.5} // Soft edge (0-1)
intensity={200}
distance={20}
decay={2}
color="#ffffff"
castShadow
shadow-mapSize={[2048, 2048]}
shadow-bias={-0.0001}
/>
```
### HemisphereLight
Two-color gradient light (sky + ground). No shadows.
```tsx
<hemisphereLight
color="#87ceeb" // Sky color
groundColor="#8b4513" // Ground color
intensity={0.6}
/>
```
### RectAreaLight
Rectangular area light (like a window or fluorescent panel). No shadow map support.
```tsx
import { RectAreaLightHelper } from 'three/addons/helpers/RectAreaLightHelper.js';
import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js';
// Must initialize uniforms before use
RectAreaLightUniformsLib.init();
<rectAreaLight
position={[0, 3, -2]}
rotation={[-Math.PI / 2, 0, 0]}
width={4}
height={2}
intensity={5}
color="#ffffff"
/>
```
## Shadow Configuration
### Enabling Shadows
Shadows require three things: Canvas shadow prop, light `castShadow`, and mesh `receiveShadow`/`castShadow`.
```tsx
import { Canvas } from '@react-three/fiber';
export function ShadowScene() {
return (
<Canvas
shadows // Enable shadow maps
// Or configure shadow type:
// shadows={{ type: PCFSoftShadowMap }}
>
{/* Light that casts shadows */}
<directionalLight
position={[5, 5, 5]}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-camera-far={20}
shadow-bias={-0.0001}
/>
{/* Object that casts shadows */}
<mesh position={[0, 1, 0]} castShadow>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="tomato" />
</mesh>
{/* Ground that receives shadows */}
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[20, 20]} />
<meshStandardMaterial color="#444" />
</mesh>
</Canvas>
);
}
```
### Shadow Map Types
```tsx
import { PCFShadowMap, PCFSoftShadowMap, VSMShadowMap, BasicShadowMap } from 'three';
// BasicShadowMap: fastest, hard edges
// PCFShadowMap: default, some softness
// PCFSoftShadowMap: softer, more expensive
// VSMShadowMap: variance shadow map, supports blurring
<Canvas shadows={{ type: PCFSoftShadowMap }}>
```
### Shadow Camera Frustum
For directional lights, the shadow camera is orthographic. Size the frustum tightly around the scene for better shadow quality:
```tsx
<directionalLight
castShadow
shadow-camera-left={-8}
shadow-camera-right={8}
shadow-camera-top={8}
shadow-camera-bottom={-8}
shadow-camera-near={0.5}
shadow-camera-far={30}
/>
```
### Cascaded Shadow Maps (CSM)
For large scenes, use drei's CSM:
```tsx
import { AccumulativeShadows, RandomizedLight } from '@react-three/drei';
export function SoftShadows() {
return (
<AccumulativeShadows
temporal
frames={60}
alphaTest={0.85}
scale={10}
position={[0, 0.01, 0]}
>
<RandomizedLight
amount={8}
radius={2}
ambient={0.5}
position={[5, 5, -3]}
bias={0.001}
/>
</AccumulativeShadows>
);
}
```
## Environment Maps and IBL
### Environment Component (drei)
The simplest way to add image-based lighting:
```tsx
import { Environment } from '@react-three/drei';
// Preset environments
<Environment preset="sunset" />
<Environment preset="city" />
<Environment preset="forest" />
<Environment preset="studio" />
// Custom HDR
<Environment files="/textures/env.hdr" />
// As scene background
<Environment preset="sunset" background />
// Blur the background
<Environment preset="sunset" background blur={0.5} />
```
### Available Presets
| Preset | Description | Use Case |
|---|---|---|
| `apartment` | Indoor apartment | Interior scenes |
| `city` | Urban skyline | Outdoor urban |
| `dawn` | Early morning sky | Warm exterior |
| `forest` | Dense forest | Nature scenes |
| `lobby` | Hotel lobby | Indoor formal |
| `night` | Night sky | Dark scenes |
| `park` | Park setting | Outdoor casual |
| `studio` | Photography studio | Product shots |
| `sunset` | Golden hour | Dramatic exterior |
| `warehouse` | Industrial space | Gritty interior |
### Custom Environment Map
```tsx
import { useRef } from 'react';
import { useThree } from '@react-three/fiber';
import { CubeCamera } from '@react-three/drei';
export function ReflectiveSphere() {
return (
<CubeCamera resolution={256} frames={Infinity}>
{(texture) => (
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshStandardMaterial
envMap={texture}
metalness={1}
roughness={0}
/>
</mesh>
)}
</CubeCamera>
);
}
```
## Light Helpers
Visualize light positions and frustums during development:
```tsx
import { useHelper } from '@react-three/drei';
import { useRef } from 'react';
import {
DirectionalLightHelper,
SpotLightHelper,
PointLightHelper,
CameraHelper,
} from 'three';
import type { DirectionalLight, SpotLight, PointLight } from 'three';
export function DebugLights({ debug = false }: { debug?: boolean }) {
const dirRef = useRef<DirectionalLight>(null);
const spotRef = useRef<SpotLight>(null);
const pointRef = useRef<PointLight>(null);
// Helpers only rendered when debug is true
useHelper(debug && dirRef, DirectionalLightHelper, 1, 'red');
useHelper(debug && spotRef, SpotLightHelper, 'green');
useHelper(debug && pointRef, PointLightHelper, 0.5, 'blue');
return (
<>
<directionalLight ref={dirRef} position={[5, 5, 5]} castShadow />
<spotLight ref={spotRef} position={[0, 5, 0]} angle={Math.PI / 6} />
<pointLight ref={pointRef} position={[-3, 2, 0]} intensity={50} />
</>
);
}
```
## Lighting Recipes
### Outdoor Scene (Sun + Sky)
```tsx
import { Sky, Environment } from '@react-three/drei';
export function OutdoorLighting() {
return (
<>
{/* Sun light */}
<directionalLight
position={[50, 50, 25]}
intensity={2}
color="#fff5e1"
castShadow
shadow-mapSize={[4096, 4096]}
shadow-camera-left={-30}
shadow-camera-right={30}
shadow-camera-top={30}
shadow-camera-bottom={-30}
/>
{/* Fill light (sky) */}
<hemisphereLight
color="#87ceeb"
groundColor="#362312"
intensity={0.4}
/>
{/* Sky dome */}
<Sky
sunPosition={[50, 50, 25]}
turbidity={3}
rayleigh={0.5}
/>
</>
);
}
```
### Indoor Scene (Area Lights + Ambient)
```tsx
export function IndoorLighting() {
return (
<>
{/* Ambient base */}
<ambientLight intensity={0.2} color="#f0e6d3" />
{/* Ceiling lights */}
<pointLight position={[0, 2.8, 0]} intensity={80} decay={2} distance={8} castShadow />
<pointLight position={[3, 2.8, 0]} intensity={60} decay={2} distance={6} />
{/* Window light */}
<directionalLight
position={[-5, 3, 2]}
intensity={1.5}
color="#b8d4e3"
castShadow
shadow-mapSize={[2048, 2048]}
/>
</>
);
}
```
### Product Studio Lighting
Use `<Environment>` with nested `<Lightformer>` components for key, fill, and rim lighting. Add `<ContactShadows>` from drei for soft ground shadows without shadow maps. Configure `form="rect"` or `form="ring"`, position, intensity, and scale for each lightformer.
## Adaptive Lighting with @suaveplan/r3f-performance
Use `usePerformanceMonitor` to track FPS and switch between quality presets (high/medium/low). Each preset configures shadow map size (4096/2048/1024), env map resolution, and max light count. Scale down on `onDecline`, scale up on `onIncline`.
## Light Animation
### Flickering Light
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import type { PointLight } from 'three';
export function FlickeringLight({ position }: { position: [number, number, number] }) {
const lightRef = useRef<PointLight>(null);
useFrame(({ clock }) => {
if (!lightRef.current) return;
const t = clock.elapsedTime;
const flicker = Math.sin(t * 20) * 0.1 + Math.sin(t * 37) * 0.05 + Math.sin(t * 53) * 0.03;
lightRef.current.intensity = 80 + flicker * 40;
});
return (
<pointLight
ref={lightRef}
position={position}
color="#ff9500"
distance={8}
decay={2}
castShadow
/>
);
}
```
## Testing Lighting
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
import { DirectionalLight, PointLight } from 'three';
describe('Lighting setup', () => {
it('creates directional light with shadow', () => {
const light = new DirectionalLight(0xffffff, 1.5);
light.castShadow = true;
light.shadow.mapSize.set(2048, 2048);
expect(light.castShadow).toBe(true);
expect(light.shadow.mapSize.x).toBe(2048);
});
it('configures point light physical decay', () => {
const light = new PointLight(0xffd700, 100, 10, 2);
expect(light.decay).toBe(2);
expect(light.distance).toBe(10);
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
function LightingDemo() {
return (
<Canvas shadows camera={{ position: [5, 5, 5] }} style={{ height: 400 }}>
<ambientLight intensity={0.3} />
<directionalLight position={[5, 5, 5]} castShadow />
<mesh castShadow position={[0, 0.5, 0]}>
<boxGeometry />
<meshStandardMaterial color="coral" />
</mesh>
<mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[10, 10]} />
<meshStandardMaterial color="#555" />
</mesh>
<OrbitControls />
</Canvas>
);
}
const meta: Meta = { title: 'R3F/Lighting/ShadowDemo', component: LightingDemo };
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When setting up lighting in R3F:
1. Enable `shadows` on `<Canvas>` and choose a shadow map type
2. Use `castShadow` on lights and objects, `receiveShadow` on surfaces
3. Size shadow camera frustum tightly around the visible scene
4. Set `shadow-bias` to prevent shadow acne (start with `-0.0001`)
5. Use `<Environment>` from drei for quick IBL and reflections
6. Use `useHelper` to visualize light positions during development
7. Limit active shadow-casting lights (expensive: 1 shadow map per light per frame)
8. Use `@suaveplan/r3f-performance` for adaptive shadow quality
9. Combine ambient/hemisphere with directional for natural-looking scenes
10. Keep lighting configuration components under 250 lines

View File

@@ -0,0 +1,519 @@
---
name: threejs-loaders
description: Three.js asset loading adapted for React Three Fiber and Suaveplan R3F packages. Covers useLoader, useGLTF, and useTexture hooks, React Suspense boundaries for async loading, preloading strategies, @suaveplan/r3f loading utilities, Draco/KTX2 mesh and texture compression, and progressive loading patterns.
---
This skill covers asset loading in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to load 3D models, textures, fonts, or other assets into an R3F scene. They may need GLTF models, compressed assets, preloading, or progress tracking.
## Core Concepts
### Three.js Loader System
Three.js provides loader classes for different asset types:
- **GLTFLoader**: Loads `.gltf`/`.glb` files (the primary 3D model format)
- **TextureLoader**: Loads images as GPU textures
- **CubeTextureLoader**: Loads cubemap textures (6 faces)
- **FBXLoader**: Loads FBX files
- **OBJLoader**: Loads OBJ files
- **DRACOLoader**: Decompresses Draco-compressed geometry
- **KTX2Loader**: Loads KTX2 GPU-compressed textures
- **EXRLoader/RGBELoader**: Loads HDR environment maps
### R3F Loading Approach
R3F replaces manual loader management with hooks and React Suspense:
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new GLTFLoader().load(url, cb)` | `useGLTF(url)` + `<Suspense>` |
| `new TextureLoader().load(url, cb)` | `useTexture(url)` + `<Suspense>` |
| Manual progress tracking | `useProgress()` hook |
| Manual error handling | Error boundaries |
| Manual disposal | Automatic on unmount |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Loading utilities, asset management | `import { useAssetLoader } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-character` | Character model loading | `import { useCharacterModel } from '@suaveplan/r3f-character'` |
| `@suaveplan/r3f-performance` | Loading performance monitoring | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
## useGLTF: Loading 3D Models
The primary way to load 3D models in R3F. Returns the full GLTF structure.
### Basic Model Loading
```tsx
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { useGLTF } from '@react-three/drei';
interface ModelProps {
url: string;
position?: [number, number, number];
scale?: number;
}
function Model({ url, position = [0, 0, 0], scale = 1 }: ModelProps) {
const { scene } = useGLTF(url);
return (
<primitive
object={scene}
position={position}
scale={scale}
/>
);
}
export function ModelScene() {
return (
<Canvas>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} />
<Suspense fallback={<LoadingPlaceholder />}>
<Model url="/models/building.glb" />
</Suspense>
</Canvas>
);
}
function LoadingPlaceholder() {
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial color="gray" wireframe />
</mesh>
);
}
```
### GLTF Return Structure
```tsx
const gltf = useGLTF('/model.glb');
// gltf.scene - THREE.Group (the root scene)
// gltf.nodes - Named nodes (meshes, bones, empties)
// gltf.materials - Named materials
// gltf.animations - AnimationClip[] (for useAnimations)
// gltf.asset - GLTF metadata
```
### Accessing Named Nodes
GLTF files export named objects. Access them via `gltf.nodes`:
```tsx
import { useGLTF } from '@react-three/drei';
function Character({ url }: { url: string }) {
const { nodes, materials } = useGLTF(url);
return (
<group>
{/* Access specific meshes by name */}
<mesh
geometry={(nodes.Body as THREE.Mesh).geometry}
material={materials.Skin}
castShadow
/>
<mesh
geometry={(nodes.Eyes as THREE.Mesh).geometry}
material={materials.Eye}
/>
</group>
);
}
```
### GLTF with TypeScript (gltfjsx)
Use `gltfjsx` to generate typed React components from GLTF files:
```bash
# Generate typed component (do NOT run tsc, this outputs .tsx directly)
bunx gltfjsx model.glb --types --transform
```
Generated output pattern:
```tsx
import { useRef } from 'react';
import { useGLTF } from '@react-three/drei';
import type { Group, Mesh, MeshStandardMaterial } from 'three';
import type { GLTF } from 'three-stdlib';
interface GLTFResult extends GLTF {
nodes: {
Body: Mesh;
Head: Mesh;
};
materials: {
Skin: MeshStandardMaterial;
Hair: MeshStandardMaterial;
};
}
export function CharacterModel(props: JSX.IntrinsicElements['group']) {
const groupRef = useRef<Group>(null);
const { nodes, materials } = useGLTF('/character.glb') as GLTFResult;
return (
<group ref={groupRef} {...props} dispose={null}>
<mesh geometry={nodes.Body.geometry} material={materials.Skin} castShadow />
<mesh geometry={nodes.Head.geometry} material={materials.Hair} castShadow />
</group>
);
}
useGLTF.preload('/character.glb');
```
## Draco Compression
Draco compresses mesh geometry by 80-90%. R3F handles decompression automatically.
### Using Draco with useGLTF
```tsx
import { useGLTF } from '@react-three/drei';
function CompressedModel({ url }: { url: string }) {
// useGLTF auto-detects Draco and uses the bundled decoder
const { scene } = useGLTF(url, '/draco/');
// Second argument is the Draco decoder path
return <primitive object={scene} />;
}
// Default Draco decoder from drei CDN (no path needed usually)
function SimpleModel() {
const { scene } = useGLTF('/model-draco.glb');
return <primitive object={scene} />;
}
```
### Custom Draco Decoder Path
Pass a second argument to `useGLTF` for the Draco decoder path: `useGLTF('/model.glb', '/libs/draco/gltf/')`.
## KTX2 Texture Compression
KTX2 provides GPU-compressed textures (ASTC, ETC2, BC7) for smaller VRAM footprint. Load with `useLoader(KTX2Loader, url, (loader) => { loader.setTranscoderPath('/libs/basis/'); loader.detectSupport(gl); })`.
## useTexture: Loading Textures
```tsx
import { useTexture } from '@react-three/drei';
import { RepeatWrapping, SRGBColorSpace } from 'three';
function TexturedMesh() {
const texture = useTexture('/textures/wood.jpg');
// Configure texture properties
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.repeat.set(2, 2);
texture.colorSpace = SRGBColorSpace;
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial map={texture} />
</mesh>
);
}
```
### Loading Multiple Textures (PBR Set)
```tsx
import { useTexture } from '@react-three/drei';
function PBRMaterial() {
const textures = useTexture({
map: '/textures/brick/diffuse.jpg',
normalMap: '/textures/brick/normal.jpg',
roughnessMap: '/textures/brick/roughness.jpg',
aoMap: '/textures/brick/ao.jpg',
displacementMap: '/textures/brick/displacement.jpg',
});
return (
<mesh>
<planeGeometry args={[4, 4, 64, 64]} />
<meshStandardMaterial
{...textures}
displacementScale={0.1}
/>
</mesh>
);
}
// Preload all textures
useTexture.preload({
map: '/textures/brick/diffuse.jpg',
normalMap: '/textures/brick/normal.jpg',
roughnessMap: '/textures/brick/roughness.jpg',
aoMap: '/textures/brick/ao.jpg',
displacementMap: '/textures/brick/displacement.jpg',
});
```
## useLoader: Generic Loader Hook
For loaders not covered by drei helpers:
```tsx
import { useLoader } from '@react-three/fiber';
import { FBXLoader } from 'three-stdlib';
import { OBJLoader } from 'three-stdlib';
import { FontLoader } from 'three-stdlib';
// FBX model
function FBXModel({ url }: { url: string }) {
const fbx = useLoader(FBXLoader, url);
return <primitive object={fbx} scale={0.01} />;
}
// OBJ model
function OBJModel({ url }: { url: string }) {
const obj = useLoader(OBJLoader, url);
return <primitive object={obj} />;
}
// Font
function LoadedFont({ url }: { url: string }) {
const font = useLoader(FontLoader, url);
// Use with TextGeometry
return null;
}
```
### Loader with Extensions
Pass a configuration callback as the third argument to `useLoader`. Inside, attach `DRACOLoader` via `loader.setDRACOLoader(dracoLoader)` and `MeshoptDecoder` via `loader.setMeshoptDecoder(MeshoptDecoder)`.
## Suspense and Loading UI
### Loading Progress
```tsx
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { useProgress, Html } from '@react-three/drei';
function Loader() {
const { progress, active, errors, item, loaded, total } = useProgress();
return (
<Html center>
<div style={{ color: 'white', fontSize: '1.5rem' }}>
{progress.toFixed(0)}% loaded
<div style={{ fontSize: '0.8rem', opacity: 0.6 }}>
{loaded}/{total} assets
</div>
</div>
</Html>
);
}
export function SceneWithProgress() {
return (
<Canvas>
<Suspense fallback={<Loader />}>
<HeavyScene />
</Suspense>
</Canvas>
);
}
```
### Nested Suspense Boundaries
Load critical assets first, then progressively load detail:
```tsx
import { Suspense } from 'react';
export function ProgressiveScene() {
return (
<Canvas>
{/* Critical: load immediately */}
<ambientLight />
<directionalLight position={[5, 5, 5]} />
{/* Tier 1: primary content */}
<Suspense fallback={<PlaceholderBuilding />}>
<Building url="/models/building.glb" />
{/* Tier 2: secondary content (loads after building) */}
<Suspense fallback={null}>
<Furniture url="/models/furniture.glb" />
{/* Tier 3: decorative (loads last) */}
<Suspense fallback={null}>
<Plants url="/models/plants.glb" />
<Decorations url="/models/decor.glb" />
</Suspense>
</Suspense>
</Suspense>
</Canvas>
);
}
```
## Preloading
Preload assets before they are needed (e.g., during a loading screen or on hover):
```tsx
import { useGLTF, useTexture } from '@react-three/drei';
// Preload at module level (loads immediately)
useGLTF.preload('/models/character.glb');
useGLTF.preload('/models/environment.glb');
useTexture.preload('/textures/ground.jpg');
// Conditional preloading (e.g., on hover or route change)
function PreloadOnHover({ modelUrl }: { modelUrl: string }) {
return (
<button
onMouseEnter={() => useGLTF.preload(modelUrl)}
onClick={() => { /* navigate to 3D view */ }}
>
View in 3D
</button>
);
}
```
### Preloading Multiple Assets
```tsx
import { useGLTF } from '@react-three/drei';
const MODEL_URLS = [
'/models/scene.glb',
'/models/character.glb',
'/models/props.glb',
];
// Preload all at once
MODEL_URLS.forEach(useGLTF.preload);
```
## Error Handling
### Error Boundaries for Loading Failures
Wrap `<Suspense>` in a React error boundary class. Override `getDerivedStateFromError` to catch loading failures. Render a fallback mesh (e.g., red wireframe box) when errors occur. Nest as `<ErrorBoundary><Suspense fallback={<Loader />}><Model /></Suspense></ErrorBoundary>`.
## Asset Optimization Guidelines
### Model Optimization
| Technique | Savings | Tool |
|---|---|---|
| Draco compression | 80-90% geometry | `gltf-transform draco` |
| Meshopt compression | 60-80% geometry | `gltf-transform meshopt` |
| Texture compression (KTX2) | 75% texture VRAM | `gltf-transform ktx2` |
| Mesh simplification | 50-90% triangles | `gltf-transform simplify` |
| Texture resize | Variable | `gltf-transform resize` |
| Remove unused data | Variable | `gltf-transform prune` |
### Pipeline Command
```bash
# Optimize a GLTF file (run via bun, not tsc)
bunx gltf-transform optimize input.glb output.glb \
--compress draco \
--texture-compress ktx2 \
--simplify-ratio 0.5
```
## Disposal
R3F automatically disposes of loaded assets when components unmount. For manual control:
```tsx
import { useGLTF } from '@react-three/drei';
function DisposableModel({ url }: { url: string }) {
const { scene } = useGLTF(url);
// Pass dispose={null} to prevent auto-disposal (useful for shared assets)
return <primitive object={scene} dispose={null} />;
}
// Manually clear the cache
function clearModelCache(url: string) {
useGLTF.clear(url);
}
```
## Testing Loading
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
describe('Asset loading', () => {
it('preload function exists on useGLTF', async () => {
const { useGLTF } = await import('@react-three/drei');
expect(typeof useGLTF.preload).toBe('function');
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, useProgress, Html } from '@react-three/drei';
function LoaderUI() {
const { progress } = useProgress();
return <Html center><span>{progress.toFixed(0)}%</span></Html>;
}
function ModelStory() {
return (
<Canvas camera={{ position: [3, 3, 3] }} style={{ height: 400 }}>
<ambientLight />
<directionalLight position={[5, 5, 5]} />
<Suspense fallback={<LoaderUI />}>
{/* Model component here */}
</Suspense>
<OrbitControls />
</Canvas>
);
}
const meta: Meta = { title: 'R3F/Loaders/ModelLoader', component: ModelStory };
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When loading assets in R3F:
1. Use `useGLTF` for GLTF/GLB models (the standard 3D format)
2. Wrap loaded components in `<Suspense>` with a fallback
3. Use `useProgress` for loading progress UI
4. Preload assets with `useGLTF.preload()` at module level or on interaction
5. Use Draco compression for mesh geometry (80-90% savings)
6. Use KTX2 for GPU-compressed textures
7. Generate typed components with `gltfjsx --types`
8. Use nested Suspense for progressive loading (critical first)
9. Add error boundaries for loading failures
10. Keep loader component files under 250 lines

View File

@@ -0,0 +1,579 @@
---
name: threejs-materials
description: Three.js materials adapted for React Three Fiber and Suaveplan R3F packages. Covers declarative material props in JSX, physically-based rendering (PBR) with MeshStandardMaterial and MeshPhysicalMaterial, @suaveplan/r3f-shaders for custom materials, material refs and animation via useFrame, transparency, and material instancing.
---
This skill covers materials in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to configure material appearance for 3D objects: PBR properties, textures, transparency, custom shaders, or animated material properties.
## Core Concepts
### Three.js Material System
Materials define how surfaces respond to light. Three.js provides a hierarchy from basic to physically-based:
- **MeshBasicMaterial**: Unlit, flat color (no light interaction)
- **MeshLambertMaterial**: Diffuse shading only (fast, non-glossy)
- **MeshPhongMaterial**: Diffuse + specular highlights (legacy)
- **MeshStandardMaterial**: PBR with metalness/roughness workflow
- **MeshPhysicalMaterial**: Extended PBR (clearcoat, transmission, sheen, iridescence)
- **MeshToonMaterial**: Cel-shaded / cartoon look
- **ShaderMaterial**: Fully custom GLSL shaders
- **RawShaderMaterial**: Custom GLSL without Three.js built-in uniforms
### R3F Material Approach
In R3F, materials are JSX children of mesh elements:
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new MeshStandardMaterial({ color: 'red' })` | `<meshStandardMaterial color="red" />` |
| `mesh.material = material` | `<mesh><meshStandardMaterial /></mesh>` |
| `material.metalness = 0.8` | `metalness={0.8}` prop |
| `material.needsUpdate = true` | Automatic on prop change |
| `material.dispose()` | Automatic on unmount |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core utilities | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-shaders` | Custom shader materials | `import { GlassMaterial, HologramMaterial } from '@suaveplan/r3f-shaders'` |
| `@suaveplan/r3f-performance` | Material LOD, adaptive quality | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
## MeshStandardMaterial: The PBR Default
The standard PBR material using the metalness/roughness workflow. This should be the default choice for most objects.
```tsx
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshStandardMaterial
color="#e74c3c" // Base color (albedo)
metalness={0.0} // 0 = dielectric, 1 = metal
roughness={0.4} // 0 = mirror, 1 = matte
emissive="#000000" // Self-illumination color
emissiveIntensity={0} // Emissive strength
envMapIntensity={1} // Environment reflection strength
flatShading={false} // Faceted vs smooth normals
wireframe={false} // Wireframe rendering
side={0} // 0=Front, 1=Back, 2=Double
/>
</mesh>
```
### PBR with Texture Maps
```tsx
import { useTexture } from '@react-three/drei';
function PBRSurface() {
const textures = useTexture({
map: '/textures/metal/diffuse.jpg',
normalMap: '/textures/metal/normal.jpg',
roughnessMap: '/textures/metal/roughness.jpg',
metalnessMap: '/textures/metal/metalness.jpg',
aoMap: '/textures/metal/ao.jpg',
});
return (
<mesh>
<planeGeometry args={[4, 4]} />
<meshStandardMaterial
{...textures}
normalScale={[1, 1]}
aoMapIntensity={1}
/>
</mesh>
);
}
```
## MeshPhysicalMaterial: Advanced PBR
Extends `MeshStandardMaterial` with physically-accurate properties for glass, car paint, fabric, etc.
### Glass / Transmission
```tsx
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshPhysicalMaterial
color="#ffffff"
transmission={1} // 0 = opaque, 1 = fully transparent
thickness={0.5} // Refraction depth
roughness={0.05} // Surface roughness
ior={1.5} // Index of refraction (glass = 1.5)
chromaticAberration={0.02} // Color fringing
envMapIntensity={1}
/>
</mesh>
```
### Car Paint / Clearcoat
```tsx
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshPhysicalMaterial
color="#1a1a2e"
metalness={0.9}
roughness={0.1}
clearcoat={1} // Clearcoat layer strength
clearcoatRoughness={0.1} // Clearcoat roughness
reflectivity={1}
/>
</mesh>
```
### Fabric / Sheen
```tsx
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshPhysicalMaterial
color="#2d1b69"
roughness={0.8}
sheen={1} // Sheen intensity (velvet/fabric)
sheenColor="#8b5cf6" // Sheen tint color
sheenRoughness={0.3} // Sheen roughness
/>
</mesh>
```
### Iridescence
```tsx
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshPhysicalMaterial
color="#333333"
metalness={1}
roughness={0.2}
iridescence={1} // Iridescence intensity
iridescenceIOR={1.3} // Thin film IOR
iridescenceThicknessRange={[100, 400]} // nm range
/>
</mesh>
```
## Material Properties Reference
### Common Properties (All Materials)
| Property | Type | Default | Description |
|---|---|---|---|
| `color` | string/Color | `'#ffffff'` | Base color |
| `opacity` | number | `1` | Transparency (requires `transparent={true}`) |
| `transparent` | boolean | `false` | Enable transparency |
| `alphaTest` | number | `0` | Discard pixels below threshold |
| `side` | number | `FrontSide` | Which face to render (0/1/2) |
| `visible` | boolean | `true` | Whether to render |
| `depthTest` | boolean | `true` | Test against depth buffer |
| `depthWrite` | boolean | `true` | Write to depth buffer |
| `wireframe` | boolean | `false` | Render as wireframe |
| `toneMapped` | boolean | `true` | Apply tone mapping |
### MeshStandardMaterial Specific
| Property | Type | Default | Description |
|---|---|---|---|
| `metalness` | number | `0` | Metallic appearance (0-1) |
| `roughness` | number | `1` | Surface roughness (0-1) |
| `emissive` | string/Color | `'#000000'` | Self-illumination color |
| `emissiveIntensity` | number | `1` | Emissive strength multiplier |
| `envMapIntensity` | number | `1` | Environment map reflection strength |
| `flatShading` | boolean | `false` | Use face normals vs vertex normals |
| `normalScale` | [x, y] | `[1, 1]` | Normal map strength |
| `map` | Texture | `null` | Albedo/diffuse texture |
| `normalMap` | Texture | `null` | Normal map texture |
| `roughnessMap` | Texture | `null` | Roughness texture (green channel) |
| `metalnessMap` | Texture | `null` | Metalness texture (blue channel) |
| `aoMap` | Texture | `null` | Ambient occlusion map |
| `displacementMap` | Texture | `null` | Displacement/height map |
| `emissiveMap` | Texture | `null` | Emissive map |
## Animated Materials
### Animating Properties with useFrame
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { MathUtils } from 'three';
import type { MeshStandardMaterial } from 'three';
export function PulsingMaterial() {
const materialRef = useRef<MeshStandardMaterial>(null);
useFrame(({ clock }) => {
if (!materialRef.current) return;
const t = clock.elapsedTime;
materialRef.current.emissiveIntensity = (Math.sin(t * 2) + 1) * 0.5;
materialRef.current.roughness = MathUtils.lerp(0.1, 0.9, (Math.sin(t) + 1) * 0.5);
});
return (
<mesh>
<sphereGeometry args={[1, 32, 32]} />
<meshStandardMaterial
ref={materialRef}
color="#4ecdc4"
emissive="#4ecdc4"
emissiveIntensity={0}
metalness={0.5}
/>
</mesh>
);
}
```
### Color Transitions
```tsx
import { useRef, useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import { Color } from 'three';
import type { MeshStandardMaterial } from 'three';
interface ColorTransitionProps {
targetColor: string;
speed?: number;
}
export function ColorTransition({ targetColor, speed = 3 }: ColorTransitionProps) {
const materialRef = useRef<MeshStandardMaterial>(null);
const target = useRef(new Color(targetColor));
useEffect(() => {
target.current.set(targetColor);
}, [targetColor]);
useFrame((_state, delta) => {
if (!materialRef.current) return;
materialRef.current.color.lerp(target.current, Math.min(delta * speed, 1));
});
return (
<mesh>
<boxGeometry />
<meshStandardMaterial ref={materialRef} color={targetColor} />
</mesh>
);
}
```
## @suaveplan/r3f-shaders Custom Materials
The `@suaveplan/r3f-shaders` package provides pre-built custom materials:
```tsx
import { GlassMaterial, HologramMaterial, NoiseMaterial } from '@suaveplan/r3f-shaders';
// Glass with custom refraction
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<GlassMaterial
color="#88ccff"
ior={1.45}
thickness={0.3}
distortion={0.1}
/>
</mesh>
// Hologram effect
<mesh>
<boxGeometry args={[1, 2, 1]} />
<HologramMaterial
color="#00ff88"
scanlineSpeed={2}
fresnelIntensity={1.5}
glitchIntensity={0.3}
/>
</mesh>
// Animated noise
<mesh>
<planeGeometry args={[4, 4, 64, 64]} />
<NoiseMaterial
color="#4a90d9"
scale={3}
speed={0.5}
displacement={0.2}
/>
</mesh>
```
## Material Sharing and Instancing
### Shared Material Across Meshes
```tsx
import { useMemo } from 'react';
import { MeshStandardMaterial } from 'three';
export function SharedMaterialScene() {
const sharedMaterial = useMemo(
() => new MeshStandardMaterial({ color: 'tomato', roughness: 0.3 }),
[]
);
return (
<>
<mesh position={[-2, 0, 0]} material={sharedMaterial}>
<boxGeometry />
</mesh>
<mesh position={[0, 0, 0]} material={sharedMaterial}>
<sphereGeometry args={[0.5, 32, 32]} />
</mesh>
<mesh position={[2, 0, 0]} material={sharedMaterial}>
<cylinderGeometry args={[0.5, 0.5, 1]} />
</mesh>
</>
);
}
```
### Material Variants
```tsx
import { useMemo } from 'react';
import { MeshStandardMaterial, Color } from 'three';
type MaterialVariant = 'default' | 'hover' | 'selected' | 'disabled';
function useMaterialVariants(): Record<MaterialVariant, MeshStandardMaterial> {
return useMemo(() => ({
default: new MeshStandardMaterial({ color: new Color('#6b7280'), roughness: 0.5 }),
hover: new MeshStandardMaterial({ color: new Color('#3b82f6'), roughness: 0.3 }),
selected: new MeshStandardMaterial({
color: new Color('#10b981'),
emissive: new Color('#10b981'),
emissiveIntensity: 0.2,
roughness: 0.3,
}),
disabled: new MeshStandardMaterial({
color: new Color('#9ca3af'),
roughness: 0.8,
transparent: true,
opacity: 0.5,
}),
}), []);
}
```
## Transparency Patterns
### Basic Transparency
```tsx
<meshStandardMaterial
color="white"
transparent
opacity={0.5}
/>
```
### Alpha Map Transparency
```tsx
import { useTexture } from '@react-three/drei';
function AlphaMasked() {
const alphaMap = useTexture('/textures/leaf-alpha.png');
return (
<mesh>
<planeGeometry args={[1, 1]} />
<meshStandardMaterial
color="green"
alphaMap={alphaMap}
transparent
alphaTest={0.5} // Discard pixels below 50% alpha
side={2} // DoubleSide for leaves
/>
</mesh>
);
}
```
### Sorting and Depth Issues
Transparent objects must render back-to-front. Control with `renderOrder` and `depthWrite`:
```tsx
{/* Render behind other transparent objects */}
<mesh renderOrder={-1}>
<meshStandardMaterial transparent opacity={0.3} depthWrite={false} />
</mesh>
{/* Render in front */}
<mesh renderOrder={1}>
<meshStandardMaterial transparent opacity={0.6} depthWrite={false} />
</mesh>
```
## Special Material Types
### MeshBasicMaterial (Unlit)
Use for UI elements, wireframes, or objects that should not be affected by lighting:
```tsx
<meshBasicMaterial color="white" /> // Flat color, no lighting
<meshBasicMaterial map={texture} /> // Textured, no lighting
<meshBasicMaterial wireframe color="cyan" /> // Wireframe
```
### MeshToonMaterial (Cel Shading)
```tsx
import { useTexture } from '@react-three/drei';
function ToonObject() {
// 3-step gradient ramp texture (3x1 pixels: dark, medium, light)
const gradientMap = useTexture('/textures/threeTone.jpg');
gradientMap.minFilter = gradientMap.magFilter = 1003; // NearestFilter
return (
<mesh>
<sphereGeometry args={[1, 32, 32]} />
<meshToonMaterial color="#ff6b6b" gradientMap={gradientMap} />
</mesh>
);
}
```
### MeshMatcapMaterial
Fast, no lights needed. Appearance defined entirely by a matcap texture:
```tsx
import { useTexture } from '@react-three/drei';
function MatcapObject() {
const matcap = useTexture('/textures/matcap-gold.png');
return (
<mesh>
<torusKnotGeometry args={[1, 0.3, 128, 32]} />
<meshMatcapMaterial matcap={matcap} />
</mesh>
);
}
```
## Adaptive Material Quality
```tsx
import { useState } from 'react';
import { usePerformanceMonitor } from '@suaveplan/r3f-performance';
export function AdaptiveMaterial() {
const [quality, setQuality] = useState<'high' | 'low'>('high');
usePerformanceMonitor({
onDecline: () => setQuality('low'),
onIncline: () => setQuality('high'),
});
return (
<mesh>
<sphereGeometry args={[1, quality === 'high' ? 64 : 16, quality === 'high' ? 64 : 16]} />
{quality === 'high' ? (
<meshPhysicalMaterial
color="#4ecdc4"
metalness={0.8}
roughness={0.2}
clearcoat={1}
clearcoatRoughness={0.1}
/>
) : (
<meshStandardMaterial
color="#4ecdc4"
metalness={0.8}
roughness={0.2}
/>
)}
</mesh>
);
}
```
## Testing Materials
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
import { MeshStandardMaterial, MeshPhysicalMaterial, Color } from 'three';
describe('Material configuration', () => {
it('creates PBR material with correct defaults', () => {
const mat = new MeshStandardMaterial({ color: '#ff0000', metalness: 0.8 });
expect(mat.metalness).toBe(0.8);
expect(mat.color.getHexString()).toBe('ff0000');
});
it('configures glass material', () => {
const mat = new MeshPhysicalMaterial({ transmission: 1, ior: 1.5 });
expect(mat.transmission).toBe(1);
expect(mat.ior).toBe(1.5);
});
it('disposes material correctly', () => {
const mat = new MeshStandardMaterial();
mat.dispose();
expect(mat.disposed).toBeDefined();
});
});
```
### Storybook Material Gallery
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
function MaterialGallery() {
const materials = [
{ name: 'Metal', props: { metalness: 1, roughness: 0.1, color: '#888' } },
{ name: 'Plastic', props: { metalness: 0, roughness: 0.3, color: '#e74c3c' } },
{ name: 'Rubber', props: { metalness: 0, roughness: 0.9, color: '#2c3e50' } },
];
return (
<Canvas camera={{ position: [0, 0, 6] }} style={{ height: 400 }}>
<Environment preset="studio" />
{materials.map((m, i) => (
<mesh key={m.name} position={[(i - 1) * 2.5, 0, 0]}>
<sphereGeometry args={[0.8, 64, 64]} />
<meshStandardMaterial {...m.props} />
</mesh>
))}
<OrbitControls />
</Canvas>
);
}
const meta: Meta = { title: 'R3F/Materials/Gallery', component: MaterialGallery };
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When configuring materials in R3F:
1. Use `meshStandardMaterial` as the default PBR material
2. Use `meshPhysicalMaterial` only when clearcoat, transmission, sheen, or iridescence is needed
3. Share materials with `useMemo` to avoid creating duplicates per mesh
4. Set `transparent={true}` before using `opacity` or `alphaTest`
5. Use `depthWrite={false}` on transparent materials to prevent sorting artifacts
6. Animate material properties via refs in `useFrame` (not re-rendering)
7. Use `@suaveplan/r3f-shaders` for pre-built custom materials
8. Use `@suaveplan/r3f-performance` for adaptive material quality
9. Load texture maps with `useTexture` inside `<Suspense>`
10. Keep material configuration components under 250 lines

View File

@@ -0,0 +1,576 @@
---
name: threejs-postprocessing
description: Three.js post-processing effects adapted for React Three Fiber and Suaveplan R3F packages. Covers @react-three/postprocessing EffectComposer, bloom, SSAO, depth-of-field, vignette, chromatic aberration, custom effects, render passes, and @suaveplan/r3f-performance for monitoring post-processing cost.
---
This skill covers post-processing in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to add screen-space visual effects to a 3D scene: bloom, ambient occlusion, depth of field, color grading, or custom post-processing passes.
## Core Concepts
### Three.js Post-Processing Pipeline
Post-processing applies full-screen image effects after the scene is rendered:
1. Scene renders to a framebuffer (render target) instead of the screen
2. The framebuffer texture passes through a chain of shader passes (effects)
3. The final result renders to the screen
### R3F Post-Processing Libraries
Two libraries exist for R3F post-processing:
| Library | Approach | Performance | Recommendation |
|---|---|---|---|
| `@react-three/postprocessing` | Effect merging (pmndrs/postprocessing) | Faster (fewer draw calls) | **Use this** |
| `three/examples/jsm/postprocessing` | Pass-based (EffectComposer) | Slower (1 pass per effect) | Legacy only |
`@react-three/postprocessing` merges compatible effects into a single shader pass, reducing GPU overhead significantly.
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core scene utilities | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-performance` | Effect budget monitoring | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
| `@suaveplan/r3f-shaders` | Custom effect shaders | `import { useAnimatedShader } from '@suaveplan/r3f-shaders'` |
## EffectComposer Setup
The `<EffectComposer>` component wraps all post-processing effects:
```tsx
import { Canvas } from '@react-three/fiber';
import { EffectComposer, Bloom, Vignette } from '@react-three/postprocessing';
export function PostProcessedScene() {
return (
<Canvas>
<ambientLight />
<directionalLight position={[5, 5, 5]} />
<mesh>
<boxGeometry />
<meshStandardMaterial color="tomato" />
</mesh>
<EffectComposer>
<Bloom intensity={0.5} luminanceThreshold={0.8} />
<Vignette darkness={0.5} offset={0.3} />
</EffectComposer>
</Canvas>
);
}
```
### EffectComposer Props
```tsx
<EffectComposer
enabled={true} // Toggle all effects
depthBuffer={true} // Enable depth buffer (needed for DOF, SSAO)
stencilBuffer={false} // Enable stencil buffer
autoClear={true} // Clear before render
multisampling={8} // MSAA samples (0 to disable)
frameBufferType={1016} // HalfFloatType for HDR
>
{/* Effects here */}
</EffectComposer>
```
## Bloom
Adds glow to bright areas. Pairs with emissive materials for neon/light effects.
```tsx
import { Bloom } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
<EffectComposer>
<Bloom
intensity={1.0} // Bloom strength
luminanceThreshold={0.9} // Brightness cutoff (0-1)
luminanceSmoothing={0.025} // Softness of the threshold
mipmapBlur={true} // Use mipmap blur (better quality)
radius={0.8} // Blur radius
blendFunction={BlendFunction.ADD} // How to composite
/>
</EffectComposer>
```
### Selective Bloom with Emissive Materials
Only emissive objects produce bloom when `luminanceThreshold` is high:
```tsx
export function NeonScene() {
return (
<>
{/* This glows (emissive > threshold) */}
<mesh position={[0, 1, 0]}>
<sphereGeometry args={[0.3, 32, 32]} />
<meshStandardMaterial
color="#ff0080"
emissive="#ff0080"
emissiveIntensity={5}
toneMapped={false} // Allow values > 1 for HDR bloom
/>
</mesh>
{/* This does not glow (no emissive) */}
<mesh>
<boxGeometry />
<meshStandardMaterial color="gray" />
</mesh>
<EffectComposer>
<Bloom
luminanceThreshold={1}
intensity={1.5}
mipmapBlur
/>
</EffectComposer>
</>
);
}
```
## Screen-Space Ambient Occlusion (SSAO)
Adds soft shadows in crevices and corners. Provides depth and realism.
```tsx
import { SSAO } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
<EffectComposer>
<SSAO
blendFunction={BlendFunction.MULTIPLY}
samples={30} // Quality (more = slower)
radius={5} // AO radius in world units
intensity={30} // AO intensity
luminanceInfluence={0.6} // Bright areas occlude less
bias={0.035} // Depth bias
worldDistanceThreshold={20}
worldDistanceFalloff={5}
worldProximityThreshold={0.5}
worldProximityFalloff={0.3}
/>
</EffectComposer>
```
### N8AO (Alternative SSAO)
Higher quality SSAO from drei:
```tsx
import { N8AO } from '@react-three/postprocessing';
<EffectComposer>
<N8AO
aoRadius={1}
intensity={2}
distanceFalloff={1}
color="black"
quality="medium" // 'low' | 'medium' | 'high' | 'ultra'
/>
</EffectComposer>
```
## Depth of Field (DOF)
Simulates camera lens focus with bokeh blur on out-of-focus areas.
```tsx
import { DepthOfField } from '@react-three/postprocessing';
<EffectComposer>
<DepthOfField
focusDistance={0} // Normalized focus distance (0-1)
focalLength={0.02} // Focal length (affects DOF range)
bokehScale={6} // Bokeh size
height={480} // Resolution of the effect
/>
</EffectComposer>
```
### Auto-Focus DOF
Focus on what the user is looking at:
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { DepthOfField } from '@react-three/postprocessing';
import { Vector3 } from 'three';
export function AutoFocusDOF() {
const depthRef = useRef<any>(null);
const focusPoint = useRef(new Vector3());
useFrame(({ camera, raycaster, pointer, scene }) => {
if (!depthRef.current) return;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(scene.children, true);
if (hits.length > 0) {
const distance = hits[0].distance;
const normalizedDistance = distance / camera.far;
depthRef.current.focusDistance = normalizedDistance;
}
});
return (
<EffectComposer>
<DepthOfField
ref={depthRef}
focusDistance={0.01}
focalLength={0.02}
bokehScale={4}
/>
</EffectComposer>
);
}
```
## Color Grading and Tone
### Tone Mapping
```tsx
import { ToneMapping } from '@react-three/postprocessing';
import { ToneMappingMode } from 'postprocessing';
<EffectComposer>
<ToneMapping
mode={ToneMappingMode.ACES_FILMIC}
// Options: LINEAR, REINHARD, REINHARD2, REINHARD2_ADAPTIVE,
// UNCHARTED2, OPTIMIZED_CINEON, ACES_FILMIC, AGX, NEUTRAL
/>
</EffectComposer>
```
### Brightness/Contrast
```tsx
import { BrightnessContrast } from '@react-three/postprocessing';
<EffectComposer>
<BrightnessContrast brightness={0.05} contrast={0.1} />
</EffectComposer>
```
### Hue/Saturation
```tsx
import { HueSaturation } from '@react-three/postprocessing';
<EffectComposer>
<HueSaturation hue={0} saturation={0.15} />
</EffectComposer>
```
### LUT (Lookup Table) Color Grading
```tsx
import { LUT3D } from '@react-three/postprocessing';
import { useLoader } from '@react-three/fiber';
import { LUTCubeLoader } from 'postprocessing';
function CinematicGrade() {
const texture = useLoader(LUTCubeLoader, '/luts/cinematic.cube');
return (
<EffectComposer>
<LUT3D lut={texture} />
</EffectComposer>
);
}
```
## Screen Effects
### Vignette
```tsx
import { Vignette } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
<Vignette
offset={0.3} // Inner radius
darkness={0.7} // Edge darkness
blendFunction={BlendFunction.NORMAL}
/>
```
### Chromatic Aberration
```tsx
import { ChromaticAberration } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
import { Vector2 } from 'three';
<ChromaticAberration
offset={new Vector2(0.002, 0.002)} // R/G/B channel offset
blendFunction={BlendFunction.NORMAL}
radialModulation={true} // Stronger at edges
modulationOffset={0.5}
/>
```
### Noise / Film Grain
```tsx
import { Noise } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
<Noise
premultiply // Multiply with scene color
blendFunction={BlendFunction.ADD}
opacity={0.5}
/>
```
### Scanlines
```tsx
import { Scanline } from '@react-three/postprocessing';
import { BlendFunction } from 'postprocessing';
<Scanline
density={1.5}
blendFunction={BlendFunction.OVERLAY}
opacity={0.1}
/>
```
## Effect Combinations: Recipes
### Cinematic Look
```tsx
<EffectComposer>
<Bloom luminanceThreshold={0.8} intensity={0.5} mipmapBlur />
<DepthOfField focusDistance={0.01} focalLength={0.02} bokehScale={3} />
<Vignette offset={0.3} darkness={0.6} />
<HueSaturation saturation={-0.1} />
<BrightnessContrast contrast={0.05} />
<Noise opacity={0.03} premultiply />
</EffectComposer>
```
### Neon / Cyberpunk
```tsx
<EffectComposer>
<Bloom luminanceThreshold={0.5} intensity={2} mipmapBlur radius={0.9} />
<ChromaticAberration offset={new Vector2(0.003, 0.003)} radialModulation />
<Scanline density={1.2} opacity={0.05} />
<Noise opacity={0.04} premultiply />
<Vignette darkness={0.8} />
</EffectComposer>
```
### Clean Product Visualization
```tsx
<EffectComposer multisampling={8}>
<N8AO aoRadius={0.5} intensity={1} quality="high" />
<Bloom luminanceThreshold={1.2} intensity={0.3} mipmapBlur />
<ToneMapping mode={ToneMappingMode.ACES_FILMIC} />
</EffectComposer>
```
## Custom Effects
### Writing a Custom Effect
```tsx
import { forwardRef, useMemo } from 'react';
import { Effect, BlendFunction } from 'postprocessing';
import { Uniform } from 'three';
const fragmentShader = /* glsl */ `
uniform float intensity;
uniform float time;
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec2 center = uv - 0.5;
float dist = length(center);
float wave = sin(dist * 20.0 - time * 3.0) * intensity;
vec2 distortedUv = uv + center * wave * 0.02;
outputColor = texture2D(inputBuffer, distortedUv);
}
`;
class RippleEffectImpl extends Effect {
constructor({ intensity = 0.5, blendFunction = BlendFunction.NORMAL } = {}) {
super('RippleEffect', fragmentShader, {
blendFunction,
uniforms: new Map([
['intensity', new Uniform(intensity)],
['time', new Uniform(0)],
]),
});
}
update(_renderer: any, _inputBuffer: any, deltaTime: number) {
const time = this.uniforms.get('time')!;
time.value += deltaTime;
}
}
export const RippleEffect = forwardRef<RippleEffectImpl, { intensity?: number }>(
({ intensity = 0.5 }, ref) => {
const effect = useMemo(() => new RippleEffectImpl({ intensity }), [intensity]);
return <primitive ref={ref} object={effect} dispose={null} />;
}
);
// Usage:
// <EffectComposer>
// <RippleEffect intensity={0.3} />
// </EffectComposer>
```
## Adaptive Post-Processing
Use `@suaveplan/r3f-performance` to scale effects based on frame rate:
```tsx
import { useState, useCallback } from 'react';
import { EffectComposer, Bloom, SSAO, Vignette } from '@react-three/postprocessing';
import { usePerformanceMonitor } from '@suaveplan/r3f-performance';
type EffectQuality = 'high' | 'medium' | 'low' | 'off';
export function AdaptiveEffects() {
const [quality, setQuality] = useState<EffectQuality>('high');
usePerformanceMonitor({
onDecline: ({ fps }) => {
if (fps < 20) setQuality('off');
else if (fps < 30) setQuality('low');
else if (fps < 45) setQuality('medium');
},
onIncline: ({ fps }) => {
if (fps > 55) setQuality('high');
else if (fps > 45) setQuality('medium');
else if (fps > 30) setQuality('low');
},
});
if (quality === 'off') return null;
return (
<EffectComposer multisampling={quality === 'high' ? 8 : quality === 'medium' ? 4 : 0}>
<Bloom
intensity={quality === 'high' ? 1 : 0.5}
luminanceThreshold={0.8}
mipmapBlur
/>
{quality !== 'low' && (
<SSAO
samples={quality === 'high' ? 30 : 15}
radius={5}
intensity={20}
/>
)}
<Vignette darkness={0.5} />
</EffectComposer>
);
}
```
## Performance Guidelines
### Effect Cost Hierarchy (Approximate)
| Effect | Cost | Notes |
|---|---|---|
| Vignette | Very Low | Single texture lookup |
| Noise | Very Low | Math-only |
| BrightnessContrast | Very Low | Per-pixel math |
| HueSaturation | Low | Color space conversion |
| ChromaticAberration | Low | 3 texture lookups |
| Bloom (mipmap) | Medium | Multi-pass blur |
| DOF | Medium-High | Multi-sample blur |
| SSAO | High | Many depth samples |
| N8AO (ultra) | Very High | Many samples + blur |
### Best Practices
1. Merge effects into a single `<EffectComposer>` (auto-merged into fewer passes)
2. Use `mipmapBlur` for Bloom (faster than kernel blur)
3. Reduce `samples` count on SSAO for mobile
4. Use `multisampling={0}` on mobile devices
5. Disable effects entirely when FPS drops below threshold
6. Use `HalfFloatType` framebuffer for HDR bloom
## Testing Post-Processing
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
describe('Post-processing setup', () => {
it('imports effects without errors', async () => {
const { Bloom, Vignette, SSAO } = await import('@react-three/postprocessing');
expect(Bloom).toBeDefined();
expect(Vignette).toBeDefined();
expect(SSAO).toBeDefined();
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
import { EffectComposer, Bloom, Vignette } from '@react-three/postprocessing';
function BloomDemo() {
return (
<Canvas style={{ height: 400 }}>
<Environment preset="studio" />
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<meshStandardMaterial
color="#ff0080"
emissive="#ff0080"
emissiveIntensity={3}
toneMapped={false}
/>
</mesh>
<EffectComposer>
<Bloom luminanceThreshold={0.8} intensity={1.5} mipmapBlur />
<Vignette darkness={0.5} />
</EffectComposer>
<OrbitControls />
</Canvas>
);
}
const meta: Meta = { title: 'R3F/PostProcessing/Bloom', component: BloomDemo };
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When adding post-processing to R3F:
1. Use `@react-three/postprocessing` (effect merging) over Three.js pass-based
2. Place `<EffectComposer>` as a sibling to scene content, not wrapping it
3. Enable `depthBuffer` for effects that need depth (DOF, SSAO)
4. Use `toneMapped={false}` on emissive materials for HDR bloom
5. Set `luminanceThreshold` high to prevent everything from blooming
6. Start with low SSAO samples and increase to taste
7. Implement adaptive quality with `@suaveplan/r3f-performance`
8. Profile with browser DevTools GPU panel to measure effect cost
9. Disable all effects as a fallback for low-end devices
10. Keep post-processing configuration under 250 lines per file

View File

@@ -0,0 +1,572 @@
---
name: threejs-shaders
description: Three.js shader programming adapted for React Three Fiber and Suaveplan R3F packages. Covers shaderMaterial in JSX, the @suaveplan/r3f-shaders library, GLSL vertex and fragment shaders with React refs, uniform animation via useFrame, the extend() pattern for custom JSX elements, and onBeforeCompile for material patching.
---
This skill covers custom shader development in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to write custom GLSL shaders for visual effects, procedural materials, particle systems, or compute-like operations in the GPU.
## Core Concepts
### Three.js Shader Pipeline
Three.js shaders run on the GPU as GLSL programs:
- **Vertex Shader**: Transforms vertex positions (runs per vertex)
- **Fragment Shader**: Determines pixel color (runs per fragment/pixel)
- **Uniforms**: Values passed from JavaScript to GLSL (same for all vertices/fragments)
- **Attributes**: Per-vertex data (position, normal, UV)
- **Varyings**: Values interpolated from vertex to fragment shader
### R3F Shader Approach
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new ShaderMaterial({ uniforms, vertexShader, fragmentShader })` | `<shaderMaterial>` JSX or `extend()` |
| `material.uniforms.time.value = t` | Ref + `useFrame` |
| `new RawShaderMaterial(...)` | `<rawShaderMaterial>` |
| Custom class extending Material | `extend({ CustomMaterial })` |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f-shaders` | Pre-built shader materials and utilities | `import { GlassMaterial, HologramMaterial, useShaderUniforms } from '@suaveplan/r3f-shaders'` |
| `@suaveplan/r3f` | Core R3F utilities | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-performance` | Shader performance monitoring | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
## ShaderMaterial in JSX
### Basic Custom Shader
```tsx
import { useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { ShaderMaterial, Color } from 'three';
const vertexShader = /* glsl */ `
varying vec2 vUv;
varying vec3 vNormal;
void main() {
vUv = uv;
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = /* glsl */ `
uniform float uTime;
uniform vec3 uColor;
varying vec2 vUv;
varying vec3 vNormal;
void main() {
float stripe = sin(vUv.y * 20.0 + uTime * 2.0) * 0.5 + 0.5;
vec3 color = mix(uColor, uColor * 0.3, step(0.5, stripe));
float fresnel = pow(1.0 - dot(vNormal, vec3(0.0, 0.0, 1.0)), 2.0);
color += fresnel * 0.3;
gl_FragColor = vec4(color, 1.0);
}
`;
export function StripedMaterial({ color = '#4ecdc4' }: { color?: string }) {
const materialRef = useRef<ShaderMaterial>(null);
const uniforms = useMemo(
() => ({
uTime: { value: 0 },
uColor: { value: new Color(color) },
}),
[]
);
useFrame(({ clock }) => {
if (materialRef.current) {
materialRef.current.uniforms.uTime.value = clock.elapsedTime;
}
});
return (
<mesh>
<sphereGeometry args={[1, 64, 64]} />
<shaderMaterial
ref={materialRef}
vertexShader={vertexShader}
fragmentShader={fragmentShader}
uniforms={uniforms}
/>
</mesh>
);
}
```
### Key Rule: useMemo for Uniforms
Always wrap uniforms in `useMemo`. Without it, R3F recreates the material every render:
```tsx
// CORRECT: uniforms created once
const uniforms = useMemo(() => ({
uTime: { value: 0 },
uResolution: { value: new Vector2(window.innerWidth, window.innerHeight) },
}), []);
// WRONG: new uniforms object every render, causes material recreation
const uniforms = {
uTime: { value: 0 },
};
```
## extend(): Custom JSX Elements
For reusable shader materials, use `extend()` to register a custom Three.js class as a JSX element:
```tsx
import { extend, useFrame } from '@react-three/fiber';
import { shaderMaterial } from '@react-three/drei';
import { Color, Vector2 } from 'three';
import { useRef } from 'react';
// Create a typed shader material class
const WaveMaterial = shaderMaterial(
// Uniforms with defaults
{
uTime: 0,
uColor: new Color('#ff6b6b'),
uAmplitude: 0.3,
uFrequency: 4.0,
},
// Vertex shader
/* glsl */ `
uniform float uTime;
uniform float uAmplitude;
uniform float uFrequency;
varying vec2 vUv;
varying float vElevation;
void main() {
vUv = uv;
vec3 pos = position;
float elevation = sin(pos.x * uFrequency + uTime) * uAmplitude
+ sin(pos.z * uFrequency * 0.7 + uTime * 0.8) * uAmplitude * 0.5;
pos.y += elevation;
vElevation = elevation;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`,
// Fragment shader
/* glsl */ `
uniform vec3 uColor;
uniform float uAmplitude;
varying vec2 vUv;
varying float vElevation;
void main() {
float brightness = (vElevation / uAmplitude + 1.0) * 0.5;
vec3 color = mix(uColor * 0.3, uColor, brightness);
gl_FragColor = vec4(color, 1.0);
}
`
);
// Register as JSX element
extend({ WaveMaterial });
// TypeScript declaration for JSX
declare module '@react-three/fiber' {
interface ThreeElements {
waveMaterial: JSX.IntrinsicElements['shaderMaterial'] & {
uTime?: number;
uColor?: Color;
uAmplitude?: number;
uFrequency?: number;
};
}
}
// Usage component
export function WavePlane() {
const materialRef = useRef<typeof WaveMaterial.prototype>(null);
useFrame(({ clock }) => {
if (materialRef.current) {
materialRef.current.uTime = clock.elapsedTime;
}
});
return (
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[10, 10, 128, 128]} />
<waveMaterial
ref={materialRef}
uColor={new Color('#4ecdc4')}
uAmplitude={0.3}
uFrequency={4}
/>
</mesh>
);
}
```
## GLSL Built-in Variables
### Vertex Shader
| Variable | Type | Description |
|---|---|---|
| `position` | `vec3` | Vertex position (attribute) |
| `normal` | `vec3` | Vertex normal (attribute) |
| `uv` | `vec2` | Texture coordinate (attribute) |
| `modelMatrix` | `mat4` | Object to world transform |
| `viewMatrix` | `mat4` | World to camera transform |
| `projectionMatrix` | `mat4` | Camera to clip space |
| `modelViewMatrix` | `mat4` | `viewMatrix * modelMatrix` |
| `normalMatrix` | `mat3` | Inverse transpose of modelView (for normals) |
| `cameraPosition` | `vec3` | Camera world position |
| `gl_Position` | `vec4` | **Output**: clip-space position |
### Fragment Shader
| Variable | Type | Description |
|---|---|---|
| `gl_FragCoord` | `vec4` | Pixel position in screen space |
| `gl_FrontFacing` | `bool` | Whether fragment is on front face |
| `gl_FragColor` | `vec4` | **Output**: pixel color (deprecated, use `out vec4`) |
## Common GLSL Patterns
### Fresnel Effect
```glsl
// In vertex shader
varying vec3 vViewDirection;
varying vec3 vWorldNormal;
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vViewDirection = normalize(cameraPosition - worldPos.xyz);
vWorldNormal = normalize((modelMatrix * vec4(normal, 0.0)).xyz);
gl_Position = projectionMatrix * viewMatrix * worldPos;
}
// In fragment shader
void main() {
float fresnel = pow(1.0 - dot(vViewDirection, vWorldNormal), 3.0);
vec3 color = mix(vec3(0.1), vec3(0.0, 0.8, 1.0), fresnel);
gl_FragColor = vec4(color, 1.0);
}
```
### Noise Functions
```glsl
// Simple 2D noise (include in fragment shader)
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453);
}
// Simplex-like value noise
float noise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = random(i);
float b = random(i + vec2(1.0, 0.0));
float c = random(i + vec2(0.0, 1.0));
float d = random(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f); // smoothstep
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}
// FBM (Fractal Brownian Motion) for complex noise
float fbm(vec2 st) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 5; i++) {
value += amplitude * noise(st);
st *= 2.0;
amplitude *= 0.5;
}
return value;
}
```
### SDF (Signed Distance Functions)
Common 2D SDFs: `sdCircle(p, r)` = `length(p) - r`, `sdBox(p, b)` = `length(max(abs(p) - b, 0.0))`. Use for procedural shapes, soft shadows, and UI in shaders. Combine with `smoothstep` for anti-aliased edges.
## @suaveplan/r3f-shaders Library
Pre-built shader materials from the Suaveplan shader library:
### Available Shaders
```tsx
import {
GlassMaterial,
HologramMaterial,
NoiseMaterial,
GradientMaterial,
FresnelMaterial,
GridMaterial,
DissolveEffect,
} from '@suaveplan/r3f-shaders';
// Glass with custom refraction
<GlassMaterial
color="#88ccff"
ior={1.45}
thickness={0.3}
distortion={0.1}
chromaticAberration={0.02}
/>
// Sci-fi hologram
<HologramMaterial
color="#00ff88"
scanlineSpeed={2}
fresnelIntensity={1.5}
glitchIntensity={0.3}
wireframe
/>
// Procedural noise surface
<NoiseMaterial
color="#4a90d9"
scale={3}
speed={0.5}
displacement={0.2}
octaves={4}
/>
// Two-color gradient
<GradientMaterial
colorA="#ff6b6b"
colorB="#4ecdc4"
direction="vertical"
/>
```
### Creating Custom Shaders for the Library
Follow the `@suaveplan/r3f-shaders` pattern:
```tsx
// src/materials/my-effect/my-effect.ts
import { shaderMaterial } from '@react-three/drei';
import { Color } from 'three';
export const MyEffectMaterial = shaderMaterial(
{ uTime: 0, uColor: new Color('#ffffff'), uIntensity: 1.0 },
/* glsl */ `/* vertex shader */`,
/* glsl */ `/* fragment shader */`
);
// src/materials/my-effect/my-effect.types.ts
import type { Color } from 'three';
export interface MyEffectMaterialProps {
uTime?: number;
uColor?: Color | string;
uIntensity?: number;
}
```
## Uniform Animation Patterns
### Time-Based Animation
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import type { ShaderMaterial } from 'three';
export function AnimatedShader() {
const matRef = useRef<ShaderMaterial>(null);
useFrame(({ clock, pointer }) => {
if (!matRef.current) return;
matRef.current.uniforms.uTime.value = clock.elapsedTime;
matRef.current.uniforms.uMouse.value.set(pointer.x, pointer.y);
});
// ... shader material with uTime and uMouse uniforms
}
```
### Reactive Uniforms (Props to Shader)
```tsx
import { useRef, useEffect } from 'react';
import { Color } from 'three';
import type { ShaderMaterial } from 'three';
interface ReactiveShaderProps {
color: string;
intensity: number;
}
export function ReactiveShader({ color, intensity }: ReactiveShaderProps) {
const matRef = useRef<ShaderMaterial>(null);
useEffect(() => {
if (!matRef.current) return;
(matRef.current.uniforms.uColor.value as Color).set(color);
matRef.current.uniforms.uIntensity.value = intensity;
}, [color, intensity]);
// ... shader material
}
```
## onBeforeCompile: Patching Built-in Materials
Modify Three.js built-in materials by injecting GLSL:
```tsx
import { useRef, useCallback } from 'react';
import { useFrame } from '@react-three/fiber';
import type { MeshStandardMaterial, Shader } from 'three';
export function PatchedMaterial() {
const matRef = useRef<MeshStandardMaterial>(null);
const shaderRef = useRef<Shader | null>(null);
const onBeforeCompile = useCallback((shader: Shader) => {
shaderRef.current = shader;
shader.uniforms.uTime = { value: 0 };
// Inject uniform declaration
shader.vertexShader = shader.vertexShader.replace(
'#include <common>',
/* glsl */ `
#include <common>
uniform float uTime;
`
);
// Inject vertex displacement
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
/* glsl */ `
#include <begin_vertex>
transformed.y += sin(transformed.x * 4.0 + uTime * 2.0) * 0.2;
`
);
}, []);
useFrame(({ clock }) => {
if (shaderRef.current) {
shaderRef.current.uniforms.uTime.value = clock.elapsedTime;
}
});
return (
<mesh>
<planeGeometry args={[5, 5, 64, 64]} />
<meshStandardMaterial
ref={matRef}
color="steelblue"
onBeforeCompile={onBeforeCompile}
/>
</mesh>
);
}
```
## RawShaderMaterial
For full control without Three.js built-in uniforms/attributes, use `<rawShaderMaterial>`. You must manually declare all attributes (`position`, `uv`), uniforms (`projectionMatrix`, `modelViewMatrix`), and set `precision`. Supports `glslVersion="300 es"` for GLSL 3.0.
## Performance Tips
1. **Minimize texture lookups**: Each `texture2D()` call has a cost. Cache results in variables.
2. **Avoid branches**: GPU prefers math over `if/else`. Use `step()`, `smoothstep()`, `mix()` instead.
3. **Limit loop iterations**: Unroll small loops. Keep `for` loops short and bounded.
4. **Use mediump where possible**: `precision mediump float` saves bandwidth on mobile.
5. **Pre-calculate on CPU**: Move invariant math to `useFrame` and pass as uniforms.
```glsl
// SLOW: branching
if (dist < radius) { color = vec3(1.0); }
else { color = vec3(0.0); }
// FAST: step function
color = vec3(step(dist, radius));
// SLOW: multiple conditions
if (x > 0.0 && x < 1.0 && y > 0.0 && y < 1.0) { ... }
// FAST: step math
float inside = step(0.0, x) * step(x, 1.0) * step(0.0, y) * step(y, 1.0);
```
## Testing Shaders
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
import { ShaderMaterial, Color } from 'three';
describe('Custom shader material', () => {
it('creates material with correct uniforms', () => {
const material = new ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uColor: { value: new Color('#ff0000') },
},
vertexShader: 'void main() { gl_Position = vec4(0.0); }',
fragmentShader: 'void main() { gl_FragColor = vec4(1.0); }',
});
expect(material.uniforms.uTime.value).toBe(0);
expect(material.uniforms.uColor.value).toBeInstanceOf(Color);
});
it('updates uniform values', () => {
const uniforms = { uTime: { value: 0 } };
uniforms.uTime.value = 1.5;
expect(uniforms.uTime.value).toBe(1.5);
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { WavePlane } from './wave-plane';
const meta: Meta<typeof WavePlane> = {
title: 'R3F/Shaders/WavePlane',
component: WavePlane,
decorators: [
(Story) => (
<Canvas camera={{ position: [5, 5, 5] }} style={{ height: 400 }}>
<ambientLight intensity={0.5} />
<Story />
<OrbitControls />
</Canvas>
),
],
};
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When writing shaders in R3F:
1. Wrap GLSL strings with `/* glsl */` template literal tag for IDE syntax highlighting
2. Always `useMemo` for uniform objects to prevent material recreation
3. Use `extend()` + `shaderMaterial` from drei for reusable shader elements
4. Animate uniforms via `useFrame` and refs (not React state)
5. Declare TypeScript JSX types when using `extend()`
6. Use `@suaveplan/r3f-shaders` pre-built materials before writing custom GLSL
7. Prefer `step`/`smoothstep`/`mix` over `if/else` in GLSL
8. Pre-allocate all uniform objects (Color, Vector2, Vector3) in `useMemo`
9. Use `onBeforeCompile` to patch built-in materials rather than rewriting from scratch
10. Keep shader component files under 250 lines (split GLSL into separate strings or files)

View File

@@ -0,0 +1,487 @@
---
name: threejs-textures
description: Three.js texture management adapted for React Three Fiber and Suaveplan R3F packages. Covers useTexture hook, texture configuration (wrapping, filtering, encoding), environment maps, HDR loading, render targets for dynamic textures, cubemaps, texture atlases, and @suaveplan/r3f texture utilities.
---
This skill covers texture management in Three.js within the React Three Fiber (R3F) context used by the Suaveplan monorepo. All code targets **React 19 + TypeScript + R3F** and follows Suaveplan CLAUDE.md conventions.
The user needs to load, configure, or create textures for 3D materials: image textures, environment maps, render targets, procedural textures, or compressed texture formats.
## Core Concepts
### Three.js Texture System
Textures in Three.js are GPU-side image data. Key classes:
- **Texture**: Base class for 2D textures from images
- **CanvasTexture**: Texture from an HTML canvas element
- **VideoTexture**: Texture from a video element
- **CubeTexture**: 6-face cubemap for environment mapping
- **DataTexture**: Texture from raw typed arrays
- **WebGLRenderTarget**: Framebuffer for render-to-texture
### R3F Texture Approach
R3F provides hooks that integrate texture loading with React Suspense:
| Three.js (Imperative) | R3F (Declarative) |
|---|---|
| `new TextureLoader().load(url, cb)` | `useTexture(url)` |
| `new CubeTextureLoader().load(urls, cb)` | `<Environment>` or `useCubeTexture` |
| `new RGBELoader().load(url, cb)` | `useLoader(RGBELoader, url)` |
| Manual texture.wrapS/wrapT | Configure after `useTexture` |
| `renderer.setRenderTarget(rt)` | `useFBO()` or `createPortal()` |
### Suaveplan R3F Packages
| Package | Purpose | Import |
|---|---|---|
| `@suaveplan/r3f` | Core texture utilities, loading helpers | `import { useR3F } from '@suaveplan/r3f'` |
| `@suaveplan/r3f-shaders` | Texture-based shader materials | `import { NoiseMaterial } from '@suaveplan/r3f-shaders'` |
| `@suaveplan/r3f-performance` | Texture memory monitoring | `import { usePerformanceMonitor } from '@suaveplan/r3f-performance'` |
## useTexture: Loading Textures
The primary hook for loading image textures. Suspends the component until loaded.
### Single Texture
```tsx
import { Suspense } from 'react';
import { useTexture } from '@react-three/drei';
function TexturedBox() {
const texture = useTexture('/textures/wood.jpg');
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial map={texture} />
</mesh>
);
}
export function Scene() {
return (
<Suspense fallback={null}>
<TexturedBox />
</Suspense>
);
}
```
### Multiple Textures (PBR Map Set)
```tsx
import { useTexture } from '@react-three/drei';
function PBRSurface() {
const textures = useTexture({
map: '/textures/brick/color.jpg',
normalMap: '/textures/brick/normal.jpg',
roughnessMap: '/textures/brick/roughness.jpg',
aoMap: '/textures/brick/ao.jpg',
displacementMap: '/textures/brick/height.jpg',
});
return (
<mesh>
<planeGeometry args={[4, 4, 64, 64]} />
<meshStandardMaterial
{...textures}
displacementScale={0.1}
normalScale={[1, 1]}
aoMapIntensity={1}
/>
</mesh>
);
}
```
### Preloading Textures
```tsx
import { useTexture } from '@react-three/drei';
// Preload at module level (starts loading immediately)
useTexture.preload('/textures/wood.jpg');
useTexture.preload({
map: '/textures/brick/color.jpg',
normalMap: '/textures/brick/normal.jpg',
});
```
## Texture Configuration
After loading, configure texture properties before use:
### Wrapping Modes
```tsx
import { useTexture } from '@react-three/drei';
import { RepeatWrapping, MirroredRepeatWrapping, ClampToEdgeWrapping } from 'three';
function TiledFloor() {
const texture = useTexture('/textures/tile.jpg');
// Tile 4x4 across the surface
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.repeat.set(4, 4);
return (
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[10, 10]} />
<meshStandardMaterial map={texture} />
</mesh>
);
}
```
| Wrapping Mode | Constant | Behavior |
|---|---|---|
| `ClampToEdgeWrapping` | Default | Edge pixels stretch |
| `RepeatWrapping` | Tiling | Texture repeats |
| `MirroredRepeatWrapping` | Mirrored tiling | Alternating mirror |
### Filtering Modes
```tsx
import { NearestFilter, LinearFilter, LinearMipmapLinearFilter } from 'three';
// Pixel-art style (no blurring)
texture.magFilter = NearestFilter;
texture.minFilter = NearestFilter;
texture.generateMipmaps = false;
// Smooth (default for photos/PBR)
texture.magFilter = LinearFilter;
texture.minFilter = LinearMipmapLinearFilter;
// Anisotropic filtering (reduces blur at oblique angles)
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
```
| Filter | Minification | Magnification | Use Case |
|---|---|---|---|
| `NearestFilter` | Pixelated | Pixelated | Pixel art, data textures |
| `LinearFilter` | Smooth | Smooth | Photos, PBR maps |
| `LinearMipmapLinearFilter` | Trilinear | N/A | Default min filter |
### Color Space
```tsx
import { SRGBColorSpace, LinearSRGBColorSpace, NoColorSpace } from 'three';
// Color/albedo textures: sRGB (default in R3F)
texture.colorSpace = SRGBColorSpace;
// Data textures (normal, roughness, metalness, AO): Linear
normalMap.colorSpace = LinearSRGBColorSpace;
roughnessMap.colorSpace = LinearSRGBColorSpace;
// Raw data (displacement, custom): No conversion
dataTexture.colorSpace = NoColorSpace;
```
### Texture Configuration Helper
```tsx
import { useTexture } from '@react-three/drei';
import { RepeatWrapping, SRGBColorSpace, LinearSRGBColorSpace } from 'three';
import type { Texture } from 'three';
function configureTexture(texture: Texture, options: {
repeat?: [number, number];
colorSpace?: string;
anisotropy?: number;
}) {
if (options.repeat) {
texture.wrapS = texture.wrapT = RepeatWrapping;
texture.repeat.set(...options.repeat);
}
if (options.colorSpace) {
texture.colorSpace = options.colorSpace;
}
if (options.anisotropy) {
texture.anisotropy = options.anisotropy;
}
return texture;
}
function ConfiguredSurface() {
const textures = useTexture({
map: '/textures/ground/color.jpg',
normalMap: '/textures/ground/normal.jpg',
});
configureTexture(textures.map, { repeat: [4, 4], colorSpace: SRGBColorSpace });
configureTexture(textures.normalMap, { repeat: [4, 4], colorSpace: LinearSRGBColorSpace });
return (
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[20, 20]} />
<meshStandardMaterial {...textures} />
</mesh>
);
}
```
## Environment Maps
### HDR Environment Maps
```tsx
import { useLoader } from '@react-three/fiber';
import { RGBELoader } from 'three-stdlib';
import { EquirectangularReflectionMapping } from 'three';
function HDREnvironment({ url }: { url: string }) {
const hdr = useLoader(RGBELoader, url);
hdr.mapping = EquirectangularReflectionMapping;
return (
<>
<primitive object={hdr} attach="environment" />
<primitive object={hdr} attach="background" />
</>
);
}
```
### Drei Environment Component
The simplest way to add environment maps:
```tsx
import { Environment } from '@react-three/drei';
// Preset HDR
<Environment preset="sunset" background />
// Custom HDR file
<Environment files="/textures/env.hdr" background />
// EXR file
<Environment files="/textures/env.exr" background />
// Multiple face cubemap
<Environment
files={[
'/cubemap/px.jpg', '/cubemap/nx.jpg',
'/cubemap/py.jpg', '/cubemap/ny.jpg',
'/cubemap/pz.jpg', '/cubemap/nz.jpg',
]}
/>
// Blurred background
<Environment files="/textures/env.hdr" background blur={0.5} />
```
### Lightformer Environments
Create procedural environment maps with `<Lightformer>` inside `<Environment>`. Use `form="rect"` or `form="ring"` with position, intensity, scale, and color props to simulate key, fill, and rim lights.
## Render Targets (Dynamic Textures)
### useFBO: Render to Texture
```tsx
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { useFBO } from '@react-three/drei';
import type { Mesh } from 'three';
export function MirrorSurface() {
const meshRef = useRef<Mesh>(null);
const renderTarget = useFBO(512, 512);
useFrame(({ gl, scene, camera }) => {
// Hide mirror during render-to-texture
if (meshRef.current) meshRef.current.visible = false;
gl.setRenderTarget(renderTarget);
gl.render(scene, camera);
gl.setRenderTarget(null);
if (meshRef.current) meshRef.current.visible = true;
});
return (
<mesh ref={meshRef}>
<planeGeometry args={[4, 3]} />
<meshBasicMaterial map={renderTarget.texture} />
</mesh>
);
}
```
### Portal Rendering
Use `createPortal()` from R3F to render a separate `Scene` into a `useFBO()` render target. In `useFrame`, set the render target, render the portal scene with a dedicated camera, then reset to null.
## Canvas, Video, and Data Textures
- **CanvasTexture**: Create via `new CanvasTexture(canvas)` with a drawn 2D canvas. Wrap in `useMemo`.
- **VideoTexture**: Use `useVideoTexture` from drei for video-mapped surfaces.
- **DataTexture**: Create from `Float32Array` for noise, heightmaps, lookup tables. Set `needsUpdate = true`.
## Texture Atlas
Pack multiple images into a single texture and use UV offsets:
```tsx
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
interface AtlasFrame {
x: number;
y: number;
width: number;
height: number;
}
function useTextureAtlas(url: string, frame: AtlasFrame, atlasSize: [number, number]) {
const texture = useTexture(url);
return useMemo(() => {
const clone = texture.clone();
clone.repeat.set(frame.width / atlasSize[0], frame.height / atlasSize[1]);
clone.offset.set(frame.x / atlasSize[0], 1 - (frame.y + frame.height) / atlasSize[1]);
clone.needsUpdate = true;
return clone;
}, [texture, frame, atlasSize]);
}
// Usage: select a 64x64 region from a 512x512 atlas
function AtlasSprite() {
const tex = useTextureAtlas(
'/textures/sprites.png',
{ x: 128, y: 0, width: 64, height: 64 },
[512, 512]
);
return (
<sprite scale={[1, 1, 1]}>
<spriteMaterial map={tex} />
</sprite>
);
}
```
## Texture Memory Management
### GPU Memory Estimation
| Dimensions | Format | Mipmaps | VRAM |
|---|---|---|---|
| 1024x1024 | RGBA8 | Yes | ~5.3 MB |
| 2048x2048 | RGBA8 | Yes | ~21.3 MB |
| 4096x4096 | RGBA8 | Yes | ~85.3 MB |
| 1024x1024 | KTX2 (BC7) | Yes | ~1.3 MB |
| 2048x2048 | KTX2 (BC7) | Yes | ~5.3 MB |
### Optimization Guidelines
1. **Use power-of-two dimensions**: 256, 512, 1024, 2048, 4096 (required for mipmaps)
2. **Use KTX2 compression**: 4x-8x VRAM savings over raw RGBA
3. **Reduce resolution**: Most roughness/AO maps work fine at 512x512
4. **Share textures**: Use `texture.clone()` for UV variations on the same image
5. **Generate mipmaps**: Set `texture.generateMipmaps = true` (default) for filtered rendering
6. **Dispose unused textures**: `texture.dispose()` frees GPU memory
```tsx
import { usePerformanceMonitor } from '@suaveplan/r3f-performance';
function useAdaptiveTextureResolution() {
const [resolution, setResolution] = useState<'high' | 'low'>('high');
usePerformanceMonitor({
onDecline: () => setResolution('low'),
onIncline: () => setResolution('high'),
});
return resolution === 'high' ? '2k' : '1k';
}
```
## Testing Textures
### Vitest Tests
```tsx
import { describe, it, expect } from 'vitest';
import {
DataTexture,
FloatType,
RedFormat,
RepeatWrapping,
CanvasTexture,
} from 'three';
describe('Texture utilities', () => {
it('creates data texture with correct dimensions', () => {
const size = 64;
const data = new Float32Array(size * size);
const tex = new DataTexture(data, size, size, RedFormat, FloatType);
expect(tex.image.width).toBe(size);
expect(tex.image.height).toBe(size);
});
it('configures repeat wrapping', () => {
const data = new Float32Array(16);
const tex = new DataTexture(data, 4, 4, RedFormat, FloatType);
tex.wrapS = tex.wrapT = RepeatWrapping;
tex.repeat.set(4, 4);
expect(tex.wrapS).toBe(RepeatWrapping);
expect(tex.repeat.x).toBe(4);
});
it('creates canvas texture', () => {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const tex = new CanvasTexture(canvas);
expect(tex.image).toBe(canvas);
});
});
```
### Storybook Stories
```tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';
function TextureDemo() {
return (
<Canvas camera={{ position: [3, 3, 3] }} style={{ height: 400 }}>
<Environment preset="studio" />
<Suspense fallback={null}>
{/* Textured mesh component */}
</Suspense>
<OrbitControls />
</Canvas>
);
}
const meta: Meta = { title: 'R3F/Textures/PBRSurface', component: TextureDemo };
export default meta;
export const Default: StoryObj = {};
```
## Checklist
When working with textures in R3F:
1. Use `useTexture` from drei for all image texture loading
2. Wrap textured components in `<Suspense>` for async loading
3. Set `colorSpace = SRGBColorSpace` for color/albedo maps only
4. Set `colorSpace = LinearSRGBColorSpace` for normal, roughness, metalness, AO maps
5. Use `RepeatWrapping` and `texture.repeat.set()` for tiled surfaces
6. Preload textures with `useTexture.preload()` at module level
7. Use KTX2 compression for production (4-8x VRAM savings)
8. Use power-of-two dimensions for mipmap generation
9. Dispose textures when no longer needed (`texture.dispose()`)
10. Keep texture-related component files under 250 lines