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.
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 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).
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.
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.
Enfin, pour voir le monde à travers une caméra perspective, on applique la transformation de la projection pour obtenir le résultùt suivant :
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 !