8.5 Efeito starfield

Nesta seção aplicaremos a transformação de projeção perspectiva para produzir um interessante efeito de “campo estelar” (starfield) formado por cubos giratórios.

A aplicação permitirá alterar o ângulo de abertura do campo de visão, bem como alternar entre projeção perspectiva e projeção ortográfica.

O resultado ficará como a seguir.

Veja como o uso de projeção perspectiva é essencial para produzir o efeito desejado (compare com a projeção ortográfica).

Configuração inicial

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

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

    project(starfield)
    add_executable(${PROJECT_NAME} main.cpp model.cpp openglwindow.cpp)
    enable_abcg(${PROJECT_NAME})
  • Crie os seguintes arquivos vazios:

    • main.cpp;
    • openglwindow.cpp e openglwindow.hpp.
    • trackball.cpp e trackball.hpp.
  • Copie os arquivos model.cpp e model.hpp do projeto da seção anterior (seção 8.4), pois eles serão utilizados sem modificações.

  • Crie o subdiretório abcg/examples/starfield/assets e, dentro dele, crie os arquivos vazios depth.frag e depth.vert. Além disso, copie para este subdiretório o modelo box.obj disponível neste link.

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

starfield/
│   CMakeLists.txt
│   main.cpp
│   model.hpp
│   model.cpp
│   openglwindow.hpp
│   openglwindow.cpp
│
└───assets/
    │   box.obj
    │   depth.frag    
    └   depth.vert

main.cpp

O conteúdo é igual ao do projeto anterior. Só mudamos o título da janela:

#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 = "Starfield Effect"});

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

depth.vert

O vertex shader também é igual ao do projeto anterior. Só precisamos alterar -posEyeSpace.z / 3.0 para -posEyeSpace.z / 100.0 para que a intensidade da cor seja zero apenas quando \(z<-100\) no espaço da câmera.

#version 410

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 / 100.0);
  fragColor = vec4(i, i, i, 1) * color;

  gl_Position = projMatrix * posEyeSpace;
}

depth.frag

O conteúdo do fragment shader é mais simples que o depth.frag do projeto anterior. A cor recebida no atributo de entrada (fragColor) é enviada sem modificações para o atributo de saída (outColor):

#version 410

in vec4 fragColor;
out vec4 outColor;

void main() { outColor = fragColor; }

openglwindow.hpp

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

#ifndef OPENGLWINDOW_HPP_
#define OPENGLWINDOW_HPP_

#include <random>

#include "abcg.hpp"
#include "model.hpp"

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:
  static const int m_numStars{500};

  GLuint m_program{};

  int m_viewportWidth{};
  int m_viewportHeight{};

  std::default_random_engine m_randomEngine;

  Model m_model;

  std::array<glm::vec3, m_numStars> m_starPositions;
  std::array<glm::vec3, m_numStars> m_starRotations;
  float m_angle{};

  glm::mat4 m_viewMatrix{1.0f};
  glm::mat4 m_projMatrix{1.0f};
  float m_FOV{30.0f};

  void randomizeStar(glm::vec3 &position, glm::vec3 &rotation);
  void update();
};

#endif

O objeto m_model, definido na linha 27, é utilizado para armazenar o VBO/EBO e VAO que representa a estrela do campo estrelado. No nosso caso, a estrela será um cubo centralizado na origem, definido pelo arquivo box.obj. A cena 3D será formada por 500 instâncias do cubo. Esse número é definido pela constante m_numStars na linha 18.

Nas linhas 29 e 30 são definidos arranjos de 500 posições aleatórias (m_starPositions) e 500 eixos aleatórios de rotação (m_starRotations), um para cada cubo da cena. Através desses atributos podemos criar uma matriz de modelo para cada cubo. Cada matriz de modelo será definida como uma concatenação

\[ \mathbf{M}_{\textrm{model}}=\mathbf{T}\mathbf{S}\mathbf{R}, \]

onde \(\mathbf{R}\) é uma rotação que faz o cubo centralizado na origem girar em torno de um eixo especificado em m_starRotations, \(\mathbf{S}\) é um fator de escala uniforme (usaremos \(s=0.2\) para todos os cubos) e \(\mathbf{T}\) é a translação que faz com que o cubo rotacionado seja posicionado no espaço do mundo na posição indicada em m_starPositions.

Na linha 31, m_angle é um ângulo de rotação em radianos que será utilizado para rodar cada cubo por seu respectivo eixo de rotação (os cubos rodam pelo mesmo ângulo, mas cada um tem seu próprio eixo aleatório de rotação).

Nas linhas 33 e 34 são definidas as matrizes de visão (m_viewMatrix) e de projeção (m_projMatrix). Aqui elas estão como matrizes identidade, mas serão definidas posteriormente em OpenGLWindow::initializeGL e OpenGLWindow::paintUI.

Na linha 35, o atributo m_FOV é o ângulo de abertura vertical do campo de visão da projeção perspectiva. Esse valor poderá ser controlado por um slider da ImGui, variando de \(5^\circ\) a \(179^\circ\).

