Texturer son terrain 3d en pixel art avec Godot

Tutoriel

Dans ce billet, je vais détailler quelques techniques — inspirées de Populous 3, jeu mythique de 1998 — utilisées pour appliquer une texture dynamique en pixel-art sur un terrain 3d avec Godot.

Texturer son terrain 3d avec Godot – 11

Quand j'ai commencé à travailler sur le terrain dans Rêvivarium, j'ai cherché à reproduire la richesse du rendu de mon jeu préféré : Populous 3.

Le terrain dans Rêvivarium avec une esthétique pixel-art.

Cet article retrace cette exploration et détaille l'implémentation complète dans un shader Godot.

Le terrain que nous allons construire dans cet article. Cliquez-glissez la souris pour pivoter, et utilisez la molette pour contrôler la distance caméra. À tester directement sur Shadertoy.

Populous: À l'aube de la création

Populous est un jeu de stratégie sorti en 1998. Le gameplay est somme toute relativement classique : au travers d'une série de niveaux, il s'agit de faire croître son village avant d'aller poutrer les tribus concurrentes. Notamment grâce aux pouvoirs de la Shaman, qui acquiert de nouveaux sorts à mesure que le jeu progresse.

Dans Populous 3, dirigez vos adeptes et lancez des sorts

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. C'était assez dingue pour l'époque.

Imaginez la satisfaction jouissive de parvenir à faire naître un volcan au milieu du camp de l'adversaire

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.

Chaque niveau présente un environnement complètement unique

3d Low-poly pixel-art

Je ne suis pas le seul développeur de 40 balais à me laisser influencer par la nostalgie des bons vieux jeux en 640x480 de ma jeunesse.

Il existe une mouvance de jeux qui appliquent un style un peu « hommage » que j'appellerais Low-poly Pixel-art.

Quelques exemples seront plus parlant.

Beyond the plastic wall — Motvind Studios, Arte

Cloud Gardens — Noio Games

EthrA — Stonelab games

Long gone — Hillfort game

Tenkemo — Meh Studio

En résumé, le style est un mélange de technique moderne et d'influence rétro. En haute résolution, on applique un texturage volontairement très pixelisé sur des modèles 3d simplifiés, et on intègre parfois des sprites en 2d.

Le pixel-art dans Rêvivarium

Un des défis auquel j'ai dû faire face est que dans Rêvivarium, le joueur peut autant zoomer pour observer un brin d'herbe de près, ou dézoomer jusqu'à voir la carte entière.

De l'herbe en gros plan

Zoom sur toute la carte

Or, un des éléments qui font que le style "marche" est que la densité de pixels est globalement constante à l'écran.

On veut surtout éviter d'avoir un modèle avec des mini-pixels et un autre avec des pixels dix fois plus gros.

Dans ces conditions, comment garantir que l'esthétique pixelisée soit conservée ?

Dans la suite de cet article, nous allons ré-implémenter un terrain en 3d avec Godot en détaillant les techniques que j'ai utilisées.

Décortiquer le rendu de Populous

J'ai passé pas mal de temps à essayer de comprendre comment fonctionnait le rendu de Populous. À première vue, le principe repose sur une technique très classique : un dégradé associe une couleur à chaque altitude. Mais si ce n'était que ça, le résultat n'aurait pas la richesse visuelle attendue.

Sur un terrain en 3d, un dégradé seul produit des bandes de couleur uniformes

Le cœur de la technique de Populous repose sur l'utilisation de textures de déplacement (displacement maps) qui perturbent localement la sélection de couleur. Deux points à la même altitude peuvent ainsi recevoir des couleurs légèrement différentes, ce qui casse les aplats et donne au terrain son motif et sa richesse visuelle.

Le même terrain avec une texture de déplacement — le motif brise les aplats

C'est cette idée — un dégradé d'altitude perturbé par une texture de déplacement — qui constitue la base de notre technique. La suite de l'article s'attaque à deux problèmes : comment implémenter ce rendu dans un shader Godot, et comment le rendre pixelisé avec une densité qui s'adapte à la distance caméra.

Une des textures de déplacement directement extraite du code du jeu

Une des textures de déplacement directement extraite du code du jeu

Une des textures de déplacement directement extraite du code du jeu

Préparer le terrain (haha)

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.

  • créez un nouveau « Plane Mesh » ;
  • spécifiez une taille de 128x128 et une subdivision de 127x127 ;
  • ajoutez un nouveau material de type « Shader Material ».

