10.4 Texturização na prática

Nesta seção, daremos continuidade ao projeto do visualizador de modelos 3D apresentado na seção 9.6.

Esta será a versão 4 do visualizador (viewer4) e terá um shader que usa uma textura para modificar as propriedades de reflexão difusa (\(\kappa_d\)) e ambiente (\(\kappa_a\)) do material utilizado no modelo de reflexão de Blinn–Phong.

Se o objeto lido do arquivo OBJ já vier com coordenadas de textura definidas em seus vértices (mapeamento UV unwrap), o visualizador usará essas coordenadas para amostrar a textura. Entretanto, também poderemos selecionar, através da interface da ImGui, um mapeamento pré-definido: triplanar, cilíndrico ou esférico.

Nesta nova versão, o botão “Load 3D Model” será transformado em um item do menu “File”. Há também uma opção de menu para carregar uma textura como um arquivo de imagem no formato PNG ou JPEG.

O resultado ficará como a seguir:

Como o código dessa versão contém apenas mudanças incrementais em relação ao anterior, nosso foco será apenas nessas mudanças.

Baixe o código completo deste link.

Carregando texturas

Para carregar uma textura no OpenGL, primeiro devemos chamar a função glGenTextures que cria um ou mais recursos de textura. Por exemplo, para criar apenas uma textura, podemos fazer

glGenTextures(1, &textureID);

onde textureID é uma variável do tipo GLuint que será preenchida com o identificador do recurso de textura criado pelo OpenGL.

Em seguida, ligamos o recurso de textura a um “alvo de textura”, que é GL_TEXTURE_2D para texturas 2D:

glBindTexture(GL_TEXTURE_2D, textureID);

Neste momento, a textura ainda está vazia. Para definir seu conteúdo, devemos ter um mapa de bits. Podemos carregar o conteúdo do mapa de bits através da função glTexImage2D. Por exemplo, considere o código a seguir:

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 800, 600, 
             0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);

glTexImage2D copia o mapa de bits contido no ponteiro pixels, supondo que o mapa é um arranjo de \(800 \times 600\) pixels. A função considera que cada pixel é uma tupla de valores RGBA (GL_RGBA), e que cada componente de cor é um byte sem sinal (GL_UNSIGNED_BYTE).

Na ABCg podemos usar a função auxiliar abcg::loadOpenGLTexture. Essa função recebe uma estrutura abcg::OpenGLTextureCreateInfo com informações sobre a criação da textura, tal como o nome de um arquivo de imagem. O valor de retorno é o identificar da textura criada. Internamente, a função usa funções da SDL para carregar o mapa de bits, e em seguida chama as funções do OpenGL para criar o recurso de textura. Assim, para criar uma textura a partir de um arquivo imagem.png, podemos fazer simplesmente:

textureID = abcg::loadOpenGLTexture({.path = "imagem.png"});

Por padrão, abcg::loadOpenGLTexture cria também o mipmap da textura e usa o filtro de minificação GL_LINEAR_MIPMAP_LINEAR. Se quisermos que o mipmap não seja gerado, basta mudar o valor de OpenGLTextureCreateInfo::generateMipmaps para false:

textureID = abcg::opengl::loadTexture({.path = "imagem.png", .generateMipmaps = false});

Para destruir a textura e liberar seus recursos, devemos chamar manualmente glDeleteTextures. Por exemplo, o seguinte código libera a textura textureID criada com glGenTextures ou abcg::loadOpenGLTexture.

glDeleteTextures(1, &textureID);

Nessa nova versão do visualizador, a classe Model implementa a função membro Model::loadDiffuseTexture, que carrega um arquivo de imagem e gera um identificador de recurso de textura difusa na variável m_diffuseTexture. A função é definida como a seguir (em model.cpp):

void Model::loadDiffuseTexture(std::string_view path) {
  if (!std::filesystem::exists(path))
    return;

  abcg::glDeleteTextures(1, &m_diffuseTexture);
  m_diffuseTexture = abcg::loadOpenGLTexture({.path = path});
}

