15. Desenhando em 3D com o WebGL

Agora vamos aplicar os fundamentos vistos na aula anterior para gerar vistas tridimensionais de uma cena.

Vamos começar desenhando formas 3D sem perspectiva e inserir a matriz de projeção mais tarde.

15.1. Desenho de um cubo, ainda sem perspectiva

Considere os vértices de um cubo de lado unitário centrado no volume de visualização canônico usado no WebGL, definido pelo trecho de código abaixo.

// posições dos 8 vértices de um cubo de lado 1
// centrado na origem
var gaPosicoes = [
    (-0.5, -0.5,  0.5),
    (-0.5,  0.5,  0.5),
    ( 0.5,  0.5,  0.5),
    ( 0.5, -0.5,  0.5),
    (-0.5, -0.5, -0.5),
    (-0.5,  0.5, -0.5),
    ( 0.5,  0.5, -0.5),
    ( 0.5, -0.5, -0.5)
];
// cores associadas a cada vértice
var gaCores = [
    vec4(0.0, 0.0, 0.0, 1.0),  // black
    vec4(1.0, 0.0, 0.0, 1.0),  // red
    vec4(1.0, 1.0, 0.0, 1.0),  // yellow
    vec4(0.0, 1.0, 0.0, 1.0),  // green
    vec4(0.0, 0.0, 1.0, 1.0),  // blue
    vec4(1.0, 0.0, 1.0, 1.0),  // magenta
    vec4(1.0, 1.0, 1.0, 1.0),  // white
    vec4(0.0, 1.0, 1.0, 1.0)   // cyan
];

Os vértices no array gaPosicoes poderiam ser do tipo vec4 mas, como sabemos que a coordenada homogênea é sempre igual a 1, essa última coordenada é inserida diretamente pelo vertex shader.

Nos exercícios anteriores, o array de posições dos vértices era preenchido com triângulos, ou seja, cada 3 indices consecutivos desse array correspondia a um triângulo. Nesse exemplo vamos mostrar um jeito diferente, vamos utilizar um array de índices que definem os triângulos usando os vértices únicos, sem repetições, de gaPosicoes, como no trecho:

// indices para cada um dos 12 triângulos, 2 por face, que definem o cubo.
var gaIndices = [
    1, 0, 3,
    3, 2, 1,
    2, 3, 7,
    7, 6, 2,
    3, 0, 4,
    4, 7, 3,
    6, 5, 1,
    1, 2, 6,
    4, 5, 6,
    6, 7, 4,
    5, 4, 0,
    0, 1, 5
];

Observe que cada linha do array gaIndices contém três inteiros onde cada inteiro corresponde a um índice do array gaPosicoes (e também de gaCores). Assim, cada linha do array define portanto um triângulo, no total de 12 triângulos (2 para cada face do cubo). Vamos usar a função gl.drawElements() do WebGL para desenhar usando uma lista de índices de vértices (elementos do array gaPosicoes), ao invés de desenhar diretamente a partir da lista de vértices.

A função para desenho render() usando gl.drawElements() pode ser simplesmente:

