web-3d-react-three-fiber · diff
git:20260709.68e20a4 to git:20260906.d80c3e7
202 added, 381 removed. Audit A to A.
---
name: web-3d-react-three-fiber
description: React Three Fiber (R3F) 3D rendering — Canvas, meshes, materials, lights, cameras, animations, events, physics, post-processing, performance
---
# React Three Fiber Patterns
- > **Quick Guide:** R3F is a React renderer for Three.js. Every Three.js class maps to a JSX element (`<mesh>`, `<boxGeometry>`, `<meshStandardMaterial>`). Use `<Canvas>` for scene setup, `useFrame` for per-frame logic (never setState inside it), `useRef` for direct mutations, and `useLoader`/`useGLTF` for assets. Animate via refs in `useFrame`, not React state. Events work like DOM events with raycasting built in. Wrap exiting 3D components in `<Suspense>` for async asset loading.
+ > **Quick Guide:** R3F is a React reconciler for Three.js, so the React tree is the scene graph and every Three.js class is a camelCase JSX element — `<mesh>`, `<boxGeometry>`, `<meshStandardMaterial>`. `<Canvas>` creates the renderer, scene and camera, and every R3F hook must be called inside it. Per-frame work goes through `useFrame` mutating refs, never through state; structural changes go through state as usual. Assets load through `useLoader`/`useGLTF`, which suspend, so their components need a `<Suspense>` boundary. Pointer events raycast into the scene and reach occluded objects unless `stopPropagation()` is called.
> **Import:** `import { Canvas, useFrame, useThree, useLoader } from "@react-three/fiber"`
+ **Detailed Resources:**
+
+ - [examples/core.md](examples/core.md) — Canvas setup, lighting rigs, `useFrame` animation, asset loading, drei helpers, physics
+ - [examples/interaction.md](examples/interaction.md) — the event object, propagation, hover, click-versus-drag, pointer capture, the full event list
+ - [examples/performance.md](examples/performance.md) — instancing, LOD, on-demand rendering, resource sharing, disposal, adaptive quality
+ - [reference.md](reference.md) — Canvas props, hook signatures, the event type, ecosystem packages, Three.js-to-JSX mapping, collider types
+
---
<critical_requirements>
- ## CRITICAL: Before Using This Skill
-
- > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
+ ## Before writing React Three Fiber code
- **(You MUST never call setState inside useFrame -- mutate refs directly for per-frame updates)**
+ **Do per-frame work by mutating refs inside `useFrame`, and allocate the objects it needs outside it.** State in the frame loop re-renders React sixty times a second, and `new THREE.Vector3()` inside it allocates sixty objects a second for the garbage collector to reclaim.
- **(You MUST wrap `<Canvas>` children that load assets in `<Suspense>` boundaries)**
+ **Multiply per-frame motion by `delta`.** The callback's second argument is seconds since the last frame, which is what makes a rotation the same speed on a 60Hz and a 144Hz display.
- **(You MUST reuse geometries and materials across meshes -- creating new instances per mesh wastes GPU memory)**
+ **Put a `<Suspense>` boundary above anything calling `useLoader` or `useGLTF`.** Those hooks suspend while the asset downloads, and a suspending component with no boundary above it takes the tree down.
- **(You MUST call `event.stopPropagation()` on pointer events to prevent hits passing through to occluded objects)**
+ **Share geometries and materials across meshes that use the same ones.** They are GPU allocations, and R3F does not deduplicate declarative children — twenty-five `<sphereGeometry>` elements are twenty-five uploads.
- **(You MUST use named constants for all numeric values -- positions, sizes, speeds, colors -- NO magic numbers)**
+ **Call `event.stopPropagation()` in pointer handlers.** A raycast returns every intersection along the ray, so without it a click reaches the objects behind the one that was clicked.
</critical_requirements>
---
- **Auto-detection:** React Three Fiber, R3F, @react-three/fiber, @react-three/drei, @react-three/rapier, @react-three/postprocessing, Canvas, useFrame, useThree, useLoader, useGLTF, mesh, boxGeometry, meshStandardMaterial, OrbitControls, drei, three.js, 3D scene, WebGL, instancedMesh
+ **Auto-detection:** @react-three/fiber, @react-three/drei, @react-three/rapier, @react-three/postprocessing, R3F, Canvas, useFrame, useThree, useLoader, useGLTF, useGraph, invalidate, frameloop, instancedMesh, boxGeometry, meshStandardMaterial, OrbitControls, Environment, Detailed, RigidBody, CuboidCollider, EffectComposer, onPointerMissed, three
- **When to use:**
+ **Applies to:**
- - Building 3D scenes, visualizations, or experiences in React
- - Loading and displaying 3D models (GLTF, OBJ, FBX)
- - Adding physics simulation to 3D objects
- - Handling pointer/click interactions on 3D meshes
- - Animating objects per-frame (rotation, position, scale)
- - Applying post-processing effects (bloom, depth of field, SSAO)
+ - 3D scenes, product viewers, visualizations and interactive experiences in React
+ - Loading and displaying models (GLTF, Draco-compressed GLTF, textures)
+ - Per-frame animation, and interaction-driven state changes
+ - Pointer events, raycasting and drag on 3D objects
+ - Physics simulation, colliders, and collision or trigger events
+ - Post-processing effect chains
+ - Scaling a scene: instancing, level of detail, on-demand rendering, adaptive quality
- **When NOT to use:**
+ **Handled elsewhere:**
- - 2D-only UIs (standard React components)
- - Static images of 3D content (pre-render instead)
- - Performance-critical scenarios where raw Three.js without React overhead is needed
+ - 2D interface work — HTML overlays anchored to a 3D position are this skill's `Html` helper, but the markup inside them is ordinary UI
+ - Pre-rendering a 3D scene to a static image at build time
+ - Which colours, fonts and materials a product uses — every one of these components takes them as props
+ - Accessibility conformance targets — a canvas is one element to assistive technology, so a scene's controls need an accessible equivalent, and what that must satisfy is settled elsewhere
- **Key patterns covered:**
+ ---
- - Canvas setup with camera, shadows, and renderer config
- - Declarative meshes, geometries, materials, and lights
- - Per-frame animation with `useFrame` and refs
- - Pointer events, raycasting, and event propagation
- - Asset loading with `useLoader`, `useGLTF`, and Suspense
- - Drei helpers (OrbitControls, Environment, Text, Html, Detailed)
- - Physics with `@react-three/rapier` (RigidBody, colliders, collision events)
- - Post-processing with `@react-three/postprocessing`
- - Performance: instancing, LOD, on-demand rendering, geometry reuse, disposal
+ <philosophy>
- ---
+ **The React tree is the scene graph.** Mounting a component adds a mesh; unmounting removes and disposes it. Suspense, context and refs all work in 3D exactly as they do in the DOM, because there is one reconciler doing both.
- **Detailed Resources:**
+ The split that matters is **refs for mutation, state for structure**. Position, rotation and scale change every frame and belong to refs, where React never sees them. Which objects exist, and whether one is selected, are structure and belong to state. Getting this backwards — animating through state — is the single most common way an R3F scene becomes slow, and it looks correct until the frame counter is opened.
- - [examples/core.md](examples/core.md) - Canvas, meshes, materials, lights, camera, useFrame, asset loading, drei helpers
- - [examples/interaction.md](examples/interaction.md) - Events, raycasting, hover/click, drag, pointer capture
- - [examples/performance.md](examples/performance.md) - Instancing, LOD, disposal, frame loop control, on-demand rendering
- - [reference.md](reference.md) - Decision frameworks, Canvas props, hook signatures, anti-patterns
+ </philosophy>
---
- <philosophy>
+ <decision_framework>
- ## Philosophy
+ ## How to animate
- React Three Fiber is a React reconciler for Three.js -- every Three.js object becomes a declarative JSX element. The React tree IS the scene graph. Components mount/unmount meshes, lights, and cameras just like DOM elements. This means React features (Suspense, context, refs, state) all work naturally in 3D.
+ ```
+ Continuous per-frame motion (spin, bob, orbit)
+ -> useFrame mutating a ref; multiply by delta
+ Discrete change from an interaction (hover colour, selected scale)
+ -> React state; it happens once, not per frame
+ One-time entrance
+ -> useFrame with a progress ref clamped at 1, or a spring-based
+ animation approach driving the same refs
+ ```
- **Core principles:**
+ ## Which collider
- 1. **Declarative scene graph** -- describe WHAT the scene looks like, not HOW to build it imperatively
- 2. **Refs for mutations, state for structure** -- per-frame updates go through `useRef` in `useFrame`, structural changes (adding/removing objects) go through React state
- 3. **Reuse everything** -- geometries, materials, and textures are GPU resources; share them across meshes
- 4. **Suspense for async** -- wrap asset-loading components in `<Suspense>` for automatic loading states
- 5. **Events are raycasted** -- pointer events automatically raycast into the scene; `stopPropagation` prevents hits on occluded objects
+ ```
+ Box -> "cuboid" fastest
+ Sphere -> "ball" fast
+ Convex shape -> "hull" good balance
+ Concave shape -> "trimesh" expensive; use sparingly
+ ```
- **The R3F ecosystem:**
+ ## How to scale a scene
- | Package | Purpose |
- | ----------------------------- | --------------------------------------------------------------- |
- | `@react-three/fiber` | Core renderer -- Canvas, hooks, reconciler |
- | `@react-three/drei` | Helpers -- controls, loaders, abstractions, text, HTML overlays |
- | `@react-three/rapier` | Physics -- rigid bodies, colliders, collision events |
- | `@react-three/postprocessing` | Effects -- bloom, DOF, SSAO, vignette |
+ ```
+ Many identical objects -> instancedMesh, or drei's <Instances>
+ Many different static meshes -> merge their geometries into one mesh
+ Objects seen at varying range -> LOD via <Detailed>
+ Mostly static scene -> frameloop="demand" plus invalidate()
+ Frame rate varies by device -> PerformanceMonitor driving dpr
+ Otherwise -> count draw calls; each <mesh> is one
+ ```
- </philosophy>
+ </decision_framework>
---
<patterns>
- ## Core Patterns
+ ## Core patterns
- ### Pattern 1: Canvas and Scene Setup
+ ### Pattern 1: Canvas and scene setup
- `<Canvas>` creates a WebGL context with scene, camera, and renderer. All R3F hooks must be used inside Canvas.
+ `<Canvas>` builds the renderer, scene and camera, and establishes the context every R3F hook reads.
```tsx
- import { Canvas } from "@react-three/fiber";
-
- const CAMERA_FOV = 50;
- const CAMERA_POSITION: [number, number, number] = [0, 2, 5];
-
- export function Scene() {
- return (
- <Canvas
- camera={{
- fov: CAMERA_FOV,
- position: CAMERA_POSITION,
- near: 0.1,
- far: 100,
- }}
- shadows
- dpr={[1, 2]}
- frameloop="always"
- >
- <ambientLight intensity={0.5} />
- <directionalLight position={[5, 5, 5]} castShadow />
- <mesh castShadow receiveShadow>
- <boxGeometry args={[1, 1, 1]} />
- <meshStandardMaterial color="orange" />
- </mesh>
- </Canvas>
- );
- }
+ <Canvas
+ camera={{ fov: CAMERA_FOV, position: CAMERA_POSITION, near: 0.1, far: 100 }}
+ shadows
+ dpr={[1, 2]} // clamped: an unbounded ratio renders 3x the pixels on a HiDPI screen
+ >
+ <ambientLight intensity={0.5} />
+ <directionalLight position={[5, 5, 5]} castShadow />
+ <mesh castShadow receiveShadow>
+ <boxGeometry args={[1, 1, 1]} />
+ <meshStandardMaterial color="orange" />
+ </mesh>
+ </Canvas>
```
- **Why good:** named position constant, shadows enabled on canvas + individual meshes, dpr clamped to prevent excessive resolution on HiDPI displays
+ Shadows are opt-in twice: on the Canvas, and per mesh via `castShadow`/`receiveShadow`.
- See [examples/core.md](examples/core.md) Pattern 1 for full Canvas config, lighting setups, and camera types.
+ Full code: [examples/core.md](examples/core.md)
---
- ### Pattern 2: Per-Frame Animation with useFrame
+ ### Pattern 2: Per-frame animation with useFrame
- `useFrame` runs every frame before render. Mutate refs directly -- never call setState.
+ The callback runs before every render. Write to the ref; do not touch state.
```tsx
- import { useRef } from "react";
- import { useFrame } from "@react-three/fiber";
- import type { Mesh } from "three";
-
- const ROTATION_SPEED = 1;
-
- export function SpinningBox() {
- const meshRef = useRef<Mesh>(null);
-
- useFrame((state, delta) => {
- if (!meshRef.current) return;
- meshRef.current.rotation.y += ROTATION_SPEED * delta;
- });
-
- return (
- <mesh ref={meshRef}>
- <boxGeometry args={[1, 1, 1]} />
- <meshStandardMaterial color="royalblue" />
- </mesh>
- );
- }
- ```
+ const meshRef = useRef<Mesh>(null);
- **Why good:** delta-time multiplication makes animation frame-rate independent, ref mutation avoids re-renders, guard clause prevents null access
+ useFrame((state, delta) => {
+ if (!meshRef.current) return;
+ meshRef.current.rotation.y += ROTATION_SPEED * delta; // frame-rate independent
+ meshRef.current.position.y =
+ Math.sin(state.clock.elapsedTime) * BOB_AMPLITUDE;
+ });
- ```tsx
- // BAD: triggers re-render every frame -- destroys performance
- function BadSpinningBox() {
- const [rotation, setRotation] = useState(0);
- useFrame((_, delta) => {
- setRotation((r) => r + delta); // setState in useFrame!
- });
- return <mesh rotation-y={rotation} />;
- }
+ return (
+ <mesh ref={meshRef}>
+ <boxGeometry args={[1, 1, 1]} />
+ <meshStandardMaterial color="royalblue" />
+ </mesh>
+ );
```
- **Why bad:** setState in useFrame causes a React re-render every frame (~60/s), defeating the purpose of direct GPU mutations
+ `delta` drives motion that accumulates; `state.clock.elapsedTime` drives motion that is a function of absolute time, such as a sine wave.
- See [examples/core.md](examples/core.md) Pattern 2 for animation with useFrame, clock-based motion, and conditional animation.
+ Full code: [examples/core.md](examples/core.md)
---
- ### Pattern 3: Asset Loading with Suspense
+ ### Pattern 3: Asset loading with Suspense
- Use `useLoader` or drei's `useGLTF` to load models, textures, and other assets. Always wrap in `<Suspense>`.
+ `useGLTF` returns the parsed `nodes` and `materials`, and suspends until the file has loaded.
```tsx
- import { Suspense } from "react";
- import { useGLTF } from "@react-three/drei";
-
function Model({ url }: { url: string }) {
const { nodes, materials } = useGLTF(url);
- return (
- <mesh
- geometry={(nodes.myMesh as THREE.Mesh).geometry}
- material={materials.myMaterial}
- />
- );
+ return <mesh geometry={nodes.body.geometry} material={materials.paint} />;
}
- // Preload for faster initial render
- useGLTF.preload("/model.glb");
+ useGLTF.preload("/model.glb"); // starts the fetch before the component mounts
- export function SceneWithModel() {
- return (
- <Canvas>
- <Suspense fallback={null}>
- <Model url="/model.glb" />
- </Suspense>
- </Canvas>
- );
- }
+ <Canvas>
+ <Suspense fallback={null}>
+ <Model url="/model.glb" />
+ </Suspense>
+ </Canvas>;
```
- **Why good:** Suspense handles loading states automatically, preload avoids waterfall, useGLTF extracts named nodes/materials
-
- See [examples/core.md](examples/core.md) Pattern 3 for texture loading, Draco compression, and progressive loading.
+ Full code: [examples/core.md](examples/core.md)
---
- ### Pattern 4: Pointer Events and Raycasting
+ ### Pattern 4: Pointer events and raycasting
- R3F meshes support DOM-like pointer events. Events are raycasted -- the nearest hit object receives the event first.
+ Meshes take DOM-shaped pointer handlers. The event carries the intersection — hit point, distance, face and the full list of hits.
```tsx
- const HOVER_COLOR = "hotpink";
- const DEFAULT_COLOR = "orange";
- const ACTIVE_SCALE = 1.2;
- const DEFAULT_SCALE = 1;
-
- export function InteractiveBox() {
- const [hovered, setHovered] = useState(false);
- const [active, setActive] = useState(false);
-
- return (
- <mesh
- scale={active ? ACTIVE_SCALE : DEFAULT_SCALE}
- onClick={(e) => {
- e.stopPropagation();
- setActive((a) => !a);
- }}
- onPointerOver={(e) => {
- e.stopPropagation();
- setHovered(true);
- }}
- onPointerOut={() => setHovered(false)}
- >
- <boxGeometry args={[1, 1, 1]} />
- <meshStandardMaterial color={hovered ? HOVER_COLOR : DEFAULT_COLOR} />
- </mesh>
- );
- }
+ <mesh
+ scale={active ? ACTIVE_SCALE : DEFAULT_SCALE}
+ onClick={(e) => {
+ e.stopPropagation(); // otherwise objects behind this one are also clicked
+ setActive((a) => !a);
+ }}
+ onPointerOver={(e) => {
+ e.stopPropagation();
+ setHovered(true);
+ }}
+ onPointerOut={() => setHovered(false)}
+ >
+ <boxGeometry args={[1, 1, 1]} />
+ <meshStandardMaterial color={hovered ? HOVER_COLOR : DEFAULT_COLOR} />
+ </mesh>
```
- **Why good:** stopPropagation prevents clicks passing through to objects behind, hover state uses React state (not per-frame), named color constants
+ Hover and selection are discrete, so React state is right here — this is not per-frame work.
- See [examples/interaction.md](examples/interaction.md) for event object properties, pointer capture, drag, and onPointerMissed.
+ Full code: [examples/interaction.md](examples/interaction.md)
---
- ### Pattern 5: Drei Helpers
+ ### Pattern 5: Drei helpers
- `@react-three/drei` provides ready-made abstractions for common tasks.
+ `@react-three/drei` supplies the abstractions almost every scene needs.
```tsx
- import { OrbitControls, Environment, Text, Html } from "@react-three/drei";
-
- // Camera controls
- <OrbitControls enableDamping dampingFactor={0.1} />
-
- // Environment lighting from HDRI preset
+ <OrbitControls enableDamping dampingFactor={0.1} maxPolarAngle={Math.PI / 2} />
<Environment preset="sunset" background />
-
- // 3D text rendered as mesh geometry
- <Text fontSize={0.5} position={[0, 2, 0]} color="white">
- Hello 3D World
- </Text>
-
- // HTML overlaid on 3D position
+ <Text fontSize={0.5} position={[0, 2, 0]} anchorX="center">Hello</Text>
<Html position={[1, 1, 0]} distanceFactor={10}>
<div className="tooltip">Click me</div>
</Html>
```
- See [examples/core.md](examples/core.md) Pattern 5 for full drei helper examples including Detailed (LOD), ContactShadows, and Float.
+ `<Environment>` replaces a hand-built lighting rig with an HDRI, which is usually the faster route to plausible lighting.
+ Full code: [examples/core.md](examples/core.md)
+
---
### Pattern 6: Physics with @react-three/rapier
- Wrap the scene in `<Physics>` and objects in `<RigidBody>` for physics simulation.
+ `<Physics>` runs the simulation; `<RigidBody>` hands it the meshes it wraps.
```tsx
- import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
-
- const GRAVITY: [number, number, number] = [0, -9.81, 0];
- const FLOOR_SIZE: [number, number, number] = [10, 0.1, 10];
-
- export function PhysicsScene() {
- return (
- <Physics gravity={GRAVITY}>
- {/* Dynamic falling box */}
- <RigidBody colliders="cuboid" restitution={0.5}>
- <mesh>
- <boxGeometry args={[1, 1, 1]} />
- <meshStandardMaterial color="tomato" />
- </mesh>
- </RigidBody>
+ <Physics gravity={GRAVITY}>
+ <RigidBody colliders="cuboid" restitution={0.5}>
+ <mesh>
+ <boxGeometry args={[1, 1, 1]} />
+ <meshStandardMaterial color="tomato" />
+ </mesh>
+ </RigidBody>
- {/* Static floor */}
- <RigidBody type="fixed">
- <mesh>
- <boxGeometry args={FLOOR_SIZE} />
- <meshStandardMaterial color="gray" />
- </mesh>
- </RigidBody>
- </Physics>
- );
- }
+ <RigidBody type="fixed">
+ <mesh>
+ <boxGeometry args={FLOOR_SIZE} />
+ <meshStandardMaterial color="gray" />
+ </mesh>
+ </RigidBody>
+ </Physics>
```
- **Why good:** gravity as named constant, explicit collider type, fixed body for static geometry
+ `type="fixed"` is what keeps the floor from falling. Rapier loads its WASM asynchronously, so `<Physics>` also needs a Suspense boundary.
- See [examples/core.md](examples/core.md) Pattern 6 for collision events, sensors, and InstancedRigidBodies.
+ Full code: [examples/core.md](examples/core.md)
---
- ### Pattern 7: Post-Processing Effects
+ ### Pattern 7: Post-processing effects
- `@react-three/postprocessing` merges effects into efficient render passes.
+ `EffectComposer` merges compatible effects into one pass rather than chaining a pass per effect.
```tsx
- import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing";
-
- const BLOOM_INTENSITY = 0.5;
- const BLOOM_LUMINANCE_THRESHOLD = 0.9;
- const VIGNETTE_DARKNESS = 0.5;
-
<EffectComposer>
- <Bloom
- intensity={BLOOM_INTENSITY}
- luminanceThreshold={BLOOM_LUMINANCE_THRESHOLD}
- />
+ <Bloom intensity={BLOOM_INTENSITY} luminanceThreshold={BLOOM_THRESHOLD} />
<Vignette darkness={VIGNETTE_DARKNESS} />
- </EffectComposer>;
+ </EffectComposer>
```
- See [reference.md](reference.md) for common effect combinations and performance considerations.
+ Full code: [reference.md](reference.md)
---
- ### Pattern 8: Instancing for Many Objects
+ ### Pattern 8: Instancing for many objects
- Use `<instancedMesh>` to render thousands of identical objects in a single draw call.
+ One `<instancedMesh>` draws thousands of copies of one geometry in a single call. Positions are written as matrices.
```tsx
- import { useRef, useEffect, useMemo } from "react";
- import { useFrame } from "@react-three/fiber";
- import * as THREE from "three";
-
- const INSTANCE_COUNT = 1000;
-
- export function Particles() {
- const meshRef = useRef<THREE.InstancedMesh>(null);
- const dummy = useMemo(() => new THREE.Object3D(), []);
+ const dummy = useMemo(() => new THREE.Object3D(), []); // allocated once
- useEffect(() => {
- if (!meshRef.current) return;
- for (let i = 0; i < INSTANCE_COUNT; i++) {
- dummy.position.set(
- (Math.random() - 0.5) * 10,
- (Math.random() - 0.5) * 10,
- (Math.random() - 0.5) * 10,
- );
- dummy.updateMatrix();
- meshRef.current.setMatrixAt(i, dummy.matrix);
- }
- meshRef.current.instanceMatrix.needsUpdate = true;
- }, [dummy]);
+ useEffect(() => {
+ for (let i = 0; i < INSTANCE_COUNT; i++) {
+ dummy.position.set(x, y, z);
+ dummy.updateMatrix();
+ meshRef.current.setMatrixAt(i, dummy.matrix);
+ }
+ meshRef.current.instanceMatrix.needsUpdate = true; // without this, nothing moves
+ }, [dummy]);
- return (
- <instancedMesh ref={meshRef} args={[undefined, undefined, INSTANCE_COUNT]}>
- <sphereGeometry args={[0.05, 8, 8]} />
- <meshBasicMaterial color="white" />
- </instancedMesh>
- );
- }
+ <instancedMesh ref={meshRef} args={[undefined, undefined, INSTANCE_COUNT]}>
+ <sphereGeometry args={[0.05, 8, 8]} />
+ <meshBasicMaterial color="white" />
+ </instancedMesh>;
```
- **Why good:** single draw call for 1000 objects, useMemo prevents recreating dummy each render, named count constant
-
- See [examples/performance.md](examples/performance.md) for animated instances, LOD with Detailed, and on-demand rendering.
+ Full code: [examples/performance.md](examples/performance.md)
---
- ### Pattern 9: Geometry and Material Reuse
+ ### Pattern 9: Sharing geometries and materials
- Create shared resources and reference them across meshes to reduce GPU overhead.
+ Build the resource once and hand the same instance to every mesh that needs it.
```tsx
- import * as THREE from "three";
- import { useMemo } from "react";
-
- export function SharedGeometryScene() {
- const sharedGeo = useMemo(() => new THREE.SphereGeometry(0.5, 32, 32), []);
- const sharedMat = useMemo(
- () => new THREE.MeshStandardMaterial({ color: "coral" }),
- [],
- );
+ const sharedGeo = useMemo(() => new THREE.SphereGeometry(0.5, 32, 32), []);
+ const sharedMat = useMemo(
+ () => new THREE.MeshStandardMaterial({ color: "coral" }),
+ [],
+ );
- return (
- <>
- <mesh geometry={sharedGeo} material={sharedMat} position={[-2, 0, 0]} />
- <mesh geometry={sharedGeo} material={sharedMat} position={[0, 0, 0]} />
- <mesh geometry={sharedGeo} material={sharedMat} position={[2, 0, 0]} />
- </>
- );
- }
+ <>
+ <mesh geometry={sharedGeo} material={sharedMat} position={[-2, 0, 0]} />
+ <mesh geometry={sharedGeo} material={sharedMat} position={[0, 0, 0]} />
+ <mesh geometry={sharedGeo} material={sharedMat} position={[2, 0, 0]} />
+ </>;
```
- **Why good:** one geometry + one material in GPU memory regardless of mesh count, useMemo prevents recreation on re-render
+ One geometry and one material on the GPU whatever the mesh count. Objects built with `new` this way are also outside R3F's automatic disposal, so they need disposing on unmount.
- See [examples/performance.md](examples/performance.md) for disposal patterns and PerformanceMonitor.
+ Full code: [examples/performance.md](examples/performance.md)
</patterns>
---
- <decision_framework>
-
- ## Decision Framework
-
- ### Choosing an Animation Approach
-
- ```
- Is it a per-frame continuous animation (rotation, bob, orbit)?
- ├─ YES → useFrame + useRef (mutate directly, never setState)
- └─ NO → Is it triggered by user interaction (hover, click)?
- ├─ YES → React state for discrete changes (scale, color)
- │ or spring-based animation libraries for smooth transitions
- └─ NO → Is it a one-time entrance animation?
- └─ YES → useFrame with a progress ref that clamps at 1.0
- ```
-
- ### Choosing a Collider Type
-
- ```
- Is the shape a box?
- ├─ YES → "cuboid" (fastest)
- └─ NO → Is it a sphere?
- ├─ YES → "ball" (fast)
- └─ NO → Is it convex (no holes/concavities)?
- ├─ YES → "hull" (good balance)
- └─ NO → "trimesh" (expensive, use sparingly)
- ```
-
- ### Performance Scaling
-
- ```
- Are there 10+ identical objects?
- ├─ YES → instancedMesh (single draw call)
- └─ NO → Are objects at varying distances?
- ├─ YES → LOD with drei's Detailed component
- └─ NO → Is the scene mostly static?
- ├─ YES → frameloop="demand" + invalidate()
- └─ NO → Check draw calls (target < 200)
- ```
-
- </decision_framework>
-
- ---
-
<red_flags>
- ## RED FLAGS
-
- **High Priority Issues:**
-
- - Calling `setState` inside `useFrame` -- causes 60 re-renders/second, destroys performance
- - Creating new `Vector3`/`Matrix4`/`Object3D` instances inside `useFrame` -- allocates memory every frame, triggers GC pauses
- - Missing `<Suspense>` around components using `useLoader`/`useGLTF` -- causes uncaught promise errors
- - Duplicate geometries/materials across identical meshes -- wastes GPU memory; share via `useMemo` or module-level instances
- - Missing `event.stopPropagation()` on pointer events -- clicks pass through to occluded objects unexpectedly
+ ## Red flags
- **Medium Priority Issues:**
+ **Breaks at runtime:**
- - Using `frameloop="always"` for mostly-static scenes -- drains battery; use `"demand"` with `invalidate()`
- - More than ~1000 draw calls (each `<mesh>` is a draw call) -- use instancing or merge geometries
- - Not disposing of geometries/materials on unmount -- GPU memory leak (R3F auto-disposes on unmount by default, but manual Three.js objects need explicit cleanup)
- - Animating layout-triggering CSS on the Canvas container -- causes reflow; use fixed dimensions
+ - `new THREE.Vector3()`, `Matrix4` or `Object3D` inside `useFrame` — an allocation per frame, and the GC pauses show up as stutter
+ - `<Physics>` with no `<Suspense>` above it — Rapier's WASM loads asynchronously
+ - An R3F hook called outside `<Canvas>` — `useFrame`, `useThree` and `useLoader` all read the fiber context the Canvas provides
+ - A manually constructed Three.js object never disposed — R3F auto-disposes what it created declaratively and nothing else
+ - `setMatrixAt` without setting `instanceMatrix.needsUpdate = true` — the buffer is never re-uploaded and the instances do not move
+ - Unbounded `dpr` on a HiDPI display — three times the pixels for no visible gain; clamp with `dpr={[1, 2]}`
- **Gotchas & Edge Cases:**
+ **Surprising behaviour:**
- - All R3F hooks (`useFrame`, `useThree`, `useLoader`) must be called inside `<Canvas>` -- they depend on fiber context
- - `useFrame` callbacks with `renderPriority >= 1` take over the render loop -- you must call `gl.render()` manually
- - `useThree` selectors for Three.js internal properties (like `camera.zoom`) are NOT reactive -- use `invalidate()` after imperative changes
- - Three.js uses a Y-up coordinate system -- `position={[x, y, z]}` where Y is vertical
- - `<instancedMesh args={[null, null, count]}>` -- the first two args (geometry, material) should be `undefined` when using child elements
- - `onPointerMissed` fires on the Canvas element for clicks that hit no mesh -- useful for deselection
- - R3F automatically disposes Three.js resources on unmount, but only for objects it created declaratively -- manual `new THREE.*()` calls need manual `.dispose()`
- - `<Physics>` from rapier must be wrapped in `<Suspense>` because it loads WASM asynchronously
- - Event `delta` property is mouse-down-to-mouse-up distance in pixels -- useful for distinguishing clicks from drags
+ - `useThree` selectors over Three.js internals such as `camera.zoom` are not reactive, because mutating a Three.js object does not notify React — call `invalidate()` after an imperative change
+ - A `useFrame` callback with `renderPriority >= 1` takes over the render loop, and must then call `gl.render()` itself
+ - `event.object` is the mesh the ray hit; `event.eventObject` is the one carrying the handler, which may be an ancestor
+ - `event.delta` is the mousedown-to-mouseup distance in pixels, which is how a click is told from the end of an orbit drag
+ - `onPointerOver`/`onPointerOut` bubble to ancestors; `onPointerEnter`/`onPointerLeave` do not
+ - `onPointerMissed` fires on the Canvas for a click that hit nothing, which is the deselection hook
+ - `<instancedMesh args={[undefined, undefined, count]}>` — the first two constructor arguments are left undefined when geometry and material are supplied as children
+ - Three.js is Y-up, so the vertical axis is the middle number in `position={[x, y, z]}`
+ - `frameloop="always"` renders continuously even when nothing has changed, which drains battery on a static scene
+ - Animating the Canvas container's width or height — the Canvas measures its container and resizes the renderer on every change, so a size transition rebuilds the drawing buffer each frame; give the container a settled size and animate a transform instead
</red_flags>
-
- ---
-
- <critical_reminders>
-
- ## CRITICAL REMINDERS
-
- > **All code must follow project conventions in CLAUDE.md**
-
- **(You MUST never call setState inside useFrame -- mutate refs directly for per-frame updates)**
-
- **(You MUST wrap `<Canvas>` children that load assets in `<Suspense>` boundaries)**
-
- **(You MUST reuse geometries and materials across meshes -- creating new instances per mesh wastes GPU memory)**
-
- **(You MUST call `event.stopPropagation()` on pointer events to prevent hits passing through to occluded objects)**
-
- **(You MUST use named constants for all numeric values -- positions, sizes, speeds, colors -- NO magic numbers)**
-
- **Failure to follow these rules will cause frame drops, memory leaks, broken interactions, and poor 3D performance.**
-
- </critical_reminders>