A função recebe em path o caminho contendo o nome do arquivo de imagem PNG ou JPEG. Se o arquivo não existir, a função simplesmente retorna (linha 72). Caso contrário, o recurso de textura anterior é liberado, e abcg::loadOpenGLTexture é chamada para criar a nova textura.

Carregando modelos com textura

Um arquivo OBJ pode vir acompanhado de um arquivo .mtl opcional que contém a descrição das propriedades dos materiais de cada objeto32. Por exemplo, o arquivo roman_lamp.obj (lâmpada romana da figura 10.11) vem acompanhado do arquivo roman_lamp.mtl que tem o seguinte conteúdo:

newmtl roman_lamp
    Ns 25.0000
    Ni 1.5000
    Tr 0.0000
    Tf 1.0000 1.0000 1.0000 
    illum 2
    Ka 0.2000 0.2000 0.2000
    Kd 1.0000 1.0000 1.0000
    Ks 0.6000 0.6000 0.6000
    Ke 0.0000 0.0000 0.0000
    map_Ka maps/roman_lamp_diffuse.jpg
    map_Kd maps/roman_lamp_diffuse.jpg
    map_bump maps/roman_lamp_normal.jpg
    bump maps/roman_lamp_normal.jpg

Entre outras coisas, o arquivo contém o valor do expoente de brilho especular (Ns) e as propriedades de reflexão ambiente (Ka), difusa (Kd) e especular (Ks). Além disso, o arquivo contém o nome do mapa de textura que deve ser utilizado para modificar a reflexão ambiente (map_Ka) e reflexão difusa (map_Kd). Há outras propriedades, mas elas não serão utilizadas no momento.

Nossa implementação de Model::loadObj, que utiliza funções da TinyObjLoader, carrega automaticamente a textura difusa, se ela existir. A definição completa dessa versão atualizada de Model::loadObj é mostrada seguir (arquivo model.cpp).

void Model::loadObj(std::string_view path, bool standardize) {
  auto const basePath{std::filesystem::path{path}.parent_path().string() + "/"};

  tinyobj::ObjReaderConfig readerConfig;
  readerConfig.mtl_search_path = basePath; // Path to material files

  tinyobj::ObjReader reader;

  if (!reader.ParseFromFile(path.data(), readerConfig)) {
    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 &attrib{reader.GetAttrib()};
  auto const &shapes{reader.GetShapes()};
  auto const &materials{reader.GetMaterials()};

  m_vertices.clear();
  m_indices.clear();

  m_hasNormals = false;
  m_hasTexCoords = false;

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

      // Position
      auto const startIndex{3 * index.vertex_index};
      glm::vec3 position{attrib.vertices.at(startIndex + 0),
                         attrib.vertices.at(startIndex + 1),
                         attrib.vertices.at(startIndex + 2)};

      // Normal
      glm::vec3 normal{};
      if (index.normal_index >= 0) {
        m_hasNormals = true;
        auto const normalStartIndex{3 * index.normal_index};
        normal = {attrib.normals.at(normalStartIndex + 0),
                  attrib.normals.at(normalStartIndex + 1),
                  attrib.normals.at(normalStartIndex + 2)};
      }

      // Texture coordinates
      glm::vec2 texCoord{};
      if (index.texcoord_index >= 0) {
        m_hasTexCoords = true;
        auto const texCoordsStartIndex{2 * index.texcoord_index};
        texCoord = {attrib.texcoords.at(texCoordsStartIndex + 0),
                    attrib.texcoords.at(texCoordsStartIndex + 1)};
      }

      Vertex const vertex{
          .position = position, .normal = normal, .texCoord = texCoord};

      // If hash 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]);
    }
  }

  // Use properties of first material, if available
  if (!materials.empty()) {
    auto const &mat{materials.at(0)}; // First material
    m_Ka = {mat.ambient[0], mat.ambient[1], mat.ambient[2], 1};
    m_Kd = {mat.diffuse[0], mat.diffuse[1], mat.diffuse[2], 1};
    m_Ks = {mat.specular[0], mat.specular[1], mat.specular[2], 1};
    m_shininess = mat.shininess;

    if (!mat.diffuse_texname.empty())
      loadDiffuseTexture(basePath + mat.diffuse_texname);
  } else {
    // Default values
    m_Ka = {0.1f, 0.1f, 0.1f, 1.0f};
    m_Kd = {0.7f, 0.7f, 0.7f, 1.0f};
    m_Ks = {1.0f, 1.0f, 1.0f, 1.0f};
    m_shininess = 25.0f;
  }

  if (standardize) {
    Model::standardize();
  }

  if (!m_hasNormals) {
    computeNormals();
  }

  createBuffers();
}