function render() {
    gl.clear( gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    gl.drawElements(gl.TRIANGLES, gCtx.numV, gl.UNSIGNED_BYTE, 0);
}

A vantagem desse método é que podemos reutilizar os vértices, resultando portanto em uma notação mais compacta e menos redundante. A função recebe os parâmetros gl.drawElements(mode, count, type, offsest) onde:

  • mode: define o modo de desenho, como gL.POINTS, gl.LINES, gl.TRIANGLES, etc., semelhante aos modos usados pelo gl.drawArrays().
  • count: número de índices a serem usados. No caso do cubo, temos 6 faces, com 2 triângulos por face e 3 índices por triângulo, ou seja, count = 6 * 2 * 3 = 36, que no caso corresponde ao comprimento do array gaIndices.
  • type: descreve o tipo usado no array gaIndices. No exemplo usamos gl.UNSIGNED_BYTE, que usa apenas 8 bits. Outros tipos possíveis poderiam ser gl.UNSIGNED_SHORT (16 bits) e gl.UNSIGNED_INT (32 bits).
  • offset: Deslocamento para desconsiderar elementos no início do array gaIndices.

Limpando o buffer de profundidade

Em 2D cada pixel tinha, basicamente, uma cor que precisava ser “limpa” usando o comando gl.clear(gl.COLOR_BUFFER_BIT).

Agora que vamos trabalhar em 3D, além de limpar o buffer de cor, precisamos limpar também o buffer de profundidade. Lembre-se que, na última aula, vimos como criar uma matriz de transformação perspectiva com profundidade. Essa profundidade fica armazenada no buffer de profundidade do WebGL e é usada para resolver oclusões, ou seja, apenas o pixel mais perto da câmera é pintado. Cada buffer é representado por um bit no contexto do WebGL e, para limpar esses dois buffers basta combinar esse bits usando um operador | (ou lógico) pelo comando

gl.clear( gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

15.2. Desenho de um cubo em 3D: 1a tentativa

Como exercício, antes de continuar sua leitura, tente criar um programa gráfico (com shaders etc) que utilize os dados fornecidos acima:

  • gaPosicoes,
  • gaCores,
  • gaIndices, e
  • gl.drawElements() para ver o resultado da função render() que também foi fornecida.

Você deve usar o seguinte vertex shader:

gVertexShaderSrc = `#version 300 es

// aPosition é um buffer de entrada
in vec3 aPosition;

in vec4 aColor;  // buffer com a cor de cada vértice
out vec4 vColor; // varying -> passado ao fShader

void main() {
    gl_Position = vec4(aPosition, 1);
    vColor = aColor;
}
`;

O código desse exemplo pode ser adaptado do exemplo completo de animação do cubo fornecido mais adiante. Se você conseguiu completar as lacunas desse programa, deve obter algo parecido com o ilustrado na Figura Fig. 15.1. Observe nessa figura que, como o vertex shader usa diretamente as posições dos vértices, sem alterar a posição default da câmera, nossa vista só permite ver uma das faces do cubo, como se nosso desenho fosse em 2D. A face sendo exibida é a mais “perto” da câmera, ou seja, no plano \(z=-0.5\), com as cores blue, magenta, white e cyan.

Cubo em 3D  -- primeira tentativa.

Fig. 15.1 Renderização de um cubo em 3D - primeira tentativa.

15.3. 2a tentativa: usando a função lookAt()

Se o problema está em mudar a câmera de lugar, vamos aplicar a função lookAt() (do módulo MVnew.js) para colocar a câmera em \(eye=(0.75, 0.75, 0.75)\), olhando para \(at=(0, 0, 0)\) e \(up=(0, 1, 0)\). Para usar essa matriz vamos modificar o vertex shader para incluir um uniforme de nome uModelView:

gVertexShaderSrc = `#version 300 es

// aPosition é um buffer de entrada
in vec3 aPosition;
uniform mat4 uModelView;

in vec4 aColor;  // buffer com a cor de cada vértice
out vec4 vColor; // varying -> passado ao fShader

void main() {
    gl_Position = uModelView * vec4(aPosition, 1);
    vColor = aColor;
}
`;

A nossa nova função de desenho deve calcular e passar essa matriz ao shader:

function render() {
    gl.clear( gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    // calcula a matriz de transformação da camera
    let eye = (0.75, 0.75, 0.75);
    let at  = (0, 0, 0);
    let up  = (0, 1, 0);
    gCtx.vista = lookAt( eye, at, up);
    gl.uniformMatrix4fv(gShader.uModelView, false, flatten(gCtx.vista));

    // desenha
    gl.drawElements(gl.TRIANGLES, gCtx.numV, gl.UNSIGNED_BYTE, 0);
}

O resultado de mover a câmera para \(eye=(0.75, 0.75, 0.75)\) é ilustrado na Figura Fig. 15.2 a.

Mas por que a figura não mostra todo o cubo?

Lembre-se que lookAt() apenas move a câmera, como se estivesse movendo o volume canônico de visualização. Nessa nova posição, apenas um “bico” do cubo fica contido dentro do novo volume de visualização (o resto fica para fora do volume e portanto é recortado). Vamos mover então a câmera para um pouco mais perto, digamos, para \(eye=(0.5, 0.5, 0.5)\). O resultado é ilustrado na Figura Fig. 15.2 b.

Esse resultado lhe parece estranho? Você consegue ver o que está acontecendo?

Cubo em 3D  -- segunda tentativa.

Fig. 15.2 Segunda tentativa para renderizar um cubo usando a função lookAt(eye, at, up). a) mostra o resultado com \(eye=(0.75, 0.75. 0.75)\); b) mostra o resultado com \(eye=(0.5, 0.5, 0.5)\) e c) mostra o resultado também em \(eye=(0.5, 0.5, 0.5)\) mas usando gl.cullBack(gl.BACK) para eliminar as faces que não estão de frente para a câmera.

