7. Post-traitement avec RenderPipeline
Sections
Réglages de lecture
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
Code et termes techniques en anglais
Désactivé : la voix de la page lit tout, sans pause entre deux voix.
Aucune voix pour cette langue dans ce navigateur.
Plus de voix
D’autres voix : Microsoft Edge propose des voix naturelles en ligne, et Windows (Paramètres › Heure et langue › Voix), macOS et Android en installent d’autres.
Code : la page dans src/07-post/main.ts, qui reprend la touche de guitare de la leçon 5.
Le post-traitement, dans trois outils
Section intitulée « Le post-traitement, dans trois outils »Le post-traitement dessine la scène dans une texture au lieu de l’écran, puis exécute des pixel shaders sur cette texture : bloom, anticrénelage, profondeur de champ, étalonnage des couleurs. Dans MonoGame, vous le faites à la main : GraphicsDevice.SetRenderTarget avec une RenderTarget2D, vous dessinez la scène, puis la texture sur un quad plein écran à travers un Effect. WPF applique un ShaderEffect aux pixels rendus d’un élément, y compris ceux d’un Viewport3D.
three.js avait EffectComposer pour WebGLRenderer : une liste de passes, chacune un ShaderMaterial dessiné sur un quad. WebGPURenderer le remplace par RenderPipeline, où les effets sont des nœuds TSL de la leçon 6, reliés en graphe.
| MonoGame | three.js, WebGLRenderer |
three.js, WebGPURenderer |
|
|---|---|---|---|
| La chaîne | votre code, target par target | EffectComposer, une liste de passes |
RenderPipeline, un graphe de nœuds |
| Rendre la scène | SetRenderTarget, puis dessiner |
RenderPass |
pass(scene, camera) |
| Un effet | un Effect sur un quad |
UnrealBloomPass, ShaderPass(shader) |
bloom(node), fxaa(node), n’importe quel nœud TSL |
| Tone mapping et sRGB | votre dernier shader | OutputPass, ajoutée à la main |
appliqués par le pipeline, sauf si outputColorTransform = false |
| À chaque image | Draw |
composer.render() |
pipeline.render(), au lieu de renderer.render() |
RenderPipeline s’appelait PostProcessing jusqu’à r183. L’ancien nom fonctionne toujours, avec un avertissement : « PostProcessing: “PostProcessing” has been renamed to “RenderPipeline”. Please update your code to use “THREE.RenderPipeline” instead. » (PostProcessing.js, lignes 5-20).
Du bloom sur les repères
Section intitulée « Du bloom sur les repères »La touche reçoit cinq repères qui émettent de la lumière, avec une intensité émissive de 6 : bien au-delà de 1, la valeur la plus lumineuse qu’un pixel peut afficher après le tone mapping. Le bloom prend les pixels au-dessus d’un seuil, les floute à plusieurs tailles, et ajoute ce flou à l’image, si bien que les objets lumineux rayonnent comme à travers l’objectif d’un appareil photo (lignes 46-62) :
// La chaîne, sous forme de graphe de nœuds : la texture de couleur de la passe de scène, le bloom ajouté par-dessus, puis la transformation de sortielet pipeline: THREE.RenderPipeline | null = null;let direct: THREE.DirectRenderPipeline | null = null;const disposables: { dispose(): void }[] = [];if (mode === 'bloom' || mode === 'bloom-fxaa') { pipeline = new THREE.RenderPipeline(renderer); const scenePass = pass(scene, camera); const sceneColor = scenePass.getTextureNode('output'); const glow = bloom(sceneColor, 1.2, 0.4, threshold); disposables.push(scenePass, glow); if (mode === 'bloom') { pipeline.outputNode = sceneColor.add(glow); } else { // FXAA attend des valeurs sRGB après tone mapping : la transformation de sortie du pipeline est désactivée et appliquée avant FXAA pipeline.outputColorTransform = false; pipeline.outputNode = fxaa(renderOutput(sceneColor.add(glow))); }}pass(scene, camera) rend la scène dans une render target, et getTextureNode('output') est sa couleur, sous forme de nœud. bloom(node, strength, radius, threshold) vient de three/addons/tsl/display/BloomNode.js ; les addons comptent une cinquantaine de nœuds de ce genre dans le même dossier, de fxaa et smaa à ssao, ssr, dof et outline. sceneColor.add(glow) est du TSL : l’image finale est la scène plus son halo. Le pipeline rend ce graphe sur un quad plein écran, puis applique le tone mapping et la conversion sRGB du renderer, puisque outputColorTransform vaut true par défaut.
La boucle de la page appelle pipeline.render() au lieu de renderer.render(scene, camera) : la scène est rendue par la passe, à l’intérieur du pipeline.
Ce que coûte chaque chaîne
Section intitulée « Ce que coûte chaque chaîne »La page accepte ?pipeline=none, bloom, bloom-fxaa ou direct. Pour chacun, la sonde lit quatre pixels : le repère de la case 5, le manche à 0.06 unité à côté, là où devrait se trouver le halo, le manche à 0.15 unité, et le fond dans un coin.
$ node scripts/probe.mjs 07-post --query pipeline=none{ "frame": 4, "render": { "calls": 6, "drawCalls": 25, "triangles": 941 }, "memory": { "geometries": 10, "textures": 3, "programs": 4 }, "pipeline": "none", "threshold": 1, "renderTargets": 1, "pixels": { "inlay at fret 5": "rgb(242, 250, 252)", "halo, 0.06 beside it": "rgb(53, 28, 20)", "neck, 0.15 beside it": "rgb(53, 28, 20)", "background": "rgb(12, 15, 20)" }}$ node scripts/probe.mjs 07-post --query pipeline=bloom{ "frame": 4, "render": { "calls": 42, "drawCalls": 37, "triangles": 953 }, "memory": { "geometries": 10, "textures": 14, "programs": 13 }, "pipeline": "bloom", "threshold": 1, "renderTargets": 12, "pixels": { "inlay at fret 5": "rgb(247, 252, 254)", "halo, 0.06 beside it": "rgb(127, 168, 190)", "neck, 0.15 beside it": "rgb(74, 81, 94)", "background": "rgb(13, 17, 23)" }}
Page en direct : la lancer ici
Ouvrir en plein écran. À essayer : mettez bloom threshold à 0, la scène de l’exercice 1 ; pipeline passe d’une chaîne à l’autre parmi celles de la section suivante.
?pipeline= |
appels, 3 images | draw calls | render targets | programmes | halo, à 0.06 | manche, à 0.15 | fond |
|---|---|---|---|---|---|---|---|
none |
6 | 25 | 1 | 4 | 53, 28, 20 | 53, 28, 20 | 12, 15, 20 |
bloom |
42 | 37 | 12 | 13 | 127, 168, 190 | 74, 81, 94 | 13, 17, 23 |
bloom-fxaa |
45 | 38 | 13 | 15 | 132, 174, 195 | 74, 82, 96 | 13, 17, 23 |
direct |
3 | 25 | 0 | 4 | 53, 28, 20 | 53, 28, 20 | 12, 15, 20 |
Sans bloom, le manche à côté du repère a le même brun que partout ailleurs. Avec, le halo rend le manche bleu clair à 0.06 unité, le teinte encore à 0.15, et éclaircit même le fond de quelques niveaux. Le prix se lit dans les autres colonnes :
- Draw calls : 37 au lieu de 25. Les 24 objets sont dessinés une fois, dans la passe de scène. Viennent ensuite 13 quads plein écran : un pour garder les pixels lumineux, cinq floutés horizontalement et cinq verticalement à des tailles divisées par deux, un pour les combiner, et le quad de sortie du pipeline. Par image, les
callscomptent 14 rendus au lieu de 2. - Render targets : 12 au lieu de 1. La passe de scène en a une ;
BloomNodeen crée onze, une pour les pixels lumineux, et une horizontale et une verticale pour chacune de ses cinq tailles (BloomNode.js, lignes 156-180). Ce sont des textures en demi-flottants, dimensionnées d’après le canvas : de la mémoire qui grandit avec l’écran. - Programmes : 13 au lieu de 4. Chaque étape est un shader.
FXAA ajoute une passe, une render target et deux programmes. Le tableau vient de WebGPU. Sur le backend WebGL 2, les chaînes avec bloom comptent un programme de plus, 14 et 16, et sur les runners de CI sans GPU, qui rendent avec SwiftShader sur le CPU, le pixel du halo de FXAA donne 135, 176, 197 : 3 de plus qu’ici, au-delà de l’écart de 2 par canal qu’accepte compare.mjs. check.sh garde ces sorties WebGL 2 dans expected/*.webgl.txt.
FXAA, après la transformation de sortie
Section intitulée « FXAA, après la transformation de sortie »FXAA lisse les bords crénelés en regardant le contraste entre pixels voisins, et « requires sRGB input », dit son code source : le tone mapping et la conversion sRGB doivent passer avant (FXAANode.js, lignes 5-6). La page désactive donc la transformation de sortie propre au pipeline, et l’applique dans le graphe avec renderOutput, avant fxaa. Dans ce mode, le renderer est créé avec antialias: false : le MSAA sur la passe de scène et FXAA après elle feraient deux fois le travail.
DirectRenderPipeline : pas de passe de sortie
Section intitulée « DirectRenderPipeline : pas de passe de sortie »Même sans effet, WebGPURenderer rend dans une render target, puis la copie à l’écran par une passe de sortie qui fait le tone mapping et la conversion sRGB : la ligne none a 1 render target et 2 appels par image. r186 ajoute DirectRenderPipeline, qui « applies output processing directly in material shaders », évitant « the intermediate framebuffer and output pass », mais « changes blending and is not compatible with materials that sample the framebuffer, such as transmissive materials » (lignes 6-20). Son render prend la scène et la caméra, contrairement à celui de RenderPipeline :
$ node scripts/probe.mjs 07-post --query pipeline=direct{ "frame": 4, "render": { "calls": 3, "drawCalls": 25, "triangles": 2924 }, "memory": { "geometries": 10, "textures": 1, "programs": 4 }, "pipeline": "direct", "threshold": 1, "renderTargets": 0, "pixels": { "inlay at fret 5": "rgb(242, 250, 252)", "halo, 0.06 beside it": "rgb(53, 28, 20)", "neck, 0.15 beside it": "rgb(53, 28, 20)", "background": "rgb(12, 15, 20)" }}Les mêmes quatre pixels que none, avec 0 render target et un appel par image. Les draw calls restent à 25, mais le 25e n’est plus un quad : un scene.background uni devient une sphère de 1 984 triangles dessinée derrière la scène (DirectRenderPipeline.js, lignes 156-175, et Background.js, ligne 131), d’où 2 924 triangles au lieu de 941. Sur un téléphone, une copie plein écran de moins par image peut compter ; mesurez-le sur l’appareil (à vérifier).
Ce que libère dispose()
Section intitulée « Ce que libère dispose() »Un composant qui crée un pipeline doit le libérer quand il disparaît. Le mode ?dispose= de la page libère après trois images et compte les render targets :
$ node scripts/probe.mjs 07-post --query dispose=pipeline{ "frame": 4, "render": { "calls": 42, "drawCalls": 37, "triangles": 953 }, "memory": { "geometries": 10, "textures": 14, "programs": 12 }, "pipeline": "bloom", "renderTargets": { "before": 12, "afterPipelineDispose": 12 }}$ node scripts/probe.mjs 07-post --query dispose=all{ "frame": 4, "render": { "calls": 42, "drawCalls": 37, "triangles": 953 }, "memory": { "geometries": 10, "textures": 1, "programs": 2 }, "pipeline": "bloom", "renderTargets": { "before": 12, "afterPipelineDispose": 12, "afterNodesDispose": 0 }}pipeline.dispose() libère un programme, le matériau de son quad de sortie, et aucune des 12 render targets : il ne libère que ce matériau (RenderPipeline.js, lignes 162-169). Les render targets appartiennent aux nœuds, et ne partent que lorsque la passe de scène et le nœud de bloom sont libérés eux aussi : il reste alors 0 render target, 1 texture et 2 programmes. Gardez donc une référence à chaque nœud qui possède une render target, et libérez-les avec le pipeline.
Dans GuitarAlchemist/ga
Section intitulée « Dans GuitarAlchemist/ga »-
L’ancien nom, et un pipeline jamais libéré.
Ocean/Ocean.tsximportePostProcessingdepuisthree/webgpu(ligne 10) et construit la même chaîne que cette leçon (lignes 534-541) :let postProcessing: PostProcessing | null = null;if (quality.enableBloom) {postProcessing = new PostProcessing(renderer);const scenePass = pass(scene, camera);const scenePassColor = scenePass.getTextureNode('output');const bloomPass = bloom(scenePassColor, quality.bloomStrength, quality.bloomRadius, quality.bloomThreshold);postProcessing.outputNode = scenePassColor.add(bloomPass);}Avec le three.js 0.180 de GA, le nom est correct ; à partir de r183, il journalise l’avertissement cité plus haut. Le nettoyage libère les contrôles, les géométries, les matériaux, les textures, le loader Draco et le renderer, mais ni le pipeline, ni
scenePass, nibloomPass(lignes 641-673). Savoir sirenderer.dispose()libère leurs render targets avec le renderer est à vérifier ; avec un renderer conservé d’un montage à l’autre, elles resteraient.PrimeRadiant/LunarLanderEngine.tsa la même chaîne sous le même nom (lignes 420-424). -
Des effets abandonnés sur le chemin WebGPU.
PrimeRadiant/ForceRadiant.tsxest passé àWebGPURendereravecconst USE_WEBGPU = true(lignes 1672-1676), et son commentaire en explique le coût : « The old EffectComposer + UnrealBloomPass + ShaderPass chain is disabled when useWebGPU is on », le remplacement en TSL étant « a follow-up task ». Le bloom n’est créé queif (!USE_WEBGPU)(lignes 1883-1898) : aujourd’hui, le graphe s’affiche sans bloom. Le nœudbloomde cette leçon est cette suite ; les effetsShaderPassqui viennent après lui devraient être portés en nœuds TSL, comme dans la leçon 6. -
EffectComposerbien utilisé, pour WebGL. Les 10 fichiers qui utilisentEffectComposerse terminent tous par uneOutputPass, redimensionnent le composer et le libèrent ;CheeseAvalanche/CheeseAvalanche.tsxen est un exemple compact (lignes 496-501, ligne 673 et ligne 682). Passer l’un d’eux àWebGPURendererrevient à réécrire la chaîne, pas à l’adapter.
Démo en direct : un studio
Section intitulée « Démo en direct : un studio »Les leçons 2, 3 et 7 réunies : une guitare sur un support, un projecteur avec une shadow map de 2048 × 2048 et un rayon doux, le studio.hdr de la leçon 3 comme environnement, le tone mapping ACES, et un anneau de néon dont la couleur est multipliée par 6, bien au-dessus de 1, pour que le bloom d’un RenderPipeline le reprenne. Sa sonde lit 62 rendus, 91 draw calls et 15 render targets après trois images. Le source est dans code/threejs/demos/studio.

Page en direct : la lancer ici
Ouvrir en plein écran. À essayer : le panneau change l’exposition, l’intensité de la lumière principale et celle de l’environnement, la force et le seuil du bloom, et la couleur du néon. Mettez bloom threshold à 0, comme dans l’exercice 1, pour voir ce qui d’autre dépasse le seuil. Un clic sur le manche joue une note, comme sur la guitare de la leçon 5.
Expérience GA
Section intitulée « Expérience GA »L’expérience 9 de la leçon 14 met du bloom sur les notes actives d’une touche : 0.14 ms par image 1080p sur le GPU de l’auteur, et un bloom sélectif par MRT coûte à peu près autant, tout en laissant le bois et les frettes nets.
À retenir
Section intitulée « À retenir »- Avec
WebGPURenderer, le post-traitement, c’estRenderPipeline: un graphe de nœuds TSL qui commence parpass(scene, camera), rendu parpipeline.render().PostProcessingest son nom d’avant r183. - Chaque effet coûte des draw calls, des programmes et des render targets qui grandissent avec le canvas : le bloom a ajouté ici 13 quads et 11 render targets.
- Les effets qui attendent du sRGB, comme FXAA, viennent après
renderOutput, avecoutputColorTransform = false. DirectRenderPipelinesupprime la passe de sortie quand aucun effet n’est nécessaire, avec des limites sur le blending et la transmission.pipeline.dispose()ne libère pas les render targets des passes : libérez chaque nœud qui en possède une.
Exercices
Section intitulée « Exercices »- Sondez la page avec
?threshold=0. Lesquels des quatre pixels changent par rapport au seuil par défaut de 1, et pourquoi le fond change-t-il autant ? - Les render targets du bloom sont dimensionnées d’après le canvas. Estimez, à partir de son code source, la mémoire de la première target horizontale du bloom sur un écran de 3840 × 2160, puis dites ce que
renderer.setPixelRatioy change. ForceRadiant.tsxde GA a besoin du bloom et d’une aberration chromatique sur le chemin WebGPU. Esquissez l’outputNode, avec le nœud que fournissent les addons.
Solution 1
$ node scripts/probe.mjs 07-post --query threshold=0{ "frame": 4, "render": { "calls": 42, "drawCalls": 37, "triangles": 953 }, "memory": { "geometries": 10, "textures": 14, "programs": 13 }, "pipeline": "bloom", "threshold": 0, "renderTargets": 12, "pixels": { "inlay at fret 5": "rgb(248, 252, 254)", "halo, 0.06 beside it": "rgb(183, 196, 207)", "neck, 0.15 beside it": "rgb(161, 146, 146)", "background": "rgb(66, 71, 84)" }}Le repère change à peine : il était déjà presque blanche. Tout le reste change : le halo passe de 127, 168, 190 à 183, 196, 207, le manche à 0.15 de 74, 81, 94 à 161, 146, 146, et le fond de 13, 17, 23 à 66, 71, 84. Avec un seuil de 0, chaque pixel est assez lumineux pour le bloom : le manche brun et le fond sombre sont floutés et rajoutés sur eux-mêmes, et toute l’image devient laiteuse. Le seuil compare la luminance, donc une scène éclairée dans la plage habituelle de 0 à 1 a besoin d’un seuil proche de 1, et les repères, émissifs au-delà de 1, sont les seuls pixels censés le franchir. Les compteurs ne changent pas : les mêmes passes s’exécutent quel que soit le seuil.
Solution 2
BloomNode dimensionne ses targets à la moitié du drawing buffer (_resolutionScale = 0.5), puis divise la taille par deux à chacun de ses cinq niveaux (BloomNode.js, lignes 322-341). Les targets sont en HalfFloatType, quatre canaux de 2 octets chacun, sans depth buffer (lignes 164-180). En 3840 × 2160, la première target horizontale fait 1920 × 1080 × 8 octets : 16 588 800 octets, environ 16.6 MB. La target des pixels lumineux a la même taille, et les targets horizontale et verticale de chaque niveau font un quart de celles du niveau au-dessus : 1 + 2 × (1 + 1/4 + 1/16 + 1/64 + 1/256) ≈ 3.66 fois 16.6 MB, environ 61 MB pour le seul bloom, avant la passe de scène. Ces valeurs sont calculées à partir du code source, pas mesurées dans la mémoire GPU.
setPixelRatio(2) sur un canvas CSS de 1920 × 1080 donne le même drawing buffer de 3840 × 2160. Les pages de la leçon plafonnent le ratio à 2 ; ThreeFretboard.tsx de GA accepte jusqu’à 6, soit 36 fois les pixels d’un ratio de 1, et 9 fois ceux d’un ratio de 2.
Solution 3
import { pass } from 'three/tsl';import { bloom } from 'three/addons/tsl/display/BloomNode.js';import { chromaticAberration } from 'three/addons/tsl/display/ChromaticAberrationNode.js';
const pipeline = new THREE.RenderPipeline(renderer);const scenePass = pass(scene, camera);const color = scenePass.getTextureNode('output');const glow = bloom(color, 0.6, 0.6, 0.5);const aberrated = chromaticAberration(color.add(glow));pipeline.outputNode = aberrated;La force, le rayon et le seuil sont ceux que ForceRadiant passe à UnrealBloomPass sur desktop. chromaticAberration(node, strength = 1.0, center = null, scale = 1.1) se trouve dans three/addons/tsl/display/ChromaticAberrationNode.js (lignes 152-163). Cette esquisse passe tsc avec les types de r186, mais n’a pas été rendue : la force qui correspond au ShaderPass de ForceRadiant est à vérifier sur la page. Gardez scenePass, glow et aberrated pour les libérer avec le pipeline.
- Documentation de three.js :
RenderPipeline,PassNode,BloomNode,FXAANode,EffectComposer; la page post-processing du manuel, écrite pourWebGLRenderer - Code source de three.js à r186 :
RenderPipeline.js,DirectRenderPipeline.js,PostProcessing.js,BloomNode.js,FXAANode.js - MonoGame :
RenderTarget2D; WPF :ShaderEffect