WebGL ortográfico 3D

Gregg Tavares
Gregg Tavares

WebGL ortográfico 3D

Esta publicación es la continuación de una serie de publicaciones sobre WebGL. El primero comenzó con los conceptos básicos y el anterior trataba sobre las matrices 2D sobre las matrices 2D. Si no los leíste, primero míralos. En la última publicación, repasamos cómo funcionaban las matrices 2D. Hablamos sobre la traducción, la rotación, el escalamiento y hasta la proyección de píxeles en el espacio de clip, todo lo cual se puede hacer con 1 matriz y algunas matemáticas de matrices mágicas. Hacer 3D es solo un pequeño paso a partir de ahí. En nuestros ejemplos anteriores de 2D, teníamos puntos 2D (x, y) que multiplicamos por una matriz de 3 × 3. Para trabajar en 3D, necesitamos puntos 3D (x, y, z) y una matriz 4 × 4. Tomemos nuestro último ejemplo y cambiemos a 3D. Volveremos a usar una F, pero esta vez una "F" en 3D. Lo primero que debemos hacer es cambiar el sombreador de vértices para que admita 3D. Este es el sombreador anterior.

<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>

Y este es el nuevo

<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>

Ahora es aún más sencillo. Luego, debemos proporcionar datos en 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);
}

A continuación, debemos cambiar todas las funciones de matriz de 2D a 3D. Estas son las versiones 2D (anteriores) de makeTranslation, makeRotation y 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
];
}

Y estas son las versiones 3D actualizadas.

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,
];
}

Observa que ahora tenemos 3 funciones de rotación. Solo necesitábamos uno en 2D, ya que, en realidad, solo rotamos alrededor del eje Z. Sin embargo, para hacer 3D, también queremos poder rotar alrededor del eje x y el eje y. Puedes ver que son muy similares. Si los resolviéramos, los verías simplificarse como antes

Rotación en Z

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

Rotación en Y


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

Rotación en X

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

También debemos actualizar la función de proyección. Este es el anterior

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
];
}

que se convirtió de píxeles a espacio de clip. Para nuestro primer intento de expandirlo a 3D, probemos

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,
];
}

Al igual que tuvimos que convertir de píxeles a espacio de recorte para x e y, para z debemos hacer lo mismo. En este caso, también estoy creando unidades de píxeles de espacio Z. Pasaré un valor similar a width para la profundidad, de modo que nuestro espacio será de 0 a píxeles de ancho y de 0 a píxeles de altura, pero para la profundidad será de -profundidad / 2 a +profundidad / 2. Por último, debemos actualizar el código que calcula la matriz.

// 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);

El primer problema que tenemos es que nuestra geometría es una F plana, lo que dificulta ver cualquier elemento en 3D. Para solucionarlo, expandamos la geometría a 3D. Nuestra F actual está formada por 3 rectángulos, 2 triángulos cada uno. Para hacerlo en 3D, necesitarás un total de 16 rectángulos. Son bastantes para enumerarlos aquí. 16 rectángulos × 2 triángulos por rectángulo × 3 vértices por triángulo = 96 vértices. Si quieres ver todos, consulta el código fuente en la muestra. Tenemos que dibujar más vértices para que

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

Cuando se mueven los controles deslizantes, es muy difícil saber que es 3D. Intentemos colorear cada rectángulo de un color diferente. Para ello, agregaremos otro atributo a nuestro sombreador de vértices y una variante para pasarlo del sombreador de vértices al sombreador de fragmentos. Este es el nuevo sombreador de vértices

<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>

Y debemos usar ese color en el sombreador de fragmentos.

<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>

Necesitamos buscar la ubicación para proporcionar los colores y, luego, configurar otro buffer y atributo para asignarle los colores.

...
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);
}

Uy, ¿qué es ese desorden? Resulta que todas las diferentes partes de esa "F" en 3D, el frente, la parte posterior, los lados, etc., se dibujan en el orden en que aparecen en nuestra geometría. Eso no nos brinda los resultados deseados, ya que, a veces, los elementos de atrás se dibujan después de los de adelante. Los triángulos en WebGL tienen el concepto de cara frontal y posterior. Un triángulo orientado hacia delante tiene sus vértices en el sentido de las manecillas del reloj. Un triángulo orientado hacia atrás tiene sus vértices en el sentido contrario a las manecillas del reloj.

Espiral triangular.

WebGL puede dibujar solo triángulos orientados hacia adelante o hacia atrás. Podemos activar esa función con

gl.enable(gl.CULL_FACE);

que hacemos solo una vez, justo al comienzo de nuestro programa. Con esa función activada, WebGL “elimina” de forma predeterminada los triángulos orientados hacia atrás. En este caso, “eliminación” es una palabra elegante para “no dibujar”. Ten en cuenta que, en lo que respecta a WebGL, si un triángulo se considera que se mueve en el sentido de las manecillas del reloj o en el sentido contrario, depende de los vértices de ese triángulo en el espacio de recorte. En otras palabras, WebGL determina si un triángulo es frontal o posterior DESPUÉS de que hayas aplicado matemáticas a los vértices en el sombreador de vértices. Eso significa que, por ejemplo, un triángulo en el sentido de las manecillas del reloj que se escala en X por -1 se convierte en un triángulo en sentido contrario a las manecillas del reloj, o un triángulo en el sentido de las manecillas del reloj que se rota 180 grados alrededor del eje X o Y se convierte en un triángulo en sentido contrario a las manecillas del reloj. Como inhabilitamos CULL_FACE, podemos ver los triángulos en el sentido de las manecillas del reloj(frontal) y en el sentido contrario(posterior). Ahora que lo activamos, cada vez que un triángulo frontal se voltee, ya sea debido a la escala o la rotación, o por cualquier otro motivo, WebGL no lo dibujará. Eso es bueno, ya que, a medida que giras algo en 3D, por lo general, quieres que los triángulos que te miran se consideren frontales.

¡Hola! ¿A dónde se fueron todos los triángulos? Resulta que muchos de ellos están orientados de la manera incorrecta. Gíralo y verás que aparecen cuando miras hacia el otro lado. Por suerte, es fácil de solucionar. Solo miramos cuáles están hacia atrás y cambiamos 2 de sus vértices. Por ejemplo, si un triángulo hacia atrás es

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

Solo debemos voltear los últimos 2 vértices para que se muestren hacia adelante.

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

Eso está más cerca, pero aún hay un problema más. Incluso con todos los triángulos orientados en la dirección correcta y con los que están orientados hacia atrás descartados, aún hay lugares en los que los triángulos que deberían estar en la parte posterior se dibujan sobre los que deberían estar en la parte frontal. Ingresa el BUFFER DE PROFUNDIDAD. Un búfer de profundidad, a veces llamado búfer Z, es un rectángulo de depth píxeles, un píxel de profundidad para cada píxel de color que se usa para crear la imagen. A medida que WebGL dibuja cada píxel de color, también puede dibujar un píxel de profundidad. Lo hace según los valores que mostramos desde el sombreador de vértices para Z. Al igual que tuvimos que convertir a espacio de clip para X e Y, Z está en espacio de clip o (de -1 a +1). Luego, ese valor se convierte en un valor de espacio de profundidad (de 0 a +1). Antes de que WebGL dibuje un píxel de color, verificará el píxel de profundidad correspondiente. Si el valor de profundidad del píxel que está a punto de dibujar es mayor que el valor del píxel de profundidad correspondiente, WebGL no dibuja el nuevo píxel de color. De lo contrario, dibuja el nuevo píxel de color con el color de tu sombreador de fragmentos Y dibuja el píxel de profundidad con el nuevo valor de profundidad. Esto significa que los píxeles que están detrás de otros no se dibujarán. Podemos activar esta función casi con la misma facilidad que activamos la eliminación con

gl.enable(gl.DEPTH_TEST);

También debemos restablecer el búfer de profundidad a 1.0 antes de comenzar a dibujar.

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

En la próxima publicación, explicaré cómo darle perspectiva.

¿Por qué el atributo es vec4, pero gl.vertexAttribPointer tiene el tamaño 3?

Si te gusta prestar atención a los detalles, es posible que hayas notado que definimos nuestros 2 atributos como

attribute vec4 a_position;
attribute vec4 a_color;

ambos son "vec4", pero cuando le indicamos a WebGL cómo sacar datos de nuestros búferes, usamos

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

El "3" en cada uno de ellos indica que solo se deben extraer 3 valores por atributo. Esto funciona porque, en el sombreador de vértices, WebGL proporciona valores predeterminados para los que no proporcionas. Los valores predeterminados son 0, 0, 0, 1, en los que x = 0, y = 0, z = 0 y w = 1. Por eso, en nuestro antiguo sombreador de vértices 2D, tuvimos que proporcionar el 1 de forma explícita. Pasábamos x e y y necesitábamos un 1 para z, pero como el valor predeterminado de z es 0, tuvimos que proporcionar un 1 de forma explícita. Sin embargo, para 3D, aunque no proporcionemos un "w", se establece de forma predeterminada en 1, que es lo que necesitamos para que funcionen las matemáticas de matrices.