10.6 Mapeamento de ambiente

No modelo de reflexão de Phong e Blinn-Phong, a cor calculada em um ponto da superfície é determinada unicamente pela luz que incide diretamente sobre o ponto. A luz indireta (isto é, a luz refletida de outros objetos) é ignorada. Uma consequência disso é que não é possível representar superfícies que refletem o ambiente ao seu redor. Entretanto, a aparência de objetos reflexivos pode ser obtida através da técnica de mapeamento de ambiente.

Mapeamento de ambiente (environment mapping) é uma técnica de texturização que aproxima a aparência de superfícies reflexivas.

O exemplo interativo a seguir usa mapeamento de ambiente (mais especificamente, mapeamento cúbico) para simular uma superfície reflexiva. Use o botão esquerdo do mouse para rodar o objeto, e o botão direito para rodar o ambiente exibido como textura de fundo:

A ideia principal do mapeamento de ambiente consiste na definição de uma correspondência entre as coordenadas de um vetor 3D sobre um ponto da superfície – geralmente o vetor de reflexão ideal \(\hat{\mathbf{r}}\) – e as coordenadas de uma textura que representa o ambiente ao redor do ponto. O valor amostrado corresponde à iluminação ambiente refletida pela superfície.

Para simplificar, geralmente supõe-se que o ambiente é estático e está a uma distância infinita da superfície. Desse modo, o resultado não depende da translação do objeto que está sendo renderizado.

Há dois tipos principais de mapeamento de ambiente:

  • Mapeamento esférico (sphere mapping).
  • Mapeamento cúbico (cube mapping).

Mapeamento esférico

No mapeamento esférico, usa-se um mapa de textura esférico (sphere map) que contém a representação 2D de uma visão de \(360^{\circ}\) do ambiente. A figura 10.26 mostra um exemplo de mapa esférico.

