需要预先学习:


1. 纹理坐标

在 纹理映射 中,你已经知道纹理坐标(UV)是什么:每个顶点指定一个 [0, 1] 范围内的坐标,告诉 GPU 这个顶点对应纹理上的哪个位置。这一篇笔记讲 OpenGL 中从数据准备到最终显示的完整流程:在顶点数据里加上纹理坐标 → 加载图片生成纹理对象 → 配置采样方式 → 在着色器中采样。


1.1 纹理坐标系

OpenGL 的 2D 纹理坐标系有两条轴:水平轴 S、垂直轴 T(也叫 U、V)。坐标范围约定为 [0, 1],(0, 0) 对应纹理的左下角,(1, 1) 对应右上角:

  t
  ↑
(0,1) ──────── (1,1)
  |               |
  |    纹理图片     |
  |               |
(0,0) ──────── (1,0)  →  s

坐标与纹理的分辨率无关:不管纹理是 512×512 还是 4096×4096,坐标都落在 [0, 1] 内。这也是纹理坐标的好处——换一张分辨率更高的纹理,模型上的贴图位置不用改。

注意:图片文件的原点在左上角(第一行像素是图片的顶部),而 OpenGL 纹理坐标原点在左下角。用 stb_image 加载图片时需要垂直翻转,否则贴图会上下颠倒,这一点在"加载纹理"一节会再提到。


1.2 在 VertexBuffer 中传递纹理坐标

在 缓冲对象与顶点 中你已经把位置、颜色写进顶点数组。纹理坐标同理,作为顶点属性与位置交错排列。以一张由两个三角形组成的矩形为例,四个顶点分别对应纹理的四个角:

float vertices[] = {
    // 位置(x, y, z)        纹理坐标(s, t)
    -0.5f, -0.5f, 0.0f,     0.0f, 0.0f,     // 左下角
     0.5f, -0.5f, 0.0f,     1.0f, 0.0f,     // 右下角
     0.5f,  0.5f, 0.0f,     1.0f, 1.0f,     // 右上角
    -0.5f,  0.5f, 0.0f,     0.0f, 1.0f      // 左上角
};

每个顶点占 5 个 float:前 3 个是位置,后 2 个是纹理坐标。配置顶点属性时,stride 是 5 * sizeof(float),纹理坐标属性的偏移是 3 * sizeof(float):

// 位置属性:3 个 float,从偏移 0 开始
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

// 纹理坐标属性:2 个 float,偏移 3 个 float
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);

顶点着色器中接收纹理坐标,并把它传给片段着色器:

#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;

out vec2 TexCoord;      // 传给片段着色器

void main()
{
    gl_Position = vec4(aPos, 1.0);
    TexCoord = aTexCoord;
}

顶点只指定了四个角的纹理坐标,三角形内部的坐标由光栅化阶段用重心坐标插值得到(见 纹理映射)。


2. 纹理的加载与设置

现在顶点已经有了纹理坐标,但 GPU 上还没有纹理数据。接下来做三件事:把图片文件解码成像素数据(加载)、在 GPU 上创建纹理对象并上传(生成)、配置采样方式并让着色器使用(应用)。


2.1 加载纹理

图片文件(png、jpg 等)是压缩编码的,GPU 不能直接使用,需要先在 CPU 端解码成像素数据——一个字节数组,每像素若干通道。stb_image 是常用的单头文件图片加载库,stbi_load 一步完成解码:

#define STB_IMAGE_IMPLEMENTATION    // 定义一次即可让 stb_image 生成实现
#include <stb_image.h>

int width, height, channels;
stbi_set_flip_vertically_on_load(true);   // OpenGL 纹理坐标原点在左下角,图片原点在左上角,需要垂直翻转

unsigned char* data = stbi_load("container.jpg", &width, &height, &channels, 0);
if (!data)
    std::cout << "图片加载失败: container.jpg" << std::endl;