Apesar do desenho mostrar uma área maior do cubo, ele mostra partes do cubo que deveriam estar escondidas devido à oclusão das faces do cubo, pois o WebGL está desenhando os dois lados de cada triângulo (frente e verso). Para casos assim, podemos fazer o WebGL “esconder” as faces internas (vamos considerar como verso) e só desenhar as faces externas (vamos considerar como faces da frente) usando a função gl.cullFace(gl.BACK) que desconsidera (não pinta) o verso das faces.

Mas como eu defino a frente e o verso de uma face, ou triângulo?

Para variar, vamos adotar a regra da mão direita, como ilustrado na Figura Fig. 15.3, onde o triângulo definido pelos vértices na ordem 1-2-3 tem normal para fora do cubo (“frente”). Ao desenhar, quando o gl.cullFace(gl.BACK) estiver habilitado, o WebGL só vai desenhar o lado da “frente”, testando se a normal dos triângulos apontam para a câmera. Observe que esse pode ser um jeito eficiente de eliminar “metade” das faces a serem desenhadas. Como exercício, verifique a ordem dos índices de cada triângulo em gaIndices e verifique que eles seguem essa regra.

Normal externa

Fig. 15.3 O triangulo formado pelos vertices 1-2-3 tem sua normal apontando para “fora” do cubo, que vamos definir como lado da “frente”. Para definir a “frente” triângulo como o lado oposto, utilize a ordem 3-2-1.

A seguinte função render() mostra como usar o gl.cullFace(). Observe que é possível também esconder objetos fazendo gl.cullFace(gl.FRONT_AND_BACK) ou ainda, desenhar apenas a parte interna usando gl.cullFace(gl.FRONT). O resultado com gl.cullFace é exibido na Figura Fig. 15.2 c.

