Renderização de gráficos 3D com OpenGL

1. Introdução


Renderizar gráficos 3D não é uma tarefa fácil, mas extremamente interessante e emocionante. Este artigo é para aqueles que estão começando a se familiarizar com o OpenGL ou para aqueles que estão interessados ​​em como os pipelines gráficos funcionam e o que são. Este artigo não fornecerá instruções exatas sobre como criar um contexto e uma janela OpenGL ou como escrever seu primeiro aplicativo de janela OpenGL. Isso se deve aos recursos de cada linguagem de programação e à escolha de uma biblioteca ou estrutura para trabalhar com o OpenGL (usarei C ++ e GLFW ), especialmente porque é fácil encontrar um tutorial na rede para a linguagem em que você está interessado. Todos os exemplos dados neste artigo funcionarão em outros idiomas com semânticas de comandos ligeiramente alteradas, por que é assim, falarei um pouco mais adiante.

O que é o OpenGL?


OpenGL é uma especificação que define uma interface de software independente de plataforma para escrever aplicativos usando gráficos de computador bidimensionais e tridimensionais. O OpenGL não é uma implementação, mas descreve apenas os conjuntos de instruções que devem ser implementadas, ou seja, é uma API.


Cada versão do OpenGL tem sua própria especificação, trabalharemos da versão 3.3 para a versão 4.6, porque todas as inovações da versão 3.3 afetam aspectos que são pouco significativos para nós. Antes de começar a escrever seu primeiro aplicativo OpenGL, eu recomendo que você descubra quais versões o seu driver suporta (você pode fazer isso no site do fornecedor da sua placa de vídeo) e atualize o driver para a versão mais recente.


Dispositivo OpenGL


O OpenGL pode ser comparado a uma grande máquina de estado, que possui muitos estados e funções para alterá-los. O estado OpenGL refere-se basicamente ao contexto OpenGL. Enquanto trabalhamos com o OpenGL, passaremos por várias funções de mudança de estado que mudarão o contexto e executaremos ações dependendo do estado atual do OpenGL.


Por exemplo, se dermos ao OpenGL o comando para usar linhas em vez de triângulos antes da renderização, o OpenGL usará linhas para todos os desenhos subseqüentes até alterarmos essa opção ou alterar o contexto.


Objetos no OpenGL


As bibliotecas OpenGL são escritas em C e possuem inúmeras APIs para diferentes idiomas, mas, no entanto, são bibliotecas em C. Muitas construções de C não são traduzidas para linguagens de alto nível; portanto, o OpenGL foi desenvolvido usando um grande número de abstrações, uma dessas abstrações são objetos.


Um objeto no OpenGL é um conjunto de opções que determina seu estado. Qualquer objeto no OpenGL pode ser descrito por seu ID e pelo conjunto de opções pelas quais é responsável. Obviamente, cada tipo de objeto tem suas próprias opções e uma tentativa de configurar opções inexistentes para o objeto levará a um erro. Aí reside a inconveniência de usar o OpenGL: um conjunto de opções é descrito por uma estrutura semelhante em C cujo identificador geralmente é um número, o que não permite que o programador encontre um erro no estágio de compilação, porque código incorreto e correto são semanticamente indistinguíveis.


glGenObject(&objectId); glBindObject(GL_TAGRGET, objectId); glSetObjectOption(GL_TARGET, GL_CORRECT_OPTION, correct_option); //Ok glSetObjectOption(GL_TARGET, GL_WRONG_OPTION, wrong_option); //  , ..    

Você encontrará esse código com muita frequência; portanto, quando você se acostumar com a configuração de uma máquina de estado, isso se tornará muito mais fácil. Este código mostra apenas um exemplo de como o OpenGL funciona. Posteriormente, exemplos reais serão apresentados.


Mas há vantagens. A principal característica desses objetos é que podemos declarar muitos objetos em nosso aplicativo, definir suas opções e sempre que iniciarmos operações usando o estado OpenGL, podemos simplesmente vincular o objeto às nossas configurações preferidas. Por exemplo, podem ser objetos com dados do modelo 3D ou algo que queremos desenhar nesse modelo. A posse de vários objetos facilita a alternância entre eles durante o processo de renderização. Com essa abordagem, podemos configurar muitos objetos necessários para renderizar e usar seus estados sem perder um tempo valioso entre os quadros.


Para começar a trabalhar com o OpenGL, você precisa se familiarizar com vários objetos básicos sem os quais não podemos exibir nada. Usando esses objetos como exemplo, entenderemos como vincular dados e instruções executáveis ​​no OpenGL.


Objetos base: Shaders e programas de shader. =


Shader é um pequeno programa executado em um acelerador de gráficos (GPU) em um determinado ponto do pipeline de gráficos. Se considerarmos os shaders abstratamente, podemos dizer que esses são os estágios do pipeline de gráficos, que:

  1. Saiba onde obter dados para processamento.
  2. Saiba como processar dados de entrada.
  3. Eles sabem onde gravar dados para processamento adicional.

