Texturer son terrain 3d en pixel art avec Godot
Dans ce billet, je vais détailler quelques techniques utilisées pour appliquer une texture dynamique en pixel-art sur un terrain 3d avec Godot.

Un peu de contexte
Les jeux vidéos ont toujours fait partie de ma vie. Pourtant, il a fallu attendre ma 40e année pour que je m'essaye au développement de JV.
(Je laisse de côté la fois ou j'ai été collé au lycée parce que j'ai créé un prototype de jeu de street-fight permettant de faire se bastonner mes profs de philo et physique.)
J'ai consacré un peu de mon récent temps libre à l'apprentissage du moteur de jeu open-source Godot. Sans avoir de direction très claire, j'ajoute des fonctionnalités un peu au gré de mon inspiration du moment. Et comme je suis un sentimental, je me laisse guider par la nostalgie.
Dans mon enfance, j'étais particulièrement fan des jeux de type « god-game ». Et tout en haut de la liste de mes jeux préférés, il y a Populous 3.
Populous: À l'aube de la création
Populous est un jeu de stratégie sorti en 1998. Le gameplay est somme toute relativement basique : au travers d'une série de niveau, il s'agit de faire croître son village avant d'aller poutrer les tribus concurrentes.
Au fur et à mesure du jeu, on acquiert de nouveaux bâtiments donnant accès à de nouvelles unités, et surtout de nouveaux sorts, utilisables par la shaman. Limitée à de petites boules de feu ou des nuages de mouches au début du jeu, la Shaman finit par devenir une machine de guerre divine, capable d'ensevelir l'adversaire sous des pluies de boules de feu, faire s'envoler son village à coup de tornades, ou carrément l'engloutir sous un volcan.
Si j'étais tant fasciné par ce jeu, c'est je crois pour deux raisons.
D'abord, les possibilités de terraformation étaient incroyables : au cours de la partie, on pouvait complètement remodeler le terrain à coup de sorts, en fabriquant des ponts, en érodant le terrain, en faisant naître des volcans. Pour l'époque, une telle souplesse était complètement folle.
Surtout, les graphismes étaient incroyables ! Admirez ces quelques captures — qui vous feront sans doute un peu rire — mais voyez la manière dont le terrain paraît vivant. Et surtout, gardez à l'esprit que le rendu était absolument dynamique : vous pouviez lancer un pont entre deux collines, voir le terrain entre les deux pousser, et la texture se transformer — passer d'une prairie verdoyante de basse altitude à une terre rocailleuse puis un désert enneigé, le tout en temps réel.
Prenez n'importe quel jeu de plus d'une 15aine d'année et les graphiques paraissent ridicules. Mais pas Populous. Presque 30 ans plus tard, je retrouve la même fascination quand je me refais une partie nostalgique.
Mon projet
Cette intro commence à s'éterniser, rentrons dans le vif du sujet.
J'ai créé un prototype avec Godot, qui permet d'afficher un terrain en 3D, avec des fonctionnalités de terraformation. J'ai réalisé qu'il était très difficile d'obtenir un visuel satisfaisant autant en vue d'ensemble qu'en zoom extrême.
Je voulais m'inspirer du rendu dynamique et vivant de Populous, mais aussi du côté un peu rétro très pixelisé.
J'ai donc cherché à comprendre comment était codé le rendu sur Populous, et jai ré-implémenté cette technique avec Godot, et c'est ce que je vais partager ici.
Voici quelques captures du résultat actuel.
La configuration de l'arbre Godot
Nous allons implémenter ces techniques avec Godot pas-à-pas. Première étape, créons l'objet qui va représenter le terrain.
Note : je ne rentrerai pas trop dans les détails car cette étape revient à implémenter la documentation existante.
Dans l'arborescence de fichiers, je sélectionne « Create new -> Scene ». Puis dans l'arborescence des nœuds, je sélectionne « Add child node -> MeshInstance3D » que je renomme en « Terrain ».
On sélectionne le terrain nouvellement créé, et dans l'inspecteur, propriété « Mesh », on sélectionne « new PlaneMesh ».
Dans les propriétés du Mesh, on spécifie une taille de 128x128 (par exemple) et une subdivision de 127x127.
Si on configure l'éditeur 3D pour afficher une vue Wireframe, on obtient normalement ceci. Nice.
Toujours dans les propriétés du MeshInstance3D, dans « Material », j'ajoute un « ShaderMaterial », « Shader -> new Shader » auquel je donne un nom. Et hop ! un shader tout neuf.
Note : ce tutoriel n'a pas pour but de détailler comment fonctionnent les shaders, il existe déjà une abondante documentation sur le sujet. Je présuppose donc que vous savez un minimum de quoi il retourne. Sinon, je vous redirige vers l'excellente documentation de Godot sur le sujet.
Prenons de l'altitude
Nous voulons un terrain dont la texture évolue dynamiquement avec la topologie. Mais notre terrain actuel est tout plat. Triste ! Commençons par y remédier.
À notre shader, ajoutons trois paramètres :
- une texture qui contiendra les variations d'altitude (entre zéro et un) ;
- la même texture qui permettra de spécifier les normales (et donc de calculer correctement l'éclairage du terrain) ;
- un multiplicateur pour obtenir l'altitude finale.
Voici le code utilisé :
shader_type spatial;
group_uniforms Elevation;
uniform sampler2D height_tex: hint_default_black, filter_linear, repeat_disable;
uniform sampler2D normal_map_tex: hint_default_black, filter_linear, repeat_disable;
uniform float height_max = 15.0;
void vertex() {
float height = texture(height_tex, UV).r;
float final_height = height * height_max;
VERTEX.y = final_height;
}
void fragment() {
vec3 normal = texture(normal_map_tex, UV).rgb;
NORMAL_MAP = normal;
}
Dans l'inspecteur de propriété, nous voyons apparaître les nouvelles options. Dans « Height Tex », j'ajoute un « New NoiseTexture2D » puis dans « Noise » -> « New FastNoiseLite ».
Ensuite, je copie la texture dans en la faisant glisser dans « Normal Map Tex » puis en sélectionnant « Make unique (recursive) », puis je sélectionne « as normal map ».
Enfin, j'ai légèrement modifié l'angle du Soleil pour rendre l'ombrage de relief un peu plus apparent. Vous devriez obtenir quelque chose de similaire.
Jusque là, nous n'avons fait que suivre le tutorial. Passons à l'étape suivante.
Première technique, le dégradé de couleur
Lorsque l'on observe les captures d'écran de Populous en détail, on remarque que globalement, la texture / couleur évolue en fonction de l'altitude.
Ainsi, l'altitude zéro est toujours en bleu et représente l'eau, on trouve souvent des couleurs rappelant le sable à basse altitude, puis des prairies verdoyantes ou des montagnes rocailleuses au fur et à mesure que l'on s'élève.
C'est d'ailleurs une technique de rendu de terrain très classique qu'on retrouve un peu partout. Qu'à cela ne tienne, ça sera notre première étape.
À notre shader, ajoutons un paramètre gradient
: une texture qui contiendra un gradient de couleur.
shader_type spatial;
group_uniforms Elevation;
uniform sampler2D height_tex: hint_default_black, filter_linear, repeat_disable;
uniform sampler2D normal_map_tex: hint_default_black, filter_linear, repeat_disable;
uniform float height_max = 15.0;
group_uniforms Rendering;
uniform sampler2D gradient: source_color, filter_linear, repeat_disable;
void vertex() {
float height = texture(height_tex, UV).r;
float final_height = height * height_max;
VERTEX.y = final_height;
}
void fragment() {
vec3 normal = texture(normal_map_tex, UV).rgb;
NORMAL_MAP = normal;
float height = texture(height_tex, UV).r;
ALBEDO = texture(gradient, vec2(height, 0.0)).rgb;
}
Dans l'inspecteur, sélectionnons la nouvelle propriété puis ajoutons un nouveau « GradientTexture1D ». Godot va alors gentiment créer une texture de 1px de haut qui contiendra un dégradé de couleur.
De manière très rapide, j'utilise l'interface de Godot pour établir une petite sélection de couleurs. Du bleu pour les basses altitudes, du blanc pour les hauteurs, un peu de jaune, de vert, de marron entre les deux…
Revenons en détail sur ce code :
float height = texture(height_tex, UV).r;
ALBEDO = texture(gradient, vec2(height, 0.0)).rgb;
La texture height_tex
contient l'altitude du pixel courant, et cette valeur est normalisée — c'est à dire qu'elle vaut entre zéro et un.
Or, les coordonnées de lecture de textures (le fameux paramètre UV) sont également comprises entre zéro et un. Ici, on a donc tout naturellement une correspondance directe entre l'altitude et la position dans le gradient de couleur. Chouette !
Le résultat ressemble à ça.
C'est déjà un peu moins tristounet que notre mesh tout gris, mais on est encore très loin du résultat final.
Un peu de bruit
Notre terrain est triste parce qu'il est désespéremment lisse. En effet, à une altitude précise correspond une couleur exacte. Le monde véritable est heureusement moins monotone. Comment rendre notre rendu un poil plus excitant ?
Comme dans 95% des cas dans le développement de jeux vidéos, la réponse est « avec du bruit ».
Au moment de lire la valeur du gradient de couleur, nous allons ajouter un peu de bruit ce qui va produire un résultat moins lisse.
Ajoutons de nouveaux paramètres au shader.
uniform sampler2D noise_micro_tex: filter_linear, repeat_enable;
uniform float noise_micro_strength: hint_range(0.0, 1.0) = 0.2;
Nous indiquons à Godot que cette texture doit être répétée, ce qui nous permet de la « plaquer » plusieurs fois sur notre terrain.
Dans l'inspecteur, configurons une nouvelle texture de type « NoiseTexture2D », n'oublions pas de cocher l'option « Seamless », puis de créer un nouveau « FastNoiseLite ». Sélectionnons un bruit de type « Value » qui s'approche du bruit blanc.
Enfin, nous allons modifier notre shader de la manière suivante :
// La couleur de base correspond à l'altitude du pixel courant
float height = texture(height_tex, UV).r;
float color_index = height;
// Nous allons chercher la valeur du bruit pour le pixel courant
// l'idée est d'aller chercher une valeur un peu avant ou après dans le gradient de couleur
float noise_micro = texture(noise_micro_tex, UV).r;
// Le bruit est une valeur entre zéro et un. Je veux perturber la couleur à la hausse ou à la baisse
// donc je « centre » la valeur en soustrayant 0.5
noise_micro -= 0.5;
// Enfin, on applique le coefficient de perturbation
color_index += noise_micro * noise_micro_strength;
ALBEDO = texture(gradient, vec2(color_index, 0.0)).rgb;
Le résultat est déjà un peu moins barbant !
Vous pouvez jouer avec les paramètres de la texture noise_micro_tex
ou le paramètre noise_micro_strength
pour adapter le rendu à votre goût.

Un peu de pixel art ?
En l'état, le résult est déjà pas trop mal. Mais rappelez-vous, nous cherchons à obtenir un style un peu rétro. Et pour ça, on veut une bonne grosse soupe de pixels (c'est très à la mode, par ailleurs).
La texture de bruit utilisée a une taille finie (512px ici), mais lorsqu'on la « plaque » sur le terrain, le GPU réalise automatiquement une interpolation afin d'obtenir une valeur continue. C'est pourquoi le résultat obtenu est toujours « lisse ».
Pour pixeliser notre résultat, nous allons indiquer au GPU de cesser cette inoportune interpolation, et de lire uniquement la valeur du pixel le plus proche.
Pour cela, un changement minimal est nécessaire.
Avant :
uniform sampler2D noise_micro_tex: filter_linear, repeat_enable;
Après :
uniform sampler2D noise_micro_tex: filter_nearest, repeat_enable;
Notez maintenant que la grille de pixel devient apparente, notre terrain n'est plus lisse. Ça y-est, nous pouvons qualifier notre prototype de « rétro ».
Ajoutons un nouveau rafinement : je veux pouvoir contrôler la densité de pixels, c'est à dire que je veux que mon terrain soit rendu avec X pixels au mètre carré (par exemple).
Indroduisons un nouveau paramètre.
uniform float pixel_density: hint_range(4.0, 32.0) = 16.0;
Et réfléchissons un peu.
La texture de notre terrain fait 128px de côté. J'ai créé un mesh de 128m de long, donc 1px = 1m².
La texture de bruit a une largeur de 512px (la valeur par défaut avec Godot). 4 fois la largeur de la texture précédente.
J'applique ces deux textures sur la même surface. Donc un pixel de la texture de terrain « couvre » 16 pixels de la texture de bruit.
J'ai donc obtenu une densité de pixel de 16.
Que dois-je faire si je veux une valeur différente ? La solution la plus évidente serait de modifier la taille de la texture de bruit. Mais ça ne serait pas très efficace.
L'autre solution est de simplement répéter la texture, car n'oublions pas qu'elle est « tileable ». Pour ce faire, il nous suffit de multiplier le paramètre UV par la bonne valeur.
vec2 world_size = vec2(textureSize(height_tex, 0));
vec2 noise_texture_size = vec2(textureSize(noise_micro_tex, 0));
vec2 ratio = world_size / noise_texture_size;
vec2 noise_uv_scale = pixel_density * ratio;
vec2 noise_uv = UV * noise_uv_scale;
float noise_micro = texture(noise_micro_tex, noise_uv).r;
Avec une densité de 4x4 :
Avec une densité de 16x16 :
De la vraie texture
Reprenons l'exemple de Populous. On peut constater que le terrain est texturé, au delà du simple bruit aléatoire. On peut reconnaître les ondulations d'une prairie, les vagues d'une dune de sable ou les crevasses d'une terre asséchée.
En inspectant les données brutes du jeu, on peut arriver à extraire des textures appelées « displacement maps ». Ce sont des textures répétables en noir et blanc. On peut alors combiner ces textures avec la technique décrite précédemment pour obtenir un rendu qui s'approche vraiment du résultat attendu.
Voici quelques uns des fichiers directement extraits du répertoire contenant les données du jeu.
Pour ce tutoriel, j'ai réutilisé ces textures telles quelles, toutefois n'importe quelle texture en noir et blanc peut faire l'affaire.
Ajoutons de nouveaux paramètres au shader.
group_uniforms Displacement;
uniform sampler2D displacement_tex: filter_nearest, repeat_enable;
uniform float disp_uv_scale = 16.0;
uniform float displacement_strength: hint_range(0.0, 1.0) = 0.2;
Puis plus bas dans le fragment shader :
vec2 disp_uv = UV * disp_uv_scale;
float displacement = texture(displacement_tex, disp_uv).r;
color_index += (displacement - 0.5) * displacement_strength;
Je conseille d'adapter les coefficients d'intégration pour la texture de bruit et celle de déplacement. J'utilise 0.1 pour la première, 0.2 pour la seconde.
Le résultat est fascinant. En changeant la texture utilisée, on va conserver le gradient de couleur en fonction de l'altitude, mais en lui conférant un motif reconnaissable.
Cool n'est-ce pas ?
Attention, une fois intégrée, cette texture va elle aussi introduire de la pixelisation. C'est probablement une bonne idée de garder une cohérence entre les tailles de textures et les coefficients multiplicateurs d'UV pour conserver un résultat correct.
Un peu de subtilité
Le résultat commence à devenir intéressant, mais à ce stade, le côté répétitif de la texture est bien trop visible. Nous allons intégrer une nouvelle texture (encore) pour essayer d'atténuer ça.
uniform sampler2D noise_macro_tex: filter_nearest, repeat_enable;
float noise_macro = texture(noise_macro_tex, noise_uv).r;
displacement *= noise_macro;
Avant :
Après :
Prendre de la hauteur
Le résultat commence à être vraiment sympa !
Il reste un problème lorsque l'on s'éloigne du terrain toutefois. En effet, à distance, le rendu est extrêmement granuleux et « sale ». Pour améliorer ça, on va demander à Godot 1/ d'utiliser les mipmap des textures et 2/ d'utiliser un filtrage anisotropique.
Avant :
uniform sampler2D noise_macro_tex: filter_nearest, repeat_enable;
uniform sampler2D noise_micro_tex: filter_nearest, repeat_enable;
uniform sampler2D displacement_tex: filter_nearest, repeat_enable;
Après :
uniform sampler2D noise_macro_tex: filter_nearest_mipmap_anisotropic, repeat_enable;
uniform sampler2D noise_micro_tex: filter_nearest_mipmap_anisotropic, repeat_enable;
uniform sampler2D displacement_tex: filter_nearest_mipmap_anisotropic, repeat_enable;
Le résultat n'est pas forcément très clair sur les captures, il faudra peut-être zoomer un peu.
Avant :
Après :
On ajoute des normales
Populous étant un jeu contraint par la technique de son époque, l'éclairage du terrain n'était pas calculé en temps réel.
En fait, le moteur de jeu se contentait globalement d'assombrir ou éclaircir la texture en fonction de différents paramètres : angle de la pente, direction par rapport au Soleil, etc. (avec beaucoup de goût je dois dire).
En ce qui nous concerne, nous allons adopter des outils un peu plus modernes. Pour améliorer encore la texture de notre sol, nous allons modifier le vecteur normal.
Pour générer une carte normale à partir d'une carte de déplacement, je passe par Gimp : filtre > générique > normal map. Et hop ! On obtient une carte normale.
Ensuite, dans le shader :
uniform sampler2D displacement_normal_tex: hint_normal, repeat_enable;
vec3 normal = texture(normal_map_tex, UV).rgb;
vec3 displacement_normal = texture(displacement_normal_tex, disp_uv).rgb;
normal += displacement_normal * 0.15;
NORMAL_MAP = normalize(normal);
Cette modif va permettre à la texture de ressortir en réagissant un peu plus à la lumière.
Sans la normale :

Avec la normale :

Conclusion
Il y aurait encore pas mal d'améliorations à apporter, mais on peut s'arrêter là pour le moment.
Voici le code du shader complet :
shader_type spatial;
group_uniforms Elevation;
uniform sampler2D height_tex: hint_default_black, filter_linear, repeat_disable;
uniform sampler2D normal_map_tex: hint_default_black, filter_linear, repeat_disable;
uniform float height_max = 15.0;
group_uniforms Rendering;
uniform sampler2D gradient: source_color, filter_linear, repeat_disable;
uniform float pixel_density: hint_range(4.0, 32.0) = 16.0;
group_uniforms Noise;
uniform sampler2D noise_macro_tex: filter_nearest_mipmap_anisotropic, repeat_enable;
uniform sampler2D noise_micro_tex: filter_nearest_mipmap_anisotropic, repeat_enable;
uniform float noise_micro_strength: hint_range(0.0, 1.0) = 0.1;
group_uniforms Displacement;
uniform sampler2D displacement_tex: filter_nearest_mipmap_anisotropic, repeat_enable;
uniform sampler2D displacement_normal_tex: hint_normal, repeat_enable;
uniform float disp_uv_scale = 16.0;
uniform float displacement_strength: hint_range(0.0, 1.0) = 0.2;
void vertex() {
float height = texture(height_tex, UV).r;
float final_height = height * height_max;
VERTEX.y = final_height;
}
void fragment() {
// Find base color position
float height = texture(height_tex, UV).r;
float color_index = height;
// Disturb color with basic noise
vec2 world_size = vec2(textureSize(height_tex, 0));
vec2 noise_texture_size = vec2(textureSize(noise_micro_tex, 0));
vec2 ratio = world_size / noise_texture_size;
vec2 noise_uv_scale = pixel_density * ratio;
vec2 noise_uv = UV * noise_uv_scale;
float noise_micro = texture(noise_micro_tex, noise_uv).r;
color_index += (noise_micro - 0.5) * noise_micro_strength;
// Add some texture with displacement map
vec2 disp_texture_size = vec2(textureSize(displacement_tex, 0));
vec2 disp_uv = UV * disp_uv_scale;
float displacement = texture(displacement_tex, disp_uv).r;
// Merge some noise to make the tiles less visible
float noise_macro = texture(noise_macro_tex, noise_uv).r;
displacement *= noise_macro;
color_index += (displacement - 0.5) * displacement_strength;
ALBEDO = texture(gradient, vec2(color_index, 0.0)).rgb;
// Compute normal map
vec3 normal = texture(normal_map_tex, UV).rgb;
vec3 displacement_normal = texture(displacement_normal_tex, disp_uv).rgb;
normal += displacement_normal * 0.15;
NORMAL_MAP = normalize(normal);
}
En modifiant la texture de déplacement et le dégradé, on obtient très rapidement des résultats très chouettes. Je trouve ça fabuleux d'obtenir un tel résultat à partir de techniques de presque 30 ans. Je vous laisse avec quelques exemples pour la fin.