// ... 把 data 上传到 GPU(见下一节) ...

stbi_image_free(data);    // 释放 stbi_load 分配的内存,不要忘记

几个要点:

  • STB_IMAGE_IMPLEMENTATION 宏:stb_image 的声明与实现都写在头文件里,但实现部分需要这个宏才会编译进来,并且只能在一个 .cpp 文件中定义一次,否则多个编译单元都会生成实现,链接时报重复定义错误
  • stbi_load 的前四个参数是路径和宽高通道数的输出参数,最后一个参数是期望通道数,传 0 表示保持图片原本的通道数(如带透明度的 png 就是 4 通道)
  • 返回的 data 是 stbi_load 内部用 malloc 分配的堆内存,用完必须调用 stbi_image_free 释放。每次加载图片都少一次释放,就泄漏一份内存
  • stbi_set_flip_vertically_on_load(true) 让解码时自动垂直翻转,正好对应上一节提到的坐标系差异

2.2 生成纹理

像素数据还在 CPU 内存里,现在要在 GPU 上创建纹理对象(texture object)并把数据上传。流程与缓冲对象类似:生成 → 绑定 → 上传数据:

GLuint texture;
glGenTextures(1, &texture);                   // 生成纹理对象
glBindTexture(GL_TEXTURE_2D, texture);        // 绑定到 2D 纹理绑定点

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);              // 生成 mipmap

glBindTexture(GL_TEXTURE_2D, 0);              // 解绑

glTexImage2D 的参数较多,逐个说明:

参数 取值示例 含义
target GL_TEXTURE_2D 纹理类型
level 0 mipmap 级别,0 为原始分辨率
internalformat GL_RGBA8 GPU 端的存储格式(通道数与位宽)
width / height width / height 纹理尺寸(像素)
border 0 历史遗留参数,必须为 0
format / type GL_RGBA / GL_UNSIGNED_BYTE CPU 端像素数据的格式与类型
data data 像素数据指针

注意区分 internalformat 与 format:前者决定 GPU 显存里怎么存,后者描述你传入的数据长什么样。两者不需要一致,比如 CPU 端是 3 通道 GL_RGB,GPU 端可以存成 4 通道 GL_RGBA8,OpenGL 会自动转换。

MipMap 的生成

纹理映射 中提到:纹理缩小时多个纹素挤进一个像素,产生摩尔纹和闪烁,MipMap 通过预生成逐级缩小一半的纹理金字塔解决。OpenGL 中生成 mipmap 只需一行:

glGenerateMipmap(GL_TEXTURE_2D);

GPU 从第 0 级开始,自动逐级把边长减半生成完整金字塔,直到 1×1,无需手动提供各级数据。

注意:过滤方式使用 mipmap 变体(如 GL_LINEAR_MIPMAP_LINEAR)时,纹理必须已经生成 mipmap,否则纹理被判为不完整(incomplete texture),采样结果全黑。反过来,生成了 mipmap 但缩小过滤不是 mipmap 变体,则只有第 0 级会被使用,生成结果等于白费。


2.3 应用纹理

纹理对象创建好了,但着色器还不知道怎么用它。应用纹理要解决三件事:采样方式(过滤与环绕)、绑定、把纹理传给着色器。

纹理过滤方式

纹理坐标是连续值,几乎不会正好落在某个纹素中心,取哪个颜色由过滤方式(filter)决定。分两种场景设置:

  • 放大过滤(magnification,GL_TEXTURE_MAG_FILTER):像素覆盖区域比一个纹素还小,纹理被放大
  • 缩小过滤(minification,GL_TEXTURE_MIN_FILTER):一个像素覆盖多个纹素,纹理被缩小
