Performance guide · React Three Fiber + Three.js

Optimize React Three Fiber game performance by removing the right work.

A slow frame is not one problem. It may be React work, JavaScript simulation, draw-call overhead, GPU fill rate, asset transfer, or garbage collection. Measure the production build, classify the bottleneck, then apply the smallest optimization that addresses it.

Record a reproducible baseline before changing the scene.

Test a production build on each device tier you intend to support. Capture frame time, dropped frames, draw calls, triangles, JavaScript heap behavior, asset transfer, and time to a usable first frame. Use the browser performance panel, the R3F performance monitor, and Three.js renderer statistics to locate the expensive phase.

CPU and React

Long scripts, wide subscriptions, frequent reconciliation, AI, physics, pathfinding, and per-frame allocations.

Renderer submission

Too many draw calls, material switches, individual props, shadow casters, and uncached scene construction.

GPU

High DPR, overdraw, expensive shaders, large shadow maps, post-processing, particles, and excessive geometry.

Loading

Large GLBs and textures, eager world loading, parse stalls, compilation hitches, and no staged first-frame target.

Keep a stable camera path or scripted interaction for comparisons. An average FPS number without the same scene, build, device, temperature, and input is not a useful regression signal.

Keep durable game state out of the hot path.

const target = useRef(new THREE.Vector3())
const velocity = useRef(new THREE.Vector3())
const mesh = useRef<THREE.Group>(null)

useEffect(() => movementStore.subscribe((state) => {
  target.current.set(state.target.x, state.target.y, state.target.z)
}), [])

useFrame((_, delta) => {
  if (!mesh.current) return
  velocity.current.subVectors(target.current, mesh.current.position)
  mesh.current.position.addScaledVector(velocity.current, Math.min(delta * 8, 1))
})

Reuse vectors and mutate visual objects directly inside useFrame. Read low-frequency goals from a store subscription, use the supplied delta, and emit one transition when gameplay meaning changes. Do not call React setState on every frame merely to move one mesh.

Quest, dialogue, inventory, achievement, persistence, and analytics systems usually belong behind events or explicit clocks. The R3F game state management guide shows the complete store, ref, selector, and authority boundary.

Batch repeated world props that share geometry and material.

import {
  Decorations,
  type DecorationSet,
} from '@overworld-engine/scene'

const lamps: DecorationSet = {
  id: 'village-lamps',
  modelPath: '/models/lamp.glb',
  instances: [
    { position: [4, 0, 2] },
    { position: [4, 0, 8], rotation: [0, Math.PI, 0] },
  ],
  collision: { radius: 0.4 },
}

<Decorations sets={[lamps]} />

Overworld creates one InstancedMesh per source mesh and derives colliders from the same instance list. That removes a class of duplicate transforms while reducing renderer submissions for dense trees, rocks, lamps, benches, and similar set dressing.

Instancing is not a universal merge button. Objects with different materials become separate batches, and independently animated or skinned characters need another design. Re-measure draw calls and GPU time after each batch instead of assuming a larger batch is always faster.

Reduce distant detail and cap quality conservatively.

import {
  ApplyQuality,
  Lod,
  detectQualityPreset,
  qualityToLodCap,
  useQualityStore,
} from '@overworld-engine/scene'

const preset = detectQualityPreset()
useQualityStore.getState().setPreset(preset)

function WorldTree() {
  const active = useQualityStore((state) => state.preset)
  const deviceCap = qualityToLodCap(active === 'custom' ? 'high' : active)

  return (
    <>
      <ApplyQuality />
      <Lod
        position={[20, 0, 10]}
        deviceCap={deviceCap}
        levels={[
          { distance: 0, modelPath: '/models/tree-high.glb' },
          { distance: 35, modelPath: '/models/tree-mid.glb' },
          { distance: 75, modelPath: '/models/tree-low.glb' },
        ]}
        render={(modelPath) => <Tree url={modelPath} />}
      />
    </>
  )
}

