6.4 Lendo um modelo 3D

Nos capítulos anteriores vimos como usar o pipeline gráfico do OpenGL para renderizar primitivas formadas a partir de arranjos ordenados de vértices. Em particular, conseguimos representar diferentes formas no plano através de malhas de triângulos. Tomemos, como exemplo, o polígono da figura 6.22:

Um polígono de seis lados.

Figura 6.22: Um polígono de seis lados.

O polígono pode ser convertido em uma malha de triângulos através de uma triangulação. Uma possível triangulação é mostrada na figura 6.23:

Triangulação de um polígono de seis lados.

Figura 6.23: Triangulação de um polígono de seis lados.

Os triângulos \(T_1\) a \(T_4\) podem ser renderizados com glDrawArrays(GL_TRIANGLES, ...) usando um VBO formado por um arranjo de posições de vértices:

std::array positions{// Triângulo T_1
                     glm::vec2{-3, 3},
                     glm::vec2{-4,-2},  
                     glm::vec2{ 1,-4},
                     // Triângulo T_2
                     glm::vec2{-3, 3},  
                     glm::vec2{ 1,-4},  
                     glm::vec2{ 4,-1},
                     // Triângulo T_3
                     glm::vec2{-3, 3},
                     glm::vec2{ 4,-1},  
                     glm::vec2{ 4, 2},
                     // Triângulo T_4
                     glm::vec2{-3, 3},
                     glm::vec2{ 4, 2},  
                     glm::vec2{ 1, 4}};
...

glDrawArrays(GL_TRIANGLES, 0, 12);

Uma desvantagem dessa representação é que há repetição de coordenadas. Por exemplo, a coordenada \((-3,3)\) é repetida quatro vezes, uma para cada triângulo. Felizmente, como a triangulação forma um leque, podemos usar GL_TRIANGLE_FAN com um arranjo mais compacto:

std::array positions{glm::vec2{-3, 3},
                     glm::vec2{-4,-2},  
                     glm::vec2{ 1,-4},
                     glm::vec2{ 4,-1},
                     glm::vec2{ 4, 2},
                     glm::vec2{ 1, 4}};
...

glDrawArrays(GL_TRIANGLE_FAN, 0, 6);

Outra possibilidade é usar geometria indexada. A figura 6.24 mostra uma possível indexação dos vértices do polígono da figura 6.23. Os índices são numerados de 0 a 5:

Geometria indexada.

Figura 6.24: Geometria indexada.

Na geometria indexada, um arranjo ordenado de posições de vértices é armazenado no VBO, e um arranjo de números inteiros que representam os índices para esses vértices é armazenado no EBO:

std::array positions{glm::vec2{-3, 3},  // Vértice 0
                     glm::vec2{-4,-2},  // Vértice 1
                     glm::vec2{ 1,-4},  // Vértice 2
                     glm::vec2{ 4,-1},  // Vértice 3
                     glm::vec2{ 4, 2},  // Vértice 4
                     glm::vec2{ 1, 4}}; // Vértice 5

std::array indices{0, 1, 2,  // Triângulo T_1
                   0, 2, 3,  // Triângulo T_2
                   0, 3, 4,  // Triângulo T_3
                   0, 4, 5}; // Triângulo T_4
...

glDrawElements(GL_TRIANGLES, 12, GL_UNSIGNED_INT, nullptr);

A geometria indexada é o formato mais utilizado para descrever malhas de triângulos. É, por exemplo, o formato utilizado nos modelos OBJ que utilizaremos nas atividades da disciplina.

Até agora, só utilizamos formas no plano. Entretanto, não há qualquer limitação no OpenGL que nos impeça de representar geometria no espaço. Basta especificarmos a posição dos vértices como coordenadas \((x,y,z)\) do espaço euclidiano usando tuplas de três elementos com glm::vec3.

Nesta seção descreveremos um passo a passo de construção de uma aplicação de leitura de modelos 3D no formato OBJ. Neste formato, os dados são gravados em formato texto e são fáceis de serem lidos. Veja a seguir o conteúdo de um arquivo OBJ contendo a definição de um cubo unitário centralizado na origem. Observe que há inicialmente a definição dos 8 vértices do cubo, e então a definição dos índices das 12 faces. Neste arquivo, cada face é um triângulo (o cubo tem seis 6 lados e cada lado é formado por 2 faces coplanares).

