【OpenGL】使用贴图

使用 SOIL 库或 stb 库来加载纹理,得到纹理数据、纹理宽度、纹理高度、通道数,然后把纹理数据填充到纹理对象,最后释放纹理数据。然后设置纹理的一些参数,比如纹理环绕方式,是否启用多级渐变纹理,纹理过滤方式以及多级纹理渐变方式。OpenGL 会给纹理采样器一个位置值,一个纹理位置称为一个纹理单元,对应一个纹理对象,最多可以设置 16 个纹理单元。

加载纹理

在使用纹理之前,必须先加载纹理。所谓加载纹理,就是把一张图片的像素信息读取出来,生成某种格式的数据,比如 char/byte 数组或二进制数据。这里介绍两个加载纹理的库,SOIL 和 stb。

SOIL

SOIL 的全称是 Simple OpenGL Image Library,它支持大多数的图片格式,比如 jpg, png, bmp, tga, dds, psd, hdr 等。这是 SOIL 的 下载地址,下载之后解压,得到以下目录结构。

1
2
3
4
5
|-Simple OpenGL Image Library
|-src
|-projects
|-lib
|-support

我们要用的就是 projects 和 src 这两个目录,lib 目录下只有一个 libSOIL.a 文件,没有 windows 可用的 lib 文件,所以要手动编译。编译的方法很简单,projects 目录下已经帮我们创建好了项目,打开最新的 VC9/SOIL.sln,如果你电脑上装的是高版本的 Visual Studio 也不用担心,vs 会自动升级项目。打开项目后直接点生成,会在 VC9/Debug 目录下生成一个 SOIL.lib 文件。使用的时候把头文件和库文件包含进项目,跟之前添加 glut,glew 等库一样。

1
2
3
4
5
GLint width, height, nChannels;
GLubyte* image = SOIL_load_image("box.jpg", &width, &height, &nChannels, SOIL_LOAD_AUTO);
GLenum format = nChannels == 4 ? GL_RGBA : (nChannels == 3 ? GL_RGB : GL_RED);
glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, (GLvoid*)image);
SOIL_free_image_data(image);

使用 SOIL_load_image 来加载一张图片。

  • 返回 unsigned byte 数组,也就是图片的纹理数据;
  • 传入参数 width,获得图片的宽度;
  • 传入参数 height,获得图片的高度;
  • 传入参数 nChannels,获得图片的通道数;

    通道数用于确定图片的内部格式,4 通道为 RGBA 格式,3 通道为 RGB 格式,2 通道为 单色+透明度 格式,1 通道为 单色 格式。

  • 最后一个参数强制以特定通道数来加载图片。

    强制设置通道数后,nChannels 还是返回图片的源通道数,比如传入 SOIL_LOAD_RGB 表示以三通道来加载图片,如果图片是 RGBA 格式,则 nChannels 仍返回 4;这时候填充纹理的时候要使用加载图片时的格式,即 GL_RGB,而不是源通道确定的格式 GL_RGBA,因为纹理数据 image 是按 SOIL_LAOD_RGB 来加载的。一般无特殊要求,最后一个参数都是传 SOIL_LOAD_AUTO,即按图片源通道数来加载。

glTexImage2D 将纹理数据填充到纹理对象,纹理数据使用完之后要调用 SOIL_free_image_data 来释放。

stb

stb 是一个单文件的图片处理库,全部由头文件组成,每个头文件为一个功能,我们需要的就是加载图片的文件 stb_image.hstb_image 同样支持加载大部分主流的图片格式,如 jpg, png, tag, bmp, psd, gif, hdr, pic 等。这是 stb 的 github 地址,因为全是头文件,所以不需要编译,使用的时候直接把需要的头文件包含到项目中即可。

1
2
3
4
5
GLint width, height, nChannels;
stbi_uc *image = stbi_load("box.jpg", &width, &height, &nChannels, 0);
GLenum format = nChannels == 4 ? GL_RGBA : (nChannels == 3 ? GL_RGB : GL_RED);
glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, image);
stbi_image_free(image);

stb_image 加载图片的方法与 SOIL 基本一样,调用 stbi_load 加载纹理数据,获得图片宽度、高度和通道数,调用 stbi_image_free 来释放纹理数据。

纹理映射