Un projet tout neuf avec un simple Plane Mesh (vue en wireframe)

Le shader prêt à être configuré

Note : Je vous redirige vers l'excellente documentation de Godot sur le sujet si vous ne savez pas ce que sont les shaders.

Préparer un rendu basique

Implémentons une version initiale avec le cœur de la technique : une carte d'altitude, un dégradé de couleurs, et une texture de déplacement.

shader_type spatial;

group_uniforms World;
uniform vec2 world_size = vec2(128.0);  // Size of the terrain mesh

group_uniforms Elevation;
uniform sampler2D height_tex: hint_default_black, filter_linear_mipmap, repeat_disable;
uniform sampler2D normal_map_tex: hint_default_black, filter_linear_mipmap, repeat_disable;
uniform float height_max = 15.0;

group_uniforms Rendering;
uniform sampler2D gradient: source_color, filter_linear, repeat_disable;

group_uniforms Displacement;
uniform sampler2D displacement_tex: filter_linear_mipmap, repeat_enable;
uniform float displacement_strength: hint_range(0.0, 1.0) = 0.2;
uniform vec2 displacement_tex_size = vec2(4.0);  // World-space area one tile covers

void vertex() {
    // Sample the height texture to set the terrain topology
    float height = texture(height_tex, UV).r;
    VERTEX.y = height * height_max;
}

void fragment() {
    // Find base color depending on terrain height
    float height = texture(height_tex, UV).r;
    float color_index = height;

    // Add some texturing using the displacement map
    vec2 displacement_tiling = world_size / displacement_tex_size;
    vec2 displacement_uv = displacement_tiling * UV;
    float displacement = texture(displacement_tex, displacement_uv).r;

    // Use displacement to select the terrain color
    color_index += (displacement - 0.5) * displacement_strength;
    color_index = clamp(color_index, 0.0, 1.0);
    ALBEDO = texture(gradient, vec2(color_index, 0.0)).rgb;

    // Make sure lighting is correct
    vec3 normal = texture(normal_map_tex, UV).rgb;
    NORMAL_MAP = normal;
}

Dans la valeur « Height Tex », créez une nouvelle « NoiseTexture2D » qui vous configurerez à votre convenance. Assurez vous de cocher « Generate Mipmaps ».

Ensuite, glissez-déposez cette texture dans le champ « Normal Map Tex », ouvrez le menu juste à côté de la prévisualisation de la texture, sélectionnez « Make unique (recursive) » (sinon, les deux textures ne pourront pas être éditées indépendemment). Ensuite ouvrez la texture et cliquez sur « As normal map ».

Pour le dégradé de couleur, créez un nouveau « GradientTexture1D » et là encore, libre à vous de sélectionner l'assemblage de couleurs qui vous convient.

La « Displacement tex » est la texture qui donne son motif au terrain. Pour ça, vous pouvez utiliser n'importe quelle texture « tileable ». Pour ce tutoriel, j'ai utilisé celle-ci :

Une simple texture en niveaux de gris

Vous pourrez admirer le résultat de votre travail :

Notre nouveau terrain aux couleurs chamarrées

La texture donne un motif qui transparaît via la sélection des couleurs

En zoomant de près, on constate que le rendu est parfaitement lisse — conséquence de l'interpolation linear déclarée sur nos textures.

Le texturage est parfaitement lissé

Faites entrer les pixels

Pour obtenir un rendu pixelisé, ajoutons un nouvel uniform au shader:

uniform float pixel_density: hint_range(4.0, 32.0, 1.0) = 16.0;

Ce paramètre nous permettra de spécifier une grille de x pixels de côté par mètre.

Ainsi, si j'ai un terrain de 128m de côté, et que je veux obtenir 16 pixels par mètre linéaire (donc 16x16=256 pixels par mètre carré), cela fera un total de 2048 pixels de côté. Il suffit pour concrétiser cette grille de discretiser la valeur UV.

Par ailleurs, deux éléments jouent dans ce qui détermine la couleur du terrain : l'altitude et la carte de déplacement. On prendra soin d'échantillonner ces deux valeurs via les UV discretisées.

La portion pertinente du shader devient :

// What is the width of the pixel-art grid?
vec2 grid_factor = world_size * pixel_density;

// Quantize the UVs
vec2 grid_uv = (floor(UV * grid_factor) + 0.5) / grid_factor;

