一、纹理
纹理(Texture)是 OpenGL 中用于在图形上添加细节和颜色的重要功能。纹理是一个2D图片(甚至也有1D和3D的纹理),它可以用来添加物体的细节。纹理可以将二维图像映射到几何形状上,从而让图形看起来更加真实。
纹理的基本概念
纹理坐标(Texture Coordinates):
纹理坐标用于定义几何形状上的纹理映射位置。
纹理坐标通常用
s
和t
表示(对应于二维图像的x
和y
坐标)。纹理坐标的范围通常是
[0, 1]
,其中(0, 0)
表示纹理的左下角,(1, 1)
表示纹理的右上角。纹理单元(Texture Unit):
纹理单元是 OpenGL 中用于存储和管理纹理的逻辑单元。
每个纹理单元可以绑定一个纹理对象。
OpenGL 提供了多个纹理单元(如
GL_TEXTURE0
,GL_TEXTURE1
, 等),可以通过glActiveTexture
来激活指定的纹理单元。纹理对象(Texture Object):
纹理对象是一个包含纹理数据和纹理参数的 OpenGL 对象。
纹理对象可以通过
glGenTextures
创建,并通过glBindTexture
绑定到指定的纹理单元。纹理过滤(Texture Filtering):
纹理过滤用于决定纹理在放大或缩小时的采样方式。
常见的纹理过滤方式包括:
线性过滤(Linear Filtering):平滑过渡,适合放大或缩小时。
最近邻过滤(Nearest Neighbor Filtering):直接取最近的纹理像素,适合像素风格的纹理。
纹理环绕(Texture Wrapping):
纹理环绕定义了纹理坐标超出
[0, 1]
范围时的行为。常见的纹理环绕方式包括:
重复(Repeat):纹理在超出范围时重复。
镜像重复(Mirrored Repeat):纹理在超出范围时镜像重复。
边缘夹持(Clamp to Edge):超出范围时夹持到纹理边缘。
边界颜色(Clamp to Border):超出范围时使用边界颜色。
为了能够把纹理映射(Map)到三角形上,需要指定三角形的每个顶点各自对应纹理的哪个部分。这样每个顶点就会关联着一个纹理坐标(Texture Coordinate),用来标明该从纹理图像的哪个部分采样(注:采集片段颜色)。之后在图形的其它片段上进行片段插值(Fragment Interpolation)。
纹理坐标在x和y轴上,范围为0到1之间(使用的是2D纹理图像)。使用纹理坐标获取纹理颜色叫做采样(Sampling)。纹理坐标起始于(0, 0),也就是纹理图片的左下角,终止于(1, 1),即纹理图片的右上角。下面的图片展示了如何把纹理坐标映射到三角形上。
为三角形指定了3个纹理坐标点:希望三角形的左下角对应纹理的左下角,因此把三角形左下角顶点的纹理坐标设置为(0, 0);同理右下方的顶点设置为(1, 0);三角形的上顶点对应于图片的上中位置所以把它的纹理坐标设置为(0.5, 1.0)。只要给顶点着色器传递这三个纹理坐标就行了,接下来它们会被传到片段着色器中,它会为每个片段进行纹理坐标的插值。
纹理坐标:
float texCoords[] = {
0.0f, 0.0f, // 左下角
1.0f, 0.0f, // 右下角
0.5f, 1.0f // 上中
};
对纹理采样的解释非常宽松,它可以采用几种不同的插值方式。所以需要告诉OpenGL该怎样对纹理采样。
纹理环绕方式
纹理坐标的范围通常是从(0, 0)到(1, 1),那如果把纹理坐标设置在范围之外会发生什么?OpenGL默认的行为是重复这个纹理图像(基本上忽略浮点纹理坐标的整数部分),但OpenGL提供了更多的选择:
环绕方式 | 描述 |
---|---|
GL_REPEAT | 对纹理的默认行为。重复纹理图像。 |
GL_MIRRORED_REPEAT | 和GL_REPEAT一样,但每次重复图片是镜像放置的。 |
GL_CLAMP_TO_EDGE | 纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。 |
GL_CLAMP_TO_BORDER | 超出的坐标为用户指定的边缘颜色。 |
每个选项都可以使用glTexParameter*函数对单独的一个坐标轴设置(s
、t
(如果是使用3D纹理那么还有一个r
)它们和x
、y
、z
是等价的):
// 设置纹理在水平方向(S轴)的环绕方式为镜像重复
// 当纹理坐标超出[0, 1]范围时,纹理会在水平方向以镜像的方式重复
// 例如,纹理坐标从1.0到2.0时,纹理会镜像显示;从2.0到3.0时,纹理会再次正常显示
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
// 设置纹理在垂直方向(T轴)的环绕方式为镜像重复
// 当纹理坐标超出[0, 1]范围时,纹理会在垂直方向以镜像的方式重复
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
第一个参数指定了纹理目标。使用的是2D纹理,因此纹理目标是GL_TEXTURE_2D。
第二个参数需要指定设置的选项与应用的纹理轴。我们打算配置的是
WRAP
选项,并且指定S
和T
轴。最后一个参数需要我们传递一个环绕方式(Wrapping),OpenGL会给当前激活的纹理设定纹理环绕方式为GL_MIRRORED_REPEAT。
如果选择GL_CLAMP_TO_BORDER选项,还需要指定一个边缘的颜色。这需要使用glTexParameter函数的fv
后缀形式,用GL_TEXTURE_BORDER_COLOR作为它的选项,并且传递一个float数组作为边缘的颜色值:
// 定义边界颜色为黄色(RGBA格式)
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
// 设置纹理的边界颜色
// 当纹理环绕模式为 GL_CLAMP_TO_BORDER 时,超出纹理范围的区域会使用这个颜色
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
纹理过滤
纹理坐标不依赖于分辨率(Resolution),它可以是任意浮点值,所以OpenGL需要知道怎样将纹理像素(Texture Pixel,也叫Texel)映射到纹理坐标。当你有一个很大的物体但是纹理的分辨率很低的时候这就变得很重要了。OpenGL也有对于纹理过滤(Texture Filtering)的选项。纹理过滤有很多个选项,但是现在我们只讨论最重要的两种:GL_NEAREST和GL_LINEAR。
Texture Pixel也叫Texel,你可以想象你打开一张
.jpg
格式图片,不断放大你会发现它是由无数像素点组成的,这个点就是纹理像素;注意不要和纹理坐标搞混,纹理坐标是你给模型顶点设置的那个数组,OpenGL以这个顶点的纹理坐标数据去查找纹理图像上的像素,然后进行采样提取纹理像素的颜色。
① GL_NEAREST(也叫邻近过滤,Nearest Neighbor Filtering)是OpenGL默认的纹理过滤方式。当设置为GL_NEAREST的时候,OpenGL会选择中心点最接近纹理坐标的那个像素。下图中可以看到四个像素,加号代表纹理坐标。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色:
② GL_LINEAR(也叫线性过滤,(Bi)linear Filtering)它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中可以看到返回的颜色是邻近像素的混合色:
GL_NEAREST产生颗粒状的图案,能够清晰看到组成纹理的像素,而GL_LINEAR能够产生更平滑的图案,很难看出单个的纹理像素。GL_LINEAR可以产生更真实的输出,但有些开发者更喜欢8-bit风格,所以他们会用GL_NEAREST选项。
当进行放大(Magnify)和缩小(Minify)操作的时候可以设置纹理过滤的选项,比如可以在纹理被缩小的时候使用邻近过滤,被放大时使用线性过滤。需要使用glTexParameter*函数为放大和缩小指定过滤方式。这段代码看起来会和纹理环绕方式的设置很相似:
// 设置纹理在缩小(minification)时的过滤方式为最近邻过滤(GL_NEAREST)
// 最近邻过滤会在缩小纹理时直接选择最近的纹理像素,不会进行插值
// 这种方式在需要保持纹理锐利边缘(如像素艺术或图标)时很有用,但可能会导致锯齿效果
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
// 设置纹理在放大(magnification)时的过滤方式为线性过滤(GL_LINEAR)
// 线性过滤会在放大纹理时对相邻的纹理像素进行插值,从而平滑过渡
// 这种方式在放大纹理时可以减少锯齿效果,使纹理看起来更平滑
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
多级渐远纹理
多级渐远纹理(Mipmap)是一种优化技术,用于处理纹理在缩小(minification)时的视觉效果。它通过为纹理生成一系列分辨率逐渐降低的图像(每个图像的大小是前一个的一半),在渲染时根据物体与观察者的距离选择合适的纹理级别(Level),从而避免纹理模糊或锯齿。
距观察者的距离超过一定的阈值,OpenGL会使用不同的多级渐远纹理,即最适合物体的距离的那个。由于距离远,解析度不高也不会被用户注意到。同时,多级渐远纹理另一加分之处是它的性能非常好。
手工为每个纹理图像创建一系列多级渐远纹理很麻烦,OpenGL有一个glGenerateMipmap函数,在创建完一个纹理后调用它OpenGL就会承担接下来的所有工作了。
在渲染中切换多级渐远纹理级别(Level)时,OpenGL在两个不同级别的多级渐远纹理层之间会产生不真实的生硬边界。就像普通的纹理过滤一样,切换多级渐远纹理级别时你也可以在两个不同多级渐远纹理级别之间使用NEAREST和LINEAR过滤。为了指定不同多级渐远纹理级别之间的过滤方式,可以使用下面四个选项中的一个代替原有的过滤方式:
过滤方式 | 描述 |
---|---|
GL_NEAREST_MIPMAP_NEAREST | 使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近插值进行纹理采样 |
GL_LINEAR_MIPMAP_NEAREST | 使用最邻近的多级渐远纹理级别,并使用线性插值进行采样 |
GL_NEAREST_MIPMAP_LINEAR | 在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近插值进行采样 |
GL_LINEAR_MIPMAP_LINEAR | 在两个邻近的多级渐远纹理之间使用线性插值,并使用线性插值进行采样 |
就像纹理过滤一样,可以使用glTexParameteri将过滤方式设置为前面四种提到的方法之一:
// 设置纹理在缩小(minification)时的过滤方式为线性多级渐远纹理过滤(GL_LINEAR_MIPMAP_LINEAR)
// 这种过滤方式会在两个最接近的多级渐远纹理级别之间进行线性插值,并且在每个纹理级别内也使用线性插值进行采样。
// 它提供了高质量的纹理缩小效果,适用于需要平滑过渡的场景,但计算成本相对较高。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
// 设置纹理在放大(magnification)时的过滤方式为线性过滤(GL_LINEAR)
// 线性过滤会对相邻的纹理像素进行插值,从而在放大纹理时提供平滑的过渡效果,避免锯齿。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
一个常见的错误是,将放大过滤的选项设置为多级渐远纹理过滤选项之一。这样没有任何效果,因为多级渐远纹理主要是使用在纹理被缩小的情况下的:纹理放大不会使用多级渐远纹理,为放大过滤设置多级渐远纹理的选项会产生一个GL_INVALID_ENUM错误代码。
二、加载与创建纹理
使用纹理之前首先要把它们加载到我们的应用中。纹理图像可能被储存为各种各样的格式,每种都有自己的数据结构和排列,所以如何才能把这些图像加载到应用中呢?一个解决方案是选一个需要的文件格式,比如.PNG
,然后写一个图像加载器,把图像转化为字节序列。写自己的图像加载器虽然不难,但仍然挺麻烦的,而且如果要支持更多文件格式呢?你就不得不为每种你希望支持的格式写加载器了。
另一个解决方案也许是一种更好的选择,使用一个支持多种流行格式的图像加载库来为我们解决这个问题。比如说我们要用的stb_image.h
库。
stb_image.h
stb_image.h
是一个非常流行的单头文件图像加载库,由 Sean Barrett 开发,支持加载多种常见格式的图像文件,如 PNG、JPEG、BMP、GIF 等。
功能特点:
支持多种图像格式:包括 PNG、JPG、BMP、GIF、HDR、TGA 等。
简单易用:只需包含单个头文件即可使用。
轻量级:整个库仅包含一个头文件,易于集成。
支持透明通道:对于支持透明通道的图像格式(如 PNG),可以正确解码并返回带有透明度信息的像素数据。
GitHub地址:stb/stb_image.h at master · nothings/stb
下载这个头文件,将它以stb_image.h
的名字加入工程,并另创建一个新的C++文件,输入以下代码:
// 定义宏 STB_IMAGE_IMPLEMENTATION
// 这个宏必须在包含 stb_image.h 之前定义,且只能定义一次。
// 它告诉 stb_image.h 提供完整的实现代码(而不是仅仅声明接口)。
#define STB_IMAGE_IMPLEMENTATION
// 包含 stb_image.h 头文件
// stb_image.h 是一个单头文件库,用于加载多种图像格式(如 PNG、JPG、BMP、GIF 等)。
// 包含该头文件后,stb_image.h 会根据是否定义了 STB_IMAGE_IMPLEMENTATION 来决定是否提供实现代码。
#include "stb_image.h"
通过定义STB_IMAGE_IMPLEMENTATION,预处理器会修改头文件,让其只包含相关的函数定义源码,等于是将这个头文件变为一个 .cpp
文件了。现在只需要在你的程序中包含stb_image.h
并编译就可以了。
用stb_image.h
加载图片
需要使用它的stbi_load函数:
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
这个函数首先接受一个图像文件的位置作为输入。接下来它需要三个int
作为它的第二、第三和第四个参数,stb_image.h
将会用图像的宽度、高度和颜色通道的个数填充这三个变量。之后生成纹理的时候会用到的图像的宽度和高度的。
// 定义宏 STB_IMAGE_IMPLEMENTATION
#define STB_IMAGE_IMPLEMENTATION
// 包含 stb_image.h 头文件
#include "stb_image.h"
#include <iostream>
int main() {
// 加载图像
int width, height, nrChannels;
unsigned char* data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
if (data) {
std::cout << "Image loaded successfully!" << std::endl;
std::cout << "Width: " << width << ", Height: " << height << ", Channels: " << nrChannels << std::endl;
// 释放图像数据
stbi_image_free(data);
}
else {
std::cout << "Failed to load image." << std::endl;
}
return 0;
}
生成纹理
和生成的OpenGL对象一样,纹理也是使用ID引用的。
// 定义一个无符号整数变量,用于存储纹理对象的 ID
unsigned int texture;
// 生成一个纹理对象,并将生成的纹理 ID 存储在变量 `texture` 中
// 参数 1 表示生成一个纹理对象
glGenTextures(1, &texture);
glGenTextures函数首先需要输入生成纹理的数量,然后把它们储存在第二个参数的unsigned int
数组中(例子中只是单独的一个unsigned int
),就像其他对象一样,我们需要绑定它,让之后任何的纹理指令都可以配置当前绑定的纹理:
// 将纹理对象绑定到 GL_TEXTURE_2D 目标
// 参数 1: GL_TEXTURE_2D 表示这是一个二维纹理
// 参数 2: texture 是之前通过 glGenTextures 生成的纹理对象的 ID
glBindTexture(GL_TEXTURE_2D, texture);
纹理绑定后,可以使用前面载入的图片数据生成一个纹理了。纹理可以通过glTexImage2D来生成:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
- 第一个参数指定了纹理目标(Target)。设置为GL_TEXTURE_2D意味着会生成与当前绑定的纹理对象在同一个目标上的纹理(任何绑定到GL_TEXTURE_1D和GL_TEXTURE_3D的纹理不会受到影响)。
- 第二个参数为纹理指定多级渐远纹理的级别,如果你希望单独手动设置每个多级渐远纹理的级别的话。这里填0,也就是基本级别。
- 第三个参数告诉OpenGL希望把纹理储存为何种格式。我们的图像只有
RGB
值,因此我们也把纹理储存为RGB
值。- 第四个和第五个参数设置最终的纹理的宽度和高度。之前加载图像的时候储存了它们,所以使用对应的变量。
- 下个参数应该总是被设为
0
(历史遗留的问题)。- 第七第八个参数定义了源图的格式和数据类型。我们使用RGB值加载这个图像,并把它们储存为
char
(byte)数组,我们将会传入对应值。- 最后一个参数是真正的图像数据。
当调用glTexImage2D时,当前绑定的纹理对象就会被附加上纹理图像。然而,目前只有基本级别(Base-level)的纹理图像被加载了,如果要使用多级渐远纹理,我们必须手动设置所有不同的图像(不断递增第二个参数)。或者,直接在生成纹理之后调用glGenerateMipmap。这会为当前绑定的纹理自动生成所有需要的多级渐远纹理。
生成了纹理和相应的多级渐远纹理后,释放图像的内存:
stbi_image_free(data);
生成一个纹理:
(完整地展示了如何在 OpenGL 中创建、配置并加载一个纹理 )
// 定义一个无符号整数变量,用于存储纹理对象的 ID
unsigned int texture;
// 生成一个纹理对象,并将生成的纹理 ID 存储在变量 `texture` 中
glGenTextures(1, &texture);
// 将生成的纹理对象绑定到 GL_TEXTURE_2D 目标
// 绑定后,后续的纹理操作都将作用于这个纹理对象
glBindTexture(GL_TEXTURE_2D, texture);
// 为当前绑定的纹理对象设置环绕、过滤方式
// 设置纹理在水平方向(S轴)的环绕方式为重复(GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
// 设置纹理在垂直方向(T轴)的环绕方式为重复(GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
// 设置纹理在缩小(minification)时的过滤方式为线性过滤(GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
// 设置纹理在放大(magnification)时的过滤方式为线性过滤(GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 加载并生成纹理
int width, height, nrChannels; // 定义变量用于存储图像的宽度、高度和通道数
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
if (data)
{
// 如果图像加载成功
// 将图像数据加载到当前绑定的纹理对象中
// 参数解释:
// GL_TEXTURE_2D: 指定纹理目标为二维纹理
// 0: 纹理级别(0 表示基本纹理级别)
// GL_RGB: 纹理的内部格式(这里假设图像是 RGB 格式)
// width, height: 图像的宽度和高度
// 0: 边框宽度(现代 OpenGL 中通常设置为 0)
// GL_RGB: 图像数据的格式(RGB 格式)
// GL_UNSIGNED_BYTE: 图像数据的类型(无符号字节)
// data: 指向图像数据的指针
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
// 自动生成多级渐远纹理(Mipmap)
// 这一步会为当前绑定的纹理对象生成多级渐远纹理
// 多级渐远纹理用于在纹理缩小(minification)时提供更高质量的渲染效果
glGenerateMipmap(GL_TEXTURE_2D);
}
else
{
// 如果图像加载失败,输出错误信息
std::cout << "Failed to load texture" << std::endl;
}
// 释放加载的图像数据,避免内存泄漏
stbi_image_free(data);
应用纹理
需要告知OpenGL如何采样纹理,所以我们必须使用纹理坐标更新顶点数据:
float vertices[] = {
// ---- 位置 ---- ---- 颜色 ---- - 纹理坐标 -
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // 右上
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下
-0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f // 左上
};
由于添加了一个额外的顶点属性,必须告诉OpenGL新的顶点格式:
// 配置顶点属性指针,用于指定顶点数据的格式和内存布局
// 参数解释:
// 2: 指定顶点属性的索引(对应于顶点着色器中的 layout(location = 2))
// 2: 每个顶点属性包含的分量数量(这里是 vec2,所以是 2 个分量)
// GL_FLOAT: 每个分量的数据类型是 float
// GL_FALSE: 不对数据进行归一化处理
// 8 * sizeof(float): 每个顶点数据的总字节数(假设每个顶点包含 8 个 float,例如:位置 3 个,颜色 3 个,纹理坐标 2 个)
// (void*)(6 * sizeof(float)): 指向顶点数据中纹理坐标起始位置的偏移量(假设纹理坐标位于第 7 和第 8 个 float 的位置)
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
// 启用顶点属性数组,使能指定索引的顶点属性
// 参数 2: 指定要启用的顶点属性索引(对应于顶点着色器中的 layout(location = 2))
glEnableVertexAttribArray(2);
需要调整前面两个顶点属性的步长参数为8 * sizeof(float)
。
接着需要调整顶点着色器使其能够接受顶点坐标为一个顶点属性,并把坐标传给片段着色器:
// 指定 GLSL 的版本为 4.00,并使用核心(Core)模式
#version 400 core
// 定义顶点位置输入,位于顶点属性位置 0
layout (location = 0) in vec3 aPos;
// 定义顶点颜色输入,位于顶点属性位置 1
layout (location = 1) in vec3 aColor;
// 定义顶点纹理坐标输入,位于顶点属性位置 2
layout (location = 2) in vec2 aTexCoord;
// 定义一个输出变量,用于将顶点颜色传递给片段着色器
out vec3 ourColor;
// 定义一个输出变量,用于将顶点纹理坐标传递给片段着色器
out vec2 TexCoord;
void main()
{
// 设置顶点的最终位置
// 将输入的顶点位置 aPos 转换为齐次坐标(vec4),并赋值给 gl_Position
gl_Position = vec4(aPos, 1.0);
// 将顶点颜色 aColor 传递给片段着色器
ourColor = aColor;
// 将顶点纹理坐标 aTexCoord 传递给片段着色器
TexCoord = aTexCoord;
}
片段着色器接下来会把输出变量TexCoord
作为输入变量。
片段着色器也应该能访问纹理对象,但是怎样能把纹理对象传给片段着色器呢?GLSL有一个供纹理对象使用的内建数据类型,叫做采样器(Sampler),它以纹理类型作为后缀,比如sampler1D
、sampler3D
,或在上面的例子中的sampler2D
。可以简单声明一个uniform sampler2D
把一个纹理添加到片段着色器中,稍后会把纹理赋值给这个uniform。
// 指定 GLSL 的版本为 4.00,并使用核心(Core)模式
#version 400 core
// 定义片段着色器的输出变量,类型为 vec4,表示片段颜色
out vec4 FragColor;
// 定义从顶点着色器传递过来的输入变量
// vec3 ourColor:顶点颜色
// vec2 TexCoord:顶点纹理坐标
in vec3 ourColor;
in vec2 TexCoord;
// 定义一个统一变量,类型为 sampler2D,表示二维纹理采样器
uniform sampler2D ourTexture;
void main()
{
// 从纹理中采样颜色
// texture(ourTexture, TexCoord):根据纹理坐标 TexCoord 从纹理 ourTexture 中采样颜色
// 将采样得到的颜色赋值给 FragColor,作为片段的最终颜色
FragColor = texture(ourTexture, TexCoord);
}
使用GLSL内建的texture函数来采样纹理的颜色,它第一个参数是纹理采样器,第二个参数是对应的纹理坐标。texture函数会使用之前设置的纹理参数对相应的颜色值进行采样。这个片段着色器的输出就是纹理的(插值)纹理坐标上的(过滤后的)颜色。
现在只剩下在调用glDrawElements之前绑定纹理了,它会自动把纹理赋值给片段着色器的采样器:
// 绑定纹理对象到 GL_TEXTURE_2D 目标
// 这一步确保当前的纹理操作将作用于这个纹理对象
glBindTexture(GL_TEXTURE_2D, texture);
// 绑定顶点数组对象(VAO)
// VAO 包含了顶点数据和顶点属性的配置信息
// 绑定 VAO 后,后续的绘制操作将使用该 VAO 中的顶点数据
glBindVertexArray(VAO);
// 使用索引绘制三角形
// 参数解释:
// GL_TRIANGLES: 指定绘制模式为三角形
// 6: 指定要绘制的顶点数量(这里表示有 6 个索引,将绘制 2 个三角形)
// GL_UNSIGNED_INT: 指定索引数据的类型为无符号整数
// 0: 指向索引数据的偏移量(这里表示从索引缓冲区的起始位置开始读取)
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
完整代码:
#include <glad/glad.h> // 包含 GLAD 库,用于管理 OpenGL 函数指针
#include <GLFW/glfw3.h> // 包含 GLFW 库,用于创建窗口和管理输入
#include <stb_image.h> // 包含 STB 图像库,用于加载图像文件
#include <shader_s.h> // 包含自定义的 Shader 类
#include <iostream> // 包含标准输入输出流库
void framebuffer_size_callback(GLFWwindow* window, int width, int height); // 视口大小改变时的回调函数声明
void processInput(GLFWwindow* window); // 处理输入的函数声明
// 设置
const unsigned int SCR_WIDTH = 800; // 窗口宽度
const unsigned int SCR_HEIGHT = 600; // 窗口高度
int main()
{
// glfw:初始化和配置
// ------------------
glfwInit(); // 初始化 GLFW
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4); // 设置 OpenGL 主版本号
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 4); // 设置 OpenGL 次版本号
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); // 设置 OpenGL 核心配置文件
#ifdef __APPLE__ // 如果是苹果系统
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); // 设置正向兼容模式
#endif
// glfw窗口创建
// ----------------
GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "LearnOpenGL", NULL, NULL); // 创建 GLFW 窗口
if (window == NULL) // 如果窗口创建失败
{
std::cout << "Failed to create GLFW window" << std::endl; // 输出错误信息
glfwTerminate(); // 终止 GLFW
return -1; // 返回错误代码
}
glfwMakeContextCurrent(window); // 设置当前窗口的上下文
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback); // 设置视口大小改变的回调函数
// glad:加载所有 OpenGL 函数指针
// ---------------------------------------
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) // 加载 OpenGL 函数指针
{
std::cout << "Failed to initialize GLAD" << std::endl; // 输出错误信息
return -1; // 返回错误代码
}
// 构建并编译我们的着色器程序
// -----------------------------------
Shader ourShader("4.1.texture.vs", "4.1.texture.fs"); // 创建 Shader 对象并加载顶点和片段着色器
// 设置顶点数据(和缓冲区)并配置顶点属性
// --------------------------------------------------
float vertices[] = {
// 位置 // 颜色 // 纹理坐标
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // 右上角
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 右下角
-0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f // 左上角
};
unsigned int indices[] = {
0, 1, 3, // 第一个三角形
1, 2, 3 // 第二个三角形
};
unsigned int VBO, VAO, EBO; // 顶点缓冲对象、顶点数组对象和元素缓冲对象的句柄
glGenVertexArrays(1, &VAO); // 生成顶点数组对象
glGenBuffers(1, &VBO); // 生成顶点缓冲对象
glGenBuffers(1, &EBO); // 生成元素缓冲对象
glBindVertexArray(VAO); // 绑定顶点数组对象
glBindBuffer(GL_ARRAY_BUFFER, VBO); // 绑定顶点缓冲对象
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); // 为顶点缓冲对象分配内存并填充数据
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO); // 绑定元素缓冲对象
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); // 为元素缓冲对象分配内存并填充数据
// 位置属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)0); // 指定位置属性的位置和格式
glEnableVertexAttribArray(0); // 启用位置属性
// 颜色属性
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3 * sizeof(float))); // 指定颜色属性的位置和格式
glEnableVertexAttribArray(1); // 启用颜色属性
// 纹理坐标属性
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float))); // 指定纹理坐标属性的位置和格式
glEnableVertexAttribArray(2); // 启用纹理坐标属性
// 加载并创建纹理
// -------------------------
unsigned int texture; // 纹理对象的句柄
glGenTextures(1, &texture); // 生成纹理对象
glBindTexture(GL_TEXTURE_2D, texture); // 绑定纹理对象
// 设置纹理环绕参数
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); // 设置 S 轴的纹理环绕模式为 GL_REPEAT
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); // 设置 T 轴的纹理环绕模式为 GL_REPEAT
// 设置纹理过滤参数
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); // 设置纹理的缩小过滤模式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // 设置纹理的放大过滤模式
// 加载图像,创建纹理并生成多级渐远纹理
int width, height, nrChannels; // 图像的宽度、高度和通道数
unsigned char* data = stbi_load("/textures/container.jpg", &width, &height, &nrChannels, 0); // 加载图像
if (data) // 如果图像加载成功
{
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data); // 创建纹理
glGenerateMipmap(GL_TEXTURE_2D); // 生成多级渐远纹理
}
else
{
std::cout << "Failed to load texture" << std::endl; // 输出错误信息
}
stbi_image_free(data); // 释放加载的图像数据
// 渲染循环
// ----------
while (!glfwWindowShouldClose(window)) // 如果窗口没有关闭
{
// 输入
// -----
processInput(window); // 处理输入
// 渲染
// ------
glClearColor(0.2f, 0.3f, 0.3f, 1.0f); // 设置清除颜色
glClear(GL_COLOR_BUFFER_BIT); // 清除颜色缓冲区
// 绑定纹理
glBindTexture(GL_TEXTURE_2D, texture); // 绑定纹理对象
// 渲染容器
ourShader.use(); // 使用着色器程序
glBindVertexArray(VAO); // 绑定顶点数组对象
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0); // 绘制三角形
// glfw:交换缓冲区并轮询 IO 事件(按键按下/释放、鼠标移动等)
// -------------------------------------------------------------------------------
glfwSwapBuffers(window); // 交换前后缓冲区
glfwPollEvents(); // 轮询事件
}
// 可选:一旦资源不再需要,就释放所有资源:
// ------------------------------------------------------------------------
glDeleteVertexArrays(1, &VAO); // 删除顶点数组对象
glDeleteBuffers(1, &VBO); // 删除顶点缓冲对象
glDeleteBuffers(1, &EBO); // 删除元素缓冲对象
// glfw:终止,清除所有先前分配的 GLFW 资源。
// ------------------------------------------------------------------
glfwTerminate(); // 终止 GLFW
return 0; // 返回成功代码
}
// 处理所有输入:查询 GLFW 是否在此帧中按下/释放了相关按键,并相应地做出反应
// ---------------------------------------------------------------------------------------------------------
void processInput(GLFWwindow* window)
{
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) // 如果按下 ESC 键
glfwSetWindowShouldClose(window, true); // 关闭窗口
}
// glfw:每当窗口大小改变(由操作系统或用户调整大小)时,此回调函数执行
// ---------------------------------------------------------------------------------------------
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
// 确保视口与新窗口尺寸匹配;注意在视网膜显示屏上,宽度和高度可能会比指定的值大得多。
glViewport(0, 0, width, height); // 设置视口大小
}
矩形是全黑的,说明某一步出了问题。。。
文档里说:
所以先下一步
可以把得到的纹理颜色与顶点颜色混合。只需把纹理颜色与顶点颜色在片段着色器中相乘来混合二者的颜色:
FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);
从
ourTexture
指定的纹理中,根据TexCoord
指定的坐标采样颜色,然后将采样到的颜色与ourColor
指定的颜色相乘,得到最终的片段颜色FragColor
。
图片之后再补吧。
三、纹理单元
你可能会奇怪为什么
sampler2D
变量是个uniform,却不用glUniform给它赋值。使用glUniform1i,可以给纹理采样器分配一个位置值,这样的话能够在一个片段着色器中设置多个纹理。一个纹理的位置值通常称为一个纹理单元(Texture Unit)。一个纹理的默认纹理单元是0,它是默认的激活纹理单元,所以前面部分没有分配一个位置值。
纹理单元的主要目的是让我们在着色器中可以使用多于一个的纹理。通过把纹理单元赋值给采样器,可以一次绑定多个纹理,只要我们首先激活对应的纹理单元。就像glBindTexture一样,可以使用glActiveTexture激活纹理单元,传入需要使用的纹理单元:
glActiveTexture(GL_TEXTURE0); // 在绑定纹理之前先激活纹理单元
glBindTexture(GL_TEXTURE_2D, texture);
激活纹理单元之后,接下来的glBindTexture函数调用会绑定这个纹理到当前激活的纹理单元,纹理单元GL_TEXTURE0默认总是被激活,所以在前面的例子里当使用glBindTexture
的时候,无需激活任何纹理单元。
OpenGL至少保证有16个纹理单元供使用,也就是说可以激活从GL_TEXTURE0到GL_TEXTRUE15。它们都是按顺序定义的,所以也可以通过GL_TEXTURE0 + 8的方式获得GL_TEXTURE8,这在当我们需要循环一些纹理单元的时候会很有用。
仍然需要编辑片段着色器来接收另一个采样器:
#version 400 core
// 使用 GLSL 版本 400 核心(Core)模式
// 声明两个 uniform 变量,它们是纹理采样器(sampler2D)
uniform sampler2D texture1; // 第一个纹理采样器
uniform sampler2D texture2; // 第二个纹理采样器
void main()
{
// 使用 mix 函数将 texture1 和 texture2 的纹理颜色进行混合
// mix 函数的第一个参数是 texture1 采样得到的颜色
// 第二个参数是 texture2 采样得到的颜色
// 第三个参数是混合因子,这里设置为 0.2,表示 texture1 的颜色占比为 80%,texture2 占比为 20%
FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);
}
最终输出颜色现在是两个纹理的结合。GLSL内建的mix函数需要接受两个值作为参数,并对它们根据第三个参数进行线性插值。如果第三个值是0.0
,它会返回第一个输入;如果是1.0
,会返回第二个输入值。0.2
会返回80%
的第一个输入颜色和20%
的第二个输入颜色,即返回两个纹理的混合色。
现在需要载入并创建另一个纹理。创建另一个纹理对象,载入图片,使用glTexImage2D生成最终纹理。
// 使用 stbi_load 函数从 "awesomeface.png" 文件加载图像数据
unsigned char *data = stbi_load("awesomeface.png", &width, &height, &nrChannels, 0);
// 检查图像数据是否成功加载
if (data)
{
// 将加载的图像数据定义为一个 OpenGL 纹理
// 第一个参数 GL_TEXTURE_2D 指定这是一个二维纹理
// 第二个参数 0 表示这是纹理的第一层(对于简单的纹理来说通常是 0)
// 第三个参数 GL_RGB 表示纹理的格式是 RGB(如果你的图像有 alpha 通道,可以使用 GL_RGBA)
// 第四个参数 width 和 height 分别是图像的宽度和高度
// 第五个参数 0 表示没有边框(对于大多数纹理来说是 0)
// 第六个参数 GL_RGBA 表示纹理的格式是 RGBA(如果你的图像没有 alpha 通道,可以使用 GL_RGB)
// 第七个参数 GL_UNSIGNED_BYTE 表示纹理数据的类型是无符号字节
// 第八个参数 data 是指向加载的图像数据的指针
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
// 为纹理生成多级渐远纹理(Mipmaps),这可以提高纹理在不同距离下的渲染质量
glGenerateMipmap(GL_TEXTURE_2D);
}
现在要读取一张包含alpha(透明度)通道的.png
图片,这意味着现在需要使用GL_RGBA参数,指定该图片数据包含了alpha通道;否则OpenGL将无法正确解析图片数据。
为了使用第二个纹理(以及第一个),我们必须改变一点渲染流程,先绑定两个纹理到对应的纹理单元,然后定义哪个uniform采样器对应哪个纹理单元:
// 激活纹理单元 0,后续的纹理绑定操作将影响纹理单元 0
glActiveTexture(GL_TEXTURE0);
// 绑定名为 texture1 的纹理对象到纹理单元 0
glBindTexture(GL_TEXTURE_2D, texture1);
// 激活纹理单元 1,后续的纹理绑定操作将影响纹理单元 1
glActiveTexture(GL_TEXTURE1);
// 绑定名为 texture2 的纹理对象到纹理单元 1
glBindTexture(GL_TEXTURE_2D, texture2);
// 绑定顶点数组对象(VAO),用于后续的绘制操作
// 这个 VAO 包含了顶点数据、顶点属性配置以及顶点缓冲对象(VBO)和元素缓冲对象(EBO)的绑定信息
glBindVertexArray(VAO);
// 绘制元素缓冲对象(EBO)中定义的六个顶点索引对应的三角形
// GL_TRIANGLES 表示绘制模式为三角形
// 6 表示 EBO 中有六个顶点索引
// GL_UNSIGNED_INT 表示 EBO 中的顶点索引数据类型为无符号整型
// 0 表示顶点索引数据在 EBO 中的起始位置
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
还要通过使用glUniform1i设置每个采样器的方式告诉OpenGL每个着色器采样器属于哪个纹理单元。我们只需要设置一次即可,所以这个会放在渲染循环的前面:
ourShader.use(); // 不要忘记在设置uniform变量之前激活着色器程序!
glUniform1i(glGetUniformLocation(ourShader.ID, "texture1"), 0); // 手动设置
ourShader.setInt("texture2", 1); // 或者使用着色器类设置
while(...)
{
[...]
}
通过使用glUniform1i设置采样器,保证了每个uniform采样器对应着正确的纹理单元。