# object Box

v  -0.5000 -0.5000  0.5000
v  -0.5000 -0.5000 -0.5000
v   0.5000 -0.5000 -0.5000
v   0.5000 -0.5000  0.5000
v  -0.5000  0.5000  0.5000
v   0.5000  0.5000  0.5000
v   0.5000  0.5000 -0.5000
v  -0.5000  0.5000 -0.5000
# 8 vertices

o Box
g Box
f 1 3 2 
f 3 1 4 
f 5 7 6 
f 7 5 8 
f 1 6 4 
f 6 1 5 
f 4 7 3 
f 7 4 6 
f 3 8 2 
f 8 3 7 
f 2 5 1 
f 5 2 8 
# 12 faces

Antes de iniciarmos o passo a passo de leitura do modelo 3D, veremos o conceito de orientação de triângulos e como isso pode afetar a renderização. Se os triângulos de um modelo 3D estiverem com orientação diferente do esperado, o modelo pode ser renderizado de forma incorreta.

Orientação e face culling

A direção do vetor normal pode ser utilizada para definir a orientação de uma superfície, isto é, qual é o “lado da frente” da superfície. Entretanto, reservaremos o uso dos vetores normais apenas para o cálculo da iluminação de superfícies. Há uma forma mais simples de determinar a orientação de uma superfície quando polígonos são utilizados, que é o caso do pipeline do OpenGL.

No OpenGL, a orientação de um triângulo é definida pela ordem em que seus vértices estão ordenados no arranjo de vértices quando o triângulo é visto de frente. Só há duas orientações possíveis (figura 6.25):

  • Sentido horário (clockwise ou CW): os vértices estão orientados no sentido horário quando o triângulo é visto de frente;

  • Sentido anti-horário (counterclockwise ou CCW): os vértices estão orientados no sentido anti-horário quando o triângulo é visto de frente.

Sentidos de orientação de um triângulo.

Figura 6.25: Sentidos de orientação de um triângulo.

Volte a observar o exemplo anterior de geometria indexada (figura 6.24) e veja como os índices de cada triângulo \(T_1\) a \(T_4\) estão ordenados no sentido anti-horário (CCW).

Por padrão, o OpenGL considera que o lado da frente é o lado orientado no sentido anti-horário (CCW). Isso pode ser modificado com a função glFrontFace, usando o argumento GL_CW ou GL_CCW, como a seguir:

  • glFrontFace(GL_CW): para indicar que o lado da frente tem vértices no sentido horário;

  • glFrontFace(GL_CCW): para indicar que o lado da frente tem vértices no sentido anti-horário (padrão).

    CCW é a orientação padrão do OpenGL porque essa é também a orientação padrão utilizada na matemática. Por exemplo, os ângulos medidos no plano cartesiano são medidos no sentido anti-horário e seguem a regra da mão direita (figura 6.26):

    • \(0\) radianos (\(0^{\circ}\)) aponta para o eixo \(x\) positivo (para a direita);
    • \(\frac{\pi}{2}\) radianos (\(90^{\circ}\)) aponta para o eixo \(y\) positivo (para cima);
    • \(\pi\) radianos (\(180^{\circ}\)) aponta para o eixo \(x\) negativo (para a esquerda).