Mas como é o pipeline de gráficos? Muito simples, assim:



Até agora, neste esquema, estamos interessados ​​apenas na vertical principal, que começa com a especificação de vértice e termina com o buffer de quadro. Como mencionado anteriormente, cada shader possui seus próprios parâmetros de entrada e saída, que diferem no tipo e no número de parâmetros.
Descrevemos brevemente cada estágio do pipeline para entender o que ele faz:

  1. Vertex Shader - necessário para processar dados de coordenadas 3D e todos os outros parâmetros de entrada. Na maioria das vezes, o sombreador de vértice calcula a posição do vértice em relação à tela, calcula as normais (se necessário) e gera dados de entrada para outros sombreadores.
  2. Shader de mosaico e shader de controle de mosaico - esses dois shaders são responsáveis ​​por detalhar as primitivas provenientes do shader de vértice e preparar os dados para processamento no shader geométrico. É difícil descrever do que esses dois shaders são capazes em duas frases, mas para os leitores terem uma pequena ideia, darei algumas imagens com baixos e altos níveis de sobreposição:

    Aconselho você a ler este artigo se quiser saber mais sobre mosaico. Nesta série de artigos, abordaremos o mosaico, mas não será em breve.
  3. Shader geométrico - é responsável pela formação de primitivas geométricas a partir da saída do shader de mosaico. Usando o sombreador geométrico, você pode criar novas primitivas a partir das primitivas básicas do OpenGL (GL_LINES, GL_POINT, GL_TRIANGLES, etc), por exemplo, usando o sombreador geométrico, é possível criar um efeito de partícula descrevendo a partícula apenas por cor, centro de cluster, raio e densidade.
  4. O shader de rasterização é um dos shaders não programáveis. Falando em um idioma compreensível, ele traduz todas as primitivas gráficas de saída em fragmentos (pixels), ou seja, determina sua posição na tela.
  5. O sombreador de fragmento é o último estágio do pipeline de gráficos. O sombreador de fragmento calcula a cor do fragmento (pixel) que será definido no buffer de quadro atual. Na maioria das vezes, no sombreador do fragmento, o sombreamento e a iluminação do fragmento, o mapeamento de texturas e os mapas normais são calculados - todas essas técnicas permitem obter resultados incrivelmente bonitos.

Os shaders do OpenGL são escritos em uma linguagem GLSL especial do tipo C, a partir da qual são compilados e vinculados a um programa de shader. Já nesta fase, parece que escrever um programa de sombreador é uma tarefa extremamente demorada, porque você precisa determinar as 5 etapas do pipeline gráfico e vinculá-las. Felizmente, não é assim: os shaders de mosaico e geometria são definidos no pipeline de gráficos por padrão, o que nos permite definir apenas dois shaders - o vértice e o fragmento (às vezes chamado de pixel shader). É melhor considerar esses dois shaders com um exemplo clássico:


Sombreador de vértice
 #version 450 layout (location = 0) in vec3 vertexCords; layout (location = 1) in vec3 color; out vec3 Color; void main(){ gl_Position = vec4(vertexCords,1.0f) ; Color = color; } 


Tonalizador de fragmentos
 #version 450 in vec3 Color; out vec4 out_fragColor; void main(){ out_fragColor = Color; } 


Exemplo de montagem de sombreador
 unsigned int vShader = glCreateShader(GL_SHADER_VERTEX); //    glShaderSource(vShader,&vShaderSource); //  glCompileShader(vShader); //  //        unsigned int shaderProgram = glCreateProgram(); glAttachShader(shaderProgram, vShader); //    glAttachShader(shaderProgram, fShader); //    glLinkProgram(shaderProgram); //  


Esses dois sombreadores simples não calculam nada, apenas passam os dados para o pipeline. Vamos prestar atenção em como os sombreadores de vértice e fragmento estão conectados: no sombreador de vértice, a variável Color é declarada na qual a cor será gravada após a execução da função principal, enquanto no sombreador a mesma variável exata com o qualificador in é declarada. como descrito anteriormente, o sombreador de fragmento recebe dados do vértice por meio de um simples empurrão dos dados pelo pipeline (mas, na verdade, não é tão simples).

Nota: Se você não declarar e inicializar uma variável do tipo vec4 no shader de fragmento, nada será exibido na tela.

Leitores atentos já notaram a declaração de variáveis ​​de entrada do tipo vec3 com qualificadores de layout estranhos no início do sombreador de vértices, é lógico supor que isso seja de entrada, mas de onde nós o obtemos?

Objetos base: buffers e matrizes de vértices


