WebGL mínimo em 75 linhas de código

O OpenGL moderno, e mais amplamente o WebGL, é muito diferente do OpenGL mais antigo que estudei no passado. Eu entendo como funciona a rasterização, então estou bastante familiarizado com os conceitos. No entanto, todos os tutoriais que li ofereciam abstrações e funções auxiliares que tornavam mais difícil entender quais partes pertencem às próprias APIs OpenGL.



Para esclarecer, abstrações como divisão de dados de posição e funcionalidade de renderização em classes separadas são importantes em aplicativos do mundo real. No entanto, essas abstrações espalham o código em diferentes áreas e adicionam redundância devido ao boilerplate e à transferência de dados entre unidades lógicas. Acho mais conveniente estudar um tópico em um fluxo linear de código, no qual cada linha está diretamente relacionada a esse tópico.



Primeiramente, preciso agradecer ao criador do tutorial que usei . Tomando isso como base, eu me livrei de todas as abstrações até chegar ao "programa mínimo viável". Espero que ajude você a começar a usar o OpenGL moderno. Aqui está o que faremos:





Triângulo equilateral, verde na parte superior, preto na parte inferior esquerda e vermelho na parte inferior direita, com as cores interpoladas entre os pontos. Uma versão um pouco mais brilhante do triângulo preto [ tradução em Habré].



Inicialização



No WebGL, precisamos canvasdesenhar. Claro, você definitivamente precisará adicionar todos os padrões, estilos, etc. de HTML usuais, mas a tela é a coisa mais importante. Depois que o DOM for carregado, podemos acessar a tela usando Javascript.



<canvas id="container" width="500" height="500"></canvas>

<script>
  document.addEventListener('DOMContentLoaded', () => {
    // All the Javascript code below goes here
  });
</script>


Acessando a tela, podemos obter o contexto de renderização WebGL e inicializar sua cor clara. As cores no mundo OpenGL são armazenadas como RGBA e cada componente tem um valor de 0a 1. A cor clara é a cor usada para desenhar a tela no início de cada quadro, redesenhando a cena.



const canvas = document.getElementById('container');
const gl = canvas.getContext('webgl');

gl.clearColor(1, 1, 1, 1);


Em programas reais, a inicialização pode e deve ser mais detalhada. Em particular, deve ser feita menção à inclusão de um buffer de profundidade que permite classificar a geometria com base nas coordenadas Z. Não faremos isso para um programa simples que consiste em apenas um triângulo.



Compilando shaders



Em sua essência, OpenGL é uma estrutura de rasterização onde temos que decidir como implementar tudo que não seja a rasterização. Portanto, pelo menos dois estágios de código devem ser executados na GPU:



  1. Um sombreador de vértice que processa todos os dados de entrada e produz uma posição 3D (na verdade, uma posição 4D em coordenadas uniformes ) para cada entrada.
  2. Um shader de fragmento que processa cada pixel na tela, reproduzindo a cor com a qual o pixel deve ser pintado.


Entre esses dois estágios, o OpenGL obtém a geometria do sombreador de vértice e determina quais pixels da tela são cobertos por essa geometria. Este é o estágio de rasterização.



Ambos os sombreadores são geralmente escritos em GLSL (OpenGL Shading Language), que é então compilado em código de máquina para a GPU. O código de máquina é então passado para a GPU para que possa ser executado durante o processo de renderização. Não entrarei em GLSL em detalhes porque desejo apenas mostrar o básico, mas a linguagem é próxima o suficiente de C para ser familiar à maioria dos programadores.



Primeiro, compilamos e passamos o sombreador de vértice para a GPU. No fragmento mostrado abaixo, o código-fonte do sombreador é armazenado como uma string, mas pode ser carregado de outros lugares. Finalmente, a string é passada para a API WebGL.



const sourceV = `
  attribute vec3 position;
  varying vec4 color;

  void main() {
    gl_Position = vec4(position, 1);
    color = gl_Position * 0.5 + 0.5;
  }
`;

const shaderV = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(shaderV, sourceV);
gl.compileShader(shaderV);

if (!gl.getShaderParameter(shaderV, gl.COMPILE_STATUS)) {
  console.error(gl.getShaderInfoLog(shaderV));
  throw new Error('Failed to compile vertex shader');
}


Vale a pena explicar algumas das variáveis ​​no código GLSL aqui:



  1. (attribute) position. , , .
  2. Varying color. ( ) . .
  3. gl_Position. , , varying-. , ,


Também existe um tipo de variável uniforme , que é uma constante em todas as chamadas de sombreador de vértice. Esses uniformes são usados ​​para propriedades como uma matriz de transformação, que será constante para todos os vértices de um elemento geométrico.



Em seguida, fazemos o mesmo com o sombreador de fragmento - compilamos e transferimos para a GPU. Observe que a variável colordo sombreador de vértice agora é lida pelo sombreador de fragmento.



const sourceF = `
  precision mediump float;
  varying vec4 color;

  void main() {
    gl_FragColor = color;
  }
`;

