现代 OpenGL 纹理使用流程 (The Cherno's Method)

1. 核心概念:什么是纹理?

  • 不仅仅是图片:在 OpenGL 中,“纹理 (Texture)”是一个广义的概念。它是一个数据容器,可以存储一维、二维或三维的数据阵列。最常见的用途是存储 2D 图像的颜色信息,但也可以用来存储法线(法线贴图)、高度(高度图)、光照查找表等。
  • **纹理单元 (Texture Unit)**:GPU 内部有多个可以放置纹理的“插槽”,这些插槽被称为纹理单元。通常至少有16或32个(GL_TEXTURE0, GL_TEXTURE1, …)。你可以将不同的纹理放入不同的单元,然后在着色器中同时访问它们,实现多重纹理混合等效果。
  • **采样器 (Sampler)**:在 GLSL 着色器中,我们通过 sampler2D (或 sampler3D 等) 这样的特殊 uniform 变量来访问纹理。采样器知道去哪个纹理单元里读取数据。

2. C++ 封装:创建一个 Texture 类 (RAII)

为了方便管理和复用,我们将所有与纹理相关的 OpenGL 操作封装在一个 Texture 类中。这个类遵循 RAII (Resource Acquisition Is Initialization) 原则。

Texture.h (接口定义)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#pragma once
#include <string>

class Texture
{
private:
unsigned int m_RendererID;
std::string m_FilePath;
unsigned char* m_LocalBuffer;
int m_Width, m_Height, m_BPP; // 宽、高、每像素的位数(Bits Per Pixel)

public:
// 构造函数:从文件路径加载纹理并上传到 GPU
Texture(const std::string& path);
// 析构函数:释放 GPU 上的纹理资源
~Texture();

// 绑定纹理到一个指定的纹理单元
void Bind(unsigned int slot = 0) const;
// 解绑纹理
void Unbind() const;

// 获取纹理的宽度和高度
inline int GetWidth() const { return m_Width; }
inline int GetHeight() const { return m_Height; }
};

Texture.cpp (核心实现逻辑)

构造函数 Texture::Texture(const std::string& path) 的步骤:

  1. **翻转图像 (重要)**:

    • OpenGL 的纹理坐标 (0,0) 在左下角,而大多数图像文件格式(如 PNG, JPG)的 (0,0) 在左上角。
    • 在加载图像前,必须告诉 stb_image 库在加载时垂直翻转图像,以匹配 OpenGL 的坐标系。
    • stbi_set_flip_vertically_on_load(1);
  2. 从文件加载图像数据到 CPU 内存

    • 使用 stb_image 库加载图像。
    • m_LocalBuffer = stbi_load(path.c_str(), &m_Width, &m_Height, &m_BPP, 4);
    • 最后一个参数 4 强制 stb_image 将图像转换为 RGBA 格式(4个通道),这简化了后续 OpenGL 的处理。
  3. 生成并绑定一个 OpenGL 纹理对象

    • glGenTextures(1, &m_RendererID);:向 GPU 申请一个纹理对象的 ID。
    • glBindTexture(GL_TEXTURE_2D, m_RendererID);:将新创建的纹理对象绑定到 GL_TEXTURE_2D 这个目标上,表示我们接下来要操作的是一个2D纹理。
  4. **设置纹理参数 (Filtering & Wrapping)**:

    • 这些参数告诉 OpenGL 当纹理需要被放大/缩小时,或者当纹理坐标超出 [0, 1] 范围时,应该如何处理。
    • **Filtering (过滤)**:
      • GL_TEXTURE_MIN_FILTER: 纹理被缩小时的过滤方式。GL_LINEAR 表示线性过滤(平滑过渡)。
      • GL_TEXTURE_MAG_FILTER: 纹理被放大时的过滤方式。GL_LINEAR
    • **Wrapping (环绕)**:
      • GL_TEXTURE_WRAP_S: 水平方向(U/S轴)的环绕方式。GL_CLAMP_TO_EDGE 表示将超出范围的坐标截断到边缘,避免边缘出现奇怪的颜色。
      • GL_TEXTURE_WRAP_T: 垂直方向(V/T轴)的环绕方式。
    • 调用: glTexParameteri(GL_TEXTURE_2D, ...); (注意是 glTex... 而不是 glTexture...)
  5. 将 CPU 上的图像数据上传到 GPU

    • 这是最关键的一步,将 m_LocalBuffer 中的像素数据发送到当前绑定的纹理对象中。
    • glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, m_Width, m_Height, 0, GL_RGBA, GL_UNSIGNED_BYTE, m_LocalBuffer);
      • GL_TEXTURE_2D: 目标是2D纹理。
      • 0: Mipmap 的级别,0是基础级别。
      • GL_RGBA8: GPU 内部存储格式。告诉 GPU 用8位来存储每个通道(R, G, B, A)。
      • m_Width, m_Height: 纹理的尺寸。
      • 0: 历史遗留参数,必须为0。
      • GL_RGBA: 源图像数据的格式。告诉 OpenGL 我们提供的数据是 RGBA 顺序。
      • GL_UNSIGNED_BYTE: 源图像数据每个通道的数据类型
      • m_LocalBuffer: 指向 CPU 内存中图像数据的指针。
  6. 清理

    • glBindTexture(GL_TEXTURE_2D, 0);:上传完成后,解绑纹理是一种好习惯。
    • stbi_image_free(m_LocalBuffer);:图像数据已经被复制到 GPU 显存,CPU 上的这份拷贝就不再需要了,必须释放以避免内存泄漏。

