Retour au Blog
Game Development5 août 202610 min de lecture

Shaders personnalisés dans Godot. Mon expérience avec 28 shaders

DR

Doug Ribeiro

10 min de lecture

Quand j'ai commencé à construire mon RPG tactique dans Godot 4, un monde 3D avec des sprites de personnages 2D, j'avais une liste mentale claire des choses que je ne savais pas faire. Les shaders étaient en tête de cette liste. Pas près du sommet. Tout en haut, souligné deux fois. Je savais que les shaders étaient des programmes exécutés sur le GPU. Je savais qu'ils contrôlaient l'apparence des pixels. Au-delà de ça, j'avais une peur diffuse des transformations de vertex et une hypothèse de travail selon laquelle les shaders étaient réservés aux spécialistes avec un background en programmation graphique, pas aux développeurs d'applications qui construisaient des interfaces Angular pour gagner leur vie.

En février 2026, environ un an après le début, le projet comptait 28 shaders personnalisés. De la pluie avec réfraction et profondeur. De l'accumulation de neige sur les surfaces. Du terrain destructible qui révèle une sous-surface carbonisée en dessous. Un cycle jour/nuit dynamique qui fait varier la température de couleur sur toute la scène. Des shaders de mise en surbrillance d'unités qui pulsent et indiquent la portée de déplacement. Je n'ai pas embauché de programmeur graphique. J'ai tout appris seul, principalement par essais et erreurs, la documentation de Godot et beaucoup de navigation sur ShaderToy.

Cet article est le récit honnête de ce parcours. Ce qui était plus difficile que prévu, ce qui était étonnamment accessible, et comment l'environnement shader de Godot se compare au GLSL brut.

Le langage shader de Godot vs le GLSL brut

Godot utilise son propre langage de shader, qui est basé sur GLSL mais n'est pas du GLSL. Il possède les mêmes types fondamentaux, vec2, vec3, vec4, sampler2D, mat4, et les mêmes fonctions mathématiques. Mais il ajoute des fonctions intégrées spécifiques à Godot, cache le code boilerplate derrière une structure plus propre, et s'intègre directement au pipeline de matériaux et de rendu.

Pour un débutant, c'est un avantage réel. Écrire directement contre l'API graphique, OpenGL ou Vulkan, veut dire gérer soi-même les vertex buffers, les uniform buffers et les framebuffers. Le système de shaders de Godot s'occupe de tout ça. On déclare un shader_type, on implémente les fonctions fragment() ou vertex(), et Godot fournit tout ce dont on a besoin. On peut se concentrer sur l'effet visuel plutôt que sur la plomberie de l'API graphique.

Le piège, c'est que la documentation de Godot sur les shaders est complète mais pas toujours accessible aux débutants. La référence explique ce que fait chaque fonction intégrée. Elle n'explique pas toujours pourquoi on l'utiliserait ni comment les choses interagissent. J'ai passé beaucoup de temps à lire la documentation puis le code source des shaders de Godot pour comprendre comment les matériaux intégrés étaient implémentés avant de pouvoir les étendre.

Le premier shader : mise en surbrillance d'unité

Mon premier vrai shader personnalisé était une mise en surbrillance d'unité. La lueur pulsante qui indique qu'une unité est sélectionnée dans mon jeu tactique. Concept simple : prendre le sprite de l'unité, le faire briller avec un contour pulsant, et changer la couleur de la lueur selon que l'unité est une unité du joueur, un ennemi ou un allié.

Je m'attendais à ce que ça prenne une journée. Ça en a pris quatre. Pas parce que le code du shader était complexe. Le shader final fait environ 30 lignes. Mais parce que je ne comprenais pas les systèmes de coordonnées de Godot, je ne comprenais pas comment l'échantillonnage UV fonctionnait pour les contours, et je confondais constamment l'espace écran avec l'espace texture. La percée est venue quand j'ai compris qu'un effet de contour fonctionne en échantillonnant les pixels voisins et en vérifiant si l'un d'entre eux est transparent là où le pixel actuel est opaque. C'est tout. Une fois que j'ai compris ça, l'implémentation était simple.

glsl
// Godot Shader Language, unit highlight with pulse
shader_type canvas_item;

uniform vec4 outline_color : source_color = vec4(1.0, 1.0, 0.0, 1.0);
uniform float outline_width : hint_range(0.0, 10.0) = 2.0;
uniform float pulse_speed : hint_range(0.0, 5.0) = 2.0;
uniform float pulse_min : hint_range(0.0, 1.0) = 0.4;

void fragment() {
    vec2 size = outline_width * TEXTURE_PIXEL_SIZE;
    vec4 color = texture(TEXTURE, UV);

    float alpha = 0.0;
    alpha = max(alpha, texture(TEXTURE, UV + vec2(size.x, 0.0)).a);
    alpha = max(alpha, texture(TEXTURE, UV - vec2(size.x, 0.0)).a);
    alpha = max(alpha, texture(TEXTURE, UV + vec2(0.0, size.y)).a);
    alpha = max(alpha, texture(TEXTURE, UV - vec2(0.0, size.y)).a);

    float pulse = pulse_min + (1.0 - pulse_min) *
        (0.5 + 0.5 * sin(TIME * pulse_speed));

    vec4 outline = outline_color * vec4(1.0, 1.0, 1.0, pulse);

    COLOR = mix(outline * (1.0 - color.a), color, color.a);
    COLOR.a = max(color.a, alpha * outline_color.a * pulse);
}

