7. Desenhando com o WebGL

Em aulas passadas vimos como usar alguns recursos básicos de interação e animação da API 2D do canvas do HTML5 com JavaScript. A partir dessa aula, vamos usar a API do WebGL 2.0 para gerar desenhos.

7.1. OpenGL, WebGL e GLSL

Na década de 1990, os computadores da Silicon Graphics (SGI) dominavam o mercado de computadores usados para a criação de gráficos em 3D, como ilustrado na cena do filme Jurassic Park, de 1993, abaixo. O modelo IRIS da SGI utilizava a API (Application Programming Interface ou interface de programação da aplicação) gráfica IRIS GL que veio a se tornar a primeira versão do OpenGL (Open Graphics Library) em 1992. Esse padrão evoluiu bastante desde então, introduzindo cada vez mais recursos, até a sua versão mais recente, a 4.6, lançada em 2017.

Cena "It's a Unix System" do filme Jurassic Park, de 1993. Preste atenção na marca do monitor.

O OpenGL é hoje a API padrão usada para o desenvolvimento de aplicativos gráficos tanto 2D quanto para 3D. Ela é portável para múltiplas plataformas e foi agregada a várias linguagens como C, Java e Python.

O WebGL surgiu da agregação do JavaScript com o OpenGL e se tornou um novo padrão para geração de gráficos interativos na Web. A primeira versão do WebGL foi derivada inicialmente do padrão OpenGL ES 2.0 mantido pelo grupo Khronos. O OpenGL ES (Embeded System) é uma versão do OpenGL voltada para sistemas gráficos embarcados como telefones celulares e consoles de jogos. A versão mais recente, WebGL 2.0 lançada em 2017, é derivada do padrão OpenGL ES 3.0 e já é compatível com os navegadores mais populares como Google Chrome, Mozilla Firefox e Opera.

Com a evolução do hardware, a API também evoluiu. A partir do padrão OpenGL 2.0, é possível programar o pipeline gráfico por meio de funções de sombreamento chamadas de shaders. Essas funções permitem a implementação de efeitos visuais sofisticados usando uma linguagem de sombreamento (shading language) baseada no OpenGL Shading Language (GLSL).

Algumas vantages de usar WebGL são:

  • Programação JavaScript - os aplicativos WebGL são escritos em JavaScript. Usando estes aplicativos, é possível interagir diretamente com outros elementos do documento HTML, inclusive outras bibliotecas JavaScript (como JQuery) e tecnologias HTML.
  • Suporte para navegadores em dispositivos móveis - o WebGL também oferece suporte a navegadores móveis, como iOS e Android.
  • Código aberto - o WebGL é um código aberto (Open Source). Você pode acessar o código-fonte da biblioteca e entender como funciona e como foi desenvolvido.
  • Não há necessidade de compilação - para executar JavaScript não há necessidade de compilar o arquivo, que pode ser aberto diretamente em qualquer navegador compatível com HTML5.
  • Gerenciamento automático de memória - o JavaScript oferece suporte ao gerenciamento automático de memória. Não há necessidade de alocação manual de memória. O WebGL herda esse recurso de JavaScript.
  • Fácil de configurar - como o WebGL é integrado ao HTML5, não há necessidade de configuração, basta um editor de texto e um navegador web.

7.2. Programação com shaders

A API do WebGL permite desenvolver shaders, os programas que vão ser executados dentro da GPU. Você deve se lembrar do pipeline gráfico, correto? Essa arquitetura permite a renderização de elementos geométricos simples, como pontos, linhas e polígonos, de uma forma muito eficiente. E é basicamente isso que o WebGL nos permite fazer, modelar nossas cenas usando esses elementos.

Um programa que usa o WebGL deve definir o código executado pela GPU na forma de duas funções costumeiramente chamadas de:

  • Vertex shader: que calcula as posições dos vértices como vistos pela câmera; e
  • Fragment shader: que calcula a cor final de cada pixel desenhado no frame buffer.

Assim como fizemos para desenhar no canvas, primeiramente definindo o estado da pena (cor, largura, forma da linha etc.) antes de mandar a pena desenhar uma linha, no WebGL devemos configurar também uma série de estados antes de desenhar.

