纹理
需要预先学习:
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_BORDERTexture构造函数里glGenerateMipmap只在gen_mips && pixel_data时执行——没有像素数据就没有第 0 级,生成没有意义