A função OpenGLWindow::randomizeStar (linha 37) usa o gerador de números pseudoaleatórios m_randomEngine (linha 25) para sortear uma posição e eixo de rotação. O resultado é armazenado nos parâmetros position e rotation passados por referência.

openglwindow.cpp

O arquivo openglwindow.cpp começa com a definição de OpenGLWindow::initializeGL:

#include "openglwindow.hpp"

#include <imgui.h>

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

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

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

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

  // Load model
  m_model.loadObj(getAssetsPath() + "box.obj");

  m_model.setupVAO(m_program);

  // Camera at (0,0,0) and looking towards the negative z
  m_viewMatrix =
      glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 0.0f, -1.0f),
                  glm::vec3(0.0f, 1.0f, 0.0f));

  // Setup stars
  for (const auto index : iter::range(m_numStars)) {
    auto &position{m_starPositions.at(index)};
    auto &rotation{m_starRotations.at(index)};

    randomizeStar(position, rotation);
  }
}

Nas linhas 24 a 26 é definida a matriz de visão como uma câmera LookAt que está na origem do espaço do mundo e está olhando na direção do eixo \(z\) negativo. Durante a execução, a câmera permanecerá fixa nessa posição e orientação. As estrelas (cubos) é que mudarão de posição para produzir o efeito de animação.

O laço das linhas 29 a 34 itera sobre cada elemento do arranjo m_starPositions e m_starRotations, e chama OpenGLWindow::randomizeStar para sortear uma posição inicial e eixo de rotação inicial para cada estrela.

A definição de OpenGLWindow::randomizeStar ficará como a seguir:

void OpenGLWindow::randomizeStar(glm::vec3 &position, glm::vec3 &rotation) {
  // Get random position
  // x and y coordinates in the range [-20, 20]
  // z coordinates in the range [-100, 0]
  std::uniform_real_distribution<float> distPosXY(-20.0f, 20.0f);
  std::uniform_real_distribution<float> distPosZ(-100.0f, 0.0f);

  position = glm::vec3(distPosXY(m_randomEngine), distPosXY(m_randomEngine),
                       distPosZ(m_randomEngine));

  //  Get random rotation axis
  std::uniform_real_distribution<float> distRotAxis(-1.0f, 1.0f);

  rotation = glm::normalize(glm::vec3(distRotAxis(m_randomEngine),
                                      distRotAxis(m_randomEngine),
                                      distRotAxis(m_randomEngine)));
}

Para a escolha da posição aleatória \((x,y,z)\):

  • As coordenadas \(x\) e \(y\) são escolhidas do intervalo \([-20, 20]\);
  • A coordenada \(z\) é escolhida do intervalo \([-100, 0]\).

Assim, o campo estrelado é a região cuboide que vai de \((-20,-20,-100)\) a \((20,20,0)\). Lembre-se que a câmera está em \(z=0\), olhando para \(z\) negativo. Logo, a câmera pode enxergar potencialmente toda essa região, a depender das configurações de projeção.

Para a escolha do eixo de rotação aleatório, criamos um vetor com coordenadas aleatórias do intervalo \([-1,1]\) em ponto flutuante e então normalizamos o vetor.

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

void OpenGLWindow::paintGL() {
  update();

  // 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);

  // Get location of uniform variables (could be precomputed)
  const GLint viewMatrixLoc{
      abcg::glGetUniformLocation(m_program, "viewMatrix")};
  const GLint projMatrixLoc{
      abcg::glGetUniformLocation(m_program, "projMatrix")};
  const GLint modelMatrixLoc{
      abcg::glGetUniformLocation(m_program, "modelMatrix")};
  const GLint colorLoc{abcg::glGetUniformLocation(m_program, "color")};

  // Set uniform variables used by every scene object
  abcg::glUniformMatrix4fv(viewMatrixLoc, 1, GL_FALSE, &m_viewMatrix[0][0]);
  abcg::glUniformMatrix4fv(projMatrixLoc, 1, GL_FALSE, &m_projMatrix[0][0]);
  abcg::glUniform4f(colorLoc, 1.0f, 1.0f, 1.0f, 1.0f);  // White

  // Render each star
  for (const auto index : iter::range(m_numStars)) {
    const auto &position{m_starPositions.at(index)};
    const auto &rotation{m_starRotations.at(index)};

    // Compute model matrix of the current star
    glm::mat4 modelMatrix{1.0f};
    modelMatrix = glm::translate(modelMatrix, position);
    modelMatrix = glm::scale(modelMatrix, glm::vec3(0.2f));
    modelMatrix = glm::rotate(modelMatrix, m_angle, rotation);

    // Set uniform variable
    abcg::glUniformMatrix4fv(modelMatrixLoc, 1, GL_FALSE, &modelMatrix[0][0]);

    m_model.render();
  }

  abcg::glUseProgram(0);
}

Logo no início, a função OpenGLWindow::update é chamada (linha 56) para atualizar a posição e rotação das estrelas de modo a produzir o efeito de animação.

Nas linhas 75 a 77 são definidos os valores das variáveis uniformes compartilhadas por todas as estrelas. Em particular, todas as estrelas usam a mesma matriz de visão e projeção. Além disso, todas usam a mesma cor (branca).

No laço da linha 80, cada estrela é renderizada. A matriz de modelo é construída nas linhas 84 a 88 usando as informações da estrela em m_starPositions e m_starRotations.

A matriz de modelo é copiada para o vertex shader na linha 91. Em seguida, na linha 93, a função Model::render é chamada para renderizar o objeto que representa a estrela, isto é, o cubo.

É importante observar que, embora o mesmo cubo seja renderizado 500 vezes, cada iteração utiliza uma matriz de modelo diferente. Podemos fazer isso pois todas as estrelas usam a mesma malha de triângulos (8 vértices e 12 triângulos). O que muda é a posição e orientação. Atributos como posição, orientação e escala da malha podem ser modificados no vertex shader por matrizes de transformação, que é o que estamos fazendo.

A definição de OpenGLWindow::paintUI ficará assim:

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

  {
    const auto widgetSize{ImVec2(218, 62)};
    ImGui::SetNextWindowPos(ImVec2(m_viewportWidth - widgetSize.x - 5, 5));
    ImGui::SetNextWindowSize(widgetSize);
    ImGui::Begin("Widget window", nullptr, ImGuiWindowFlags_NoDecoration);

    {
      ImGui::PushItemWidth(120);
      static std::size_t currentIndex{};
      const std::vector<std::string> comboItems{"Perspective", "Orthographic"};

      if (ImGui::BeginCombo("Projection",
                            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();

      ImGui::PushItemWidth(170);
      const auto aspect{static_cast<float>(m_viewportWidth) /
                        static_cast<float>(m_viewportHeight)};
      if (currentIndex == 0) {
        m_projMatrix =
            glm::perspective(glm::radians(m_FOV), aspect, 0.01f, 100.0f);

        ImGui::SliderFloat("FOV", &m_FOV, 5.0f, 179.0f, "%.0f degrees");
      } else {
        m_projMatrix = glm::ortho(-20.0f * aspect, 20.0f * aspect, -20.0f,
                                  20.0f, 0.01f, 100.0f);
      }
      ImGui::PopItemWidth();
    }

    ImGui::End();
  }
}

Esse é o código que define os widgets da ImGui. Assim como fizemos no projeto anterior, a matriz de projeção (m_projMatrix) é definida como projeção perspectiva ou ortográfica de acordo com a seleção do usuário.

O restante de openglwindow.cpp ficará assim:

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

void OpenGLWindow::terminateGL() {
  m_model.terminateGL();
  abcg::glDeleteProgram(m_program);
}

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

  // Update stars
  for (const auto index : iter::range(m_numStars)) {
    auto &position{m_starPositions.at(index)};
    auto &rotation{m_starRotations.at(index)};

    // Z coordinate increases by 10 units per second
    position.z += deltaTime * 10.0f;

    // If this star is behind the camera, select a new random position and
    // orientation, and move it back to -100
    if (position.z > 0.1f) {
      randomizeStar(position, rotation);
      position.z = -100.0f;  // Back to -100
    }
  }
}

Em OpenGLWindow::update, incrementamos m_angle a uma taxa de \(90^\circ\) por segundo (linha 157).

Além disso, iteramos sobre todas as estrelas no laço da linha 160. A coordenada \(z\) da posição de cada estrela é incrementada a uma taxa de 10 unidades por segundo (linha 165). Assim, uma estrela que começa em \(z=-100\) (distância máxima em relação à câmera) chega até \(z=0\) (onde está a câmera) em 10 segundos.

A condicional da linha 169 verifica se a estrela já passou para trás da câmera. Verificamos se a coordenada \(z\) da posição é maior que \(0.1\) pois a estrela é um cubo unitário que foi reduzido em tamanho por um fator de escala \(0.2\). Logo, quando o cubo está em \(z=0\) (isto é, na posição \(z\) da câmera), ainda está com metade da malha triangular (metade do cubo) no espaço \(z<0\). Isso significa que o cubo ainda pode ser visto pela câmera, pois a câmera está olhando na direção de \(z\) negativo e a distância do plano de recorte próximo foi definida como \(0.01\) (veja a chamada a glm::perspective na linha 130). Se \(z>0.1\), então com certeza o cubo está totalmente atrás da câmera. Quando isso acontece, OpenGLWindow::randomizeStar é chamada novamente para sortear uma nova posição e eixo de rotação para a estrela. Além disso, a estrela é deslocada para \(z=-100\) para começar um novo percurso em direção ao plano \(z=0\) da câmera.

Isso conclui o projeto starfield!

Baixe o código completo usando este link.