过滤方式 效果
GL_NEAREST 最近邻,取最近的纹素,像素风格
GL_LINEAR 双线性插值,取周围 2×2 纹素加权平均,平滑
GL_NEAREST_MIPMAP_NEAREST 最近邻选 mipmap 级别 + 最近邻采样
GL_LINEAR_MIPMAP_NEAREST 最近邻选级别 + 双线性插值
GL_NEAREST_MIPMAP_LINEAR 双线性选级别 + 最近邻采样
GL_LINEAR_MIPMAP_LINEAR 三线性:级内双线性 + 级间线性插值

放大过滤没有 mipmap 变体——mipmap 解决的是缩小问题,纹理放大时选哪一级都没有意义,直接在原始级别采样即可。

纹理环绕方式

纹理坐标超出 [0, 1] 时取什么内容,由环绕方式(wrap mode)决定,S、T 两个方向分别设置:

环绕方式 效果
GL_REPEAT 重复平铺(默认)
GL_MIRRORED_REPEAT 镜像平铺
GL_CLAMP_TO_EDGE 截断,边缘像素向外延伸
GL_CLAMP_TO_BORDER 截断,超出部分显示边界颜色(需另设 GL_TEXTURE_BORDER_COLOR)

设置参数的实际时机

这些参数通过 glTexParameteri 设置:

glBindTexture(GL_TEXTURE_2D, texture);

// 环绕方式:S、T 两个方向
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

// 过滤方式:放大与缩小
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);

这些参数是纹理对象自身的状态。只要纹理处于绑定状态,glTexParameteri 什么时候调用都可以——放在 glTexImage2D 之前、之后,还是 glGenerateMipmap 之后,效果没有区别,前提是在纹理被采样(绘制)之前设置完成。很多教程把参数设置写在 glTexImage2D 和 glGenerateMipmap 之间,那只是让代码集中,并不表示存在顺序依赖。

注:判断时机是否关键,可以想这些参数存在哪里——它们存在纹理对象内部,与数据上传、mipmap 生成互不依赖。真正有顺序约束的只有数据依赖:glGenerateMipmap 需要第 0 级数据已经上传,mipmap 变体过滤需要 mipmap 已经生成。

纹理单元

一个片元可以同时采样多张纹理(如漫反射贴图 + 法线贴图)。GPU 提供多个纹理单元(texture unit),每个单元是一个"插槽",同一时刻每个单元绑定一张纹理。应用纹理的完整流程:

// 1. 激活纹理单元,把纹理绑定进去
glActiveTexture(GL_TEXTURE0);              // 激活 0 号纹理单元
glBindTexture(GL_TEXTURE_2D, texture);     // 绑定到当前激活的单元

// 2. 把采样器关联到纹理单元编号
glUseProgram(shaderProgram);
glUniform1i(glGetUniformLocation(shaderProgram, "ourTexture"), 0);

// 3. 绘制
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

GL_TEXTURE0 + n 表示第 n 号纹理单元。glActiveTexture 之后,所有纹理操作(glBindTexture、glTexParameteri、glTexImage2D)都作用于当前激活单元中绑定的纹理。

着色器端使用采样器:

#version 330 core

in vec2 TexCoord;
out vec4 FragColor;

uniform sampler2D ourTexture;      // 纹理采样器

void main()
{
    FragColor = texture(ourTexture, TexCoord);
}

sampler2D 是 GLSL 的采样器类型,texture(sampler, uv) 是采样函数。

注:glUniform1i 的第二个参数是纹理单元编号,不是纹理 ID。完整链路是:glActiveTexture(GL_TEXTURE0) 把纹理对象放进 0 号单元 → glUniform1i(..., 0) 告诉采样器去 0 号单元取纹理 → 采样时从单元里取出纹理对象进行采样。纹理单元让"多张纹理"成为可能:一张纹理对应一个单元,采样器各自指向不同的单元。


3. 纹理的 C++ 封装

上面的原生 API 每次使用都要写一大段:加载、生成、绑定、上传、设参数、生成 mipmap。和 着色器、缓冲对象与顶点 的封装一样,用 RAII 类把这些过程收进一次构造,析构时自动释放 GPU 资源。