为了把纹理映射到绘制的图形上,需要指定图形每个顶点各自对应纹理的哪部分,即每个顶点从哪里采样,这样每个顶点就会关联一个纹理坐标,之后在图形的其它片段进行片段插值。

纹理坐标在 X 轴和 Y 轴的取值范围都是 0~1,如果我们按照下图方式给三角形贴上纹理。

纹理映射

则纹理坐标为:

1
2
3
4
5
GLfloat texCoords[] = {
0.0f, 0.0f, // 左下角
1.0f, 0.0f, // 右下角
0.5f, 1.0f // 上中
};

纹理环绕方式

纹理坐标必须在 0~1 的范围内,那如果我们设置的值超过 1 会怎样?OpenGL 默认会进行纹理重复,我们也可以手动设置环绕方式,共有下面四种方式。

Wrapping 描述
GL_REPEAT 对纹理的默认行为。重复纹理图像
GL_MIRRORED_REPEAT 和GL_REPEAT一样,但每次重复图片是镜像放置的
GL_CLAMP_TO_EDGE 纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果
GL_CLAMP_TO_BORDER 超出的坐标为用户指定的边缘颜色

使用 glTexParameter 函数可以对纹理进行设置,如果要设置纹理环绕方式,则设置 GL_TEXTURE_WARP 选项。

1
2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);

如果选择 GL_CLAMP_TO_BORDER 方式,则还需要设置一个颜色,调用 glTexParamter 函数的 fv 后缀格式,设置 GL_TEXTURE_BORDER_COLOR 参数。

1
2
3
4
GLfloat color[] = {1.0f, 1.0f, 1.0f, 1.0f};
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, color);

纹理过滤

纹理坐标不依赖于分辨率,当一张图片的分辨率过低时,给定一个纹理坐标,其对应取到的颜色可能不是很明确,我们必须告诉 OpenGL 如何确定这个颜色,这就是纹理过滤。纹理过滤有很多选项,最常用的有两种,邻近过滤 GL_NEAREST线性过滤 GL_LINEAR

GL_NEAREST 会选择离纹理坐标最近的像素颜色作为样本颜色。

nearest

GL_LINEAR 会基于纹理坐标周围的几个像素计算出插值。

linear

很明显 GL_LINEAR 最终呈现的效果会比较好,但计算量也会比较大,另外如果想做 8-bit 风格的游戏,也可能会选择 GL_NEAREST

使用 glTexParameter 函数来设置纹理过滤方式,可以设置纹理缩小时的的过滤方式 GL_TEXTURE_MIN_FILTER 和纹理放大时的过滤方式 GL_TEXTURE_MAX_FILTER。比如下面代码设置纹理缩小时使用邻近过滤,纹理放大时使用线性过滤。

1
2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

多级渐变纹理

一个场景里会有很多物体,有些物体离得很远,但它使用的纹理分辨率却和近处物体一样高,那么 OpenGL 从高分辨率纹理中为这些片段获取正确的颜色值就很困难,因为它跨过纹理很大一部分才获取一个颜色,而且高分辨率的纹理也会造成浪费,降低性能。

OpenGL 使用多级渐变纹理来解决这个问题,多级渐变纹理的原理是使用多个图像来表示同一个纹理,后一个纹理分辨率是前一个纹理的二分之一。当一个物体距观察者的距离超过一个阈值时,OpenGL 就会使用多级渐变纹理,即使用最适合物体距离的那个图像。

手工创建多级渐变纹理是一件很麻烦的事,幸好 OpenGL 可以为我们做这件事。创建完纹理之后调用 glGenerateMipmaps 函数就可以启用多级渐变纹理了。

应用多级渐变纹理之后,两个不同 level 的纹理层之间会产生硬边界,给人一种不真实的感觉。像纹理过滤一样,可以为多级纹理之间指定渐变过滤方式,同样有邻近过滤 NEAREST 和线性过滤 LINEAR 两种方式。现在设置过滤方式的时候,不仅可以设置纹理过滤方式,还可以同时设置纹理过滤方式和多级纹理渐变方式,共有 6 种过滤方式。

过滤方式 描述
GL_NEAREST 邻近取样
GL_LINEAR 线性取样
GL_NEAREST_MIPMAP_NEAREST 纹理过滤邻近取样,多级渐变纹理邻近取样
GL_NEAREST_MIPMAP_LINEAR 纹理过滤邻近取样,多级渐变纹理线性取样
GL_LINEAR_MIPMAP_NEAREST 纹理过滤线性取样,多级渐变纹理邻近取样
GL_LINEAR_MIPMAP_LINEAR 纹理过滤线性取样,多级渐变纹理线性取样

