3D orthographique WebGL
Cet article fait partie d'une série d'articles sur WebGL. Le premier commençait par les principes de base et le précédent portait sur les matrices 2D. Si vous ne les avez pas encore lus, veuillez d'abord les consulter. Dans le dernier article, nous avons vu comment fonctionnaient les matrices 2D. Nous avons vu que la translation, la rotation, la mise à l'échelle et même la projection de pixels dans l'espace de découpage peuvent toutes être effectuées par une seule matrice et quelques calculs matriciels magiques. La 3D n'est qu'à un pas de là. Dans nos exemples 2D précédents, nous avions des points 2D (x, y) que nous multipliions par une matrice 3x3. Pour la 3D, nous avons besoin de points 3D (x, y, z) et d'une matrice 4x4. Reprenons notre dernier exemple et passons-le en 3D. Nous allons utiliser un F, mais cette fois-ci, un F en 3D. La première chose à faire est de modifier le nuanceur de sommets pour gérer 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);
}
Nous devons ensuite modifier 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
];
}
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 faisions pivoter que l'axe Z. Pour la 3D, nous voulons également pouvoir faire pivoter l'objet autour des axes X et Y. Comme vous pouvez le constater, ils sont tous très similaires. Si nous les calculions, vous verriez qu'elles 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 convertissait les pixels en espace de découpe. Pour notre première tentative d'extension 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écoupage pour x et y, nous devons faire de même pour z. Dans ce cas, je définis également les unités de l'espace Z en pixels. Je vais transmettre une valeur semblable à width pour la profondeur. Notre espace aura donc une largeur de 0 à "width" pixels, une hauteur de 0 à "height" pixels, mais une profondeur 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 est que notre géométrie est un F plat, ce qui rend difficile de voir la 3D. Pour y remédier, étendons la géométrie à la 3D. Notre F actuel est composé de trois rectangles, chacun constitué de deux triangles. Pour le rendre en 3D, il faudra un total de 16 rectangles. La liste est longue. 16 rectangles x 2 triangles par rectangle x 3 sommets par triangle = 96 sommets. Si vous souhaitez tous les voir, affichez 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 en 3D. Essayons de colorer chaque rectangle d'une couleur différente. Pour ce faire, nous allons ajouter un autre attribut à notre nuanceur de sommets et une variable pour le transmettre du nuanceur de sommets au nuanceur de fragments. 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 fragments.
<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 donner 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 oh, qu'est-ce que c'est que ce bazar ? En fait, toutes les différentes parties de ce "F" en 3D (avant, arrière, côtés, etc.) sont dessinées dans l'ordre dans lequel elles apparaissent dans notre géométrie. Cela ne 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 de face avant et de face 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 disposés dans le sens inverse des aiguilles d'une montre.
WebGL peut dessiner uniquement 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 n'effectuons qu'une seule fois, au tout début de notre programme. Lorsque cette fonctionnalité est activée, WebGL élimine par défaut les triangles orientés vers l'arrière. Dans ce cas, "élimination" est un terme sophistiqué pour dire "ne pas dessiner". Notez que, pour WebGL, le fait qu'un triangle soit considéré comme allant dans le sens des aiguilles d'une montre ou dans le sens inverse des aiguilles d'une montre dépend des sommets de ce triangle dans l'espace de découpage. En d'autres termes, WebGL détermine si un triangle est à l'avant ou à l'arrière APRÈS que vous avez appliqué des calculs mathématiques aux sommets dans le nuanceur de sommets. Par exemple, un triangle dans le sens des aiguilles d'une montre dont l'échelle est de -1 sur l'axe X devient un triangle dans le sens inverse des aiguilles d'une montre, ou 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 est désactivé, nous pouvons voir 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 se retourne, que ce soit en raison d'une mise à l'échelle, d'une rotation ou pour toute autre raison, WebGL ne le dessine 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 orientés vers 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 de l'autre côté. Heureusement, il est facile à résoudre. Nous examinons simplement ceux qui sont inversés et échangeons deux de leurs sommets. Par exemple, si un triangle pointant vers l'arrière est
1, 2, 3,
40, 50, 60,
700, 800, 900,
nous inversons simplement les deux derniers sommets pour qu'il soit orienté vers l'avant.
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 ceux orientés vers l'arrière sont éliminés, il reste des endroits où les triangles qui devraient être à l'arrière sont dessinés par-dessus ceux qui devraient être à l'avant.
Saisissez le TAMPON DE PROFONDEUR.
Un tampon de profondeur, parfois appelé tampon Z, est un rectangle de depth pixels, 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. Pour ce faire, il se base sur les valeurs que nous renvoyons depuis le nuanceur de sommets pour Z. Tout comme nous avons dû convertir les coordonnées X et Y en espace de découpage, la coordonnée Z est également dans l'espace de découpage (ou de -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 s'apprête à 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é l'élimination avec
gl.enable(gl.DEPTH_TEST);
Nous devons également effacer le tampon de profondeur pour le ramener à 1.0 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 post, je vous expliquerai comment lui donner de la perspective.
Pourquoi l'attribut est-il vec4, mais la taille de gl.vertexAttribPointer est de 3 ?
Pour ceux d'entre vous qui sont attentifs aux détails, vous avez peut-être remarqué que nous avons défini nos deux attributs comme
attribute vec4 a_position;
attribute vec4 a_color;
qui sont tous deux des "vec4". Cependant, lorsque nous indiquons à WebGL comment extraire les 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 chacune de ces lignes indique qu'il ne faut extraire que trois valeurs par attribut. Cela fonctionne, car dans le nuanceur de sommets, WebGL fournit des valeurs par défaut pour ceux 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 la valeur 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. Pour la 3D, même si nous ne fournissons pas de valeur "w", elle est définie par défaut sur 1, ce dont nous avons besoin pour que les calculs matriciels fonctionnent.