8. Interação e animação com WebGL

Em nossa última aula mostramos como gerar triângulos em um viewport, uma região definida dentro do canvas. Por default, o viewport ocupa toda a região do canvas.

Nessa aula vamos ver como usar triângulos para compor objetos mais complexos, além de interagir e animar esses objetos.

8.1. Desenhando círculos

Vimos na aula passada os tipos primitivos que o WebGL é capaz de tratar que, basicamente, se limitam a pontos, linhas e triângulos definidos em um buffer de vértices.

Para desenhar objetos mais complexos, como quadrados e círculos, precisamos definir uma forma de decompô-los usando essas primitivas. Um quadrado, por exemplo, pode ser desenhado usando dois triângulos. No caso de um círculo ou outras curvas complexas, a aproximação será tanto melhor quanto mais triângulos usarmos na sua composição.

O exemplo da última aula que desenha dois triângulos com cores diferentes usa chamadas diferentes de gl.drawArrays para cada triângulo. Isso é feito para que a CPU possa modificar o uniform uColor antes da GPU renderizar cada triângulo. Esse processo pode ser mais eficiente se conseguirmos fazer com o que a GPU desenhe todo o buffer de uma única vez. Uma forma de conseguir isso é usando um segundo buffer para armazenar definir as cores de cada vértice.

O exemplo a seguir mostra como podemos desenhar círculos no WebGL usando essa ideia de usar um buffer separado para as cores. O código fonte desse exemplo está disponível no JSitor. Clique na aba Browser para ver o resultado.

O fonte HTML é muito semelhante ao exemplo anterior e vamos continuar utilizando as bibliotecas macWebglUtils.js e MVnew.js. Clique agora na aba JavaScript para observar o código fonte dos shaders.

Vamos começar analisando o seguinte código fonte do vertex shader:

var gsVertexShaderSrc = `#version 300 es

// aPosition é um buffer de entrada
in vec2 aPosition;
uniform vec2 uResolution;
in vec4 aColor;  // buffer com a cor de cada vértice
out vec4 vColor; // varying -> passado ao fShader

void main() {
    vec2 escala1 = aPosition / uResolution;
    vec2 escala2 = escala1 * 2.0;
    vec2 clipSpace = escala2 - 1.0;

    gl_Position = vec4(clipSpace, 0, 1);
    vColor = aColor;
}
`;

Note que esse código contém um novo atributo aColor para receber as cores associadas a cada vértice e também um varying de nome vColor, uma variável utilizada para passar informação do vertex shader para o fragment shader. Assim, para vColor é um vec4 de saída (out) para o vertex shader e precisa ser entrada (in) de mesmo tipo e nome no fragment shader, como ilustrado no trecho abaixo. Dessa forma, o linker do WebGL pode associar essas variáveis para produzir o programa executável na GPU.

var gsFragmentShaderSrc = `#version 300 es

// Vc deve definir a precisão do FS.
// Use highp ("high precision") para desktops e mediump para mobiles.
precision highp float;

// out define a saída
in vec4 vColor;
out vec4 outColor;

void main() {
    outColor = vColor;
}
`;

8.1.1. Aproximando um círculo por triângulos

Vamos considerar inicialmente o problema de aproximar um circulo de raio raio centrado na origem. O número de vértices usados na aproximação é definido pelo parâmetro ref da função aproximeDisco().

function aproximeDisco(raio, ref = 4) {
    // primeiro um quadrado ao redor da origem
    let vertices = [
        vec2(raio, 0),
        vec2(0, raio),
        vec2(-raio, 0),
        vec2(0, -raio),
    ];

    // refinamento: adiciona 1 vértice em cada lado
    for (let i = 1; i < ref; i++) {
        let novo = [];  // array com vertices refinados
        let nv = vertices.length;
        for (let j = 0; j < nv; j++) {
            novo.push(vertices[j]);
            let k = (j + 1) % nv;  // array circular
            let v0 = vertices[j];
            let v1 = vertices[k];
            let m = mix(v0, v1, 0.5); // mix combina v0 e v1 na proporção 0.5

            let s = raio / length(m);
            m = mult(s, m)  // multiplica por escalar s
            novo.push(m);   // vertice no ponto médio
        }
        vertices = novo;
    }
    return vertices;
}