Note que, logo no início da função, definimos um objeto readerConfig (linha 81) que é utilizado como argumento de ObjReader::ParseFromFile da TinyObjLoader (linha 86) para informar o diretório onde estão os arquivos de materiais. Por padrão, esse caminho é o mesmo diretório onde está o arquivo OBJ:

void Model::loadObj(std::string_view path, bool standardize) {
  auto const basePath{std::filesystem::path{path}.parent_path().string() + "/"};

  tinyobj::ObjReaderConfig readerConfig;
  readerConfig.mtl_search_path = basePath; // Path to material files

  tinyobj::ObjReader reader;

  if (!reader.ParseFromFile(path.data(), readerConfig)) {
    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());
  }

Nas linhas 158 a 174, as propriedades do primeiro material são utilizadas. Se não houver nenhum material, valores padrão são utilizados:

  // Use properties of first material, if available
  if (!materials.empty()) {
    auto const &mat{materials.at(0)}; // First material
    m_Ka = {mat.ambient[0], mat.ambient[1], mat.ambient[2], 1};
    m_Kd = {mat.diffuse[0], mat.diffuse[1], mat.diffuse[2], 1};
    m_Ks = {mat.specular[0], mat.specular[1], mat.specular[2], 1};
    m_shininess = mat.shininess;

    if (!mat.diffuse_texname.empty())
      loadDiffuseTexture(basePath + mat.diffuse_texname);
  } else {
    // Default values
    m_Ka = {0.1f, 0.1f, 0.1f, 1.0f};
    m_Kd = {0.7f, 0.7f, 0.7f, 1.0f};
    m_Ks = {1.0f, 1.0f, 1.0f, 1.0f};
    m_shininess = 25.0f;
  }

Durante a leitura dos atributos dos vértices, Model::loadObj também verifica se a malha contém coordenadas de textura. Isso é feito nas linhas 134 a 141:

      // Texture coordinates
      glm::vec2 texCoord{};
      if (index.texcoord_index >= 0) {
        m_hasTexCoords = true;
        auto const texCoordsStartIndex{2 * index.texcoord_index};
        texCoord = {attrib.texcoords.at(texCoordsStartIndex + 0),
                    attrib.texcoords.at(texCoordsStartIndex + 1)};
      }

Se o vértice contém coordenadas de textura (linha 136), o flag m_hasTexCoords é definido como true e as coordenadas são carregadas em texCoord. Se o arquivo OBJ não tiver coordenadas de textura, texCoord será (0, 0) para todos os vértices.

A estrutura Vertex é criada com o novo atributo de coordenadas de textura:

      Vertex const vertex{
          .position = position, .normal = normal, .texCoord = texCoord};

texCoord é um novo atributo de Vertex (um glm::vec2), criado de forma semelhante ao modo como criamos o atributo normal no projeto viewer2 (seção 9.5), isto é, usamos o atributo como chave de hash e carregamos seus dados no formato de um VBO de dados intercalados.

Renderizando

Em Model::render (chamado em Window::onPaint), incluímos a ativação da textura no pipeline. A definição completa ficará como a seguir:

void Model::render(int numTriangles) const {
  abcg::glBindVertexArray(m_VAO);

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

  // Set minification and magnification parameters
  abcg::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
  abcg::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

  // Set texture wrapping parameters
  abcg::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
  abcg::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

  auto const numIndices{(numTriangles < 0) ? m_indices.size()
                                           : numTriangles * 3};

  abcg::glDrawElements(GL_TRIANGLES, numIndices, GL_UNSIGNED_INT, nullptr);

  abcg::glBindVertexArray(0);
}