Lod changes only when the selected level changes, uses hysteresis to avoid boundary flicker, and preloads nearby levels. Built-in quality presets adjust DPR and shadows through ApplyQuality, and expose hints for shadow-map size and particle count.

Device detection uses browser, memory, CPU, pointer, and optional renderer hints. It is a starting point, not a benchmark. Persist an explicit player override and consider lowering quality after measured frame regression rather than locking users to a guessed tier.

Load the next useful assets, not the entire world.

import {
  defineAssetManifest,
  preloadManifest,
  useZoneStreaming,
} from '@overworld-engine/loading'
import { playerPositionRef } from '@overworld-engine/scene'

const village = defineAssetManifest({
  models: ['/models/guide.glb', '/models/village-props.glb'],
  images: ['/maps/village.webp'],
  audio: ['/audio/village.ogg'],
})

const zones = [{
  id: 'village',
  priority: 10,
  bounds: { minX: -40, maxX: 40, minZ: -40, maxZ: 40 },
  manifest: village,
}]

preloadManifest(village, { categories: ['models'] })
const loading = useZoneStreaming(zones, playerPositionRef)

Asset manifests are plain data, so scenes can compose and deduplicate them. Zone streaming orders higher-priority zones first and uses distance within each priority bucket. It starts work without blocking the current render and exposes failures for retry.

Be precise about progress: useGLTF.preload has no real completion event. Overworld counts a model as started when it enters the loader cache; images and audio can report settlement. Use useSceneLoadProgress after the Canvas mounts for Three.js loading-manager progress, and treat “usable first frame” as a separate milestone from “every optional asset loaded.”

Choose a render policy that matches what can actually stop.

R3F supports on-demand rendering for scenes that remain visually identical until an input or state change invalidates them. That can fit paused games, inventory previews, map screens, editors, or turn-based scenes. A continuously animated RPG world generally needs the default continuous loop.

Adaptive quality can be more useful than changing the whole render policy. Reduce DPR, shadows, particles, post-processing, or the most detailed LOD after sustained regression, then recover gradually. Avoid oscillating quality on a single slow frame.

The official R3F documentation covers scaling performance, instancing, LOD, and adaptive quality and its performance pitfalls.

Turn performance into a release constraint.

  • Define target device tiers and a stable production-build test route for each.
  • Track frame-time percentiles and long frames, not only average FPS.
  • Record draw calls, triangles, texture memory, transfer size, and first usable frame.
  • Exercise movement, camera rotation, combat effects, UI overlays, and zone transitions.
  • Fail review when a repeatable regression exceeds the budget or lacks an explicit exception.

Overworld provides boundaries and primitives; it does not optimize your shaders, texture formats, physics engine, post-processing chain, or authored geometry. Those remain game-owned and should be inspected with renderer, browser, and device-specific profilers.

React Three Fiber game performance questions

Does React Three Fiber make Three.js games slow?

Not by itself. Performance depends on the work performed by your scene, frame loop, React subscriptions, shaders, assets, and device. Profile the running build before choosing an optimization.

Should an RPG use on-demand rendering?

Only when the visible scene can genuinely rest. Menus, editors, paused views, and turn-based scenes may benefit. A world with continuous animation, movement, particles, or simulation normally needs continuous rendering.

When should I use instancing in React Three Fiber?

Use instancing for many objects that share geometry and material, such as trees, lamps, rocks, and street furniture. Separate materials or independently skinned characters need different strategies.

What belongs inside useFrame?

Keep interpolation, camera movement, animation advancement, and direct visual mutation in useFrame. Durable quests, inventory, dialogue, saves, and broad React state updates should react to meaningful events instead.

Place performance boundaries inside a complete R3F architecture

Connect the render loop, headless systems, assets, platform adapters, tests, and server authority.

Read the React Three Fiber RPG architecture guide →