4.4 Triângulos coloridos

Na seção 3.5, renderizamos pontos (GL_POINTS) para gerar o Triângulo de Sierpinski. Neste projeto, desenharemos triângulos (GL_TRIANGLES). Para cada quadro de exibição, renderizaremos um triângulo colorido com coordenadas 2D aleatórias dentro do viewport que ocupa toda a janela da aplicação. O resultado ficará como a seguir:

Durante o desenvolvimento desta atividade veremos com mais detalhes os comandos do OpenGL utilizados para especificar os dados gráficos e configurar o pipeline.

Configuração inicial

Repita a configuração inicial dos projetos anteriores e mude o nome do projeto para coloredtriangles.

O arquivo abcg/examples/CMakeLists.txt ficará assim (com a compilação desabilitada para os projetos anteriores):

# add_subdirectory(helloworld)
# add_subdirectory(firstapp)
# add_subdirectory(tictactoe)
# add_subdirectory(sierpinski)
add_subdirectory(coloredtriangles)

O arquivo abcg/examples/coloredtriangles/CMakeLists.txt ficará assim:

project(coloredtriangles)
add_executable(${PROJECT_NAME} main.cpp window.cpp)
enable_abcg(${PROJECT_NAME})

Como nos projetos anteriores, crie os arquivos main.cpp, window.cpp e window.hpp em abcg/examples/coloredtriangles. Vamos editá-los a seguir.

main.cpp

O conteúdo de main.cpp é praticamente idêntico ao do projeto sierpinski:

#include "window.hpp"

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

    Window window;
    window.setOpenGLSettings(
        {.samples = 2, .doubleBuffering = false});
    window.setWindowSettings(
        {.width = 600, .height = 600, .title = "Colored Triangles"});

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

window.hpp

A definição da classe Window também é parecida com a do projeto anterior:

#ifndef WINDOW_HPP_
#define WINDOW_HPP_

#include <random>

#include "abcgOpenGL.hpp"

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

private:
  glm::ivec2 m_viewportSize{};

  GLuint m_VAO{};
  GLuint m_VBOPositions{};
  GLuint m_VBOColors{};
  GLuint m_program{};

  std::default_random_engine m_randomEngine;

  std::array<glm::vec4, 3> m_colors{{{0.36f, 0.83f, 1.00f, 1},
                                     {0.63f, 0.00f, 0.61f, 1},
                                     {1.00f, 0.69f, 0.30f, 1}}};

  void setupModel();
};

#endif

No projeto sierpinski utilizamos apenas um VBO (m_VBOVertices). Dessa vez usaremos dois VBOs: um para a posição dos vértices (m_VBOPositions) e outro para as cores (m_VOBColors).

O arranjo m_vertexColors contém as cores RGBA que serão copiadas para m_VBOColors. São três cores, uma para cada vértice do triângulo.

window.cpp

Vamos primeiro incluir o cabeçalho window.hpp e a definição de Window::onCreate:

#include "window.hpp"

void Window::onCreate() {
  auto const *vertexShader{R"gl(#version 300 es
                                   
    layout(location = 0) in vec2 inPosition;
    layout(location = 1) in vec4 inColor;

    out vec4 fragColor;

    void main() { 
      gl_Position = vec4(inPosition, 0, 1);
      fragColor = inColor;
    }
  )gl"};

  auto const *fragmentShader{R"gl(#version 300 es
                                     
    precision mediump float;

    in vec4 fragColor;

    out vec4 outColor;

    void main() { outColor = fragColor; }
  )gl"};

  m_program = abcg::createOpenGLProgram(
      {{.source = vertexShader, .stage = abcg::ShaderStage::Vertex},
       {.source = fragmentShader, .stage = abcg::ShaderStage::Fragment}});

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

  auto const seed{std::chrono::steady_clock::now().time_since_epoch().count()};
  m_randomEngine.seed(seed);
}

O código é praticamente idêntico ao do projeto anterior.

Observe o conteúdo da string em vertexShader. A string é inicializada com um raw string literal. O código do shader é o texto que está entre R"gl( e )gl":

#version 300 es