Mapa de textura esférico (adaptado do [original](https://www.opengl.org/archives/resources/code/samples/advanced/advanced97/notes/node95.html)).

Figura 10.26: Mapa de textura esférico (adaptado do original).

Se o vetor de reflexão ideal de um ponto \(P\) de uma superfície tem coordenadas \(\hat{\mathbf{r}}=(x, y, z)\) no espaço da câmera, as respectivas coordenadas de textura no mapa esférico são calculadas como

\[ u=\frac{1}{2}\left(\frac{x}{m}+1\right),\\ v=\frac{1}{2}\left(\frac{y}{m}+1\right), \]

onde

\[ m=\sqrt{x^2+y^2+(z+1)^2}. \]

O valor resultante do mapa esférico amostrado em \((u,v)\) é a intensidade de cor do ambiente refletida em \(P\).

Embora o mapeamento entre o vetor unitário de coordenadas \((x,y,z)\) e as coordenadas de textura \((u,v)\) seja simples, o mapeamento esférico possui várias limitações:

  • O mapa de textura esférico precisa ser reconstruído sempre que a câmera mudar de orientação.
  • A representação 2D da visão de \(360^{\circ}\) introduz distorções severas no mapeamento do ambiente, principalmente nas bordas da esfera mapeada. Apenas o texel em \((u,v)=(0.5, 0.5)\) não tem distorção.
  • O vetor \((0,0,-1)\) não tem representação no mapa esférico. Essa direção corresponde a uma singularidade no mapeamento.

O mapeamento cúbico não tem tais restrições e por isso substitui o mapeamento esférico na maioria das aplicações.

Mapeamento cúbico

O mapeamento cúbico considera que cada ponto da superfície está situado no centro de um cubo imaginário que representa o ambiente ao redor do ponto. Cada lado do cubo corresponde a uma textura, de modo que qualquer direção no \(\mathbb{R}^3\) pode ser mapeada para uma posição única em alguma das seis texturas (figura 10.27).

Cada direção corresponde a um par de coordenadas de textura em um dos lados do cubo.

Figura 10.27: Cada direção corresponde a um par de coordenadas de textura em um dos lados do cubo.

A coleção de seis texturas, uma para cada lado do cubo, é chamada de mapa de textura cúbico (do inglês cubemap). A figura 10.28 mostra um exemplo de mapa de textura cúbico e seu mapeamento no cubo imaginário (mapa de textura por Emil Persson). Pela convenção do OpenGL, o cubo é definido em um sistema que segue a regra da mão esquerda.

Mapa de textura cúbico mapeado sobre as faces internas de um cubo.

Figura 10.28: Mapa de textura cúbico mapeado sobre as faces internas de um cubo.

Reflexão

Para aproximar uma superfície reflexiva, a direção do vetor \(\hat{\mathbf{r}}=(x,y,z)\) calculado sobre um ponto \(P\) da superfície é mapeada para coordenadas de textura \((u,v)\) em uma das seis texturas do mapa cúbico. O resultado da amostragem é a intensidade de luz do ambiente refletida por \(P\) (figura 10.29).

Mapeamento cúbico usando o vetor de reflexão ideal.

Figura 10.29: Mapeamento cúbico usando o vetor de reflexão ideal.

As coordenadas de textura são calculadas como

\[ u=\frac{1}{2}\left(\frac{u_c}{m}+1\right),\\ v=\frac{1}{2}\left(\frac{v_c}{m}+1\right), \]

onde

\[ m = \text{max}\{|x|, |y|, |z|\}, \]

e

\[ (u_c, v_c) = \begin{cases} (-z,-y) \quad\text{na textura +x} &\text{se}\quad x>0 \quad\text{e}\quad m=|x|, \\ (\phantom{-}z,-y) \quad\text{na textura}-\hspace{-0.25em}\text{x} &\text{se}\quad x\leq0 \quad\text{e}\quad m=|x|, \\ (\phantom{-}x,\phantom{-}z) \quad\text{na textura +y} &\text{se}\quad y>0 \quad\text{e}\quad m=|y|, \\ (\phantom{-}x,-z) \quad\text{na textura}-\hspace{-0.25em}\text{y} &\text{se}\quad y\leq0 \quad\text{e}\quad m=|y|, \\ (\phantom{-}x,-y) \quad\text{na textura +z} &\text{se}\quad z>0 \quad\text{e}\quad m=|z|, \\ (-x,-y) \quad\text{na textura}-\hspace{-0.25em}\text{z} &\text{se}\quad z\leq0 \quad\text{e}\quad m=|z|. \end{cases} \]

Refração

Refração é o fenômeno de mudança na direção de propagação da luz quando a luz transmitida por um meio muda para outro meio (por exemplo, do ar para a água). A figura 10.30 ilustra como a luz na direção \(\hat{\mathbf{i}}\) propagada no ar muda para uma direção \(\hat{\mathbf{t}}\) ao atravessar a superfície de diferentes materiais (água, vidro e diamante).

Refração em diferentes meios.

Figura 10.30: Refração em diferentes meios.

A relação entre o ângulo \(\theta_1\) (ângulo de incidência) e o ângulo \(\theta_2\) (ângulo de refração), é dada pela Lei de Snell:

\[ \frac{\sin{\theta_1}}{\sin{\theta_2}}=\frac{n_1}{n_2}, \]

onde \(n_1\) e \(n_2\) são os índices de refração dos meios.

A tabela 10.1 mostra os índices de refração de alguns materiais.

Tabela 10.1: Índices de refração.
Meio Índice de refração
Ar 1.00
Água 1.33
Gelo 1.31
Vidro 1.52
Diamante 2.42

O mapeamento de ambiente pode ser utilizado para simular o efeito de refração. Para isto, considera-se que o vetor \(\hat{\mathbf{i}}\) de luz incidente é a direção oposta do vetor até o observador, isto é,

\[ \hat{\mathbf{i}}=-\hat{\mathbf{v}}. \]

Uma vez calculado o vetor \(\hat{\mathbf{t}}\), basta amostrar o mapa de textura cúbico a partir das coordenadas de \(\hat{\mathbf{t}}\) no lugar de \(\hat{\mathbf{r}}\).

A figura 10.31 ilustra a geometria do cálculo do vetor \(\hat{\mathbf{t}}\) a partir do vetor \(\hat{\mathbf{i}}\), vetor \(\hat{\mathbf{n}}\) normal à superfície, e ângulos \(\theta_1\) e \(\theta_2\) de incidência e refração.

Geometria do vetor de refração.

Figura 10.31: Geometria do vetor de refração.

O vetor \(\hat{\mathbf{t}}\) pode ser escrito como a soma de dois vetores \(\mathbf{a}\) e \(\mathbf{b}\):

\[ \hat{\mathbf{t}}=\mathbf{a}+\mathbf{b}, \]

onde

\[ \begin{align} &\mathbf{a}=\hat{\mathbf{m}}\sin{\theta_2},\\ &\mathbf{b}=-\hat{\mathbf{n}}\cos{\theta_2}. \end{align} \]

O vetor \(\mathbf{a}\) depende do vetor unitário \(\hat{\mathbf{m}}\) calculado como

\[ \hat{\mathbf{m}}=\dfrac{\hat{\mathbf{i}}+\mathbf{c}}{\sin{\theta_1}}, \]

onde

\[ \mathbf{c}=\hat{\mathbf{n}}\cos{\theta_1}. \]

Observe que \(\hat{\mathbf{i}}+\mathbf{c}\) tem tamanho \(\sin{\theta_1}\). Logo, a divisão por \(\sin{\theta_1}\) resulta no vetor unitário \(\hat{\mathbf{m}}\).

Expandindo a equação \(\hat{\mathbf{t}}=\mathbf{a}+\mathbf{b}\) e combinando com a relação entre os senos dos ângulos (Lei de Snell), é possível chegar à forma simplificada:

\[ \hat{\mathbf{t}}=\eta\hat{\mathbf{i}}+(\eta c_1 - c_2)\hat{\mathbf{n}}, \]

onde \(\eta\) é a razão entre os índices de refração:

\[ \eta = \frac{n_1}{n_2}, \]

e

\[ \begin{align} &c_1=\cos{\theta_1}=\hat{\mathbf{n}}\cdot \hat{\mathbf{i}},\\ &c_2=\cos{\theta_2}=\sqrt{1 - \eta^2\left(1-c_1^2\right)}. \end{align} \]

Mapeamento de ambiente na prática

Vamos continuar com o desenvolvimento do visualizador de modelos 3D, desta vez acrescentando shaders de mapeamento cúbico para simular o efeito de reflexão e refração.

Esta será a versão 6 do visualizador (viewer6). Utilizaremos o código do projeto viewer5 apresentado na seção 10.5 e incluiremos os seguintes shaders:

  • cubereflect.vert e cubereflect.frag para simular o efeito de reflexão.
  • cuberefract.vert e cuberefract.frag para simular o efeito de refração.
  • skybox.vert e skybox.frag para mostrar o mapa de textura cúbico como uma textura de fundo;

O resultado ficará como a seguir:

Baixe o código completo deste link.

Carregando o mapa de textura cúbico

Nos projetos anteriores, utilizamos as funções Model::loadDiffuseTexture e Model::loadNormalTexture para carregar as texturas difusa e de normais. Agora, incluiremos a função Model::loadCubeTexture para criar o mapa de textura cúbico:

void Model::loadCubeTexture(const std::string& path) {
  if (!std::filesystem::exists(path)) return;

  abcg::glDeleteTextures(1, &m_cubeTexture);
  m_cubeTexture = abcg::opengl::loadCubemap(
      {path + "posx.jpg", path + "negx.jpg", path + "posy.jpg",
       path + "negy.jpg", path + "posz.jpg", path + "negz.jpg"});
}

A textura é identificada por m_cubeTexture.

Model::loadCubeTexture recebe o caminho de um diretório (path) que deve conter os arquivos posx.jpg, negx.jpg, posy.jpg, negy.jpg, posz.jpg e negz.jpg correspondentes aos arquivos de imagem das texturas de cada lado do cubo. Esses nomes são enviados como um arranjo de strings para o método abcg::opengl::loadCubemap, definido em abcg_image.cpp.

Internamente, abcg::opengl::loadCubemap cria um identificador de textura e liga-o ao alvo GL_TEXTURE_CUBE_MAP no lugar de GL_TEXTURE_2D:

GLuint textureID{};
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);

Em seguida, a função glTexImage2D é chamada seis vezes. Cada chamada de glTexImage2D usa como alvo um dos identificadores a seguir que identifica um lado do cubo:

  • GL_TEXTURE_CUBE_MAP_POSITIVE_X para o lado +x;
  • GL_TEXTURE_CUBE_MAP_NEGATIVE_X para o lado -x;
  • GL_TEXTURE_CUBE_MAP_POSITIVE_Y para o lado +y;
  • GL_TEXTURE_CUBE_MAP_NEGATIVE_Y para o lado -y;
  • GL_TEXTURE_CUBE_MAP_POSITIVE_Z para o lado +z;
  • GL_TEXTURE_CUBE_MAP_NEGATIVE_Z para o lado -z.

Consulte a definição de abcg::opengl::loadCubemap em abcg/abcg_image.cpp para mais detalhes. Voltando agora ao nosso código, a função Model::loadCubeTexture é chamada em OpenGLWindow::initializeGL:

  // Load cubemap
  m_model.loadCubeTexture(getAssetsPath() + "maps/cube/");

Essa chamada de função supõe que os seis arquivos de imagem estão em assets/maps/cube/.

Em Model::render, precisamos habilitar a unidade de textura que utilizará o mapa de textura cúbico. Nos projetos anteriores, ativamos as unidades GL_TEXTURE0 para a textura difusa, e GL_TEXTURE1 para a textura de normais. Agora, ativaremos a unidade GL_TEXTURE2 para o mapa cúbico:

  abcg::glActiveTexture(GL_TEXTURE0);
  abcg::glBindTexture(GL_TEXTURE_2D, m_diffuseTexture);

  abcg::glActiveTexture(GL_TEXTURE1);
  abcg::glBindTexture(GL_TEXTURE_2D, m_normalTexture);

  abcg::glActiveTexture(GL_TEXTURE2);
  abcg::glBindTexture(GL_TEXTURE_CUBE_MAP, m_cubeTexture);

Com essa configuração, podemos usar até três amostradores de textura ao mesmo tempo no fragment shader: diffuseTex, normalTex, e agora cubeTex. Na verdade, nossos novos shaders (cubereflect.frag e cuberefract.frag) não usam diffuseTex e normalTex, e poderíamos deixar o mapa cúbico na unidade GL_TEXTURE0. Entretanto, da forma como está o código já está pronto para ter o suporte a três amostradores simultâneos.

Há ainda mais um passo necessário para habilitar o uso amostrador no shader. Em OpenGLWindow::paintGL, precisamos definir o valor da variável uniforme cubeTex. Esse valor deve ser 2 pois queremos que essa variável use a unidade de textura GL_TEXTURE2. Assim, em OpenGLWindow::paintGL teremos os seguintes trecho de código atualizados:

  const GLint cubeTexLoc{abcg::glGetUniformLocation(program, "cubeTex")};

e

  abcg::glUniform1i(cubeTexLoc, 2);

Shaders

Os shaders de reflexão e refração são bastante similares. Começaremos com o shaders de reflexão (cubereflect.vert e cubereflect.frag) e em seguida abordaremos os shaders de refração (cuberefract.vert e cuberefract.frag).

Para simplificar, vamos considerar que a cor de cada ponto da superfície do modelo é determinada unicamente pelos valores do mapa de textura cúbico. Em outras palavras, será ignorada a iluminação e a texturização difusa ou de normais.

cubereflect.vert

O código completo do vertex shader é mostrado a seguir:

#version 410

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;

uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projMatrix;
uniform mat3 normalMatrix;

out vec3 fragP;
out vec3 fragN;

void main() {
  fragP = (viewMatrix * modelMatrix * vec4(inPosition, 1.0)).xyz;
  fragN = normalMatrix * inNormal;

  gl_Position = projMatrix * vec4(fragP, 1.0);
}

Os atributos de entrada são apenas dois:

  • inPosition: posição do vértice no espaço do objeto;
  • inNormal: vetor normal no espaço do objeto.

Observe que não precisamos das coordenadas de textura, pois elas podem ser calculadas a partir das coordenadas do vetor de reflexão ideal.

Os atributos de saída também são apenas dois, e correspondem aos atributos de entrada transformados para o espaço da câmera:

  • fragP: posição do vértice no espaço da câmera;
  • fragN: vetor normal no espaço da câmera.

Com esses atributos, podemos calcular o vetor de reflexão no fragment shader e então amostrar o mapa de textura cúbico.

cubereflect.frag

O código completo é mostrado a seguir:

#version 410

in vec3 fragP;
in vec3 fragN;

uniform mat3 texMatrix;
uniform samplerCube cubeTex;

out vec4 outColor;

void main() {
  vec3 V = normalize(-fragP);
  vec3 N = normalize(fragN);
  vec3 R = reflect(-V, N);

  outColor = texture(cubeTex, texMatrix * R);
}

O amostrador do mapa de textura cúbico é definido pela variável cubeTex na linha 7. Observe que o tipo de dado é samplerCube no lugar de sampler2D.

Na linha 12, o vetor V (vetor na direção da câmera) é calculado como -fragP, pois

\[ \hat{\mathbf{v}}=\frac{E-P}{|E-P|}, \] e \(E\) é a posição da câmera no espaço da câmera, que é a origem. Logo,

\[ \hat{\mathbf{v}}=\frac{-P}{|-P|}. \]

Na linha 13, o vetor N é fragN normalizado. A normalização é necessária pois, no fragment shader, fragN é o resultado da interpolação linear das coordenadas dos vetores normais definidos nos vértices, e a interpolação linear de dois vetores unitários diferentes entre si não é um vetor unitário.

Na linha 14, o vetor R de reflexão ideal é calculado com a função reflect. O primeiro argumento é -V pois reflect supõe que esse argumento é o vetor incidente em \(P\), e não o vetor que sai de \(P\).

Na linha 16, a função texture é utilizada com o amostrador cubeTex para amostrar o mapa de textura cúbico usando as coordenadas do vetor de reflexão ideal. Como o amostrador é do tipo samplerCube, o OpenGL se encarrega de amostrar o lado correto do cubo imaginário.

Observe que, na chamada a texture, transformamos R pela matriz texMatrix. Essa é a matriz inversa de rotação obtida do trackball virtual m_trackBallLight (trackball usado para mudar a direção da fonte de luz). Com isso podemos simular o efeito de girar o cubo imaginário usando o trackball da fonte da luz. Em OpenGLWindow::paintGL, a variável uniforme texMatrix é definida como:

  const glm::mat3 texMatrix{m_trackBallLight.getRotation()};
  abcg::glUniformMatrix3fv(texMatrixLoc, 1, GL_TRUE, &texMatrix[0][0]);

O segundo argumento de glUniformMatrix3fv é GL_TRUE. Isso faz com que a matriz enviada seja a transposta da original, que é igual à inversa da matriz (lembre-se que matrizes de rotação são ortogonais). Precisamos da matriz inversa, pois rodar o cubo imaginário por, digamos, \(90^\circ\) em torno do eixo \(x\), corresponde a manter o cubo parado e rodar o vetor R por \(-90^\circ\) em torno do mesmo eixo. Só podemos rodar o vetor R, pois o OpenGL não possui uma função para rodar o cubo imaginário.

Isso é tudo para o efeito de reflexão. Vamos agora aos shaders de refração.

cuberefract.vert

O código deste vertex shader é exatamente igual ao código de cubereflect.vert:

#version 410

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;

uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projMatrix;
uniform mat3 normalMatrix;

out vec3 fragP;
out vec3 fragN;

void main() {
  fragP = (viewMatrix * modelMatrix * vec4(inPosition, 1.0)).xyz;
  fragN = normalMatrix * inNormal;

  gl_Position = projMatrix * vec4(fragP, 1.0);
}
cuberefract.frag

O código do fragment shader também é praticamente idêntico ao código de cubereflect.frag:

#version 410

in vec3 fragP;
in vec3 fragN;

uniform mat3 texMatrix;
uniform samplerCube cubeTex;

out vec4 outColor;

void main() {
  vec3 V = normalize(-fragP);
  vec3 N = normalize(fragN);
  vec3 T = refract(-V, N, 1.0 / 1.52);  // Air to glass

  outColor = texture(cubeTex, texMatrix * T);
}

A única diferença em relação ao shader de reflexão é que, na linha 14, calculamos um vetor T de refração usando a função refract. A função recebe como argumentos o vetor incidente (-V), vetor normal (N), e razão entre os índices de refração, que neste caso é 1.0 / 1.52 pois estamos considerando uma transição do ar para o vidro.

Renderizando um skybox

Skybox é o nome dado a um cubo centralizado ao redor da câmera e texturizado com o mapa de textura cúbico de modo a simular uma imagem de fundo.

Para renderizar um skybox, precisamos definir primeiro a geometria do cubo. Na definição da classe OpenGLWindow (em openglwindow.hpp), definimos a posição dos vértices de cada lado do cubo em um arranjo m_skyPositions:

  const std::array<glm::vec3, 36>  m_skyPositions{
    // Front
    glm::vec3{-1, -1, +1}, glm::vec3{+1, -1, +1}, glm::vec3{+1, +1, +1},
    glm::vec3{-1, -1, +1}, glm::vec3{+1, +1, +1}, glm::vec3{-1, +1, +1},
    // Back
    glm::vec3{+1, -1, -1}, glm::vec3{-1, -1, -1}, glm::vec3{-1, +1, -1},
    glm::vec3{+1, -1, -1}, glm::vec3{-1, +1, -1}, glm::vec3{+1, +1, -1},
    // Right
    glm::vec3{+1, -1, -1}, glm::vec3{+1, +1, -1}, glm::vec3{+1, +1, +1},
    glm::vec3{+1, -1, -1}, glm::vec3{+1, +1, +1}, glm::vec3{+1, -1, +1},
    // Left
    glm::vec3{-1, -1, +1}, glm::vec3{-1, +1, +1}, glm::vec3{-1, +1, -1},
    glm::vec3{-1, -1, +1}, glm::vec3{-1, +1, -1}, glm::vec3{-1, -1, -1},
    // Top
    glm::vec3{-1, +1, +1}, glm::vec3{+1, +1, +1}, glm::vec3{+1, +1, -1},
    glm::vec3{-1, +1, +1}, glm::vec3{+1, +1, -1}, glm::vec3{-1, +1, -1},
    // Bottom
    glm::vec3{-1, -1, -1}, glm::vec3{+1, -1, -1}, glm::vec3{+1, -1, +1},
    glm::vec3{-1, -1, -1}, glm::vec3{+1, -1, +1}, glm::vec3{-1, -1, +1}
  };

O cubo será renderizado com GL_TRIANGLES sem usar geometria indexada. Assim, cada lado do cubo é formado por dois triângulos, e cada triângulo é uma sequência de três vértices do tipo glm::vec3.

Podemos definir coordenadas de textura para cada lado do cubo e então renderizar cada lado com a textura correspondente do mapa de textura cúbico. Entretanto, isso não é necessário. Podemos usar diretamente a posição dos vértices como coordenadas de amostragem do amostrador samplerCube.

Precisamos de um VBO (m_skyVBO), VAO (m_skyVAO), e um programa de shader (m_skyProgram) para renderizar o cubo. Em OpenGLWindow, definiremos os seguintes membros da classe:

  const std::string m_skyShaderName{"skybox"};
  GLuint m_skyVAO{};
  GLuint m_skyVBO{};
  GLuint m_skyProgram{};

m_skyShaderName é o nome dos arquivos dos shaders: skybox.vert e skybox.frag.

Os recursos (VBO, VAO, etc) são inicializados na função OpenGLWindow::initializeSkybox chamada em OpenGLWindow::initializeGL:

void OpenGLWindow::initializeSkybox() {
  // Create skybox program
  const auto path{getAssetsPath() + "shaders/" + m_skyShaderName};
  m_skyProgram = createProgramFromFile(path + ".vert", path + ".frag");

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

  // Get location of attributes in the program
  const GLint positionAttribute{
      abcg::glGetAttribLocation(m_skyProgram, "inPosition")};

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

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

  abcg::glBindBuffer(GL_ARRAY_BUFFER, m_skyVBO);
  abcg::glEnableVertexAttribArray(positionAttribute);
  abcg::glVertexAttribPointer(positionAttribute, 3, GL_FLOAT, GL_FALSE, 0,
                              nullptr);
  abcg::glBindBuffer(GL_ARRAY_BUFFER, 0);

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

Também precisamos definir a função OpenGLWindow::terminateSkybox que é chamada em OpenGLWindow::terminateGL para liberar os recursos:

void OpenGLWindow::terminateSkybox() {
  abcg::glDeleteProgram(m_skyProgram);
  abcg::glDeleteBuffers(1, &m_skyVBO);
  abcg::glDeleteVertexArrays(1, &m_skyVAO);
}

Para renderizar o cubo, definiremos a função OpenGLWindow::renderSkybox que é chamada em OpenGLWindow::paintGL depois da renderização do objeto que está sendo visualizado:

void OpenGLWindow::renderSkybox() {
  abcg::glUseProgram(m_skyProgram);

  // Get location of uniform variables
  const GLint viewMatrixLoc{
      abcg::glGetUniformLocation(m_skyProgram, "viewMatrix")};
  const GLint projMatrixLoc{
      abcg::glGetUniformLocation(m_skyProgram, "projMatrix")};
  const GLint skyTexLoc{abcg::glGetUniformLocation(m_skyProgram, "skyTex")};

  // Set uniform variables
  const auto viewMatrix{m_trackBallLight.getRotation()};
  abcg::glUniformMatrix4fv(viewMatrixLoc, 1, GL_FALSE, &viewMatrix[0][0]);
  abcg::glUniformMatrix4fv(projMatrixLoc, 1, GL_FALSE, &m_projMatrix[0][0]);
  abcg::glUniform1i(skyTexLoc, 0);

  abcg::glBindVertexArray(m_skyVAO);

  abcg::glActiveTexture(GL_TEXTURE0);
  abcg::glBindTexture(GL_TEXTURE_CUBE_MAP, m_model.getCubeTexture());

  abcg::glEnable(GL_CULL_FACE);
  abcg::glFrontFace(GL_CW);
  abcg::glDepthFunc(GL_LEQUAL);
  abcg::glDrawArrays(GL_TRIANGLES, 0, m_skyPositions.size());
  abcg::glDepthFunc(GL_LESS);

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

Observe como a matriz de visão é a matriz de rotação do trackball da fonte de luz (m_trackBallLight). Assim, conseguimos rodar o cubo através desse trackball.

Como a câmera está dentro do cubo, o back-face culling é ativado (linha 204) considerando que o lado da frente das faces tem orientação horária (linha 205), isto é, o lado da frente é o lado voltado para dentro do cubo.

Na linha 206, a função de comparação do teste de profundidade é modificada para GL_LEQUAL (menor ou igual a) no lugar da configuração padrão GL_LESS (menor que). Isso é necessário pois, no shader, faremos com que a posição de cada fragmento tenha valor máximo de profundidade (\(z=1\) em NDC) para que somente os pixels “de fundo” do framebuffer sejam modificados. Na configuração padrão, glClear limpa os pixels do buffer de profundidade com valor 1. Assim, após a renderização do objeto, os pixels com valor 1 correspondem aos pixels que não foram modificados, e somente esses pixels precisam ser desenhados com a textura de fundo. A função GL_LEQUAL garante que esses pixels serão preenchidos com o skybox.

No lugar de modificar a função de teste de profundidade e renderizar o cubo com \(z=1\) em NDC, poderíamos simplesmente renderizar o cubo primeiro (sem modificar o buffer de profundidade), e então renderizar o objeto por cima. Entretanto, a abordagem que adotamos (desenhar o cubo depois do objeto) é mais eficiente pois evita sobreposições de pixels e permite que o pipeline não perca tempo processando fragmentos que não passarão no teste de profundidade.

Os shaders skybox.vert e skybox.frag são definimos a seguir.

skybox.vert
#version 410

layout(location = 0) in vec3 inPosition;

out vec3 fragTexCoord;

uniform mat4 viewMatrix;
uniform mat4 projMatrix;

void main() {
  fragTexCoord = inPosition;

  vec4 P = projMatrix * viewMatrix * vec4(inPosition, 1.0);
  gl_Position = P.xyww;
}

O atributo de entrada do vertex shader é a posição do vértice do cubo (inPosition).

O atributo de saída é fragTexCoord, que corresponde às coordenadas que serão utilizadas para amostrar o mapa de textura cúbico. Na linha 11, essas coordenadas são definidas com os valores de inPosition, isto é, a posição do vértice é considerada como as coordenadas do vetor que será utilizado para amostrar a textura.

Na linha 13, P é a posição do vértice transformada para o espaço de recorte.

Na linha 14, utilizamos um pequeno truque: definimos gl_Position como P.xyww, que corresponde a um vetor no qual o valor da coordenada \(w\) é repetido como valor da coordenada \(z\). Ao fazer isso, garantimos que \(z\) será sempre 1 em NDC, pois a divisão por \(w\) feita após o recorte dividirá \(z\) por \(w\). Como ambos têm o mesmo valor, o resultado será 1. Com isso conseguimos fazer parecer que as faces do cubo estão sempre no fundo da cena.

skybox.frag
#version 410

in vec3 fragTexCoord;

out vec4 outColor;

uniform samplerCube skyTex;

void main() { outColor = texture(skyTex, fragTexCoord); }

O fragment shader recebe a saída do vertex shader (fragTexCoord), que são as coordenadas utilizadas para amostrar o mapa de textura cúbido através do amostrador skyTex.

Observação

Como o OpenGL define o cubo imaginário do mapeamento cúbico em um espaço que segue a regra da mão esquerda, a imagem de fundo do skybox normalmente ficaria espelhada em relação ao que é mostrado na figura 10.28. Isso só não ocorre porque a função abcg::opengl::loadCubemap corrige automaticamente as texturas para nós. A correção é feita trocando a textura +z com a textura -z, virando +y e -y de cabeça para baixo, e virando as demais texturas horizontalmente. Consulte o código da função para mais detalhes.