Post-processing

Bloom, depth of field and lens effects: the finishing pass that makes a scene look shot, not rendered.

What it is

Normally Three.js draws straight to the screen. With post-processing it draws into an image first, then runs full-screen shader passes over it, like filters on a photo. Bloom makes very bright pixels glow, depth of field blurs what's out of focus using the depth buffer, and chromatic aberration, vignette and grain imitate a real lens and film. @react-three/postprocessing wraps the postprocessing library, which merges compatible effects into a single pass.

When to use it

  • Neon, sci-fi and night scenes (bloom)
  • Drawing the eye to one object (depth of field)
  • A cinematic finish on product shots and hero scenes
Official docs ↗

Toggle the effects

Loading 3D scene…

Only the rings glow because their emissive colour is pushed past 1.0 (emissiveIntensity={4}, toneMapped={false}) and Bloom's luminanceThreshold is 1. Each effect costs a little GPU time per pixel, so on a large screen it's worth turning off what the scene doesn't need: watch the FPS meter (sidebar) as you toggle.

Show code · src/app/three-postprocessing/PostScene.tsx
"use client";

import { OrbitControls } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { Bloom, ChromaticAberration, DepthOfField, EffectComposer, Noise, Vignette } from "@react-three/postprocessing";
import { useRef, useState } from "react";
import type { Group } from "three";
import { ThreeCanvas } from "@/components/three/ThreeCanvas";

type Effects = { bloom: boolean; dof: boolean; chroma: boolean; vignette: boolean; noise: boolean };

// A row of glowing rings at different depths. Colours above 1.0
// (emissiveIntensity > 1) are "brighter than white": that's what Bloom picks
// up, while the dark matte shapes stay crisp.
function Rings({ animate }: { animate: boolean }) {
  const group = useRef<Group>(null);
  useFrame((_, delta) => {
    if (!animate || !group.current) return;
    group.current.children.forEach((child, i) => { child.rotation.x += delta * (0.3 + i * 0.1); child.rotation.y += delta * 0.4; });
  });
  const colours = ["#ff4d8d", "#7b6cf0", "#3ee0c5", "#ffb84d", "#4da6ff"];
  return (
    <group ref={group}>
      {colours.map((c, i) => (
        <mesh key={c} position={[(i - 2) * 1.6, 0, -i * 1.8 + 2]}>
          <torusGeometry args={[0.55, 0.12, 24, 96]} />
          <meshStandardMaterial color="#111" emissive={c} emissiveIntensity={4} toneMapped={false} />
        </mesh>
      ))}
      {[-3, 0, 3].map((x) => (
        <mesh key={x} position={[x, -0.2, -1]}>
          <icosahedronGeometry args={[0.45, 0]} />
          <meshStandardMaterial color="#d9d6ff" roughness={0.5} flatShading />
        </mesh>
      ))}
    </group>
  );
}

export function PostScene() {
  const [fx, setFx] = useState<Effects>({ bloom: true, dof: false, chroma: false, vignette: true, noise: false });
  const [intensity, setIntensity] = useState(1.2);
  const [focus, setFocus] = useState(7);
  const toggle = (k: keyof Effects) => setFx((f) => ({ ...f, [k]: !f[k] }));

  const labels: Record<keyof Effects, string> = { bloom: "Bloom", dof: "Depth of field", chroma: "Chromatic aberration", vignette: "Vignette", noise: "Film grain" };

  return (
    <>
      <ThreeCanvas camera={{ position: [0, 1, 7], fov: 45 }} label="Glowing neon rings at different depths, with switchable post-processing effects">
        {(animate) => (
          <>
            <color attach="background" args={["#08070c"]} />
            <ambientLight intensity={0.3} />
            <directionalLight position={[3, 4, 5]} intensity={1.5} />
            <Rings animate={animate} />
            <OrbitControls minDistance={3} maxDistance={14} />
            {/* The scene renders into a buffer first; each effect is then a
                full-screen pass over that image, merged into as few shader
                passes as possible. */}
            <EffectComposer multisampling={4}>
              {fx.bloom && <Bloom mipmapBlur luminanceThreshold={1} intensity={intensity} />}
              {fx.dof && <DepthOfField worldFocusDistance={focus} worldFocusRange={2} bokehScale={4} />}
              {fx.chroma && <ChromaticAberration offset={[0.002, 0.002]} radialModulation modulationOffset={0.2} />}
              {fx.vignette && <Vignette offset={0.3} darkness={0.7} />}
              {fx.noise && <Noise opacity={0.12} />}
            </EffectComposer>
          </>
        )}
      </ThreeCanvas>
      <div className="mb-2 flex flex-wrap gap-x-4 gap-y-1.5">
        {(Object.keys(labels) as (keyof Effects)[]).map((k) => (
          <label key={k} className="flex items-center gap-1.5 text-sm">
            <input type="checkbox" checked={fx[k]} onChange={() => toggle(k)} /> {labels[k]}
          </label>
        ))}
      </div>
      <div className="mb-2 flex flex-wrap items-center gap-x-4 gap-y-2">
        <label className="flex flex-1 basis-55 items-center gap-2 text-sm">
          Bloom intensity
          <input className="min-w-0 flex-1" type="range" min={0} max={3} step={0.05} value={intensity} disabled={!fx.bloom} onChange={(e) => setIntensity(Number(e.target.value))} />
          <span className="w-9 tabular-nums">{intensity.toFixed(1)}</span>
        </label>
        <label className="flex flex-1 basis-55 items-center gap-2 text-sm">
          Focus distance
          <input className="min-w-0 flex-1" type="range" min={3} max={14} step={0.1} value={focus} disabled={!fx.dof} onChange={(e) => setFocus(Number(e.target.value))} />
          <span className="w-9 tabular-nums">{focus.toFixed(1)}</span>
        </label>
      </div>
    </>
  );
}