layout(location = 0) in vec2 inPosition;
layout(location = 1) in vec4 inColor;

out vec4 fragColor;

void main() { 
  gl_Position = vec4(inPosition, 0, 1);
  fragColor = inColor;
}

Este vertex shader define dois atributos de entrada: inPosition, que recebe a posição 2D do vértice, e inColor que recebe a cor do vértice em formato RGBA. A saída, fragColor, é também uma cor RGBA. Na função main, a posição \((x,y)\) do vértice é repassada sem modificações para gl_Position. A conversão de \((x,y)\) em coordenadas cartesianas para \((x,y,0,1)\) em coordenadas homogêneas preserva a geometria do triângulo20. A cor do atributo de entrada também é repassada sem modificações para o atributo de saída.

Vejamos agora o fragment shader:

#version 300 es

precision mediump float;

in vec4 fragColor;

out vec4 outColor;

void main() { outColor = fragColor; }

O fragment shader é mais simples. O atributo de entrada (fragColor) é copiado sem modificações para o atributo de saída (outColor).

A compilação e ligação dos shaders é feita pela chamada a abcg::createOpenGLProgram na linha 26. O resultado é m_program, um número inteiro que identifica o programa de shader composto pelo par de vertex/fragment shader. Para ativar o programa no pipeline, devemos chamar glUseProgram(m_program). Para desativá-lo, podemos ativar outro programa (se existir) ou chamar glUseProgram(0).

A função Window::onPaint é definida assim:

void Window::onPaint() {
  setupModel();

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

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

  abcg::glDrawArrays(GL_TRIANGLES, 0, 3);

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

Novamente, o código é similar ao utilizado no projeto sierpinski. O comando de renderização, glDrawArrays, dessa vez usa GL_TRIANGLES e 3 vértices, sendo que o índice inicial dos vértices no arranjo é 0. Isso significa que o pipeline desenhará apenas um triângulo.

Em Window::onPaintUI, usaremos controles de UI da ImGui para criar uma pequena janela de edição das três cores dos vértices:

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

  {
    auto const widgetSize{ImVec2(250, 90)};
    ImGui::SetNextWindowPos(ImVec2(m_viewportSize.x - widgetSize.x - 5,
                                   m_viewportSize.y - widgetSize.y - 5));
    ImGui::SetNextWindowSize(widgetSize);
    auto windowFlags{ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoTitleBar};
    ImGui::Begin(" ", nullptr, windowFlags);

    // Edit vertex colors
    auto colorEditFlags{ImGuiColorEditFlags_NoTooltip |
                        ImGuiColorEditFlags_NoPicker};
    ImGui::PushItemWidth(215);
    ImGui::ColorEdit3("v0", &m_colors.at(0).x, colorEditFlags);
    ImGui::ColorEdit3("v1", &m_colors.at(1).x, colorEditFlags);
    ImGui::ColorEdit3("v2", &m_colors.at(2).x, colorEditFlags);
    ImGui::PopItemWidth();

    ImGui::End();
  }
}

As funções ImGui::SetNextWindowPos e ImGui::SetNextWindowSize definem a posição e tamanho da janela da ImGui que está prestes a ser criada na linha 70. A janela é inicializada com alguns flags para que ela não possa ser redimensionada (ImGuiWindowFlags_NoResize) e não tenha a barra de título (ImGuiWindowFlags_NoTitleBar). Os controles ImGui::ColorEdit3 também são criados com flags para desabilitar o color picker (ImGuiColorEditFlags_NoPicker) e os tooltips (ImGuiColorEditFlags_NoTooltip), pois eles podem atrapalhar o desenho dos triângulos.

A definição de Window::onResize é idêntica à do projeto sierpinski. A definição de Window::onDestroy também é bem semelhante e libera os recursos do pipeline:

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

  abcg::glClear(GL_COLOR_BUFFER_BIT);
}

void Window::onDestroy() {
  abcg::glDeleteProgram(m_program);
  abcg::glDeleteBuffers(1, &m_VBOPositions);
  abcg::glDeleteBuffers(1, &m_VBOColors);
  abcg::glDeleteVertexArrays(1, &m_VAO);
}