// Add some texturing using the displacement map
vec2 displacement_tiling = world_size / displacement_tex_size;
vec2 displacement_uv = grid_uv * displacement_tiling;
float displacement = texture(displacement_tex, displacement_uv).r;

// Find base color depending on terrain height
float height = texture(height_tex, grid_uv).r;

Le motif avant la pixelisation

Le même motif une fois pixelisé

Problème d'artefacts et mipmaps

Ça n'est pas très visible sur les captures mais le résultat actuel présente un problème très désagréable à l'œil.

Les jointures entre les cellules présentent des artefacts (zoomez un peu pour bien les voir)

En effet, les jointures entre les pixels semblent présenter des artefacts. Vous ne le verrez pas sur les captures statiques mais en se déplaçant, ces jointures présentent par ailleurs un scintillement particulièrement exaspérant.

Expliquer — et résoudre — ce problème va nécessiter de se plonger un peu dans la façon dont fonctionne un GPU.

Revenons sur la déclaration de notre texture de déplacement : filter_linear_mipmap. Le drapeau linear demande au GPU d'interpoler entre les texels — c'est ce qui produit le rendu lisse constaté de près. Le drapeau mipmap active la sélection automatique entre plusieurs versions pré-calculées de la texture à résolution décroissante, les mipmaps.

Que sont les mipmaps ? Lorsqu'un modèle 3d texturé est vu de loin, plusieurs texels se retrouvent correspondre au même fragment — ce qui produit des artefacts visuels. Les mipmaps règlent ce problème. À l'échantillonnage, le GPU sélectionne le bon niveau de mipmap en fonction de la densité de texels à l'écran [en].

Reste à déterminer comment le GPU calcule cette densité. Pour ça, il évalue la rapidité d'évolution de l'UV d'un fragment à l'autre. Si l'UV change beaucoup en passant d'un pixel à l'autre sur votre écran, c'est que la texture est vue de loin, et on peut sélectionner un mipmap élevé.

Quand vous échantillonnez une texture ainsi dans le fragment shader:

float displacement = texture(displacement_tex, displacement_uv).r;

Le gpu compare la valeur actuelle de displacement_uv avec celle calculée par le fragment d'à côté, et en déduit une valeur de mipmap.

Voyez-vous le problème désormais ? Puisque nous avons discretisé l'UV, la valeur est identique d'un fragment à l'autre pour l'intérieur de chaque cellule : le GPU considère que la texture est vue de près et sélectionne un mipmap bas. Puis, au passage d'une cellule à l'autre, le changement brusque amène le GPU à sélectionner un mipmap complètement différent juste à la jointure.

Pour résoudre le problème, deux solutions : soit on spécifie manuellement le mipmap désiré en utilisant textureLod, soit on calcule nous-même la variation d'UV en passant par textureGrad.

// Manually compute UV derivative for accurate mipmap selection
vec2 duvdx = dFdx(UV);
vec2 duvdy = dFdy(UV);

// Sample the displacement texture
float displacement = textureGrad(
    displacement_tex, displacement_uv,
    duvdx * displacement_tiling,
    duvdy * displacement_tiling).r;

// Find base color depending on terrain height
float height = textureGrad(height_tex, grid_uv, duvdx, duvdy).r;

Les jointures sont désormais propres

Qui a volé mes pixels ?

Prenons de la hauteur pour constater notre travail

En prenant de la distance, l'effet pixelisé disparaît complètement

Damnatation 🏊 ! Qui a volé nos pixels ?!

Le problème est que nous avons introduit une pixelisation taillée pour une certaine distance par rapport à la caméra. À grande distance, nos blocs pixelisés se perdent et deviennent simplement… des pixels.

Dans un jeu ou la distance entre la caméra est à hauteur à peu près constante, la technique précédente peut suffire. Mais si dans votre jeu la distance à la caméra peut évoluer — et que vous voulez maintenir une esthétique pixelisée à tout instant — il va falloir ruser.

Étudions les solutions à cet épineux problème.

Ajuster la densité avec la distance

Dans une projection en perspective, la surface linéaire occupée par un objet est inversement proportionnelle à sa distance.

Concrètement, chaque fois que la distance entre l'objet et la caméra double, le côté de la surface occupée à l'écran est divisée par deux.

Pour conserver une pixelisation à peu près constante quelle que soit le niveau de zoom, on va logiquement diviser par deux la densité de la grille de pixels quand la distance double.

