WebGL ortográfico 3D

Gregg Tavares
Gregg Tavares

WebGL ortogonal 3D

Esta postagem é uma continuação de uma série de postagens sobre o WebGL. A primeira começou com os fundamentos e a anterior foi sobre matrizes 2D sobre matrizes 2D. Se você ainda não leu, leia primeiro. No post anterior, explicamos como as matrizes 2D funcionam. Falamos sobre como a translação, rotação, escala e até a projeção de pixels para o espaço de clipe podem ser feitas com uma matriz e algumas matemáticas mágicas. Para fazer 3D, é só dar um pequeno passo a mais. Nos exemplos anteriores em 2D, tínhamos pontos 2D (x, y) que multiplicamos por uma matriz 3x3. Para fazer isso, precisamos de pontos 3D (x, y, z) e uma matriz 4x4. Vamos usar nosso último exemplo e mudar para 3D. Vamos usar um F novamente, mas desta vez um "F" em 3D. A primeira coisa que precisamos fazer é mudar o sombreador de vértice para processar 3D. Aqui está o sombreador antigo.

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

E aqui está a nova

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

Ficou ainda mais simples! Em seguida, precisamos fornecer dados em 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);
}

Em seguida, precisamos mudar todas as funções de matriz de 2D para 3D. Estas são as versões 2D (anteriores) de makeTranslation, makeRotation e 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
];
}

Confira as versões 3D atualizadas.

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

Agora temos três funções de rotação. Só precisamos de um em 2D, porque estávamos girando apenas em torno do eixo Z. Agora, para fazer 3D, também queremos poder girar em torno do eixo x e do eixo y. Você pode notar que eles são muito semelhantes. Se nós os resolvêssemos, você os simplificaria, assim como antes.

Rotação em Z

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

Rotação Y


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

Rotação X

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

Também precisamos atualizar a função de projeção. Aqui está o antigo

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 converteu de pixels para espaço de clipe. Para nossa primeira tentativa de expansão para 3D, vamos tentar

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

Assim como precisamos converter de pixels para clipspace para x e y, para z, precisamos fazer a mesma coisa. Neste caso, também estou criando as unidades de pixel do espaço Z. Vou transmitir um valor semelhante a width para a profundidade, para que nosso espaço tenha de 0 a largura de pixels, de 0 a altura de pixels, mas para a profundidade, será de -profundidade / 2 a +profundidade / 2. Por fim, precisamos atualizar o código que calcula a 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);

O primeiro problema é que nossa geometria é uma F plana, o que dificulta a visualização em 3D. Para corrigir isso, vamos expandir a geometria para 3D. Nosso F atual é composto por três retângulos e dois triângulos cada. Para torná-lo 3D, é necessário um total de 16 retângulos. Há muitas coisas para listar aqui. 16 retângulos x 2 triângulos por retângulo x 3 vértices por triângulo é igual a 96 vértices. Se você quiser ver todos eles, confira a origem no exemplo. Precisamos desenhar mais vértices para

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

Ao mover os controles deslizantes, é muito difícil dizer que o modelo é 3D. Vamos tentar colorir cada retângulo com uma cor diferente. Para fazer isso, vamos adicionar outro atributo ao nosso sombreador de vértice e uma variação para transmiti-lo do sombreador de vértice ao sombreador de fragmentos. Confira o novo sombreador de vértice

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

E precisamos usar essa cor no 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>

Precisamos procurar o local para fornecer as cores e, em seguida, configurar outro buffer e atributo para atribuir as cores.

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

O que é essa bagunça? Bem, todas as várias partes do 'F' 3D, frente, verso, laterais etc. são desenhadas na ordem em que aparecem na nossa geometria. Isso não nos dá os resultados desejados, porque às vezes os objetos de trás são desenhados depois dos de frente. Os triângulos no WebGL têm o conceito de frente e verso. Um triângulo frontal tem vértices que vão no sentido horário. Um triângulo voltado para trás tem vértices que vão no sentido anti-horário.

Enrolamento triangular.

O WebGL tem a capacidade de desenhar apenas triângulos voltados para frente ou para trás. Podemos ativar esse recurso com

gl.enable(gl.CULL_FACE);