TextureCreateInfo 是创建纹理的参数包:宽高、像素数据、格式、过滤方式、环绕方式、是否生成 mipmap 都通过它传入。它还有一个从图片路径构造的重载,内部调用 stb_image 解码,免去手动加载图片的步骤。

Texture 是纹理对象本体:构造时完成生成、绑定、上传数据、设置过滤与环绕、生成 mipmap 的完整流程;析构时自动调用 glDeleteTextures。与前面的封装一致,禁止拷贝、允许移动。

// Texture.hpp
#pragma once

#include <cstdint>
#include <string>
#include <glad/glad.h>

namespace MY::OpenGL
{
    /**
     *  纹理的像素格式
     *  决定每个像素的通道数及每个通道的位宽
     */
    enum class TextureFormat : uint8_t
    {
        None    = 0,
        R8      = 1,
        RG8     = 2,
        RGB8    = 3,
        RGBA8   = 4,
        RGBA16F = 5,
        Depth   = 6
    };

    /**
     *  纹理的缩放过滤模式
     *  - Nearest:最近邻采样,像素风格
     *  - Linear:线性插值,平滑效果
     *  - Trilinear:三线性过滤,在 mipmap 层级之间也做线性插值(需开启 mipmap)
     */
    enum class TextureFilter : uint8_t
    {
        Nearest, Linear, Trilinear
    };

    /**
     *  纹理坐标超出 [0, 1] 范围时的环绕模式
     *  - Repeat:重复平铺
     *  - ClampToEdge:截断到边缘像素
     *  - ClampToBorder:截断到用户指定的边界颜色
     */
    enum class TextureWrap : uint8_t
    {
        Repeat, ClampToEdge, ClampToBorder
    };

    /**
     *  @brief 创建纹理所需的参数
     */
    struct TextureCreateInfo
    {
        TextureCreateInfo() = delete;
        
        TextureCreateInfo(uint32_t w, uint32_t h, const void* data = nullptr, 
            TextureFormat fmt  = TextureFormat::RGBA8) 
            : width(w), height(h), format(fmt), pixel_data(data) { }

        /**
         *  直接从图片创建纹理
         *  底层调用 stb_image.h
         */
        TextureCreateInfo(const std::string& img_path);

        /**
         *  对于从文件加载的纹理,Texture 类只是持有 pixel_data 指针,释放逻辑由 TextureCreateInfo 的析构函数负责
         *  因此会在这里调用 stbi_image_free() 释放 pixel_data 指向的内存
         */
        ~TextureCreateInfo();

        uint32_t      width      = 0;        // 纹理宽度(像素)
        uint32_t      height     = 0;        // 纹理高度(像素)
        TextureFormat format     = TextureFormat::RGBA8;  // 像素格式
        TextureFilter filter     = TextureFilter::Linear; // 缩放过滤模式
        TextureWrap   wrap_s     = TextureWrap::Repeat;   // 水平方向环绕模式
        TextureWrap   wrap_t     = TextureWrap::Repeat;   // 垂直方向环绕模式
        const void*   pixel_data = nullptr;   // 像素数据指针,由文件加载时底层为 stbi_load 分配的内存
        bool          gen_mips   = true;      // 是否自动生成 mipmap

        bool from_file = false;     // 是否从文件加载纹理
    };

    /*
    *   OpenGL 纹理的 RAII 封装
    *   构造时根据 TextureCreateInfo 创建 OpenGL 纹理对象并上传像素数据,
    *   析构时自动调用 glDeleteTextures 释放 GPU 资源。
    *   禁止拷贝,允许移动(移动后源对象不再持有纹理)
    */
    class Texture
    {
    public:
        Texture(const TextureCreateInfo& info);
        ~Texture();

        // 禁止拷贝
        Texture(const Texture&)            = delete;
        Texture& operator=(const Texture&) = delete;

