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 era sobre las matrices 2D sobre las matrices 2D. Si todavía no los leíste, míralos primero. En la última publicación, repasamos cómo funcionaban las matrices 2D. Hablamos sobre traslación, rotación, escalamiento y hasta proyección de píxeles al espacio de recorte con 1 matriz y algunas matemáticas mágicas. Hacer 3D está solo un pequeño paso a partir de ahí. En nuestros ejemplos 2D anteriores, teníamos puntos 2D (x, y) que multiplicamos por una matriz 3x3. Para hacer 3D se necesitan puntos en 3D (x, y, z) y una matriz 4x4. Tomemos nuestro último ejemplo y cambiemos a 3D. Volveremos a usar una F, pero esta vez una 3D. Lo primero que debemos hacer es cambiar el sombreador de vértices para que controle 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 aquí está 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, necesitamos 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);
}

Ahora debemos cambiar todas las funciones matriciales de 2D a 3D. Estas son las versiones en 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 aquí están 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 una en 2D, ya que realizamos solo girando alrededor del eje Z. Sin embargo, para hacer 3D, también queremos poder rotar sobre el eje X y el eje Y. Al observarlas, puedes ver que todas son muy similares. Si los resolviéramos, los verías que se simplificaron como antes

Rotación en Z

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

Rotación Y


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

Rotación X

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

También necesitamos 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 convertía de píxeles a espacio de recorte. Para nuestro primer intento de expandirlo a 3D,

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

Así como necesitamos convertir píxeles a un espacio de recorte para x e y, en el caso de z, debemos hacer lo mismo. En este caso, también estoy creando las unidades de píxeles espaciales Z. Pasaré un valor similar a width para la profundidad, de modo que nuestro espacio será de 0 a los píxeles de ancho y de 0 a los píxeles de alto, pero en el caso de la profundidad, será de -depth / 2 a +depth / 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 la visualización de cualquier 3D. Para solucionarlo, expandamos la geometría a 3D. Nuestra F actual está formada por 3 rectángulos, 2 triángulos cada uno. Para hacerla en 3D, se requerirá un total de 16 rectángulos. Estos son varios de los que deberíamos enumerar aquí. 16 rectángulos x 2 triángulos por rectángulo x 3 vértices por triángulo son de 96 vértices. Si quieres verlos a todos, consulta el código fuente en la muestra. Tenemos que dibujar más vértices

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

Mover los controles deslizantes es bastante difícil de determinar que es 3D. Intentemos colorear cada rectángulo con un color diferente. Para ello, agregaremos otro atributo a nuestro sombreador de vértices y una variable 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>

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>

Debemos buscar la ubicación para proporcionar los colores y, luego, configurar otro búfer y atributo para darle 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);
}

¿Qué desorden? Resulta que todas las partes de esa "F" en 3D, el frente, el reverso, los lados, etc., se dibujan en el orden en que aparecen en nuestra geometría. Eso no nos proporciona los resultados deseados, ya que a veces los que están en la parte posterior se dibujan después de los de frente. Los triángulos de WebGL tienen el concepto de enfoque frontal y posterior. Un triángulo orientado al frente tiene sus vértices en el sentido de las manecillas del reloj. Un triángulo orientado hacia atrás tiene sus vértices en sentido contrario a las manecillas del reloj.

Triángulo con curvas.

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

gl.enable(gl.CULL_FACE);

que hacemos una sola vez, al comienzo del programa. Cuando esa función está activada, WebGL selecciona de forma predeterminada los triángulos ubicados en la parte trasera. En este caso, "Culling" es una palabra elegante para "no dibujar". Ten en cuenta que, en el sentido de WebGL, si se considera que un triángulo gira 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 está al frente o atrás DESPUÉS de aplicar matemáticas a los vértices en el sombreador de vértices. Eso significa, por ejemplo, que un triángulo en el sentido de las manecillas del reloj que se ajusta en X de -1 se convierte en un triángulo en el sentido contrario a las manecillas del reloj o un triángulo en el sentido de las manecillas del reloj rotado 180 grados alrededor del eje X o Y se convierte en un triángulo en sentido contrario a las manecillas del reloj. Debido a que teníamos inhabilitado CULL_FACE, podemos ver los triángulos en el sentido de las manecillas del reloj(frontal) y en sentido contrario a las manecillas del reloj(atrás). Ahora que la activamos, cada vez que un triángulo frontal gire, ya sea debido al escalamiento o la rotación, o por cualquier motivo, WebGL no lo dibujará. Eso es bueno, ya que, cuando das la vuelta en 3D, en general, querrás que los triángulos que estén frente a ti se consideren de frente.

¡Hola! ¿Dónde están todos los triángulos? Resulta que muchos de ellos están en la dirección equivocada. Al rotarla, los verás cuando mires al otro lado. Por suerte, es fácil de solucionar. Solo vemos cuáles están al revés e intercambian 2 de sus vértices. Por ejemplo, si un triángulo hacia atrás

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

solo giramos los últimos 2 vértices para avanzar.

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

Eso está más cerca, pero todavía hay un problema más. Incluso con todos los triángulos orientados en la dirección correcta y con los que están mirando hacia atrás, aún tenemos lugares donde los triángulos que deberían estar en la parte trasera se dibujan sobre triángulos que deberían estar al frente. Ingresa al BÚFER DEPTH. Un búfer de profundidad, a veces llamado Z-Buffer, es un rectángulo de depth píxeles, un píxel de profundidad para cada píxel de color que se usa en la creación de la imagen. A medida que WebGL dibuja cada píxel de color, también puede dibujar uno de profundidad. Lo hace en función de los valores que muestra el sombreador de vértices para Z. Así como tuvimos que convertir a espacio de recorte para X e Y, entonces a Z está en espacio de recorte o (-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á los píxeles de profundidad correspondiente. Si el valor de profundidad del píxel que está a punto de dibujar es mayor que el del píxel de profundidad correspondiente, WebGL no dibujará el nuevo píxel de color. De lo contrario, dibuja el píxel de color nuevo con el color de tu sombreador de fragmentos Y dibuja el píxel de profundidad con el nuevo valor de profundidad. Esto significa que no se dibujarán los píxeles que se encuentren detrás de otros. Podemos activar esta función con la misma facilidad con la que activamos la selección con

gl.enable(gl.DEPTH_TEST);

También debemos borrar 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 siguiente publicación, explicaré cómo hacer que tenga perspectiva.

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

Si son detallista, quizás hayan notado que definimos nuestros 2 atributos como

attribute vec4 a_position;
attribute vec4 a_color;

los dos son "vec4", pero cuando le indicamos a WebGL cómo quitar los datos de los búferes, usamos

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

Ese número 3 en cada uno de ellos indica que solo se extraigan 3 valores por atributo. Esto funciona porque, en el sombreador de vértices, WebGL proporciona valores predeterminados para aquellos que no proporcionas. Los valores predeterminados son 0, 0, 0, 1, donde x = 0, y = 0, z = 0 y w = 1. Es por eso que, en nuestro sombreador de vértices 2D anterior, tuvimos que proporcionar explícitamente el 1. Pasamos x e y, y necesitábamos un 1 para z, pero como el valor predeterminado para z es 0, tuvimos que proporcionar 1 de forma explícita. Sin embargo, para 3D, aunque no proporcionamos una “w”, el valor predeterminado es 1, que es lo que necesitamos para que funcionen las matemáticas de las matrices.