Direção convencional dos ângulos em um eixo de rotação ([fonte](https://en.wikipedia.org/wiki/File:Right-hand_grip_rule.svg)).

Figura 6.26: Direção convencional dos ângulos em um eixo de rotação (fonte).

Há algumas vantagens em saber qual é o lado da frente de um triângulo:

  • Podemos desenhar cada lado com uma cor ou efeito diferente. No fragment shader, a variável embutida gl_FrontFacing é uma variável booleana que é true sempre que o fragmento pertencer a um triângulo virado de frente, e false caso contrário.
  • Podemos fazer uso da técnica de face culling, também chamada de back-face culling.

Face culling

Face culling é uma técnica que consiste em descartar todas as faces (triângulos no OpenGL) que não estão de frente para a câmera. O uso de face culling pode aumentar de forma considerável a eficiência da renderização, pois os triângulos podem ser removidos antes da rasterização.

Se a malha de triângulos formar um sólido opaco e fechado, então o face culling pode remover cerca de metade dos triângulos. Esse é o caso da malha de triângulos que aproxima uma esfera. Na figura 6.27, parte das faces da frente da malha foram deslocadas para revelar as faces voltadas para trás, em vermelho. Essas faces em vermelho podem ser removidas completamente se a esfera estiver fechada.

Faces voltadas para trás (em vermelho).

Figura 6.27: Faces voltadas para trás (em vermelho).

O descarte de primitivas usando face culling pode ser feito automaticamente pelo pipeline gráfico do OpenGL, após a etapa de recorte de primitivas, e imediatamente antes da rasterização. Podemos ativar o face culling através de glEnable(GL_CULL_FACE) e desativá-lo através de glDisable(GL_CULL_FACE).

Por padrão, o face culling está desativado.

A função glCullFace pode ser utilizada para especificar qual lado deve ser descartado quando o face culling estiver habilitado. Por exemplo:

  • glCullFace(GL_FRONT): para descartar os triângulos que estão de frente quando projetados no viewport;
  • glCullFace(GL_BACK): para descartar os triângulos que estão voltados para trás quando projetados no viewport (padrão).
  • glCullFace(GL_FRONT_AND_BACK): para descartar todos os triângulos, mas ainda renderizar pontos e segmentos.

Configuração inicial

A configuração inicial do nosso leitor de arquivos OBJ é semelhante a dos projetos anteriores.

  • No arquivo abcg/examples/CMakeLists.txt, inclua a linha:

    add_subdirectory(loadmodel)
  • Crie o subdiretório abcg/examples/loadmodel e o arquivo abcg/examples/loadmodel/CMakeLists.txt, que ficará assim:

    project(loadmodel)
    add_executable(${PROJECT_NAME} main.cpp openglwindow.cpp)
    enable_abcg(${PROJECT_NAME})
  • Crie os arquivos main.cpp, openglwindow.cpp e openglwindow.hpp.

  • Crie o subdiretório abcg/examples/loadmodel/assets. Dentro dele, crie os arquivos loadmodel.frag e loadmodel.vert.

    Baixe o arquivo bunny.zip e descompacte-o em assets. O conteúdo é o arquivo bunny.obj que contém o modelo 3D de um coelho: o Stanford Bunny (figura 6.28) processado e simplificado no MeshLab.

Renderização do "Stanford Bunny" ([fonte](https://commons.wikimedia.org/wiki/File:Computer_generated_render_of_the_%22Stanford_Bunny%22.jpg)).

Figura 6.28: Renderização do “Stanford Bunny” (fonte).

O conteúdo de abcg/examples/loadmodel ficará com a seguinte estrutura:

loadmodel/
│   CMakeLists.txt
│   main.cpp
│   openglwindow.hpp
│   openglwindow.cpp
│
└───assets/
    │   bunny.obj
    │   loadmodel.frag    
    └   loadmodel.vert

main.cpp

O conteúdo de main.cpp é bem similar ao dos projetos anteriores. Não há nada de realmente novo aqui:

#include <fmt/core.h>

#include "abcg.hpp"
#include "openglwindow.hpp"

int main(int argc, char **argv) {
  try {
    abcg::Application app(argc, argv);

    auto window{std::make_unique<OpenGLWindow>()};
    window->setOpenGLSettings({.samples = 4});
    window->setWindowSettings(
        {.width = 600, .height = 600, .title = "Load Model"});

    app.run(std::move(window));
  } catch (const abcg::Exception &exception) {
    fmt::print(stderr, "{}\n", exception.what());
    return -1;
  }
  return 0;
}

loadmodel.vert

O vertex shader ficará como a seguir:

#version 410 core

layout(location = 0) in vec3 inPosition;

uniform float angle;

void main() {
  float sinAngle = sin(angle);
  float cosAngle = cos(angle);

  gl_Position = vec4( inPosition.x * cosAngle + inPosition.z * sinAngle,
                      inPosition.y,
                      inPosition.z * cosAngle - inPosition.x * sinAngle, 1.0);
}

Só há um atributo de entrada, inPosition, que é a posição \((x,y,z)\) do vértice.

Observe que não há atributo de saída. A cor dos fragmentos será determinada unicamente no fragment shader.

A variável uniforme angle (linha 5) é um ângulo (em radianos) que será incrementado continuamente para produzir uma animação de rotação.

No código de main, gl_Position recebe inPosition transformado através de uma operação de rotação em torno do eixo \(y\) pelo ângulo angle. A transformação de inPosition (posição \(x,y,z\)) para gl_Position (posição \(x',y',z',1\)) é como segue:

\[ \begin{align} x' &= x \cos(\theta) + z \sin(\theta),\\ y' &= y,\\ z' &= z \cos(\theta) - x \sin(\theta). \end{align} \]

A derivação da transformação de rotação será vista no próximo capítulo. Veremos também que poderemos simplificar esse código através do uso de matrizes de transformação.

loadmodel.frag

O conteúdo do fragment shader ficará assim:

#version 410

out vec4 outColor;

void main() {
  float i = 1.0 - gl_FragCoord.z;

  if (gl_FrontFacing) {
    outColor = vec4(i, i, i, 1);
  } else {
    outColor = vec4(i, 0, 0, 1);
  }
}

Na linha 6, a variável i é um valor de intensidade de cor. Esse valor é calculado a partir da componente z da variável embutida gl_FragCoord.

gl_FragCoord é um vec4 que contém a posição do fragmento no espaço da janela. As componentes \(x\) e \(y\) são a posição do fragmento na janela, em pixels. A componente \(z\) é a “profundidade” do fragmento, que varia de 0 (mais perto) a 1 (mais distante). Lembre-se que esse é o valor \(z\) que estava no intervalo \([-1, 1]\) em coordenadas normalizadas do dispositivo (NDC) e que, após a rasterização, foi mapeado para \([0, 1]\) no espaço da janela (o mapeamento pode ser controlado com glDepthRange).

A cor de saída depende do valor da variável embutida gl_FrontFacing, que indica se o fragmento pertence a uma face de frente ou de trás. Se é true (frente), a cor de saída é um tom de cinza dado pelo valor de i. Caso contrário (trás), a cor de saída é um tom de vermelho.

O resultado será um modelo renderizado em tons de cinza (frente) ou vermelho (trás). Quanto maior for a profundidade do fragmento, menor será sua intensidade. Com isso conseguiremos distinguir melhor a forma e o volume do objeto.

openglwindow.hpp

A definição da classe OpenGLWindow ficará assim:

#ifndef OPENGLWINDOW_HPP_
#define OPENGLWINDOW_HPP_

#include <vector>

#include "abcg.hpp"

struct Vertex {
  glm::vec3 position;

  bool operator==(const Vertex& other) const {
    return position == other.position;
  }
};

class OpenGLWindow : public abcg::OpenGLWindow {
 protected:
  void initializeGL() override;
  void paintGL() override;
  void paintUI() override;
  void resizeGL(int width, int height) override;
  void terminateGL() override;

 private:
  GLuint m_VAO{};
  GLuint m_VBO{};
  GLuint m_EBO{};
  GLuint m_program{};

  int m_viewportWidth{};
  int m_viewportHeight{};

  float m_angle{};
  int m_verticesToDraw{};

  std::vector<Vertex> m_vertices;
  std::vector<GLuint> m_indices;

  void loadModelFromFile(std::string_view path);
  void standardize();
};

#endif

Primeiro, observe a estrutura Vertex definida nas linhas 8 a 14:

struct Vertex {
  glm::vec3 position;

  bool operator==(const Vertex& other) const {
    return position == other.position;
  }
};

Essa estrutura define os atributos que compõem um vértice. Há apenas uma posição \((x,y,z)\) e a definição de um operador de igualdade (==) que verifica se um dado vértice other é igual ao vértice atual.

As instâncias dessa estrutura serão os elementos de uma tabela hash implementada com std::unordered_map. Durante a leitura do modelo OBJ, a tabela hash será utilizada para verificar se há algum vértice com posição repetida (por isso o operador de igualdade). Através disso conseguiremos criar uma geometria indexada o mais compacta possível. Veremos mais sobre isso na implementação da função de leitura do modelo OBJ.

A variável m_angle (linha 33) é o ângulo de rotação que será enviado à variável uniforme do vertex shader.

A variável m_verticesToDraw (linha 34) é a quantidade de vértices do VBO que será processada pela função de renderização, glDrawElements. O valor de m_verticesToDraw será controlado por um slider da ImGui. Assim conseguiremos controlar quantos triângulos queremos renderizar.

Nas linhas 36 e 37, m_vertices e m_indices são os arranjos de vértices e índices lidos do arquivo OBJ. Esses são os dados que serão enviados ao VBO (m_VBO) e EBO (m_EBO).

O carregamento do arquivo OBJ será feito pela função OpenGLWindow::loadModelFromFile (linha 37).

A função OpenGLWindow::standardize (linha 40) será chamada após OpenGLWindow::loadModelFromFile e servirá para centralizar o modelo na origem e normalizar as coordenadas de todos os vértices no intervalo \([-1, 1]\).

openglwindow.cpp

O início de openglwindow.cpp começa como a seguir:

#include "openglwindow.hpp"

#include <fmt/core.h>
#include <imgui.h>
#include <tiny_obj_loader.h>

#include <cppitertools/itertools.hpp>
#include <glm/gtx/fast_trigonometry.hpp>
#include <glm/gtx/hash.hpp>
#include <unordered_map>

// Explicit specialization of std::hash for Vertex
namespace std {
template <>
struct hash<Vertex> {
  size_t operator()(Vertex const& vertex) const noexcept {
    const std::size_t h1{std::hash<glm::vec3>()(vertex.position)};
    return h1;
  }
};
}  // namespace std

Observe que incluímos tiny_obj_loader.h (linha 5). Esse é o cabeçalho da biblioteca TinyObjLoader que fará o parsing do conteúdo do arquivo OBJ. As funções de TinyObjLoader serão utilizadas em OpenGLWindow::loadModelFromFile.

Nas linhas 12 a 21 há uma especialização explícita de std::hash para a nossa estrutura Vertex. Isso é necessário para que possamos usar Vertex como chave de uma tabela hash (como std::unordered_map).

Geralmente, uma tabela hash utiliza tipos de dados mais simples como chave, como char, int e float. Internamente esses dados são convertidos para um valor de hashing do tipo std::size_t. Como queremos usar um Vertex, precisamos definir como o valor de hashing será gerado. Isso é feito através da sobrecarga do operador de chamada de função () na linha 16. O valor de hashing é o h1 da linha 17, gerado a partir da posição do vértice. Para isso funcionar, a biblioteca GLM implementa sua própria especialização de std::hash para glm::vec3 (definido no cabeçalho glm/gtx/hash.cpp). Na verdade, para este projeto poderíamos ter usado glm::vec3 diretamente no lugar de Vertex. Entretanto, em projetos futuros ampliaremos o número de atributos de Vertex e nossas chaves serão mais complexas. Essa é uma boa oportunidade para entendermos agora esse código, que só ficará maior nos próximos projetos.

Vamos agora à definição de OpenGLWindow::initializeOpenGL:

void OpenGLWindow::initializeGL() {
  abcg::glClearColor(0, 0, 0, 1);

  // Enable depth buffering
  abcg::glEnable(GL_DEPTH_TEST);

  // Create program
  m_program = createProgramFromFile(getAssetsPath() + "loadmodel.vert",
                                    getAssetsPath() + "loadmodel.frag");

  // Load model
  loadModelFromFile(getAssetsPath() + "bunny.obj");
  standardize();

  m_verticesToDraw = m_indices.size();

  // Generate VBO
  abcg::glGenBuffers(1, &m_VBO);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_VBO);
  abcg::glBufferData(GL_ARRAY_BUFFER, sizeof(m_vertices[0]) * m_vertices.size(),
                     m_vertices.data(), GL_STATIC_DRAW);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

  // Generate EBO
  abcg::glGenBuffers(1, &m_EBO);
  abcg::glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_EBO);
  abcg::glBufferData(GL_ELEMENT_ARRAY_BUFFER,
                     sizeof(m_indices[0]) * m_indices.size(), m_indices.data(),
                     GL_STATIC_DRAW);
  abcg::glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);

  // Create VAO
  abcg::glGenVertexArrays(1, &m_VAO);

  // Bind vertex attributes to current VAO
  abcg::glBindVertexArray(m_VAO);

  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_VBO);
  GLint positionAttribute{abcg::glGetAttribLocation(m_program, "inPosition")};
  abcg::glEnableVertexAttribArray(positionAttribute);
  abcg::glVertexAttribPointer(positionAttribute, 3, GL_FLOAT, GL_FALSE,
                              sizeof(Vertex), nullptr);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

  abcg::glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_EBO);

  // End of binding to current VAO
  abcg::glBindVertexArray(0);
}

Na linha 27, o teste de profundidade é habilitado. Isso faz com que os fragmentos sejam descartados durante a renderização com base na comparação de sua profundidade com o valor atual do buffer de profundidade. Precisamos usar o teste de profundidade pois, em cenas 3D, geralmente é inviável ordenar e renderizar os triângulos do mais longe para o mais perto, como fizemos com os objetos do projeto asteroids (seção 5.2) usando o “algoritmo do pintor.”

Na linha 34 carregamos o arquivo bunny.obj. Internamente, OpenGLWindow::loadModelFromFile armazena os vértices e índices em m_vertices e m_indices. Na linha 35, OpenGLWindow::standardize modifica esses dados para fazer com que a geometria caiba no volume de visão do pipeline gráfico, que é o cubo de tamanho \(2 \times 2 \times 2\) centralizado em \((0,0,0)\) no espaço normalizado do dispositivo (figura 6.29).

Volume de visão em coordenadas normalizadas do dispositivo.

Figura 6.29: Volume de visão em coordenadas normalizadas do dispositivo.

O restante do código de OpenGLWindow::initializeGL contém a criação do VAO, VBO e EBO usando os dados de m_vertices e m_indices.

A definição de OpenGLWindow::loadModelFromFile será como a seguir:

void OpenGLWindow::loadModelFromFile(std::string_view path) {
  tinyobj::ObjReader reader;

  if (!reader.ParseFromFile(path.data())) {
    if (!reader.Error().empty()) {
      throw abcg::Exception{abcg::Exception::Runtime(
          fmt::format("Failed to load model {} ({})", path, reader.Error()))};
    }
    throw abcg::Exception{
        abcg::Exception::Runtime(fmt::format("Failed to load model {}", path))};
  }

  if (!reader.Warning().empty()) {
    fmt::print("Warning: {}\n", reader.Warning());
  }

  const auto& attrib{reader.GetAttrib()};
  const auto& shapes{reader.GetShapes()};

  m_vertices.clear();
  m_indices.clear();

  // A key:value map with key=Vertex and value=index
  std::unordered_map<Vertex, GLuint> hash{};

  // Loop over shapes
  for (const auto& shape : shapes) {
    // Loop over indices
    for (const auto offset : iter::range(shape.mesh.indices.size())) {
      // Access to vertex
      const tinyobj::index_t index{shape.mesh.indices.at(offset)};

      // Vertex position
      const int startIndex{3 * index.vertex_index};
      const float vx{attrib.vertices.at(startIndex + 0)};
      const float vy{attrib.vertices.at(startIndex + 1)};
      const float vz{attrib.vertices.at(startIndex + 2)};

      Vertex vertex{};
      vertex.position = {vx, vy, vz};

      // If hash doesn't contain this vertex
      if (hash.count(vertex) == 0) {
        // Add this index (size of m_vertices)
        hash[vertex] = m_vertices.size();
        // Add this vertex
        m_vertices.push_back(vertex);
      }

      m_indices.push_back(hash[vertex]);
    }
  }
}

A variável reader (linha 74) é um objeto da classe tinyobj::ObjReader, responsável pela leitura e parsing do arquivo. O resultado é um conjunto de malhas (shapes) e um conjunto de atributos de vértices (attrib). Cada malha pode ser um objeto, ou um pedaço de um objeto. No nosso caso, leremos tudo como um objeto só. Para mais detalhes sobre a estrutura utilizada pelo TinyObjLoader, consulte a documentação.

Na linha 96 definimos a tabela hash que será utilizada para fazer a consulta de vértices não repetidos.

Embora o formato OBJ utilize geometria indexada, durante a leitura do modelo é possível que tenhamos vértices em uma mesma posição, embora com índices diferentes. Isso acontece porque os vértices podem diferir em relação a outros atributos além da posição. Por exemplo, dois vértices podem ter a mesma posição no espaço, mas cada um pode ter uma cor diferente. Neste projeto, cada vértice só contém o atributo de posição. Podemos então simplificar o modelo mantendo apenas um índice para cada posição de vértice.

Cada vértice lido do modelo OBJ será inserido na tabela hash usando a posição \((x,y,z)\) como chave, e a ordem de leitura do vértice como valor associado à chave. Se em algum momento verificarmos que um vértice de mesma posição já existe na tabela hash, usaremos o valor da chave como índice do vértice. No fim, teremos uma lista de vértices não repetidos (em m_vertices), que será o nosso VBO, e uma lista de índices (em m_indices), que será o EBO.

No laço da linha 99, o conjunto de malhas (shapes) é iterado para ler todos os triângulos e vértices.

A posição de cada vértice é lida nas linhas 107 a 109, nas variáveis vx, vy e vz, e utilizada para criar o vértice vertex nas linhas 111 e 112.

Na linha 115 é verificado se o vértice atual existe na tabela hash. Se não existir, ele é incluído na tabela e em m_vertices.

Na linha 122, o índice do vértice atual é inserido em m_indices. O índice é o valor da tabela hash para a chave de vertex.

A definição da função OpenGLWindow::standardize será como segue:

void OpenGLWindow::standardize() {
  // Center to origin and normalize largest bound to [-1, 1]

  // Get bounds
  glm::vec3 max(std::numeric_limits<float>::lowest());
  glm::vec3 min(std::numeric_limits<float>::max());
  for (const auto& vertex : m_vertices) {
    max.x = std::max(max.x, vertex.position.x);
    max.y = std::max(max.y, vertex.position.y);
    max.z = std::max(max.z, vertex.position.z);
    min.x = std::min(min.x, vertex.position.x);
    min.y = std::min(min.y, vertex.position.y);
    min.z = std::min(min.z, vertex.position.z);
  }

  // Center and scale
  const auto center{(min + max) / 2.0f};
  const auto scaling{2.0f / glm::length(max - min)};
  for (auto& vertex : m_vertices) {
    vertex.position = (vertex.position - center) * scaling;
  }
}

As maiores e menores coordenadas \(x\), \(y\) e \(z\) dos vértices são calculadas no laço da linha 133. Esses valores determinam os limites de uma caixa envoltória que contém o modelo geométrico. O centro dessa caixa é calculado na linha 143.

Na linha 144 é calculado um fator de escala que reduz o comprimento da maior dimensão da caixa para o comprimento 2.

No laço da linha 145 essas variáveis são utilizadas para centralizar o modelo na origem e mudar sua escala de modo que o modelo caiba no volume de visão em NDC, isto é, todos os vértices terão coordenadas no intervalo \([-1, 1]\).

Definiremos OpenGLWindow::paintGL como a seguir:

void OpenGLWindow::paintGL() {
  // Animate angle by 15 degrees per second
  const float deltaTime{static_cast<float>(getDeltaTime())};
  m_angle = glm::wrapAngle(m_angle + glm::radians(15.0f) * deltaTime);

  // Clear color buffer and depth buffer
  abcg::glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

  abcg::glViewport(0, 0, m_viewportWidth, m_viewportHeight);

  abcg::glUseProgram(m_program);
  abcg::glBindVertexArray(m_VAO);

  // Update uniform variable
  const GLint angleLoc{abcg::glGetUniformLocation(m_program, "angle")};
  abcg::glUniform1f(angleLoc, m_angle);

  // Draw triangles
  abcg::glDrawElements(GL_TRIANGLES, m_verticesToDraw, GL_UNSIGNED_INT,
                       nullptr);

  abcg::glBindVertexArray(0);
  abcg::glUseProgram(0);
}

Na linha 153, o valor de m_angle é incrementado a uma taxa de 15 graus por segundo (veja que o ângulo é convertido para radianos).

Observe, na linha 156, que glClear agora usa GL_DEPTH_BUFFER_BIT além de GL_COLOR_BUFFER_BIT. Isso é necessário para limpar o buffer de profundidade antes de renderizar o quadro atual.

O restante do código é similar ao que já usamos em projetos anteriores.

Vamos agora à definição de OpenGLWindow::paintUI:

void OpenGLWindow::paintUI() {
  abcg::OpenGLWindow::paintUI();

  // Create window for slider
  {
    ImGui::SetNextWindowPos(ImVec2(5, m_viewportHeight - 94));
    ImGui::SetNextWindowSize(ImVec2(m_viewportWidth - 10, -1));
    ImGui::Begin("Slider window", nullptr, ImGuiWindowFlags_NoDecoration);

    // Create a slider to control the number of rendered triangles
    {
      // Slider will fill the space of the window
      ImGui::PushItemWidth(m_viewportWidth - 25);

      static int n{m_verticesToDraw / 3};
      ImGui::SliderInt("", &n, 0, m_indices.size() / 3, "%d triangles");
      m_verticesToDraw = n * 3;

      ImGui::PopItemWidth();
    }

    ImGui::End();
  }

  // Create a window for the other widgets
  {
    const auto widgetSize{ImVec2(172, 62)};
    ImGui::SetNextWindowPos(ImVec2(m_viewportWidth - widgetSize.x - 5, 5));
    ImGui::SetNextWindowSize(widgetSize);
    ImGui::Begin("Widget window", nullptr, ImGuiWindowFlags_NoDecoration);

    static bool faceCulling{};
    ImGui::Checkbox("Back-face culling", &faceCulling);

    if (faceCulling) {
      abcg::glEnable(GL_CULL_FACE);
    } else {
      abcg::glDisable(GL_CULL_FACE);
    }

    // CW/CCW combo box
    {
      static std::size_t currentIndex{};
      const std::vector<std::string> comboItems{"CW", "CCW"};

      ImGui::PushItemWidth(70);
      if (ImGui::BeginCombo("Front face",
                            comboItems.at(currentIndex).c_str())) {
        for (const auto index : iter::range(comboItems.size())) {
          const bool isSelected{currentIndex == index};
          if (ImGui::Selectable(comboItems.at(index).c_str(), isSelected))
            currentIndex = index;
          if (isSelected) ImGui::SetItemDefaultFocus();
        }
        ImGui::EndCombo();
      }
      ImGui::PopItemWidth();

      if (currentIndex == 0) {
        abcg::glFrontFace(GL_CW);
      } else {
        abcg::glFrontFace(GL_CCW);
      }
    }

    ImGui::End();
  }
}

Na linhas 185 a 194 é definido o slider que controla o número de triângulos que será renderizado.

Na linha 207 é criada uma caixa de seleção (checkbox) de ativação do back-face culling (estamos usando o padrão do glCullFace, que é GL_BACK). O resultado da variável booleana faceCulling é utilizado para ativar ou desativar o face culling nas linhas 210 a 212.

Uma caixa de combinação (combo box) com as opções CW e CCW é definida a partir da linha 216. Nas linhas 234 e 236, a função glFrontFace é chamada com GL_CW ou GL_CCW de acordo com o que foi selecionado pelo usuário. Como a opção CW é a primeira opção da caixa, a aplicação iniciará com GL_CW.

O conteúdo restante de openglwindow.cpp é similar ao utilizado nos projetos anteriores:

void OpenGLWindow::resizeGL(int width, int height) {
  m_viewportWidth = width;
  m_viewportHeight = height;
}

void OpenGLWindow::terminateGL() {
  abcg::glDeleteProgram(m_program);
  abcg::glDeleteBuffers(1, &m_EBO);
  abcg::glDeleteBuffers(1, &m_VBO);
  abcg::glDeleteVertexArrays(1, &m_VAO);
}

Baixe o código completo do projeto a partir deste link.

Outros modelos OBJ estão disponíveis neste link.