Em nossa introdução à programação com shaders usando WebGL desenhamos apenas formas básicas para introduzir os princípios de programação usando shaders. Para desenhar cenas mais complexas vamos fazer uso de transformações, como já começamos a explorar usando o canvas 2D do HTML5. Nessa aula, vamos continuar usando exemplos no espaço 2D para aplicação e composição de transformações afins na forma matricial, como introduzimos na aula anterior.
Você deve se lembrar que, para gerar desenhos de objetos, em geral, é conveniente desenhar usando coordenadas centradas no objeto, por exemplo, um cubo ou uma esfera com seus vértices ao redor da origem. A ideia é, assim como em várias outras áreas da computação, utilizar modelos disponíveis em bibliotecas contendo vários objetos complexos e utilizar esses objetos para compor cenas aplicando transformações adequadas a cada objeto. Assim, ao invés de “desenhar uma cena”, a geração de desenhos em computação gráfica se parece mais como um processo de composição de uma cena em 3D, como a montagem de um cenário fotográfico real antes de tirarmos uma foto. Isso permite também reutilizar um mesmo objeto e simplesmente “transformá-lo” para vários lugares da cena, como usar o mesmo modelo de automóvel em lugares diferentes em uma cena de trânsito, ou usar um modelo de árvore na composição de uma floresta ou parque.
As transformações lineares e afins são essenciais na computação gráfica. Lembre-se que uma transformação linear é um mapeamento em um espaço vetorial que preserva combinações lineares. Tais transformações incluem rotações, escalas, cisalhamento (que “estica” retângulos em paralelogramos) e suas combinações.
Da mesma forma, as transformações afins são transformações que preservam as combinações afins. Por exemplo, se \(P\) e \(Q\) são dois pontos e \(m\) é seu ponto médio, e \(\mathbf{T}\) é uma transformação afim, então o ponto médio de \(\mathbf{T}(p)\) e \(\mathbf{T}(q)\) é \(\mathbf{T}(m)\).
Uma propriedade importante das transformações afins é que elas preservam linhas retas e paralelismo, embora não preservem ângulos. Vimos na aula passada como cada transformação básica pode ser representada de forma matricial. Hoje vamos ver que como essas transformações básicas podem ser compostas por meio de multiplicação de matrizes para realizar transformações complexas.
Vamos começar vendo como realizar a animação de bolas transferindo o cálculo de translações para o vertex shader.
Nesse exemplo vamos modificar o código descrito na aula 8 - Interação e animação com WebGL que ilustrou a animação de bolas usando WebGL recalculando a posição dos vértices para gerar cada imagem. Por exemplo, o trecho de código
// desenha vertices
gPosicoes = [];
for (let i = 0; i < gObjetos.length; i++)
gObjetos[i].atualize(delta);
mostra que o buffer gPosicoes
é limpo para que as novas posições sejam recalculadas e recarregadas no buffer durante a atualização de cada objeto. Depois de recalcular os vértices na CPU, esse novo array precisa ser enviado a GPU para serem desenhados.
Uma alternativa seria, caso a gente possa representar os vértices de uma forma normalizada, por exemplo, centrados ao redor da origem, então podemos manter o buffer de vértices na GPU e, ao desenhar, pedir para o vertex shader transladar o centro para a posição desejada. A CPU então apenas precisa atualizar a nova posição de cada bola, e passar esse único ponto para a GPU redesenhar toda a bola usando os mesmos vértices normalizados!
Vamos fazer isso introduzindo o uniforme uTranslation
no código do vertex shader, como no trecho a seguir:
var gVertexShaderSrc = `#version 300 es
// aPosition é um buffer de entrada
in vec2 aPosition;
uniform vec2 uResolution;
uniform vec2 uTranslation;
in vec4 aColor; // buffer com a cor de cada vértice
out vec4 vColor; // varying -> passado ao fShader
void main() {
vec2 escala1 = (aPosition + uTranslation) / uResolution;
vec2 escala2 = escala1 * 2.0;
vec2 clipSpace = escala2 - 1.0;
gl_Position = vec4(clipSpace, 0, 1);
vColor = aColor;
}
`;
Observe que a GPU calcula a translação modificando a posição de cada vértice na função main
pelo comando (aPosition + uTranslation)
.
Isso poupa um pouco do trabalho da CPU.
Com isso, a função de atualização dos vértices agora só precisa atualizar o centro do disco definido em this.pos
e sua velocidade em this.vel
, como abaixo:
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);
this.pos = vec2(x, y);
this.vel = vec2(vx, vy);
}
Finalmente, a função de desenho precisa ser modificada para que, ao invés de atualizar os vértices, apenas modifique o uniforme uTranslate
com a translação de cada objeto, como:
function desenhe() {
// calcula a diferença de tempo da última atualização
let now = Date.now();
let delta = (now - gUltimoT) / 1000; // velocidade em segundos
gUltimoT = now;
// limpa o canvas
gl.clear(gl.COLOR_BUFFER_BIT);
// desenha posicoes, mas mantem os vértices
for (let i = 0; i < gObjetos.length; i++) {
let obj = gObjetos[i];
obj.atualize(delta);
gl.uniform2f(gShader.uTranslation, obj.pos[0], obj.pos[1]);
gl.drawArrays(gl.TRIANGLES, 3*obj.nv*i, 3*obj.nv);
}
window.requestAnimationFrame(desenhe);
}
O código completo desse exemplo, disponível abaixo, pode ser visto no JSitor:
Vamos dar mais um passo para transferir mais trabalho para a GPU e simplificar o trabalho da CPU.
A função aproximeDisco()
usada anteriormente desenha um disco de raio arbitrário (qualquer tamanho).
Considere agora que essa função é capaz de aproximar um disco de raio unitário apenas, ao redor da origem, como abaixo:
function aproximeDisco(ref=4) {
let raio = 1.0;
// 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 = [];
let nv = vertices.length;
for (let j = 0; j<nv; j++) {
novo.push(vertices[j]);
let k = (j+1)%nv;
let v0 = vertices[j];
let v1 = vertices[k];
let m = mix(v0, v1, 0.5);
let s = raio/length(m);
m = mult(s, m)
novo.push( m );
}
vertices = novo;
}
return vertices;
};
Nesse caso, podemos utilizar uma transformação de escala para esticar o disco até o tamanho desejado. Inclusive podemos agora esticar o disco de forma não uniforme.
Antes de continuar, copie o código do exemplo anterior, fazendo um fork no JSitor e, utilizando essa nova função aproximeDisco()
, modifique o código restante para fazer cada disco ser renderizado usando transformações de escala realizadas pelo vertex shader.
Pausa para pensar …
Antes de prosseguir sua leitura, procure imaginar como seria o novo vertex shader. Em particular, como aplicar a transformação de escala ao buffer aPosition
.
Para incluir uma transformação de escala não uniforme no vertex shader podemos utilizar mais um uniforme do tipo vec2, uScale
, como:
var gVertexShaderSrc = `#version 300 es
// aPosition é um buffer de entrada
in vec2 aPosition;
uniform vec2 uResolution;
uniform vec2 uTranslation;
uniform vec2 uScale;
in vec4 aColor; // buffer com a cor de cada vértice
out vec4 vColor; // varying -> passado ao fShader
void main() {
vec2 sPos = aPosition * uScale;
vec2 escala1 = (sPos + uTranslation) / uResolution;
vec2 escala2 = escala1 * 2.0;
vec2 clipSpace = escala2 - 1.0;
gl_Position = vec4(clipSpace, 0, 1);
vColor = aColor;
}
`;
Observe que a transformação de escala deve ser aplicada antes da translação.
Após registrar o novo uniforme na criação dos shaders, além de arrumar alguns outros detalhes nas chamadas dessas funções, a nova função de desenho precisa passar a escala:
function desenhe() {
// atualiza o relógio
let now = Date.now();
let delta = (now - gUltimoT) / 1000;
gUltimoT = now;
// limpa
gl.clear(gl.COLOR_BUFFER_BIT);
// atualiza uniformes sem modificar os vértices
for (let i = 0; i < gObjetos.length; i++) {
let obj = gObjetos[i];
obj.atualize(delta);
gl.uniform2f(gShader.uTranslation, obj.pos[0], obj.pos[1]);
gl.uniform2f(gShader.uScale, obj.raio * obj.sx, obj.raio * obj.sy);
gl.drawArrays(gl.TRIANGLES, obj.nv * i * 3, 3 * obj.nv);
}
window.requestAnimationFrame(desenhe);
}
Observe que nesse trecho de código, o fator de escala aplicado é proporcional ao raio, mas também uma escala (sx, sy) independente em cada eixo que permite distorcer o disco como um ovo ou elipse. O código completo desse exemplo pode ser acessado no JSitor.
Desejamos agora simular um efeito de rotação do disco, introduzindo uma velocidade de rotação vrz
e um ângulo theta
que corresponde à orientação do disco.
Vamos modificar o vertex shader para receber um uniform float uTheta
que define um ângulo de rotação do disco.
Como vimos na aula anterior, para aplicar uma rotação por uTheta
ao redor da origem sobre um ponto (x, y), podemos aplicar a seguinte formula:
\(novo_x = x * cos( uTheta ) + y * sin( uTheta )\)
\(novo_y = y * cos( uTheta ) - x * sin( uTheta )\)
Novamente, faça um fork do código do exemplo anterior para incluir rotação na nossa animação dos discos.
Outra pausa para pensar …
Antes de prosseguir sua leitura, procure imaginar como seria o novo vertex shader. Em particular, como aplicar a transformação de rotação a cada vértice do buffer aPosition
.
O seguinte trecho de código define nosso vertex shader que recebe essas transformações de translação, escala e rotação e as aplica sobre cada vértice em aPosition
:
var gVertexShaderSrc = `#version 300 es
// aPosition é um buffer de entrada
in vec2 aPosition;
uniform vec2 uResolution;
uniform vec2 uTranslation;
uniform vec2 uScale;
uniform float uTheta;
in vec4 aColor; // buffer com a cor de cada vértice
out vec4 vColor; // varying -> passado ao fShader
void main() {
float s = sin(uTheta);
float c = cos(uTheta);
vec2 sPos = aPosition * uScale;
vec2 rPos = vec2(
sPos.x * c + sPos.y * s,
sPos.y * c - sPos.x * s );
vec2 escala1 = (rPos + uTranslation) / uResolution;
vec2 escala2 = escala1 * 2.0;
vec2 clipSpace = escala2 - 1.0;
gl_Position = vec4(clipSpace, 0, 1);
vColor = aColor;
}
`;
Dessa vez, o vertex shader recebe um uniform float uTheta
e usa suas próprias funções sin()
e cos()
para calcular a rotação de cada vértice. Observe nesse trecho de código que a rotação está sendo aplicada após o escalonamento e antes da translação. Procure imaginar o que acontece com o desenhe se essa ordem fosse diferente. Por exemplo, o que aconteceria se a rotação fosse aplicada antes do escalonamento? Experimente!!
O restante do código é semelhante ao anterior. A velocidade angular precisa ser definida e armazenada em cada disco, além de ser atualizada também na geração de cada novo desenho. Observe que escolhemos mudar a direção de rotação toda vez que o disco bate em uma das paredes do canvas. O código completo desse exemplo é mostrado abaixo e pode também ser acessado no JSitor.
Vimos na aula anterior que essas transformações básicas de translação, rotação, escala etc., podem ser representadas de forma matricial de forma muito mais compacta e elegante que a apresentada nos exemplos anteriores. Tipicamente, vamos utilizar matrizes de transformação para realizar as seguintes operações:
A maneira de aplicação dessas transformações no WebGL é similar ao modelo de pen-plotter, onde os atributos do desenho (com cor e grossura da pena) precisam ser definidos antes do comando para desenhar. Assim as transformações também precisam ser definidas antes e, ao desenhar, o atual estado de transformações é aplicado automaticamente a todos os objetos desenhados. Nos exemplos anteriores, isso foi realizado atribuindo valores aos uniformes uTranslation
, uTheta
e uScale
antes de desenhar cada disco.
Como as transformações são usadas para diferentes propósitos, programas gráficos usando o WebGL costumam trabalhar com três conjuntos de matrizes para organizar os seguintes tipos de transformação:
Como já vimos, há ainda mais uma transformação que não é tratada por essas matrizes. Esta é a transformação que mapeia o espaço normalizado para uma região do canvas, definido por gl.viewport()
, onde gl
é o contexto gráfico webgl2
do canvas do HTML5.
É essencial entender como trabalhar e manipular essas matrizes de transformação para entender como os programas e sistemas gráficos que trabalham no modo imediato funcionam.
Como último exemplo vamos agora juntar todas as transformações na forma de uma única matriz.
Observe no exemplo com rotação que o cálculo do seno e cosseno é feito para cada vértice. Isso pode ser computacionalmente custoso, principalmente para objetos complexos com um grande número de vértices. Para balancear melhor essa carga computacional, pode ser conveniente passar apenas uma matriz de transformação para o vertex shader que combine todas as transformações.
Para facilitar o processamento dessas matrizes utilizaremos a biblioteca MVnew.js
, proveniente do livro Interactive Computer Graphics
, de Angel e Shneier, que também oferece operações com vetores.
Vamos também começar a usar pontos e vetores em 3D com coordenadas homogêneas. Assim, cada ponto ou vetor é do tipo vec4
. Para trabalhar no plano xy
, vamos considerar a coordenada z=0
. Isso implica também que as nossas matrizes de transformação serão do tipo mat4
, com \(4\times4\) elementos. Essa forma também é mais próxima a representação padrão do GLSL.
O trecho de código abaixo ilustra o vertex shader usando uma única matrix de transformação, passada à GPU pelo uniform mat4 uMatrix
.
gVertexShaderSrc = `#version 300 es
// aPosition é um buffer de entrada
in vec2 aPosition;
uniform mat4 uMatrix;
in vec4 aColor; // buffer com a cor de cada vértice
out vec4 vColor; // varying -> passado ao fShader
void main() {
gl_Position = vec4( uMatrix * vec4(aPosition,0,1) );
vColor = aColor;
}
`;
Observe que o GLSL possui o tipo mat4
(e mat3
) que facilita a manipulação de matrizes. O uniforme uMatrix
é uma matriz que combina a matriz de projeção e a matriz modelView. A matriz de projeção mapeia pontos no canvas para o sistema de coordenadas normalizadas e pode ser definida como:
let w = gCanvas.width;
let h = gCanvas.height;
let projection = mat4(
2/w, 0, 0, -1,
0, -2/h, 0, 1,
0, 0, 1, 0,
0, 0, 0, 1
);
Observe que projection
corresponde a representação matricial da transformação que mapeia um ponto no canvas para um ponto no espaço normalizado de cantos [-1,-1] e [+1,+1]. Por exemplo, calcule o resultado da multiplicação dessa matriz pelos pontos:
vec4(w/2, h/2, 0, 1)
, no centro do canvas;vec4(0, 0, 0, 1)
, no canto superior esquerdo do canvas; evec4(w, h, 0, 1)
, no canto inferior direito do canvas.Para fazer a multiplicação, considere cada ponto como um vetor coluna.
Representação em ordem de colunas
Na matemática e na computação, estamos acostumados a representar matrizes ordenadas por linhas, ou seja, na matriz [ [1,2], [3,4] ] o elemento [1,2] corresponde a 1a linha. O OpenGL (e o WegGL) adota uma representação de matrizes ordenadas por colunas. Nesse caso, o elemento [1, 2], corresponde à primeira coluna.
Usando a biblioteca MVnew.js
, ao criar um mat4
como nesse exemplo, passamos os elementos na ordem que estamos acostumados (por linhas) e a própria biblioteca transforma para a representação do WebGL.
Resumindo, ao trabalhar com elementos do tipo vec
e mat
da biblioteca, você não precisa se preocupar com essa ordem. Mas se você utilizar arrays do JavaScript, então deve tomar cuidado!
O seguinte trecho calcula a matriz de transformação modelView:
var modelView = translate( obj.pos[0], obj.pos[1], 0 );
modelView = mult(modelView, rotateZ(obj.theta));
modelView = mult(modelView, scale(obj.sx*obj.raio, obj.sy*obj.raio, 1));
// combina projection e modelveiw
var uMatrix = mult(projection, modelView);
Assim como no exercício que fizemos usando transformações no canvas, a ordem para compor as transformações na matriz parece invertida. Mas por se tratar de multiplicação de matrizes, observe no código do vertex shader que
\(gl_Position = uMatrix * aPosition\)
Podemos decompor uMatrix
como: \(projection * T * R * S * Position\), onde T
, R
e S
correspondem as matrizes de translação, rotação e escala, respectivamente. Assim, a primeira transformação aplicada a um vértice é a de escala, seguida por rotação, translação e projeção, que é a ordem mais comum para pensar nesse problema.
O código completo desse exemplo, disponível abaixo, pode ser visto no JSitor:
Um objeto VAO (Vertex Array Object) é um recurso oferecido pelo WebGL 2.0 que facilita a organização dos buffers e seus atributos usados para desenhar.
Uma aplicação típica de VAOs é quando temos objetos muitos distintos ou que, por algum motivo, escolhemos desenhar separadamente, usando um outro conjunto de buffers.
Para dar um exemplo de aplicação de VAOs, vamos estender o exemplo anterior incluindo também quadrados em nossa animação.
Um quadrado de lado unitário centrado na origem do sistema de coordenadas normalizado padrão do WebGL pode ser desenhado nos arrays gPosicoesQuads
e gCoresQuads
como ilustrado no trecho abaixo:
function desenheQuad(cor) {
let vt = [ // quadrado de lado 1
vec2( 0.5, 0.5),
vec2(-0.5, 0.5),
vec2(-0.5, -0.5),
vec2( 0.5, -0.5)
];
let i, j, k;
[i, j, k] = [0,1,2]
gPosicoesQuads.push( vt[i] );
gPosicoesQuads.push( vt[j] );
gPosicoesQuads.push( vt[k] );
[i, j, k] = [0,2,3]
gPosicoesQuads.push( vt[i] );
gPosicoesQuads.push( vt[j] );
gPosicoesQuads.push( vt[k] );
for (let i=0; i<6; i++)
gCoresQuads.push(cor);
}
Um objeto Quad
pode ser representado como:
function Quad (x, y, vx, vy, vrz, sx, sy, cor) {
desenheQuad(cor);
this.pos = vec2(x, y); // posição
this.vel = vec2(vx, vy); // velocidade
this.theta = 0; // orientação
this.vrz = vrz; // velocidade de rotação
this.sx = sx; // escala em x
this.sy = sy; // escala em y
}
Apesar de podermos usar os mesmos shaders do exemplo de animação com discos, desejamos criar buffers de posições e cores distintos para armazenar os quadrados, para desenhar discos e quadrados usando os mesmos shaders.
Uma solução é criar VAOs para cada conjunto de buffers, como ilustrado no trecho a seguir.
Observe que a parte de código para criação dos buffers para armazenar as cores e posições dos discos é muito semelhante ao código usado para armazenar as informações dos quadrados. A novidade desse trecho é o uso de VAOs, que são criados usando gl.createVertexArray()
e habilitados por gl.bindVertexArray()
. Uma vez habilitado, os buffers e atributos criados em seguida são associados a esse VAO.
Observe que, ao final do uso de um VAO, é considerado uma boa prática “limpar” o VAO fazendo gl.bindVertexArray(null)
.
O uso de VAO facilita muito o desenho. Agora que os buffers foram criados e carregados, a função de desenho dos discos apenas precisa chamar gl.bindVertexArray(gShader.discosVAO)
antes de desenhar, assim como a função de desenho dos quadrados só precisa chamar gl.bindVertexArray(gShader.quadsVAO)
.
Por exemplo, podemos usar a seguinte função de desenho
function desenheQuads(delta, projection) {
// atualiza e desenha quads
gl.bindVertexArray(gShader.quadsVAO);
for (let i=0; i<gQuads.length; i++) {
let obj = gQuads[i];
atualize(obj, delta);
// Calcula a matriz modelView
var modelView = translate( obj.pos[0], obj.pos[1], 0 );
modelView = mult(modelView, rotateZ(obj.theta));
modelView = mult(modelView, scale(obj.sx, obj.sy, 1));
// combina projection e modelveiw
var uMatrix = mult(projection, modelView);
// carrega na GPU
gl.uniformMatrix4fv(gShader.uMatrix, false, flatten(uMatrix));
// desenhe
gl.drawArrays(gl.TRIANGLES, 6*i, 6);
}
}
e outra semelhante chamada desenheDiscos()
para desenhar os discos. A função para desenhar discos e quadrados com animação seria
function desenhe() {
// atualiza o relógio
let now = Date.now();
let delta = (now - gUltimoT)/1000;
gUltimoT = now;
// limpa o canvas
gl.clear( gl.COLOR_BUFFER_BIT );
// cria a matriz de projeção - pode ser feita uma única vez
let w = gCanvas.width;
let h = gCanvas.height;
let projection = mat4(
2/w, 0, 0, -1,
0, -2/h, 0, 1,
0, 0, 1, 0,
0, 0, 0, 1
);
desenheQuads(delta, projection);
desenheDiscos(delta, projection);
window.requestAnimationFrame(desenhe);
}
que é basicamente a mesma usado no exemplo de animação com apenas discos.
O código completo desse exemplo, disponível abaixo, pode ser visto no JSitor.
Nessa aula vimos como podemos usar transformações para simplificar o processo de criar desenhos e animações usando WebGL. Aplicando as noções de geometria e álgebra linear que vimos nas últimas aulas vimos que essas transformações podem ser representadas de forma compacta e elegante por meio de matrizes.
Na próxima aula, vamos continuar nossa jornada para aumentar o realismo de nossos desenhos introduzindo conceitos de geometria projetiva para tratar o problema de visualização de objetos em 3D.
No distante planeta de Omicron Persei 8 (OP8), o viewport é definido com origem no canto superior direito. O eixo x aponta para baixo e o eixo y aponta para a esquerda. Seja \(w\) e \(h\) a largura e altura como mostrado na figura abaixo. Considere a região retangular do desenho, em coordenadas normalizadas, com lados esquerdo e direito definidos por \(x_{min}\) e \(x_{max}\) e os lados inferior e superior definidos por \(y_{min}\) e \(y_{max}\).
\(v_x\) = alguma função de \(p_x\) e/ou \(p_y\).
\(v_y\) = alguma função de \(p_x\) e/ou \(p_y\).
mostre os passos da sua solução, não apenas a fórmula final.
Considere os dois sistemas de coordenadas da figura abaixo.
Vimos que o WebGL interpola linearmente as cores dos vértices para pintar o interior de um triângulo (conhecido como Gouraud shading). Considere o triângulo da figura abaixo, com vértices em (0,0), (1,0) e (1,1). Seja \(C_0\), \(C_1\) e \(C_2\) as cores RGB correspondentes para cada um desses vértices. Derive uma função \(C(x, y)\) que interpola as cores linearmente e que possa ser usada para pintar um ponto \(Q = (x, y)\) no interior do triângulo. Você pode expressar sua solução como uma fórmula ou na forma de pseudo-código. A cor deve ser uma função de x, y, e as 3 cores \(C_0\), \(C_1\) e \(C_2\). No caso de usar fórmulas, mostre o seu trabalho intermediário para chegar na solução.
4. Considere o código da animação ilustrada nesse exercício no JSitor. Ao invés de discos, considere agora o seguinte triângulo em coordenadas normalizadas [(0.5, 0), (-0.5,-0.5), (-0.5, 0.5)].
Esse triângulo corresponde a uma forma que pode ser orientada na direção do movimento. Digamos que, em sua orientação inicial, definida pelos vértices, o bico do triângulo (vértice em (0.5, 0)) “aponta” para a direita. Escreva um programa que anime esse triângulo com uma velocidade constante aleatória (vx, vy), e que essa velocidade é refletida quando o objeto bate em uma parede do canvas. O triângulo deve ser rotacionado para que seu “bico” sempre aponte na direção do movimento. Use um fator de escala como (0.3, 0.2) para diminuir o tamanho do triângulo na animação.