const shaderF = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(shaderF, sourceF);
gl.compileShader(shaderF);

if (!gl.getShaderParameter(shaderF, gl.COMPILE_STATUS)) {
  console.error(gl.getShaderInfoLog(shaderF));
  throw new Error('Failed to compile fragment shader');
}


Além disso, os sombreadores de vértice e de fragmento estão vinculados a um programa OpenGL.



const program = gl.createProgram();
gl.attachShader(program, shaderV);
gl.attachShader(program, shaderF);
gl.linkProgram(program);

if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
  console.error(gl.getProgramInfoLog(program));
  throw new Error('Failed to link program');
}

gl.useProgram(program);


Dizemos à GPU que queremos executar os shaders acima. Agora, tudo o que precisamos fazer é criar os dados de entrada e deixar que a GPU processe esses dados.



Enviando dados de entrada para GPU



Os dados recebidos serão armazenados na memória da GPU e processados ​​a partir daí. Em vez de fazer chamadas de desenho separadas para cada parte dos dados de entrada, que transferem os dados correspondentes um pedaço de cada vez, todos os dados de entrada são transferidos em sua totalidade para e lidos da GPU. (O OpenGL antigo transmitia dados em elementos individuais, o que diminuía o desempenho.) O



OpenGL fornece uma abstração chamada Vertex Buffer Object (VBO). Ainda estou descobrindo como funciona, mas vamos acabar fazendo o seguinte para usá-lo:



  1. Armazene a sequência de dados na memória da unidade central de processamento (CPU).
  2. Transferência de bytes na memória GPU através de um tampão original criado com gl.createBuffer()e pontos de ancoragem gl.ARRAY_BUFFER .


Para cada variável de dados de entrada (atributo) no sombreador de vértice, teremos um VBO, embora seja possível usar um VBO para vários elementos dos dados de entrada.



const positionsData = new Float32Array([
  -0.75, -0.65, -1,
   0.75, -0.65, -1,
   0   ,  0.65, -1,
]);

const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, positionsData, gl.STATIC_DRAW);


Normalmente definimos a geometria com quaisquer coordenadas que nosso aplicativo entende e, em seguida, usamos um conjunto de transformações no sombreador de vértice para mapeá-las no espaço de clipe OpenGL. Não vou entrar em detalhes sobre o espaço de truncamento (ele está associado a coordenadas uniformes), enquanto você só precisa saber que X e Y mudam no intervalo de -1 a +1. Como o sombreador de vértice simplesmente passa a entrada como está, podemos definir nossas coordenadas diretamente no espaço de recorte.



Em seguida, também vincularemos o buffer a uma das variáveis ​​no sombreador de vértice. No código, fazemos o seguinte:



  1. Obtemos o descritor positionda variável do programa criado acima.
  2. Instruímos o OpenGL a ler dados do ponto de ancoragem gl.ARRAY_BUFFERem grupos de 3 com determinados parâmetros, por exemplo, com um deslocamento e uma distância igual a 0.




const attribute = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(attribute);
gl.vertexAttribPointer(attribute, 3, gl.FLOAT, false, 0, 0);


É importante notar que podemos criar um VBO dessa forma e vinculá-lo a um atributo de sombreador de vértice porque executamos essas funções uma após a outra. Se fossemos separar as duas funções (por exemplo, criar todos os VBOs em uma passagem e, em seguida, vinculá-los a atributos separados), então, antes de mapear cada VBO para o atributo correspondente, precisaríamos chamar todas as vezes gl.bindBuffer(...).



Renderização!



Finalmente, quando todos os dados na memória da GPU estiverem preparados conforme necessário, podemos dizer ao OpenGL para limpar a tela e executar o programa para processar os arrays que preparamos. Como parte da etapa de rasterização (determinar quais pixels são cobertos pelos vértices), dizemos ao OpenGL para tratar vértices em grupos de 3 como triângulos.



gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);


Com esse esquema linear, o programa será executado de uma vez. Em qualquer aplicação prática, armazenaríamos os dados de forma estruturada, enviaríamos para a GPU à medida que fossem alterados e os renderizaríamos em cada quadro.






Para resumir, a seguir está um diagrama com um conjunto mínimo de conceitos que são necessários para exibir nosso primeiro triângulo na tela. Mas mesmo esse esquema é bastante simplificado, por isso é melhor escrever as 75 linhas de código apresentadas neste artigo e estudá-las.





A sequência final altamente simplificada de etapas necessárias para renderizar um triângulo



Para mim, a parte mais difícil de aprender OpenGL foi a quantidade de clichês necessária para exibir a imagem mais simples na tela. Visto que a estrutura de rasterização exige que forneçamos funcionalidade de renderização 3D e a comunicação com a GPU é muito grande, muitos conceitos devem ser estudados diretamente. Esperamos que este artigo tenha mostrado o básico de uma maneira mais simples do que aparece em outros tutoriais.



Veja também:








Veja também:






All Articles