Acho que não vale a pena explicar o que são objetos de buffer, é melhor considerarmos como criar e preencher um buffer no OpenGL.
 float vertices[] = { // // -0.8f, -0.8f, 0.0f, 1.0f, 0.0f, 0.0f, 0.8f, -0.8f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.8f, 0.0f, 0.0f, 0.0f, 1.0f }; unsigned int VBO; //vertex buffer object glGenBuffers(1,&VBO); glBindBuffer(GL_SOME_BUFFER_TARGET,VBO); glBufferData(GL_SOME_BUFFER_TARGET, sizeof(vertices), vertices, GL_STATIC_DRAW); 

Não há nada de difícil nisso: anexamos o buffer gerado ao destino desejado (mais tarde descobriremos qual) e carregamos os dados indicando seu tamanho e tipo de uso.


GL_STATIC_DRAW - os dados no buffer não serão alterados.
GL_DYNAMIC_DRAW - os dados no buffer serão alterados, mas não com frequência.
GL_STREAM_DRAW - os dados no buffer serão alterados a cada chamada de empate.

É ótimo, agora nossos dados estão localizados na memória da GPU, o programa de sombreador é compilado e vinculado, mas há uma ressalva: como o programa sabe onde obter os dados de entrada para o sombreador de vértice? Fizemos o download dos dados, mas não indicamos de onde o programa shader o obteria. Esse problema é resolvido por um tipo separado de objetos OpenGL - matrizes de vértices.


imagem
A imagem é retirada deste tutorial .

Assim como os buffers, as matrizes de vértices são melhor visualizadas usando seu exemplo de configuração.


  unsigned int VBO, VAO; glGenBuffers(1, &VBO); glGenBuffers(1, &EBO); glGenVertexArrays(1, &VAO); glBindVertexArray(VAO); //    glBindBuffer(GL_ARRAY_BUFFER, VBO); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); //     () glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), nullptr); //     () glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), reinterpret_cast<void*> (sizeof(float) * 3)); glBindBuffer(GL_ARRAY_BUFFER, 0); glBindVertexArray(0); 

Criar matrizes de vértices não é diferente de criar outros objetos OpenGL; o mais interessante começa após a linha:

 glBindVertexArray(VAO); 
Uma matriz de vértices (VAO) lembra todas as ligações e configurações executadas com ela, incluindo a ligação de objetos de buffer para descarregamento de dados. Neste exemplo, existe apenas um objeto, mas na prática pode haver vários. Depois disso, o atributo de vértice com um número específico é configurado:


  glBindBuffer(GL_ARRAY_BUFFER, VBO); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), nullptr); 

Onde conseguimos esse número? Lembre-se de qualificadores de layout para variáveis ​​de entrada do vertex shader? São eles que determinam a qual atributo de vértice a variável de entrada será vinculada. Agora repare brevemente os argumentos da função para que não haja perguntas desnecessárias:
  1. O número do atributo que queremos configurar.
  2. O número de itens que queremos levar. (Como a variável de entrada do vertex shader com layout = 0 é do tipo vec3, usamos 3 elementos do tipo float)
  3. Tipo de itens.
  4. É necessário normalizar os elementos, se for um vetor.
  5. O deslocamento para o próximo vértice (como temos as coordenadas e as cores localizadas seqüencialmente e cada uma tem o tipo vec3, passamos por 6 * sizeof (float) = 24 bytes).
  6. O último argumento mostra qual deslocamento usar para o primeiro vértice. (para coordenadas, este argumento é 0 bytes, para cores 12 bytes)

Agora estamos prontos para renderizar nossa primeira imagem


Lembre-se de vincular o VAO e o programa shader antes de chamar a renderização.
 { // your render loop glUseProgram(shaderProgram); glBindVertexArray(VAO); glDrawElements(GL_TRIANGLES,0,3); //        } 


Se você fez tudo certo, deve obter este resultado:



O resultado é impressionante, mas de onde veio o preenchimento de gradiente no triângulo, porque indicamos apenas três cores: vermelho, azul e verde para cada vértice individual? Essa é a mágica do shader de rasterização: o fato é que o valor de Cor que definimos no vértice não está entrando no shader de fragmento. Nós transmitimos apenas três vértices, mas muito mais fragmentos são gerados (existem exatamente tantos fragmentos quanto pixels preenchidos). Portanto, para cada fragmento, a média dos três valores de cores é obtida, dependendo da proximidade com cada um dos vértices. Isso é muito bem visto nos cantos do triângulo, onde os fragmentos assumem o valor da cor que indicamos nos dados do vértice.

Olhando para o futuro, direi que as coordenadas da textura são transmitidas da mesma maneira, o que facilita a sobreposição de texturas em nossas primitivas.

Acho que vale a pena terminar este artigo, o mais difícil está para trás, mas o mais interessante está apenas começando. Se você tiver alguma dúvida ou viu um erro no artigo, escreva sobre isso nos comentários, ficarei muito agradecido.


No próximo artigo, examinaremos as transformações, aprenderemos sobre variáveis ​​unifrom e aprenderemos a impor texturas a primitivas.

Source: https://habr.com/ru/post/pt467599/


All Articles