Gráficos 3D: Um Tutorial WebGL
Publicados: 2022-03-11O mundo dos gráficos 3D pode ser muito intimidante para entrar. Se você deseja apenas criar um logotipo 3D interativo ou projetar um jogo completo, se você não conhece os princípios da renderização 3D, está preso a uma biblioteca que abstrai muitas coisas.
Usar uma biblioteca pode ser a ferramenta certa, e JavaScript tem uma incrível de código aberto na forma de three.js. Existem algumas desvantagens em usar soluções pré-fabricadas, no entanto:
- Eles podem ter muitos recursos que você não planeja usar. O tamanho dos recursos do three.js base minificado é de cerca de 500kB, e quaisquer recursos extras (carregar arquivos de modelo reais é um deles) tornam a carga útil ainda maior. Transferir tantos dados apenas para mostrar um logotipo giratório em seu site seria um desperdício.
- Uma camada extra de abstração pode dificultar modificações de outra forma fáceis. Sua maneira criativa de sombrear um objeto na tela pode ser simples de implementar ou exigir dezenas de horas de trabalho para incorporar as abstrações da biblioteca.
- Embora a biblioteca seja otimizada muito bem na maioria dos cenários, muitos sinos e assobios podem ser cortados para o seu caso de uso. O renderizador pode fazer com que certos procedimentos sejam executados milhões de vezes na placa gráfica. Cada instrução removida de tal procedimento significa que uma placa gráfica mais fraca pode lidar com seu conteúdo sem problemas.
Mesmo se você decidir usar uma biblioteca gráfica de alto nível, ter conhecimento básico das coisas sob o capô permite que você a use de forma mais eficaz. As bibliotecas também podem ter recursos avançados, como ShaderMaterial
em three.js
. Conhecer os princípios da renderização gráfica permite que você use esses recursos.
Nosso objetivo é dar uma breve introdução a todos os principais conceitos por trás da renderização de gráficos 3D e do uso do WebGL para implementá-los. Você verá a coisa mais comum que é feita, que é mostrar e mover objetos 3D em um espaço vazio.
O código final está disponível para você bifurcar e brincar.
Representando modelos 3D
A primeira coisa que você precisa entender é como os modelos 3D são representados. Um modelo é feito de uma malha de triângulos. Cada triângulo é representado por três vértices, para cada um dos vértices do triângulo. Existem três propriedades mais comuns associadas aos vértices.
Posição do vértice
A posição é a propriedade mais intuitiva de um vértice. É a posição no espaço 3D, representada por um vetor 3D de coordenadas. Se você souber as coordenadas exatas de três pontos no espaço, terá todas as informações necessárias para desenhar um triângulo simples entre eles. Para fazer com que os modelos pareçam realmente bons quando renderizados, há mais algumas coisas que precisam ser fornecidas ao renderizador.
Vértice Normal
Considere os dois modelos acima. Eles consistem nas mesmas posições de vértice, mas parecem totalmente diferentes quando renderizados. Como isso é possível?
Além de dizer ao renderizador onde queremos que um vértice esteja localizado, também podemos dar uma dica de como a superfície está inclinada nessa posição exata. A dica está na forma da normal da superfície naquele ponto específico do modelo, representado com um vetor 3D. A imagem a seguir deve fornecer uma visão mais descritiva de como isso é tratado.
A superfície esquerda e direita correspondem à bola esquerda e direita na imagem anterior, respectivamente. As setas vermelhas representam as normais especificadas para um vértice, enquanto as setas azuis representam os cálculos do renderizador de como a normal deve ficar para todos os pontos entre os vértices. A imagem mostra uma demonstração para o espaço 2D, mas o mesmo princípio se aplica em 3D.
O normal é uma dica de como as luzes iluminarão a superfície. Quanto mais próximo a direção de um raio de luz estiver da normal, mais brilhante será o ponto. Ter mudanças graduais na direção normal causa gradientes de luz, enquanto mudanças abruptas sem mudanças entre elas causam superfícies com iluminação constante através delas e mudanças repentinas na iluminação entre elas.
Coordenadas de textura
A última propriedade significativa são as coordenadas de textura, comumente chamadas de mapeamento UV. Você tem um modelo e uma textura que deseja aplicar a ele. A textura tem várias áreas, representando imagens que queremos aplicar em diferentes partes do modelo. Tem que haver uma maneira de marcar qual triângulo deve ser representado com qual parte da textura. É aí que entra o mapeamento de textura.
Para cada vértice, marcamos duas coordenadas, U e V. Essas coordenadas representam uma posição na textura, com U representando o eixo horizontal e V o eixo vertical. Os valores não estão em pixels, mas em uma posição percentual dentro da imagem. O canto inferior esquerdo da imagem é representado com dois zeros, enquanto o canto superior direito é representado com dois zeros.
Um triângulo é pintado apenas tomando as coordenadas UV de cada vértice no triângulo e aplicando a imagem que é capturada entre essas coordenadas na textura.
Você pode ver uma demonstração do mapeamento UV na imagem acima. O modelo esférico foi tirado e cortado em partes pequenas o suficiente para serem achatadas em uma superfície 2D. As costuras onde foram feitos os cortes são marcadas com linhas mais grossas. Um dos patches foi destacado, para que você possa ver bem como as coisas combinam. Você também pode ver como uma costura no meio do sorriso coloca partes da boca em duas partes diferentes.
Os wireframes não fazem parte da textura, mas apenas sobrepostos à imagem para que você possa ver como as coisas são mapeadas.
Carregando um modelo OBJ
Acredite ou não, isso é tudo que você precisa saber para criar seu próprio carregador de modelo simples. O formato de arquivo OBJ é simples o suficiente para implementar um analisador em poucas linhas de código.
O arquivo lista as posições dos vértices em um formato v <float> <float> <float>
, com um quarto float opcional, que vamos ignorar, para manter as coisas simples. As normais de vértice são representadas de forma semelhante com vn <float> <float> <float>
. Finalmente, as coordenadas de textura são representadas com vt <float> <float>
, com um terceiro float opcional que devemos ignorar. Em todos os três casos, os floats representam as respectivas coordenadas. Essas três propriedades são acumuladas em três arrays.
As faces são representadas com grupos de vértices. Cada vértice é representado com o índice de cada uma das propriedades, onde os índices começam em 1. Existem várias maneiras de representar, mas vamos nos ater ao formato f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3
, exigindo que todas as três propriedades sejam fornecidas e limitando o número de vértices por face a três. Todas essas limitações estão sendo feitas para manter o carregador o mais simples possível, já que todas as outras opções requerem algum processamento trivial extra antes de estarem em um formato que o WebGL goste.
Colocamos muitos requisitos para o nosso carregador de arquivos. Isso pode parecer limitante, mas os aplicativos de modelagem 3D tendem a permitir que você defina essas limitações ao exportar um modelo como um arquivo OBJ.
O código a seguir analisa uma string representando um arquivo OBJ e cria um modelo na forma de uma matriz de faces.
function Geometry (faces) { this.faces = faces || [] } // Parses an OBJ file, passed as a string Geometry.parseOBJ = function (src) { var POSITION = /^v\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var NORMAL = /^vn\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var UV = /^vt\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var FACE = /^f\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)(?:\s+(-?\d+)\/(-?\d+)\/(-?\d+))?/ lines = src.split('\n') var positions = [] var uvs = [] var normals = [] var faces = [] lines.forEach(function (line) { // Match each line of the file against various RegEx-es var result if ((result = POSITION.exec(line)) != null) { // Add new vertex position positions.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = NORMAL.exec(line)) != null) { // Add new vertex normal normals.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = UV.exec(line)) != null) { // Add new texture mapping point uvs.push(new Vector2(parseFloat(result[1]), 1 - parseFloat(result[2]))) } else if ((result = FACE.exec(line)) != null) { // Add new face var vertices = [] // Create three vertices from the passed one-indexed indices for (var i = 1; i < 10; i += 3) { var part = result.slice(i, i + 3) var position = positions[parseInt(part[0]) - 1] var uv = uvs[parseInt(part[1]) - 1] var normal = normals[parseInt(part[2]) - 1] vertices.push(new Vertex(position, normal, uv)) } faces.push(new Face(vertices)) } }) return new Geometry(faces) } // Loads an OBJ file from the given URL, and returns it as a promise Geometry.loadOBJ = function (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(Geometry.parseOBJ(xhr.responseText)) } } xhr.open('GET', url, true) xhr.send(null) }) } function Face (vertices) { this.vertices = vertices || [] } function Vertex (position, normal, uv) { this.position = position || new Vector3() this.normal = normal || new Vector3() this.uv = uv || new Vector2() } function Vector3 (x, y, z) { this.x = Number(x) || 0 this.y = Number(y) || 0 this.z = Number(z) || 0 } function Vector2 (x, y) { this.x = Number(x) || 0 this.y = Number(y) || 0 }
A estrutura Geometry
contém os dados exatos necessários para enviar um modelo para a placa gráfica para processar. Antes de fazer isso, você provavelmente gostaria de ter a capacidade de mover o modelo na tela.
Executando Transformações Espaciais
Todos os pontos no modelo que carregamos são relativos ao seu sistema de coordenadas. Se quisermos transladar, girar e dimensionar o modelo, tudo o que precisamos fazer é realizar essa operação em seu sistema de coordenadas. O sistema de coordenadas A, relativo ao sistema de coordenadas B, é definido pela posição de seu centro como um vetor p_ab
, e o vetor para cada um de seus eixos, x_ab
, y_ab
e z_ab
, representando a direção desse eixo. Portanto, se um ponto se move por 10 no eixo x
do sistema de coordenadas A, então - no sistema de coordenadas B - ele se moverá na direção de x_ab
, multiplicado por 10.
Todas essas informações são armazenadas na seguinte forma de matriz:
x_ab.x y_ab.x z_ab.x p_ab.x x_ab.y y_ab.y z_ab.y p_ab.y x_ab.z y_ab.z z_ab.z p_ab.z 0 0 0 1
Se quisermos transformar o vetor 3D q
, basta multiplicar a matriz de transformação pelo vetor:
qx qy qz 1
Isso faz com que o ponto se mova por qx
ao longo do novo eixo x
, por qy
ao longo do novo eixo y
e por qz
ao longo do novo eixo z
. Finalmente, faz com que o ponto se mova adicionalmente pelo vetor p
, razão pela qual usamos o um como elemento final da multiplicação.
A grande vantagem de usar essas matrizes é o fato de que se tivermos várias transformações para realizar no vértice, podemos mesclá-las em uma transformação multiplicando suas matrizes, antes de transformar o próprio vértice.
Existem várias transformações que podem ser realizadas, e vamos dar uma olhada nas principais.
Sem transformação
Se nenhuma transformação acontecer, então o vetor p
é um vetor zero, o vetor x
é [1, 0, 0]
, y
é [0, 1, 0]
e z
é [0, 0, 1]
. A partir de agora vamos nos referir a esses valores como os valores padrão para esses vetores. Aplicando esses valores nos dá uma matriz identidade:
1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1
Este é um bom ponto de partida para encadear transformações.
Tradução
Quando realizamos a tradução, todos os vetores, exceto o vetor p
, têm seus valores padrão. Isso resulta na seguinte matriz:
1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1
Escala
Dimensionar um modelo significa reduzir a quantidade que cada coordenada contribui para a posição de um ponto. Não há deslocamento uniforme causado pelo dimensionamento, portanto, o vetor p
mantém seu valor padrão. Os vetores de eixo padrão devem ser multiplicados por seus respectivos fatores de escala, o que resulta na seguinte matriz:
s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1
Aqui s_x
, s_y
e s_z
representam a escala aplicada a cada eixo.
Rotação
A imagem acima mostra o que acontece quando giramos o quadro de coordenadas em torno do eixo Z.
A rotação não resulta em deslocamento uniforme, de modo que o vetor p
mantém seu valor padrão. Agora as coisas ficam um pouco mais complicadas. As rotações fazem com que o movimento ao longo de um determinado eixo no sistema de coordenadas original se mova em uma direção diferente. Portanto, se girarmos um sistema de coordenadas em 45 graus em torno do eixo Z, mover-se ao longo do eixo x
do sistema de coordenadas original causará um movimento diagonal entre os eixos x
e y
no novo sistema de coordenadas.
Para simplificar, mostraremos apenas como as matrizes de transformação procuram rotações em torno dos eixos principais.
Around X: 1 0 0 0 0 cos(phi) sin(phi) 0 0 -sin(phi) cos(phi) 0 0 0 0 1 Around Y: cos(phi) 0 sin(phi) 0 0 1 0 0 -sin(phi) 0 cos(phi) 0 0 0 0 1 Around Z: cos(phi) -sin(phi) 0 0 sin(phi) cos(phi) 0 0 0 0 1 0 0 0 0 1
Implementação
Tudo isso pode ser implementado como uma classe que armazena 16 números, armazenando matrizes em ordem de coluna principal.
function Transformation () { // Create an identity transformation this.fields = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } // Multiply matrices, to chain transformations Transformation.prototype.mult = function (t) { var output = new Transformation() for (var row = 0; row < 4; ++row) { for (var col = 0; col < 4; ++col) { var sum = 0 for (var k = 0; k < 4; ++k) { sum += this.fields[k * 4 + row] * t.fields[col * 4 + k] } output.fields[col * 4 + row] = sum } } return output } // Multiply by translation matrix Transformation.prototype.translate = function (x, y, z) { var mat = new Transformation() mat.fields[12] = Number(x) || 0 mat.fields[13] = Number(y) || 0 mat.fields[14] = Number(z) || 0 return this.mult(mat) } // Multiply by scaling matrix Transformation.prototype.scale = function (x, y, z) { var mat = new Transformation() mat.fields[0] = Number(x) || 0 mat.fields[5] = Number(y) || 0 mat.fields[10] = Number(z) || 0 return this.mult(mat) } // Multiply by rotation matrix around X axis Transformation.prototype.rotateX = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[5] = c mat.fields[10] = c mat.fields[9] = -s mat.fields[6] = s return this.mult(mat) } // Multiply by rotation matrix around Y axis Transformation.prototype.rotateY = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[10] = c mat.fields[2] = -s mat.fields[8] = s return this.mult(mat) } // Multiply by rotation matrix around Z axis Transformation.prototype.rotateZ = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[5] = c mat.fields[4] = -s mat.fields[1] = s return this.mult(mat) }
Olhando através de uma câmera
Aí vem a parte fundamental da apresentação de objetos na tela: a câmera. Há dois componentes principais em uma câmera; ou seja, sua posição e como ele projeta objetos observados na tela.
A posição da câmera é tratada com um truque simples. Não há diferença visual entre mover a câmera um metro para frente e mover o mundo inteiro um metro para trás. Então, naturalmente, fazemos o último, aplicando a inversa da matriz como uma transformação.
O segundo componente chave é a forma como os objetos observados são projetados na lente. No WebGL, tudo o que é visível na tela está localizado em uma caixa. A caixa se estende entre -1 e 1 em cada eixo. Tudo visível está dentro dessa caixa. Podemos usar a mesma abordagem de matrizes de transformação para criar uma matriz de projeção.
Projeção ortográfica
A projeção mais simples é a projeção ortográfica. Você pega uma caixa no espaço, denotando a largura, altura e profundidade, com a suposição de que seu centro está na posição zero. Em seguida, a projeção redimensiona a caixa para encaixá-la na caixa descrita anteriormente, dentro da qual o WebGL observa os objetos. Como queremos redimensionar cada dimensão para dois, dimensionamos cada eixo por 2/size
, em que size
é a dimensão do respectivo eixo. Uma pequena ressalva é o fato de estarmos multiplicando o eixo Z por um negativo. Isso é feito porque queremos inverter a direção dessa dimensão. A matriz final tem esta forma:
2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1
Projeção de Perspectiva
Não vamos entrar em detalhes de como essa projeção é projetada, mas apenas usar a fórmula final, que é praticamente padrão até agora. Podemos simplificar colocando a projeção na posição zero nos eixos x e y, tornando os limites direito/esquerdo e superior/inferior iguais a width/2
e height/2
respectivamente. Os parâmetros n
e f
representam os planos de recorte near
e far
, que são a menor e a maior distância que um ponto pode ter para ser capturado pela câmera. Eles são representados pelos lados paralelos do tronco na imagem acima.
Uma projeção em perspectiva geralmente é representada com um campo de visão (usaremos o vertical), proporção e as distâncias do plano próximo e distante. Essas informações podem ser usadas para calcular a width
e a height
e, em seguida, a matriz pode ser criada a partir do seguinte modelo:
2*n/width 0 0 0 0 2*n/height 0 0 0 0 (f+n)/(nf) 2*f*n/(nf) 0 0 -1 0
Para calcular a largura e a altura, as seguintes fórmulas podem ser usadas:
height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * height
O FOV (campo de visão) representa o ângulo vertical que a câmera captura com sua lente. A proporção de aspecto representa a proporção entre a largura e a altura da imagem e é baseada nas dimensões da tela para a qual estamos renderizando.
Implementação
Agora podemos representar uma câmera como uma classe que armazena a posição da câmera e a matriz de projeção. Também precisamos saber como calcular transformações inversas. Resolver inversões de matrizes gerais pode ser problemático, mas há uma abordagem simplificada para nosso caso especial.
function Camera () { this.position = new Transformation() this.projection = new Transformation() } Camera.prototype.setOrthographic = function (width, height, depth) { this.projection = new Transformation() this.projection.fields[0] = 2 / width this.projection.fields[5] = 2 / height this.projection.fields[10] = -2 / depth } Camera.prototype.setPerspective = function (verticalFov, aspectRatio, near, far) { var height_div_2n = Math.tan(verticalFov * Math.PI / 360) var width_div_2n = aspectRatio * height_div_2n this.projection = new Transformation() this.projection.fields[0] = 1 / height_div_2n this.projection.fields[5] = 1 / width_div_2n this.projection.fields[10] = (far + near) / (near - far) this.projection.fields[10] = -1 this.projection.fields[14] = 2 * far * near / (near - far) this.projection.fields[15] = 0 } Camera.prototype.getInversePosition = function () { var orig = this.position.fields var dest = new Transformation() var x = orig[12] var y = orig[13] var z = orig[14] // Transpose the rotation matrix for (var i = 0; i < 3; ++i) { for (var j = 0; j < 3; ++j) { dest.fields[i * 4 + j] = orig[i + j * 4] } } // Translation by -p will apply R^T, which is equal to R^-1 return dest.translate(-x, -y, -z) }
Esta é a peça final que precisamos antes de podermos começar a desenhar coisas na tela.
Desenhando um objeto com o pipeline gráfico WebGL
A superfície mais simples que você pode desenhar é um triângulo. Na verdade, a maioria das coisas que você desenha no espaço 3D consiste em um grande número de triângulos.
A primeira coisa que você precisa entender é como a tela é representada no WebGL. É um espaço 3D, abrangendo entre -1 e 1 nos eixos x , y e z . Por padrão, este eixo z não é usado, mas você está interessado em gráficos 3D, então você vai querer habilitá-lo imediatamente.
Tendo isso em mente, o que se segue são três passos necessários para desenhar um triângulo nesta superfície.
Você pode definir três vértices, que representam o triângulo que deseja desenhar. Você serializa esses dados e os envia para a GPU (unidade de processamento gráfico). Com um modelo completo disponível, você pode fazer isso para todos os triângulos do modelo. As posições dos vértices que você fornece estão no espaço de coordenadas local do modelo que você carregou. Simplificando, as posições que você fornece são as exatas do arquivo, e não as que você obtém após realizar transformações de matriz.
Agora que você forneceu os vértices à GPU, diga à GPU qual lógica usar ao colocar os vértices na tela. Esta etapa será usada para aplicar nossas transformações de matrizes. A GPU é muito boa em multiplicar muitas matrizes 4x4, então vamos colocar essa capacidade em bom uso.
Na última etapa, a GPU rasterizará esse triângulo. Rasterização é o processo de obter gráficos vetoriais e determinar quais pixels da tela precisam ser pintados para que esse objeto gráfico vetorial seja exibido. No nosso caso, a GPU está tentando determinar quais pixels estão localizados dentro de cada triângulo. Para cada pixel, a GPU perguntará qual cor você deseja que ele seja pintado.
Esses são os quatro elementos necessários para desenhar o que você quiser e são o exemplo mais simples de um pipeline gráfico. O que se segue é uma olhada em cada um deles e uma implementação simples.
O framebuffer padrão
O elemento mais importante para um aplicativo WebGL é o contexto WebGL. Você pode acessá-lo com gl = canvas.getContext('webgl')
, ou usar 'experimental-webgl'
como fallback, caso o navegador usado atualmente não suporte todos os recursos WebGL ainda. A canvas
a que nos referimos é o elemento DOM da tela em que queremos desenhar. O contexto contém muitas coisas, entre as quais está o framebuffer padrão.
Você pode descrever vagamente um framebuffer como qualquer buffer (objeto) no qual você possa desenhar. Por padrão, o framebuffer padrão armazena a cor para cada pixel da tela ao qual o contexto WebGL está vinculado. Conforme descrito na seção anterior, quando desenhamos no framebuffer, cada pixel está localizado entre -1 e 1 nos eixos x e y . Algo que também mencionamos é o fato de que, por padrão, o WebGL não usa o eixo z . Essa funcionalidade pode ser habilitada executando gl.enable(gl.DEPTH_TEST)
. Ótimo, mas o que é um teste de profundidade?
A ativação do teste de profundidade permite que um pixel armazene cor e profundidade. A profundidade é a coordenada z desse pixel. Depois de desenhar em um pixel em uma certa profundidade z , para atualizar a cor desse pixel, você precisa desenhar em uma posição z mais próxima da câmera. Caso contrário, a tentativa de empate será ignorada. Isso permite a ilusão de 3D, já que desenhar objetos que estão atrás de outros objetos fará com que esses objetos sejam ocluídos por objetos na frente deles.
Quaisquer sorteios que você fizer permanecem na tela até que você diga para serem eliminados. Para fazer isso, você deve chamar gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
. Isso limpa o buffer de cor e profundidade. Para escolher a cor para a qual os pixels limpos estão definidos, use gl.clearColor(red, green, blue, alpha)
.
Vamos criar um renderizador que usa uma tela e a limpa mediante solicitação:
function Renderer (canvas) { var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') gl.enable(gl.DEPTH_TEST) this.gl = gl } Renderer.prototype.setClearColor = function (red, green, blue) { gl.clearColor(red / 255, green / 255, blue / 255, 1) } Renderer.prototype.getContext = function () { return this.gl } Renderer.prototype.render = function () { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) } var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) loop() function loop () { renderer.render() requestAnimationFrame(loop) }
Anexar este script ao seguinte HTML lhe dará um retângulo azul brilhante na tela
<!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html>
A chamada requestAnimationFrame
faz com que o loop seja chamado novamente assim que o quadro anterior terminar de renderizar e todo o tratamento de eventos for concluído.
Objetos de buffer de vértice
A primeira coisa que você precisa fazer é definir os vértices que deseja desenhar. Você pode fazer isso descrevendo-os por meio de vetores no espaço 3D. Depois disso, você deseja mover esses dados para a RAM da GPU, criando um novo Vertex Buffer Object (VBO).
Um Buffer Object em geral é um objeto que armazena uma matriz de pedaços de memória na GPU. Sendo um VBO apenas denota para que a GPU pode usar a memória. Na maioria das vezes, os Buffer Objects que você cria serão VBOs.
Você pode preencher o VBO pegando todos os N
vértices que temos e criando um array de floats com 3N
elementos para a posição do vértice e VBOs normais do vértice, e 2N
para as coordenadas de textura VBO. Cada grupo de três floats, ou dois floats para coordenadas UV, representa as coordenadas individuais de um vértice. Em seguida, passamos esses arrays para a GPU e nossos vértices estão prontos para o restante do pipeline.
Como os dados agora estão na RAM da GPU, você pode excluí-los da RAM de uso geral. Ou seja, a menos que você queira modificá-lo posteriormente e enviá-lo novamente. Cada modificação precisa ser seguida por um upload, pois as modificações em nossos arrays JS não se aplicam a VBOs na RAM real da GPU.
Abaixo está um exemplo de código que fornece todas as funcionalidades descritas. Uma observação importante a ser feita é o fato de que as variáveis armazenadas na GPU não são coletadas como lixo. Isso significa que temos que excluí-los manualmente quando não quisermos mais usá-los. Daremos apenas um exemplo de como isso é feito aqui, e não nos concentraremos nesse conceito mais adiante. A exclusão de variáveis da GPU é necessária apenas se você planeja parar de usar determinada geometria em todo o programa.

Também adicionamos serialização à nossa classe Geometry
e elementos dentro dela.
Geometry.prototype.vertexCount = function () { return this.faces.length * 3 } Geometry.prototype.positions = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.position answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.normals = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.normal answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.uvs = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.uv answer.push(vx, vy) }) }) return answer } //////////////////////////////// function VBO (gl, data, count) { // Creates buffer object in GPU RAM where we can store anything var bufferObject = gl.createBuffer() // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject) // Write the data, and set the flag to optimize // for rare changes to the data we're writing gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW) this.gl = gl this.size = data.length / count this.count = count this.data = bufferObject } VBO.prototype.destroy = function () { // Free memory that is occupied by our buffer object this.gl.deleteBuffer(this.data) }
O tipo de dados VBO
gera o VBO no contexto WebGL passado, com base no array passado como segundo parâmetro.
Você pode ver três chamadas para o contexto gl
. A chamada createBuffer()
cria o buffer. A chamada bindBuffer()
diz à máquina de estado WebGL para usar essa memória específica como o VBO atual ( ARRAY_BUFFER
) para todas as operações futuras, até que seja informado o contrário. Depois disso, definimos o valor do VBO atual para os dados fornecidos, com bufferData()
.
Também fornecemos um método destroy que exclui nosso objeto buffer da RAM da GPU, usando deleteBuffer()
.
Você pode usar três VBOs e uma transformação para descrever todas as propriedades de uma malha, juntamente com sua posição.
function Mesh (gl, geometry) { var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() }
Como exemplo, aqui está como podemos carregar um modelo, armazenar suas propriedades na malha e depois destruí-lo:
Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })
Tonalizadores
O que se segue é o processo de duas etapas descrito anteriormente de mover os pontos para as posições desejadas e pintar todos os pixels individuais. Para fazer isso, escrevemos um programa que é executado na placa gráfica muitas vezes. Este programa normalmente consiste em pelo menos duas partes. A primeira parte é um Vertex Shader , que é executado para cada vértice, e sai onde devemos colocar o vértice na tela, entre outras coisas. A segunda parte é o Fragment Shader , que é executado para cada pixel que um triângulo cobre na tela e gera a cor para a qual o pixel deve ser pintado.
Sombreadores de vértice
Digamos que você queira ter um modelo que se mova para a esquerda e para a direita na tela. Em uma abordagem ingênua, você pode atualizar a posição de cada vértice e reenviá-lo para a GPU. Esse processo é caro e lento. Alternativamente, você daria um programa para a GPU executar para cada vértice e faria todas essas operações em paralelo com um processador construído para fazer exatamente esse trabalho. Esse é o papel de um shader de vértice .
Um sombreador de vértice é a parte do pipeline de renderização que processa vértices individuais. Uma chamada para o sombreador de vértice recebe um único vértice e gera um único vértice depois que todas as transformações possíveis no vértice são aplicadas.
Shaders são escritos em GLSL. Existem muitos elementos exclusivos para essa linguagem, mas a maior parte da sintaxe é muito parecida com C, então deve ser compreensível para a maioria das pessoas.
Existem três tipos de variáveis que entram e saem de um vertex shader, e todas elas servem a um uso específico:
-
attribute
— São entradas que contêm propriedades específicas de um vértice. Anteriormente, descrevemos a posição de um vértice como um atributo, na forma de um vetor de três elementos. Você pode ver os atributos como valores que descrevem um vértice. -
uniform
— Estas são entradas que são as mesmas para cada vértice dentro da mesma chamada de renderização. Digamos que queremos poder mover nosso modelo, definindo uma matriz de transformação. Você pode usar uma variáveluniform
para descrever isso. Você também pode apontar para recursos na GPU, como texturas. Você pode ver os uniformes como valores que descrevem um modelo ou parte de um modelo. -
varying
— Essas são as saídas que passamos para o sombreador de fragmento. Como existem potencialmente milhares de pixels para um triângulo de vértices, cada pixel receberá um valor interpolado para essa variável, dependendo da posição. Assim, se um vértice enviar 500 como saída e outro 100, um pixel que estiver no meio entre eles receberá 300 como entrada para aquela variável. Você pode ver as variações como valores que descrevem superfícies entre vértices.
So, let's say you want to create a vertex shader that receives a position, normal, and uv coordinates for each vertex, and a position, view (inverse camera position), and projection matrix for each rendered object. Let's say you also want to paint individual pixels based on their uv coordinates and their normals. “How would that code look?” você pode perguntar.
attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 model; uniform mat4 view; uniform mat4 projection; varying vec3 vNormal; varying vec2 vUv; void main() { vUv = uv; vNormal = (model * vec4(normal, 0.)).xyz; gl_Position = projection * view * model * vec4(position, 1.); }
Most of the elements here should be self-explanatory. The key thing to notice is the fact that there are no return values in the main
function. All values that we would want to return are assigned, either to varying
variables, or to special variables. Here we assign to gl_Position
, which is a four-dimensional vector, whereby the last dimension should always be set to one. Another strange thing you might notice is the way we construct a vec4
out of the position vector. You can construct a vec4
by using four float
s, two vec2
s, or any other combination that results in four elements. There are a lot of seemingly strange type castings which make perfect sense once you're familiar with transformation matrices.
You can also see that here we can perform matrix transformations extremely easily. GLSL is specifically made for this kind of work. The output position is calculated by multiplying the projection, view, and model matrix and applying it onto the position. The output normal is just transformed to the world space. We'll explain later why we've stopped there with the normal transformations.
For now, we will keep it simple, and move on to painting individual pixels.
Fragment Shaders
A fragment shader is the step after rasterization in the graphics pipeline. It generates color, depth, and other data for every pixel of the object that is being painted.
The principles behind implementing fragment shaders are very similar to vertex shaders. There are three major differences, though:
- There are no more
varying
outputs, andattribute
inputs have been replaced withvarying
inputs. We have just moved on in our pipeline, and things that are the output in the vertex shader are now inputs in the fragment shader. - Our only output now is
gl_FragColor
, which is avec4
. The elements represent red, green, blue, and alpha (RGBA), respectively, with variables in the 0 to 1 range. You should keep alpha at 1, unless you're doing transparency. Transparency is a fairly advanced concept though, so we'll stick to opaque objects. - At the beginning of the fragment shader, you need to set the float precision, which is important for interpolations. In almost all cases, just stick to the lines from the following shader.
With that in mind, you can easily write a shader that paints the red channel based on the U position, green channel based on the V position, and sets the blue channel to maximum.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec2 clampedUv = clamp(vUv, 0., 1.); gl_FragColor = vec4(clampedUv, 1., 1.); }
The function clamp
just limits all floats in an object to be within the given limits. The rest of the code should be pretty straightforward.
With all of this in mind, all that is left is to implement this in WebGL.
Combining Shaders into a Program
The next step is to combine the shaders into a program:
function ShaderProgram (gl, vertSrc, fragSrc) { var vert = gl.createShader(gl.VERTEX_SHADER) gl.shaderSource(vert, vertSrc) gl.compileShader(vert) if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(vert)) throw new Error('Failed to compile shader') } var frag = gl.createShader(gl.FRAGMENT_SHADER) gl.shaderSource(frag, fragSrc) gl.compileShader(frag) if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(frag)) throw new Error('Failed to compile shader') } var program = gl.createProgram() gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) throw new Error('Failed to link program') } this.gl = gl this.position = gl.getAttribLocation(program, 'position') this.normal = gl.getAttribLocation(program, 'normal') this.uv = gl.getAttribLocation(program, 'uv') this.model = gl.getUniformLocation(program, 'model') this.view = gl.getUniformLocation(program, 'view') this.projection = gl.getUniformLocation(program, 'projection') this.vert = vert this.frag = frag this.program = program } // Loads shader files from the given URLs, and returns a program as a promise ShaderProgram.load = function (gl, vertUrl, fragUrl) { return Promise.all([loadFile(vertUrl), loadFile(fragUrl)]).then(function (files) { return new ShaderProgram(gl, files[0], files[1]) }) function loadFile (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(xhr.responseText) } } xhr.open('GET', url, true) xhr.send(null) }) } }
There isn't much to say about what's happening here. Each shader gets assigned a string as a source and compiled, after which we check to see if there were compilation errors. Then, we create a program by linking these two shaders. Finally, we store pointers to all relevant attributes and uniforms for posterity.
Actually Drawing the Model
Last, but not least, you draw the model.
First you pick the shader program you want to use.
ShaderProgram.prototype.use = function () { this.gl.useProgram(this.program) }
Then you send all the camera related uniforms to the GPU. These uniforms change only once per camera change or movement.
Transformation.prototype.sendToGpu = function (gl, uniform, transpose) { gl.uniformMatrix4fv(uniform, transpose || false, new Float32Array(this.fields)) } Camera.prototype.use = function (shaderProgram) { this.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection) this.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view) }
Finally, you take the transformations and VBOs and assign them to uniforms and attributes, respectively. Since this has to be done to each VBO, you can create its data binding as a method.
VBO.prototype.bindToAttribute = function (attribute) { var gl = this.gl // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, this.data) // Enable this attribute in the shader gl.enableVertexAttribArray(attribute) // Define format of the attribute array. Must match parameters in shader gl.vertexAttribPointer(attribute, this.size, gl.FLOAT, false, 0, 0) }
Then you assign an array of three floats to the uniform. Each uniform type has a different signature, so documentation and more documentation are your friends here. Finally, you draw the triangle array on the screen. You tell the drawing call drawArrays()
from which vertex to start, and how many vertices to draw. The first parameter passed tells WebGL how it shall interpret the array of vertices. Using TRIANGLES
takes three by three vertices and draws a triangle for each triplet. Using POINTS
would just draw a point for each passed vertex. There are many more options, but there is no need to discover everything at once. Below is the code for drawing an object:
Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) }
The renderer needs to be extended a bit to accommodate all the extra elements that need to be handled. It should be possible to attach a shader program, and to render an array of objects based on the current camera position.
Renderer.prototype.setShader = function (shader) { this.shader = shader } Renderer.prototype.render = function (camera, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }
We can combine all the elements that we have to finally draw something on the screen:
var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Geometry.loadOBJ('/assets/sphere.obj').then(function (data) { objects.push(new Mesh(gl, data)) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) loop() function loop () { renderer.render(camera, objects) requestAnimationFrame(loop) }
This looks a bit random, but you can see the different patches of the sphere, based on where they are on the UV map. You can change the shader to paint the object brown. Just set the color for each pixel to be the RGBA for brown:
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); gl_FragColor = vec4(brown, 1.); }
It doesn't look very convincing. It looks like the scene needs some shading effects.
Adding Light
Lights and shadows are the tools that allow us to perceive the shape of objects. Lights come in many shapes and sizes: spotlights that shine in one cone, light bulbs that spread light in all directions, and most interestingly, the sun, which is so far away that all the light it shines on us radiates, for all intents and purposes, in the same direction.
Sunlight sounds like it's the simplest to implement, since all you need to provide is the direction in which all rays spread. For each pixel that you draw on the screen, you check the angle under which the light hits the object. This is where the surface normals come in.
You can see all the light rays flowing in the same direction, and hitting the surface under different angles, which are based on the angle between the light ray and the surface normal. The more they coincide, the stronger the light is.
If you perform a dot product between the normalized vectors for the light ray and the surface normal, you will get -1 if the ray hits the surface perfectly perpendicularly, 0 if the ray is parallel to the surface, and 1 if it illuminates it from the opposite side. So anything between 0 and 1 should add no light, while numbers between 0 and -1 should gradually increase the amount of light hitting the object. You can test this by adding a fixed light in the shader code.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); gl_FragColor = vec4(brown * lightness, 1.); }
Colocamos o sol para brilhar na direção para frente-esquerda-baixo. Você pode ver como o sombreamento é suave, mesmo que o modelo seja muito irregular. Você também pode notar o quão escuro é o lado inferior esquerdo. Podemos adicionar um nível de luz ambiente, o que tornará a área na sombra mais brilhante.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); float ambientLight = 0.3; lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }
Você pode obter esse mesmo efeito introduzindo uma classe de luz, que armazena a direção da luz e a intensidade da luz ambiente. Em seguida, você pode alterar o sombreador de fragmento para acomodar essa adição.
Agora o shader se torna:
#ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }
Então você pode definir a luz:
function Light () { this.lightDirection = new Vector3(-1, -1, -1) this.ambientLight = 0.3 } Light.prototype.use = function (shaderProgram) { var dir = this.lightDirection var gl = shaderProgram.gl gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z) gl.uniform1f(shaderProgram.ambientLight, this.ambientLight) }
Na classe do programa shader, adicione os uniformes necessários:
this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')
No programa, adicione uma chamada para a nova luz no renderizador:
Renderer.prototype.render = function (camera, light, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() light.use(shader) camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }
O loop mudará um pouco:
var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }
Se você fez tudo certo, a imagem renderizada deve ser a mesma da última imagem.
Um passo final a considerar seria adicionar uma textura real ao nosso modelo. Vamos fazer isso agora.
Adicionando texturas
O HTML5 tem um ótimo suporte para carregar imagens, então não há necessidade de fazer análises malucas de imagens. As imagens são passadas para o GLSL como sampler2D
, informando ao sombreador qual das texturas vinculadas deve ser amostrada. Há um número limitado de texturas que podem ser vinculadas, e o limite é baseado no hardware usado. Um sampler2D
pode ser consultado por cores em determinadas posições. É aqui que entram as coordenadas UV. Aqui está um exemplo em que substituímos o marrom por cores amostradas.
#ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; uniform sampler2D diffuse; varying vec3 vNormal; varying vec2 vUv; void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }
O novo uniforme deve ser adicionado à listagem no programa shader:
this.diffuse = gl.getUniformLocation(program, 'diffuse')
Finalmente, implementaremos o carregamento de textura. Como dito anteriormente, o HTML5 oferece facilidades para carregar imagens. Tudo o que precisamos fazer é enviar a imagem para a GPU:
function Texture (gl, image) { var texture = gl.createTexture() // Set the newly created texture context as active texture gl.bindTexture(gl.TEXTURE_2D, texture) // Set texture parameters, and pass the image that the texture is based on gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) // Set filtering methods // Very often shaders will query the texture value between pixels, // and this is instructing how that value shall be calculated gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) this.data = texture this.gl = gl } Texture.prototype.use = function (uniform, binding) { binding = Number(binding) || 0 var gl = this.gl // We can bind multiple textures, and here we pick which of the bindings // we're setting right now gl.activeTexture(gl['TEXTURE' + binding]) // After picking the binding, we set the texture gl.bindTexture(gl.TEXTURE_2D, this.data) // Finally, we pass to the uniform the binding ID we've used gl.uniform1i(uniform, binding) // The previous 3 lines are equivalent to: // texture[i] = this.data // uniform = i } Texture.load = function (gl, url) { return new Promise(function (resolve) { var image = new Image() image.onload = function () { resolve(new Texture(gl, image)) } image.src = url }) }
O processo não é muito diferente do processo usado para carregar e vincular VBOs. A principal diferença é que não estamos mais ligando a um atributo, mas ligando o índice da textura a um inteiro uniforme. O tipo sampler2D
nada mais é do que um deslocamento de ponteiro para uma textura.
Agora tudo o que precisa ser feito é estender a classe Mesh
, para lidar com texturas também:
function Mesh (gl, geometry, texture) { // added texture var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.texture = texture // new this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() } Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.texture.use(shaderProgram.diffuse, 0) // new this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) } Mesh.load = function (gl, modelUrl, textureUrl) { // new var geometry = Geometry.loadOBJ(modelUrl) var texture = Texture.load(gl, textureUrl) return Promise.all([geometry, texture]).then(function (params) { return new Mesh(gl, params[0], params[1]) }) }
E o script principal final ficaria assim:
var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Mesh.load(gl, '/assets/sphere.obj', '/assets/diffuse.png') .then(function (mesh) { objects.push(mesh) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }
Até a animação é fácil neste momento. Se você queria que a câmera girasse em torno do nosso objeto, você pode fazer isso apenas adicionando uma linha de código:
function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) }
Sinta-se à vontade para brincar com os shaders. Adicionar uma linha de código transformará essa iluminação realista em algo caricatural.
void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = lightness > 0.1 ? 1. : 0.; // new lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }
É tão simples quanto dizer à iluminação para ir aos extremos com base em se ela cruzou um limite definido.
Para onde ir em seguida
Existem muitas fontes de informação para aprender todos os truques e complexidades do WebGL. E a melhor parte é que se você não encontrar uma resposta relacionada ao WebGL, você pode procurá-la no OpenGL, já que o WebGL é basicamente baseado em um subconjunto do OpenGL, com alguns nomes sendo alterados.
Em nenhuma ordem específica, aqui estão algumas ótimas fontes para informações mais detalhadas, tanto para WebGL quanto para OpenGL.
- Fundamentos do WebGL
- Aprendendo WebGL
- Um tutorial OpenGL muito detalhado orientando você através de todos os princípios fundamentais descritos aqui, de uma forma muito lenta e detalhada.
- E há muitos, muitos outros sites dedicados a ensinar a você os princípios da computação gráfica.
- Documentação MDN para WebGL
- Especificação Khronos WebGL 1.0 para se você estiver interessado em entender os detalhes mais técnicos de como a API WebGL deve funcionar em todos os casos extremos.