WebGL minimal en 75 lignes de code

OpenGL moderne, et plus largement WebGL, est très différent de l'ancien OpenGL que j'ai étudié dans le passé. Je comprends le fonctionnement de la pixellisation, donc je connais assez bien les concepts. Cependant, chaque didacticiel que j'ai lu offrait des abstractions et des fonctions d'assistance qui m'ont rendu plus difficile de comprendre quelles parties appartiennent aux API OpenGL elles-mêmes.



Pour clarifier, les abstractions telles que la division des données de position et la fonctionnalité de rendu en classes séparées sont importantes dans les applications du monde réel. Cependant, ces abstractions dispersent le code dans différentes zones et ajoutent de la redondance en raison du passe-partout et du transfert de données entre les unités logiques. Je trouve qu'il est plus pratique d' étudier un sujet dans un flux de code linéaire, dans lequel chaque ligne est directement liée à ce sujet.



Tout d'abord, je dois remercier le créateur du tutoriel que j'ai utilisé . En prenant cela comme base, je me suis débarrassé de toutes les abstractions jusqu'à ce que j'obtienne le "programme minimal viable". J'espère que cela vous aidera à démarrer avec OpenGL moderne. Voici ce que nous allons faire:





Triangle équilatéral, vert en haut, noir en bas à gauche et rouge en bas à droite, avec des couleurs interpolées entre les points. Une version légèrement plus lumineuse du triangle noir [ traduction en Habré].



Initialisation



Dans WebGL, nous devons canvasdessiner. Bien sûr, vous devrez certainement ajouter tout le passe-partout HTML habituel, les styles, etc., mais le canevas est la chose la plus importante. Une fois le DOM chargé, nous pouvons accéder au canevas en utilisant Javascript.



<canvas id="container" width="500" height="500"></canvas>

<script>
  document.addEventListener('DOMContentLoaded', () => {
    // All the Javascript code below goes here
  });
</script>


En accédant au canevas, nous pouvons obtenir le contexte de rendu WebGL et initialiser sa couleur claire. Les couleurs dans le monde OpenGL sont stockées en tant que RGBA et chaque composant a une valeur de 0à 1. La couleur claire est la couleur utilisée pour dessiner la toile au début de chaque cadre, redessinant la scène.



const canvas = document.getElementById('container');
const gl = canvas.getContext('webgl');

gl.clearColor(1, 1, 1, 1);


Dans les programmes réels, l'initialisation peut et doit être plus détaillée. En particulier, il convient de mentionner l'inclusion d' un tampon de profondeur qui permet de trier la géométrie en fonction des coordonnées Z. Nous ne le ferons pas pour un programme simple constitué d'un seul triangle.



Compilation de shaders



À la base, OpenGL est un cadre de rastérisation dans lequel nous devons prendre des décisions sur la façon de mettre en œuvre tout autre que la rastérisation. Par conséquent, au moins deux étapes de code doivent être exécutées sur le GPU:



  1. Un vertex shader qui traite toutes les données d'entrée et génère une position 3D (en fait une position 4D en coordonnées uniformes ) pour chaque entrée.
  2. Un shader de fragment qui traite chaque pixel à l'écran, restituant la couleur avec laquelle le pixel doit être peint.


Entre ces deux étapes, OpenGL obtient la géométrie du vertex shader et détermine quels pixels d'écran sont couverts par cette géométrie. C'est la phase de rastérisation.



Les deux shaders sont généralement écrits en GLSL (OpenGL Shading Language), qui est ensuite compilé en code machine pour le GPU. Le code machine est ensuite transmis au GPU afin qu'il puisse être exécuté pendant le processus de rendu. Je n'entrerai pas dans GLSL en détail car je ne veux montrer que les bases, mais le langage est suffisamment proche de C pour être familier à la plupart des programmeurs.



Tout d'abord, nous compilons et transmettons le vertex shader au GPU. Dans le fragment ci-dessous, le code source du shader est stocké sous forme de chaîne, mais peut être chargé à partir d'autres emplacements. Enfin, la chaîne est transmise à l'API WebGL.



const sourceV = `
  attribute vec3 position;
  varying vec4 color;

  void main() {
    gl_Position = vec4(position, 1);
    color = gl_Position * 0.5 + 0.5;
  }
`;

const shaderV = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(shaderV, sourceV);
gl.compileShader(shaderV);

if (!gl.getShaderParameter(shaderV, gl.COMPILE_STATUS)) {
  console.error(gl.getShaderInfoLog(shaderV));
  throw new Error('Failed to compile vertex shader');
}


Il vaut la peine d'expliquer ici certaines des variables du code GLSL:



  1. (attribute) position. , , .
  2. Varying color. ( ) . .
  3. gl_Position. , , varying-. , ,


Il existe également un type de variable uniforme , qui est une constante pour tous les appels de vertex shader. Ces uniformes sont utilisés pour des propriétés comme une matrice de transformation, qui sera constante pour tous les sommets d'un élément géométrique.



Ensuite, nous faisons la même chose avec le fragment shader - nous le compilons et le transférons vers le GPU. Notez que la variable colordu vertex shader est maintenant lue par le fragment shader.



const sourceF = `
  precision mediump float;
  varying vec4 color;

  void main() {
    gl_FragColor = color;
  }
`;

