7.7 LookAt na prática

Nesta seção, seguiremos o passo a passo de desenvolvimento de uma aplicação que renderiza uma cena 3D do ponto de vista de uma câmera LookAt.

A cena 3D será composta por quatro instâncias do modelo “Stanford Bunny” dispostos sobre o plano \(xz\) do espaço do mundo. A câmera LookAt simulará um observador em primeira pessoa. A figura 7.31 mostra o posicionamento dos objetos e a localização inicial da câmera.

Objetos e elementos de cena dispostos no espaço do mundo.

Figura 7.31: Objetos e elementos de cena dispostos no espaço do mundo.

Na figura acima, os vetores \(\hat{\mathbf{i}}\),\(\hat{\mathbf{j}}\),\(\hat{\mathbf{k}}\) correspondem às direções dos eixos \(x\),\(y\),\(z\) do quadro padrão com ponto de referência na origem \((0,0,0)\). Os vetores \(\hat{\mathbf{u}}\),\(\hat{\mathbf{v}}\),\(\hat{\mathbf{n}}\) são os vetores de base do quadro da câmera com \(P_0=(0, 0.5, 2.5)\). A câmera está localizada na origem de seu quadro e está olhando na direção de seu eixo \(z\) negativo.

  • O coelho vermelho está na posição \((0,0,0)\) do quadro padrão e tem escala de \(10\%\) do tamanho original.
  • O coelho cinza está na posição \((-1,0,0)\) do quadro padrão e está rodado em \(90^{\circ}\) em torno do eixo \(y\) de seu quadro local.
  • O coelho azul está na posição \((1,0,0)\) do quadro padrão e está rodado em \(-90^{\circ}\) em torno do eixo \(y\) de seu quadro local.
  • O coelho amarelo está na posição \((0,0,-1)\) do quadro padrão e está com sua orientação original.

A posição e orientação da câmera podem ser modificadas através do teclado:

  • As setas para cima/baixo (ou W/S) fazem a câmera ir para a frente e para trás ao longo da direção de visão (direção de \(\pm\hat{\mathbf{n}}\)). Esse movimento de câmera é conhecido como dolly no jargão da cinematografia.
  • As setas para os lados (ou A/D) fazem a câmera girar em torno de seu eixo \(y\) (vetor \(\hat{\mathbf{v}}\)). Esse movimento é chamado de pan.
  • As teclas Q/E fazem a câmera deslizar para os lados (direção de \(\pm\hat{\mathbf{u}}\)). Esse movimento é chamado de truck ou dolly lateral.

Neste exemplo, a altura da câmera permanecerá sempre em \(y=0.5\).

O resultado ficará como a seguir:

Observação

Para controlar a câmera usando o teclado é necessário abrir o link original e clicar na área de desenho. Desse modo a aplicação terá o foco do teclado.

Configuração inicial

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

    add_subdirectory(lookat)
  • Crie o subdiretório abcg/examples/lookat e o arquivo abcg/examples/lookat/CMakeLists.txt com o seguinte conteúdo:

    project(lookat)
    add_executable(${PROJECT_NAME} camera.cpp ground.cpp main.cpp window.cpp)
    enable_abcg(${PROJECT_NAME})
  • Crie os arquivos camera.cpp, camera.hpp, ground.cpp, ground.hpp, main.cpp, window.cpp e window.hpp.

  • Crie o subdiretório abcg/examples/lookat/assets. Dentro dele, crie os arquivos lookat.frag e lookat.vert. Além disso, baixe o arquivo bunny.zip e descompacte-o em assets.

A estrutura de abcg/examples/lookat ficará assim:

lookat/
│   CMakeLists.txt
│   camera.hpp
│   camera.cpp
│   ground.hpp
│   ground.cpp
│   main.cpp
│   window.hpp
│   window.cpp
│
└───assets/
    │   bunny.obj
    │   lookat.frag    
    └   lookat.vert

main.cpp

Exceto pelo título da janela, o conteúdo de main.cpp é o mesmo do projeto anterior:

#include "window.hpp"

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

    Window window;
    window.setOpenGLSettings({.samples = 4});
    window.setWindowSettings({
        .width = 600,
        .height = 600,
        .title = "LookAt Camera",
    });

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

lookat.vert

O vertex shader ficará como a seguir:

#version 300 es

layout(location = 0) in vec3 inPosition;

uniform vec4 color;
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projMatrix;

out vec4 fragColor;

void main() {
  vec4 posEyeSpace = viewMatrix * modelMatrix * vec4(inPosition, 1);

  float i = 1.0 - (-posEyeSpace.z / 5.0);
  fragColor = vec4(i, i, i, 1) * color;

  gl_Position = projMatrix * posEyeSpace;
}