Um programa usando WebGL deve compilar os shaders que são enviados a GPU. Para enviar os dados à GPU, podemos utilizar uma das seguintes quatro formas básicas:

  • Atributos e Buffers: buffers são arrays de dados que são carregados na GPU que podem conter posições, normais, cores etc. Os atributos definem como os dados devem ser extraídos de um buffer e passados ao shader, por exemplo, como 3 floats de 32 bits.
  • Uniforms: são variáveis globais configuradas antes de executar o shader.
  • Texturas: são imagens que podem ser usadas pelos shaders.
  • Varyings: são variáveis usadas para passar dados do vertex para o fragment shader.

7.3. Esqueleto de um programa usando WebGL

O seguinte exemplo apenas desenha um triângulo mas ilustra os principais blocos de um programa usando WebGL. O código fonte desse exemplo está disponível no JSitor.

Clique na aba HTML para se certificar que a página só contém um elemento canvas de id="glcanvas". Agora clique na aba JavaScript para ver o conteúdo da função main, como abaixo:

function main()
{
    gCanvas = document.getElementById("glcanvas");
    gl = gCanvas.getContext('webgl2');
    if (!gl) alert( "WebGL 2.0 isn't available" );

    crieShaders();
    desenhe();
}

Observe que, ao invés de usar getContext("2d"), dessa vez usando getContext("webgl2") para utilizar a API do WebGL 2.0. Vamos acessar essa API pela variável global gl.

Antes de descrever as funções crieShaders e desenhe(), vamos descrever um pouco mais o conteúdo dos shaders.

Nota sobre nossa notação

Nos exemplos a seguir utilizaremos a notação chamada de “camel case” para os nomes das variáveis globais e funções. As variáveis globais começaram pela letra “g” (minúscula) e, para facilitar também o entendimento de tipos simples, uma segunda letra minúscula pode indicar um tipo primitivo, como “s” para string e “a” para array. Dessa forma, um nome gaTriangulo indica uma variável global do tipo array.

7.3.1. Código do Vertex e Fragment Shaders

Nesse (e em futuros) exemplos, o código fonte do vertex e fragment shaders estão em duas Strings: gsVertexShaderSrc e gsFragmentShaderSrc:

var gsVertexShaderSrc = `#version 300 es

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

void main()
{
    // gl_Position é uma variável reservada que deve ser especificada
    gl_Position = vec4(aPosition, 0, 1);
}
`;

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
out vec4 fColor;

void main()
{
    // nesse caso é uma constante
    fColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;

O código de cada shader deve sempre conter, na primeira linha, a diretiva #version 300 es para indicar que o código está em WebGL 2.0. Nesse exemplo, o vertex shader deve receber da CPU um buffer aPosition do tipo vec2, indicando que cada dado desse buffer é constituído por um vetor de 2 elementos do tipo float com 32 bits. O shader deve possuir uma função main() que, nesse caso, transforma elementos do buffer para o tipo vec4 usados para desenhar em 3D. Um ponto em 3D no WebGL é representado pelas coordenadas (x, y, z, w), onde a coordenada w é chamada de coordenada homogênea. Veremos nas próximas aulas por que o WebGL utiliza 4 coordenadas para desenhar em 3D. Por hora, vamos considerar w=1.0 e, como estamos desenhando um triângulo em 2D, podemos assumir que os pontos estão no plano z=0.0 e o buffer aPosition recebe as coordenadas dos pontos no plano (x, y).

O código do fragment shader apenas define que cada pixel de saída é da cor vermelha com RGBA = (1.0, 0.0, 0.0, 1.0).

7.3.2. Definindo um triângulo em um sistema de coordenadas normalizado

É conveniente definir um triângulo em JavaScript por meio de seus vértices como em:

var gaTriangulo = [
    [-0.5, -0.5],
    [0.0, 0.5],
    [0.5, -0.5],
];

Observe que a variável global gaTriangulo é um array em JS contendo 3 arrays: [-0.5, -0.5], [0.0, 0.5] e [0.5, -0.5], que representando os vértices do triângulo.

O WebGL foi desenvolvido para renderizar objetos 3D mas também pode ser usado para 2D. Como o WebGL precisa ser independente do sistema gráfico, ele utiliza um sistema de coordenadas 3D normalizado representado por um cubo de cantos (-1.0, -1.0, -1.0) e (1.0, 1.0, 1.0). O volume interno desse cubo corresponde ao volume de recorte (clipping volume), ou seja, qualquer objeto fora desse volume não é renderizado. O que fazer então para renderizar objetos em 2D? Como já mencionamos, no caso 2D, vamos considerar o quadrado definido pelos cantos (-1.0, -1.0) e (1.0, 1.0), no plano z=0.0.

Observe que, como um buffer em GLSL não tem a mesma estrutura que um array do JS, precisamos converter o array em uma estrutura linear uniforme (como um vetor na linguagem C). Isso é feito pela função flatten() disponível no código do exemplo, que devolve um array 1D (vetor) com elementos do tipo float 32 bits.

7.3.3. Como desenhar um triângulo?

Assim como programas na linguagem C precisam ser compilados antes de serem executados, o programa gráfico também precisa ser compilado para ser executado pela GPU. A função makeProgram() faz a compilação do código fonte do vertex e fragment shaders, usando a própria API do WebGL. Não iremos entrar em detalhes sobre o funcionamento dessa função e, nos programas futuros, vamos mover a função makeProgram() e as demais funções auxiliares para uma biblioteca. Assim fica mais fácil para a gente se concentrar nos desenhos e nos fundamentos da CG. O programa compilado deve ainda ser habilitado pela chamada gl.useProgram();.

O programa está pronto mas e os dados? Precisamos agora indicar onde estão os dados para que a GPU possa executar o programa. O trecho de código abaixo cria um buffer na GPU que é associado aos dados vetorizados das posições (vértices), convertidos por flatten(gaTriangulo).

var bufPosicoes = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufPosicoes);
gl.bufferData(gl.ARRAY_BUFFER, flatten(gaTriangulo), gl.STATIC_DRAW);