Une caméra en vue plongeante qui regarde un terrain plat. Plus on s'éloigne de l'aplomb, plus la distance augmente, moins la grille de pixels est dense.

Calcul de la distance fragment / caméra

Pour calculer la distance fragment / caméra il nous faut les coordonnées de la caméra — que Godot nous fournit bien diligemment — et les coordonnées du fragment — dont nous ne disposons pas.

Il faudra donc la récupérer au moyen d'un varying.

varying vec3 world_vertex;

void vertex() {
    
    VERTEX.y = height * height_max;

    // VERTEX is in model coordinates, and we need world coordinates
    world_vertex = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}

void fragment() {
    float camera_dist = distance(CAMERA_POSITION_WORLD, world_vertex);
    

Ajustement de la densité

On veut aussi rendre configurable la distance de référence en deça de laquelle on conserve la densité de référence.

Ajoutons un nouvel uniform:

uniform float pixel_reference_dist: hint_range(1.0, 50.0) = 5.0;

La fonction qui incrémente chaque fois que son paramètre double est le log2.

void fragment() {
    float camera_dist = distance(CAMERA_POSITION_WORLD, world_vertex);

    // LOD band: increments each time camera distance doubles past min distance
    float lod = max(0.0, log2(camera_dist / pixel_reference_dist));
    float lod_band = floor(lod);

    // Halve density for each lod increment
    float band_density = pixel_density / exp2(lod_band);
    vec2 grid_factor = world_size * band_density;
    

Admirons le résultat

La densité de la grille s'adapte avec la distance

Même vu de très loin, le terrain conserve son esthétique pixelisée

Alleeer plus haaaauuuuuuut…

Gérer les transitions entre bandes

On pourrait croire qu'on en a fini, pourtant il n'en est rien.

Notre shader affiche un plan continu. La grille est pixelisée, mais la distance fragment/caméra varie continûment : le passage d'une densité à l'autre se fait sans considération d'aucune grille, créant des « bandes » inélégantes.

Observez précisément la frontière entre deux bandes de densité, et voyez qu'elle brise la grille

Il faut être attentif pour constater le problème sur une image fixe, mais quand la caméra est en mouvement c'est carrément insupportable.

Quand la caméra est en mouvement, la transition abrupte d'une bande de densité à l'autre est très visible

En pixel-art, un pixel ne peut pas être coupé sous peine d'opprobre. Il nous faut donc remplacer cette transition abrupte (et si j'ose dire carrément disgracieuse) par une zone de transition progressive.

Observons que les deux grilles adjacentes sont alignées : la grille fine est une subdivision de la grille grossière, donc chaque pixel grossier contient 2×2 = 4 pixels fins.

Notre stratégie : dans la zone de transition, nous allons progressivement « promouvoir » des pixels de la grille fine vers la grille grossière. Les 4 pixels fins adjacents prennent un par un la couleur du pixel grossier qui les contient. À la fin de la transition, chaque pixel fin a été promu, et la grille grossière devient la nouvelle grille fine de la bande suivante.

Identifier la zone de transition

Commençons par repérer se trouvent ces zones. La partie fractionnelle de lod indique la position du fragment au sein de sa bande : 0 au début, 1 juste avant la bande suivante. On s'en sert pour créer une rampe de transition dans les derniers band_transition_width pourcent de chaque bande.

uniform float band_transition_width: hint_range(0.0, 0.5) = 0.3;

On remplace temporairement le shader avec une visualisation de debug.

void fragment() {
    float camera_dist = distance(CAMERA_POSITION_WORLD, world_vertex);
    float lod = max(0.0, log2(camera_dist / pixel_reference_dist));
    float transition = smoothstep(1.0 - band_transition_width, 1.0, fract(lod));

    // Debug: visualize the transition zones
    ALBEDO = vec3(transition);

On visualise la répartition des bandes et les zones de transition

On voit très bien les différentes bandes de densité

En noir, un fragment appartenant au cœur d'une bande ; en dégradé noir vers blanc, la zone de transition vers la bande suivante.

Promouvoir les pixels

Nous calculons les deux grilles — fine et grossière — puis, dans la zone de transition, nous sélectionnons l'une ou l'autre pour chaque pixel fin.

// Fine grid: snap UVs to the center of the current band's pixel cells
float density_fine = pixel_density / exp2(lod_band);
vec2 grid_factor_fine = world_size * density_fine;
ivec2 fine_cell = ivec2(floor(UV * grid_factor_fine));
vec2 grid_uv_fine = (vec2(fine_cell) + 0.5) / grid_factor_fine;

// Coarse grid: the next band's cells, at half the density
vec2 grid_factor_coarse = grid_factor_fine * 0.5;
vec2 grid_uv_coarse = (floor(UV * grid_factor_coarse) + 0.5) / grid_factor_coarse;

// Progressively promote fine pixels to the coarser grid
float transition = smoothstep(1.0 - band_transition_width, 1.0, fract(lod));
bool use_fine = ???
vec2 grid_uv = use_fine ? grid_uv_fine : grid_uv_coarse;

Reste la question centrale : comment choisir quels pixels promouvoir, et dans quel ordre ?

Dithering et matrice de Bayer

Il reste à implémenter la dernière étape : la fameuse sélection de pixel pour la promotion.

On récap : pour chaque pixel fin, on a besoin d'un seuil de promotion. Dans la zone de transition, chaque pixel dont le seuil est dépassé bascule vers la grille grossière.

Une exigence importante : les promotions sont spatialement dispersées — à chaque instant de la transition, les pixels déjà promus sont répartis de la manière la plus uniforme possible.

On pourrait utiliser une valeur aléatoire et croiser les doigts, mais il existe une technique qui répond exactement à cet usage : le dithering (ou tramage en bon français).

Le dithering est notamment utilisé pour tramer des valeurs discrètes afin de donner la perception d'une transition plus douce.

Un dégradé utilisant seulement deux couleurs. Le dithering crée une impression de continuité.

La technique nécessiterait des articles entiers [en] mais ce serait hors-périmètre. On utilise généralement une matrice de Bayer — un tableau en 2d contenant l'ordre de promotion pour chaque emplacement du motif.

0 12 3 15 8 4 11 7 2 14 1 9 10 6 13 5
Étape 0 / 16

La propriété clé de cette matrice : à chaque seuil, les cellules déjà promues sont réparties le plus uniformément possible dans la grille — c'est ce qui garantit une transition visuellement homogène.

float bayer4x4(ivec2 cell) {
    int x = cell.x & 3;  // équivalent % 4
    int y = cell.y & 3;
    mat4 m = mat4(
        vec4(0, 12, 3, 15),
        vec4(8, 4, 11, 7),
        vec4(2, 14, 1, 9),
        vec4(10, 6, 13, 5)
    );

    // Convert promotion order to transition threshold
    return (m[x][y] + 0.5) / 16.0;
}

Le motif se répète tous les 4 pixels. Chaque pixel grossier contient 2x2=4 pixels fins, le motif 4x4 couvre 2x2 pixels grossiers, ce qui garantit de la variété au sein de chaque pixel et entre pixels voisins.

// Bayer dithered band transition. Each fine cell gets a fixed Bayer
// threshold. As fract(lod) nears 1.0, `transition` ramps up and cells
// whose threshold falls below it are promoted to the coarse grid.
float transition = smoothstep(1.0 - band_transition_width, 1.0, fract(lod));
float bayer = bayer4x4(fine_cell);
vec2 grid_uv = bayer > transition ? grid_uv_fine : grid_uv_coarse;

Quand transition vaut 0 (début de bande), tous les seuils de Bayer sont supérieurs : chaque pixel reste sur la grille fine. À mesure que transition augmente, les cellules dont le seuil est le plus bas sont promues en premier. Quand transition atteint 1, la totalité des pixels a basculé vers la grille grossière, qui devient à son tour la grille « fine » de la bande suivante.

Plusieurs bandes de densité distinctes coexistent sans briser la grille de pixels

Plusieurs bandes de densité distinctes coexistent sans briser la grille de pixels

La transition se fait sans briser la grille

Shader complet

Voici l'implémentation complète.

shader_type spatial;

group_uniforms World;
uniform vec2 world_size = vec2(128.0);  // Size of the terrain mesh

group_uniforms Elevation;
uniform sampler2D height_tex: hint_default_black, filter_linear_mipmap, repeat_disable;
uniform sampler2D normal_map_tex: hint_default_black, filter_linear_mipmap, 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, 1.0) = 16.0;
uniform float pixel_reference_dist: hint_range(1.0, 50.0) = 8.0;
uniform float band_transition_width: hint_range(0.0, 0.5) = 0.3;

group_uniforms Displacement;
uniform sampler2D displacement_tex: filter_linear_mipmap, repeat_enable;
uniform float displacement_strength: hint_range(0.0, 1.0) = 0.2;
uniform vec2 displacement_tex_size = vec2(4.0);  // World-space area one tile covers

// Return a 4x4 Bayer matrix threshold for ordered dithering.
//
// Indexes the standard Bayer matrix by the low two bits of the cell
// coordinates, producing a spatially stable pattern tied to terrain pixels.
float bayer4x4(ivec2 cell) {
    int x = cell.x & 3;
    int y = cell.y & 3;
    mat4 m = mat4(
        vec4(0, 12, 3, 15),
        vec4(8, 4, 11, 7),
        vec4(2, 14, 1, 9),
        vec4(10, 6, 13, 5)
    );
    return (m[x][y] + 0.5) / 16.0;
}

varying vec3 world_vertex;

void vertex() {
    float height = texture(height_tex, UV).r;
    VERTEX.y = height * height_max;
    // Transform to world space for distance-based LOD in fragment()
    world_vertex = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}

void fragment() {
    // Per-fragment inputs. UV derivatives are captured before quantization:
    // quantized UVs are constant within each cell, so their screen-space
    // derivatives are zero — which would force the GPU to select mip 0.
    float camera_dist = distance(CAMERA_POSITION_WORLD, world_vertex);
    vec2 duvdx = dFdx(UV);
    vec2 duvdy = dFdy(UV);

    // LOD band: increments by one each time camera distance doubles past
    // the reference distance, halving pixel density at each step.
    float lod = max(0.0, log2(camera_dist / pixel_reference_dist));
    float lod_band = floor(lod);

    // Fine grid: snap UVs to the center of the current band's pixel cells.
    // grid_factor = total number of cells across the terrain.
    float density_fine = pixel_density / exp2(lod_band);
    vec2 grid_factor_fine = world_size * density_fine;
    ivec2 fine_cell = ivec2(floor(UV * grid_factor_fine));
    vec2 grid_uv_fine = (vec2(fine_cell) + 0.5) / grid_factor_fine;

    // Coarse grid: the next band's cells, at half the density.
    vec2 grid_factor_coarse = grid_factor_fine * 0.5;
    vec2 grid_uv_coarse = (floor(UV * grid_factor_coarse) + 0.5) / grid_factor_coarse;

    // Bayer dithered band transition. Each fine cell gets a fixed Bayer
    // threshold. As fract(lod) nears 1.0, `transition` ramps up and cells
    // whose threshold falls below it are promoted to the coarse grid.
    float transition = smoothstep(1.0 - band_transition_width, 1.0, fract(lod));
    float bayer = bayer4x4(fine_cell);
    vec2 grid_uv = bayer > transition ? grid_uv_fine : grid_uv_coarse;

    // Sample displacement texture, tiled across the terrain.
    // Derivatives are scaled by the tiling ratio (chain rule).
    vec2 displacement_tiling = world_size / displacement_tex_size;
    vec2 displacement_uv = grid_uv * displacement_tiling;
    vec2 disp_duvdx = duvdx * displacement_tiling;
    vec2 disp_duvdy = duvdy * displacement_tiling;
    float displacement = textureGrad(displacement_tex, displacement_uv, disp_duvdx, disp_duvdy).r;

    // Sample terrain height at the quantized position
    float height = textureGrad(height_tex, grid_uv, duvdx, duvdy).r;

    // Map height + displacement to a color via the gradient
    float color_index = height + (displacement - 0.5) * displacement_strength;
    color_index = clamp(color_index, 0.0, 1.0);
    ALBEDO = texture(gradient, vec2(color_index, 0.0)).rgb;

    NORMAL_MAP = texture(normal_map_tex, UV).rgb;
}

Résultat final

Le rendu de Populous paraissait vivant parce que chaque pixel du terrain participait à la personnalité du niveau.

En combinant la technique des displacement maps avec une pixelisation adaptative — une densité de pixels variant selon la distance, et des transitions tramées par matrice de Bayer — on retrouve ce rendu versatile, vivant et cohérent à toutes les échelles.

Ce sont deux techniques bien connues, mais Je ne connais aucun exemple ou elles ont été combinées. Vais-je devenir célèbre ?

On pourrait aller plus loin : combiner plusieurs matériaux, pixeliser des effets dynamiques comme la lave en fusion. Mais ça sera pour une prochaine fois.

Je vous laisse avec quelques exemples tirés de Rêvivarium.