📍 Article principal de la série : Three.js, React Three Fiber et WebGPU en 2026 : 3D temps réel sur le web
Introduction
TSL, pour Three Shading Language, est l’API JavaScript de Three.js qui remplace l’écriture manuelle de shaders GLSL ou WGSL. Plutôt que de coder un programme bas niveau dans un fichier séparé, on compose un graphe de nœuds dans son fichier .ts, avec autocomplétion et type-checking, et le compilateur Three.js produit le code GPU adapté au renderer actif. Ce tutoriel construit pas à pas un premier shader TSL : un matériau qui mélange deux couleurs en fonction de la position, anime ce mélange dans le temps, et lit une texture pour moduler le rendu. À la fin, on saura ce qu’est un nœud, comment le composer, et comment debugger quand le résultat ne correspond pas à l’intention.
Prérequis
- Un projet R3F v9 fonctionnel avec Three.js r171+
- Notions de mathématiques shader : interpolation, vec2/vec3/vec4, espaces objet/monde
- Une compréhension de base de ce qu’est un vertex shader et un fragment shader
- Temps estimé : 35 à 50 minutes
Étape 1 — Comprendre ce qu’est un nœud TSL
Un nœud TSL est un objet JavaScript qui représente une opération sur des données GPU. Il peut être une lecture (la position du vertex courant, l’UV du fragment, une uniform JS injectée dans le shader), une opération (addition, multiplication, lookup de texture), ou une sortie (la couleur finale du fragment, la position projetée du vertex). On compose ces nœuds en chaînant des appels de méthode, et le résultat est un autre nœud qui peut servir d’entrée à un autre.
// Schéma conceptuel
import { positionLocal, time, vec3, mix } from 'three/tsl'
const colorA = vec3(1, 0, 0)
const colorB = vec3(0, 0, 1)
const t = time.mul(0.5) // un nœud "temps multiplié par 0.5"
const blend = positionLocal.y.add(t).fract() // 0..1 oscillant
const final = mix(colorA, colorB, blend) // interpolation
Aucune ligne ci-dessus n’exécute de calcul GPU au moment où elle s’évalue. Chaque appel construit une description du calcul. C’est seulement quand on assigne ce nœud à la propriété colorNode d’un matériau et qu’on rend la scène que Three.js compile le graphe en WGSL ou GLSL et l’envoie à la GPU. Cette nature déclarative est ce qui permet à TSL de cibler indifféremment WebGPU et WebGL : c’est le compilateur, pas l’auteur du shader, qui se préoccupe du backend.
Étape 2 — Importer correctement les nœuds TSL
Les nœuds TSL vivent dans deux sous-chemins de Three.js. three/tsl expose les nœuds eux-mêmes (positionLocal, uv, texture, mix, time, etc.). three/webgpu expose les matériaux compatibles avec ces nœuds (MeshBasicNodeMaterial, MeshStandardNodeMaterial, NodeMaterial). Sans ces matériaux, on ne peut pas brancher un graphe TSL sur un mesh.
// Imports types pour un shader TSL
import { positionLocal, uv, texture, time, mix, vec3 } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
Cet import est valide à partir de Three.js r171, où la séparation a été stabilisée. Avant cette version, les chemins variaient et les exemples trouvés sur des blogs anciens ne fonctionnent plus tels quels. La page TSL de la documentation liste l’ensemble des nœuds exportés et leur signature.
Étape 3 — Premier matériau TSL : couleur fixe
On commence par le shader minimal : un matériau qui colore le mesh avec une couleur fixe, sans utiliser la moindre dynamique. C’est le « hello world » du shader, et il valide que la chaîne de compilation fonctionne avant qu’on n’ajoute de la complexité.
// src/components/SimpleTslBox.tsx
import { vec3 } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useMemo } from 'react'
export default function SimpleTslBox() {
const material = useMemo(() => {
const m = new MeshBasicNodeMaterial()
m.colorNode = vec3(1, 0.4, 0) // orange
return m
}, [])
return (
<mesh material={material}>
<boxGeometry args={[1, 1, 1]} />
</mesh>
)
}
Le useMemo garantit qu’on n’instancie qu’une seule fois le matériau, sinon chaque re-render créerait un nouveau matériau et fuiterait des ressources GPU. La propriété colorNode est l’entrée du graphe TSL : on lui assigne un nœud qui produit un vec3 (le composant final RGB du fragment). À l’écran, on doit voir un cube orange uni, sans variation. Si le cube est noir, c’est que le renderer actif ne supporte pas les NodeMaterial — typiquement le WebGLRenderer classique au lieu du WebGPURenderer. La solution est documentée dans le tutoriel Activer WebGPU en R3F avec fallback WebGL 2.
Étape 4 — Lire l’UV et créer un dégradé
Pour montrer la composition, on remplace la couleur fixe par un dégradé qui interpole deux couleurs en fonction de la coordonnée UV horizontale. C’est l’occasion de découvrir uv et mix, deux nœuds qu’on retrouvera partout par la suite.
import { uv, mix, vec3 } from 'three/tsl'
const colorA = vec3(1, 0.2, 0.1) // rouge orangé
const colorB = vec3(0.1, 0.4, 1) // bleu
material.colorNode = mix(colorA, colorB, uv().x)
Le nœud uv() retourne un vec2 contenant les coordonnées de texture du fragment courant, dans l’intervalle [0, 1]. On accède à la composante x via la propriété .x (TSL surcharge l’accès aux composantes pour reproduire la syntaxe shader). Le résultat est un cube avec une face teintée du rouge à gauche au bleu à droite. Cette opération est typiquement ce qui prendrait dix lignes de GLSL et un fichier séparé ; en TSL, c’est trois lignes lisibles.
Étape 5 — Animer dans le temps
Pour rendre la scène vivante, on remplace le facteur fixe d’interpolation par un facteur qui dépend du temps. Le nœud time retourne le temps écoulé depuis le démarrage du renderer en secondes, sous forme d’un nœud scalaire qu’on peut combiner à volonté.
import { uv, mix, time, vec3 } from 'three/tsl'
const colorA = vec3(1, 0.2, 0.1)
const colorB = vec3(0.1, 0.4, 1)
// oscillation 0..1 à la fréquence d'environ 0.3 Hz
const t = time.mul(0.3).sin().mul(0.5).add(0.5)
material.colorNode = mix(colorA, colorB, t.add(uv().x).fract())
Plusieurs choses se passent. time.mul(0.3) ralentit le temps brut à 0.3 fois sa vitesse, ce qui donne une période d’environ 21 secondes pour un cycle complet. .sin() applique un sinus, qui oscille entre -1 et 1. .mul(0.5).add(0.5) remappe cette oscillation dans [0, 1]. Le .fract() final enroule la valeur quand elle dépasse 1, créant un défilement continu sans saut. À l’écran, on voit les couleurs dériver progressivement sur la surface du cube, comme une vague colorée qui se déplace. Le shader s’exécute pour chaque fragment, à chaque frame, à plusieurs millions d’échantillons par seconde — sans aucune ligne de GLSL écrite par nous.
Étape 6 — Lire une texture dans le shader
Les vrais matériaux mélangent souvent des textures avec du calcul procédural. Le nœud texture permet de lire un échantillon d’une Texture Three.js classique. On charge une texture avec useTexture de Drei et on la branche dans le graphe TSL.
import { texture, vec3 } from 'three/tsl'
import { useTexture } from '@react-three/drei'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useMemo } from 'react'
export default function TexturedTslBox() {
const tex = useTexture('/textures/concrete.jpg')
const material = useMemo(() => {
const m = new MeshBasicNodeMaterial()
// texture(tex) renvoie un vec4 ; .rgb extrait le vec3 pour multiplier proprement la teinte
m.colorNode = texture(tex).rgb.mul(vec3(1.0, 0.7, 0.7))
return m
}, [tex])
return (
<mesh material={material}>
<boxGeometry args={[1, 1, 1]} />
</mesh>
)
}
L’étape clé est de bien dépendre de tex dans le useMemo, faute de quoi le matériau pointe vers une texture pas encore chargée. texture(tex) retourne un vec4 RGBA — on extrait .rgb pour rester en vec3 et appliquer un teint composante par composante via .mul(vec3(...)). Le résultat est un cube qui montre le motif de la texture, mais avec une chaleur de couleur ajustée. Cette technique du teint multiplicatif est extrêmement courante : elle permet d’avoir une palette de quelques textures neutres et de produire des dizaines de variantes de couleur sans dupliquer les fichiers.
Étape 7 — Animer la position des vertices
Jusqu’ici on a manipulé le fragment shader (la couleur finale). TSL permet aussi d’agir sur le vertex shader via positionNode, qui décide de la position projetée de chaque vertex. C’est ainsi qu’on crée des effets de vague, de flottement, de morphing.
import { positionLocal, time, vec3, sin, normalLocal } from 'three/tsl'
// Déplace chaque vertex selon une onde sinus dépendant de sa hauteur
const wave = sin(positionLocal.y.mul(4).add(time.mul(2))).mul(0.1)
material.positionNode = positionLocal.add(normalLocal.mul(wave))
L’onde dépend de la coordonnée Y du vertex et du temps, ce qui produit un déplacement vertical qui se propage. On déplace le vertex dans la direction de la normale locale, multipliée par l’amplitude calculée. Le résultat est une déformation qui suit la forme du mesh : sur une sphère, on obtient une pulsation respiratoire ; sur un plan, une houle. Le coût GPU est négligeable pour quelques milliers de vertices et reste raisonnable pour des centaines de milliers.
Étape 8 — Inspecter le code généré
Quand un shader ne fait pas ce qu’on attend, il est précieux de regarder le code WGSL ou GLSL réellement produit. Le WebGPURenderer récent expose renderer.debug.getShaderAsync(scene, camera, mesh) qui retourne le source du shader pour un mesh donné — vérifier la signature exacte dans la documentation correspondant à votre version de Three.js. On l’invoque dans la console DevTools après avoir mis le renderer dans un objet global accessible.
// Dans une scène, exposer le renderer pour debug
const { gl } = useThree() // R3F v9 : state.gl ; v10 alpha : state.renderer
;(window as any).__renderer = gl
Puis, dans la console : await window.__renderer.debug.getShaderAsync(material, scene, mesh). La sortie est un objet contenant les sources vertex et fragment, qu’on peut copier dans un éditeur séparé pour analyser ce que le compilateur a produit. Cette inspection révèle souvent qu’un nœud qu’on pensait innocent introduit une boucle ou une branche coûteuse, et oriente l’optimisation.
Étape 9 — Encapsuler un shader TSL dans un hook réutilisable
Quand le projet grossit, on veut pouvoir réutiliser un shader sur plusieurs mesh sans dupliquer le code. Le pattern propre est de créer un hook personnalisé qui retourne un matériau prêt à l’emploi, paramétré par les valeurs qui changent d’un usage à l’autre. C’est la composition au sens React, appliquée au monde GPU.
// src/hooks/useWaveMaterial.ts
import { positionLocal, time, sin, normalLocal, vec3, mix, uv } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useMemo } from 'react'
type Options = { colorA: [number, number, number]; colorB: [number, number, number]; speed?: number }
export function useWaveMaterial(opts: Options) {
return useMemo(() => {
const m = new MeshBasicNodeMaterial()
const a = vec3(...opts.colorA)
const b = vec3(...opts.colorB)
const t = time.mul(opts.speed ?? 0.3).sin().mul(0.5).add(0.5)
m.colorNode = mix(a, b, t.add(uv().x).fract())
const wave = sin(positionLocal.y.mul(4).add(time.mul(2))).mul(0.05)
m.positionNode = positionLocal.add(normalLocal.mul(wave))
return m
}, [opts.colorA[0], opts.colorA[1], opts.colorA[2], opts.colorB[0], opts.colorB[1], opts.colorB[2], opts.speed])
}
Le hook prend les couleurs et la vitesse en paramètres, retourne un matériau mémoïsé qui ne se recrée que si les paramètres changent. Côté composant consommateur, l’usage est limpide : const mat = useWaveMaterial({ colorA: [1,0,0], colorB: [0,0,1] }) puis <mesh material={mat}>. C’est le pattern qu’on retrouve dans toutes les bibliothèques de shaders réutilisables comme lamina. Cette mise en abstraction est la dernière étape avant de pouvoir utiliser TSL sereinement à l’échelle d’un projet réel.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
| Mesh noir avec un NodeMaterial | Renderer WebGL classique au lieu du WebGPURenderer | Activer WebGPU comme dans le tutoriel précédent |
Cannot find module 'three/tsl' |
Three.js antérieur à r171 | Mettre à jour |
| Shader marche en WebGPU mais pas en WebGL | Nœud non transpilable utilisé | Vérifier la matrice TSL ou conditionner par renderer kind |
| Texture noire dans le matériau | Texture pas encore chargée au moment de la création du matériau | Recréer le matériau avec une dépendance correcte au useMemo |
| Couleurs trop pâles ou trop saturées | Mismatch d’espace colorimétrique | Vérifier texture.colorSpace et le sortir en sRGB |
| FPS qui s’effondre avec un shader simple | Création d’un nouveau matériau à chaque frame | useMemo avec dépendances correctes |
Tutoriels frères
- Activer WebGPU en R3F avec fallback WebGL 2
- Post-processing en WebGPU avec TSL : bloom, DoF, vignette
À lire ensuite
- 🔝 Retour au guide principal : Three.js, React Three Fiber et WebGPU en 2026
- TSL — documentation officielle
- TSL — galerie d’exemples Three.js
- The Book of Shaders — fondamentaux mathématiques
FAQ
TSL remplace-t-il GLSL ?
Pas obligatoirement. Three.js continue de supporter ShaderMaterial et le GLSL brut. TSL est l’option recommandée pour les nouveaux projets et pour la portabilité WebGPU/WebGL, mais une équipe avec une expertise GLSL existante peut continuer comme avant.
Peut-on mélanger TSL et GLSL dans un même shader ?
Pas dans un même nœud. On peut en revanche avoir certains matériaux en TSL et d’autres en GLSL dans la même scène. La cohabitation est gérée par Three.js sans difficulté.
Le compilateur TSL produit-il du code aussi performant qu’un GLSL écrit à la main ?
Pour la quasi-totalité des cas, oui. L’optimiseur du driver GPU lisse les différences. Pour des shaders extrêmement chauds (pixel shader d’un sol qui occupe 80% de l’écran avec des dizaines de samples), un profilage peut révéler 5 à 15% de différence selon les cas, dans un sens ou dans l’autre. Le bénéfice de productivité dépasse largement cet écart.
Comment partager des uniforms entre plusieurs matériaux ?
On crée une uniform via uniform() de TSL, on la stocke dans une variable accessible aux deux matériaux, et on la mute via .value = ... chaque frame ou à chaque changement. Le coût GPU est négligeable et c’est la pratique recommandée pour les paramètres globaux (heure du jour, palette saisonnière).
Peut-on debugger TSL pas à pas comme du JavaScript classique ?
Pas directement, parce que le code s’exécute sur la GPU. Mais on peut imprimer le source compilé (étape 8), assigner une valeur intermédiaire à la couleur de sortie pour la visualiser, ou utiliser Spector.js pour capturer une frame complète. Ces trois techniques couvrent l’essentiel des cas.
Comment gérer les changements d’API entre versions de Three.js ?
La règle de prudence est de pinner précisément la version de three dans le package.json et de ne mettre à jour qu’après avoir lu les notes de release. TSL est encore jeune et certaines méthodes ont été renommées récemment, par exemple label() devenu setName(). Le guide de migration officiel liste ces changements à chaque release et permet d’éviter les surprises.
Quel matériau choisir entre MeshBasicNodeMaterial, MeshStandardNodeMaterial et NodeMaterial nu ?
MeshBasicNodeMaterial n’utilise pas l’éclairage, parfait pour des UI 3D ou des effets purement graphiques. MeshStandardNodeMaterial intègre le PBR avec metalness/roughness et reçoit les lumières de la scène, c’est ce qu’on veut pour des objets physiquement plausibles. NodeMaterial nu donne le plus de contrôle mais demande de tout câbler manuellement, à réserver aux cas avancés. Pour démarrer, MeshStandardNodeMaterial est presque toujours le bon choix.
Comment intégrer une fonction TSL dans une bibliothèque partagée ?
La structure recommandée est de regrouper les fonctions par thème (lighting, noise, color) dans des fichiers .ts qui exportent des fonctions retournant des nœuds. Chaque fonction prend ses paramètres en entrée explicite et retourne un graphe composable. Cette modularité fait que les shaders gagnent en lisibilité et que la couverture de test devient envisageable, au moins pour la logique mathématique sous-jacente.