O atributo de entrada, inPosition, é a posição \((x,y,z)\) do vértice. Vamos supor que estas coordenadas estão no espaço do objeto.

O atributo de saída, fragColor, é uma cor RGBA.

As variáveis uniformes são utilizadas para determinar a cor do objeto (color, na linha 5) e as matrizes \(4 \times 4\) de transformação geométrica (linhas 6 a 8):

  • Matriz de modelo: modelMatrix;
  • Matriz de visão: viewMatrix;
  • Matriz de projeção: projMatrix.

Embora ainda não tenhamos visto a fundamentação teórica sobre a construção de uma matriz de projeção, vamos utilizar essa matriz desde já. Ela será necessária para obter o efeito de perspectiva e assim manter a ilusão de que a câmera LookAt é um observador dentro de um cenário 3D.

No código de main, a linha 13 transforma a posição de entrada usando as matrizes de modelo e visão. Para entendermos a ordem das transformações a partir do espaço do objeto, temos de ler os operandos da direita para a esquerda:

  vec4 posEyeSpace = viewMatrix * modelMatrix * vec4(inPosition, 1);

Primeiro, vec4(inPosition, 1) produz a posição \((x,y,z,1)\), isto é, o ponto/vértice em coordenadas homogêneas que corresponde à posição \((x,y,z)\) no espaço do objeto. Esse vértice é transformado, através do produto matricial, pela matriz de modelo modelMatrix. A transformação pela matriz de modelo converte coordenadas do espaço do objeto para o espaço do mundo. Em seguida há uma transformação pela matriz de visão viewMatrix. A matriz de visão converte coordenadas do espaço do mundo para coordenadas do espaço da câmera. Assim, o resultado armazenado em posEyeSpace é a posição do vértice no espaço da câmera.

Na linha 15, calculamos um valor i de intensidade de cor a partir da coordenada \(z\) do vértice no espaço da câmera:

  float i = 1.0 - (-posEyeSpace.z / 5.0);

Lembre-se que, no espaço da câmera, a câmera está olhando na direção de seu eixo \(z\) negativo. Logo, do ponto de vista da câmera, todos os objetos à sua frente têm valor \(z\) negativo. Ao fazermos -PosEyeSpace.z, tornamos esse valor positivo, correspondendo à distância entre o vértice e a câmera ao longo do eixo \(z\). A ideia aqui é transformar essa distância em um valor de intensidade de cor. A intensidade será máxima (1) se o objeto estiver o mais próximo possível da câmera (isto é, se estiver na mesma posição da câmera), e mínima (0) se estiver a 5 ou mais unidades de distância na direção de visão. Na linha 16, esse valor de intensidade é utilizado para multiplicar as componentes RGB da cor color.

  fragColor = vec4(i, i, i, 1) * color;

Assim, quanto mais longe o objeto estiver da câmera, mais escuro ele ficará. A partir da distância 5, a intensidade fica negativa, mas nesse caso o OpenGL fixa automaticamente o valor de cor para zero, pois uma cor não pode ter intensidade negativa.

Na linha 18, projMatrix * posEyeSpace faz com que as coordenadas no espaço da câmera sejam convertidas para o espaço de recorte. É esse o resultado final armazenado em gl_Position:

  gl_Position = projMatrix * posEyeSpace;

lookat.frag

O conteúdo do fragment shader ficará assim:

#version 300 es

precision mediump float;

in vec4 fragColor;

out vec4 outColor;

void main() {
  if (gl_FrontFacing) {
    outColor = fragColor;
  } else {
    outColor = fragColor * 0.5;
  }
}

Se o triângulo estiver orientado de frente para a câmera, a cor final do fragmento será a cor de entrada (fragColor). Caso contrário, a cor terá metade da intensidade original (a cor RGB é multiplicada por 0.5). Assim, se a câmera estiver dentro de um objeto, os triângulos serão desenhados com uma cor mais escura, pois estaremos vendo o lado de trás da malha triangular.

camera.hpp

Neste arquivo definiremos a classe Camera que gerenciará a câmera LookAt. O conteúdo ficará como a seguir:

#ifndef CAMERA_HPP_
#define CAMERA_HPP_

#include <glm/mat4x4.hpp>
#include <glm/vec3.hpp>

class Camera {
public:
  void computeViewMatrix();
  void computeProjectionMatrix(glm::vec2 const &size);

  void dolly(float speed);
  void truck(float speed);
  void pan(float speed);

  glm::mat4 const &getViewMatrix() const { return m_viewMatrix; }
  glm::mat4 const &getProjMatrix() const { return m_projMatrix; }

private:
  glm::vec3 m_eye{0.0f, 0.5f, 2.5f}; // Camera position
  glm::vec3 m_at{0.0f, 0.5f, 0.0f};  // Look-at point
  glm::vec3 m_up{0.0f, 1.0f, 0.0f};  // "up" direction

