WebGL orthographique 3D

Gregg Tavares
Gregg Tavares

3D orthographique WebGL

Cet article fait suite à une série d'articles sur WebGL. Le premier a commencé par les principes de base, et le précédent portait sur les matrices 2D sur les matrices 2D. Si vous ne l'avez pas fait, veuillez les consulter en premier. Dans le dernier article, nous avons expliqué le fonctionnement des matrices 2D. Nous avons vu que la traduction, la rotation, la mise à l'échelle et même la projection à partir de pixels dans l'espace de clip peuvent tous être effectués à l'aide d'une seule matrice et de quelques calculs magiques. Il n'y a qu'un pas à franchir pour passer à la 3D. Dans nos exemples 2D précédents, nous avions des points 2D (x, y) que nous avons multipliés par une matrice 3x3. Pour la 3D, nous avons besoin de points 3D (x, y, z) et d'une matrice 4x4. Prenons notre dernier exemple et convertissons-le en 3D. Nous allons à nouveau utiliser un F, mais cette fois-ci, il s'agit d'un F 3D. La première chose que nous devons faire est de modifier le nuanceur de sommet pour qu'il gère la 3D. Voici l'ancien nuanceur.

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform mat3 u_matrix;

void main() {
// Multiply the position by the matrix.
gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
}
</script>

Et voici la nouvelle

<script id="3d-vertex-shader" type="x-shader/x-vertex">
attribute vec4 a_position;

uniform mat4 u_matrix;

void main() {
// Multiply the position by the matrix.
gl_Position = u_matrix * a_position;
}
</script>

C'est encore plus simple ! Nous devons ensuite fournir des données 3D.

...

gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);

...

// Fill the buffer with the values that define a letter 'F'.
function setGeometry(gl) {
gl.bufferData(
    gl.ARRAY_BUFFER,
    new Float32Array([
        // left column
        0,   0,  0,
        30,   0,  0,
        0, 150,  0,
        0, 150,  0,
        30,   0,  0,
        30, 150,  0,

        // top rung
        30,   0,  0,
        100,   0,  0,
        30,  30,  0,
        30,  30,  0,
        100,   0,  0,
        100,  30,  0,

        // middle rung
        30,  60,  0,
        67,  60,  0,
        30,  90,  0,
        30,  90,  0,
        67,  60,  0,
        67,  90,  0]),
    gl.STATIC_DRAW);
}

Ensuite, nous devons convertir toutes les fonctions de matrice de 2D en 3D. Voici les versions 2D (avant) de makeTranslation, makeRotation et makeScale.

function makeTranslation(tx, ty) {
return [
1, 0, 0,
0, 1, 0,
tx, ty, 1
];
}

function makeRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);
return [
c,-s, 0,
s, c, 0,
0, 0, 1
];
}

function makeScale(sx, sy) {
return [
sx, 0, 0,
0, sy, 0,
0, 0, 1
];
}

Et voici les versions 3D mises à jour.

function makeTranslation(tx, ty, tz) {
return [
    1,  0,  0,  0,
    0,  1,  0,  0,
    0,  0,  1,  0,
    tx, ty, tz, 1
];
}

function makeXRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);

return [
1, 0, 0, 0,
0, c, s, 0,
0, -s, c, 0,
0, 0, 0, 1
];
};

function makeYRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);

return [
c, 0, -s, 0,
0, 1, 0, 0,
s, 0, c, 0,
0, 0, 0, 1
];
};

function makeZRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);
return [
    c, s, 0, 0,
-s, c, 0, 0,
    0, 0, 1, 0,
    0, 0, 0, 1,
];
}

function makeScale(sx, sy, sz) {
return [
sx, 0,  0,  0,
0, sy,  0,  0,
0,  0, sz,  0,
0,  0,  0,  1,
];
}