重新设置纹理缩小时和放大时的过滤方式:

1
2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_FILTER, GL_LINEAR);

要注意的是,纹理放大的时候不能设置多级渐变纹理的过滤选项,因为多级渐变纹理只应用于纹理缩小时,纹理放大时是不会使用多级渐变纹理的,设置过滤方式就会报 GL_INVALID_ENUM 错误。

使用纹理

纹理和顶点缓冲、顶点属性一样,都是对象;所以第一步就是创建纹理对象。

1
2
GLuint texture;
glGenTexutres(1, &texture);

然后使用 SOIL 或 stb 来加载图片,再把纹理数据填充到纹理对象中去,填充之前需要先绑定纹理对象。

1
2
3
4
glBindTexture(GL_TEXTURE_2D, texture);
GLint width,height;
GLubyte *image = SOIL_load_image("box.jpg", &width, &height, 0, SOIL_LOAD_RGB);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, (GLvoid*)image);

然后设置纹理的一些参数,设置纹理环绕方式,启用多级渐变纹理,设置纹理过滤方式以及多级纹理渐变方式。

1
2
3
4
5
glGenerateMipmap(GL_TEXTURE_2D);
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_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

最后要释放图像内存,解绑纹理对象。

1
2
SOIL_free_image_data(image);
glBindTexture(GL_TEXTURE_2D, 0);

开始绘制

现在我们成功创建了纹理对象,并保存了一张图像的数据;接下来还需要扩展 VBO ,除了顶点位置和顶点颜色之外,添加顶点对应的纹理坐标。

1
2
3
4
5
6
GLfloat vertices[] = {
// ---- 位置 ---- ---- 颜色 ---- 纹理坐标
-0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f,
0.5f, -0.5f, 0.0f, .0f, 1.0f, 0.0f, 1.0f, 0.0f,
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.5f, 1.0f
};

然后重新链接三个顶点属性。

1
2
3
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(GL_FLOAT), (GLvoid*)0);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(GL_FLOAT), (GLvoid*)(3 * sizeof(GL_FLOAT)));
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(GL_FLOAT), (GLvoid*)(6 * sizeof(GL_FLOAT)));

image

根据这张图很容易看出,每个属性的步长都是 32 个字节,颜色属性的初始偏移值为 12 个字节,纹理坐标属性的初始偏移值为 24 个字节。

绘制的时候不仅要绑定顶点数组对象 VAO,还要绑定纹理对象,然后不要忘记三个顶点属性都要启用(我一开始就是忘记启用后面两个属性,导致一直看不到效果)。

1
2
3
4
5
6
7
8
9
glBindTexture(GL_TEXTURE_2D, Texture);
glBindVertexArray(VAO);
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glEnableVertexAttribArray(2);
glDrawArrays(GL_TRIANGLES, 0, 3);
glDisableVertexAttribArray(0);
glDisableVertexAttribArray(1);
glDisableVertexAttribArray(2);

到这里程序要做的工作就已经完了,我们创建了一个顶点缓冲对象 VBO,一个顶点数组对象 VAO,一个贴图对象 texture;在顶点缓冲对象中保存了顶点位置、顶点颜色和纹理坐标三个属性值,然后分别绑定到 VAO 的三个属性。但我们的工作还没完,我们还需要修改着色器,告诉着色器如何使用这三个顶点属性。

顶点着色器接收顶点位置、顶点颜色和纹理坐标三个属性,然后输出作为下一阶段的输入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#version 440 core

layout(location=0) in vec3 position;
layout(location=1) in vec3 color;
layout(location=2) in vec2 texCoord;

out vec4 Color;
out vec2 TexCoord;

void main()
{
gl_Position = vec4(position, 1.0f);
Color = vec4(color, 1.0f);
TexCoord = texCoord;
}

片段着色器接收顶点颜色、纹理数据和纹理坐标,然后根据纹理数据和纹理坐标生成最终的片段颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
#version 440 core

in vec4 Color;
in vec2 TexCoord;

out vec4 finalColor;

uniform sampler2D Texture;

void main()
{
finalColor = texture(Texture, TexCoord);
}