  // Matrix to change from world space to camera space
  glm::mat4 m_viewMatrix;

  // Matrix to change from camera space to clip space
  glm::mat4 m_projMatrix;
};

#endif

Observe, nas linhas 20 a 22, que a classe tem todos os atributos necessários para criar o quadro de uma câmera LookAt:

  • m_eye: posição da câmera \((0, 0.5, 2.5)\).
  • m_at: posição para onde a câmera está olhando \((0, 0.5, 0)\).
  • m_up: vetor de direção para cima \((0, 1, 0)\).

Na linha 25 temos a matriz de visão (m_viewMatrix) que será calculada pela função Camera::computeViewMatrix declarada na linha 9.

Na linha 28 temos a matriz de projeção (m_projMatrix) que será calculada pela função Camera::computeProjectionMatrix declarada na linha 10.

As funções Camera::dolly, Camera::truck e Camera::pan serão chamadas a partir de Window em resposta à entrada do teclado. Internamente, essas funções modificarão as variáveis m_eye e m_at, fazendo a câmera mudar de posição e orientação.

Camera::getViewMatrix e Camera::getProjMatrix são funções de acesso à matriz de visão e projeção.

camera.cpp

A definição das funções membro de Camera ficará como a seguir:

#include "camera.hpp"

#include <glm/gtc/matrix_transform.hpp>

void Camera::computeProjectionMatrix(glm::vec2 const &size) {
  m_projMatrix = glm::mat4(1.0f);
  auto const aspect{size.x / size.y};
  m_projMatrix = glm::perspective(glm::radians(70.0f), aspect, 0.1f, 5.0f);
}

void Camera::computeViewMatrix() {
  m_viewMatrix = glm::lookAt(m_eye, m_at, m_up);
}

void Camera::dolly(float speed) {
  // Compute forward vector (view direction)
  auto const forward{glm::normalize(m_at - m_eye)};

  // Move eye and center forward (speed > 0) or backward (speed < 0)
  m_eye += forward * speed;
  m_at += forward * speed;

  computeViewMatrix();
}

void Camera::truck(float speed) {
  // Compute forward vector (view direction)
  auto const forward{glm::normalize(m_at - m_eye)};
  // Compute vector to the left
  auto const left{glm::cross(m_up, forward)};

  // Move eye and center to the left (speed < 0) or to the right (speed > 0)
  m_at -= left * speed;
  m_eye -= left * speed;

  computeViewMatrix();
}

void Camera::pan(float speed) {
  glm::mat4 transform{1.0f};

  // Rotate camera around its local y axis
  transform = glm::translate(transform, m_eye);
  transform = glm::rotate(transform, -speed, m_up);
  transform = glm::translate(transform, -m_eye);

  m_at = transform * glm::vec4(m_at, 1.0f);

  computeViewMatrix();
}

No próximo capítulo, quando tivermos visto o conteúdo teórico sobre matrizes de projeção, descreveremos o funcionamento da função Camera::computeProjectionMatrix. Por enquanto, basta sabermos que ela calcula uma matriz de projeção perspectiva.

Em Camera::computeViewMatrix, chamamos a função lookAt da GLM usando os atributos da câmera:

void Camera::computeViewMatrix() {
  m_viewMatrix = glm::lookAt(m_eye, m_at, m_up);
}

Camera::computeViewMatrix será chamada sempre que houver alguma alteração em m_eye ou m_at.

Em Camera::dolly, os pontos m_eye e m_at são deslocados para a frente ou para trás ao longo da direção de visão (vetor forward):

void Camera::dolly(float speed) {
  // Compute forward vector (view direction)
  auto const forward{glm::normalize(m_at - m_eye)};

  // Move eye and center forward (speed > 0) or backward (speed < 0)
  m_eye += forward * speed;
  m_at += forward * speed;

  computeViewMatrix();
}

Veja que, ao final, Camera::computeViewMatrix é chamada para reconstruir a matriz de visão.

Camera::truck funciona de forma parecida com Camera::dolly. Os pontos m_eye e m_at são deslocados nas laterais de acordo com a direção do vetor left. O vetor left é o produto vetorial entre o vetor up e o vetor forward.

void Camera::truck(float speed) {
  // Compute forward vector (view direction)
  auto const forward{glm::normalize(m_at - m_eye)};
  // Compute vector to the left
  auto const left{glm::cross(m_up, forward)};

  // Move eye and center to the left (speed < 0) or to the right (speed > 0)
  m_at -= left * speed;
  m_eye -= left * speed;

  computeViewMatrix();
}

Camera::pan faz o movimento de girar a câmera em torno de seu eixo \(y\). Isso é feito alterando apenas o ponto m_at:

