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 window.cpp)
    enable_abcg(${PROJECT_NAME})
  • Crie os seguintes arquivos vazios:

    • main.cpp;
    • window.cpp e window.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
│   window.hpp
│   window.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 "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 = "Starfield Effect",
    });

    app.run(window);
  } catch (std::exception const &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 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 / 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 300 es

precision mediump float;

in vec4 fragColor;
out vec4 outColor;

void main() { outColor = fragColor; }

window.hpp

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

#ifndef WINDOW_HPP_
#define WINDOW_HPP_

#include <random>

#include "abcgOpenGL.hpp"
#include "model.hpp"

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

private:
  std::default_random_engine m_randomEngine;

  glm::ivec2 m_viewportSize{};

  Model m_model;

  struct Star {
    glm::vec3 m_position{};
    glm::vec3 m_rotationAxis{};
  };

  std::array<Star, 500> m_stars;

  float m_angle{};

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

  GLuint m_program{};

  void randomizeStar(Star &star);
};

#endif

O objeto m_model, definido na linha 23, é utilizado para armazenar o VBO/EBO e VAO que representa a estrela do campo estrelado. A estrela, em seu espaço local, será um cubo centralizado na origem, definido pelo arquivo box.obj.

Cada estrela é representada por uma estrutura Star que contém uma posição (posição do centro do cubo) e um eixo de rotação. A partir desses atributos conseguiremos criar a matriz de modelo que posicionará a estrela no espaço do mundo. 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 do eixo especificado em m_rotationAxis, \(\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_position.

A cena 3D será formada por 500 estrelas (arranjo m_stars).

Na linha 32, 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 34 e 35 são definidas as matrizes de visão (m_viewMatrix) e de projeção (m_projMatrix). Aqui elas estão como matrizes identidade por padrão, mas serão modificadas posteriormente em Window::onCreate e Window::onPaintUI.

Na linha 36, 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 Window::randomizeStar (linha 40) usa o gerador de números pseudoaleatórios m_randomEngine (linha 19) para sortear uma posição e eixo de rotação para a estrela star passada por referência.

window.cpp

O arquivo window.cpp começa com a definição de Window::onCreate:

#include "window.hpp"

#include <glm/gtc/random.hpp>
#include <glm/gtx/fast_trigonometry.hpp>

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

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

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

  m_model.loadObj(assetsPath + "box.obj");
  m_model.setupVAO(m_program);

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

  // Setup stars
  for (auto &star : m_stars) {
    randomizeStar(star);
  }
}

Na linha 25 é definida a matriz de visão como uma câmera LookAt que está na origem do espaço do mundo, 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 (isto é, os cubos) é que mudarão de posição para produzir o efeito de animação.

O laço das linhas 28 a 30 itera sobre cada elemento do arranjo m_stars e chama Window::randomizeStar para sortear uma posição e eixo de rotação inicial para cada estrela.

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

void Window::randomizeStar(Star &star) {
  // Random position: x and y in [-20, 20), z in [-100, 0)
  std::uniform_real_distribution<float> distPosXY(-20.0f, 20.0f);
  std::uniform_real_distribution<float> distPosZ(-100.0f, 0.0f);
  star.m_position =
      glm::vec3(distPosXY(m_randomEngine), distPosXY(m_randomEngine),
                distPosZ(m_randomEngine));

  // Random rotation axis
  star.m_rotationAxis = glm::sphericalRand(1.0f);
}

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, utilizamos um vetor unitário aleatório calculado com glm::sphericalRand.

Vamos agora à definição de Window::onUpdate que atualiza a posição e a rotação das estrelas de modo a produzir o efeito de animação:

void Window::onUpdate() {
  // Increase angle by 90 degrees per second
  auto const deltaTime{gsl::narrow_cast<float>(getDeltaTime())};
  m_angle = glm::wrapAngle(m_angle + glm::radians(90.0f) * deltaTime);

  // Update stars
  for (auto &star : m_stars) {
    // Increase z by 10 units per second
    star.m_position.z += deltaTime * 10.0f;

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

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

A condicional da linha 57 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 estiver em \(z=0\) (isto é, na posição \(z\) da câmera), ainda estará com metade da malha triangular (metade do cubo) no espaço \(z<0\). Isso significa que o cubo ainda poderá 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 será definida como \(0.01\) (Veremos isso mais adiante). Se \(z>0.1\), então com certeza o cubo está totalmente atrás da câmera. Quando isso acontece, Window::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.

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

void Window::onPaint() {
  abcg::glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

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

  abcg::glUseProgram(m_program);

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

  // Set uniform variables that have the same value for every model
  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 (auto &star : m_stars) {
    // Compute model matrix of the current star
    glm::mat4 modelMatrix{1.0f};
    modelMatrix = glm::translate(modelMatrix, star.m_position);
    modelMatrix = glm::scale(modelMatrix, glm::vec3(0.2f));
    modelMatrix = glm::rotate(modelMatrix, m_angle, star.m_rotationAxis);

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

    m_model.render();
  }

  abcg::glUseProgram(0);
}

Nas linhas 79 a 81 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 84, cada estrela é renderizada. A matriz de modelo é construída nas linhas 86 a 89 usando os atributos da estrela.

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

É importante observar que o mesmo cubo (mesmo VBO) está seja renderizado 500 vezes, mas 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). A única coisa que muda entre o desenho de uma estrela e outra é 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 Window::onPaintUI ficará assim:

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

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

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

      if (ImGui::BeginCombo("Projection",
                            comboItems.at(currentIndex).c_str())) {
        for (auto const index : iter::range(comboItems.size())) {
          auto const 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);
      auto const aspect{gsl::narrow<float>(m_viewportSize.x) /
                        gsl::narrow<float>(m_viewportSize.y)};
      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 window.cpp ficará assim:

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

void Window::onDestroy() {
  m_model.destroy();
  abcg::glDeleteProgram(m_program);
}

Isso conclui o projeto starfield!

Baixe o código completo usando este link.