EmpireUI
Get Pro
← Blog9 min read#r3f#drei#react-three-fiber

react-three-fiber + Drei: Cameras, Controls, Helpers and Loaders

Cut through react-three-fiber boilerplate with Drei. Cameras, OrbitControls, GLTF loaders, and environment helpers — all explained with real code.

Abstract 3D geometric shapes floating in dark digital space

Why Drei Exists and Why You Should Care

If you've spent more than 20 minutes in react-three-fiber, you've written useRef, attached it to a <perspectiveCamera>, then fought Three.js's imperative camera API from inside a declarative React tree. It works. It's also miserable. That's the exact gap Drei fills.

Drei (German for "three", cute) is the official helper library for r3f. It launched alongside r3f v4 back when the ecosystem was still figuring itself out, and by 2024 it had grown into 80+ components covering cameras, controls, loaders, shaders, and environment maps. You don't *have* to use it, but skipping it means reimplementing things that are already solved.

In practice, Drei doesn't lock you in. Every component is individually importable, tree-shakeable, and built on the same Three.js primitives you already know. It's more like a toolbox you pull from than a framework you commit to.

This guide focuses on the four areas that trip people up most: camera setup, orbit/pointer controls, scene helpers, and asset loading. We'll skip the toy examples and go straight to patterns you'd actually ship.

Camera Setup: PerspectiveCamera vs OrthographicCamera

By default, r3f creates a PerspectiveCamera at position [0, 0, 5] with a 75-degree FOV. That gets you started, but you'll want control over where the camera is, what it's looking at, and how it responds to canvas resizes. Drei's <PerspectiveCamera> and <OrthographicCamera> components handle all of that declaratively.

The makeDefault prop is the key one. Without it, your camera component just creates a Three.js camera object and does nothing with it — r3f keeps using its internal default. Set makeDefault and r3f swaps it in as the active camera immediately.

import { Canvas } from '@react-three/fiber'
import { PerspectiveCamera, OrthographicCamera } from '@react-three/drei'

// Perspective — good for 3D scenes
function Scene3D() {
  return (
    <Canvas>
      <PerspectiveCamera
        makeDefault
        position={[0, 2, 8]}
        fov={60}
        near={0.1}
        far={1000}
      />
      {/* your scene */}
    </Canvas>
  )
}

// Orthographic — good for UI overlays, 2D games, isometric views
function Scene2D() {
  return (
    <Canvas orthographic>
      <OrthographicCamera
        makeDefault
        zoom={50}
        position={[0, 0, 100]}
      />
      {/* your scene */}
    </Canvas>
  )
}

Worth noting: the zoom prop on <OrthographicCamera> maps directly to Three.js's camera.zoom. A value of 50 means 1 unit in Three.js space equals 50 pixels on screen. If your orthographic scene looks tiny or enormous, that's the first thing to tweak.

Quick aside: if you need to drive the camera from outside React (say, from a GSAP scroll trigger), grab the camera with useThree: const { camera } = useThree(). The Drei camera component and useThree share the same reference, so animating camera.position directly still works fine.

Controls: OrbitControls, FlyControls, and Pointer Events

Orbit controls are the first thing everyone reaches for, and Drei's version is a one-liner. It wraps Three.js's OrbitControls module, auto-attaches to the canvas's domElement, and syncs with r3f's render loop. No manual setup, no requestAnimationFrame, no forgetting to call .dispose() on unmount — Drei handles it.

import { OrbitControls } from '@react-three/drei'

function Scene() {
  return (
    <Canvas>
      <OrbitControls
        enablePan={true}
        enableZoom={true}
        enableRotate={true}
        minDistance={2}
        maxDistance={20}
        maxPolarAngle={Math.PI / 2} // lock at horizon
      />
      {/* meshes */}
    </Canvas>
  )
}

Honestly, the maxPolarAngle prop alone saves most people 30 minutes of Googling. Set it to Math.PI / 2 and users can't orbit below the ground plane — critical for any scene with a floor.

For first-person navigation, <PointerLockControls> is the one you want. It locks the cursor and gives WASD-style mouse-look. Combined with r3f's useFrame, you can build a basic FPS camera in under 50 lines. <FlyControls> is similar but doesn't lock the pointer, which is better for editing tools.

import { PointerLockControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { useRef } from 'react'

function FPSCamera() {
  const { camera } = useThree()
  const controlsRef = useRef()

  return (
    <>
      <PointerLockControls ref={controlsRef} />
      {/* click the canvas to engage pointer lock */}
    </>
  )
}

One more thing — if you're building something interactive where 3D controls and regular DOM events need to coexist (like a draggable overlay on top of a 3D scene), pass makeDefault={false} and manage event propagation yourself. Otherwise OrbitControls will eat every pointer event on the canvas.

Scene Helpers: Grid, Axes, Stats, and BVH

Development helpers are where Drei really shines over raw Three.js. Three.js has GridHelper and AxesHelper, but they're both imperative — you create them, add them to the scene, and remember to remove them before shipping. Drei wraps them as React components you can conditionally render with a simple flag.

import { Grid, GizmoHelper, GizmoViewport, Stats } from '@react-three/drei'

const isDev = process.env.NODE_ENV === 'development'

function DevHelpers() {
  if (!isDev) return null

  return (
    <>
      {/* A 10x10 unit grid at y=0 */}
      <Grid
        position={[0, -0.01, 0]}
        args={[10, 10]}
        cellSize={0.5}
        cellThickness={1}
        cellColor="#6f6f6f"
        sectionSize={3}
        sectionThickness={1.5}
        sectionColor="#9d4b4b"
        fadeDistance={25}
        fadeStrength={1}
        followCamera={false}
        infiniteGrid={true}
      />

      {/* FPS counter, draw calls, geometry count */}
      <Stats />

      {/* Orientation gizmo, top-right corner */}
      <GizmoHelper alignment="bottom-right" margin={[80, 80]}>
        <GizmoViewport
          axisColors={['#9d4b4b', '#2f7f4f', '#3b5b9d']}
          labelColor="white"
        />
      </GizmoHelper>
    </>
  )
}

The <Stats> component deserves a mention on its own. It renders a Three.js Stats panel (the classic FPS counter from the mrdoob era) in the top-left corner. Zero config. If you're profiling a complex scene with lots of draw calls, it's the fastest way to see where your frame budget is going.

That said, <Stats> doesn't survive a production build accidentally left in — unlike a console.log, it renders a visible DOM node. Wrap it in a isDev check or an env flag from day one.

One more thing worth knowing: Drei ships <Bvh> (Bounding Volume Hierarchy) as a scene-level wrapper that automatically optimizes raycasting across all child meshes. If you have 100+ objects and click/hover interactions are sluggish, just wrap your scene in <Bvh>. It's one of those things that costs you nothing to add and can drop raycasting time from 16ms to under 1ms on complex scenes. Pair it with some nice glassmorphism components for the UI layer on top and you've got a fast, polished result.

Loaders: useGLTF, useTexture, and Suspense Patterns

Loading 3D assets is where most r3f tutorials go vague. They show you useLoader(GLTFLoader, url) and call it a day — but that pattern doesn't give you preloading, doesn't handle errors gracefully, and makes asset management annoying as your scene grows. Drei's useGLTF and useTexture hooks solve this cleanly.

import { useGLTF, useTexture } from '@react-three/drei'
import { Suspense } from 'react'

// Preload outside the component so the asset fetches immediately
useGLTF.preload('/models/spaceship.glb')

function Spaceship() {
  // This suspends until the GLTF is loaded
  const { scene, animations, nodes, materials } = useGLTF('/models/spaceship.glb')

  // Access individual meshes by node name (set in Blender)
  const { hull, engine } = nodes

  return (
    <group>
      <primitive object={scene} />
    </group>
  )
}

// Load multiple textures in one call
function TexturedMesh() {
  const [colorMap, normalMap, roughnessMap] = useTexture([
    '/textures/metal_color.jpg',
    '/textures/metal_normal.jpg',
    '/textures/metal_roughness.jpg',
  ])

  return (
    <mesh>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial
        map={colorMap}
        normalMap={normalMap}
        roughnessMap={roughnessMap}
      />
    </mesh>
  )
}

// Wrap in Suspense to handle loading state
function App() {
  return (
    <Canvas>
      <Suspense fallback={<LoadingSpinner />}>
        <Spaceship />
        <TexturedMesh />
      </Suspense>
    </Canvas>
  )
}

The .preload() static call is the part people miss. Without it, useGLTF starts fetching only when the component mounts — meaning the user sees a loading state right when they navigate to your page. Call .preload() at module level and the fetch starts the moment the JavaScript is parsed.

For environments and lighting, <Environment> is the other big loader Drei ships. It handles HDR environment maps, which give you physically-based ambient lighting in one component. You can point it at a local .hdr file or use one of the built-in presets: 'apartment', 'city', 'dawn', 'forest', 'lobby', 'night', 'park', 'studio', 'sunset', 'warehouse'.

import { Environment } from '@react-three/drei'

function LitScene() {
  return (
    <Canvas>
      {/* Preset HDR — loads from Drei's CDN */}
      <Environment preset="studio" />

      {/* Or your own HDR file */}
      <Environment files="/hdri/evening.hdr" background />

      {/* background prop sets it as the scene skybox too */}
    </Canvas>
  )
}

Putting It Together: A Minimal Production-Ready Scene

Here's a pattern that combines everything above into something you'd actually ship — a scene with a loaded GLTF, orbit controls, environment lighting, and dev helpers that strip out in production. This isn't a toy example; it's close to what you'd start a real project with.

import { Canvas } from '@react-three/fiber'
import {
  PerspectiveCamera,
  OrbitControls,
  Environment,
  useGLTF,
  Grid,
  Stats,
  Bvh,
} from '@react-three/drei'
import { Suspense } from 'react'

const IS_DEV = process.env.NODE_ENV === 'development'

// Preload at module level
useGLTF.preload('/models/product.glb')

function ProductModel() {
  const { scene } = useGLTF('/models/product.glb')
  return <primitive object={scene} dispose={null} />
}

function LoadingFallback() {
  return (
    <mesh>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color="#444" wireframe />
    </mesh>
  )
}

export function ProductViewer() {
  return (
    <div style={{ width: '100%', height: '500px' }}>
      <Canvas shadows dpr={[1, 2]}>
        <PerspectiveCamera makeDefault position={[0, 1.5, 5]} fov={55} />

        <OrbitControls
          enablePan={false}
          minDistance={2}
          maxDistance={10}
          maxPolarAngle={Math.PI / 2}
        />

        {/* Physically-based lighting */}
        <Environment preset="studio" />
        <ambientLight intensity={0.3} />
        <directionalLight
          position={[5, 10, 5]}
          intensity={1.5}
          castShadow
          shadow-mapSize={[2048, 2048]}
        />

        {/* Optimize raycasting for interactive scenes */}
        <Bvh>
          <Suspense fallback={<LoadingFallback />}>
            <ProductModel />
          </Suspense>
        </Bvh>

        {IS_DEV && <Grid infiniteGrid fadeDistance={30} />}
        {IS_DEV && <Stats />}
      </Canvas>
    </div>
  )
}

The dpr={[1, 2]} prop on Canvas is important — it clamps device pixel ratio between 1 and 2. On a 3x retina display, rendering at full DPR means 9x the pixels. You'll tank performance. Most scenes look fine at 2x, so capping there is the right default.

Look, the dispose={null} on the <primitive> is one of those things that bites you eventually. Without it, r3f disposes the GLTF's geometry and materials when the component unmounts — which is fine for one-off renders but breaks if you're navigating between routes and the same model appears on multiple pages. Set dispose={null} and manage disposal yourself, or let useGLTF's internal cache handle it.

From here, you'd layer in things like click interactions (onClick on meshes works exactly like DOM events), animation mixing with useAnimations from Drei, or post-processing with @react-three/postprocessing. The foundation you just built handles all of it. For complementary UI work outside the canvas — tooltips, modals, that kind of thing — browse components to find what pairs well with 3D scenes.

Performance Tips Before You Ship

Three.js scenes can run at 60fps in development and drop to 20fps in production because production builds enable features like shadows and higher DPR that dev mode often skips. Profile with the actual production build before you call it done. The <Stats> component is your friend here — leave it in behind a dev flag until you've confirmed frame times.

Geometry instancing is the biggest win for scenes with repeated objects. If you have 500 trees that all use the same geometry, <InstancedMesh> renders them in a single draw call instead of 500. Drei ships <Instances> and <Instance> components that make this declarative and much less painful than the raw Three.js API.

import { Instances, Instance } from '@react-three/drei'

function Forest({ positions }) {
  return (
    // One draw call for all trees
    <Instances limit={1000}>
      <cylinderGeometry args={[0.05, 0.1, 2, 6]} />
      <meshStandardMaterial color="#5c4a1e" />
      {positions.map((pos, i) => (
        <Instance key={i} position={pos} rotation={[0, Math.random() * Math.PI * 2, 0]} />
      ))}
    </Instances>
  )
}

Texture compression is the other one people skip. A 4096x4096 PNG is 64MB in GPU memory. The same texture as a KTX2 with Basis compression lands around 8MB and decompresses on the GPU instead of the CPU. Drei's <KTX2Loader> handles this with one extra prop on useGLTF: useGLTF('/model.glb', true) — the second argument enables the Draco decoder for compressed meshes. Combine that with KTX2 textures baked in Blender and your scene load time drops significantly. Check shader effects for more on GPU-side optimizations.

FAQ

Do I need Drei to use react-three-fiber?

No, Drei is completely optional. r3f works fine without it — you'd just write more boilerplate for things like camera setup, controls, and loaders. Most real projects add Drei within the first hour because reimplementing OrbitControls from scratch isn't a good use of time.

What's the difference between useGLTF and useLoader(GLTFLoader)?

useGLTF wraps useLoader with some extra features: a static .preload() method, automatic Draco decoder setup, and a shared cache so the same URL isn't fetched twice. If you're loading GLTFs, use useGLTF. useLoader is for custom loaders Drei doesn't already wrap.

Why do my shadows look pixelated even with high shadow-mapSize?

Shadow map resolution only helps if the shadow camera's frustum is tight around your scene. A directional light with a huge frustum spreads those 2048x2048 pixels over a massive area, making each pixel cover meters. Set shadow-camera-near, shadow-camera-far, and the left/right/top/bottom values to cover just your scene bounds.

Can I use Drei components outside a Canvas?

Most Drei components require a Canvas context because they call useThree or useFrame internally. A few utilities like the HTML component are designed to escape the canvas, but camera, controls, and loader hooks will throw if you call them outside a Canvas. Keep them inside.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Three.js with React: Particles, Blobs and Interactive 3D ScenesReact Three Fiber in 2026: 3D Scenes, Shaders and Performancereact-three-fiber Intro: 3D Scenes in React With Three.jsTheatre.js: Visual Animation Editor for React and Three.js