void Camera::pan(float speed) {
  glm::mat4 transform{1.0f};

  // Rotate camera around its local y axis
  transform = glm::translate(transform, m_eye);
  transform = glm::rotate(transform, -speed, m_up);
  transform = glm::translate(transform, -m_eye);

  m_at = transform * glm::vec4(m_at, 1.0f);

  computeViewMatrix();
}

Após a linha 40, a matriz transform representa uma concatenação de transformações na forma:

\[ \mathbf{M}=\mathbf{I}.\mathbf{T}(\mathbf{p}_{\textrm{eye}}).\mathbf{R}_y(\theta).\mathbf{T}(-\mathbf{p}_{\textrm{eye}}). \]

A ordem de aplicação das transformações é obtida lendo a expressão acima da direita para a esquerda (no código, lemos de baixo para cima, da linha 45 à linha 40):

  1. \(\mathbf{T}(-\mathbf{p}_{\textrm{eye}})\) (linha 45) tem o efeito de transladar a câmera para a origem do mundo, isto é, faz o ponto \(\mathbf{p}_{\textrm{eye}}\) virar a origem \(O\).
  2. \(\mathbf{R}_y(\theta)\) (linha 44) roda a câmera em torno do eixo \(y\) do mundo. Como a câmera agora está na origem, é como se a câmera fosse girada em torno de seu próprio eixo \(y\).
  3. \(\mathbf{T}(\mathbf{p}_{\textrm{eye}})\) (linha 43) é a transformação inversa da primeira, isto é, faz a câmera voltar à sua posição original (mas note que, por causa do passo anterior, a orientação da câmera não é mais a orientação original).
  4. \(\mathbf{I}\) é a matriz identidade (criada na linha 40).

A linha 47 transforma m_at por transform. O resultado é rodar m_at em torno do eixo \(y\) local da câmera.

Observação

As operações da linha 40 até a linha 45 em Camera::pan são equivalentes ao pseudocódigo:

transform = I;
transform = transform * T(m_eye);
transform = transform * Ry(-speed);
transform = transform * T(-m_eye);

que é o mesmo que

transform = I * T(m_eye) * Ry(-speed) * T(-m_eye);

onde I, Ry e T são as matrizes de transformação identidade, rotação em \(y\), e translação.

window.hpp

Deixaremos a definição da classe Window como a seguir:

#ifndef WINDOW_HPP_
#define WINDOW_HPP_

#include "abcgOpenGL.hpp"

#include "camera.hpp"
#include "ground.hpp"

struct Vertex {
  glm::vec3 position;

  friend bool operator==(Vertex const &, Vertex const &) = default;
};

class Window : public abcg::OpenGLWindow {
protected:
  void onEvent(SDL_Event const &event) override;
  void onCreate() override;
  void onPaint() override;
  void onPaintUI() override;
  void onResize(glm::ivec2 const &size) override;
  void onDestroy() override;
  void onUpdate() override;

private:
  glm::ivec2 m_viewportSize{};

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

  GLint m_viewMatrixLocation{};
  GLint m_projMatrixLocation{};
  GLint m_modelMatrixLocation{};
  GLint m_colorLocation{};

  Camera m_camera;
  float m_dollySpeed{};
  float m_truckSpeed{};
  float m_panSpeed{};

  Ground m_ground;

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

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

#endif

O código é semelhante ao dos projetos anteriores. As principais diferenças estão nas seguintes linhas:

  • Linhas 6 e 7: inclusão dos cabeçalhos camera.hpp e ground.hpp;
  • Linhas 33 a 36: identificadores das variáveis uniform do vertex shader;
  • Linha 38: definição de um objeto da classe Camera para controlar a câmera LookAt;
  • Linhas 39 a 41: definição de variáveis de controle de velocidade de dolly, truck e pan;
  • Linha 43: definição de um objeto da classe Ground para desenhar o chão.

Algumas coisas foram removidas em relação ao projeto loadmodel, como a variável que controlava o número de triângulos exibidos e a função OpenGLWindow::standardize que normalizava e centralizava o modelo no NDC. Dessa vez, o modelo armazenado no VBO será o modelo sem modificações, isto é, o modelo lido diretamente do arquivo. Para mudar a escala e posição do modelo, usaremos a matriz de modelo.

window.cpp

No início de window.cpp fazemos a especialização explícita de std::hash para Vertex, como fizemos no projeto anterior:

#include "window.hpp"

#include <unordered_map>

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

A definição de Window::onEvent vem a seguir:

void Window::onEvent(SDL_Event const &event) {
  if (event.type == SDL_KEYDOWN) {
    if (event.key.keysym.sym == SDLK_UP || event.key.keysym.sym == SDLK_w)
      m_dollySpeed = 1.0f;
    if (event.key.keysym.sym == SDLK_DOWN || event.key.keysym.sym == SDLK_s)
      m_dollySpeed = -1.0f;
    if (event.key.keysym.sym == SDLK_LEFT || event.key.keysym.sym == SDLK_a)
      m_panSpeed = -1.0f;
    if (event.key.keysym.sym == SDLK_RIGHT || event.key.keysym.sym == SDLK_d)
      m_panSpeed = 1.0f;
    if (event.key.keysym.sym == SDLK_q)
      m_truckSpeed = -1.0f;
    if (event.key.keysym.sym == SDLK_e)
      m_truckSpeed = 1.0f;
  }
  if (event.type == SDL_KEYUP) {
    if ((event.key.keysym.sym == SDLK_UP || event.key.keysym.sym == SDLK_w) &&
        m_dollySpeed > 0)
      m_dollySpeed = 0.0f;
    if ((event.key.keysym.sym == SDLK_DOWN || event.key.keysym.sym == SDLK_s) &&
        m_dollySpeed < 0)
      m_dollySpeed = 0.0f;
    if ((event.key.keysym.sym == SDLK_LEFT || event.key.keysym.sym == SDLK_a) &&
        m_panSpeed < 0)
      m_panSpeed = 0.0f;
    if ((event.key.keysym.sym == SDLK_RIGHT ||
         event.key.keysym.sym == SDLK_d) &&
        m_panSpeed > 0)
      m_panSpeed = 0.0f;
    if (event.key.keysym.sym == SDLK_q && m_truckSpeed < 0)
      m_truckSpeed = 0.0f;
    if (event.key.keysym.sym == SDLK_e && m_truckSpeed > 0)
      m_truckSpeed = 0.0f;
  }
}

Os eventos de teclado são tratados de forma separada para as teclas pressionadas (SDL_KEYDOWN, linhas 14 a 27) e para as teclas liberadas (SDL_KEYUP, linhas 28 a 46).

Quando uma tecla é pressionada (setas ou QEWASD), a velocidade de dolly, pan ou truck é modificada para +1 ou -1. Quando a tecla é liberada, a velocidade correspondente volta para 0.

Vamos agora à definição de Window::onCreate, que também é bem parecida com a do projeto loadmodel:

void Window::onCreate() {
  auto const &assetsPath{abcg::Application::getAssetsPath()};

  abcg::glClearColor(0, 0, 0, 1);

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

  // Create program
  m_program =
      abcg::createOpenGLProgram({{.source = assetsPath + "lookat.vert",
                                  .stage = abcg::ShaderStage::Vertex},
                                 {.source = assetsPath + "lookat.frag",
                                  .stage = abcg::ShaderStage::Fragment}});

  m_ground.create(m_program);

  // Get location of uniform variables
  m_viewMatrixLocation = abcg::glGetUniformLocation(m_program, "viewMatrix");
  m_projMatrixLocation = abcg::glGetUniformLocation(m_program, "projMatrix");
  m_modelMatrixLocation = abcg::glGetUniformLocation(m_program, "modelMatrix");
  m_colorLocation = abcg::glGetUniformLocation(m_program, "color");

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

  // Generate VBO
  abcg::glGenBuffers(1, &m_VBO);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_VBO);
  abcg::glBufferData(GL_ARRAY_BUFFER,
                     sizeof(m_vertices.at(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.at(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);
  auto const 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);
}

Em relação ao projeto anterior, modificamos o nomes dos shaders lidos (linhas 58 a 62) e chamamos Ground::create na linha 64 para inicializar o VAO/VBO do chão.

A definição de Window::loadModelFromFile é a mesma do projeto anterior:

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

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

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

  auto const &attributes{reader.GetAttrib()};
  auto const &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 (auto const &shape : shapes) {
    // Loop over indices
    for (auto const offset : iter::range(shape.mesh.indices.size())) {
      // Access to vertex
      auto const index{shape.mesh.indices.at(offset)};

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

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

      // If map doesn't contain this vertex
      if (!hash.contains(vertex)) {
        // 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]);
    }
  }
}

Vamos à definição de Window::onPaint:

void Window::onPaint() {
  // Clear color buffer and depth buffer
  abcg::glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

  abcg::glViewport(0, 0, m_viewportSize.x, m_viewportSize.y);

  abcg::glUseProgram(m_program);

  // Set uniform variables for viewMatrix and projMatrix
  // These matrices are used for every scene object
  abcg::glUniformMatrix4fv(m_viewMatrixLocation, 1, GL_FALSE,
                           &m_camera.getViewMatrix()[0][0]);
  abcg::glUniformMatrix4fv(m_projMatrixLocation, 1, GL_FALSE,
                           &m_camera.getProjMatrix()[0][0]);

  abcg::glBindVertexArray(m_VAO);

  // Draw white bunny
  glm::mat4 model{1.0f};
  model = glm::translate(model, glm::vec3(-1.0f, 0.0f, 0.0f));
  model = glm::rotate(model, glm::radians(90.0f), glm::vec3(0, 1, 0));
  model = glm::scale(model, glm::vec3(0.5f));

  abcg::glUniformMatrix4fv(m_modelMatrixLocation, 1, GL_FALSE, &model[0][0]);
  abcg::glUniform4f(m_colorLocation, 1.0f, 1.0f, 1.0f, 1.0f);
  abcg::glDrawElements(GL_TRIANGLES, m_indices.size(), GL_UNSIGNED_INT,
                       nullptr);

  // Draw yellow bunny
  model = glm::mat4(1.0);
  model = glm::translate(model, glm::vec3(0.0f, 0.0f, -1.0f));
  model = glm::scale(model, glm::vec3(0.5f));

  abcg::glUniformMatrix4fv(m_modelMatrixLocation, 1, GL_FALSE, &model[0][0]);
  abcg::glUniform4f(m_colorLocation, 1.0f, 0.8f, 0.0f, 1.0f);
  abcg::glDrawElements(GL_TRIANGLES, m_indices.size(), GL_UNSIGNED_INT,
                       nullptr);

  // Draw blue bunny
  model = glm::mat4(1.0);
  model = glm::translate(model, glm::vec3(1.0f, 0.0f, 0.0f));
  model = glm::rotate(model, glm::radians(-90.0f), glm::vec3(0, 1, 0));
  model = glm::scale(model, glm::vec3(0.5f));

  abcg::glUniformMatrix4fv(m_modelMatrixLocation, 1, GL_FALSE, &model[0][0]);
  abcg::glUniform4f(m_colorLocation, 0.0f, 0.8f, 1.0f, 1.0f);
  abcg::glDrawElements(GL_TRIANGLES, m_indices.size(), GL_UNSIGNED_INT,
                       nullptr);

  // Draw red bunny
  model = glm::mat4(1.0);
  model = glm::scale(model, glm::vec3(0.1f));

  abcg::glUniformMatrix4fv(m_modelMatrixLocation, 1, GL_FALSE, &model[0][0]);
  abcg::glUniform4f(m_colorLocation, 1.0f, 0.25f, 0.25f, 1.0f);
  abcg::glDrawElements(GL_TRIANGLES, m_indices.size(), GL_UNSIGNED_INT,
                       nullptr);

  abcg::glBindVertexArray(0);

  // Draw ground
  m_ground.paint();

  abcg::glUseProgram(0);
}

Nas linhas 173 a 176, o conteúdo das matrizes de visão e projeção é enviado às variáveis uniformes no shader:

  // Set uniform variables for viewMatrix and projMatrix
  // These matrices are used for every scene object
  abcg::glUniformMatrix4fv(viewMatrixLoc, 1, GL_FALSE,
                           &m_camera.m_viewMatrix[0][0]);
  abcg::glUniformMatrix4fv(projMatrixLoc, 1, GL_FALSE,
                           &m_camera.m_projMatrix[0][0]);

Observe o uso da função glUniformMatrix4fv. Essa função tem a assinatura

void glUniformMatrix4fv(GLint location,
                        GLsizei count,
                        GLboolean transpose,
                        const GLfloat *value);

onde

  • location é o identificador de localização da variável uniforme no shader;
  • count é o número de matrizes que queremos transferir à variável uniforme;
  • transpose é um valor booleano que indica se queremos enviar a transposta da matriz;
  • value é o ponteiro para o primeiro elemento do arranjo de elementos da matriz.

A renderização do coelho branco é configurada nas linhas 181 a 189:

  // Draw white bunny
  glm::mat4 model{1.0f};
  model = glm::translate(model, glm::vec3(-1.0f, 0.0f, 0.0f));
  model = glm::rotate(model, glm::radians(90.0f), glm::vec3(0, 1, 0));
  model = glm::scale(model, glm::vec3(0.5f));

  abcg::glUniformMatrix4fv(m_modelMatrixLocation, 1, GL_FALSE, &model[0][0]);
  abcg::glUniform4f(m_colorLocation, 1.0f, 1.0f, 1.0f, 1.0f);
  abcg::glDrawElements(GL_TRIANGLES, m_indices.size(), GL_UNSIGNED_INT,
                       nullptr);

Nas linhas 181 a 184 é criada a concatenação de transformações que forma a matriz de modelo (model). Para o coelho branco, essa concatenação é

\[ \mathbf{M}_{\textrm{model}}=\mathbf{I}.\mathbf{T}(-1,0,0).\mathbf{R}_y\left(\frac{\pi}{2}\right).\mathbf{S}(0.5, 0.5, 0.5). \] Essas transformações servem para posicionar o modelo do coelho no mundo. Inicialmente o modelo está em seu quadro local, com posição e orientação definida no arquivo bunny.obj: na origem, sobre o plano \(y=0\), como vimos na figura 7.16. As transformações são aplicadas da seguinte forma:

  • Transformação de escala para reduzir o tamanho do coelho para \(50\%\) de seu tamanho original (linha 184);
  • Rotação em \(90^{\circ}\) em torno do eixo \(y\) do espaço do objeto, que é o mesmo eixo \(y\) do espaço do mundo (linha 183);
  • Translação pelo vetor \((-1,0,0)\), que posiciona o coelho em sua posição final na cena (linha 182);
  • Transformação identidade (linha 181).

Na linha 186, a matriz de modelo é enviada à variável uniforme m_modelMatrix no vertex shader.

Na linha 187, a variável uniforme color é definida com \((1,1,1,1)\) (branco) no vertex shader.

Na linha 188 é feita a chamada ao comando de renderização.

Observe como um procedimento semelhante é feito para os outros coelhos. Mudam apenas as transformações que serão usadas para criar a matriz model, e o valor de cor definido na variável uniforme color.

Para o coelho amarelo:

  // Draw yellow bunny
  model = glm::mat4(1.0);
  model = glm::translate(model, glm::vec3(0.0f, 0.0f, -1.0f));
  model = glm::scale(model, glm::vec3(0.5f));

  abcg::glUniformMatrix4fv(m_modelMatrixLocation, 1, GL_FALSE, &model[0][0]);
  abcg::glUniform4f(m_colorLocation, 1.0f, 0.8f, 0.0f, 1.0f);
  abcg::glDrawElements(GL_TRIANGLES, m_indices.size(), GL_UNSIGNED_INT,
                       nullptr);

Para o coelho azul:

  // Draw blue bunny
  model = glm::mat4(1.0);
  model = glm::translate(model, glm::vec3(1.0f, 0.0f, 0.0f));
  model = glm::rotate(model, glm::radians(-90.0f), glm::vec3(0, 1, 0));
  model = glm::scale(model, glm::vec3(0.5f));

  abcg::glUniformMatrix4fv(m_modelMatrixLocation, 1, GL_FALSE, &model[0][0]);
  abcg::glUniform4f(m_colorLocation, 0.0f, 0.8f, 1.0f, 1.0f);
  abcg::glDrawElements(GL_TRIANGLES, m_indices.size(), GL_UNSIGNED_INT,
                       nullptr);

Para o pequeno coelho vermelho:

  // Draw red bunny
  model = glm::mat4(1.0);
  model = glm::scale(model, glm::vec3(0.1f));

  abcg::glUniformMatrix4fv(m_modelMatrixLocation, 1, GL_FALSE, &model[0][0]);
  abcg::glUniform4f(m_colorLocation, 1.0f, 0.25f, 0.25f, 1.0f);
  abcg::glDrawElements(GL_TRIANGLES, m_indices.size(), GL_UNSIGNED_INT,
                       nullptr);

Note que todos os modelos foram renderizados com o mesmo VAO (linha 178), pois todos compartilham o mesmo VBO. É apenas a matriz de modelo que faz com que cada coelho tenha uma transformação diferente no cenário 3D.

Ao fim de Window::onPaint temos o seguinte código:

  abcg::glBindVertexArray(0);

  // Draw ground
  m_ground.paint();

  abcg::glUseProgram(0);

Na linha 221, o VAO dos coelhos deixa de ser usado. Em seguida, na linha 224, o chão é desenhado. O chão tem seu próprio VAO, mas usa os mesmos shaders dos coelhos. É por isso que o programa de shader só é desabilitado na linha 226 com a chamada a glUseProgram(0).

A definição de Window::onPaintUI e Window::onResize é dada a seguir:

void Window::onPaintUI() { abcg::OpenGLWindow::onPaintUI(); }

void Window::onResize(glm::ivec2 const &size) {
  m_viewportSize = size;
  m_camera.computeProjectionMatrix(size);
}

A função Camera::computeProjectioMatrix é chamada dentro de Window::onResize para reconstruir a matriz de projeção. Os valores da matriz dependem do tamanho atual da janela.

A definição de Window::destroy ficará como a seguir:

void Window::onDestroy() {
  m_ground.destroy();

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

Não há nada de muito novo nesse código, exceto a chamada a Ground::destroy para liberar o VAO e VBO do chão.

Finalmente, a definição de Window::onUpdate ficará como a seguir:

void Window::onUpdate() {
  auto const deltaTime{gsl::narrow_cast<float>(getDeltaTime())};

  // Update LookAt camera
  m_camera.dolly(m_dollySpeed * deltaTime);
  m_camera.truck(m_truckSpeed * deltaTime);
  m_camera.pan(m_panSpeed * deltaTime);
}

Aqui, a posição e a orientação da câmera LookAt são atualizadas antes da chamada de Window::onPaint. As funções de movimentação da câmera são chamadas usando as variáveis de velocidade (m_dollySpeed, m_truckSpeed, m_panSpeed) que tiveram seus valores determinados em Window::onEvent de acordo com as teclas pressionadas.

ground.hpp

A classe Ground é responsável pelo desenho do chão. Embora não seja uma classe derivada de abcg::OpenGLWindow, os nomes das funções são semelhantes (create, paint e destroy). Como vimos anteriormente, essas funções são chamadas nas respectivas funções de Window:

#ifndef GROUND_HPP_
#define GROUND_HPP_

#include "abcgOpenGL.hpp"

class Ground {
public:
  void create(GLuint program);
  void paint();
  void destroy();

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

  GLint m_modelMatrixLoc{};
  GLint m_colorLoc{};
};

#endif

Ground::create recebe como parâmetro o identificador de um programa de shader já existente. Assim, o chão pode usar os mesmos shaders dos coelhos.

Em Ground::paint, veremos que o chão é desenhado como um padrão de xadrez. Como é um padrão composto por quadriláteros, o VBO não precisa ser a malha geométrica do chão inteiro, mas apenas um quadrilátero de tamanho unitário. Esse quadrilátero será desenhado várias vezes para formar um ladrilho com padrão de xadrez.

ground.cpp

Vamos começar com a definição de Ground::create:

#include "ground.hpp"

void Ground::create(GLuint program) {
  // Unit quad on the xz plane
  std::array<glm::vec3, 4> vertices{{{-0.5f, 0.0f, +0.5f},
                                     {-0.5f, 0.0f, -0.5f},
                                     {+0.5f, 0.0f, +0.5f},
                                     {+0.5f, 0.0f, -0.5f}}};

  // Generate VBO
  abcg::glGenBuffers(1, &m_VBO);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_VBO);
  abcg::glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices.data(),
                     GL_STATIC_DRAW);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

  // Create VAO and bind vertex attributes
  abcg::glGenVertexArrays(1, &m_VAO);
  abcg::glBindVertexArray(m_VAO);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_VBO);
  auto const positionAttribute{
      abcg::glGetAttribLocation(program, "inPosition")};
  abcg::glEnableVertexAttribArray(positionAttribute);
  abcg::glVertexAttribPointer(positionAttribute, 3, GL_FLOAT, GL_FALSE, 0,
                              nullptr);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);
  abcg::glBindVertexArray(0);

  // Save location of uniform variables
  m_modelMatrixLoc = abcg::glGetUniformLocation(program, "modelMatrix");
  m_colorLoc = abcg::glGetUniformLocation(program, "color");
}