Shaders météo : pluie et neige

Les effets météo ont été les premiers shaders dont j'étais véritablement fier du résultat. Mon shader de pluie fonctionne comme un effet plein écran CanvasLayer et crée l'illusion de pluie en superposant plusieurs textures de bruit défilantes à des vitesses et opacités différentes, puis en appliquant une passe de réfraction qui distord légèrement la scène en dessous.

La pluie en elle-même n'était pas la partie difficile. Faire défiler une texture est l'une des premières choses qu'enseigne tout tutoriel de shader. La partie difficile était la réfraction, faire en sorte que les traînées de pluie distordent visiblement ce qui se trouve derrière elles, ce qui nécessite de lire la texture d'écran à une position UV décalée. Godot 4 l'expose via un uniform sampler2D avec hint_screen_texture (l'ancien built-in SCREEN_TEXTURE a disparu), mais l'interaction entre l'ordonnancement z des CanvasLayer et le moment où la texture d'écran est capturée est subtile et m'a fait passer une soirée entière avec un rendu incorrect avant de comprendre l'ordre de capture.

La neige était en fait plus facile que la pluie parce que je n'avais pas besoin de réfraction. Le shader de neige utilise une approche de particules dans le shader. Je génère les positions des flocons de neige de manière procédurale en utilisant des ondes sinusoïdales et du bruit fractionnel, ce qui donne une distribution naturelle sans avoir besoin d'un vrai système de particules. Le shader expose la force et la direction du vent comme des uniforms, donc je peux animer le vent via GDScript pendant les événements météo.

L'accumulation de neige, le deuxième shader de neige, était plus intéressante. Elle lit un masque de texture personnalisé qui définit où la neige peut s'accumuler (surfaces planes, dessus des murs) et mélange progressivement entre la texture de base et une version enneigée en utilisant un uniform piloté par le temps. Combiné avec le shader de chute de neige, la scène passe de dégagée à enneigée d'une manière qui affecte à la fois l'atmosphère et le sol.

Le cycle jour/nuit : 6 shaders qui travaillent ensemble

Mon cycle jour/nuit n'est pas un seul shader. Ce sont six shaders qui se coordonnent entre eux via des uniforms partagés pilotés par un noeud central DayCycleManager. Chaque shader gère un aspect du changement visuel : la température de couleur globale, l'intensité des ombres, la couleur de la lumière ambiante, le dégradé du ciel, la visibilité des étoiles, et la position de la lune/du soleil.

Concevoir ce système m'a enseigné la leçon d'architecture shader la plus importante que je connaisse : les shaders doivent être pilotés par les données, pas directement par le temps. Ma première tentative faisait lire TIME indépendamment par chaque shader et calculer la phase actuelle de la journée de son côté. Le résultat était une incohérence subtile. Chaque shader portait sa propre copie du calcul de phase, alors de petites différences les faisaient dériver les uns par rapport aux autres, et aucun ne suivait l'horloge du jeu : le TIME de Godot continue de tourner pendant que le jeu est en pause et boucle toutes les 3 600 secondes par défaut.

L'approche correcte : le DayCycleManager calcule une seule valeur day_progress normalisée de 0.0 à 1.0 à chaque tick _process() et la transmet comme uniform aux six shaders. Chaque shader utilise la même valeur dans la même frame. Le rendu visuel est parfaitement synchronisé.