        // 允许移动
        Texture(Texture&&) noexcept;
        Texture& operator=(Texture&&) noexcept;
        
    public:
        uint32_t get_width()        const { return m_width;  }
        uint32_t get_height()       const { return m_height; }
        uint32_t get_texture_id()   const { return m_texture_id; }

    private:
        uint32_t m_width        = 0;   // 纹理宽度
        uint32_t m_height       = 0;   // 纹理高度
        GLuint   m_texture_id   = 0;   // OpenGL 纹理对象 ID
    };
} // namespace MY::OpenGL
// Texture.cpp
#define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>

#include <iostream>

#include "Texture.hpp"

namespace MY::OpenGL
{
    namespace
    {
        /**
         *  @brief 将 TextureFormat 映射到 OpenGL 的 (internal_format, format, type) 三元组
         */
        struct GLFormatMapping
        {
            GLint  internal_format;
            GLenum format;
            GLenum type;
        };

        static GLFormatMapping to_gl_format(TextureFormat fmt)
        {
            switch(fmt)
            {
                case TextureFormat::R8:      return {GL_R8,    GL_RED,  GL_UNSIGNED_BYTE};
                case TextureFormat::RG8:     return {GL_RG8,   GL_RG,   GL_UNSIGNED_BYTE};
                case TextureFormat::RGB8:    return {GL_RGB8,  GL_RGB,  GL_UNSIGNED_BYTE};
                case TextureFormat::RGBA8:   return {GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE};
                case TextureFormat::RGBA16F: return {GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT};
                case TextureFormat::Depth:   return {GL_DEPTH_COMPONENT24, GL_DEPTH_COMPONENT, GL_UNSIGNED_BYTE};
                case TextureFormat::None:
                default:                     return {GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE};
            }
        }

        /**
         *  @brief 将 TextureFilter 映射到 OpenGL 的 GL_TEXTURE_MIN_FILTER 参数
         *  @param filter 过滤模式
         *  @param gen_mips 是否生成 mipmap,影响 Nearest/Linear 是否使用 MIPMAP 变体
         */
        static GLenum to_gl_min_filter(TextureFilter filter, bool gen_mips)
        {
            switch(filter)
            {
                case TextureFilter::Nearest:   return gen_mips ? GL_NEAREST_MIPMAP_NEAREST : GL_NEAREST;
                case TextureFilter::Linear:    return gen_mips ? GL_LINEAR_MIPMAP_LINEAR  : GL_LINEAR;
                case TextureFilter::Trilinear: return GL_LINEAR_MIPMAP_LINEAR;
                default:                       return GL_LINEAR;
            }
        }

        /**
         *  @brief 将 TextureFilter 映射到 OpenGL 的 GL_TEXTURE_MAG_FILTER 参数
         *  mipmap 不影响放大过滤,仅区分 Nearest 和 Linear
         */
        static GLenum to_gl_mag_filter(TextureFilter filter)
        {
            return (filter == TextureFilter::Nearest) ? GL_NEAREST : GL_LINEAR;
        }

        /**
         *  @brief 将 TextureWrap 映射到 OpenGL 的环绕参数
         */
        static GLenum to_gl_wrap(TextureWrap wrap)
        {
            switch(wrap)
            {
                case TextureWrap::Repeat:        return GL_REPEAT;
                case TextureWrap::ClampToEdge:   return GL_CLAMP_TO_EDGE;
                case TextureWrap::ClampToBorder: return GL_CLAMP_TO_BORDER;
                default:                         return GL_REPEAT;
            }
        }

        /**
         *  @brief 将 stb_image 返回的通道数映射到 TextureFormat
         */
        static TextureFormat channels_to_format(int channels)
        {
            switch(channels)
            {
                case 1:  return TextureFormat::R8;
                case 2:  return TextureFormat::RG8;
                case 3:  return TextureFormat::RGB8;
                case 4:  return TextureFormat::RGBA8;
                default: return TextureFormat::RGBA8;
            }
        }
    } // namespace