Notez que nous avons maintenant trois fonctions de rotation. Nous n'en avions besoin que d'un en 2D, car nous ne tournions qu'autour de l'axe Z. Pour la 3D, nous devons également pouvoir faire pivoter l'objet autour des axes X et Y. Vous pouvez voir qu'ils sont tous très similaires. Si nous les calculions, vous verriez qu'ils se simplifient comme avant.

Rotation Z

newX = x * c + y * s;
newY = x * -s + y * c;

Rotation Y


newX = x * c + z * s;
newZ = x * -s + z * c;

Rotation X

newY = y * c + z * s;
newZ = y * -s + z * c;

Nous devons également mettre à jour la fonction de projection. Voici l'ancienne

function make2DProjection(width, height) {
// Note: This matrix flips the Y axis so 0 is at the top.
return [
2 / width, 0, 0,
0, -2 / height, 0,
-1, 1, 1
];
}

qui convertit les pixels en espace de clip. Pour notre première tentative de conversion en 3D, essayons

function make2DProjection(width, height, depth) {
// Note: This matrix flips the Y axis so 0 is at the top.
return [
    2 / width, 0, 0, 0,
    0, -2 / height, 0, 0,
    0, 0, 2 / depth, 0,
-1, 1, 0, 1,
];
}

Tout comme nous avons dû convertir les pixels en espace de découpe pour x et y, nous devons faire de même pour z. Dans ce cas, je crée également des unités de pixel d'espace Z. Je vais transmettre une valeur semblable à width pour la profondeur. Notre espace sera donc de 0 à la largeur en pixels, de 0 à la hauteur en pixels, mais pour la profondeur, il sera de -depth / 2 à +depth / 2. Enfin, nous devons mettre à jour le code qui calcule la matrice.

// Compute the matrices
var projectionMatrix =
    make2DProjection(canvas.width, canvas.height, canvas.width);
var translationMatrix =
    makeTranslation(translation[0], translation[1], translation[2]);
var rotationXMatrix = makeXRotation(rotation[0]);
var rotationYMatrix = makeYRotation(rotation[1]);
var rotationZMatrix = makeZRotation(rotation[2]);
var scaleMatrix = makeScale(scale[0], scale[1], scale[2]);

// Multiply the matrices.
var matrix = matrixMultiply(scaleMatrix, rotationZMatrix);
matrix = matrixMultiply(matrix, rotationYMatrix);
matrix = matrixMultiply(matrix, rotationXMatrix);
matrix = matrixMultiply(matrix, translationMatrix);
matrix = matrixMultiply(matrix, projectionMatrix);

// Set the matrix.
gl.uniformMatrix4fv(matrixLocation, false, matrix);

Le premier problème que nous rencontrons est que notre géométrie est un F plat, ce qui rend difficile la visualisation de la 3D. Pour résoudre ce problème, étendons la géométrie en 3D. Notre F actuel est composé de trois rectangles, chacun avec deux triangles. Pour le rendre en 3D, vous aurez besoin d'un total de 16 rectangles. C'est une longue liste. 16 rectangles x 2 triangles par rectangle x 3 sommets par triangle = 96 sommets. Si vous souhaitez les afficher tous, accédez à la source de l'exemple. Nous devons dessiner plus de sommets.

// Draw the geometry.
gl.drawArrays(gl.TRIANGLES, 0, 16 * 6);

En déplaçant les curseurs, il est assez difficile de voir qu'il s'agit d'une image 3D. Essayons de colorer chaque rectangle d'une couleur différente. Pour ce faire, nous allons ajouter un autre attribut à notre nuanceur de vertex et une variation pour le transmettre du nuanceur de vertex au nuanceur de fragment. Voici le nouveau nuanceur de sommets

<script id="3d-vertex-shader" type="x-shader/x-vertex">
attribute vec4 a_position;
attribute vec4 a_color;

uniform mat4 u_matrix;

varying vec4 v_color;

void main() {
// Multiply the position by the matrix.
gl_Position = u_matrix * a_position;

// Pass the color to the fragment shader.
v_color = a_color;
}
</script>

Nous devons utiliser cette couleur dans le nuanceur de fragment.

<script id="3d-vertex-shader" type="x-shader/x-fragment">
precision mediump float;

// Passed in from the vertex shader.
varying vec4 v_color;

void main() {
gl_FragColor = v_color;
}
</script>

Nous devons rechercher l'emplacement pour fournir les couleurs, puis configurer un autre tampon et un autre attribut pour lui attribuer les couleurs.

...
var colorLocation = gl.getAttribLocation(program, "a_color");

...
// Create a buffer for colors.
var buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(colorLocation);

// We'll supply RGB as bytes.
gl.vertexAttribPointer(colorLocation, 3, gl.UNSIGNED_BYTE, true, 0, 0);

// Set Colors.
setColors(gl);

...
// Fill the buffer with colors for the 'F'.

function setColors(gl) {
gl.bufferData(
    gl.ARRAY_BUFFER,
    new Uint8Array([
        // left column front
    200,  70, 120,
    200,  70, 120,
    200,  70, 120,
    200,  70, 120,
    200,  70, 120,
    200,  70, 120,

        // top rung front
    200,  70, 120,
    200,  70, 120,
    ...
    ...
    gl.STATIC_DRAW);
}

Oh non, qu'est-ce que c'est ? Il s'avère que toutes les différentes parties de cette lettre "F" 3D, l'avant, l'arrière, les côtés, etc., sont dessinées dans l'ordre dans lequel elles apparaissent dans notre géométrie. Cela ne nous donne pas tout à fait les résultats souhaités, car parfois les éléments à l'arrière sont dessinés après ceux à l'avant. Les triangles dans WebGL ont le concept d'orientation avant et arrière. Les sommets d'un triangle orienté vers l'avant sont disposés dans le sens des aiguilles d'une montre. Les sommets d'un triangle orienté vers l'arrière sont orientés dans le sens inverse des aiguilles d'une montre.

Enroulement en triangle.

WebGL ne peut dessiner que des triangles orientés vers l'avant ou vers l'arrière. Nous pouvons activer cette fonctionnalité avec

gl.enable(gl.CULL_FACE);

que nous ne faisons qu'une seule fois, au tout début de notre programme. Lorsque cette fonctionnalité est activée, WebGL effectue par défaut une "suppression" des triangles orientés vers l'arrière. Dans ce cas, le terme "élagage" désigne l'absence de dessin. Notez que, pour WebGL, le fait qu'un triangle soit considéré comme tournant dans le sens des aiguilles d'une montre ou dans le sens inverse dépend des sommets de ce triangle dans l'espace de découpe. En d'autres termes, WebGL détermine si un triangle est à l'avant ou à l'arrière APRÈS avoir appliqué des calculs aux sommets dans le nuanceur de sommets. Cela signifie, par exemple, qu'un triangle dans le sens des aiguilles d'une montre dont la valeur d'échelle sur l'axe X est -1 devient un triangle dans le sens inverse des aiguilles d'une montre, ou qu'un triangle dans le sens des aiguilles d'une montre pivoté de 180 degrés autour de l'axe X ou Y devient un triangle dans le sens inverse des aiguilles d'une montre. Comme CULL_FACE était désactivé, nous pouvons voir à la fois les triangles dans le sens des aiguilles d'une montre(avant) et dans le sens inverse des aiguilles d'une montre(arrière). Maintenant que nous l'avons activé, chaque fois qu'un triangle orienté vers l'avant est retourné en raison d'une mise à l'échelle ou d'une rotation, ou pour quelque raison que ce soit, WebGL ne l'affiche pas. C'est une bonne chose, car lorsque vous faites pivoter un objet en 3D, vous souhaitez généralement que les triangles qui vous font face soient considérés comme étant à l'avant.

Salut ! Où sont passés tous les triangles ? Il s'avère que beaucoup d'entre eux sont orientés dans la mauvaise direction. Faites-le pivoter, et vous les verrez apparaître lorsque vous regarderez de l'autre côté. Heureusement, il est facile à résoudre. Nous vérifions simplement celles qui sont à l'envers et échangeons deux de leurs sommets. Par exemple, si un triangle inversé est

1,   2,   3,
40,  50,  60,
700, 800, 900,

Nous retournons simplement les deux derniers sommets pour les faire avancer.

1,   2,   3,
700, 800, 900,
40,  50,  60,

C'est mieux, mais il reste un problème. Même si tous les triangles sont orientés dans la bonne direction et que les triangles orientés vers l'arrière sont éliminés, il existe encore des endroits où les triangles qui devraient être à l'arrière sont dessinés par-dessus les triangles qui devraient être à l'avant. Saisissez le BUFFER DE PROFONDEUR. Un tampon de profondeur, parfois appelé tampon Z, est un rectangle de pixels depth, un pixel de profondeur pour chaque pixel de couleur utilisé pour créer l'image. Lorsque WebGL dessine chaque pixel de couleur, il peut également dessiner un pixel de profondeur. Il le fait en fonction des valeurs que nous renvoyons du nuanceur de sommets pour Z. Tout comme nous avons dû convertir en espace de clip pour X et Y, Z est donc dans l'espace de clip (-1 à +1). Cette valeur est ensuite convertie en valeur d'espace de profondeur (de 0 à +1). Avant que WebGL ne dessine un pixel de couleur, il vérifie le pixel de profondeur correspondant. Si la valeur de profondeur du pixel qu'il est sur le point de dessiner est supérieure à la valeur du pixel de profondeur correspondant, WebGL ne dessine pas le nouveau pixel de couleur. Sinon, il dessine à la fois le nouveau pixel de couleur avec la couleur de votre nuanceur de fragment ET le pixel de profondeur avec la nouvelle valeur de profondeur. Cela signifie que les pixels situés derrière d'autres pixels ne seront pas dessinés. Nous pouvons activer cette fonctionnalité presque aussi simplement que nous avons activé le masquage avec

gl.enable(gl.DEPTH_TEST);

Nous devons également rétablir la valeur 1,0 dans le tampon de profondeur avant de commencer à dessiner.

// Draw the scene.
function drawScene() {
// Clear the canvas AND the depth buffer.
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
...

Dans le prochain article, je vous expliquerai comment lui donner de la perspective.

Pourquoi l'attribut est-il vec4, mais la taille de gl.vertexAttribPointer est-elle de 3 ?

Les plus attentifs d'entre vous auront peut-être remarqué que nous avons défini nos deux attributs comme suit :

attribute vec4 a_position;
attribute vec4 a_color;

qui sont tous deux "vec4", mais lorsque nous indiquons à WebGL comment extraire des données de nos tampons, nous utilisons

gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);
gl.vertexAttribPointer(colorLocation, 3, gl.UNSIGNED_BYTE, true, 0, 0);

Le chiffre 3 dans chacun de ces éléments indique uniquement de récupérer trois valeurs par attribut. Cela fonctionne, car dans le nuanceur de sommets, WebGL fournit des valeurs par défaut pour celles que vous ne fournissez pas. Les valeurs par défaut sont 0, 0, 0, 1, où x = 0, y = 0, z = 0 et w = 1. C'est pourquoi, dans notre ancien nuanceur de sommets 2D, nous devions fournir explicitement le 1. Nous transmettions x et y, et nous avions besoin d'un 1 pour z, mais comme la valeur par défaut de z est 0, nous avons dû fournir explicitement un 1. Toutefois, pour la 3D, même si nous ne fournissons pas de "w", il est défini par défaut sur 1, ce qui est nécessaire pour que les calculs matriciels fonctionnent.