function render() {
    gl.clear( gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    gl.enable(gl.CULL_FACE);
    gl.cullFace(gl.BACK);

    // calcula a matriz de transformação da camera
    let eye = (0.75, 0.75, 0.75);
    let at  = (0, 0, 0);
    let up  = (0, 1, 0);
    gCtx.vista = lookAt( eye, at, up);
    gl.uniformMatrix4fv(gShader.uModelView, false, flatten(gCtx.vista));

    // desenha
    gl.drawElements(gl.TRIANGLES, gCtx.numV, gl.UNSIGNED_BYTE, 0);
}

Além da limitação do volume de visualização e da necessidade de usar cullFace() pois não estamos usando informação de profundidade, nosso desenho também não apresenta distorções perspectiva. Para isso, vamos fazer uma nova tentativa de renderização, agora usando também uma matriz de projeção.

15.4. 3a tentativa: usando a função perspective()

Vamos dar um pouco mais de trabalho para a GPU, modificando novamente o vertex shader para que receba, além da uModelView, a matriz uPerspective, como a seguir:

gVertexShaderSrc = `#version 300 es

// aPosition é um buffer de entrada
in vec3 aPosition;
uniform mat4 uModelView;
uniform mat4 uPerspective;

in vec4 aColor;  // buffer com a cor de cada vértice
out vec4 vColor; // varying -> passado ao fShader

void main() {
    gl_Position = uPerspective * uModelView * vec4(aPosition, 1);
    vColor = aColor;
}
`;

A nova função render() deve agora calcular a perspectiva com profundidade. Vamos aproveitar e afastar um pouco mais a câmera, para \(eye=(1.75, 1.75, 1.75)\). Nesse caso, a nova render() poderia ser:

function render() {
    gl.clear( gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    // gl.enable(gl.CULL_FACE);
    // gl.cullFace(gl.BACK);

    // calcula a matriz de transformação perpectiva (fovy, aspect, near, far)
    gCtx.perspectiva = perspective( 60, 1, 0.1, 5);
    gl.uniformMatrix4fv(gShader.uPerspective, false, flatten(gCtx.perspectiva));

    // calcula a matriz de transformação da camera
    let eye = (1.75, 1.75, 1.75);
    let at  = (0, 0, 0);
    let up  = (0, 1, 0);
    gCtx.vista = lookAt( eye, at, up);
    gl.uniformMatrix4fv(gShader.uModelView, false, flatten(gCtx.vista));

    // desenha
    gl.drawElements(gl.TRIANGLES, gCtx.numV, gl.UNSIGNED_BYTE, 0);
}

O código completo desse exemplo está disponível no JSitor e também pode ser visto logo abaixo. Modifique os valores para o campo de visão vertical fovy e a razão de aspecto para ver os seus efeitos sobre o resultado. Tanto o plano near quanto o far podem ser modificados desde que o intervalo contenha os objetos de interesse e depende também da posição da câmera (eye).

15.5. Animação do cubo em perspectiva

Os exemplos anteriores serviram para ilustrar, passo-a-passo, o efeito da função lookAt(), que define a posição e orientação da câmera e da função perspective(), que desenha as distorções devido à projeção perspectiva.

Vamos agora animar o cubo, fazendo-o rodar ao longo de um dos eixos. Vamos fazer isso por meio de transformações no espaço afim, aplicando transformações de rotação sobre o cubo centrado na origem.

O código desse exemplo está disponível no JSitor e também pode ser visto mais abaixo. Para gerar a animação, a seguinte função render() atualiza a transformação de rotação aplicada a apenas um dos eixos. Observe que apenas a matriz model é atualizada nessa função. Nessa nova versão do programa, as demais matrizes, view e perspective, são calculadas e passadas para a GPU apenas uma única vez, quando os shaders são criados.

function render() {
    gl.clear( gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    // modelo muda a cada frame da animação
    if(!gCtx.pause) gCtx.theta[gCtx.axis] += 2.0;

    let rx = rotateX(gCtx.theta[EIXO_X]);
    let ry = rotateY(gCtx.theta[EIXO_Y]);
    let rz = rotateZ(gCtx.theta[EIXO_Z]);
    let model = mult(rz, mult(ry, rx));

    gl.uniformMatrix4fv(gShader.uModelView, false, flatten(mult(gCtx.vista, model)));
    gl.drawElements(gl.TRIANGLES, gCtx.numV, gl.UNSIGNED_BYTE, 0);

    window.requestAnimationFrame(render);
}

A escolha do eixo é feita clicando em um dos botões da interface. Estude esse exemplo para entender como funciona a Pausa desse exemplo.

15.6. Desenho de uma esfera

Assim como no caso 2D, o desenho de objetos mais complexos, como por exemplo objetos compostos por superfícies não planares como uma esfera, é um pouco mais trabalhoso também. Lembre-se que, para desenhar um disco em 2D, ao invés de calcular cada vértice, usamos uma forma recursiva que refina o contorno do disco a cada iteração. Assim, podemos começar com apenas 4 vértices, que formam um quadrado, e refinar nosso modelo de disco incluindo um novo vértice no meio de cada lado, até atingir a qualidade desejada.

Vamos aplicar essa ideia para criar o modelo de uma esfera de raio unitário, centrada na origem. Sabemos que a esfera unitária cruza cada eixo na coordenada 1 e -1. Assim, podemos facilmente construir a forma mais primitiva da esfera com os seguintes 8 triângulos definidos a partir desses 6 pontos de cruzamento:

let vp = [  // eixos positivos
    (1.0, 0.0, 0.0),
    (0.0, 1.0, 0.0),
    (0.0, 0.0, 1.0),
];

let vn = [ // eixos negativos
    (-1.0, 0.0, 0.0),
    ( 0.0,-1.0, 0.0),
    ( 0.0, 0.0,-1.0),
];

let triangulo = [
    [vp[0], vp[1], vp[2]], // define um triângulo
    [vp[0], vp[1], vn[2]],

    [vp[0], vn[1], vp[2]],
    [vp[0], vn[1], vn[2]],

    [vn[0], vp[1], vp[2]],
    [vn[0], vp[1], vn[2]],

    [vn[0], vn[1], vp[2]],
    [vn[0], vn[1], vn[2]],
];

Observe que esses triângulos são equiláteros. Para refinar esse modelo, podemos dividir cada triângulo em 4 outros triângulos equiláteros, definidos usando o ponto médio de cada lado da seguinte maneira:

function dividaTriangulo(a, b, c, ndivs) {
    // Cada nível quebra um triângulo em 4 subtriângulos
    // a, b, c em ordem mão direita
    //    c
    // a  b

    if (ndivs > 0) {
        let ab = mix( a, b, 0.5);
        let bc = mix( b, c, 0.5);
        let ca = mix( c, a, 0.5);

        ab = normalize(ab);
        bc = normalize(bc);
        ca = normalize(ca);

        dividaTriangulo( a, ab, ca, ndivs - 1);
        dividaTriangulo( b, bc, ab, ndivs - 1);
        dividaTriangulo( c, ca, bc, ndivs - 1);
        dividaTriangulo(ab, bc, ca, ndivs - 1);
    }

    else {
        insiraTriangulo(a, b, c);
    }
};

Nesse caso, como a esfera tem raio unitário, quando um “ponto” médio é normalizado, na verdade estamos normalizando o vetor que conecta o ponto a origem. O vetor normalizado, somado à origem, resulta em um novo ponto sobre a superfície da esfera de raio unitário. Observer que o processo continua recursivamente até atingir o nível desejado e, ao terminar, o triângulo é inserido na lista de vértices. Dessa vez, estamos colocando triângulos no array de vértices para usar a função gl.drawArrays().

O código completo desse exemplo está disponível no JSitor e, basicamente, utiliza a mesma estrutura que usamos para desenhar o cubo em perspectiva.

15.7. Onde estamos e para onde vamos?

Nessa aula aplicamos os fundamentos teóricos apresentados nas aulas anteriores para posicionar a câmera na cena usando a função lookAt() e aplicar uma projeção perspectiva com profundidade usando a função perspective().

Aproveitamos a extensão de 2D para 3D para mostrar também outra forma de desenhar no WebGL, usando a função g.drawElements() e apresentar o recurso gl.cullFace que pode ser usado para desenhar apenas os triângulos voltados para a câmera.

Por fim, damos alguns exemplos de animação e desenho de objetos com superfícies mais complexas, como uma esfera.

Na próxima aula, vamos continuar nossa jornada para aumentar o realismo de nossos desenhos introduzindo efeitos de iluminação e sombreamento.

15.8. Para saber mais