No início da função, definimos os vértices de um quadrilátero de tamanho unitário centralizado no plano \(xz\). Em seguida, criamos o VBO e fazemos a ligação do VBO com o atributo inPosition do shader program. Por fim, salvamos a localização das variáveis uniformes que serão utilizadas em Ground::paint.

A propósito, eis o código de Ground::paint:

void Ground::paint() {
  abcg::glBindVertexArray(m_VAO);

  // Draw a grid of 2N+1 x 2N+1 tiles on the xz plane, centered around the
  // origin
  auto const N{5};
  for (auto const z : iter::range(-N, N + 1)) {
    for (auto const x : iter::range(-N, N + 1)) {
      // Set model matrix as a translation matrix
      glm::mat4 model{1.0f};
      model = glm::translate(model, glm::vec3(x, 0.0f, z));
      abcg::glUniformMatrix4fv(m_modelMatrixLoc, 1, GL_FALSE, &model[0][0]);

      // Set color (checkerboard pattern)
      auto const gray{(z + x) % 2 == 0 ? 1.0f : 0.5f};
      abcg::glUniform4f(m_colorLoc, gray, gray, gray, 1.0f);

      abcg::glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
    }
  }

  abcg::glBindVertexArray(0);
}

Aqui, desenhamos uma grade de 11x11 quadriláteros (variando \(z\) e \(x\) de -5 a 5). Cada quadrilátero é transladado através de uma matriz de modelo e então desenhado com glDrawArrays usando a primitiva GL_TRIANGLE_STRIP. A cor utilizada – configurada pela variável uniforme do shader – é modificada de acordo com a paridade das coordenadas da grade de modo a formar o padrão de xadrez.

Em Ground::destroy, o VBO e o VAO são liberados:

void Ground::destroy() {
  abcg::glDeleteBuffers(1, &m_VBO);
  abcg::glDeleteVertexArrays(1, &m_VAO);
}

Como o programa de shader é o mesmo dos coelhos, o responsável pela liberação dos shaders é Window, como já vimos em Window::onDestroy.

Isso conclui o projeto lookat. Baixe o código completo a partir deste link.