Ainda não acabamos. A GPU agora tem um lugar para os dados (um buffer) que vem da CPU (nosso triângulo), mas ainda é necessário definir sua relação com o programa, ou seja, precisamos dizer que esse é o buffer associado à variável aPosition, que é do vertex shader, do tipo vec2 etc. (como definido no código do vertex shader). O trecho de código abaixo configura e habilita esse atributo.

var aPositionLoc = gl.getAttribLocation(program, "aPosition");
// Configuração do atributo para ler do buffer
// atual ARRAY_BUFFER
let size = 2;          // 2 elementos de cada vez
let type = gl.FLOAT;   // tipo de 1 elemento = float 32 bits
let normalize = false; // não normalize os dados
let stride = 0;        // passo, quanto avançar a cada iteração depois de size*sizeof(type)
let offset = 0;        // começo do buffer
gl.vertexAttribPointer(aPositionLoc, size, type, normalize, stride, offset);
gl.enableVertexAttribArray(aPositionLoc);

Com isso temos o programa executado na GPU compilado e configurado. O gaTriangulo é renderizado pela função desenhe(), mostrada abaixo:

function desenhe() {
    // define como mapear coordenadas normalidas para o canvas
    gl.viewport(0, 0, gCanvas.width, gCanvas.height);
    // limpa o contexto
    gl.clearColor(0.0, 1.0, 1.0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    // desenhe!
    let tipo = gl.LINE_LOOP; // experimente gl.TRIANGLES e outros
    let offset = 0;
    let count = triangulo.length;
    gl.drawArrays(tipo, offset, count);
}

A chamada gl.viewport(x, y, width, height) define a área do canvas onde é projetada a região normalizada de observação definida pelos cantos (-1, -1) a (1, 1), como mostra a Figura Fig. 7.1. O viewport define uma transformação que é parte do estado da API e assim pode ser modificada para exibir desenhos diferentes em um mesmo canvas, cada um em um viewport distinto.

O desenho é “limpo” pela chamada gl.clear(gl.COLOR_BUFFER_BIT) com a cor definida por gl.clearColor(). Observe que limpar o desenho pode significar ‘pintar’ o buffer de desenho com uma certa cor. Mas como o buffer pode armazenar várias informações distintas (como profundidade), a chamada de gl.clear() pode ser usada para limpar outras informações também. Nesse exemplo, como só estamos usando cor, apenas o bit de cor foi setado, mas poderíamos incluir também o gl.DEPTH_BUFFER_BIT e o gl.STENCIL_BUFFER_BIT.

Relação entre o canvas, viewport e a região normalizada de desenho.

Fig. 7.1 Relação entre o canvas, viewport e a região normalizada de desenho.

Dica: mude a cor de fundo durante a depuração do seu programa

Muitas vezes, usamos a cor branca para limpar o fundo. Durante a depuração, pode ser vantajoso alterar essa cor para algo bem visível como vermelho (1.0,0.0,0.0,1.0) ou alguma cor incomum no seu desenho. Essa cor também pode ser definida uma única vez durante a inicialização do programa, usando uma constante em JavaScript.

Toda essa preparação é necessária para que a GPU possa renderizar o objeto (nesse caso, um triângulo). Lembre-se que a GPU só é capaz de renderizar objetos primitivos a partir de um array de vértices. A gente ainda precisa escolher um tipo de desenho que define como o array vai ser desenhado. A Figura Fig. 7.2 mostra esses tipos para o WebGL. A chamada gl.drawArrays() recebe um desses tipos e desenha os vértices do array.

  • POINTS: cada vértice é desenhado como um ponto.
  • LINES: cada par de vértices é desenhado como uma linha.
  • LINE_STRIP: a sequência de vértices define um caminho (linha) aberta
  • LINE_LOOP: a sequência de vértices define um caminho fechado, o último vértice é conectado ao primeiro.
  • TRIANGLES: cada trio de vértices é desenhado como um triângulo (região interna).
  • TRIANGLE_STRIP: cada 3 vértices consecutivos da sequência definem um triângulo.
  • TRIANGLE_FAN: o primeiro vértice é considerado a referência. Cada par de vértices consecutivos é usado para definir um triângulo usando a referência. Assim, todos os triângulos tem o 1o vértice em comum.
Resultado da renderização de um array de vértices usando os tipos primitivos do WebGL.

Fig. 7.2 Resultado da renderização de um array de vértices usando os tipos primitivos do WebGL. fonte Cg Tutorial.

Exercício

Modifique o código para desenhar uma lista de vértices usando cada um dos tipos primitivos.

7.4. Um exemplo mais complexo

O código fonte desse exemplo está disponível em JSitor.

Clique na barra HTML e note que há dois scripts JS sendo carregados:

<head>
    <meta http-equiv="Content-Type" content="text/html;charset=utf-8">
    <title>WebGL - exemplo 2</title>
    <script src="https://www.ime.usp.br/~hitoshi/mac0420/libs/macWebglUtils.js"></script>
    <script src="https://www.ime.usp.br/~hitoshi/mac0420/libs/MVnew.js"></script>
    <script src="webgl2.js"></script>
</head>

A biblioteca macWebglUtils.js contém, basicamente, as funções para a compilação dos shaders. A segunda biblioteca, MVnew.js, é uma biblioteca utilizada pelos exemplos do livro Interactive Computer Graphics A Top-Down Approach with WebGL , de Edward Angel e Dave Shreiner. A função flatten() e outras para tratamento de arrays do JavaScript como vec2, vec3 ou vec4 serão utilizadas em futuros exemplos. Você pode baixar essas bibliotecas da pasta https://www.ime.usp.br/~hitoshi/mac0420/libs/.

Por fim, o arquivo webgl2.js, contém o programa exibido nesse exemplo.

Vimos que é vantajoso para o WebGL desenhar em uma região normalizada quadrada. No entanto, em geral, o canvas não é quadrado. Por isso, muitas vezes é mais conveniente desenhar usando coordenadas no canvas, em pixels, ao invés de coordenadas normalizadas no intervalo [-1, 1]. Nesse exemplo, observe que o array gaPositions define dois triângulos usando coordenadas em um canvas \(200\times200\).

var gaPositions = [
    // triangulo 1
    [ 50,  50],
    [150,  50],
    [100, 150],
    // triangulo 2
    [ 100,  150],
    [ 200,  150],
    [ 150,   50],
];

O vertex shader vai receber essas coordenadas e deve normalizá-las para o intervalo [-1, 1]. Vamos dar uma olhada no código fonte do nosso novo vertex shader:

gsVertexShaderSrc = `#version 300 es

// aPosition é um buffer de entrada
in vec2 aPosition;
uniform vec2 uResolution;  // recebe width x height

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

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

A sintaxe do GLSL se parece muito com C e permite a manipulação de dados 2D, 3D e 4D, definidos por vec2, vec3 e vec4, respectivamente. Nesse exemplo, tanto aPosition quanto uResolution são elementos do tipo vec2. Um uniform permite a passagem de informação da CPU para a GPU. Nesse caso, uResolution vai indicar a resolução do gCanvas. Vamos adotar a notação que nomes de atributos (associados a um buffer) começam com letra a e os uniforms começam com a letra u.

O GLSL permite operações vetoriais componente a componente. Assim, a operação aPosition / uResolution normaliza cada coordenada para o intervalo [0.0, 1.0]. Por exemplo, para um canvas com uResolution =(200,400), uma aPosition =(50, 300), a variável local escala1 definida na main() do vertex shader seria (50/200, 300/400) – divisão elemento a elemento – resultando no vec2 =(0.25, 0.75).

Em seguida, essas coordenadas são multiplicadas por 2.0 e subtraídas de 1.0, novamente, elemento a elemento. Por exemplo, o resultado anterior (0.25, 0.75)*2.0 resulta em (0.5, 1.5) que, quando subtraído de 1.0, resulta em (-0.5, 0.5).

Assim as coordenadas no buffer aPosition são mapeadas para o intervalo [-1.0, 1.0]. Finalmente o vec2 é convertido para um vec4 composto pelas coordenadas (x, y) normalizadas de aPosition com z=0 e w=1.0.

Observe também no código fonte do fragment shader que podemos usar um uniform para alterar a cor de um triângulo, como ilustrado no trecho a seguir pelo uniform uColor do tipo vec4:

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
out vec4 outColor;
uniform vec4 uColor;

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

A criação dos shaders é muito semelhante ao exemplo anterior, mas inclui chamadas extras de gl.getUniformLocation para registrar esses uniforms na estrutura global gShader que mantem as informações do programa na GPU.

A função de desenho pode então utilizar esses uniforms da seguinte forma:

function desenhe() {

    // define como mapear coordenadas normalidas para o canvas
    gl.viewport(0, 0, gCanvas.width, gCanvas.height);
    // limpa o contexto
    gl.clearColor(0.0, 1.0, 1.0, 1.0);
    gl.clear( gl.COLOR_BUFFER_BIT );

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

    // desenhe 2 triangulos, cada um com uma cor aleatória
    for (let ii=0; ii<2; ii++) {
        // Set a random color.
        gl.uniform4f(gShader.uColor, Math.random(), Math.random(), Math.random(), 1);

        gl.drawArrays(gl.TRIANGLES, 3*ii, 3);
    }
}

Primeiro, observe as chamadas para gl.uniformXf, onde X é o tamanho do dado a ser passado, ou seja, 2 floats para uResolution e 4 para uColor. Por meio dessas chamadas, a GPU recebe os valores a serem aplicados pelo programa gráfico.

Finalmente, cada triângulo é desenhado usando o tipo TRIANGLES com os vértices apropriados.

7.5. Onde estamos e para onde vamos?

A partir de agora vamos usar o WebGL para ilustrar nossos exemplos e desenvolver nossos aplicativos gráficos. No entanto, continuaremos a usar recursos básicos do WebGL para demonstrar fundamentos da computação gráfica e treinar sua habilidade de programação geométrica, tópico a ser coberto nas próximas aulas.

A programação de um shader pode é complexa, principalmente nesse início, devido ao grande número de elementos que precisam ser definidos como escrever o código fonte de cada shader, compilar, montar, e depois ainda configurar atributos, buffers, uniforms, etc. Na próxima aula vamos considerar mais alguns detalhes importantes para desenvolver programas gráficos com shaders, integrando-os com os elementos de interação e animação que vimos em aulas passadas.

7.6. Exercícios

  1. Escreva um programa que desenha um quadrado de lado aleatório em posição aleatória no canvas usando WebGL (e shaders).

    • Modifique esse programa para desenhar N=50 quadrados em posições aleatórias, com tamanho e cores também aleatórias.
  2. Escreva um programa que aproxima um disco de raio r centrado na origem usando incialmente 4 vértices. Em seguida, escreva um função que duplica o número de vértices usado para desenhar o disco, calculando a posição de vértices intermediários. Em seguida, inclua um slider HTML que permita controlar o número de vertices de 4 a 64. A cor do disco pode ser aleatória.

7.7. Para saber mais

Recomendamos a seguinte leitura: