OpenGL [ 纹理 ]

上一篇我们已经让 Shader 能够接收顶点属性并输出颜色。接下来把颜色从"顶点数据"换成"图片数据":CPU 读取一张 PNG 或 JPG,OpenGL 把它上传为纹理对象,片元着色器根据纹理坐标采样,最后把图片贴到三角形上。

这篇文章按照"讲课 → 官方语义 → 实际代码 → 运行验证 → 常见问题"的方式,重点解决四件事:

  • 使用 stb_image 加载图片并上传到 GPU;
  • 理解纹理对象和纹理单元不是同一个概念;
  • 配置纹理环绕和纹理过滤;
  • 理解 mipmap 多层纹理,以及为什么缩小时需要它。

本文的核心判断:纹理对象保存图像数据和采样参数,纹理单元是 Shader 访问纹理的绑定槽位,采样器只是把纹理坐标转换成一次采样结果。 glBindTexture 选择资源,glActiveTexture 选择槽位,uniform sampler2D 告诉 Shader 使用哪个槽位;三者缺一不可。

本文使用 C++17、GLFW、GLAD 2、stb_image.h 和 OpenGL 3.3 Core Profile。窗口、VAO、VBO、EBO 和 Shader Program 的基础代码沿用前几篇文章。


一、纹理在渲染管线中处于什么位置?

顶点属性提供位置、颜色和纹理坐标,纹理对象提供图片,片元着色器把二者组合起来:

一次纹理采样可以拆成四步:

  1. 顶点着色器接收纹理坐标,并通过 out 传给片元着色器;
  2. 光栅化阶段对纹理坐标进行插值;
  3. 片元着色器使用 sampler2D 和纹理坐标调用 texture();
  4. OpenGL 根据环绕、过滤和 mipmap 状态,从纹理对象返回颜色。

Khronos 的 OpenGL 3.3 Core Profile 规范把纹理表示为命名对象,并把纹理访问组织成多个 texture image unit【纹理图像单元,日常简称:纹理单元(Texture Unit)】。纹理对象和纹理单元的关系可以先记成:

概念 保存或选择什么 常用 API
Texture Object 像素数据、尺寸、格式、环绕、过滤、mipmap glGenTextures、glTexImage2D
Texture Unit 当前 Shader 采样哪个纹理对象 glActiveTexture、glBindTexture
Sampler Uniform Shader 使用哪个纹理单元编号 glUniform1i
Texture Coordinate 采样位置,通常是 [0, 1] 顶点 in vec2

二、用 stb_image 从文件加载像素

1. stb_image 是什么?

stb_image.h 是一个单头文件图片加载库。它负责把 PNG、JPG、BMP、TGA 等文件解码成 CPU 内存中的像素数组;它不创建 OpenGL 纹理,也不负责上传 GPU。

下载官方单文件版本并放入项目:

复制代码
third_party/stb/
└── stb_image.h

在且只在一个 .cpp 文件中定义实现宏:

cpp 复制代码
#define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>

其他源文件只写:

cpp 复制代码
#include <stb_image.h>

如果在多个 .cpp 文件中定义 STB_IMAGE_IMPLEMENTATION,链接阶段会出现重复定义。stb_image 的 stbi_set_flip_vertically_on_load 可以在加载时翻转图片行顺序;这样通常能让图片左下角对应 OpenGL 纹理坐标的 (0, 0)。

2. 加载图片并查看通道数

cpp 复制代码
int width = 0;
int height = 0;
int channels = 0;

stbi_set_flip_vertically_on_load(true);
unsigned char* pixels = stbi_load(
    "assets/container.jpg",
    &width,
    &height,
    &channels,
    0);

if (!pixels) {
    std::cerr << "Failed to load image: "
              << stbi_failure_reason() << '\n';
    return 0;
}

std::cout << "image: " << width << " x " << height
          << ", channels: " << channels << '\n';

stbi_load 返回的内存由 stb_image 分配,上传完成后必须调用:

复制代码
stbi_image_free(pixels);

不要使用 delete[] pixels。分配和释放必须由同一个库配对完成。

bash 复制代码
➜  opengl-stb-image ./build/opengl_stb_image 
image: 1536x1024, channels: 3

3. 图片通道和 OpenGL format

常见的通道数量与 OpenGL 源格式对应如下:

channels 源格式
1 GL_RED
2 GL_RG
3 GL_RGB
4 GL_RGBA

实际工程中也可以强制 stbi_load 输出 4 个通道:

cpp 复制代码
unsigned char* pixels = stbi_load(
    path, &width, &height, &channels, STBI_rgb_alpha);
GLenum sourceFormat = GL_RGBA;

这样可以统一处理 JPG 的 RGB 和 PNG 的 RGBA,但内存占用会增加。本文示例根据通道数选择格式。

所以:通道(channel)就是一张图片里,每个像素保存多少份颜色信息

每个通道就是一个0~255 的字节,代表某一种颜色分量。

stb_image 加载图片返回 channels:代表图片文件原生自带几个通道

传入 stbi_load 的最后一个参数 desired_channels,是强制让 stb 输出成几个通道。

常见通道类型

  1. 1 通道:灰度图 (Gray) 每个像素只有 1 个字节,代表亮度。0 = 黑,255 = 白。没有彩色。 R(灰度)
  2. 3 通道:RGB 每个像素 3 字节:红 (R)、绿 (G)、蓝 (B)。 没有透明信息。png/jpg 都可以是 RGB。jpg 基本都是 3 通道。
  3. 4 通道:RGBA 每个像素 4 字节:红 (R)、绿 (G)、蓝 (B)、A 透明度 (Alpha) A=0 → 完全透明;A=255 → 完全不透明。 PNG 支持透明,所以 PNG 经常是 4 通道。JPG没有 Alpha 通道,永远 3 通道。

举例子:

  • RGB:[255,0,0] → 红色
  • RGBA:[255,0,0,128] → 半透明红色

这里区分一下两个概念:

  • 通道(channel) :一个像素里面,有几组颜色分量(R/G/B/A)
  • 位深(bit) :单个通道,用多少个 bit 来存这个分量的值

举最常见例子:8bit RGB 图片

  • 通道:3 通道 R、G、B
  • 位深:每通道 8 bit 1 byte = 8 bit 所以:每个通道占用 1 字节 (unsigned char,0~255) 一个像素总占用:3 × 8bit = 24 bit / 像素

三、纹理对象:把像素上传到 GPU

1. 创建和绑定纹理对象

cpp 复制代码
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);

glGenTextures 只返回纹理对象名称;真正确定它是二维纹理,是在第一次绑定到 GL_TEXTURE_2D 时完成的。此后针对 GL_TEXTURE_2D 的 glTexImage2D 和 glTexParameteri 都会操作这个当前绑定对象。

glBindTexture 参考页 对这个绑定语义有明确说明:纹理名称第一次绑定时确定目标类型,之后不能把同一个对象当作另一种目标使用。

也就是说:

OpenGL 的纹理对象名称(纹理 ID,GLuint texture) ,第一次 glBindTexture 的时候,必须指定目标(target,比如 GL_TEXTURE_2D) 。 一旦绑定成功,这个纹理 ID 就永久绑定死了这个目标类型。

后续不能拿同一个纹理 ID,去绑定到别的 target(比如 GL_TEXTURE_1D / GL_TEXTURE_CUBE_MAP)。 强行这么做 → 未定义行为(可能崩溃、黑屏、报错、随机 bug)。

2. 设置像素行对齐

stb_image 默认加载出的图像,每个通道 8 bit(1 字节) :RGB 图像每个像素包含 3 个通道,占用 3 字节;RGBA 图像每个像素包含 4 个通道,占用 4 字节。 图像单行总字节数计算公式:单行字节数 = 图像宽度 × 单像素字节数。 对于 RGB 图像,单行字节长度不一定是 4 的整数倍。

OpenGL 默认 GL_UNPACK_ALIGNMENT = 4,含义:从 CPU 主机内存向 GPU 上传纹理时,OpenGL假定图像每一行像素数据的起始地址满足 4 字节对齐。 当 RGB 图片单行字节数无法被 4 整除时,OpenGL 会错误计算每行的结束位置,发生内存读取错位,最终纹理画面错乱。

注意:GL_UNPACK_ALIGNMENT 的判断逻辑,依赖「单通道位深」计算单行跨度,但它本身不限制位深。 如果是 16bit / 通道或者 32bit 浮点图像,单通道占用 2/4 字节,单行字节数会变化,对齐问题依然存在,规则不变。

在调用 glTexImage2D 上传图像前,建议执行:

复制代码
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);

该语句设置像素解包对齐为 1 字节对齐。不会修改像素数据本身,也不会改变位深 / 通道信息;仅改变 glTexImage2D 读取客户端内存时,对图像行跨度的计算规则。

补充备注:

GL_UNPACK_ALIGNMENT 属于 OpenGL 全局状态,设置后持续生效;

RGBA(8bit / 通道)单像素 4 字节,单行字节天然是 4 倍数,不设置该项也能正常上传;但统一写这句可以兼容 RGB,省去分支判断;

对齐问题本质:是内存行起始地址约束,和位深、通道是相互独立概念:

  • 通道:一个像素有几组颜色分量
  • 位深:单个颜色分量占用多少 bit
  • UNPACK_ALIGNMENT:内存读取时每行起始地址的对齐要求

3. glTexImage2D 的参数

glTexImage2D 用于分配 GPU 端 2D 纹理存储空间,并把 CPU 内存中的像素数据上传到 GPU 显存。调用完成后,GPU 会持有一份独立的纹理副本;CPU 侧原始像素缓冲区不再被 OpenGL 引用,可以安全释放。

cpp 复制代码
glTexImage2D(
    GL_TEXTURE_2D,       // target:纹理目标类型,标记本次操作的纹理种类
    0,                   // level:Mipmap层级,0代表原图(最精细一级)
    GL_RGB8,             // internalformat:GPU内部存储格式
    width,               // 纹理宽度
    height,              // 纹理高度
    0,                   // border:边框宽度,Core Profile核心模式强制填0,已废弃
    GL_RGB,              // format:CPU端像素数组的通道排布格式
    GL_UNSIGNED_BYTE,    // type:CPU端每个颜色分量的数据类型(位深)
    pixels               // pixels:CPU内存像素数据指针,可以传nullptr只分配显存不上传数据
);

target :纹理目标。第一次绑定纹理 ID 时就确定了 target 类型;这里必须和纹理对象绑定的 target 保持一致,示例为GL_TEXTURE_2D代表 2D 纹理。

level:Mipmap 层级。0 是基础层级(原始分辨率);更大数值代表更小的多级渐远纹理。

internalformat(内部格式) :GPU 显存里,纹理数据存储的格式。同时定义通道数量 + 每通道位深。

  • GL_RGB8:3 通道 RGB,每通道 8bit;
  • GL_RGBA8:4 通道 RGBA,每通道 8bit;
  • GL_R8:单通道 Red,每通道 8bit。

width /height:纹理像素宽高。

border :旧版 OpenGL 纹理边框,Core Profile 不支持,必须固定传 0。

format(源数据格式) :CPU 内存中像素数据的通道排列方式,只描述通道顺序,不描述位深。

  • GL_RGB:内存顺序 R-G-B;
  • GL_RGBA:内存顺序 R-G-B-A;
  • GL_RED:仅单个红色通道。

type :CPU 每个颜色分量的数据类型,决定单个通道占用多少字节(位深)。GL_UNSIGNED_BYTE 代表每个分量 8bit(1 字节),也是 stb_image 默认输出类型。

pixels:CPU 像素数组指针。传入空指针时,仅在 GPU 分配对应大小存储空间,不拷贝像素。

这里出现两个容易混淆的格式:

  • internalformat 是 GPU 纹理内部存储格式,例如 GL_RGB8、GL_RGBA8;
  • format 是 CPU 像素数组的排列格式,例如 GL_RGB、GL_RGBA。

完整的加载函数可以写成:

cpp 复制代码
/**
 * @brief 加载2D图片并创建OpenGL 2D纹理对象
 * @param path 图片文件路径
 * @return GLuint 纹理对象ID;加载失败返回0
 * @note 支持1通道灰度、3通道RGB、4通道RGBA图片;使用stb_image加载图片
 * @note stb_image默认输出8bit/通道 unsigned char类型像素数据
 * @note 内部自动生成Mipmap;图片会上下翻转,适配OpenGL纹理左下角原点规则
 */
static GLuint loadTexture2D(const char* path)
{
    // 图片宽、高;channels:图片文件原生通道数量
    int width = 0;
    int height = 0;
    int channels = 0;

    // stb_image:加载图片时垂直翻转图片
    // 图片文件原点在左上角,OpenGL纹理原点在左下角,翻转防止贴图上下颠倒
    stbi_set_flip_vertically_on_load(true);

    // 加载图片到CPU内存
    // 参数:文件路径,输出宽,输出高,输出原生通道数,最后一个参数0=保持原图通道不强制转换
    unsigned char* pixels = stbi_load(
        path, &width, &height, &channels, 0);

    // 图片加载失败,打印错误信息,返回0
    if (!pixels) {
        std::cerr << "Failed to load image " << path << ": "
                  << stbi_failure_reason() << '\n';
        return 0;
    }

    GLenum sourceFormat = 0;    // CPU内存像素数据的通道排布格式(源格式)
    GLenum internalFormat = 0;  // GPU显存内部存储格式(内部格式,包含通道+每通道位深)
    // 根据图片原生通道数,自动匹配对应的源格式、GPU内部存储格式
    switch (channels) {
    case 1:
        sourceFormat = GL_RED;
        internalFormat = GL_R8;     // GPU:单通道,每通道8bit
        break;
    case 3:
        sourceFormat = GL_RGB;
        internalFormat = GL_RGB8;   // GPU:3通道RGB,每通道8bit
        break;
    case 4:
        sourceFormat = GL_RGBA;
        internalFormat = GL_RGBA8;  // GPU:4通道RGBA,每通道8bit
        break;
    default:
        // 不支持的通道类型,释放CPU像素内存并返回
        std::cerr << "Unsupported channel count: " << channels << '\n';
        stbi_image_free(pixels);
        return 0;
    }

    GLuint texture = 0;
    // 生成一个纹理名称ID,此时仅仅是一个数字,没有创建任何GPU对象与显存资源
    glGenTextures(1, &texture);

    // 将纹理ID绑定到GL_TEXTURE_2D目标,第一次绑定时创建2D纹理对象
    // 纹理对象此时仅创建元数据(纹理环绕、过滤等状态参数),**尚未分配像素显存**
    glBindTexture(GL_TEXTURE_2D, texture);

    // 设置像素解包对齐规则:CPU内存上传纹理时,每行数据按1字节对齐读取
    // stb加载的RGB图片单像素3字节,单行字节数不一定是4的倍数;OpenGL默认UNPACK_ALIGNMENT=4,会造成内存读取错位
    // 仅影响glTexImage2D读取主机内存的计算逻辑,不会修改像素本身
    glPixelStorei(GL_UNPACK_ALIGNMENT, 1);

    // glTexImage2D:为2D纹理分配GPU像素显存,并将CPU内存像素数据拷贝上传至GPU显存
    // 参数依次:
    // target:纹理目标类型,必须与glBindTexture使用的目标保持一致 GL_TEXTURE_2D
    // level:Mipmap层级,0代表基础原始纹理层级
    // internalformat:GPU内部存储格式,定义GPU上通道数量与每通道位深
    // width / height:纹理宽高
    // border:纹理边框,Core Profile核心模式废弃,强制填0
    // format:CPU内存像素通道排布格式,只描述内存通道顺序,不包含位深信息
    // type:CPU每个颜色分量的数据类型;GL_UNSIGNED_BYTE代表单分量8bit(stb默认输出类型)
    // pixels:CPU像素缓冲区指针;若传入nullptr,则只分配显存,不上传像素数据
    glTexImage2D(GL_TEXTURE_2D,
                 0,
                 static_cast<GLint>(internalFormat),
                 width,
                 height,
                 0,
                 sourceFormat,
                 GL_UNSIGNED_BYTE,
                 pixels);

    // 基于level0基础纹理,自动生成全套Mipmap多级渐远纹理
    glGenerateMipmap(GL_TEXTURE_2D);

    // ✅ glTexImage2D执行完成,像素数据已经拷贝至GPU显存,OpenGL不再引用CPU侧pixels内存
    // 安全释放stb在CPU堆上申请的像素缓冲区
    stbi_image_free(pixels);

    return texture;
}

glGenTextures:只生成纹理名称(ID),此时没有任何 GPU 资源,只是一个数字。

glBindTexture(GL_TEXTURE_2D, tex):创建纹理对象(Object) ,把这个 ID 绑定成一个GL_TEXTURE_2D类型的纹理对象。

✅ 对象存在了,但是显存还没有分配!没有地方存图片像素! 对象只是一个 "空壳",保存纹理参数(环绕、过滤),不包含像素存储。

glTexImage2D:真正分配显存存储空间 + 上传像素数据。给这个空壳纹理对象,分配一块对应宽高、内部格式的显存,然后把 CPU 的像素拷贝进去。

glTexImage2D 把 CPU 数组复制到 OpenGL 的纹理数据存储中,因此 stbi_image_free 可以在上传调用返回后执行。纹理对象保存的是 GPU 端数据,不依赖 pixels 继续存在。


四、纹理单元:纹理对象放在哪个采样槽位?

1. glActiveTexture 选择单元

OpenGL 不只有一个当前纹理绑定点。每个纹理单元都可以为 GL_TEXTURE_2D 绑定一个纹理对象:

cpp 复制代码
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);

这两行分别表示:

  1. 选择纹理单元 0;
  2. 把 texture 绑定到该单元的二维纹理目标。

OpenGL 3.3 规范中的 ActiveTexture 会选择后续纹理操作作用的 texture image unit。GL_TEXTURE0、GL_TEXTURE1 等是不同的单元编号,不是不同的纹理对象。

展开说说:

误区:OpenGL 只有一个纹理位置,一次只能绑定一张纹理。

真相:OpenGL 有多个「纹理绑定槽位」,叫做 纹理单元(Texture Unit)。

显卡硬件天生支持 同时绑定多张纹理,让一个 Shader 同时读取 2、4、8、16 张图片(取决于显卡上限)。

① 纹理对象(Texture Object)

是我们自己 glGenTextures 出来的东西,也就是你的图片资源。

每加载一张图片 = 一个纹理对象(GPU显存里真正存图的实体)。

② 纹理单元 / 纹理绑定点(Texture Unit)

这是显卡自带的硬件槽位,不是我们创建的!

系统自带:GL_TEXTURE0、GL_TEXTURE1、GL_TEXTURE2 ... GL_TEXTURE15

作用:作为桥梁,让 Shader 能读取当前绑定的纹理对象

第一行:glActiveTexture(GL_TEXTURE0)

选择接下来要操作的「纹理单元」

意思:我现在切换到 0号纹理槽位,接下来所有 glBindTexture、纹理参数、上传操作,全部作用在这个槽位上。

第二行:glBindTexture(GL_TEXTURE_2D, texture)

把你的纹理对象(图片)挂到当前选中的槽位上

也就是:把这张贴图绑定到 0号纹理单元的2D纹理目标上

所以要时刻注意: GL_TEXTURE0 / GL_TEXTURE1 不是纹理对象!!!

它们不是图片 ,它们是硬件编号、槽位编号。

  • 纹理对象:你加载的图片(GPU显存里的数据)

  • 纹理单元:显卡提供的「插槽」,用来把图片交给GPU采样器

类比:

显卡有 16 个 USB 口(纹理单元 0~15)

我们的图片是 U盘(纹理对象)

glActiveTexture = 选哪个USB口

glBindTexture = 把U盘插进去


那么为什么需要多个纹理单元?

因为一个着色器可以同时采样多张纹理:

比如:颜色贴图 + 法线贴图 + 高光贴图 一起作用

那就需要同时绑定 3 个纹理单元:

cpp 复制代码
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, colorTex);

glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, normalTex);

glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, specularTex);

Shader 中通过 sampler2D 编号对应单元:

cpp 复制代码
uniform sampler2D u_ColorTex;  // 对应 0 号单元
uniform sampler2D u_NormalTex; // 对应 1 号单元
uniform sampler2D u_SpecTex;   // 对应 2 号单元

所以:

OpenGL 并非只有唯一的全局纹理绑定位置,而是拥有多个独立的纹理图像单元(Texture Image Unit)。每个纹理单元都可以独立绑定一张 2D 纹理对象。

glActiveTexture(unit) 的作用是:切换当前全局活跃的纹理单元,后续所有纹理绑定、参数设置、数据上传操作,都会作用于该选中单元。

GL_TEXTURE0、GL_TEXTURE1、GL_TEXTURE2 是硬件纹理单元的编号,不是纹理对象。纹理对象是用户自己创建的图片资源,纹理单元是显卡内置的绑定槽位,二者完全独立。

  • 纹理对象 = 你的图片(数据)

  • 纹理单元 = 显卡的插槽(通道)

  • glActiveTexture = 选插槽

  • glBindTexture = 插图片

  • 多个单元 = 可以同时让Shader读取多张纹理

2. Shader sampler uniform 保存的是单元编号

片元着色器:

cpp 复制代码
#version 330 core
// 指定GLSL版本为330 core,对应OpenGL 3.3核心模式
// core核心模式:移除了旧版废弃的固定管线功能,只保留现代可编程管线

// 顶点着色器传入的纹理坐标,经过光栅化插值后传递到片元着色器
in vec2 texCoord;

// 片元着色器输出颜色,最终写入帧缓冲的颜色通道
out vec4 fragColor;

// 采样器2D类型uniform变量
// sampler2D:专门用来采样2D纹理的类型;
// uniform:全局只读变量,由CPU程序传入值,在整个图元绘制期间保持不变
// 这个采样器的值,代表要使用的**纹理单元编号**(默认0,对应GL_TEXTURE0)
uniform sampler2D diffuseTexture;

void main()
{
    // texture(采样器, 纹理坐标):GLSL内置纹理采样函数
    // 根据传入的纹理坐标texCoord,在diffuseTexture绑定的纹理上采样,得到RGBA颜色
    fragColor = texture(diffuseTexture, texCoord);
}

C++ 侧:

cpp 复制代码
// 激活指定的着色器程序对象,后续所有绘制、uniform变量修改都会作用在此program上
// OpenGL同一时间只能有一个处于激活状态的着色器程序
glUseProgram(program);

// 获取着色器内uniform变量 "diffuseTexture" 的位置索引
// 参数1:着色器程序ID;参数2:shader源码中uniform变量名字符串
// 返回值:变量的location编号;如果找不到该变量返回-1(例如变量被编译器优化移除)
const GLint location = glGetUniformLocation(program, "diffuseTexture");

// 设置uniform采样器变量的值
// glUniform1i:1个int类型uniform
// 采样器 sampler2D 的值,代表**纹理单元编号**,不是纹理对象ID!
// 这里填入0,代表片元着色器中的diffuseTexture采样器绑定到 GL_TEXTURE0 纹理单元
glUniform1i(location, 0); // sampler2D 使用 GL_TEXTURE0

这里的 0 不是纹理对象名称,而是纹理单元编号。完整绑定顺序是:

cpp 复制代码
// 激活着色器程序对象program;后续所有uniform变量更新、绘制命令都会作用于该着色器程序
// OpenGL在同一时刻只能激活一个着色器程序
glUseProgram(program);

// 选择当前要操作的纹理单元:GL_TEXTURE0(0号纹理单元)
// 后续glBindTexture纹理绑定操作,都会作用在这个选中的纹理单元上
glActiveTexture(GL_TEXTURE0);

// 将2D纹理对象texture绑定到当前选中的纹理单元的GL_TEXTURE_2D目标
// 纹理对象必须是第一次绑定时确定为GL_TEXTURE_2D类型,不可更换target
glBindTexture(GL_TEXTURE_2D, texture);

// 向着色器中diffuseTexture这个sampler2D采样器uniform变量赋值
// 注意:传给sampler的值是【纹理单元编号】,不是纹理对象ID
// 这里传入0,代表告诉着色器:diffuseTexture采样器去GL_TEXTURE0纹理单元读取纹理数据
glUniform1i(location, 0);

如果使用两个纹理:

cpp 复制代码
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, diffuseTexture);
glUniform1i(diffuseLocation, 0);

glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, detailTexture);
glUniform1i(detailLocation, 1);

Shader 中对应两个采样器:

cpp 复制代码
uniform sampler2D diffuseTexture;
uniform sampler2D detailTexture;

vec4 base = texture(diffuseTexture, texCoord);
vec4 detail = texture(detailTexture, texCoord * 4.0);
fragColor = mix(base, detail, 0.25);

3. 对象、单元和 sampler 的三层关系

写法 实际含义
glBindTexture(GL_TEXTURE_2D, texture) 选择要操作的纹理对象
glActiveTexture(GL_TEXTURE0) 选择纹理单元 0
glUniform1i(location, 0) 让 Shader sampler 读取单元 0
texture(diffuseTexture, uv) 按 sampler 指向的单元采样

五、纹理环绕:坐标超出 [0, 1] 怎么办?

纹理坐标通常使用 0.0 到 1.0。如果 UV 超出这个范围,OpenGL 按 GL_TEXTURE_WRAP_S、GL_TEXTURE_WRAP_T 和可选的 GL_TEXTURE_WRAP_R 决定如何处理。

cpp 复制代码
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

1. 四种常见环绕模式

模式 坐标超出范围后的行为 适用场景
GL_REPEAT 忽略整数部分,重复纹理 地面、砖墙、网格
GL_MIRRORED_REPEAT 每隔一个重复区间镜像 减少接缝的重复材质
GL_CLAMP_TO_EDGE 取边缘 texel UI、精灵、不要边框的图片
GL_CLAMP_TO_BORDER 使用 GL_TEXTURE_BORDER_COLOR 需要自定义边界颜色

使用边界颜色:

cpp 复制代码
const float borderColor[] = { 1.0f, 0.0f, 1.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D,
                 GL_TEXTURE_BORDER_COLOR,
                 borderColor);
glTexParameteri(GL_TEXTURE_2D,
                GL_TEXTURE_WRAP_S,
                GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D,
                GL_TEXTURE_WRAP_T,
                GL_CLAMP_TO_BORDER);

2. S、T、R 对应哪个方向?

二维纹理使用:

  • S:水平方向,通常对应 u;
  • T:垂直方向,通常对应 v;
  • R:第三维,二维纹理不使用。
cpp 复制代码
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

如果图片上下颠倒,优先检查 stbi_set_flip_vertically_on_load(true) 和顶点 UV 的约定,不要把环绕模式当成坐标翻转工具。


六、纹理过滤:一个采样点对应哪些 texel?

纹理坐标是连续的浮点数,而纹理由离散的 texel【纹理像素】组成。过滤模式决定 OpenGL 如何根据采样坐标计算颜色。

1. 放大过滤:GL_TEXTURE_MAG_FILTER

纹理被放大时,只能在相邻 texel 之间选择:

cpp 复制代码
// glTexParameteri:设置纹理参数(整数版本)
// 参数1:GL_TEXTURE_2D,指定当前操作的是2D纹理
// 参数2:GL_TEXTURE_MAG_FILTER,纹理放大过滤方式
//      放大:模型离相机很近,1个纹素(texel)覆盖多个屏幕像素,需要插值计算像素颜色
// 参数3:GL_LINEAR,使用双线性过滤(线性插值)
//      采样时取纹理坐标附近4个texel,加权平均得到最终颜色,画面平滑,无锯齿
glTexParameteri(GL_TEXTURE_2D,
                GL_TEXTURE_MAG_FILTER,
                GL_LINEAR);

常用值:

模式 结果
GL_NEAREST 选择最近的一个 texel,像素块明显
GL_LINEAR 对相邻 texel 做线性插值,画面更平滑

2. 缩小过滤:GL_TEXTURE_MIN_FILTER

纹理缩小时,一个屏幕像素可能覆盖大量 texel。OpenGL 需要使用更复杂的过滤方式:

cpp 复制代码
// glTexParameteri:设置纹理参数(整数版本接口)
// 参数1:GL_TEXTURE_2D,表示当前配置的是2D纹理对象
// 参数2:GL_TEXTURE_MIN_FILTER,纹理缩小过滤模式
//      缩小:模型离相机较远,多个纹素(texel)映射到同一个屏幕像素,需要做采样合并
// 参数3:GL_LINEAR_MIPMAP_LINEAR,三线性过滤
//      第一步:在两个相邻层级的mipmap纹理内部,各自做双线性插值;
//      第二步:再对这两个mipmap层级的采样结果做一次线性插值;
//      效果:远距离贴图过渡平滑,减少闪烁,是质量较高的纹理缩小采样方案
glTexParameteri(GL_TEXTURE_2D,
                GL_TEXTURE_MIN_FILTER,
                GL_LINEAR_MIPMAP_LINEAR);

合法的常用组合如下:

Min Filter 使用 mipmap? 说明
GL_NEAREST 否 最近 texel
GL_LINEAR 否 线性插值
GL_NEAREST_MIPMAP_NEAREST 是 选最近 mip 层,再取最近 texel
GL_LINEAR_MIPMAP_NEAREST 是 选最近 mip 层,再线性过滤
GL_NEAREST_MIPMAP_LINEAR 是 在两层 mip 之间线性混合,层内最近过滤
GL_LINEAR_MIPMAP_LINEAR 是 trilinear filtering,层内和层间都线性

GL_TEXTURE_MAG_FILTER 不接受 mipmap 选项,因为放大时通常只使用基础层。使用 mipmap min filter 之前,必须定义完整的 mipmap 链,否则纹理可能不完整,采样结果也会不可用。

一个常见的平衡配置是:

cpp 复制代码
// glTexParameteri:设置纹理参数(整数版本接口)
// 参数1:GL_TEXTURE_2D,表示当前配置的是2D纹理对象
// 参数2:GL_TEXTURE_MIN_FILTER,纹理缩小过滤模式
//      缩小场景:模型离相机较远,多个纹素(texel)映射到同一个屏幕像素,需要做采样合并
// 参数3:GL_LINEAR_MIPMAP_LINEAR,三线性过滤
//      第一步:在两个相邻层级的mipmap纹理内部,各自做双线性插值;
//      第二步:再对这两个mipmap层级的采样结果做一次线性插值;
//      效果:远距离贴图过渡平滑,减少闪烁,是质量较高的纹理缩小采样方案
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER,
                GL_LINEAR_MIPMAP_LINEAR);

// glTexParameteri:设置纹理参数(整数版本接口)
// 参数1:GL_TEXTURE_2D,表示当前配置的是2D纹理对象
// 参数2:GL_TEXTURE_MAG_FILTER,纹理放大过滤方式
//      放大场景:模型离相机很近,1个纹素(texel)覆盖多个屏幕像素,需要插值计算像素颜色
// 参数3:GL_LINEAR,使用双线性过滤
//      采样时取纹理坐标附近4个texel,加权平均得到最终颜色,画面平滑,无锯齿
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER,
                GL_LINEAR);

七、多层纹理:mipmap 不是"多张随便的图片"

1. mipmap level 的结构

mipmap 是同一张纹理的多个分辨率层级:

cpp 复制代码
level 0: 1024 x 1024
level 1:  512 x  512
level 2:  256 x  256
level 3:  128 x  128
...
level n:    1 x    1

level 0 是原始纹理。每增加一个 level,宽度和高度通常减半。OpenGL 根据纹理在屏幕上的投影大小选择更合适的 level,减少远处物体采样大量高分辨率 texel 的成本,也降低闪烁。

2. 自动生成 mipmap

上传 level 0 后调用:

cpp 复制代码
// glTexImage2D:向GPU上传2D纹理图像数据,分配纹理存储空间
// 参数1:GL_TEXTURE_2D,目标纹理类型为2D纹理
// 参数2:0,指定mipmap层级,0代表基础层级(原始最高分辨率贴图)
// 参数3:GL_RGBA8,GPU内部存储格式:RGBA每个通道占8bit,共32bit每texel
// 参数4:width,纹理图片宽度(纹素texel数量)
// 参数5:height,纹理图片高度(纹素texel数量)
// 参数6:0,边框宽度,OpenGL标准固定填0,不支持纹理边框
// 参数7:GL_RGBA,传入CPU内存像素数据的格式:按RGBA顺序排列
// 参数8:GL_UNSIGNED_BYTE,每个通道数据类型:无符号字节(0~255)
// 参数9:pixels,CPU端像素数据缓冲区指针;传NULL时只分配显存不填充数据
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8,
              width, height, 0,
              GL_RGBA, GL_UNSIGNED_BYTE, pixels);

// glGenerateMipmap:基于刚刚上传的基础纹理,自动生成全套mipmap金字塔
// 会逐级缩小纹理分辨率,生成一层一层更小的纹理,用于远距离采样
// 注意:前面MIN_FILTER使用GL_LINEAR_MIPMAP_LINEAR时,必须调用此函数,否则渲染出错
glGenerateMipmap(GL_TEXTURE_2D);

glGenerateMipmap 根据基础层生成后续 mipmap levels。它生成的是同一纹理对象的不同 level,不会创建多个纹理对象。

3. 手动定义多层纹理

也可以手动为每层上传数据:

cpp 复制代码
// glTexImage2D:向GPU上传2D纹理图像数据,分配对应mip层级的纹理存储空间
// 参数1:GL_TEXTURE_2D,目标纹理类型为2D纹理
// 参数2:0,指定当前上传的是mipmap层级0,也就是最高分辨率原始贴图
// 参数3:GL_RGBA8,GPU内部存储格式:R/G/B/A每个通道8bit,单个纹素texel共32bit
// 参数4:1024,纹理宽度,单位:纹素texel
// 参数5:1024,纹理高度,单位:纹素texel
// 参数6:0,纹理边框宽度,OpenGL规范固定填0,不支持纹理边框
// 参数7:GL_RGBA,CPU内存像素数据的通道排布顺序
// 参数8:GL_UNSIGNED_BYTE,每个通道数据类型:无符号字节,取值范围0~255
// 参数9:level0Pixels,CPU端mip level0像素数据缓冲区指针
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8,
              1024, 1024, 0,
              GL_RGBA, GL_UNSIGNED_BYTE, level0Pixels);

// glTexImage2D:上传mipmap层级1的纹理数据
// 参数2:1,mipmap层级1;分辨率是level0的1/2:512×512
glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8,
              512, 512, 0,
              GL_RGBA, GL_UNSIGNED_BYTE, level1Pixels);

// glTexImage2D:上传mipmap层级2的纹理数据
// 参数2:2,mipmap层级2;分辨率是level1的1/2:256×256
glTexImage2D(GL_TEXTURE_2D, 2, GL_RGBA8,
              256, 256, 0,
              GL_RGBA, GL_UNSIGNED_BYTE, level2Pixels);

手动上传适合压缩纹理、特殊滤波或资源管线已经生成 mipmap 的情况。普通图片加载使用 glGenerateMipmap 更简单。

4. 多层纹理和纹理数组不是一回事

"mipmap level"与"texture array layer"经常被混淆:

概念 含义
Mipmap level 同一张图的不同分辨率层
Texture array layer 同一尺寸下的多张独立图层
sampler2D 采样二维纹理的某个 mip 层,由 LOD 选择
sampler2DArray 采样二维纹理数组,需要额外 layer 坐标

本文的 glGenerateMipmap 生成的是 mipmap levels,不是二维纹理数组。


八、纹理坐标与 Shader 采样

1. 顶点数据增加 UV

前面的位置和颜色每个顶点有 6 个 float;加入纹理坐标后,每个顶点可以包含 8 个 float:

cpp 复制代码
const float vertices[] = {
    // position              // color            // uv
     0.0f,  0.6f, 0.0f,       1.0f, 0.2f, 0.2f,  0.5f, 1.0f,
    -0.6f, -0.5f, 0.0f,       0.2f, 1.0f, 0.2f,  0.0f, 0.0f,
     0.6f, -0.5f, 0.0f,       0.2f, 0.3f, 1.0f,  1.0f, 0.0f,
};

配置 UV 属性:

cpp 复制代码
// glVertexAttribPointer:配置顶点属性数据的解析规则,告诉GPU如何从顶点缓冲区读取属性
// 参数1:2,顶点属性索引,对应shader里 layout(location = 2) 的属性
// 参数2:2,该属性每个顶点包含2个分量(这里是UV纹理坐标,u、v)
// 参数3:GL_FLOAT,每个分量的数据类型为浮点数float
// 参数4:GL_FALSE,是否归一化;false代表浮点数不做0~1范围归一化,直接原样读取
// 参数5:8 * sizeof(float),步长(Stride),一个完整顶点所有数据占用字节数;
//        本顶点结构一共8个float,例如:pos(2)+color(3)+uv(2)+... 总共8个float
// 参数6:reinterpret_cast<void*>(6 * sizeof(float)),数据偏移量(Offset);
//        UV属性在每个顶点内,从第6个float之后开始,单位字节。前面6个float是别的顶点属性
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE,
                      8 * sizeof(float),
                      reinterpret_cast<void*>(6 * sizeof(float)));

// glEnableVertexAttribArray:启用索引为2的顶点属性通道
// 顶点属性默认关闭,必须启用,GPU才会从VBO读取该属性并传入着色器
glEnableVertexAttribArray(2);

2. 顶点 Shader 传递 UV

cpp 复制代码
#version 330 core
// 声明GLSL版本为330,对应OpenGL 3.3核心配置文件(core profile)

// layout(location = N):绑定顶点属性索引,和C++代码 glVertexAttribPointer 的第一个参数一一对应
// location = 0:索引0,接收顶点位置,vec3类型(x,y,z)
layout (location = 0) in vec3 aPosition;
// location = 1:索引1,接收顶点RGB颜色,vec3类型(r,g,b)
layout (location = 1) in vec3 aColor;
// location = 2:索引2,接收顶点UV纹理坐标,vec2类型(u,v),对应C++端 location=2 的顶点属性
layout (location = 2) in vec2 aTexCoord;

// out:顶点着色器向外输出的变量,会进入光栅化阶段,在三角形内部自动插值,传递给片元着色器
out vec3 vertexColor;
// 输出插值用的顶点颜色
out vec2 texCoord;
// 输出插值用的纹理UV坐标

void main()
{
    // gl_Position:GLSL内建特殊输出变量,代表顶点在【裁剪空间】的齐次坐标,必须赋值
    // vec4(aPosition, 1.0):把三维位置vec3扩展为齐次四维向量(x,y,z,w),w分量设为1.0
    gl_Position = vec4(aPosition, 1.0);

    // 将输入的顶点颜色,原样输出给片元着色器;三角形内部片元会自动做颜色插值
    vertexColor = aColor;
    // 将输入的顶点UV坐标,原样输出给片元着色器;三角形内部片元会自动对UV插值,用于纹理采样
    texCoord = aTexCoord;
}

3. 片元 Shader 采样纹理

cpp 复制代码
#version 330 core
// 声明GLSL版本为330,对应OpenGL 3.3核心模式

// in:接收来自顶点着色器经过光栅插值后的变量
in vec3 vertexColor;
// 插值后的顶点RGB颜色,由顶点着色器out vertexColor传入
in vec2 texCoord;
// 插值后的UV纹理坐标,由顶点着色器out texCoord传入,用于纹理采样

// uniform:全局着色器变量,由CPU在绘制前传入GPU,所有片元共用同一个值
uniform sampler2D diffuseTexture;
// 2D纹理采样器,用来绑定我们创建的2D纹理对象,调用texture函数读取纹素颜色
uniform float brightness;
// 亮度系数,用来整体缩放颜色明暗,CPU侧可以修改这个值控制画面亮度

// out:片元着色器最终输出颜色,RGBA,写入帧缓冲
out vec4 fragColor;

void main()
{
    // texture:纹理采样函数;参数1:采样器;参数2:UV坐标
    // 根据插值后的texCoord,从diffuseTexture纹理中采样得到texel颜色,返回vec4(RGBA)
    vec4 textureColor = texture(diffuseTexture, texCoord);

    // vertexColor * brightness:把插值顶点颜色乘以亮度系数,调节明暗
    // vec4(...,1.0):把RGB转成RGBA,Alpha通道固定为1(不透明)
    // 纹理颜色 和 顶点颜色*亮度 相乘,得到最终片元颜色
    fragColor = textureColor * vec4(vertexColor * brightness, 1.0);
}

texture() 会使用 sampler 指向的纹理单元,并依据当前纹理对象的环绕、过滤和 mipmap 状态返回采样结果。


九、完整的纹理初始化与绘制片段

下面代码把加载图片、创建纹理、配置采样参数和绑定纹理单元串起来:

cpp 复制代码
// 定义这个宏,才会编译 stb_image 的实现代码;必须放在#include <stb_image.h>之前
#define STB_IMAGE_IMPLEMENTATION
// stb_image:单头文件图像加载库,用于读取图片文件到CPU内存缓冲区
#include <stb_image.h>

/**
 * @brief 从图片文件创建OpenGL 2D纹理对象,自动生成mipmap
 * @param path 图片文件路径
 * @return 成功返回纹理对象ID;失败返回0
 */
static GLuint createTexture(const char* path)
{
    // width:图片宽度;height:图片高度;channels:图片原始通道数量(3=RGB,4=RGBA)
    int width = 0;
    int height = 0;
    int channels = 0;

    // stbi加载图片时,垂直翻转图片
    // 原因:图片文件原点在左上角;OpenGL纹理UV原点在左下角,不翻转图片会上下颠倒
    stbi_set_flip_vertically_on_load(true);

    // stbi_load:加载图像到内存
    // 参数1:文件路径
    // 参数2/3/4:输出宽、高、通道数
    // 参数5:强制通道数,填0代表保持图片原本通道
    unsigned char* pixels = stbi_load(
        path, &width, &height, &channels, 0);

    // 图片加载失败判断
    if (!pixels) {
        std::cerr << "Cannot load " << path << ": "
                  << stbi_failure_reason() << '\n';
        return 0;
    }

    GLenum format = 0;         // CPU端像素数据格式(传给glTexImage2D的第7个参数)
    GLenum internalFormat = 0; // GPU内部存储纹理格式(传给glTexImage2D的第3个参数)
    if (channels == 3) {
        format = GL_RGB;
        internalFormat = GL_RGB8; // GPU内部每个RGB通道8bit
    } else if (channels == 4) {
        format = GL_RGBA;
        internalFormat = GL_RGBA8; // GPU内部每个RGBA通道8bit
    } else {
        // 不支持其他通道(灰度图等),释放内存直接返回
        std::cerr << "Only RGB/RGBA images are supported in this example\n";
        stbi_image_free(pixels);
        return 0;
    }

    GLuint texture = 0;
    // glGenTextures:生成1个纹理对象ID,不会立刻分配显存
    glGenTextures(1, &texture);
    // glBindTexture:绑定该纹理为当前GL_TEXTURE_2D目标的活动纹理,后续纹理操作都作用在这个纹理上
    glBindTexture(GL_TEXTURE_2D, texture);

    // glPixelStorei:设置像素解包对齐;GL_UNPACK_ALIGNMENT=1,表示像素行按1字节对齐
    // 防止图片宽度不是4的倍数时,读取像素出现错位、花屏
    glPixelStorei(GL_UNPACK_ALIGNMENT, 1);

    // 设置纹理S方向(UV的u方向,水平)环绕模式:GL_REPEAT 重复平铺
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    // 设置纹理T方向(UV的v方向,垂直)环绕模式:GL_REPEAT 重复平铺
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

    // 纹理缩小过滤:GL_LINEAR_MIPMAP_LINEAR 三线性过滤,远距离采样平滑,减少闪烁
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER,
                    GL_LINEAR_MIPMAP_LINEAR);
    // 纹理放大过滤:GL_LINEAR 双线性插值,放大贴图平滑无锯齿
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

    // glTexImage2D:上传CPU内存像素数据到GPU显存,创建level0基础纹理
    glTexImage2D(GL_TEXTURE_2D, 0,
                 static_cast<GLint>(internalFormat),
                 width, height, 0,
                 format, GL_UNSIGNED_BYTE, pixels);

    // glGenerateMipmap:基于level0基础纹理,自动生成全套mipmap金字塔
    glGenerateMipmap(GL_TEXTURE_2D);

    // stbi_image_free:释放CPU端图片像素缓冲区,GPU显存已经保存纹理,CPU内存可以释放
    stbi_image_free(pixels);

    return texture;
}

// ========== 初始化阶段(程序启动只调用一次) ==========
// 加载图片并创建纹理对象,得到纹理ID
GLuint diffuseTexture = createTexture("assets/container.jpg");
// 获取shader中uniform变量 diffuseTexture 的位置索引
GLint diffuseLocation =
    glGetUniformLocation(program, "diffuseTexture");

// ========== 绘制循环内,每次绘制物体时调用 ==========
// 使用指定着色器程序
glUseProgram(program);

// glActiveTexture:激活纹理单元 GL_TEXTURE0(0号纹理单元)
// OpenGL默认至少支持16个纹理单元,采样器通过编号绑定到对应单元
glActiveTexture(GL_TEXTURE0);
// 将diffuseTexture纹理绑定到当前激活的纹理单元(GL_TEXTURE0)
glBindTexture(GL_TEXTURE_2D, diffuseTexture);
// 设置shader里面的sampler2D采样器diffuseTexture = 0,对应纹理单元0
glUniform1i(diffuseLocation, 0);

// 绑定顶点数组对象VAO(顶点属性+VBO+EBO状态)
glBindVertexArray(vao);
// 绘制三角形,使用索引缓冲,共3个索引,索引类型无符号int,偏移nullptr
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, nullptr);

这段代码的顺序很重要:

  1. 图片先由 stbi_load 解码到 CPU 内存;
  2. glTexImage2D 把像素上传到当前纹理对象;
  3. glGenerateMipmap 创建后续 mipmap levels;
  4. 绘制前选择 GL_TEXTURE0 并绑定纹理对象;
  5. sampler uniform 设置为 0,让 Shader 采样单元 0。

十、常见问题:黑色、倒置、边缘和闪烁

1. 纹理全黑

按这个顺序排查:

  1. stbi_load 是否返回空指针?输出 stbi_failure_reason();
  2. glTexImage2D 的 format 是否和通道数一致?
  3. 纹理是否绑定到正确的 GL_TEXTURE0?
  4. sampler uniform 是否设置为正确的单元编号?
  5. 是否在绘制前调用了 glUseProgram?
  6. 纹理坐标是否落在预期范围?
  7. Shader 是否成功编译和链接?

2. 图片上下颠倒

图片文件的原点通常在左上角,而 OpenGL 纹理坐标常按左下角理解:

cpp 复制代码
stbi_set_flip_vertically_on_load(true);

也可以不翻转图片,改写 UV 的 v 坐标。两种方式选一种,不要同时处理两次。

3. RGB 图片出现错行或颜色异常

检查:

cpp 复制代码
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);

尤其是 RGB 图片宽度导致每行字节数不是 4 的倍数时,默认对齐会造成错行。

4. 使用 mipmap 过滤却没有生成 mipmap

如果设置:

cpp 复制代码
glTexParameteri(GL_TEXTURE_2D,
                GL_TEXTURE_MIN_FILTER,
                GL_LINEAR_MIPMAP_LINEAR);

就必须在 level 0 上传后调用:

cpp 复制代码
glGenerateMipmap(GL_TEXTURE_2D);

否则纹理对象缺少采样所需的 mipmap levels。

5. 纹理边缘出现奇怪颜色

检查 GL_TEXTURE_WRAP_S/T。UI 和精灵通常适合:

cpp 复制代码
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,
                GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,
                GL_CLAMP_TO_EDGE);

重复材质才使用 GL_REPEAT。

6. 两张纹理互相覆盖

常见错误是只调用了:

cpp 复制代码
glBindTexture(GL_TEXTURE_2D, secondTexture);

却没有切换 glActiveTexture。绑定前必须明确选择单元:

cpp 复制代码
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, firstTexture);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, secondTexture);

十一、这一篇真正应该记住什么?

  1. stb_image 只负责把文件解码到 CPU 内存;
  2. glTexImage2D 把像素上传到纹理对象;
  3. 纹理对象保存图像数据、采样参数和 mipmap levels;
  4. 纹理单元是 Shader 访问纹理对象的槽位;
  5. sampler2D 的 uniform 值是纹理单元编号,不是纹理对象编号;
  6. GL_TEXTURE_WRAP_S/T 决定 UV 越界后的行为;
  7. GL_TEXTURE_MAG_FILTER 处理放大,GL_TEXTURE_MIN_FILTER 处理缩小;
  8. 使用 mipmap 过滤前必须生成或上传对应的 mipmap levels;
  9. RGB 图片上传时要特别留意 GL_UNPACK_ALIGNMENT;
  10. 图片上下方向要统一由 stbi_set_flip_vertically_on_load 或 UV 约定解决。

纹理的难点不是调用 glTexImage2D,而是分清"图片文件、CPU 像素数组、纹理对象、纹理单元、sampler uniform、纹理坐标"这六层对象。每一层都有自己的生命周期和绑定关系,渲染结果才会稳定。


官方资料与延伸阅读

附录

cpp 复制代码
// 定义该宏,才会编译 stb_image 的实现代码;必须放在#include <stb_image.h>之前
#define STB_IMAGE_IMPLEMENTATION
// stb_image:单头文件图像加载库,用于读取图片文件到CPU内存缓冲区
#include <stb_image.h>

// GLAD2头文件,必须放在GLFW头文件前面,防止系统自带OpenGL头文件冲突
#include <glad/gl.h>
// GLFW窗口库头文件,用于创建窗口、OpenGL上下文、处理窗口事件
#include <GLFW/glfw3.h>

// 标准输入输出库,用于控制台打印日志、错误信息
#include <iostream>

// GLM数学库,提供向量、矩阵类型,用于图形学坐标变换
#include <glm/glm.hpp>
// GLM矩阵变换工具,如平移、旋转、缩放、透视投影
#include <glm/gtc/matrix_transform.hpp>

// 匿名命名空间:内部函数仅在本cpp文件内可见,避免全局命名冲突
namespace {

/**
 * @brief GLFW全局错误回调函数,捕获GLFW内部产生的错误
 * @param code 错误码
 * @param description 错误的文本描述
 */
void glfwErrorCallback(int code, const char* description) {
    std::cerr << "GLFW error [" << code << "]: "
              << description << '\n';
}

/**
 * @brief 帧缓冲区尺寸变化回调函数
 *        当窗口被用户缩放时自动触发,用来同步OpenGL视口大小
 * @param window 触发事件的窗口句柄
 * @param width 帧缓冲区新宽度(像素,不是窗口逻辑坐标)
 * @param height 帧缓冲区新高度(像素,不是窗口逻辑坐标)
 */
void framebufferSizeCallback(GLFWwindow*, int width, int height) {
    // 设置OpenGL视口:定义渲染画面映射到窗口的像素区域
    // 参数:x起点,y起点,宽度,高度
    glViewport(0, 0, width, height);
}

/**
 * @brief 校验OpenGL上下文版本与类型,打印显卡OpenGL信息
 * @return true 上下文符合要求(>=3.3 Core);false 版本/类型不满足
 */
bool checkOpenGLContext() {
    GLint major = 0;       // OpenGL主版本号
    GLint minor = 0;       // OpenGL次版本号
    GLint profileMask = 0; // Profile掩码,用来判断是Core/兼容模式

    // 获取OpenGL主版本号
    glGetIntegerv(GL_MAJOR_VERSION, &major);
    // 获取OpenGL次版本号
    glGetIntegerv(GL_MINOR_VERSION, &minor);
    // 获取上下文Profile掩码
    glGetIntegerv(GL_CONTEXT_PROFILE_MASK, &profileMask);

    // 判断版本是否 >=3.3
    const bool versionOK = major > 3 || (major == 3 && minor >= 3);
    // 判断是否是Core核心模式上下文
    const bool coreOK = (profileMask & GL_CONTEXT_CORE_PROFILE_BIT) != 0;

    // 打印显卡OpenGL信息
    // glGetString返回const GLubyte*,reinterpret_cast将其重新解释为char字符串指针
    std::cout << "OpenGL version: "
              << reinterpret_cast<const char*>(glGetString(GL_VERSION))
              << '\n';
    std::cout << "OpenGL vendor: "
              << reinterpret_cast<const char*>(glGetString(GL_VENDOR))
              << '\n';
    std::cout << "OpenGL renderer: "
              << reinterpret_cast<const char*>(glGetString(GL_RENDERER))
              << '\n';
    std::cout << "GLSL version: "
              << reinterpret_cast<const char*>(
                     glGetString(GL_SHADING_LANGUAGE_VERSION))
              << '\n';
    std::cout << "Context profile: "
              << (coreOK ? "Core" : "Unknown/Compatibility") << '\n';

    // 版本校验失败
    if (!versionOK) {
        std::cerr << "OpenGL 3.3 or newer is required.\n";
        return false;
    }

    // 不是Core核心模式
    if (!coreOK) {
        std::cerr << "An OpenGL Core Profile context is required.\n";
        return false;
    }

    return true;
}

/**
 * @brief 编译单个着色器对象
 * @param type 着色器类型:GL_VERTEX_SHADER顶点着色器 / GL_FRAGMENT_SHADER片元着色器
 * @param source GLSL源码字符串
 * @return 成功返回着色器对象ID,失败返回0
 */
static GLuint compileShader(GLenum type, const char* source) {
    // 创建着色器对象,返回着色器名称ID
    const GLuint shader = glCreateShader(type);
    // 将 GLSL 源码挂载到 shader 对象上
    glShaderSource(shader, 1, &source, nullptr);
    // 驱动编译着色器源码
    glCompileShader(shader);

    GLint success = GL_FALSE;
    // 获取编译状态
    glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
    if (!success) {
        GLint logLength = 0;
        glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &logLength);
        // 分配字符串缓冲区存放编译日志
        std::string log(static_cast<std::size_t>(logLength), '\0');
        glGetShaderInfoLog(shader, logLength, nullptr, log.data());
        std::cerr << "Shader compilation failed:\n" << log << '\n';
        // 编译失败,删除着色器对象,释放GPU资源
        glDeleteShader(shader);
        return 0;
    }
    return shader;
}

/**
 * @brief 创建着色器程序:编译顶点、片元着色器,并且链接为完整程序
 * @return 成功返回program程序ID,失败返回0
 */
static GLuint createProgram() {
    // 顶点着色器源码:330 core
    // layout(location=0) 接收顶点坐标;layout(location=1)接收顶点颜色
    // out变量vertexColor传递颜色给片元着色器
    constexpr const char* vertexSource = R"GLSL(
        #version 330 core
        layout (location = 0) in vec3 aPosition;
        layout (location = 1) in vec3 aColor;
        layout (location = 2) in vec2 aTexCoord;

        out vec3 vertexColor;
        out vec2 texCoord;

        void main()
        {
            gl_Position = vec4(aPosition, 1.0);
            vertexColor = aColor;
            texCoord = aTexCoord;
        }
    )GLSL";

    // 片元着色器源码:接收顶点着色器插值后的颜色与UV,采样纹理并输出像素颜色
    constexpr const char* fragmentSource = R"GLSL(
        #version 330 core

        in vec3 vertexColor;
        in vec2 texCoord;

        uniform sampler2D diffuseTexture;
        uniform float brightness;

        out vec4 fragColor;

        void main()
        {
            vec4 textureColor = texture(diffuseTexture, texCoord);

            fragColor = textureColor * vec4(vertexColor * brightness, 1.0);
        }

    )GLSL";

    // 编译顶点着色器
    const GLuint vertexShader = compileShader(GL_VERTEX_SHADER, vertexSource);
    // 编译片元着色器
    const GLuint fragmentShader = compileShader(GL_FRAGMENT_SHADER, fragmentSource);
    // 如果任意着色器编译失败,直接返回0
    if (vertexShader == 0 || fragmentShader == 0) return 0;

    // 创建着色器程序对象
    const GLuint program = glCreateProgram();
    // 将编译好的着色器附加到程序对象上
    glAttachShader(program, vertexShader);
    glAttachShader(program, fragmentShader);
    // 链接程序:将多个着色器合并,校验输入输出变量匹配
    glLinkProgram(program);

    GLint success = GL_FALSE;
    glGetProgramiv(program, GL_LINK_STATUS, &success);
    if (!success) {
        GLint logLength = 0;
        glGetProgramiv(program, GL_INFO_LOG_LENGTH, &logLength);
        std::string log(static_cast<std::size_t>(logLength), '\0');
        glGetProgramInfoLog(program, logLength, nullptr, log.data());
        std::cerr << "Program link failed:\n" << log << '\n';
        glDeleteProgram(program);
        return 0;
    }
    // 链接成功后,着色器对象已经挂载进program,可以删除释放资源
    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);
    return program;
}

/**
 * @brief 使用stb_image加载图片,创建OpenGL 2D纹理,自动生成Mipmap
 * @param path 图片文件路径
 * @return 成功返回纹理ID,失败返回0
 */
static GLuint createTexture(const char* path) {
    int width = 0, height = 0, channels = 0;
    // stbi加载图片时垂直翻转图片,匹配OpenGL UV原点在左下角的规则
    stbi_set_flip_vertically_on_load(true);
    // 加载图像到CPU内存
    unsigned char* pixels = stbi_load(
        path, &width, &height, &channels, 0);
    if (!pixels) {
        std::cerr << "Cannot load: " << path << ": "
            << stbi_failure_reason() << '\n';
        return 0;
    }

    GLenum format = 0;         // CPU内存像素数据格式
    GLenum internalFormat = 0;// GPU显存内部存储格式
    if (channels == 3) {
        format = GL_RGB;
        internalFormat = GL_RGB8;
    } else if (channels == 4) {
        format = GL_RGBA;
        internalFormat = GL_RGBA8;
    } else {
        std::cerr << "Only RGB/RGBA images are supported in this example\n";
        stbi_image_free(pixels);
        return 0;
    }

    GLuint texture = 0;
    // 生成1个纹理对象ID
    glGenTextures(1, &texture);
    // 绑定为当前2D纹理,后续纹理参数操作作用于该纹理
    glBindTexture(GL_TEXTURE_2D, texture);
    // 设置像素解包对齐,防止图片宽度非4倍数时像素错位花屏
    glPixelStorei(GL_UNPACK_ALIGNMENT, 1);

    // S方向(u水平)环绕模式:重复平铺
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    // T方向(v垂直)环绕模式:重复平铺
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

    // 缩小过滤:三线性过滤GL_LINEAR_MIPMAP_LINEAR,远距离平滑,减少闪烁
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
    // 放大过滤:双线性GL_LINEAR,放大贴图平滑无锯齿
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

    // 上传 CPU 内存像素数据到 GPU 显存,创建 level0 基础纹理
    glTexImage2D(GL_TEXTURE_2D, 0,
                static_cast<GLint>(internalFormat),
                width, height, 0,
                format, GL_UNSIGNED_BYTE, pixels);

    // 自动生成整套Mipmap金字塔
    glGenerateMipmap(GL_TEXTURE_2D);
    // 释放CPU端图片内存,显存已经保存纹理
    stbi_image_free(pixels);
    return texture;
}

} // namespace

int main() {

    // 注册GLFW全局错误回调,GLFW发生任何错误都会调用这个函数
    glfwSetErrorCallback(glfwErrorCallback);

    // 初始化GLFW库
    if (!glfwInit()) {
        std::cerr << "glfwInit failed.\n";
        return 1;
    }

    // ========== 配置OpenGL上下文提示(必须在创建窗口之前执行) ==========
    // 指定OpenGL主版本3
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    // 指定OpenGL次版本3,即3.3
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    // 指定使用Core核心模式,废弃旧版固定管线API
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

#ifdef __APPLE__
    // macOS平台强制要求:Core上下文必须开启向前兼容模式,移除废弃API
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
#endif

    // 创建窗口与OpenGL上下文
    // 参数:宽,高,窗口标题,全屏模式(null=窗口化),共享上下文(null=不共享)
    GLFWwindow* window = glfwCreateWindow(
        960, 540, "OpenGL Texture", nullptr, nullptr);

    // 窗口创建失败处理
    if (!window) {
        std::cerr << "glfwCreateWindow failed.\n";
        glfwTerminate(); // 销毁GLFW资源,退出
        return 1;
    }

    /////////////////////////////////////////////////////////////////

    // 将当前窗口的OpenGL上下文设置为【当前上下文】
    // 重要:OpenGL函数必须在存在当前上下文之后,才能查询加载
    glfwMakeContextCurrent(window);

    // GLAD2加载OpenGL函数指针
    // 传入glfwGetProcAddress作为查询函数,GLAD内部循环调用它获取所有OpenGL函数地址
    const int gladVersion = gladLoadGL(
        reinterpret_cast<GLADloadfunc>(glfwGetProcAddress));
    if (gladVersion == 0) {
        std::cerr << "gladLoadGL failed.\n";
        glfwDestroyWindow(window);
        glfwTerminate();
        return 1;
    }

    // 校验OpenGL上下文版本,打印显卡信息
    if (!checkOpenGLContext()) {
        glfwDestroyWindow(window);
        glfwTerminate();
        return 1;
    }

    // 获取窗口对应的帧缓冲区实际像素大小(Mac视网膜屏窗口逻辑尺寸≠像素尺寸)
    int framebufferWidth = 0;
    int framebufferHeight = 0;
    glfwGetFramebufferSize(window, &framebufferWidth, &framebufferHeight);
    // 初始化视口,匹配帧缓冲大小
    glViewport(0, 0, framebufferWidth, framebufferHeight);
    // 注册窗口大小变化回调函数
    glfwSetFramebufferSizeCallback(window, framebufferSizeCallback);

    // 垂直同步开关:1=开启,帧率锁定显示器刷新率;0=关闭,不限制帧率
    glfwSwapInterval(1);

    /////////////////////////////////////////////////////////////////

    // 创建着色器程序
    const GLuint program = createProgram();
    if (program == 0) {
        std::cerr << "CreateProgram failed.\n";
        glfwDestroyWindow(window);
        glfwTerminate();
        return 1;
    }

    /////////////////////////////////////////////////////////////////

    // 顶点数组:每8个float为1个顶点
    // 格式:position(x,y,z)  color(r,g,b)  uv(u,v)
    const float vertices[] = {
        // position              // color            // uv
        0.0f,  0.6f, 0.0f,       1.0f, 0.2f, 0.2f,  0.5f, 1.0f,
       -0.6f, -0.5f, 0.0f,       0.2f, 1.0f, 0.2f,  0.0f, 0.0f,
        0.6f, -0.5f, 0.0f,       0.2f, 0.3f, 1.0f,  1.0f, 0.0f,
    };
    // 索引数组:3个索引,构成1个三角形,索引对应vertices里顶点序号
    const unsigned int indices[] = {0, 1, 2};

    // VAO顶点数组对象,VBO顶点缓冲对象,EBO索引缓冲对象
    GLuint vao = 0, vbo = 0, ebo = 0;
    glGenVertexArrays(1, &vao);
    glGenBuffers(1, &vbo);
    glGenBuffers(1, &ebo);
    if (!vao || !vbo || !ebo) {
        std::cerr << "glGenVertexArrays failed.\n";
        glDeleteVertexArrays(1, &vao);
        glDeleteBuffers(1, &vbo);
        glDeleteBuffers(1, &ebo);
        glDeleteProgram(program);
        glfwDestroyWindow(window);
        glfwTerminate();
        return 1;
    }
    // 绑定VAO,后续顶点属性配置会存入VAO状态
    glBindVertexArray(vao);
    // 绑定VBO到ARRAY_BUFFER目标
    glBindBuffer(GL_ARRAY_BUFFER, vbo);
    // 将顶点数据上传到VBO显存;GL_STATIC_DRAW表示数据很少修改,GPU优化
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
    // 绑定EBO到ELEMENT_ARRAY_BUFFER索引缓冲目标
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
    // 将索引数据上传EBO显存
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    // 加载图片,创建纹理
    GLuint diffuseTexture = createTexture("./assets/container.jpg");
    GLuint diffuseLocation = glGetUniformLocation(program, "diffuseTexture");

    // 配置location=0:顶点位置属性,3个float,步长8*float,偏移0字节
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), reinterpret_cast<void*>(0));
    glEnableVertexAttribArray(0);
    // 配置location=1:顶点颜色属性,3个float,偏移3*sizeof(float)
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), reinterpret_cast<void*>(sizeof(float) * 3));
    glEnableVertexAttribArray(1);
    // 配置location=2:UV纹理坐标属性,2个float,偏移6*sizeof(float)
    glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), reinterpret_cast<void*>(sizeof(float) * 6));
    glEnableVertexAttribArray(2);

    // 解绑VAO、VBO,防止意外修改
    glBindVertexArray(0);
    glBindBuffer(GL_ARRAY_BUFFER, 0);


    // ========== 主渲染循环 ==========
    // glfwWindowShouldClose 判断窗口是否收到关闭信号(点击右上角关闭按钮)
    while (!glfwWindowShouldClose(window)) {
        // 设置清屏颜色:RGBA,深蓝灰色
        glClearColor(0.08f, 0.12f, 0.20f, 1.0f);
        // 清除颜色缓冲区
        glClear(GL_COLOR_BUFFER_BIT);

        // 使用着色器程序
        glUseProgram(program);
        // 绑定VAO,读取顶点配置
        glBindVertexArray(vao);
        // 激活0号纹理单元
        glActiveTexture(GL_TEXTURE0);
        // 将纹理绑定到当前激活的纹理单元
        glBindTexture(GL_TEXTURE_2D, diffuseTexture);
        // 设置shader采样器diffuseTexture = 0,对应0号纹理单元
        glUniform1i(diffuseLocation, 0);

        // 获取brightness这个uniform变量在shader中的位置
        int brightnessLocation = glGetUniformLocation(program, "brightness");
        // 用sin函数随时间动态变化亮度,范围0.3 ~ 1.0
        const float brightness =
            0.65f + 0.35f * static_cast<float>(std::sin(glfwGetTime()));
        // 将亮度值传给shader的uniform变量
        glUniform1f(brightnessLocation, brightness);

        // 使用索引缓冲绘制三角形,共3个索引
        glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, nullptr);


        // 双缓冲机制:交换前后缓冲区,把后台绘制好的画面提交到屏幕显示
        glfwSwapBuffers(window);
        // 轮询处理窗口事件:鼠标、键盘、窗口缩放、关闭事件等
        glfwPollEvents();
    }

    // 程序退出前,释放GPU资源
    glDeleteVertexArrays(1, &vao);
    glDeleteBuffers(1, &vbo);
    glDeleteBuffers(1, &ebo);
    glDeleteProgram(program);
    // 销毁窗口,终止GLFW
    glfwDestroyWindow(window);
    glfwTerminate();
    return 0;
}
相关推荐
余额瞒着我当琳1 小时前
C++ map 底层与AVL 树
开发语言·c++·算法
喆星时瑜1 小时前
R语言向量语法的详细教程
开发语言·r语言
乃嘿仔2 小时前
AI 热点日报 · 2026-10-01
开发语言·人工智能·php
All for pursuit.2 小时前
【贪心-2】253.会议室 II
数据结构·c++·算法·leetcode
重生之小比特2 小时前
【C++进阶】异常
开发语言·c++
朝朝辞暮i2 小时前
C++ 第 15 课:指针 * 和地址 &
开发语言·c++
奶茶树2 小时前
【C++】14. C++11新特性【下】
开发语言·c++
web打印社区2 小时前
汽修 / 4S 店:维修工单、结算单网页怎么静默出纸
开发语言·前端·javascript·chrome·ecmascript
Sirens.3 小时前
Java多线程实例:单例模式、阻塞队列、线程池与定时器
java·开发语言·单例模式