Vamos agora à função Window::setupModel. A definição completa é dada a seguir. Em seguida faremos uma análise detalhada de cada trecho:

void Window::setupModel() {
  abcg::glDeleteBuffers(1, &m_VBOPositions);
  abcg::glDeleteBuffers(1, &m_VBOColors);
  abcg::glDeleteVertexArrays(1, &m_VAO);

  // Create array of random vertex positions
  std::uniform_real_distribution rd(-1.5f, 1.5f);
  std::array<glm::vec2, 3> const positions{
      {{rd(m_randomEngine), rd(m_randomEngine)},
       {rd(m_randomEngine), rd(m_randomEngine)},
       {rd(m_randomEngine), rd(m_randomEngine)}}};

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

  // Generate VBO of colors
  abcg::glGenBuffers(1, &m_VBOColors);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_VBOColors);
  abcg::glBufferData(GL_ARRAY_BUFFER, sizeof(m_colors), m_colors.data(),
                     GL_STATIC_DRAW);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

  // Get location of attributes in the program
  auto const positionAttribute{
      abcg::glGetAttribLocation(m_program, "inPosition")};
  auto const colorAttribute{abcg::glGetAttribLocation(m_program, "inColor")};

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

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

  abcg::glEnableVertexAttribArray(positionAttribute);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_VBOPositions);
  abcg::glVertexAttribPointer(positionAttribute, 2, GL_FLOAT, GL_FALSE, 0,
                              nullptr);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

  abcg::glEnableVertexAttribArray(colorAttribute);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_VBOColors);
  abcg::glVertexAttribPointer(colorAttribute, 4, GL_FLOAT, GL_FALSE, 0,
                              nullptr);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

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

As linhas 96 a 98 liberam os VBOs e o VAO, caso tenham sido criados anteriormente:

  abcg::glDeleteBuffers(1, &m_VBOPositions);
  abcg::glDeleteBuffers(1, &m_VBOColors);
  abcg::glDeleteVertexArrays(1, &m_VAO);

É importante fazer isso, pois a função Window::setupModel é chamada continuamente em Window::onPaint. Se não liberarmos os recursos, em algum momento eles consumirão toda a memória da GPU e CPU21.

As linhas 100 a 105 criam um arranjo com as posições dos vértices. Esse arranjo será copiado para o VBO m_VBOPositions. Também precisamos copiar as cores para m_VBOColors, mas nesse caso não precisamos criar um novo arranjo pois já temos m_colors definido como membro de Window:

  // Create array of random vertex positions
  std::uniform_real_distribution rd(-1.5f, 1.5f);
  std::array<glm::vec2, 3> const positions{
      {{rd(m_randomEngine), rd(m_randomEngine)},
       {rd(m_randomEngine), rd(m_randomEngine)},
       {rd(m_randomEngine), rd(m_randomEngine)}}};

As coordenadas das posições dos vértices são valores float pseudoaleatórios no intervalo \([-1.5, 1.5)\). Vimos no projeto anterior que, para uma primitiva ser vista no viewport, ela precisa ser especificada entre \([-1, -1]\) e \([1, 1]\). Logo, nossos triângulos terão partes que ficarão para fora da janela. O pipeline se encarregará de recortar os triângulos e mostrar apenas os fragmentos que estão dentro do viewport.