const shaderF = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(shaderF, sourceF);
gl.compileShader(shaderF);

if (!gl.getShaderParameter(shaderF, gl.COMPILE_STATUS)) {
  console.error(gl.getShaderInfoLog(shaderF));
  throw new Error('Failed to compile fragment shader');
}


De plus, les shaders de sommets et de fragments sont liés en un seul programme OpenGL.



const program = gl.createProgram();
gl.attachShader(program, shaderV);
gl.attachShader(program, shaderF);
gl.linkProgram(program);

if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
  console.error(gl.getProgramInfoLog(program));
  throw new Error('Failed to link program');
}

gl.useProgram(program);


Nous disons au GPU que nous voulons exécuter les shaders ci-dessus. Il ne nous reste plus qu'à créer les données entrantes et à laisser le GPU traiter ces données.



Envoi de données entrantes vers le GPU



Les données entrantes seront stockées dans la mémoire du GPU et traitées à partir de là. Au lieu de faire des appels de tirage séparés pour chaque élément de données entrantes, qui transfèrent les données correspondantes un morceau à la fois, toutes les données entrantes sont transférées dans leur intégralité vers le GPU et lues à partir de là. (L'ancien OpenGL transmettait des données sur des éléments individuels, ce qui ralentissait les performances.)



OpenGL fournit une abstraction appelée Vertex Buffer Object (VBO). Je suis toujours en train de comprendre comment cela fonctionne, mais nous finirons par faire ce qui suit pour l'utiliser:



  1. Stockez la séquence de données dans la mémoire de l'unité centrale (CPU).
  2. Transférez des octets vers la mémoire GPU via un tampon unique créé avec gl.createBuffer()et des points d'ancrage gl.ARRAY_BUFFER .


Pour chaque variable de données d'entrée (attribut) dans le vertex shader, nous aurons un VBO, bien qu'il soit possible d'utiliser un VBO pour plusieurs éléments des données d'entrée.



const positionsData = new Float32Array([
  -0.75, -0.65, -1,
   0.75, -0.65, -1,
   0   ,  0.65, -1,
]);

const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, positionsData, gl.STATIC_DRAW);


En règle générale, nous définissons la géométrie avec les coordonnées que notre application comprend, puis nous utilisons un ensemble de transformations dans le shader de vertex pour les mapper dans l' espace de découpe OpenGL. Je n'entrerai pas dans les détails sur l'espace de troncature (il est associé à des coordonnées homogènes), alors qu'il suffit de savoir que X et Y changent dans l'intervalle de -1 à +1. Puisque le vertex shader passe simplement l'entrée telle quelle, nous pouvons définir nos coordonnées directement dans l'espace de découpage.



Ensuite, nous lierons également le tampon à l'une des variables du vertex shader. Dans le code, nous procédons comme suit:



  1. Nous obtenons le descripteur positionde variable du programme créé ci-dessus.
  2. Nous demandons à OpenGL de lire les données du point d'ancrage gl.ARRAY_BUFFERpar groupes de 3 avec certains paramètres, par exemple, avec un décalage et une foulée de 0.




const attribute = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(attribute);
gl.vertexAttribPointer(attribute, 3, gl.FLOAT, false, 0, 0);


Il est intéressant de noter que nous pouvons créer un VBO de cette manière et le lier à un attribut vertex shader car nous exécutons ces fonctions une par une. Si nous devions séparer les deux fonctions (par exemple, créer tous les VBO en une seule passe, puis les lier à des attributs séparés), alors avant de mapper chaque VBO à l'attribut correspondant, nous aurions besoin d'appeler à chaque fois gl.bindBuffer(...).



Le rendu!



Enfin, lorsque toutes les données de la mémoire GPU sont préparées selon les besoins, nous pouvons dire à OpenGL d'effacer l'écran et d'exécuter le programme pour traiter les tableaux que nous avons préparés. Dans le cadre de l'étape de rastérisation (détermination des pixels couverts par les sommets), nous demandons à OpenGL de traiter les sommets par groupes de 3 comme des triangles.



gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);


Avec un tel schéma linéaire, le programme sera exécuté en une seule fois. Dans toute application pratique, nous stockions les données de manière structurée, les envoyions au GPU au fur et à mesure qu'elles changeraient et nous les rendions à chaque image.






Pour résumer, vous trouverez ci-dessous un diagramme avec un ensemble minimal de concepts nécessaires pour afficher notre premier triangle à l'écran. Mais même ce schéma est grandement simplifié, il est donc préférable d'écrire les 75 lignes de code présentées dans cet article et de les étudier.





La dernière séquence très simplifiée d'étapes nécessaires pour afficher un triangle



Pour moi, la partie la plus difficile de l'apprentissage d'OpenGL était la quantité de passe-partout nécessaire pour afficher l'image la plus simple à l'écran. Étant donné que le cadre de rastérisation nous oblige à fournir des fonctionnalités de rendu 3D et que la communication avec le GPU est très importante, de nombreux concepts doivent être étudiés directement. Espérons que cet article vous a montré les bases d'une manière plus simple qu'elles n'apparaissent dans d'autres tutoriels.



Voir également:








Voir également:






All Articles