Tradução 2D do WebGL
Antes de passar para o 3D, vamos continuar com o 2D por mais um tempo. Tenha paciência comigo, por favor. Este artigo pode parecer óbvio para alguns, mas vou chegar a um ponto em alguns artigos.
Este artigo é uma continuação de uma série que começa com Fundamentos do WebGL. Se você ainda não leu, sugiro que leia pelo menos o primeiro capítulo e depois volte aqui. Translação é um nome matemático sofisticado que basicamente significa "mover" algo. Acho que mover uma frase do inglês para o japonês também se encaixa, mas, neste caso, estamos falando de mover geometria. Usando o exemplo de código que criamos na primeira postagem, você pode facilmente traduzir nosso retângulo apenas mudando os valores transmitidos para setRectangle, certo? Confira um exemplo com base no exemplo anterior.
// First lets make some variables
// to hold the translation of the rectangle
var translation = [0, 0];
// then let's make a function to
// re-draw everything. We can call this
// function after we update the translation.
// Draw the scene.
function drawScene() {
// Clear the canvas.
gl.clear(gl.COLOR_BUFFER_BIT);
// Setup a rectangle
setRectangle(gl, translation[0], translation[1], width, height);
// Draw the rectangle.
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
Até aqui, tudo bem. Mas imagine que queremos fazer o mesmo com uma forma mais complicada. Digamos que queremos desenhar um "F" composto por seis triângulos como este.
Bem, no código atual, teríamos que mudar setRectangle para algo mais parecido com isto.
// Fill the buffer with the values that define a letter 'F'.
function setGeometry(gl, x, y) {
var width = 100;
var height = 150;
var thickness = 30;
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([
// left column
x, y,
x + thickness, y,
x, y + height,
x, y + height,
x + thickness, y,
x + thickness, y + height,
// top rung
x + thickness, y,
x + width, y,
x + thickness, y + thickness,
x + thickness, y + thickness,
x + width, y,
x + width, y + thickness,
// middle rung
x + thickness, y + thickness * 2,
x + width * 2 / 3, y + thickness * 2,
x + thickness, y + thickness * 3,
x + thickness, y + thickness * 3,
x + width * 2 / 3, y + thickness * 2,
x + width * 2 / 3, y + thickness * 3]),
gl.STATIC_DRAW);
}
Como você pode ver, isso não vai funcionar bem. Se quisermos desenhar uma geometria muito complexa com centenas ou milhares de linhas, teremos que escrever um código bastante complexo. Além disso, sempre que desenhamos, o JavaScript precisa atualizar todos os pontos. Há uma maneira mais simples. Basta fazer upload da geometria e realizar a translação no sombreador. Este é o novo sombreador
<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform vec2 u_translation;
void main() {
// Add in the translation.
vec2 position = a_position + u_translation;
// convert the rectangle from pixels to 0.0 to 1.0
vec2 zeroToOne = position / u_resolution;
...
e vamos reestruturar um pouco o código. Por um lado, só precisamos definir a geometria uma vez.
// Fill the buffer with the values that define a letter 'F'.
function setGeometry(gl) {
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([
// left column
0, 0,
30, 0,
0, 150,
0, 150,
30, 0,
30, 150,
// top rung
30, 0,
100, 0,
30, 30,
30, 30,
100, 0,
100, 30,
// middle rung
30, 60,
67, 60,
30, 90,
30, 90,
67, 60,
67, 90]),
gl.STATIC_DRAW);
}
Em seguida, precisamos atualizar u_translation antes de desenhar com a translação que queremos.
...
var translationLocation = gl.getUniformLocation(
program, "u_translation");
...
// Set Geometry.
setGeometry(gl);
..
// Draw scene.
function drawScene() {
// Clear the canvas.
gl.clear(gl.COLOR_BUFFER_BIT);
// Set the translation.
gl.uniform2fv(translationLocation, translation);
// Draw the rectangle.
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
Observe que setGeometry é chamado apenas uma vez. Ela não está mais dentro de drawScene.
Agora, quando desenhamos, o WebGL faz praticamente tudo. Estamos apenas definindo uma translação e pedindo para desenhar. Mesmo que nossa geometria tivesse dezenas de milhares de pontos, o código principal permaneceria o mesmo.
Rotação 2D do WebGL
Vou admitir logo de cara que não sei se minha explicação vai fazer sentido, mas não custa tentar.
Primeiro, quero apresentar o que é chamado de "círculo unitário". Se você se lembra da matemática do ensino médio (não durma!), um círculo tem um raio. O raio de um círculo é a distância do centro até a borda. Um círculo unitário é um círculo com um raio de 1,0.
Se você se lembra da matemática básica do ensino fundamental, se multiplicar algo por 1, o valor não muda. Portanto, 123 * 1 = 123. Bem básico, né? Um círculo unitário, com raio de 1,0, também é uma forma de 1. É um 1 rotativo. Então, você pode multiplicar algo por esse círculo unitário e, de certa forma, é como multiplicar por 1, exceto que a mágica acontece e as coisas giram. Vamos pegar esse valor X e Y de qualquer ponto no círculo unitário e multiplicar nossa geometria por eles, usando nosso exemplo anterior. Estas são as atualizações do nosso sombreador.
<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;
void main() {
// Rotate the position
vec2 rotatedPosition = vec2(
a_position.x * u_rotation.y + a_position.y * u_rotation.x,
a_position.y * u_rotation.y - a_position.x * u_rotation.x);
// Add in the translation.
vec2 position = rotatedPosition + u_translation;
E atualizamos o JavaScript para que possamos transmitir esses dois valores.
...
var rotationLocation = gl.getUniformLocation(program, "u_rotation");
...
var rotation = [0, 1];
..
// Draw the scene.
function drawScene() {
// Clear the canvas.
gl.clear(gl.COLOR_BUFFER_BIT);
// Set the translation.
gl.uniform2fv(translationLocation, translation);
// Set the rotation.
gl.uniform2fv(rotationLocation, rotation);
// Draw the rectangle.
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
Por que isso funciona? Bem, vamos fazer as contas.
rotatedX = a_position.x * u_rotation.y + a_position.y * u_rotation.x;
rotatedY = a_position.y * u_rotation.y - a_position.x * u_rotation.x;
Digamos que você tenha um retângulo e queira girá-lo. Antes de começar a girar, o canto superior direito está em 3.0, 9.0. Vamos escolher um ponto no círculo unitário a 30 graus no sentido horário a partir das 12 horas.
A posição no círculo é 0,50 e 0,87.
3.0 * 0.87 + 9.0 * 0.50 = 7.1
9.0 * 0.87 - 3.0 * 0.50 = 6.3
É exatamente onde precisamos que esteja.
O mesmo para 60 graus no sentido horário
A posição no círculo é 0,87 e 0,50.
3.0 * 0.50 + 9.0 * 0.87 = 9.3
9.0 * 0.50 - 3.0 * 0.87 = 1.9
Ao girar esse ponto no sentido horário para a direita, o valor de X aumenta e o de Y diminui. Se continuarmos além de 90 graus, X começará a diminuir novamente e Y começará a aumentar. Esse padrão nos dá rotação. Há outro nome para os pontos em um círculo unitário. Eles são chamados de seno e cosseno. Então, para qualquer ângulo, podemos procurar o seno e o cosseno assim.
function printSineAndCosineForAnAngle(angleInDegrees) {
var angleInRadians = angleInDegrees * Math.PI / 180;
var s = Math.sin(angleInRadians);
var c = Math.cos(angleInRadians);
console.log("s = " + s + " c = " + c);
}
Se você copiar e colar o código no Console JavaScript e digitar printSineAndCosignForAngle(30), vai aparecer s = 0.49 c= 0.87 (observação: arredondei os números).
Se você juntar tudo isso, poderá girar a geometria em qualquer ângulo que quiser. Basta definir a rotação como o seno e o cosseno do ângulo que você quer girar.
...
var angleInRadians = angleInDegrees * Math.PI / 180;
rotation[0] = Math.sin(angleInRadians);
rotation[1] = Math.cos(angleInRadians);
Espero que isso tenha feito sentido. Agora, um mais simples. Escale.
O que são radianos?
Radianos são uma unidade de medida usada com círculos, rotação e ângulos. Assim como podemos medir a distância em polegadas, jardas, metros etc., podemos medir ângulos em graus ou radianos.
Você provavelmente sabe que fazer cálculos com medidas métricas é mais fácil do que com medidas imperiais. Para converter polegadas em pés, dividimos por 12. Para converter polegadas em jardas, dividimos por 36. Não sei vocês, mas eu não consigo dividir por 36 de cabeça. Com o sistema métrico, é muito mais fácil. Para converter milímetros em centímetros, dividimos por 10. Para converter milímetros em metros, dividimos por 1.000. Eu consigo dividir por 1.000 de cabeça.
Radianos e graus são semelhantes. Os graus dificultam os cálculos. Os radianos facilitam os cálculos. Há 360 graus em um círculo, mas há apenas 2π radianos. Portanto, uma volta completa é 2π radianos. Meia volta é π radianos. Um quarto de volta, ou seja, 90 graus, é π/2 radianos. Se você quiser girar algo em 90 graus, use Math.PI * 0.5. Se quiser girar 45 graus, use Math.PI * 0.25 etc.
Quase toda a matemática que envolve ângulos, círculos ou rotação funciona de maneira muito simples se você começar a pensar em radianos. Faça um teste. Use radianos, não graus, exceto em telas de interface.
Escala 2D do WebGL
O escalonamento é tão fácil quanto a translação.
Multiplicamos a posição pela escala desejada. Confira as mudanças em relação ao exemplo anterior.
<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;
uniform vec2 u_scale;
void main() {
// Scale the positon
vec2 scaledPosition = a_position * u_scale;
// Rotate the position
vec2 rotatedPosition = vec2(
scaledPosition.x * u_rotation.y +
scaledPosition.y * u_rotation.x,
scaledPosition.y * u_rotation.y -
scaledPosition.x * u_rotation.x);
// Add in the translation.
vec2 position = rotatedPosition + u_translation;
e adicionamos o JavaScript necessário para definir a escala ao desenhar.
...
var scaleLocation = gl.getUniformLocation(program, "u_scale");
...
var scale = [1, 1];
...
// Draw the scene.
function drawScene() {
// Clear the canvas.
gl.clear(gl.COLOR_BUFFER_BIT);
// Set the translation.
gl.uniform2fv(translationLocation, translation);
// Set the rotation.
gl.uniform2fv(rotationLocation, rotation);
// Set the scale.
gl.uniform2fv(scaleLocation, scale);
// Draw the rectangle.
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
Observe que o dimensionamento por um valor negativo inverte nossa geometria. Espero que os últimos três capítulos tenham ajudado você a entender a translação, a rotação e a escala. Em seguida, vamos falar sobre a mágica das matrizes que combinam esses três elementos em uma forma muito mais simples e, muitas vezes, mais útil.
Por que um "F"?
A primeira vez que vi alguém usar um "F" foi em uma textura. O "F" em si não é importante. O importante é que você consiga identificar a orientação de qualquer direção. Se usássemos um coração ♥ ou um triângulo △, por exemplo, não seria possível saber se ele foi invertido horizontalmente. Um círculo ○ seria ainda pior. Um retângulo colorido poderia funcionar com cores diferentes em cada canto, mas você teria que se lembrar de qual canto era qual. A orientação de um F é reconhecida instantaneamente.
Qualquer forma que permita identificar a orientação funciona. Eu uso o "F" desde que fui apresentado à ideia pela primeira vez.
Matrizes 2D do WebGL
Nos últimos três capítulos, mostramos como transladar, girar e dimensionar geometrias. Translação, rotação e escala são consideradas tipos de "transformação". Cada uma dessas transformações exigiu mudanças no sombreador, e cada uma das três transformações dependia da ordem.
Por exemplo, aqui temos uma escala de 2, 1, rotação de 30% e translação de 100, 0.
E aqui está uma translação de 100,0, rotação de 30% e escala de 2, 1
Os resultados são completamente diferentes. Pior ainda, se precisássemos do segundo exemplo, teríamos que escrever um sombreador diferente que aplicasse a translação, a rotação e a escala na nova ordem desejada. Bem, algumas pessoas muito mais inteligentes do que eu descobriram que é possível fazer tudo isso com matemática de matrizes. Para 2D, usamos uma matriz 3x3. Uma matriz 3x3 é como uma grade com 9 caixas.
| 1.0 | 2,0 | 3.0 |
| 4.0 | 5.0 | 6.0 |
| 7.0 | 8.0 | 9.0 |
Para fazer o cálculo, multiplicamos a posição pelas colunas da matriz e somamos os resultados. Nossas posições têm apenas dois valores, x e y, mas para fazer esse cálculo, precisamos de três valores. Por isso, vamos usar 1 para o terceiro valor. Nesse caso, o resultado seria
newX = x * 1.0 + y * 4.0 + 1 * 7.0
newY = x * 2.0 + y * 5.0 + 1 * 8.0
extra = x * 3.0 + y * 6.0 + 1 * 9.0
Você provavelmente está olhando para isso e pensando "QUAL É O SENTIDO DISSO?". Vamos supor que temos uma tradução. Vamos chamar o valor que queremos transladar de tx e ty. Vamos criar uma matriz como esta
| 1.0 | 0,0 | 0,0 |
| 0,0 | 1,0 | 0,0 |
| tx | ty | 1.0 |
Agora confira
newX = x * 1.0 + y * 0.0 + 1 * tx
newY = x * 0.0 + y * 1.0 + 1 * ty
extra = x * 0.0 + y * 0.0 + 1 * 1
Se você se lembra da álgebra, podemos excluir qualquer lugar que multiplique por zero. Multiplicar por 1 não faz nada, então vamos simplificar para ver o que está acontecendo
newX = x + tx;
newY = y + ty;
E não nos importamos com o extra. Isso é surpreendentemente parecido com o código de tradução do nosso exemplo. Da mesma forma, vamos fazer a rotação. Como apontamos no post sobre rotação, só precisamos do seno e do cosseno do ângulo em que queremos girar.
s = Math.sin(angleToRotateInRadians);
c = Math.cos(angleToRotateInRadians);
E criamos uma matriz como esta,
| c | -s | 0,0 |
| s | c | 0,0 |
| 0,0 | 0,0 | 1.0 |
Aplicando a matriz, temos o seguinte:
newX = x * c + y * s + 1 * 0
newY = x * -s + y * c + 1 * 0
extra = x * 0.0 + y * 0.0 + 1 * 1
Se ocultarmos todas as multiplicações por 0 e 1, teremos:
newX = x * c + y * s;
newY = x * -s + y * c;
que é exatamente o que tínhamos na nossa amostra de rotação. Por fim, o escalonamento. Vamos chamar nossos dois fatores de escalonamento de sx e sy e criar uma matriz como esta:
| sx | 0,0 | 0,0 |
| 0,0 | sy | 0,0 |
| 0,0 | 0,0 | 1.0 |
Aplicando a matriz, temos o seguinte:
newX = x * sx + y * 0 + 1 * 0
newY = x * 0 + y * sy + 1 * 0
extra = x * 0.0 + y * 0.0 + 1 * 1
que é muito
newX = x * sx;
newY = y * sy;
que é o mesmo da nossa amostra de escalonamento.
Agora, você deve estar pensando: E o que isso significa? Não faz sentido. Parece muito trabalho só para fazer a mesma coisa que já estávamos fazendo.
É aqui que a mágica acontece. Podemos multiplicar matrizes e aplicar todas as transformações de uma só vez. Vamos supor que temos uma função, matrixMultiply, que usa duas matrizes, as multiplica e retorna o resultado.
Para deixar as coisas mais claras, vamos criar funções para criar matrizes de translação, rotação e escala.
function makeTranslation(tx, ty) {
return [
1, 0, 0,
0, 1, 0,
tx, ty, 1
];
}
function makeRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);
return [
c,-s, 0,
s, c, 0,
0, 0, 1
];
}
function makeScale(sx, sy) {
return [
sx, 0, 0,
0, sy, 0,
0, 0, 1
];
}
Agora vamos mudar o sombreador. O sombreador antigo era assim:
<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;
uniform vec2 u_scale;
void main() {
// Scale the positon
vec2 scaledPosition = a_position * u_scale;
// Rotate the position
vec2 rotatedPosition = vec2(
scaledPosition.x * u_rotation.y + scaledPosition.y * u_rotation.x,
scaledPosition.y * u_rotation.y - scaledPosition.x * u_rotation.x);
// Add in the translation.
vec2 position = rotatedPosition + u_translation;
...
O novo sombreador será muito mais simples.
<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform mat3 u_matrix;
void main() {
// Multiply the position by the matrix.
vec2 position = (u_matrix * vec3(a_position, 1)).xy;
...
E é assim que o usamos
// Draw the scene.
function drawScene() {
// Clear the canvas.
gl.clear(gl.COLOR_BUFFER_BIT);
// Compute the matrices
var translationMatrix =
makeTranslation(translation[0], translation[1]);
var rotationMatrix = makeRotation(angleInRadians);
var scaleMatrix = makeScale(scale[0], scale[1]);
// Multiply the matrices.
var matrix = matrixMultiply(scaleMatrix, rotationMatrix);
matrix = matrixMultiply(matrix, translationMatrix);
// Set the matrix.
gl.uniformMatrix3fv(matrixLocation, false, matrix);
// Draw the rectangle.
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
Mesmo assim, você pode estar se perguntando: e daí? Isso não parece ser uma grande vantagem . Mas agora, se quisermos mudar a ordem, não precisamos escrever um novo sombreador. Podemos mudar a matemática.
...
// Multiply the matrices.
var matrix = matrixMultiply(translationMatrix, rotationMatrix);
matrix = matrixMultiply(matrix, scaleMatrix);
...
A capacidade de aplicar matrizes como essa é especialmente importante para animações hierárquicas, como braços em um corpo, luas em um planeta ao redor de um sol ou galhos em uma árvore. Para um exemplo simples de animação hierárquica, vamos desenhar o "F" cinco vezes, mas cada vez vamos começar com a matriz do "F" anterior.
// Draw the scene.
function drawScene() {
// Clear the canvas.
gl.clear(gl.COLOR_BUFFER_BIT);
// Compute the matrices
var translationMatrix = makeTranslation(translation[0], translation[1]);
var rotationMatrix = makeRotation(angleInRadians);
var scaleMatrix = makeScale(scale[0], scale[1]);
// Starting Matrix.
var matrix = makeIdentity();
for (var i = 0; i < 5; ++i) {
// Multiply the matrices.
matrix = matrixMultiply(matrix, scaleMatrix);
matrix = matrixMultiply(matrix, rotationMatrix);
matrix = matrixMultiply(matrix, translationMatrix);
// Set the matrix.
gl.uniformMatrix3fv(matrixLocation, false, matrix);
// Draw the geometry.
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
}
Para isso, introduzimos a função makeIdentity, que cria uma matriz de identidade. Uma matriz de identidade é uma matriz que representa efetivamente 1.0. Portanto, se você multiplicar pela identidade, nada vai acontecer. Assim como
X * 1 = X
so too
matrixX * identity = matrixX
Este é o código para criar uma matriz de identidade.
function makeIdentity() {
return [
1, 0, 0,
0, 1, 0,
0, 0, 1
];
}
Mais um exemplo: em todas as amostras até agora, o "F" gira em torno do canto superior esquerdo. Isso ocorre porque a matemática que estamos usando sempre gira em torno da origem, e o canto superior esquerdo do nosso "F" está na origem (0, 0). Mas agora, como podemos fazer matemática de matrizes e escolher a ordem em que as transformações são aplicadas, podemos mover a origem antes que o restante das transformações seja aplicado.
// make a matrix that will move the origin of the 'F' to
// its center.
var moveOriginMatrix = makeTranslation(-50, -75);
...
// Multiply the matrices.
var matrix = matrixMultiply(moveOriginMatrix, scaleMatrix);
matrix = matrixMultiply(matrix, rotationMatrix);
matrix = matrixMultiply(matrix, translationMatrix);
Com essa técnica, é possível girar ou dimensionar de qualquer ponto. Agora você sabe como o Photoshop ou o Flash permitem mover o ponto de rotação. Vamos deixar as coisas ainda mais loucas. Se você voltar ao primeiro artigo sobre fundamentos do WebGL, talvez se lembre de que temos um código no sombreador para converter de pixels em clipspace, que é assim.
...
// convert the rectangle from pixels to 0.0 to 1.0
vec2 zeroToOne = position / u_resolution;
// convert from 0->1 to 0->2
vec2 zeroToTwo = zeroToOne * 2.0;
// convert from 0->2 to -1->+1 (clipspace)
vec2 clipSpace = zeroToTwo - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
Se você analisar cada uma dessas etapas, a primeira, "converter de pixels para 0,0 a 1,0", é uma operação de escala. A segunda também é uma operação de escalonamento. A próxima é uma translação, e a última dimensiona Y por -1. Podemos fazer tudo isso na matriz que passamos para o sombreador. Poderíamos criar duas matrizes de escala, uma para dimensionar por 1.0/resolução, outra para dimensionar por 2.0, uma terceira para transladar por -1.0,-1.0 e uma quarta para dimensionar Y por -1 e, em seguida, multiplicar todas elas, mas, como a matemática é simples, vamos criar uma função que cria uma matriz de "projeção" para uma determinada resolução diretamente.
function make2DProjection(width, height) {
// Note: This matrix flips the Y axis so that 0 is at the top.
return [
2 / width, 0, 0,
0, -2 / height, 0,
-1, 1, 1
];
}
Agora podemos simplificar ainda mais o sombreador. Este é o novo sombreador de vértice completo.
<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform mat3 u_matrix;
void main() {
// Multiply the position by the matrix.
gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
}
</script>
No JavaScript, precisamos multiplicar pela matriz de projeção
// Draw the scene.
function drawScene() {
...
// Compute the matrices
var projectionMatrix =
make2DProjection(canvas.width, canvas.height);
...
// Multiply the matrices.
var matrix = matrixMultiply(scaleMatrix, rotationMatrix);
matrix = matrixMultiply(matrix, translationMatrix);
matrix = matrixMultiply(matrix, projectionMatrix);
...
}
Também removemos o código que definia a resolução. Com essa última etapa, passamos de um sombreador bastante complicado com 6 a 7 etapas para um muito simples com apenas 1 etapa, tudo graças à mágica da matemática de matrizes.
Espero que este artigo tenha ajudado a desmistificar a matemática de matrizes. Em seguida, vou passar para o 3D. Em 3D, a matemática de matrizes segue os mesmos princípios e usos. Comecei com 2D para facilitar o entendimento.