que fazemos apenas uma vez, no início do programa. Com esse recurso ativado, o WebGL "elimina" os triângulos voltados para trás. "Eliminação", neste caso, é uma palavra chique para "não desenhar". No que diz respeito ao WebGL, se um triângulo é considerado como horário ou anti-horário depende dos vértices desse triângulo no espaço de clipagem. Em outras palavras, o WebGL descobre se um triângulo está na frente ou atrás DEPOIS de você aplicar matemática aos vértices no sombreador de vértice. Isso significa que, por exemplo, um triângulo no sentido horário que é dimensionado em X por -1 se torna um triângulo no sentido anti-horário ou um triângulo no sentido horário girado 180 graus em torno do eixo X ou Y se torna um triângulo no sentido anti-horário. Como o CULL_FACE estava desativado, podemos ver triângulos no sentido horário(frente) e anti-horário(traseira). Agora que ativamos essa opção, sempre que um triângulo frontal for invertido devido à escala ou rotação ou por qualquer outro motivo, o WebGL não vai renderizá-lo. Isso é bom, porque, ao girar algo em 3D, você geralmente quer que os triângulos que estão de frente para você sejam considerados frontal.

Oi! Para onde foram todos os triângulos? Acontece que muitos deles estão enfrentando a direção errada. Gire-o e você vai ver os objetos aparecerem quando olhar para o outro lado. Felizmente, a solução é fácil. Basta verificar quais estão invertidas e trocar dois dos vértices. Por exemplo, se um triângulo para trás for

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

basta inverter os dois últimos vértices para avançar.

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

Isso está mais próximo, mas ainda há outro problema. Mesmo com todos os triângulos voltados para a direção correta e com os que estão de costas sendo eliminados, ainda temos lugares em que triângulos que deveriam estar na parte de trás estão sendo desenhados sobre triângulos que deveriam estar na frente. Insira o DEPTH BUFFER. Um buffer de profundidade, às vezes chamado de Z-buffer, é um retângulo de pixels depth, um pixel de profundidade para cada pixel de cor usado para criar a imagem. Como o WebGL desenha cada pixel de cor, ele também pode desenhar um pixel de profundidade. Isso é feito com base nos valores retornados do shader de vértice para Z. Assim como tivemos que converter para o espaço do clipe para X e Y, o Z está no espaço do clipe ou (-1 a +1). Esse valor é convertido em um valor de espaço de profundidade (0 a +1). Antes de desenhar um pixel de cor, o WebGL verifica o pixel de profundidade correspondente. Se o valor de profundidade do pixel que está prestes a ser renderizado for maior do que o valor do pixel de profundidade correspondente, o WebGL não renderizará o novo pixel de cor. Caso contrário, ele renderiza o novo pixel de cor com a cor do sombreador de fragmentos E renderiza o pixel de profundidade com o novo valor de profundidade. Isso significa que os pixels que estão atrás de outros pixels não são renderizados. Podemos ativar esse recurso quase tão simples quanto a eliminação com

gl.enable(gl.DEPTH_TEST);

Também precisamos limpar o buffer de profundidade de volta para 1,0 antes de começar a desenhar.

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

No próximo post, vou explicar como dar perspectiva a ele.

Por que o atributo é vec4, mas o tamanho de gl.vertexAttribPointer é 3?

Para quem é detalhista, você pode ter notado que definimos nossos dois atributos como

attribute vec4 a_position;
attribute vec4 a_color;

Ambos são "vec4", mas quando dizemos ao WebGL como extrair dados dos nossos buffers, usamos

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

O "3" em cada um deles indica que você só pode extrair três valores por atributo. Isso funciona porque, no sombreador de vértice, o WebGL fornece padrões para aqueles que você não fornece. Os padrões são 0, 0, 0, 1, em que x = 0, y = 0, z = 0 e w = 1. Por isso, no nosso antigo sombreador de vértice 2D, precisávamos fornecer explicitamente o 1. Estávamos transmitindo x e y e precisávamos de um 1 para z, mas, como o padrão para z é 0, tivemos que fornecer um 1 explicitamente. No entanto, para 3D, mesmo que não forneçamos um "w", ele será definido como 1 por padrão, o que é necessário para que a matemática da matriz funcione.