Introduction aux compute shaders avec Godot

Les jeux modernes affichent des environnements 3d extrêmement complexes, simulent des villes entières grouillantes de vie ou des hordes de zombies se jetant sur vous. Comment exploitent-ils la puissance délirante des pc modernes ? Grâce aux compute shaders.

Rêvivarium – 2

La première fois que j'ai tenté d'implémenter une simulation de fluide en gdscript, j'ai obtenu quelque chose de fonctionnel… et qui tournait à 3 fps.

Un peu tristounet quand on voit certains jeux qui simulent des planètes entières sans sourciller.

Pour obtenir ces résultats, il faut utiliser les bons outils. Cet article ouvre une série consacrée aux compute shaders et à leur utilisation avec Godot.

Les compute shaders sont massivement utilisés dans Rêvivarium et grâce à eux, un monde vivant entier est simulé en temps réel : de l'eau s'écoule en suivant les contours du terrain et creusant des ravines, le vent souffle et emporte le sable en formant des dunes, le Soleil chauffe l'air, des milliers de plantes vivent et meurent en fonction de leur environnement… Et tout ça s'affiche à l'écran à 60 fps.

Simulation d'une tempête de sable. Le vent est calculé dynamiquement grâce à une simulation de fluide, et la trajectoire de chaque grain de sable est calculée en temps réel.

Dans ce premier volet, nous allons étudier :

  • ce que sont les compute shaders ;
  • comment fonctionne l'interaction entre le cpu et le gpu ;
  • comment les utiliser dans Godot ;
  • quelques principes et concepts essentiels.

Préambule

Dans un article précédent, nous avons abordé l'architecture graphique moderne et la collaboration entre le CPU et le GPU qui nous a permis de comprendre ce que sont les vertex shaders et fragment shaders.

Les compute shaders ne sont pas spécifiques à Godot : toutes les API de rendu 3D modernes (Vulkan, OpenGL, Metal, WebGPU…) fournissent des compute shaders. Le travail de Godot est de rendre accessible ces outils au travers d'une interface unifiée.

Dans cet article, nous essaierons donc d'aborder les deux aspects : les principes généraux qui seront valables partout, mais aussi les détails de leur mise en œuvre spécifiques à Godot.

La documentation Godot sur les compute shaders est très spartiate, et ne constitue qu'une très rapide introduction au sujet. En développant Rêvivarium, je me suis confronté à pas mal de cas pratiques pour lesquels j'ai dû trouver des solutions. Le présent article est donc issu de l'expérience et permettra d'aller beaucoup plus loin.

Qu'est-ce qu'un compute shader ?

Commençons par les bases.

Un shader est un programme qui a la particularité de tourner sur le GPU (la carte graphique).

Les shaders sont généralement utilisés pour de l'affichage :

  • les vertex shaders permettent de déterminer à quel endroit de l'écran doit s'afficher un modèle 3d (pour chaque vertex, un shader tourne et détermine la position à l'écran) ;
  • les fragment shaders (parfois pixel shaders) font correspondre — à chaque fragment de l'écran couvert par ledit modèle — une couleur de pixel.

Les shaders sont conçus pour s'exécuter de manière massivement parallèle : des milliers d'instances de ces programmes tournent en même temps sans se préoccuper de ce qui les entoure, et c'est ce parallélisme massif qui permet d'obtenir des performances en temps réel qui rendent les jeux vidéo possibles.

Un compute shader n'est pas autre chose, à une différence près : il n'est pas circonscrit à une tâche d'affichage et aura donc un usage généraliste.

Ainsi, si je veux implémenter une fonctionnalité qui a besoin d'une importante quantité de calculs, et que ces calculs peuvent être exécutés en parallèle et indépendamment les uns des autres, l'usage des compute shaders sera approprié :

  • une simulation de fluide ou de particules (chaque particule est indépendante) ;
  • une génération de texture (si chaque pixel reçoit une valeur calculée qui ne dépend que de sa position dans la grille de pixels) ;
  • la simulation d'un phénomène physique sur une grille ;
  • le calcul de déplacement de milliers ou millions d'agents dans un jeu ;
  • etc.

La danse du cpu et du gpu

Le gpu n'est pas une unité de calcul autonome ; le cerveau de l'opération reste le cpu (en ce qui nous concerne, un programme gdscript).

C'est le cpu qui répartit les tâches et commande au gpu d'exécuter des shaders.

L'interaction entre le cpu et le gpu est relativement complexe, et il m'a fallu pas mal de temps pour m'en faire une idée claire. Par conséquent, je vais vous proposer une métaphore que nous filerons dans l'article.

Le cpu est un contremaître qui dispose d'une mission : construire une succession de frames de jeu vidéo. Il dispose d'une usine avec une chaîne de montage, mais cette usine ne peut travailler que sur un élément à la fois. Il dispose aussi d'un entrepôt pour stocker de la donnée (la mémoire ram).

De l'autre côté de la ville se trouve le gpu. C'est une usine avec des centaines ou milliers de chaînes de montage indépendantes pilotées par autant d'ouvriers spécialisés. Ces ouvriers sont extrêmement rapides, mais ils ne savent rien faire d'autre que d'exécuter des instructions bien précises (des shaders). Le gpu dispose de son propre entrepôt de mémoire (la vram).

Cpu et gpu disposent chacun d'une gare entre lesquelles circulent des trains. À chaque frame, le contremaître assemble un train qui va contenir une succession de wagons, et chaque wagon contient des ordres d'exécution ou des données.

L'ordre qui nous intéresse dans cet article porte un nom : le dispatch — « exécute tel shader, sur telles données, en tant d'exemplaires ».

Le train part et parvient au gpu. Un réceptionniste parcourt les wagons un par un, récupère les ordres et met en branle les différentes chaînes de montage. Les ouvriers spécialisés exécutent les ordres, suivent les instructions à la lettre, récupérant ou écrivant parfois des données dans l'entrepôt.

Quand le travail est terminé, le résultat est une frame complète envoyée directement à l'affichage (pas de retour au cpu).

Faire partir un train coûte cher (ça demande pas mal d'opérations au niveau de l'OS et du driver graphique), et c'est le même prix pour un wagon ou dix-mille, donc le travail du contremaître est de préparer un train complet pour que toute la frame puisse être créée sans allers-retours. Et quand le train part, pas de temps mort, le cpu commence directement à travailler sur le train de la frame suivante.

Illustration, gravure en noir et blanc. Une ancienne usine de fabrications de bougies.
Rare représentation d'un shader terminant la construction d'un pixel. Source

On l'a compris, le cpu ne peut pas accéder à la vram, comme le gpu ne peut accéder à la ram. Si le cpu veut communiquer des données au gpu, il doit les charger dans un wagon-cargo. Symétriquement, si le cpu veut accéder à des données du gpu, il doit envoyer un wagon pour commander au gpu de lire la donnée, la copier dans un wagon-cargo qui fera le trajet inverse.

Transférer de la donnée d'un côté à l'autre coûte donc cher en temps, et en bande passante : le train a une taille limitée et si on le remplit trop, on ne pourra pas tenir les délais.

Dernier twist mais c'est important : le gpu ne gère pas lui-même l'organisation de son entrepôt ! Ça reste la responsabilité du contremaître, qui assigne et réserve les emplacements mémoire. Le gpu lit et écrit des données dans l'entrepôt, mais c'est le cpu qui tient un registre et qui se charge d'indiquer à quelles adresses doivent avoir lieu ces lectures et écritures.

De cette analogie, on retiendra les points importants suivants :

  • cpu et gpu constituent deux unités de calcul distinctes ;
  • cpu et gpu ont chacun leur zone de mémoire de travail ;
  • c'est le cpu qui a la main sur tout et qui pilote l'opération ;
  • la communication entre cpu et gpu est massivement asynchrone — quand un ordre part, il s'écoule parfois 2 ou 3 frames avant que le résultat ne soit visible ;
  • les notions de transfert de données et de bande passante ont une importance capitale.

Comment ça « trop de particules » ?!

Un premier shader

Trêve d'analogies, voici un shader tout ce qu'il y a de plus concret.

Le shader qui suit permet de « peindre une image » avec une couleur unique : il reçoit en paramètre une image, une couleur, et écrit ladite couleur dans chaque pixel de l'image.

#[compute]
#version 450

// The GPU runs this program in workgroups of 8x8 invocations.
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;

// The texture we write into: four float channels per texel.
layout(set = 0, binding = 0, rgba32f) uniform restrict writeonly image2D color_out;

// The parameters sent along with each dispatch.
layout(push_constant, std430) uniform Params {
    vec4 color;
} params;

void main() {
    // This invocation's number in the dispatch is also the texel it handles.
    ivec2 texel = ivec2(gl_GlobalInvocationID.xy);
    imageStore(color_out, texel, params.color);
}

Décortiquons tout cela.

#[compute]

Cette ligne est un marqueur spécifique à Godot nécessaire pour que le moteur comprenne qu'il s'agit d'un compute shader.

#version 450

Il existe différents langages qui permettent de programmer des shaders, et différentes versions pour chaque langage. Godot s'attend à recevoir du code glsl en version 450.

layout…

S'ensuivent trois lignes qui commencent par layout, un mot clé qui n'est pas une instruction mais une annotation. layout permet de configurer la déclaration qui vient après. Pour comprendre une ligne layout, il est plus facile de commencer par la fin de la ligne.

// The GPU runs this program in workgroups of 8x8 invocations.
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;

Ici, la déclaration n'indique rien à part qu'il s'agit d'un paramètre d'entrée (in), et s'applique au programme lui-même. Cette ligne indique que le shader sera lancé par groupes parallèles de 64 exécutions. Les concepts d'invocation et de workgroup sont essentiels et seront détaillés juste après le code.

// The texture we write into: four float channels per texel.
layout(set = 0, binding = 0, rgba32f) uniform restrict writeonly image2D color_out;

Pour comprendre cette ligne, on part de la fin.

  • color_out: on déclare une variable…
  • image2D: …de type image2D, une image en deux dimensions à laquelle le shader pourra accéder pixel par pixel, comme un tableau à deux dimensions brut.
  • restrict et writeonly: on déclare que cette variable sera écrite et non lue, et qu'aucune autre variable ne permettra d'accéder à la même zone de mémoire. Ce sont des indications optionnelles qui permettent au gpu d'optimiser ses performances.
  • uniform: la variable vient de l'extérieur et sera fournie par le cpu ; « uniform » parce que chaque invocation du shader recevra exactement la même variable.

Ensuite vient l'annotation layout:

set = 0, binding = 0 : Le shader doit travailler sur une image stockée en mémoire, mais où ? On l'a dit, c'est le cpu qui tient le registre de l'entrepôt du gpu.

Le shader déclare donc « j'irai chercher l'adresse de cette image dans le bordereau 0, à la ligne 0 ». C'est le cpu qui remplira ce bordereau au lancement du train, nous verrons comment plus en détail.

rgba32f : Permet au gpu de connaître le format des pixels de l'image. Ici, quatre canaux (rgba) et chaque valeur stockée sur 32 bits.

// The parameters sent along with each dispatch.
layout(push_constant, std430) uniform Params {
    vec4 color;
} params;
  • params: le nom de la variable.
  • Params { vec4 color; }: on crée une structure (un regroupement de champs) ne contenant qu'un seul champ color de type vec4.
  • uniform: comme pour l'image, c'est le cpu qui fournira la valeur, identique pour toutes les invocations.

Le layout nécessite ici des explications.

push_constant: le cpu dispose de plusieurs manières pour passer des valeurs au gpu qui va exécuter le shader.

Les données volumineuses voyagent en wagon-cargo et restent ensuite dans l'entrepôt du gpu. Mais il faut souvent passer au shader quelques paramètres qui changent à chaque dispatch. Pour ça, on utilise les push constants : une poignée d'octets épinglés au bon de commande lui-même — ils voyagent avec l'ordre, sans wagon-cargo.

std430: quand il reçoit la commande d'exécution, le gpu connaît l'emplacement des données en mémoire, mais pas comment les données sont organisées précisément. À quel octet correspond telle variable ? Cpu et gpu doivent se mettre d'accord sur la manière dont ces données sont stockées et décodées car il existe différentes conventions. std430 en est une. Je ne rentre pas plus dans les détails pour l'instant, c'est un véritable sac de nœuds et nous y reviendrons dans un autre article.

void main() {

Le point d'entrée principal du shader.

// This invocation's number in the dispatch is also the texel it handles.
ivec2 texel = ivec2(gl_GlobalInvocationID.xy);

Comme nous allons le voir juste après, le shader est exécuté en de nombreuses invocations simultanées. Afin de les distinguer, chaque invocation reçoit des paramètres qui lui permettent d'identifier sa position dans la totalité du calcul.

Ici je m'arrangerai pour faire correspondre le nombre total d'invocations au nombre de pixels de l'image sur laquelle je travaille (par exemple 512 x 512). Par conséquent, ce shader est codé pour considérer que son numéro d'invocation unique gl_GlobalInvocationID.xy correspond à des coordonnées précises dans la grille de pixels.

On va y revenir juste après.

imageStore(color_out, texel, params.color);

On appelle une fonction native de glsl, qui va assigner une couleur au pixel en écrivant une valeur à l'emplacement mémoire adéquat.

Ce qu'on peut retenir de ce premier shader :

  • le shader indique quel bordereau (set, binding) le cpu doit remplir pour lui communiquer l'adresse mémoire d'une variable ;
  • il précise également le format de la donnée attendue ;
  • le cpu devra suivre ces indications en préparant le dispatch.

Invocations et workgroups

Le shader qui précède permet de peindre une image entière avec une couleur donnée : chaque pixel de l'image recevra la couleur passée en paramètre.

Si nous avions voulu écrire une telle fonction dans un autre langage, nous aurions probablement utilisé une boucle. En pseudo-code :

for x in 0 to image_width:
  for y in 0 to image_height:
    image.writePixel(x, y, color)

Or vous aurez peut-être remarqué qu'il n'y a pas de boucle dans ce shader, mais une seule instruction imageStore, qui écrit un pixel à une adresse donnée.

C'est là où le changement de mentalité doit se faire : un shader correspond à un seul programme exécuté un grand nombre de fois. Et chaque instance doit effectuer une fraction du travail total de manière indépendante.

Ce qui nous ramène aux concepts absolument essentiels d'invocations et de workgroups.

Une invocation est une unique exécution d'un shader. Si votre shader écrit un pixel, et que vous avez dix invocations, vous écrirez dix pixels.

Les gpu ne lancent jamais une seule invocation, cela irait à l'encontre du principe même de parallélisme. Les shaders sont exécutés en groupes de taille fixe — appelés workgroups — et c'est le shader lui-même qui déclare la taille de ce groupe.

Imaginons que je veuille effectuer une opération sur une texture de 512x512 pixels. Je sais que le shader déclare un workgroup de 8x8 donc chaque workgroup travaillera sur un groupe de 8x8 pixels. Pour couvrir l'intégralité de la texture, le cpu doit donc dispatcher 64 x 64 workgroups (512 / 8 = 64 sur chaque axe).

Le nombre total d'invocations sera de 64 x 64 x 8 x 8 = 262144 soit le nombre de pixels dans la texture.

Eh oui, ce nombre total est configuré en deux parties :

  • la taille du workgroup (configurée dans le shader) ;
  • multipliée par le nombre de workgroups dispatchés (configuré en gdscript).

Vous pouvez maintenant décoder le nom gl_GlobalInvocationID : le numéro de cette invocation dans le calcul global, tous workgroups confondus. C'est exactement la valeur unique, entre 0 et 511 sur chaque axe, que recevait chaque invocation de notre premier shader.

Le projet fil rouge

Au fil de cette série d'articles, nous allons implémenter une simulation avec Godot, inspirée de Rêvivarium : une île, et de l'eau qui dévale ses pentes, quelques phénomènes physiques, tout ça en temps réel.

Un simple terrain avec Godot

Voici ce que nous allons construire dans un premier temps : un projet Godot avec deux plans, l'un qui représentera le terrain, l'autre l'eau. Les données d'altitude seront stockées dans une texture. Cette texture sera initialisée par un compute shader.

Je présupposerai un minimum d'aisance avec Godot, aussi les étapes qui suivent ne seront pas trop détaillées.

Démarrez un nouveau projet Godot et configurez l'arbre ainsi :

Reproduisez cette arborescence

Ground et Water sont deux mesh qui vont représenter le terrain et l'eau qui s'écoule. Pour ça créez deux objets de type MeshInstance3D, assignez-leur un nouveau Plane mesh, configurez une taille de 512m et une subdivision de 511. Assignez à chaque mesh un material de type ShaderMaterial et créez deux shaders Godot ground.gdshader et water.gdshader.

Ces deux shaders vont fonctionner de manière similaire : ils vont lire une altitude dans une texture, déformer le mesh en conséquence, et assigner à chaque fragment une couleur en fonction de son altitude.

Pour l'instant c'est très similaire à ce qu'on trouve dans la documentation officielle de Godot ou dans ce précédent billet sur le terrain en pixel-art.

Le code de ground.gdshader:

shader_type spatial;

// Renders the terrain: vertices displaced by the terrain height, colors
// from an altitude gradient.

// The texture that holds the heightmap data
uniform sampler2D world_tex : filter_linear, repeat_disable;

// A gradient: altitude -> color
uniform sampler2D gradient : source_color, filter_linear, repeat_disable;

// Altitude covered by each half of the gradient: the gradient spans
// [-max_height, +max_height] around sea level.
uniform float max_height = 100.0;

// Terrain height at a texture coordinate, in world units (0 = sea level).
float height_at(vec2 uv) {
    return texture(world_tex, uv).r;
}

// Surface normal at a texture coordinate
// Compute the normal vector dynamically, so the lighting is correct
// This is out of scope for the content of this post, you can safely
// ignore that method.
vec3 normal_at(vec2 uv) {
    vec2 texel = 1.0 / vec2(textureSize(world_tex, 0));
    float height = height_at(uv);
    float height_right = height_at(uv + vec2(texel.x, 0.0));
    float height_down = height_at(uv + vec2(0.0, texel.y));
    vec3 right = vec3(1.0, height_right - height, 0.0);
    vec3 down = vec3(0.0, height_down - height, 1.0);
    return normalize(cross(down, right));
}

void vertex() {
    // Setup the terrain mesh from the heightmap texture
    // Update each vertex vertical coordinates
    // using data from the world texture
    VERTEX.y += height_at(UV);
    NORMAL = normal_at(UV);
}

void fragment() {
    // Find the terrain color from altitude -> gradient
    float gradient_position = height_at(UV) / (2.0 * max_height) + 0.5;
    ALBEDO = texture(gradient, vec2(gradient_position, 0.0)).rgb;
    ROUGHNESS = 1.0;
}

water.gdshader:

shader_type spatial;

// Renders the water surface: vertices displaced to the top of the water
// column (terrain height + water depth), colors from a depth gradient.

uniform sampler2D world_tex : filter_linear, repeat_disable;
uniform sampler2D depth_gradient : source_color, filter_linear, repeat_disable;

// Water depth that reaches the darkest end of the gradient.
uniform float max_depth = 20.0;

// Where the depth is zero, the water surface would coincide exactly with
// the terrain and the two would flicker (z-fighting).
// Sinking the water a bit lower hides dry areas below the terrain.
const float DRY_OFFSET = 0.05;

// Water surface height at a texture coordinate: the terrain plus the
// water sitting on it, in world units.
float surface_at(vec2 uv) {
    vec2 world = texture(world_tex, uv).rg;
    return world.r + world.g;
}

// Surface normal at a texture coordinate
vec3 normal_at(vec2 uv) {
    vec2 texel = 1.0 / vec2(textureSize(world_tex, 0));
    float height = surface_at(uv);
    float height_right = surface_at(uv + vec2(texel.x, 0.0));
    float height_down = surface_at(uv + vec2(0.0, texel.y));
    vec3 right = vec3(1.0, height_right - height, 0.0);
    vec3 down = vec3(0.0, height_down - height, 1.0);
    return normalize(cross(down, right));
}

void vertex() {
    VERTEX.y += surface_at(UV) - DRY_OFFSET;
    NORMAL = normal_at(UV);
}

void fragment() {
    float water_depth = texture(world_tex, UV).g;
    float gradient_position = min(water_depth / max_depth, 1.0);
    ALBEDO = texture(depth_gradient, vec2(gradient_position, 0.0)).rgb;
    ROUGHNESS = 0.6;
}

Dans ces deux shaders, nous déclarons trois uniform à configurer : gradient, depth_gradient et world_tex.

Dans l'interface de Godot, vous pourrez manuellement configurer les deux dégradés avec les couleurs qui vous inspirent.

Utilisez l'interface et remplissez le dégradé comme ça vous chante

Reste la texture world_tex: cette texture doit contenir deux canaux (r, g) et chaque valeur correspond à une hauteur : r pour la hauteur du terrain, g pour la profondeur de l'eau. Cette texture pour l'instant n'existe pas. Nous allons y remédier à la prochaine étape.

Initialiser la carte d'altitude

Assignez un script au nœud principal WaterSimulation. Voici le code du nouveau water_simulation.gd:

@tool
extends Node3D
## Runs the water simulation on the GPU and displays the result.

# The size of the world and texture we want
const GRID_SIZE := 512

# The workgroup defined in the shader
const WORKGROUP_SIZE := 8

@onready var ground: MeshInstance3D = $Ground
@onready var water: MeshInstance3D = $Water

# The RenderingDevice is a Godot server: a low-level api that gives
# direct access to graphic APIs
# See https://docs.godotengine.org/en/stable/classes/class_renderingdevice.html
# and https://docs.godotengine.org/en/stable/tutorials/performance/using_servers.html
var rd: RenderingDevice

# Those are the resources we need to compile and run the shader
# RIDs are entries in the gpu memory registry the cpu maintains.
# We'll get back to it later.
var shader: RID
var pipeline: RID

# The whole world lives in this GPU texture
# We decide that one texel = one cell of the grid = one m²
# red channel = terrain height, green channel = water depth.
var world_texture: RID

# The world texture will only live on the gpu.
# Current code lives on the cpu.
# Godot provides a special texture type Texture2DRD that bridges the two.
# I can pass this texture to a Godot material that will access the data
# directly on the gpu.
var world_texture_display: Texture2DRD


func _ready() -> void:
    world_texture_display = Texture2DRD.new()

    # Pass the Texture2DRD reference to the shaders
    ground.mesh.material.set_shader_parameter("world_tex", world_texture_display)
    water.mesh.material.set_shader_parameter("world_tex", world_texture_display)

    # Every gpu-related operation must originate from the rendering thread
    RenderingServer.call_on_render_thread(init_gpu)


## Create the GPU resources, generate the island, connect the display.
func init_gpu() -> void:
    rd = RenderingServer.get_rendering_device()

    create_world_texture()
    create_island_pipeline()
    generate_island()

    # Here we bind a Godot classical 2d texture to actual data on the gpu
    world_texture_display.texture_rd_rid = world_texture


## Create the texture in the gpu memory
# Well, not really...
# We are only creating a new entry in the cpu registry.
# The cpu now knows that there is one allocated block in the
# gpu memory at a specific address, made to hold data with
# specific image characteristics.
# Nothing happens in memory yet, and the actual data is simply garbage.
func create_world_texture() -> void:

    # First setup the texture characteristics
    # Provide the size (width / height) and format (two 32 bits channels)
    var format := RDTextureFormat.new()
    format.width = GRID_SIZE
    format.height = GRID_SIZE
    format.format = RenderingDevice.DATA_FORMAT_R32G32_SFLOAT

    # The format can take some flags that will configure
    # different permissions.
    #  - our compute shader will write into it
    #  - the display materials will read it
    #  - the Godot editor will read it back to draw inspector previews.
    format.usage_bits = (
        RenderingDevice.TEXTURE_USAGE_STORAGE_BIT |
        RenderingDevice.TEXTURE_USAGE_SAMPLING_BIT |
        RenderingDevice.TEXTURE_USAGE_CAN_COPY_FROM_BIT
    )

    # Delegate to the graphics API the texture creation
    world_texture = rd.texture_create(format, RDTextureView.new())


## Load and compile the shader
func create_island_pipeline() -> void:
    var shader_file: RDShaderFile = load("res://shaders/generate_island.glsl")
    shader = rd.shader_create_from_spirv(shader_file.get_spirv())
    pipeline = rd.compute_pipeline_create(shader)


## Ask the GPU to run the island generation shader
func generate_island() -> void:
    # This operation gives the shader access to the texture.
    # A uniform set is a supply slip: numbered lines, each naming a
    # resource from the warehouse. Here: line 0 of slip 0 names our
    # world texture.
    var uniform := RDUniform.new()
    uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
    uniform.binding = 0
    uniform.add_id(world_texture)
    var uniform_set := rd.uniform_set_create([uniform], shader, 0)

    # Initialize the push constant.
    # The shader will receive it in its `params` variable.
    # For technical and obscure (for now) reasons, the block size
    # must be a multiple of 16 bytes.
    var params := PackedFloat32Array([GRID_SIZE, GRID_SIZE, 0.0, 0.0])
    var push_constant := params.to_byte_array()

    # 512 cells wide / 8 invocations per workgroup = 64 workgroups per axis.
    # Right now, the texture size is a multiple of the workgroup size,
    # so no invocation will be "wasted", but that shall not always be the
    # case.
    var groups := ceili(float(GRID_SIZE) / WORKGROUP_SIZE)

    # A Compute List holds a series of gpu instructions.
    # It's like a single wagon of the "train of commands" the cpu
    # is building, but a wagon in itself can hold several instructions.
    # When it receives them, the gpu will execute them in order.

    # So don't see this as "execute this shader", rather "add this
    # to the list of instructions the cpu is preparing to send to the
    # gpu to build the entire frame".
    var compute_list := rd.compute_list_begin()

    # Tells the gpu "from now on, the loaded program is this one"
    rd.compute_list_bind_compute_pipeline(compute_list, pipeline)

    # Hand the supply slip to the crews
    rd.compute_list_bind_uniform_set(compute_list, uniform_set, 0)

    # Setup the push constant
    rd.compute_list_set_push_constant(
        compute_list, push_constant, push_constant.size())

    # Add a dispatch to the list of commands
    rd.compute_list_dispatch(compute_list, groups, groups, 1)

    # We could add several dispatches in a single compute list
    # but for now we only need one.
    rd.compute_list_end()

Le script est abondamment commenté. Quelques points nécessitent tout de même des éclaircissements.

Une API de bas niveau

RenderingDevice est une API de très bas niveau — presque aucune abstraction. C'est quasiment une surcouche directe à l'api graphique utilisée (Vulkan, Direct3D 12, Metal…).

Par conséquent, la partie spécifique à Godot s'arrête ici : le shader qui suit est du GLSL standard, qui fonctionnerait à l'identique dans n'importe quel moteur.

Rendering devices, compute lists et wagons

Si vous avez lu la doc, vous avez peut-être vu que Godot distingue le global rendering device et les local rendering devices. Comme on l'a expliqué, Godot construit — pour chaque frame — un train de commande qu'il envoie au gpu. Quand vous écrivez le code suivant…

rd = RenderingServer.get_rendering_device()
…
rd.compute_list_begin()

…vous venez ajouter votre propre wagon au train existant, et vous êtes tributaire de son aiguillage. Ce n'est pas vous qui décidez quand le train part, c'est Godot, et impossible d'attendre son arrivée : quand le gpu exécute votre commande, Godot est déjà passé aux frames suivantes. Vous ne pouvez pas bloquer tout le processus pour attendre le résultat, parce que ça bloquerait effectivement tout le rendu pendant des millisecondes.

Illustration, dessin en noir et blanc, des passagers attendant le train en retard sous la pluie.
Les dispatch attendant le train pour le gpu. Source

La documentation officielle utilise les local rendering device. Ça revient à ouvrir sa propre ligne privée vers le gpu, avec ses propres trains, qu'on gère comme on veut : on peut dispatcher un shader, attendre le retour du calcul, tout ça sans bloquer l'affichage principal. Ça a ses usages et ses contraintes mais c'est hors scope pour le moment. C'est utile de savoir que la différence existe pour mieux comprendre la doc Godot.

Le thread de rendu

Avec Godot, le code spécifique à l'affichage s'exécute dans un thread dédié (si l'option correspondante est activée) — on parle bien ici d'un thread sur le cpu.

Par principe, tout appel à l'api de rendu (RenderingDevice) doit donc se faire dans ce thread. C'est pour cette raison qu'on utilise call_on_render_thread.

Shaders, pipelines et compilation

Un mot sur la compilation du shader et la distinction shader / pipeline.

Vous programmez un shader en glsl, c'est un fichier texte. Ce texte est transformé en un format intermédiaire — le SPIR-V (à vrai dire on s'en fout un peu) — qui n'est pas utilisable en l'état.

Une autre transformation est nécessaire, la compilation à proprement parler qui produira du code exécutable. Le résultat final est la pipeline. Pipeline = programme compilé. Pourquoi ce nom ? Cela vient du côté graphique, où la pipeline désigne la chaîne d'étapes que traversent les vertex (vertex shader, rasterizer, fragment shader…).

Pourquoi ces deux étapes sont-elles nécessaires ? C'est tout un sujet qui sera abordé dans un autre article de la série.

Cycle de vie des ressources

Le cpu réserve de l'espace dans la mémoire du gpu, mais cette mémoire n'est jamais libérée.

En l'état, notre code actuel contient de jolies fuites de mémoire.

Le cycle de vie des ressources est également un sujet à part entière, et nous lui consacrerons plus d'explications dans le prochain article de la série.

Ze shader at last

Le code du shader (enfin) :

#[compute]
#version 450

// Generate the island terrain and its initial sea.
//
// One invocation runs per cell and writes that cell's terrain height (red
// channel) and water depth (green channel). One cell spans one world unit;
// heights are world units too, and 0 is the sea level.

// The GPU runs this program in workgroups of 8x8 invocations.
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;

// The texture we write the world into. "rg32f" = two float channels per
// texel. The GDScript side attaches the texture here (set 0, binding 0).
layout(set = 0, binding = 0, rg32f) uniform restrict writeonly image2D world_out;

// The parameters the GDScript side sends along with each dispatch.
layout(push_constant, std430) uniform Params {
    vec2 grid_size;
    vec2 _pad; // unused, rounds the block size up to 16 bytes
} params;

// Depth of the open sea around the island, in world units.
const float SEA_DEPTH = 45.0;

const float PI = 3.14159265;

// Terrain height at a position of the map (both axes from 0 to 1), in
// world units, 0 at sea level.
// Feel free to treat it as a black box and skip ahead.
// Or replace it with whatever funky heightmap generation
// code you want.
float island_height(vec2 uv) {
    // How far this position is from the center of the map: 0 at the
    // center, 1 at the middle of the map borders.
    float distance_to_center = length(uv - 0.5) * 2.0;

    // The island silhouette: 1 at the center, fading to 0 between 55% and
    // 95% of the way out — so open sea surrounds the land.
    float island_shape = 1.0 - smoothstep(0.55, 0.95, distance_to_center);

    // The land, made of overlapping waves of different sizes, in world
    // units: a 100-unit dome peaking at the center of the map, hills half
    // as tall, bumps half as tall again. The hill and bump frequencies are
    // arbitrary — change them, the island changes.
    float dome = sin(uv.x * PI) * sin(uv.y * PI) * 100.0;
    float hills = sin(uv.x * 17.0 + uv.y * 12.0) * 50.0;
    float bumps = sin(uv.x * 31.0 - uv.y * 27.0) * 25.0;
    float land = (dome + hills + bumps) * island_shape;

    // Sink everything: where the island faded to nothing, only the open
    // sea floor remains.
    return land - SEA_DEPTH;
}

void main() {
    // Invocation number = coordinates of the terrain cell it handles
    ivec2 cell = ivec2(gl_GlobalInvocationID.xy);

    // Sometimes, we can't get exactly the number of invocation we want.
    // If workgroup size is 8x8, the total invocation count will be a multiple of that.
    // If the texture does not match those dimensions, then some invocations will
    // "overshoot" so we have to add a guard clause.
    ivec2 size = ivec2(params.grid_size);
    if (cell.x >= size.x || cell.y >= size.y) {
        return;
    }

    // Normalizes grid-sized coordinates (0 to grid_size) to [0;1] coordinates.
    // (+0.5 targets the center of the cell).
    vec2 uv = (vec2(cell) + 0.5) / params.grid_size;

    // Find the terrain height at those coordinates
    float height = island_height(uv);

    // The sea fills everything below zero.
    float water = max(0.0, -height);

    // imageStore always takes a vec4.
    // Our texture only keeps the first two channels and drops the rest.
    imageStore(world_out, cell, vec4(height, water, 0.0, 0.0));
}

Si vous avez suivi les consignes à la lettre, rechargez la scène et une île devrait apparaître dans votre éditeur.

Ne rêverait-on pas d'y passer ses vacances ?

Conclusion

Nous avons commencé à utiliser les compute shaders dans un cas simple, étudié le fonctionnement des échanges entre cpu et gpu, et décrit les principes et problèmes auxquels nous allons devoir être attentifs.

Pour l'instant nous avons obtenu ceci :

Une arborescence Godot avec un script d'initialisation. Ce script crée une texture en mémoire. Il appelle un compute shader qui va remplir la texture avec des données d'altitude. Il passe aussi une référence aux shaders d'affichage du terrain et du plan d'eau, pour qu'ils accèdent aux données.

L'avantage de ce système est qu'il est extrêmement rapide. En effet, le cpu n'a que peu de travail à faire : il réserve un bloc mémoire, construit un dispatch, l'envoie au gpu, et fin de la journée de travail. C'est le gpu qui initialise la texture et c'est le gpu qui se charge de l'affichage.

Et le plus beau dans tout ça, c'est que les données restent sur le gpu : en l'état, le cpu n'a pas accès aux données, il n'y a pas eu de transfert de mémoire — aucun wagon-cargo n'a circulé. Nous aurions pu récupérer les données sur le cpu pour les retransférer aux shaders godot, mais cela aurait créé un aller-retour (roundtrip) coûteux et inutile.

Lorsque nous utilisons les compute shaders, nous devrons être attentives à tous ces éléments :

  • comment découper le travail en tâches parallélisables ;
  • comment organiser la mémoire ;
  • gérer le transfert de données entre le cpu et le gpu pour le limiter au mieux.

Dans le reste de la série, nous allons continuer à développer notre île, chaque nouvel ajout étudiant de nouveaux concepts et de nouvelles techniques.