DayCycleManager (C#)Transmet l'uniform day_progress (0.0 à 1.0) à tous les shaders à chaque… 1 Shader de dégradé du cielInterpole entre les palettes de couleurs aube, jour, crépuscule et nuit 2 Shader de température de couleurFait passer toute la scène de l'aube chaude à la nuit froide 3 Shader de lumière ambianteAjuste l'intensité ambiante et la couleur de la lumière ambiante 4 Shader d'ombre des personnagesChange la direction et la douceur des ombres selon l'angle du soleil 5 Shader de visibilité des étoilesFait apparaître le champ d'étoiles et le halo de la lune aux valeurs de… 6 Shader de position du soleil et de la lunePlace le disque du soleil ou de la lune pour le day_progress courant 7

Shaders d'environnement destructible

Les shaders les plus techniquement complexes du projet sont les effets d'environnement destructible. Quand un mur subit des dégâts, je veux que la surface visible se fissure, se carbonise et finisse par s'effondrer. Le shader pour ça lit un uniform de valeur de dégâts et l'utilise pour mélanger entre trois couches de texture : surface intacte, surface fissurée et sous-surface carbonisée.

L'effet de fissuration utilise un motif de bruit de Voronoi pour générer la géométrie des fissures de manière procédurale. À mesure que les dégâts augmentent, le seuil de fissure diminue et davantage de bords de cellules de Voronoi deviennent visibles, donnant une apparence de fracturation naturelle. Les zones carbonisées utilisent une passe séparée de détection de bords qui met en surbrillance les bords des fissures en orange-rouge avant qu'ils n'exposent complètement la texture de sous-surface.

Réussir ça a nécessité de comprendre comment mélanger entre des couches de texture en utilisant un masque, comment générer du bruit de Voronoi dans un shader (ce qui est plus complexe que le bruit de Perlin), et comment mapper correctement la valeur de dégâts de la logique de jeu aux seuils de mélange visuels. Le shader final fait environ 80 lignes et a nécessité presque deux semaines d'itération sur mon temps libre pour arriver à un résultat satisfaisant.

Ce qui était étonnamment facile

L'échange de palette. Je m'attendais à ce que ce soit compliqué. Remplacer un ensemble de couleurs par un autre pour les variations de couleur des unités. Ça ne l'est pas. On crée une petite texture de palette, on échantillonne la couleur du sprite, on cherche la position de cette couleur dans la palette source, puis on retourne la couleur correspondante de la palette cible. Si le sprite est conçu avec une palette limitée (ce qui est le cas du mien, par choix), tout l'échange se fait en environ 15 lignes de code shader.

Les effets de dissolution étaient aussi plus faciles que prévu. Échantillonner une texture de bruit, comparer la valeur de bruit à un uniform de seuil de dissolution, et rejeter le pixel s'il est en dessous du seuil. La dissolution ronge le sprite selon un motif de bruit plutôt qu'un simple balayage. Ajoutez une couleur de bord à la limite du seuil et vous obtenez un effet de bord brûlant gratuitement. J'utilise ça pour les animations de mort des unités.

  • Étalonnage des couleurs et application de LUT. Simple recherche dans une texture 3D
  • Effets de contour simples. Échantillonnage de pixels voisins comme décrit plus haut
  • Lignes de balayage en espace écran. Motif répétitif sur UV.y, rien de plus
  • Clignotement de sprite (pour le retour de dégâts). Mélange entre la couleur du sprite et le blanc avec un uniform
  • Révélation du brouillard de guerre. SDF circulaire sur la couche de brouillard, transition douce en bordure

Ce qui était véritablement difficile

Le normal mapping pour les sprites de personnages. Godot supporte les normal maps sur les sprites, mais les faire paraître correctes pour des personnages 2D placés dans un monde 3D, sous une seule direction de lumière cohérente, a pris beaucoup de temps à ajuster. Le problème est que les normal maps supposent un modèle d'éclairage spécifique, et le rendu artistique que je voulais n'était pas ce modèle. J'ai fini par écrire un shader d'éclairage de sprites personnalisé qui interprète la normal map différemment pour produire un résultat plus stylisé.

Le tri de la transparence. Quand plusieurs shaders transparents s'empilent les uns sur les autres, la pluie par-dessus une surbrillance d'unité transparente par-dessus un brouillard semi-transparent, le moteur de rendu peut produire un mélange incorrect selon l'ordre de dessin. Comprendre l'ordre de dessin, le z-index des CanvasItem pour la couche de sprites, et quand la composition de transparence se fait correctement a nécessité un temps de débogage significatif.

L'optimisation des performances. Les shaders tournent sur le GPU, donc les shaders individuels sont rapides. Mais 28 shaders fonctionnant simultanément sur une scène avec beaucoup d'objets visibles ont commencé à montrer un impact sur les performances sur le matériel bas de gamme. J'ai dû ajouter des niveaux de qualité. Des versions simplifiées des shaders météo qui désactivent les opérations coûteuses comme la réfraction en réglages basse qualité. Le profilage de l'utilisation GPU dans Godot est moins mature que le profilage CPU, ce qui a rendu ça plus difficile que ça n'aurait dû l'être.

Le transfert de compétences vers le travail d'interface web

Écrire des shaders a changé ma façon de penser les effets visuels dans mon travail d'interface web. Les animations CSS, les filtres SVG, les effets canvas. Ils fonctionnent tous sur les mêmes primitives mathématiques que le code shader. Lerp, smoothstep, fonctions de bruit, coordonnées UV. Quand je vois un effet de filtre CSS maintenant, je pense automatiquement à ce que le code shader équivalent ressemblerait. Quand j'implémente une animation de transition sur un composant Angular, je pense à quelle courbe d'interpolation donnerait le bon ressenti.

Ça m'a aussi donné une appréciation beaucoup plus profonde de ce que le matériel GPU fait réellement. Les développeurs Angular ont tendance à considérer les transformations et animations CSS comme de la magie. Savoir qu'elles se compilent en opérations GPU avec le même genre de multiplications de matrices et d'échantillonnage de textures que je fais dans les shaders fait de moi un développeur mieux informé quand je prends des décisions de performance.

Les shaders paraissent intimidants de l'extérieur parce que le vocabulaire n'est pas familier. Une fois qu'on réalise qu'ils sont simplement des fonctions qui transforment des entrées en couleurs de pixels, et que ces entrées sont juste des vecteurs et des textures, l'intimidation s'estompe et la créativité commence.

Références

GodotShadersGame DevelopmentVisual Effects