    TextureCreateInfo::TextureCreateInfo(const std::string& img_path)
    {
        from_file = true;   // 标记为从文件加载纹理
        
        int w = 0, h = 0, channels = 0;

        // OpenGL 纹理坐标原点在左下角,而图片文件原点在左上角,需要垂直翻转
        stbi_set_flip_vertically_on_load(true);
        
        unsigned char* data = stbi_load(img_path.c_str(), &w, &h, &channels, 0);
        if(!data)
        {
            std::cerr << "[TextureCreateInfo] 加载图片失败: " << img_path << std::endl;
            return;
        }

        width      = static_cast<uint32_t>(w);
        height     = static_cast<uint32_t>(h);
        format     = channels_to_format(channels);
        pixel_data = data;
    }

    TextureCreateInfo::~TextureCreateInfo()
    {
        if(pixel_data)
        {
            if(from_file)
            {
                stbi_image_free(const_cast<void*>(pixel_data));
            }
            
            pixel_data = nullptr;
        }
    }

    Texture::Texture(const TextureCreateInfo& info)
        : m_width(info.width), m_height(info.height)
    {
        glGenTextures(1, &m_texture_id);
        glBindTexture(GL_TEXTURE_2D, m_texture_id);

        auto [internal_fmt, fmt, type] = to_gl_format(info.format);
        glTexImage2D(GL_TEXTURE_2D, 0, internal_fmt,
                     static_cast<GLsizei>(info.width), static_cast<GLsizei>(info.height),
                     0, fmt, type, info.pixel_data);

        // 设置缩放过滤
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, to_gl_min_filter(info.filter, info.gen_mips));
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, to_gl_mag_filter(info.filter));

        // 设置环绕模式
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, to_gl_wrap(info.wrap_s));
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, to_gl_wrap(info.wrap_t));

        // 生成 mipmap(需要有像素数据才有意义)
        if(info.gen_mips && info.pixel_data)
            glGenerateMipmap(GL_TEXTURE_2D);

        glBindTexture(GL_TEXTURE_2D, 0);
    }

    Texture::~Texture()
    {
        glDeleteTextures(1, &m_texture_id);
    }

    Texture::Texture(Texture&& other) noexcept
        : m_width(other.m_width), m_height(other.m_height), m_texture_id(other.m_texture_id)
    {
        other.m_width = 0;
        other.m_height = 0;
        other.m_texture_id = 0;
    }

    Texture& Texture::operator=(Texture&& other) noexcept
    {
        if(this != &other)
        {
            glDeleteTextures(1, &m_texture_id);

            m_width = other.m_width;
            m_height = other.m_height;
            m_texture_id = other.m_texture_id;

            other.m_width = 0;
            other.m_height = 0;
            other.m_texture_id = 0;
        }
        return *this;
    }
} // namespace MY::OpenGL

几个点值得注意:

  • to_gl_format 把 TextureFormat 映射为 glTexImage2D 需要的三元组(internalformat / format / type)。RGBA16F 对应 GL_RGBA16F + GL_HALF_FLOAT,即每通道 16 位半精度浮点,常用于 HDR 颜色缓冲
  • to_gl_min_filter 会根据 gen_mips 调整映射:Linear 在生成 mipmap 时自动升级为 GL_LINEAR_MIPMAP_LINEAR(三线性),Nearest 对应 GL_NEAREST_MIPMAP_NEAREST;放大过滤只有 GL_NEAREST / GL_LINEAR,对应前面讲的"放大没有 mipmap 变体"
  • to_gl_wrap 把三个环绕取值直接映射为 GL_REPEAT、GL_CLAMP_TO_EDGE、GL_CLAMP_TO_BORDER
  • Texture 构造函数里 glGenerateMipmap 只在 gen_mips && pixel_data 时执行——没有像素数据就没有第 0 级,生成没有意义