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.
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:
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:
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:
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.
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).
É 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.
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
.
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.
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.
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.
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.
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.
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.
Recomendamos a seguinte leitura: