Dessinons un triangle avec WebGPU

Plongeons-nous dans la nouvelle API graphique, WebGPU, et dessinons un triangle ensemble.

Keywords: WebGPU, algÚbre linéaire, les caméras, le pipeline de rendu, le rendu en temps réel, les transformations

By Carmen Cincotti  

Le but de cette semaine est de rendre un seul triangle en utilisant WebGPU et de comprendre un peu plus le concept du pipeline de rendu.

WebGPU Triangle

Le pipeline de rendu

L’API de WebGPU est plus basĂ©e sur des API graphiques telles que Vulkan et Metal que sur OpenGL. C’est pour cela qu’on ne l’appelle pas ‘WebGL 3.0’. Ceci dit, on a plus de pouvoir de le configurer par rapport Ă  WebGL, et c’est pourquoi donc je voudrais aborder au sujet du pipeline de rendu.

⚠Le cadre de notre discussion sur ce sujet se limite Ă  un haut niveau. Si vous voulez approfondir le sujet, je vous recommande de voir cette playlist de Computer Graphics par Keenan Crane (en anglais)

Le pipeline de rendu

Le pipeline de rendu oĂč les cases vertes sont dĂ©signĂ©es comme programmables (non fixĂ©s).

Input (Entrée)

L’entrĂ©e du pipeline correspond aux informations que nous fournissons au GPU Ă  partir du CPU, telles que les buffers (ou tampons en français) ou les textures, et tout ce qui peut ĂȘtre nĂ©cessaire pour rendre une scĂšne.

Les données que nous voulons probablement lui fournir sont des informations sur :

  • Les modĂšles 3D
    • Les coordonnĂ©es des sommets
    • Les transformations locales
    • Les couleurs
    • Les textures
  • LumiĂšres
  • CamĂ©ra
  • L’information sur une interaction avec l’utilisateur
  • 
 des autres choses dans la scĂšne.

Le shader de vertex

Les shaders sont programmés par nous, ce qui est important parce que presque tout le pipeline de rendu est fixe (ou non programmable).

Un shader est défini par Wikipédia comme suit :

Un shader ou nuanceur (le mot est issu du verbe anglais to shade pris dans le sens de « nuancer ») est un programme informatique, utilisé en image de synthÚse, pour paramétrer une partie du processus de rendu réalisé par une carte graphique ou un moteur de rendu logiciel.

Le shader de vertex s’exĂ©cute une fois pour chaque sommet sur le GPU. Il prend en entrĂ©e les sommets et leurs attributs. En ce qui concerne la position d’un sommet, il traite et applique des transformations afin de les placer Ă  partir de l’espace 3D dans l’espace Ă©cran 2D.

Ici, on transforme généralement les sommets par rapport à la caméra en appliquant une série de matrices de transformation.

void main() { gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; }

Une implĂ©mentation basique d’un shader de vertex en GLSL

Une formation rapide des transformations

Je voudrais expliquer rapidement les transformations qui sont souvent utilisĂ©es pour transformer la position d’un sommet avant de continuer. On y reviendra lorsqu’on dĂ©veloppe notre scĂšne dans une partie ultĂ©rieure.

Il est important de retenir le fait que ces transformations visent comme but Ă  changer la position d’un sommet d’un espace coordonnĂ© Ă  un autre.

⚠ J’utiliserai la convention de nommage française donnĂ©e par Microsoft.

L’espace de modùle (Model space)

En infographie, un modùle 3D est construit dans un espace propre au modùle qui s’appelle l’espace de modùle, (Model space).

Model

L’espace universel (World space)

L’espace universel est l’espace dans lequel la scĂšne est dĂ©finie. La position et l’orientation des objets 3D, de la camĂ©ra, des lumiĂšres sont comprises. On applique la transformation modĂšle-universel pour transformer nos modĂšles en espace universel.

World

L’espace de camĂ©ra (View space)

La camĂ©ra est gĂ©nĂ©ralement le repĂšre du monde dans une scĂšne (autrement dit, la camĂ©ra est Ă  l’origine en regardant l’axe z positif). Il faut donc bouger le monde par rapport Ă  la position et orientation de la camĂ©ra.

Autrement dit, si on veut voir le monde Ă  l’envers, il faudrait juste tourner le monde entier Ă  l’envers et ne pas toucher la camĂ©ra ! Pour ce faire, on applique la transformation de vue. Sans appliquer la transformation de la projection, on voit le monde Ă  partir d’une camĂ©ra orthographie.

View

Enfin, pour voir le monde à travers une caméra perspective, on applique la transformation de la projection pour obtenir le résultùt suivant :

Projection

Assemblage et rastérisation des primitives

AprĂšs avoir transformĂ© nos sommets de l’espace 3D Ă  l’espace 2D (en vue de notre camĂ©ra), le GPU doit assembler, dĂ©couper (dans un processus appelĂ© clipping), et rastĂ©riser (pixeliser) nos primitives. Ce processus est chargĂ© de transformer nos sommets, qui sont dĂ©crits en 3D, en pixels sur l’écran en 2D.

⚠Comme je l’ai dit, le but n’est pas de faire une thĂšse de doctorat. On reverra en profondeur ces sujets au cours de notre projet.

Le shader de fragment

Enfin, le fun du pipeline de rendu ! On est chargĂ© de programmer ce shader, qui n’a qu’un seul travail - fournir la couleur d’un pixel (fragment). Ce programme est exĂ©cutĂ© sur chaque fragment de chaque frame !

Plus prĂ©cisĂ©ment, le shader de fragment prend en entrĂ©e les attributs des sommets qui ont des valeurs interpolĂ©es et son rĂŽle est de calculer la couleur finale sur l’écran du fragment (pixel) fourni par l’étape de rastĂ©risation.

AprĂšs avoir traitĂ© chaque pixel et avoir rĂ©solu la visibilitĂ© de chaque objet, l’image est rendue puis affichĂ©e Ă  l’écran.

WebGPU et notre premier triangle

⚠ Pour voir le code, il faut utiliser un navigateur avec le drapeau (flag) WebGPU activĂ©. Voit cette ressource pour en savoir plus.

⚠ Je suppose des connaissances basiques sur le sujet des API graphiques (comme WebGL)

Le code se trouve dans mon codebase qui accompagne ce blog.

WebGPU fait partie de la prochaine gĂ©nĂ©ration de rendu, crĂ©Ă© pour le web. Si vous ĂȘtes expĂ©rimentĂ© dans d’autres API graphiques, vous seriez plus Ă  l’aise que ne pas en avoir. Quoi qu’il en soit, aprĂšs un peu de temps et de rĂ©pĂ©tition, vous verrez qu’il n’est pas si difficile de comprendre comment crĂ©er des projets cools !

Je propose qu’on crĂ©e un triangle afin de nous introduire Ă  l’API WebGPU.

Adaptateur et Appareil (Adapter and Device)

On commence premiĂšrement par s’assurer de la disponibilitĂ© de WebGPU dans notre navigateur :

// ~~ INITIALIZE ~~ Make sure we can initialize WebGPU if (!navigator.gpu) { console.error("WebGPU cannot be initialized - navigator.gpu not found") return null; } const adapter = await navigator.gpu.requestAdapter(); if (!adapter) { console.error("WebGPU cannot be initialized - Adapter not found") return null; } const device = await adapter.requestDevice(); device.lost.then(() => { console.error("WebGPU cannot be initialized - Device has been lost") return null; }); const canvas = document.getElementById("canvas-container") const context = canvas.getContext('webgpu'); if (!context) { console.error("WebGPU cannot be initialized - Canvas does not support WebGPU") return null; }

Ce sont quoi, un adapter et un device ?

  • Adapter - c’est comme VkPhysicalDevice (si t’es expĂ©rimentĂ© dans Vulkan). WebGPU nous permit d’obtenir une liste de tous les GPUs de notre ordinateur. On peut Ă©galement passer un argument Ă  la fonction pour sĂ©lectionner un GPU d’un certain genre. Par exemple, si vous avez plusieurs GPUs dans ton systĂšme comme dans un ordinateur portable avec un GPU basse consommation pour une utilisation sur batterie et un GPU haute puissance pour une utilisation tout en Ă©tant branchĂ©.

  • Device - c’est comme VkDevice. C’est le pilote GPU sur la carte graphique matĂ©riel et notre mĂ©thode de lui communiquer. ThĂ©oriquement, vous pouvez crĂ©er plusieurs devices afin de communiquer entre plusieurs GPUs.

Swap Chain

Apres avoir créé le contexte de Canvas, on le configure :

const devicePixelRatio = window.devicePixelRatio || 1; const presentationSize = [ canvas.clientWidth * devicePixelRatio, canvas.clientHeight * devicePixelRatio, ]; const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device, format: presentationFormat, alphaMode: "opaque" });

Ces lignes de code peuvent sembler bizarres et abstraites
 on configure la swap chain et le contexte Canvas en mĂȘme temps. En fait, si vous voyez un tuto WebGPU du passĂ©, vous pourriez voir la mĂ©thode obsolĂšte oĂč l’on configure explicitement la swap chain.

Qu’est-ce qu’une swap chain ? Le rĂŽle principal de la swap chain est de synchroniser la prĂ©sentation de nos images avec la frĂ©quence de rafraĂźchissement de notre Ă©cran. C’est une file d’attente qui contient des images attendant d’ĂȘtre affichĂ©es. Nous pouvons configurer son fonctionnement en passant des paramĂštres Ă  la mĂ©thode context.configure.

Les sommets

const vertices = new Float32Array([ -1.0, -1.0, 0, 1, 1, 0, 0, 1, -0.0, 1.0, 0, 1, 0, 1, 0, 1, 1.0, -1.0, 0, 1, 0, 0, 1, 1 ]); const vertexBuffer = device.createBuffer({ size: vertices.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, mappedAtCreation: true }); new Float32Array(vertexBuffer.getMappedRange()).set(vertices); vertexBuffer.unmap(); const vertexBuffersDescriptors = [{ attributes: [ { shaderLocation: 0, offset: 0, format: "float32x4" }, { shaderLocation: 1, offset: 16, format: "float32x4" } ], arrayStride: 32, stepMode: "vertex" }];

Ce qui est particulier, c’est qu’on doit tasser notre tableau de sommets dans un seul tampon gĂ©ant. Chaque sommet se compose par six floats dans ce cas (trois pour reprĂ©senter la position et trois pour reprĂ©senter la couleur). Si l’on veut inclure d’autres attributs, il faut en ajouter plus de floats Ă  chaque sommet.

AprĂšs avoir dĂ©fini nos sommets, on crĂ©e le vertexBuffer qui est le tampon qui vivra dans le GPU. On est chargĂ© de le remplir Ă  cette Ă©tape. L’acte de mapping un tampon est important pour son fonctionnement. Un tampon mappĂ© veut dire que le CPU peut lui Ă©crire et l’interdit au GPU.

Inversement, si le tampon est unmapped, le GPU pourra le lire et le CPU sera interdit. C’est pour cela qu’on dĂ©signe mappedAtCreation comme true pendant l’étape crĂ©ation. On peut ensuite invoquer .set pour copier nos sommets dans le tampon. Enfin, nous supprimons l’accĂšs en Ă©criture du CPU et accordons l’accĂšs en lecture au GPU en appelant vertexBuffer.unmap().

Les vertexBuffersDescriptors sont des instructions instruisant au GPU comment dĂ©coder le tampon. Dans notre cas, on utilise 32 bytes pour dĂ©crire tous les attributs d’un sommet. Dans nos shaders, le GPU pourra trouver le vecteur position Ă  l’offset 0, et le vecteur couleur Ă  l’offset 16.

Le vertex et shader de fragment

const shaderModule = device.createShaderModule({ code: ` struct VertexOut { @builtin(position) position : vec4<f32>; @location(0) color : vec4<f32>; }; @vertex fn vertex_main(@location(0) position: vec4<f32>, @location(1) color: vec4<f32>) -> VertexOut { var output : VertexOut; output.position = position; output.color = color; return output; } @fragment fn fragment_main(fragData: VertexOut) -> @location(0) vec4<f32> { return fragData.color; } ` });

Ces shaders sont francs. On les dĂ©finit en utilisant WGSL, qui est comme Rust. Il n’y a pas de surprise dans ce code, et je t’invite Ă  revoir la section sur des shaders en haut pour en savoir plus.

Le pipeline de rendu

const pipeline = device.createRenderPipeline({ vertex: { module: shaderModule, entryPoint: 'vertex_main', buffers: vertexBuffersDescriptors }, fragment: { module: shaderModule, entryPoint: 'fragment_main', targets: [ { format: presentationFormat, }, ], }, primitive: { topology: 'triangle-list', }, });

Enfin, on dĂ©finit le pipeline de rendu qui n’est qu’une configuration. On combine nos shaders et nos attributs de sommet tout en dĂ©finissant le type de primitive qui sera gĂ©nĂ©rĂ©. C’est plutĂŽt une Ă©tape boilerplate.

Le frame d’animation et les tampons de commande

function frame() { const commandEncoder = device.createCommandEncoder(); const textureView = context.getCurrentTexture().createView(); const renderPassDescriptor = { colorAttachments: [ { view: textureView, clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, loadOp: 'clear', storeOp: 'store', }, ], }; const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); passEncoder.setPipeline(pipeline); passEncoder.setVertexBuffer(0, vertexBuffer); passEncoder.draw(3); passEncoder.end(); device.queue.submit([commandEncoder.finish()]); requestAnimationFrame(frame); }

On commence notre animation ! Ce qui est diffĂ©rent par rapport Ă  WebGL, c’est cette idĂ©e d’un command buffer (tampon de commande). On utilise un command buffer afin de prĂ©enregistrer toutes les opĂ©rations de dessin afin que WebGPU puisse les traiter plus efficacement. L’avantage que cela rĂ©duira la bande passante entre le CPU et le GPU (et par consĂ©quent - la performance s’amĂ©liorera) et nous pouvons remplir ce tampon en parallĂšle en utilisant plusieurs threads.

Le commandEncoder est responsable de recevoir nos commandes de rendu. Pour crĂ©er un commandEncoder qui peut ĂȘtre soumis au GPU, on appelle finish sur l’encodeur. Le commandEncoder reçu est passĂ© Ă  l’appareil (device) afin d’ĂȘtre exĂ©cutĂ© puis notre triangle sera rendu !

Enfin, l’image de notre triangle sera Ă©crit Ă  la swap chain puis affichĂ©e sur le canvas !

Des ressources (en français et anglais)


Comments for Dessinons un triangle avec WebGPU



Written by Carmen Cincotti, computer graphics enthusiast, language learner, and improv actor currently living in San Francisco, CA.  Follow @CarmenCincotti

Contribute

Interested in contributing to Carmen's Graphics Blog? Click here for details!