A primeira aproximação é feita usando um quadrado de diâmetro 2*raio, ou seja, usando 4 vértices. Para cada nível de refinamento ref, o número de vértices dobra, inserindo um novo vértice no ponto médio entre dois vértices previamente calculados. Observe que cada vértice é inicialmente criado como um elemento do tipo vec2. Esse tipo assim como operações com esse tipo estão definidas na biblioteca MVnew.js. Vamos usar essa biblioteca para realizar operações com vetores. Por exemplo, para dois vetores u e v a biblioteca nos permite fazer:

  • length(u) que retorna o comprimento de u.
  • negate(u) que retorna -u.
  • add(u, v) que retorna a soma u+v.
  • subtract(u, v) que retorna u-v.
  • mult(u, v) que retorna u*v, elemento a elemento.
  • dot(u, v) que retorna o produto escalar <u.v>.
  • cross(u, v) que retorna o produto vetorial u x v.
  • mix(u, v, a) que retorna a combinação a*u + (1-a)*V.

Essas operações facilitam o raciocínio geométrico e a manipulação desses dados vetoriais.

Os vértices ainda precisam ser desenhados ao redor do centro da circunferência. Nesse exercício vamos mover cada vértice explicitamente. Em aulas futuras vamos ver como aplicar transformações, como fizemos com o canvas. O seguinte trecho faz isso, inserindo os vértices transladados no buffer de posições.

function desenheDisco(centro, raio, cor, ref = 4) {
    let vertices = aproximeDisco(raio, ref);
    // adiciona disco no buffer de posicoes e cores
    let nv = vertices.length;
    for (let i = 0; i < nv; i++) {
        let k = (i + 1) % nv;
        gaPosicoes.push(centro);
        gaPosicoes.push(add(centro, vertices[i]));
        gaPosicoes.push(add(centro, vertices[k]));

        if (DISCO_UMA_COR) {
            gaCores.push(cor);
            gaCores.push(cor);
            gaCores.push(cor);
        }
        else {
            // sorteieCorRGBA na biblioteca macWeglUtils.js
            gaCores.push(sorteieCorRGBA());
            gaCores.push(sorteieCorRGBA());
            gaCores.push(sorteieCorRGBA());
        }
    }
}

Além do buffer de posições, esse trecho também monta o buffer de cores. Altere a constante DISCO_UMA_COR (e talvez DISCO_RES=1) para ver o que acontece quando cada vértice do triângulo recebe uma cor diferente. Nesse caso o WebGL interpola as cores, como ilustrado na Figura Fig. 8.1.

Efeito de um triângulo com múltiplas cores

Fig. 8.1 Efeito de interpolação entre as cores quando associamos uma cor distinta para cada vértice.

8.2. WebGL e Animação

Um problema na hora de criar animações usando WebGL é decidir qual e/ou quanta informação é necessário passar para a GPU para redesenhar cada quadro. Nesse exemplo, vamos mostrar que podemos modificar apenas o buffer de posições e manter o mesmo buffer de cores.

O código fonte desse exemplo está disponível no JSitor. Clique na aba Browser para ver o resultado da animação.

Como em aulas passadas, a animação é realizada pela chamada window.requestAnimationFrame() ao final da função de desenho desenhe(). No entanto, para gerar animações mais suaves, dessa vez usamos o relógio da máquina pelo comando Date.now(), que retorna o tempo em milisegundos, e definimos as velocidades usando uma unidade física, como pixels por segundo.

Para desenhar múltiplos discos, criamos a classe Disco, que ao criar um novo objeto do tipo Disco, basicamente preenche o buffer gPosicoes e o buffer gCores com os dados do novo disco. Observe que todos os discos desenhados estão nesses dois buffers.