Nas linhas 107 a 119 são criados os VBOs (um para as posições 2D, outro para as cores RGBA):

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

  // Generate VBO of colors
  abcg::glGenBuffers(1, &m_VBOColors);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_VBOColors);
  abcg::glBufferData(GL_ARRAY_BUFFER, sizeof(m_colors), m_colors.data(),
                     GL_STATIC_DRAW);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);
  • glGenBuffers cria o identificador de um objeto de buffer (buffer object). Um objeto de buffer é um arranjo de dados alocado pelo OpenGL, geralmente na memória da GPU.

  • glBindBuffer com o argumento GL_ARRAY_BUFFER vincula o objeto de buffer a um buffer de atributos de vértices. Isso define o objeto de buffer como um objeto de buffer de vértice (VBO). O objeto de buffer pode ser desvinculado com glBindBuffer(0), ou vinculando outro objeto de buffer.

  • glBufferData aloca a memória e inicializa o buffer com o conteúdo copiado de um ponteiro alocado na CPU. O primeiro parâmetro indica o tipo de objeto de buffer utilizado. O segundo parâmetro é o tamanho do buffer em bytes. O terceiro parâmetro é um ponteiro para o primeiro elemento do arranjo contendo os dados que serão copiados. O quarto parâmetro é uma “dica” fornecida ao driver de como o buffer será utilizado. Essas dicas podem ser:

    • GL_STATIC_DRAW significa que o buffer será modificado apenas uma vez, mas utilizado muitas vezes.
    • GL_STREAM_DRAW significa que o buffer será modificado apenas uma vez e utilizado algumas poucas vezes.
    • GL_DYNAMIC_DRAW significa que o buffer será modificado repetidamente, e utilizado também muitas vezes. As modificações do buffer podem ser feitas com comandos como glBufferData, glBufferSubData e glMapBuffer.

    O sufixo DRAW nesses identificadores significa que os dados são escritos pela aplicação e utilizados em um comando de desenho. Além do DRAW é possível usar o sufixo READ (os dados são escritos pela GPU e lidos pela aplicação) e COPY (os dados são escritos pela GPU e utilizados em um comando de desenho).

Após a cópia dos dados com o glBufferData, o arranjo do qual os dados foram copiados não é mais necessário e pode ser destruído. No nosso código, positions é um arranjo alocado na pilha e portanto é liberado no fim do escopo da função.

As linhas 121 a 124 usam glGetAttribLocation para obter o número de localização de cada atributo de entrada do vertex shader de m_program:

  // Get location of attributes in the program
  auto const positionAttribute{
      abcg::glGetAttribLocation(m_program, "inPosition")};
  auto const colorAttribute{abcg::glGetAttribLocation(m_program, "inColor")};

O resultado de positionAttribute será 0, pois o vertex shader define inPosition com layout(location = 0). Da mesma forma, colorAttribute será 1, pois o vertex shader define inColor com layout(location = 1). Poderíamos omitir esse código e escrever os valores 0 e 1 diretamente no código como valores hard-coded, mas é recomendável fazer a consulta da localização com glGetAttribLocation.

Agora que sabemos a localização dos atributos inPosition e inColor no vertex shader, podemos especificar como os dados de cada VBO serão mapeados para esses atributos. Isso é feito nas linhas 126 a 145 a seguir:

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

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

  abcg::glEnableVertexAttribArray(positionAttribute);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_VBOPositions);
  abcg::glVertexAttribPointer(positionAttribute, 2, GL_FLOAT, GL_FALSE, 0,
                              nullptr);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

  abcg::glEnableVertexAttribArray(colorAttribute);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_VBOColors);
  abcg::glVertexAttribPointer(colorAttribute, 4, GL_FLOAT, GL_FALSE, 0,
                              nullptr);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

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

Na linha 127, glGenVertexArray cria um VAO que, como vimos no projeto sierpinski, armazena o estado da especificação de vinculação dos VBOs com o vertex shader. Neste projeto, essa especificação é feita nas linhas 132 a 142.

Em Window::onPaint, quando vinculamos esse VAO com glBindVertexArray, o estado da configuração dos VBOs com o programa de shader é recuperado automaticamente (isto é, a configuração feita nas linhas 132 a 142). Na nossa aplicação isso não é incrivelmente útil: as linhas 132 a 142 já são executadas para todo quadro de exibição, pois chamamos Window::setupModel logo antes de glDrawArrays. Mas, em aplicações futuras, chamaremos Window::setupModel apenas uma vez (por exemplo, em Window::onCreate). Geralmente, o modelo geométrico é definido apenas uma vez e nunca mais é alterado (ou é raramente alterado). Nesse caso, o VAO é útil para que não tenhamos de configurar manualmente a ligação dos VBOs com os atributos do vertex shader para todo quadro de exibição.

