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:
5
plugins/suaveplan-threejs/.claude-plugin/plugin.json
Normal file
5
plugins/suaveplan-threejs/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "suaveplan-threejs",
|
||||
"version": "1.0.0",
|
||||
"description": "React Three Fiber development skills for Suaveplan 3D packages"
|
||||
}
|
||||
538
plugins/suaveplan-threejs/skills/threejs-animation/SKILL.md
Normal file
538
plugins/suaveplan-threejs/skills/threejs-animation/SKILL.md
Normal 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
|
||||
522
plugins/suaveplan-threejs/skills/threejs-fundamentals/SKILL.md
Normal file
522
plugins/suaveplan-threejs/skills/threejs-fundamentals/SKILL.md
Normal 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
|
||||
576
plugins/suaveplan-threejs/skills/threejs-geometry/SKILL.md
Normal file
576
plugins/suaveplan-threejs/skills/threejs-geometry/SKILL.md
Normal 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
|
||||
487
plugins/suaveplan-threejs/skills/threejs-interaction/SKILL.md
Normal file
487
plugins/suaveplan-threejs/skills/threejs-interaction/SKILL.md
Normal 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
|
||||
513
plugins/suaveplan-threejs/skills/threejs-lighting/SKILL.md
Normal file
513
plugins/suaveplan-threejs/skills/threejs-lighting/SKILL.md
Normal 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
|
||||
519
plugins/suaveplan-threejs/skills/threejs-loaders/SKILL.md
Normal file
519
plugins/suaveplan-threejs/skills/threejs-loaders/SKILL.md
Normal 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
|
||||
579
plugins/suaveplan-threejs/skills/threejs-materials/SKILL.md
Normal file
579
plugins/suaveplan-threejs/skills/threejs-materials/SKILL.md
Normal 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
|
||||
576
plugins/suaveplan-threejs/skills/threejs-postprocessing/SKILL.md
Normal file
576
plugins/suaveplan-threejs/skills/threejs-postprocessing/SKILL.md
Normal 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
|
||||
572
plugins/suaveplan-threejs/skills/threejs-shaders/SKILL.md
Normal file
572
plugins/suaveplan-threejs/skills/threejs-shaders/SKILL.md
Normal 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)
|
||||
487
plugins/suaveplan-threejs/skills/threejs-textures/SKILL.md
Normal file
487
plugins/suaveplan-threejs/skills/threejs-textures/SKILL.md
Normal 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
|
||||
Reference in New Issue
Block a user