应用纹理

从片段着色器可以看出,顶点颜色值我们并没有用过,三角形的颜色直接从纹理中取样。可以将纹理采样和颜色两个向量相乘,得到下面的结果。

1
finalColor = texture(Texture, TexCoord) * Color;

应用纹理和颜色

纹理单元

纹理采样时需要用到纹理数据和纹理坐标,纹理坐标保存在 VBO 中,然后传递给顶点着色器,再由顶点着色器传给片段着色器;而纹理数据则在片段着色器中定义成 uniform 变量,其类型是 sampler2D,但奇怪的是我们并没有在程序中给这个 uniform 变量赋值,为什么着色器能读取到纹理数据呢?要解开这个疑问,首先得认识一下纹理单元。

OpenGL 会给纹理采样器一个位置值,一个纹理位置称为一个纹理单元,对应一个纹理对象,给一个纹理单元赋值的步骤如下。

  • 第一步使用 glActiveTexture 函数来激活一个纹理单元,其参数的取值范围是 GL_TEXTURE0 ~ GL_TEXTURE15,也就是说我们最多可以使用 16 个纹理单元;

可以使用 GL_TEXTURE0 + 8 来表示 GL_TEXTURE8,这在循环中很有用。

  • 第二步,使用 glBindTexture 函数绑定一个纹理对象,这一步必须在激活纹理单元之后,否则就不会对该纹理单元作用;
  • 第三步,使用 glUniformi 函数来设置 uniform 采样器的值,第一个参数是 uniform 变量的 location,第二个参数是第几个纹理单元。

每一个纹理单元都需要这三步操作,假如我们事先创建好了两个纹理对象,然后赋值给两个纹理单元。

1
2
3
4
5
6
7
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, TexObject1);
glUniform1i(glGetUniformLocation(shader->getProgram(), "Texture1"), 0);

glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, TexObject2);
glUniform1i(glGetUniformLocation(shader->getProgram(), "Texture2"), 1);

然后在片段着色器中使用 mix 函数来混合两个纹理采样,生成最终的颜色,最后一个参数表示第二张贴图在取样时所占的权重。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#version 440 core

in vec4 Color;
in vec2 TexCoord;

out vec4 finalColor;

uniform sampler2D Texture1;
uniform sampler2D Texture2;

void main()
{
finalColor = mix(texture(Texture1, TexCoord), texture(Texture2,TexCoord), 0.5);
}

运行结果如下。

混合贴图

如果再混合上设置的颜色,修改片段着色器。

1
2
3
4
void main()
{
finalColor = mix(texture(Texture1, TexCoord) ,texture(Texture2,TexCoord), 0.5) * Color;
}

贴图混合颜色

接下来回到前面的问题,为什么只有一个纹理单元时,不给纹理单元设置值也可以呢?这是因为一个纹理的默认纹理单元是 0,它是默认激活的纹理单元;当只有一个纹理时我们只需要将纹理绑定纹理对象即可,OpenGL 会自动把我们绑定的纹理对象赋值给默认的纹理单元。

修改上面代码如下,绑定纹理对象 TexObject1 之后,第一个纹理单元就会自动取到了 TexObject1 的数据,所以只需要给第二个纹理单元赋值即可。

1
2
3
4
5
glBindTexture(GL_TEXTURE_2D, TexObject1);

glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, TexObject2);
glUniform1i(glGetUniformLocation(shader->getProgram(), "Texture2"), 1);

如果代码是下面这样的,则结果只会取到第二张贴图的数据,这是因为没有激活第二个纹理单元,则第二次绑定纹理对象时也是作用在第一个纹理单元上的,所以第二个纹理对象的数据会覆盖掉第一个纹理对象绑定到纹理单元 0 上。

1
2
glBindTexture(GL_TEXTURE_2D, TexObject1);
glBindTexture(GL_TEXTURE_2D, TexObject2);

但如果是下面这样的,则结果只会取到第一张纹理对象的数据,因为第二个纹理单元已经激活了,所以第二次绑定纹理对象作用于第二个纹理单元,不会影响到第一个纹理单元。同时因为第二个纹理单元没有手动给它赋值,所以它的值是空的;而第一个纹理单元是默认纹理单元,可以不需要手动激活和赋值。

1
2
3
4
glBindTexture(GL_TEXTURE_2D, TexObject1);

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