其他成员函数的实现:

  • Texture::~Texture(): 在析构函数中调用 glDeleteTextures(1, &m_RendererID); 来释放 GPU 资源。
  • Texture::Bind(unsigned int slot):
    • glActiveTexture(GL_TEXTURE0 + slot);:激活指定的纹理单元。
    • glBindTexture(GL_TEXTURE_2D, m_RendererID);:将本纹理对象绑定到这个激活的单元上。
  • Texture::Unbind(): glBindTexture(GL_TEXTURE_2D, 0);

3. GLSL 着色器中的交互

顶点着色器 (Vertex Shader)

  • 接收纹理坐标作为顶点属性,并将其传递给片段着色器。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #version 330 core
    layout(location = 0) in vec4 position;
    layout(location = 1) in vec2 texCoord; // 从VBO接收纹理坐标

    out vec2 v_TexCoord; // 将纹理坐标输出给片段着色器

    void main()
    {
    gl_Position = position;
    v_TexCoord = texCoord;
    }

片段着色器 (Fragment Shader)

  • 接收从顶点着色器传来的纹理坐标,并使用 sampler2D uniform 来进行纹理采样。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #version 330 core
    out vec4 color;

    in vec2 v_TexCoord; // 接收插值后的纹理坐标

    uniform sampler2D u_Texture; // 纹理采样器

    void main()
    {
    // 使用 texture() 函数进行采样
    vec4 texColor = texture(u_Texture, v_TexCoord);
    color = texColor;
    }

4. 渲染循环中的使用流程 (Application.cpp)

  1. 初始化阶段

    • 创建 Shader 对象。
    • 创建 Texture 对象。
    • 关键连接:告诉着色器 u_Texture 这个采样器应该去哪个纹理单元找纹理。
      1
      2
      3
      4
      5
      6
      Texture texture("res/textures/my_texture.png");
      Shader shader("res/shaders/Basic.shader");

      shader.Bind();
      // 告诉 shader, u_Texture 对应的是 0 号纹理单元
      shader.SetUniform1i("u_Texture", 0);
  2. **渲染循环内部 (每一帧)**:

    • 在调用绘制命令 (glDraw...) 之前,必须确保所有需要的状态都已设置好
    • 绑定着色器。
    • 绑定纹理到它被指定的那个纹理单元
    • 绑定 VAO。
    • 发出绘制命令。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      // In while loop...
      shader.Bind();
      // 告诉 shader 去 0 号槽找纹理。这一步其实在初始化时做一次就行,因为uniform的值会保持。
      // shader.SetUniform1i("u_Texture", 0);

      // 激活 0 号纹理单元,并将我们的纹理绑定上去。
      texture.Bind(0);

      va.Bind();
      glDrawElements(...);

这个流程确保了 GPU 在执行绘制时,能够正确地找到纹理数据,并根据顶点提供的纹理坐标,将其正确地“贴”到物体表面。