Ajustando a taxa de atualização

Neste momento, se compilarmos e executarmos a aplicação, perceberemos que os triângulos coloridos estão sendo gerados muito rapidamente. Isso ocorre porque um novo triângulo é desenhado a cada chamada a Window::onPaint, na maior taxa possível que a GPU consegue suportar. Geralmente isso é muito acima da taxa de atualização do monitor. Uma tentativa de solucionar isso é habilitar o modo Vsync (sincronização vertical) através da seguinte modificação em main.cpp:

    window.setOpenGLSettings(
        {.samples = 2, .vSync = true, .doubleBuffering = false});

Com a sincronização vertical ativada, as chamadas de Window::onPaint serão sincronizadas com a taxa de atualização do monitor, geralmente 60 Hz. O problema é que essa solução muito provavelmente não funcionará em nossa aplicação. Muitos drivers de vídeo só permitem habilitar o modo Vsync quando o double buffering está habilitado, o que não é nosso caso. Precisamos de outra estratégia para ajustar a velocidade da geração de novos triângulos. Uma solução é usar um temporizador e desenhar um novo triângulo apenas se um intervalo de tempo tiver decorrido desde o desenho do último triângulo. É isso que faremos a seguir.

Adicione abcg::Timer m_timer como variável membro de Window. abcg::Timer é uma classe da ABCg que implementa um temporizador usando std::chrono, da biblioteca padrão do C++. Quando o objeto m_timer é criado, o horário de sua inicialização é armazenado internamente. Podemos chamar m_timer.elapsed() sempre que quisermos saber quantos segundos se passaram desde o início do temporizador. Podemos também chamar m_timer.restart() para reiniciar o temporizador.

Modifique Window::onPaint com o seguinte código:

void Window::onPaint() {
  // Check whether to render the next triangle
  if (m_timer.elapsed() < 1.0 / 20)
    return;
  m_timer.restart();

  setupModel();

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

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

  abcg::glDrawArrays(GL_TRIANGLES, 0, 3);

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

Na linha 41, verificamos se o temporizador já passou de 0.05 segundos (1.0 / 20). Se ainda não, precisamos aguardar mais algum tempo antes de desenhar um novo triângulo, e por isso retornamos na linha 42. Em algum momento, depois de algumas chamadas de Window::onPaint, o temporizador terá passado 0.05 segundos. Neste caso, reiniciamos o temporizador na linha 43 e continuamos com a execução das instruções do restante do código para desenhar um novo triângulo. Desse modo, a taxa de geração de triângulos será fixada em 20 por segundo.

Execute o código novamente e veja o resultado.

O projeto completo pode ser baixado deste link.

Exercício

Habilite o modo de mistura de cor usando o código mostrado na seção 4.3. Inclua o código a seguir em Window::onCreate:

abcg::glEnable(GL_BLEND);
abcg::glBlendEquation(GL_FUNC_ADD);
abcg::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); 

Além disso, mude a componente A (alpha) das cores RGBA de m_colors. Por exemplo, com a definição a seguir, os triângulos ficarão 50% transparentes:

std::array<glm::vec4, 3> m_colors{{{0.36f, 0.83f, 1.00f, 0.5f},
                                   {0.63f, 0.00f, 0.61f, 0.5f},
                                   {1.00f, 0.69f, 0.30f, 0.5f}}};
Exercício

Modifique o projeto coloredtriangles para suportar novas funcionalidades:

  • Geração de cores aleatórias nos vértices;
  • Possibilidade de desenhar cada triângulo com uma cor sólida;
  • Ajuste do intervalo de tempo entre a renderização de cada triângulo.

Um exemplo é dado a seguir:


  1. O conceito de coordenadas homogêneas será abordado futuramente, quando trabalharmos com transformações geométricas 3D.↩︎

  2. Em geral, destruir e criar os VBOs a cada quadro de exibição não é muito eficiente. É preferível criar o VBO apenas uma vez e, se necessário, modificá-lo com glBufferData a cada quadro. Em nossa aplicação, optamos por chamar setupModel a cada Window::onPaint apenas para manter o código mais simples.↩︎