Na linha 190, a função glActiveTexture é chamada para ativar a primeira “unidade de textura” (GL_TEXTURE0). O número de unidades de textura ativas corresponde ao número de texturas diferentes que podem ser utilizadas ao mesmo tempo em um programa de shader. Para cada estágio de shader (por exemplo, para o vertex shader, ou para o fragment shader) é possível acessar pelo menos 16 unidades de textura ao mesmo tempo. Esse número pode ser maior dependendo da implementação do driver. Se considerarmos todas os estágios de shaders disponíveis, então podemos ativar pelo menos 32 texturas ao mesmo tempo no OpenGL ES (16 para o vertex shader, mais 16 para o fragment shader), e 80 no OpenGL para desktop (16 para cada um dos 5 estágios de shaders). Como queremos manter as coisas simples, nosso visualizador por enquanto só utilizará uma unidade de textura. Nas próximas versões veremos como usar mais unidades.

Após a ativação da unidade de textura, a função glBindTexture é chamada para ligar o identificar de textura à unidade recém ativada.

Nas linha 193 a 199, a função glTexParameteri é chamada para configurar os filtros de textura e modos de empacotamento. Os valores aqui utilizados são os valores padrão do OpenGL.

Isso é tudo para configurar a texturização. O restante agora é feito nos shaders. O projeto viewer4 define dois novos shaders com suporte a texturas: texture.vert e texture.frag. Esses shaders são bem similares aos shaders do modelo de Blinn–Phong.

Observação

Para o conteúdo de assets ficar mais organizado, a partir desta versão do visualizador, as texturas ficarão armazenadas em assets/maps, e os shaders ficarão armazenados em assets/shaders. Os arquivos .obj continuam na pasta assets, agora junto também com os arquivos .mtl.

texture.vert

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

#version 300 es

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

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

uniform vec4 lightDirWorldSpace;

out vec3 fragV;
out vec3 fragL;
out vec3 fragN;
out vec2 fragTexCoord;
out vec3 fragPObj;
out vec3 fragNObj;

void main() {
  vec3 P = (viewMatrix * modelMatrix * vec4(inPosition, 1.0)).xyz;
  vec3 N = normalMatrix * inNormal;
  vec3 L = -(viewMatrix * lightDirWorldSpace).xyz;

  fragL = L;
  fragV = -P;
  fragN = N;
  fragTexCoord = inTexCoord;
  fragPObj = inPosition;
  fragNObj = inNormal;

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

Esse código contém algumas poucas modificações em relação ao conteúdo de blinnphong.vert.

Observe como agora temos o atributo de entrada inTexCoord (linha 5) contendo as coordenadas de textura lidas do arquivo OBJ.

O vertex shader não faz nenhum processamento com as coordenadas de textura e simplesmente passa-as adiante para o fragment shader através do atributo de saída fragTexCoord (linha 17).

O vertex shader também possui dois outros atributos adicionais de saída:

  • fragPObj (linha 18) é a posição do vértice no espaço do objeto (isto é, uma cópia de inPosition);
  • fragNObj (linha 19) é o vetor normal no espaço do objeto (isto é, uma cópia de inNormal).

Esses atributos são utilizados para calcular, no fragment shader, as coordenadas de textura do mapeamento triplanar, cilíndrico ou esférico (o vetor normal só é utilizado no mapeamento triplanar). Se o mapeamento utilizado é aquele fornecido pelo arquivo OBJ, isto é, o mapeamento determinado pelas coordenada de textura de inTexCoord, então os atributos fragPObj e fragNObj não são utilizados.

texture.frag

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

#version 300 es

precision mediump float;

in vec3 fragN;
in vec3 fragL;
in vec3 fragV;
in vec2 fragTexCoord;
in vec3 fragPObj;
in vec3 fragNObj;

// Light properties
uniform vec4 Ia, Id, Is;

// Material properties
uniform vec4 Ka, Kd, Ks;
uniform float shininess;

// Diffuse texture sampler
uniform sampler2D diffuseTex;

// Mapping mode
// 0: triplanar; 1: cylindrical; 2: spherical; 3: from mesh
uniform int mappingMode;

out vec4 outColor;

// Blinn-Phong reflection model
vec4 BlinnPhong(vec3 N, vec3 L, vec3 V, vec2 texCoord) {
  N = normalize(N);
  L = normalize(L);

  // Compute lambertian term
  float lambertian = max(dot(N, L), 0.0);

  // Compute specular term
  float specular = 0.0;
  if (lambertian > 0.0) {
    V = normalize(V);
    vec3 H = normalize(L + V);
    float angle = max(dot(H, N), 0.0);
    specular = pow(angle, shininess);
  }

  vec4 map_Kd = texture(diffuseTex, texCoord);
  vec4 map_Ka = map_Kd;

  vec4 diffuseColor = map_Kd * Kd * Id * lambertian;
  vec4 specularColor = Ks * Is * specular;
  vec4 ambientColor = map_Ka * Ka * Ia;

  return ambientColor + diffuseColor + specularColor;
}

// Planar mapping
vec2 PlanarMappingX(vec3 P) { return vec2(1.0 - P.z, P.y); }
vec2 PlanarMappingY(vec3 P) { return vec2(P.x, 1.0 - P.z); }
vec2 PlanarMappingZ(vec3 P) { return P.xy; }

#define PI 3.14159265358979323846

// Cylindrical mapping
vec2 CylindricalMapping(vec3 P) {
  float longitude = atan(P.x, P.z);
  float height = P.y;

  float u = longitude / (2.0 * PI) + 0.5;  // From [-pi, pi] to [0, 1]
  float v = height - 0.5;                  // Base at y = -0.5

  return vec2(u, v);
}

// Spherical mapping
vec2 SphericalMapping(vec3 P) {
  float longitude = atan(P.x, P.z);
  float latitude = asin(P.y / length(P));

  float u = longitude / (2.0 * PI) + 0.5;  // From [-pi, pi] to [0, 1]
  float v = latitude / PI + 0.5;           // From [-pi/2, pi/2] to [0, 1]

  return vec2(u, v);
}

void main() {
  vec4 color;

  if (mappingMode == 0) {
    // Triplanar mapping

    // An offset to center the texture around the origin
    vec3 offset = vec3(-0.5, -0.5, -0.5);    

    // Sample with x planar mapping
    vec2 texCoord1 = PlanarMappingX(fragPObj + offset);
    vec4 color1 = BlinnPhong(fragN, fragL, fragV, texCoord1);

    // Sample with y planar mapping
    vec2 texCoord2 = PlanarMappingY(fragPObj + offset);
    vec4 color2 = BlinnPhong(fragN, fragL, fragV, texCoord2);

    // Sample with z planar mapping
    vec2 texCoord3 = PlanarMappingZ(fragPObj + offset);
    vec4 color3 = BlinnPhong(fragN, fragL, fragV, texCoord3);

    // Compute average based on normal
    vec3 weight = abs(normalize(fragNObj));
    color = color1 * weight.x + color2 * weight.y + color3 * weight.z;
  } else {
    vec2 texCoord;
    if (mappingMode == 1) {
      // Cylindrical mapping
      texCoord = CylindricalMapping(fragPObj);
    } else if (mappingMode == 2) {
      // Spherical mapping
      texCoord = SphericalMapping(fragPObj);
    } else if (mappingMode == 3) {
      // From mesh
      texCoord = fragTexCoord;
    }
    color = BlinnPhong(fragN, fragL, fragV, texCoord);
  }

  if (gl_FrontFacing) {
    outColor = color;
  } else {
    float i = (color.r + color.g + color.b) / 3.0;
    outColor = vec4(i, 0, 0, 1.0);
  }
}

A primeira mudança em relação ao shader blinnphong.frag é o número de atributos de entrada, que agora inclui os atributos fragTexCoord, fragPObj e fragNObj da saída do vertex shader:

in vec3 fragN;
in vec3 fragL;
in vec3 fragV;
in vec2 fragTexCoord;
in vec3 fragPObj;
in vec3 fragNObj;

Há também duas novas variáveis uniformes:

// Diffuse texture sampler
uniform sampler2D diffuseTex;

// Mapping mode
// 0: triplanar; 1: cylindrical; 2: spherical; 3: from mesh
uniform int mappingMode;
  • diffuseTex é um amostrador de textura 2D (sampler2D) que acessa a primeira unidade de textura (GL_TEXTURE0) ativada com glActiveTexture em Model::render. É através desse amostrador que conseguiremos acessar os texels da textura difusa.
  • mappingMode é um inteiro que identifica o modo de mapeamento escolhido pelo usuário. Esse valor é determinado pelo índice da caixa de combinação “UV mapping” da ImGui. O padrão é 3 para arquivos OBJ que possuem coordenadas de textura, e 0 quando as coordenadas de textura não foram encontradas.
Importante

O pipeline associa diffuseTex à unidade de textura GL_TEXTURE0 pois o valor dessa variável uniforme é definido como 0 no código em C++. Isso é feito na linha 106 de Window::onPaint junto com a definição das outras variáveis uniformes:

  auto const diffuseTexLoc{abcg::glGetUniformLocation(program, "diffuseTex")};
  auto const mappingModeLoc{abcg::glGetUniformLocation(program, "mappingMode")};

  // 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::glUniform1i(diffuseTexLoc, 0);
  abcg::glUniform1i(mappingModeLoc, m_mappingMode);

A definição da função BlinnPhong é ligeiramente diferente daquela do shader blinnphong.frag:

// Blinn-Phong reflection model
vec4 BlinnPhong(vec3 N, vec3 L, vec3 V, vec2 texCoord) {
  N = normalize(N);
  L = normalize(L);

  // Compute lambertian term
  float lambertian = max(dot(N, L), 0.0);

  // Compute specular term
  float specular = 0.0;
  if (lambertian > 0.0) {
    V = normalize(V);
    vec3 H = normalize(L + V);
    float angle = max(dot(H, N), 0.0);
    specular = pow(angle, shininess);
  }

  vec4 map_Kd = texture(diffuseTex, texCoord);
  vec4 map_Ka = map_Kd;

  vec4 diffuseColor = map_Kd * Kd * Id * lambertian;
  vec4 specularColor = Ks * Is * specular;
  vec4 ambientColor = map_Ka * Ka * Ia;

  return ambientColor + diffuseColor + specularColor;
}

Agora, a função tem o parâmetro adicional de coordenadas de textura texCoord (linha 29).

Até a linha 43, o código é igual ao anterior.

A linha 45 contém o código utilizado para amostrar a textura difusa. A função texture recebe como argumentos o amostrador de textura (diffuseTex) e as coordenadas de textura (texCoord). O resultado é a cor RGBA amostrada na posição dada, usando o modo de filtragem e modo de empacotamento definidos pelo código C++ antes da renderização.

Como estamos amostrando uma textura difusa, a cor da textura (map_Kd) é multiplicada por Kd * Id * lambertian para compor a componente difusa final (linha 48).

Também criamos uma cor ambiente map_Ka (linha 46) que multiplica as componentes de reflexão ambiente (linha 50). Nesse caso, consideramos que map_Ka é igual a map_Kd, pois geralmente é esse o caso (a textura difusa é também a textura ambiente). Entretanto, é possível que um material defina uma textura diferente para a componente ambiente. Nesse caso teríamos de mudar o shader para incluir um outro amostrador específico para map_Ka.

Além da função BlinnPhong, o shader também define as funções de geração de coordenadas de textura usando mapeamento planar (PlanarMappingX, PlanarMappingY, PlanarMappingZ), cilíndrico (CylindricalMapping) e esférico (SphericalMapping):

// Planar mapping
vec2 PlanarMappingX(vec3 P) { return vec2(1.0 - P.z, P.y); }
vec2 PlanarMappingY(vec3 P) { return vec2(P.x, 1.0 - P.z); }
vec2 PlanarMappingZ(vec3 P) { return P.xy; }

#define PI 3.14159265358979323846

// Cylindrical mapping
vec2 CylindricalMapping(vec3 P) {
  float longitude = atan(P.x, P.z);
  float height = P.y;

  float u = longitude / (2.0 * PI) + 0.5;  // From [-pi, pi] to [0, 1]
  float v = height - 0.5;                  // Base at y = -0.5

  return vec2(u, v);
}

// Spherical mapping
vec2 SphericalMapping(vec3 P) {
  float longitude = atan(P.x, P.z);
  float latitude = asin(P.y / length(P));

  float u = longitude / (2.0 * PI) + 0.5;  // From [-pi, pi] to [0, 1]
  float v = latitude / PI + 0.5;           // From [-pi/2, pi/2] to [0, 1]

  return vec2(u, v);
}

Todas as funções recebem como parâmetro a posição do ponto (P) e retornam as coordenadas de textura correspondentes ao mapeamento. O código reproduz as equações descritas na seção 10.1. A única exceção é o cálculo da componente \(v\) do mapeamento cilíndrico (linha 68), que aqui é deslocada para fazer com que o cilindro tenha base em \(-0.5\) em vez de \(0\). Assim, a textura fica centralizada verticalmente em objetos de raio unitário centralizados na origem, que é o nosso caso pois usamos a função Model::standardize após a leitura do arquivo OBJ.

As funções de geração de coordenadas de textura são chamadas em main de acordo com o valor de mappingMode:

void main() {
  vec4 color;

  if (mappingMode == 0) {
    // Triplanar mapping

    // An offset to center the texture around the origin
    vec3 offset = vec3(-0.5, -0.5, -0.5);    

    // Sample with x planar mapping
    vec2 texCoord1 = PlanarMappingX(fragPObj + offset);
    vec4 color1 = BlinnPhong(fragN, fragL, fragV, texCoord1);

    // Sample with y planar mapping
    vec2 texCoord2 = PlanarMappingY(fragPObj + offset);
    vec4 color2 = BlinnPhong(fragN, fragL, fragV, texCoord2);

    // Sample with z planar mapping
    vec2 texCoord3 = PlanarMappingZ(fragPObj + offset);
    vec4 color3 = BlinnPhong(fragN, fragL, fragV, texCoord3);

    // Compute average based on normal
    vec3 weight = abs(normalize(fragNObj));
    color = color1 * weight.x + color2 * weight.y + color3 * weight.z;
  } else {
    vec2 texCoord;
    if (mappingMode == 1) {
      // Cylindrical mapping
      texCoord = CylindricalMapping(fragPObj);
    } else if (mappingMode == 2) {
      // Spherical mapping
      texCoord = SphericalMapping(fragPObj);
    } else if (mappingMode == 3) {
      // From mesh
      texCoord = fragTexCoord;
    }
    color = BlinnPhong(fragN, fragL, fragV, texCoord);
  }

  if (gl_FrontFacing) {
    outColor = color;
  } else {
    float i = (color.r + color.g + color.b) / 3.0;
    outColor = vec4(i, 0, 0, 1.0);
  }
}

Observe, no mapeamento triplanar (linhas 88–107), como a textura é amostrada três vezes (linhas 93–103) e as cores amostradas são combinadas em uma média ponderada pelo valor absoluto das componentes do vetor normal (linhas 105–107).

Observação

Observe que, no mapeamento triplanar, não seria necessário chamar BlinnPhong três vezes. Afinal, a iluminação sem a textura é a mesma nas três chamadas.

O código ficaria mais eficiente se BlinnPhong retornasse uma estrutura contendo as componentes ambiente, difusa e especular separadas. Poderíamos então criar uma outra função só para fazer a amostragem da textura difusa e compor a cor final.

Se mappingMode é 3, então nenhuma função de mapeamento é chamada e as coordenadas de textura utilizadas em BlinnPhong são aquelas contidas em fragTexCoord, pois essas são as coordenadas de textura interpoladas a partir das coordenadas definidas nos vértices.

Isso resume as modificações necessárias para habilitar a texturização. O restante do código contém modificações complementares relacionadas a conceitos que já foram abordados em projetos anteriores, como a mudança da interface da ImGui e a determinação de um ângulo e eixo de rotação inicial para o trackball virtual.


  1. Nosso visualizador suporta apenas um objeto por arquivo OBJ e, portanto, suporta apenas um material. Por isso, todos os arquivos OBJ que utilizaremos devem ter apenas um objeto e um material.↩︎