function Disco(x, y, r, vx, vy, cor) {
    this.vertices = aproximeDisco(r, DISCO_RES);
    this.nv = this.vertices.length;
    this.vel = vec2(vx, vy);
    this.cor = cor;
    this.pos = vec2(x, y);

    // inicializa buffers
    let centro = this.pos;
    let nv = this.nv;
    let vert = this.vertices;
    for (let i = 0; i < nv; i++) {
        let k = (i + 1) % nv;
        gPosicoes.push(centro);
        gPosicoes.push(add(centro, vert[i])); // translada
        gPosicoes.push(add(centro, vert[k]));

        gCores.push(cor);
        gCores.push(cor);
        gCores.push(cor);
    }

A atualização de cada disco é realizada pelo método atualize() mostrada abaixo, que recebe um parâmetro delta com o intervalo de tempo transcorrido desde a última renderização. Note que o buffer gPosicoes precisar ser limpo antes de ser preenchido com as novas posições dos vértices.

this.atualize = function (delta) {
    this.pos = add(this.pos, mult(delta, this.vel));
    let x, y;
    let vx, vy;
    [x, y] = this.pos;
    [vx, vy] = this.vel;

    // bateu? Altere o trecho abaixo para considerar o raio!
    if (x < 0) { x = -x; vx = -vx; };
    if (y < 0) { y = -y; vy = -vy; };
    if (x >= gCanvas.width) { x = gCanvas.width; vx = -vx; };
    if (y >= gCanvas.height) { y = gCanvas.height; vy = -vy; };
    // console.log(x, y, vx, vy);
    let centro = this.pos = vec2(x, y);
    this.vel = vec2(vx, vy);

    let nv = this.nv;
    let vert = this.vertices;
    for (let i = 0; i < nv; i++) {
        let k = (i + 1) % nv;
        gPosicoes.push(centro);
        gPosicoes.push(add(centro, vert[i]));
        gPosicoes.push(add(centro, vert[k]));
    }
}

Como o buffer de posições é modificado, a função de desenho precisa atualizar o buffer usado pelo GPU da seguinte maneira:

function desenhe() {
    let now = Date.now();
    let delta = (now - gUltimoT) / 1000;
    gUltimoT = now;

    // desenha vertices
    gPosicoes = [];
    for (let i = 0; i < gObjetos.length; i++)
        gObjetos[i].atualize(delta);

    // atualiza o buffer de vertices
    gl.bindBuffer(gl.ARRAY_BUFFER, gShader.bufPosicoes);
    gl.bufferData(gl.ARRAY_BUFFER, flatten(gPosicoes), gl.STATIC_DRAW);

    gl.uniform2f(gShader.uResolution, gCanvas.width, gCanvas.height);

    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.drawArrays(gl.TRIANGLES, 0, gPosicoes.length);

    window.requestAnimationFrame(desenhe);
}

Observe que a estrutura da função desenhe é basicamente a mesma de animações anteriores, atualizando o estado de cada objeto antes de serem desenhados.

Organização do programa

Programas gráficos são uma excelente oportunidade para treinar suas habilidades de programação orientada a objetos. No entanto, nesse curso vamos usar apenas recursos simples do JavaScript para definir classes e objetos, como fizemos nesse exemplo, criando a classe Disco (usando function ao invés de class), reunindo os discos em gObjetos (um array) e reunindo as propriedades do shader no objeto gShader. Com isso evitamos criar uma variável global distinta para cada objeto e propriedade.

O uso de classes também simplifica o reuso de código, pois elas podem ser colocadas em bibliotecas e reutilizadas depois, poupando esforço de codificação, além de tornar o código mais sucinto e legível.

Observe no entanto que muitos exemplos que você encontra na literatura (como nas referências que usamos para preparar essas notas de aula) o mais comum é criar muitas variáveis globais mesmo, como gPosicoes e gCores. Fique a vontade para reorganizar o código de formas ainda mais simples e elegantes.

8.3. Mas e a interação com WebGL?

Para isso, reservamos o seguinte exercício:

Inclua um slider para controlar o nível de refinamento usado para desenhar os discos, permitindo que o parâmetro ref varie de 1 até 10.

8.4. Onde estamos e para onde vamos?

Essa aula encerra nossa introdução ao WebGL para criar desenhos 2D. Esses fundamentos, que incluem a compilação de shaders usando buffers, atributes, uniforms e varyings, são os mesmos usados para criar programas em 3D. Nossos exemplos ilustraram também o uso de shaders com os recursos de interação e animação que vimos em aulas passadas.

Antes de trabalhar com elementos e desenhos em 3D, nas próximas aulas vamos fazer uma revisão de geometria e álgebra linear para entender melhor outros fundamentos de programação geométrica.

8.5. Exercícios

  1. Inclua um botão Pause no programa de animação e outro botão Passo que deve simular a animação passo-a-passo. Quando o programa estiver pausado, cada clique no botão Passo faz os objetos avançarem 100 ms no tempo.
  2. Estenda o programa de animação de círculos para criar e desenhar também quadrados e/ou retângulos de tamanhos e cores aleatórias.

8.6. Para saber mais

Recomendamos a seguinte leitura: