📍 Article principal de la série : Three.js, React Three Fiber et WebGPU en 2026 : 3D temps réel sur le web
Introduction
Le post-processing transforme une scène 3D brute en image cinéma. Bloom pour les zones lumineuses, depth of field pour la profondeur, vignette pour cadrer le regard, color grading pour l’ambiance : ce sont les passes qui font la différence entre un rendu d’amateur et un rendu de production. En WebGPU avec Three.js r184 et au-delà, ces effets s’écrivent en TSL nodes plutôt qu’en passes GLSL imbriquées, ce qui les rend plus lisibles, plus typés, et compatibles avec le repli WebGL via le compilateur. Ce tutoriel construit pas à pas une chaîne de post-processing complète : bloom, depth of field, vignette et color correction, mesurée en coût GPU.
Prérequis
- Un projet R3F v9 avec WebGPU activé (cf. tutoriel WebGPU)
- Three.js r184 ou supérieur
- Une compréhension de base de TSL (cf. tutoriel TSL)
- Une scène avec quelques objets et un éclairage suffisant pour montrer les effets
- Temps estimé : 50 à 70 minutes
Étape 1 — Comprendre l’architecture du post-processing nodal
En WebGL classique, le post-processing s’organise autour d’un EffectComposer qui enchaîne des passes : chaque passe lit la texture produite par la précédente et écrit dans une texture intermédiaire. C’est efficace mais verbeux et difficile à composer. Three.js a introduit avec WebGPU une nouvelle API basée sur des node graphs : on déclare la chaîne d’effets sous forme d’arbre TSL, et le moteur orchestre lui-même les passes et les ressources.
L’objet central s’appelle RenderPipeline depuis Three.js r183 — auparavant nommé PostProcessing, le renommage est documenté dans le guide de migration officiel. On l’importe depuis three/webgpu, on l’instancie avec le renderer, on lui assigne un outputNode qui est un nœud TSL produisant la couleur finale, et on appelle sa méthode render à la place du renderer.render habituel. Si vous lisez encore des tutoriels ou exemples antérieurs à r183, vous y verrez PostProcessing : c’est exactement la même classe, juste un ancien nom.
Étape 2 — Créer une scène de test représentative
Avant d’ajouter du post-processing, on a besoin d’une scène qui révélera bien les effets. Trois éléments sont nécessaires : des sources lumineuses fortes (pour le bloom), des objets à des distances variées (pour le depth of field), et un fond légèrement contrasté (pour la vignette).
// src/scenes/PostProcessingDemo.tsx
import { Canvas } from '@react-three/fiber'
import { OrbitControls, Environment } from '@react-three/drei'
import { createRenderer } from '@/renderer/createRenderer'
export default function PostProcessingDemo() {
return (
<Canvas
gl={async (canvas) => (await createRenderer(canvas as HTMLCanvasElement)).renderer}
camera={{ position: [0, 1, 5], fov: 45 }}
>
<Environment preset="sunset" />
<mesh position={[-1.5, 0, 0]}>
<sphereGeometry args={[0.6, 32, 32]} />
<meshStandardMaterial color="#ff5500" emissive="#ff2200" emissiveIntensity={2} />
</mesh>
<mesh position={[0, 0, -2]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#3366ff" />
</mesh>
<mesh position={[1.5, 0, -4]}>
<torusGeometry args={[0.5, 0.2, 16, 64]} />
<meshStandardMaterial color="#ffffff" metalness={0.8} roughness={0.2} />
</mesh>
<mesh rotation={[-Math.PI/2, 0, 0]} position={[0, -1, 0]}>
<planeGeometry args={[20, 20]} />
<meshStandardMaterial color="#222" />
</mesh>
<OrbitControls />
</Canvas>
)
}
La sphère orange a un emissiveIntensity volontairement élevé pour qu’elle déclenche fortement le bloom. Le cube à -2 et le tore à -4 sont à des distances différentes pour qu’on puisse régler le focus du DoF. Le sol absorbe les ombres et donne une référence neutre. Avant tout post-processing, le rendu doit déjà être plausible : si la scène brute paraît plate, c’est qu’il manque de l’éclairage ou des contrastes, et aucune passe de bloom ne corrigera le problème.
Étape 3 — Premier effet : la vignette
La vignette est l’effet le plus simple, parfait pour valider la chaîne avant d’attaquer les passes coûteuses. Elle assombrit progressivement les bords de l’écran pour concentrer le regard au centre. En TSL, c’est une multiplication de la couleur de scène par un facteur qui dépend de la distance au centre.
// src/post/vignette.ts
import { uv, length, smoothstep, vec2, mul } from 'three/tsl'
import type { ShaderNodeObject } from 'three/tsl'
export function vignette(sceneColor: ShaderNodeObject<any>, intensity = 0.6) {
const center = vec2(0.5, 0.5)
const dist = length(uv().sub(center))
// smoothstep(0.2, 0.5, dist) : 0 au centre, 1 vers les coins
const edgeFactor = smoothstep(0.2, 0.5, dist)
// darkening : 1 au centre, (1 - intensity) aux coins
const darkening = edgeFactor.mul(intensity).oneMinus()
return mul(sceneColor, darkening)
}
L’algorithme calcule la distance euclidienne au centre de l’écran via length(uv - center), puis applique un smoothstep(0.2, 0.5, dist) qui vaut 0 au centre et monte progressivement vers 1 aux coins. On multiplie ce facteur par l’intensité d’assombrissement souhaitée, puis oneMinus() inverse en facteur multiplicatif appliqué à la couleur source : 1 au centre (pas de modification), 1 - intensity aux coins (assombrissement). La fonction retourne un nouveau nœud, ce qui permet de la chaîner avec d’autres effets sans muter ses entrées.
Étape 4 — Brancher le post-processing au renderer
Pour appliquer notre vignette, on remplace le rendu standard par un PostProcessing node. R3F v9 fournit le hook usePostProcessing qui prend en charge le câblage. On crée un composant qui s’insère dans le Canvas et qui intercepte la boucle de rendu.
// src/post/PostFX.tsx
import { useThree, useFrame } from '@react-three/fiber'
import { useMemo, useEffect } from 'react'
import { RenderPipeline, pass } from 'three/webgpu'
import { vignette } from './vignette'
export default function PostFX() {
const { gl, scene, camera, size } = useThree()
const pp = useMemo(() => {
const p = new RenderPipeline(gl as any)
const scenePass = pass(scene, camera)
p.outputNode = vignette(scenePass.getTextureNode(), 0.7)
return p
}, [gl, scene, camera])
useEffect(() => () => pp.dispose?.(), [pp])
useFrame(() => {
pp.render()
}, 1) // priorité 1 : on remplace le render natif
return null
}
Plusieurs subtilités méritent attention. La propriété state.gl reste le nom du renderer en R3F v9 stable ; la branche v10 alpha la renomme state.renderer, mais tant qu’on cible la stable on conserve gl. Le pass(scene, camera) crée un nœud représentant le rendu brut de la scène — c’est l’entrée du graphe. La méthode getTextureNode() produit un nœud qui lit la texture résultante, qu’on passe à vignette. Le useFrame avec priorité 1 court-circuite le rendu automatique de R3F : sans cette priorité, R3F continuerait d’appeler renderer.render() en plus, et on rendrait deux fois. La page doit maintenant montrer la scène avec un assombrissement progressif aux bords.
Étape 5 — Ajouter le bloom
Le bloom diffuse la lumière des zones très lumineuses (au-dessus de 1.0 dans l’espace HDR) pour simuler l’éblouissement de l’œil ou de la caméra. L’algorithme classique extrait les zones brillantes, les flou-Gauss à plusieurs résolutions, puis les ajoute à l’image originale.
import { bloom } from 'three/addons/tsl/display/BloomNode.js'
import { vignette } from './vignette'
const scenePass = pass(scene, camera)
const sceneTex = scenePass.getTextureNode()
const bloomed = bloom(sceneTex, 0.8, 0.3, 0.85)
// strength 0.8, radius 0.3, threshold 0.85
const finalNode = vignette(bloomed, 0.6)
p.outputNode = finalNode
Trois paramètres se règlent. strength contrôle l’intensité du flou ajouté ; au-delà de 1, l’image devient laiteuse. radius contrôle l’étendue du flou ; plus grand donne un bloom plus diffus mais plus coûteux. threshold est le seuil au-dessus duquel un pixel contribue au bloom ; à 0.85 on garde un effet propre, à 0.3 tout brille (parfois souhaitable pour un look stylisé). La sphère orange émissive de notre scène doit maintenant baigner dans un halo doré, et la vignette s’applique par-dessus pour cadrer le tout.
Étape 6 — Ajouter le depth of field
Le DoF flou les objets éloignés du plan de focus, simulant l’optique d’une caméra réelle. En WebGPU avec TSL, l’effet exploite la profondeur écrite dans le depth buffer pour calculer un facteur de flou par pixel.
import { dof } from 'three/addons/tsl/display/DepthOfFieldNode.js'
import { uniform } from 'three/tsl'
const scenePass = pass(scene, camera)
const sceneTex = scenePass.getTextureNode()
const viewZ = scenePass.getViewZNode()
// Signature : dof(textureNode, viewZNode, focusDistance, focalLength, bokehScale)
const focusDistance = uniform(5)
const focalLength = uniform(1)
const bokehScale = uniform(4)
const focused = dof(sceneTex, viewZ, focusDistance, focalLength, bokehScale)
const bloomed = bloom(focused, 0.6, 0.3, 0.85)
const finalNode = vignette(bloomed, 0.6)
La signature exacte de dof documentée par DepthOfFieldNode prend cinq nœuds positionnels : la texture de scène, le nœud de profondeur en espace vue (getViewZNode(), pas une depth texture brute), la distance de focus, la focale et l’échelle de bokeh. Les trois derniers passent par des uniform pour pouvoir les piloter depuis le code applicatif. L’ordre des passes compte : on applique le DoF avant le bloom parce que le bloom doit pouvoir diffuser la lumière depuis les zones nettes ; appliquer le bloom d’abord puis flouter le tout aurait pour effet d’estomper les halos là où on les voulait visibles. La distance de focus à 5 unités correspond à la position de la caméra dans notre scène test ; en pratique, on mute le .value de l’uniform au clic ou via une animation react-spring.
Étape 7 — Color grading et tone mapping
La dernière étape est l’ajustement colorimétrique : tone mapping pour ramener l’HDR dans la plage SDR de l’écran, color correction pour donner une ambiance. Three.js inclut nativement plusieurs tone mappers (ACES, Reinhard, Linear) qu’on active sur le renderer ; en post-processing TSL, on peut affiner avec un nœud personnalisé.
import { mul, add, vec3 } from 'three/tsl'
function colorGrade(input, params: { lift: number; gamma: number; gain: number }) {
// ASC-CDL simplifié : (input * gain + lift) ^ (1/gamma)
const lifted = add(mul(input, vec3(params.gain, params.gain, params.gain)), vec3(params.lift, params.lift, params.lift))
return lifted.pow(vec3(1 / params.gamma))
}
const graded = colorGrade(focused, { lift: 0.02, gamma: 1.1, gain: 1.05 })
const bloomed = bloom(graded, 0.6, 0.3, 0.85)
const finalNode = vignette(bloomed, 0.6)
Le modèle ASC CDL (American Society of Cinematographers Color Decision List) est une opération à trois paramètres standard dans l’industrie cinéma : lift remonte les noirs, gamma contrôle les midtones, gain ajuste les highlights. Avec ces trois leviers, on couvre la majorité des ajustements d’ambiance. Pour approfondir (look-up tables 3D, presets cinéma), on charge des LUTs .cube via le helper LUT3DLoader de Three.js.
Étape 8 — Mesurer le coût GPU de la chaîne
Chaque passe a un coût. Pour valider qu’on tient les 60 fps, on mesure le temps GPU de la chaîne avec l’API renderer.compute() ou via les DevTools navigateur. Sous Chrome, le panneau Performance avec la trace GPU activée montre la durée de chaque passe.
// Mesure approximative côté CPU (le GPU est asynchrone)
useFrame(() => {
const t = performance.now()
pp.render()
const dt = performance.now() - t
if (dt > 12) console.warn('[postfx] render took', dt.toFixed(1), 'ms')
}, 1)
Sur une RTX moderne, la chaîne complète bloom + DoF + vignette + grading coûte typiquement 1 à 2 ms à 1080p. Sur un mobile mid-range, le même setup peut grimper à 8-12 ms, ce qui consomme plus de la moitié du budget de frame. La règle est de mesurer sur la cible de production réelle et d’envisager des qualités différentes selon le device : sur mobile, on désactive souvent le DoF et on réduit le radius du bloom.
Étape 9 — Adapter la qualité au device cible
Une chaîne de post-processing qui tient à 60 fps sur desktop peut s’effondrer à 25 fps sur un téléphone Android d’entrée de gamme. Plutôt que de fournir une expérience dégradée à tous, on définit des profils de qualité et on bascule selon les capabilities détectées au démarrage. La détection s’appuie sur le pixel ratio, la mémoire estimée du device et un mini benchmark de la première frame.
// src/post/quality.ts
export type Quality = 'low' | 'medium' | 'high'
export function detectQuality(): Quality {
const dpr = window.devicePixelRatio
const mem = (navigator as any).deviceMemory ?? 4
const cores = navigator.hardwareConcurrency ?? 4
if (mem < 4 || cores < 4) return 'low'
if (dpr >= 2 && mem >= 8 && cores >= 8) return 'high'
return 'medium'
}
export const POST_PROFILE = {
low: { bloom: { strength: 0.4, radius: 0.2 }, dof: false, grading: false },
medium: { bloom: { strength: 0.6, radius: 0.3 }, dof: true, grading: true },
high: { bloom: { strength: 0.8, radius: 0.4 }, dof: true, grading: true },
}
L’API navigator.deviceMemory retourne une estimation en gigaoctets de la RAM du device, arrondie pour la confidentialité. hardwareConcurrency donne le nombre de cœurs logiques. Combinées au pixel ratio, ces trois valeurs donnent une heuristique fiable pour classer le device. Le profil low conserve un bloom léger pour ne pas perdre l’esthétique, mais désactive DoF et color grading — les effets les plus coûteux. Le profil high active tout avec des paramètres plus généreux. On choisit le profil au montage et on construit la chaîne de post-processing en conséquence, sans qu’aucune branche conditionnelle ne tourne dans la boucle de rendu.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
| Image entièrement noire après ajout du PostFX | Le rendu natif R3F est encore actif et écrase le post-processing | Utiliser useFrame priorité 1 et frameloop="demand" sur Canvas si besoin |
| Bloom invisible malgré une lumière forte | Material non emissive ou intensity trop faible | Vérifier emissive et emissiveIntensity |
| FPS qui chute brutalement à l’ajout du DoF | Coût GPU excessif sur mobile | Conditionner l’effet par capability ou réduire bokehScale |
| Scintillement dans les zones lumineuses | Tone mapping non activé : valeurs HDR clipées | renderer.toneMapping = ACESFilmicToneMapping |
| Color grading saturé hors écran | Sortie hors [0,1] sans clamp | Ajouter .clamp(0, 1) en fin de chaîne |
L’ordre des passes mérite d’être tracé sur papier avant de coder, surtout sur des chaînes longues. Une feuille avec les flèches d’entrée/sortie, la résolution de chaque passe et le coût estimé en millisecondes prend dix minutes et économise des heures de tâtonnement.
Tutoriels frères
- Premier shader TSL : Three Shading Language pas à pas
- Performance R3F : instancing, LOD et frustum culling
À lire ensuite
- 🔝 Retour au guide principal : Three.js, React Three Fiber et WebGPU en 2026
- Three.js — exemples de post-processing
- pmndrs/postprocessing — bibliothèque pour WebGL
- Tone mapping — fondamentaux
FAQ
Faut-il abandonner @react-three/postprocessing ?
Non, pas si vous ciblez WebGL en priorité. La bibliothèque pmndrs reste excellente pour ce backend. Pour WebGPU, les passes TSL natives sont plus performantes et mieux intégrées. Beaucoup de projets gèrent les deux via un switch sur le renderer kind, ce qui demande de maintenir deux chaînes mais garantit la portabilité.
Combien de passes peut-on enchaîner sans perte de qualité ?
Chaque passe induit une lecture/écriture de texture, donc une latence et une consommation mémoire. En pratique, 3 à 5 effets significatifs (bloom, DoF, grading, vignette, tone mapping) restent confortables. Au-delà, on commence à voir des baisses de FPS et des artefacts d’accumulation d’erreur.
Le DoF marche-t-il sur des objets transparents ?
Pas naturellement. Les objets transparents ne s’écrivent pas dans le depth buffer, donc le DoF les ignore. La solution est d’utiliser une seconde passe dédiée aux transparents, ou de basculer sur du Weighted Blended OIT (Order Independent Transparency).
Comment debugger une passe TSL qui produit du noir ?
Court-circuiter la chaîne : assigner directement p.outputNode = sceneTex et vérifier qu’on a bien la scène brute. Puis ajouter une passe à la fois en validant chaque étape. Cette technique méthodique localise rapidement la passe défectueuse.
Le bloom consomme-t-il plus en WebGPU qu’en WebGL ?
À algorithme équivalent, légèrement moins en WebGPU grâce au meilleur parallélisme. Le vrai gain de WebGPU est dans la possibilité d’utiliser des compute shaders pour des effets plus avancés (SSGI, denoising) impossibles en WebGL.
Faut-il appliquer le post-processing avant ou après le tone mapping ?
La séquence canonique est : rendu HDR de la scène en linéaire, post-processing en linéaire (les effets travaillent sur des valeurs non clampées au-delà de 1.0), tone mapping en sortie pour ramener au SDR, puis affichage en sRGB. Inverser cet ordre — par exemple appliquer le bloom après tone mapping — produit des résultats fades parce que les zones brillantes ont déjà été compressées avant que le bloom ne les détecte.
Comment optimiser la chaîne quand le profilage révèle un goulot ?
Trois leviers par ordre d’efficacité : réduire la résolution interne du bloom et du DoF (passes à demi ou quart de résolution sont visuellement indistinguables sur la plupart des contenus), désactiver les effets non essentiels en mobile, et fusionner les passes mathématiquement compatibles dans un seul shader (vignette + grading + tone mapping s’enchaînent en une seule passe sans perte). Cette dernière optimisation gagne facilement 1 à 2 ms sur des configs serrées.