OpenGL学习 (三)

OpenGL学习 (三)

本节主要涉及纹理方面的知识。

纹理坐标

纹理坐标在x和y轴上,范围为0到1之间(注意我们使用的是2D纹理图像)。使用纹理坐标获取纹理颜色叫做采样(Sampling)。纹理坐标起始于(0, 0),也就是纹理图片的左下角,终始于(1, 1),即纹理图片的右上角。上面的图片展示了我们是如何把纹理坐标映射到三角形上的。

纹理环绕方式

环绕方式 描述
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是等价的):
1
2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
第一个参数指定了纹理目标;我们使用的是2D纹理,因此纹理目标是GL_TEXTURE_2D。第二个参数需要我们指定设置的选项与应用的纹理轴。我们打算配置的是WRAP选项,并且指定S和T轴。最后一个参数需要我们传递一个环绕方式(Wrapping),在这个例子中OpenGL会给当前激活的纹理设定纹理环绕方式为GL_MIRRORED_REPEAT。

纹理过滤

纹理贴到三维图形上时,纹理与三维图形尺寸可能会不一致。一个像素一般不会正好对应于一个纹素(texel),所以像素的颜色无法直接得到 (可能一个像素会对应多个纹素此时看起来会模糊,也可能一个纹素对应多个像素,这样看起来采样不够会有锯齿),需要经过一定的运算,这个过程就是纹理过滤。


OpenGL中纹理过滤的选项

GL_NEAREST(也叫邻近过滤,Nearest Neighbor Filtering)是OpenGL默认的纹理过滤方式。当设置为GL_NEAREST的时候,OpenGL会选择中心点最接近纹理坐标的那个像素。下图中你可以看到四个像素,加号代表纹理坐标。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色:

GL_LINEAR(也叫线性过滤,(Bi)linear Filtering)它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中你可以看到返回的颜色是邻近像素的混合色:
那这两者有什么区别呢?
GL_NEAREST产生了颗粒状的图案,我们能够清晰看到组成纹理的像素,而GL_NEAREST能够产生更平滑的图案,很难看出单个的纹理像素。GL_NEAREST可以产生更真实的输出,但有些开发者更喜欢8-bit风格,所以他们会用GL_NEAREST选项。

当进行放大(Magnify)和缩小(Minify)操作的时候可以设置纹理过滤的选项,比如你可以在纹理被缩小的时候使用邻近过滤,被放大时使用线性过滤。我们需要使用glTexParameter*函数为放大和缩小指定过滤方式。这段代码看起来会和纹理环绕方式的设置很相似:

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

多级渐远纹理

多级渐远纹理(Mipmap),它简单来说就是一系列的纹理图像,后一个纹理图像是前一个的二分之一。多级渐远纹理背后的理念很简单:距观察者的距离超过一定的阈值,OpenGL会使用不同的多级渐远纹理,即最适合物体的距离的那个。由于距离远,解析度不高也不会被用户注意到。同时,多级渐远纹理另一加分之处是它的性能非常好,它消耗的存储量仅仅比原图多\(\frac{1}{3}\)。

如上图所示Mipmap会创建logn张贴图,分别在摄像机处于不同远近位置的时候播放相应分辨率的贴图。

手工为每个纹理图像创建一系列多级渐远纹理很麻烦,OpenGL有一个glGenerateMipmaps函数,在创建完一个纹理后调用它OpenGL就会承担接下来的所有工作了。

在渲染中切换多级渐远纹理级别(Level)时,OpenGL在两个不同级别的多级渐远纹理层之间会产生不真实的生硬边界。就像普通的纹理过滤一样,切换多级渐远纹理级别时你也可以在两个不同多级渐远纹理级别之间使用NEAREST和LINEAR过滤。为了指定不同多级渐远纹理级别之间的过滤方式,你可以使用下面四个选项中的一个代替原有的过滤方式:

过滤方式 描述
GL_NEAREST_MIPMAP_NEAREST 使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近插值进行纹理采样。
GL_LINEAR_MIPMAP_NEAREST 使用最邻近的多级渐远纹理级别,并使用线性插值进行采样
GL_NEAREST_MIPMAP_LINEAR 在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近插值进行采样
GL_LINEAR_MIPMAP_LINEAR 在两个邻近的多级渐远纹理之间使用线性插值,并使用线性插值进行采样。

就像纹理过滤一样,我们可以使用glTexParameteri将过滤方式设置为前面四种提到的方法之一:

1
2
3
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
//Error!多级渐远纹理主要是使用在纹理被缩小的情况下的:纹理放大不会使用多级渐远纹理,为放大过滤设置多级渐远纹理的选项会产生一个GL_INVALID_ENUM错误代码。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

生成纹理

1
2
3
//和之前生成的OpenGL对象一样,纹理也是使用ID引用的
unsigned int texture;
glGenTextures(1, &texture);

glGenTextures函数首先需要输入生成纹理的数量,然后把它们储存在第二个参数的unsigned int数组中(我们的例子中只是单独的一个unsigned int),就像其他对象一样,我们需要绑定它,让之后任何的纹理指令都可以配置当前绑定的纹理:

1
2
3
4
5
glBindTexture(GL_TEXTURE_2D, texture);
//往纹理中灌入数据
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
//生成Mipmap
glGenerateMipmap(GL_TEXTURE_2D);

glTexImage2D参数详解:

  • 第一个参数指定了纹理目标(Target)。设置为GL_TEXTURE_2D意味着会生成与当前绑定的纹理对象在同一个目标上的纹理(任何绑定到GL_TEXTURE_1D和GL_TEXTURE_3D的纹理不会受到影响,就是要和上面glBindTexture的第一个参数相同才会奏效)。
  • 第二个参数为纹理指定多级渐远纹理的级别,如果你希望单独手动设置每个多级渐远纹理的级别的话。这里我们填0,也就是基本级别。
  • 第三个参数告诉OpenGL我们希望把纹理储存为何种格式。我们的图像只有RGB值,因此我们也把纹理储存为RGB值
  • 第四个和第五个参数设置最终的纹理的宽度高度。我们之前加载图像的时候储存了它们,所以我们使用对应的变量。
  • 下个参数应该总是被设为0(历史遗留的问题)
  • 第七第八个参数定义了源图的格式和数据类型。我们使用RGB值加载这个图像,并把它们储存为char(byte)数组,我们将会传入对应值。
  • 最后一个参数是真正的图像数据

当调用glTexImage2D时,当前绑定的纹理对象就会被附加上纹理图像。然而,目前只有基本级别(Base-level)的纹理图像被加载了,如果要使用多级渐远纹理,我们必须手动设置所有不同的图像(不断递增第二个参数)。或者,直接在生成纹理之后调用glGenerateMipmap。这会为当前绑定的纹理自动生成所有需要的多级渐远纹理。

生成一个纹理的过程应该看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
unsigned int texture;
glGenTextures(1, &texture);
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);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, 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)
{
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);

应用纹理

我们添加了一个额外的顶点属性,我们必须告诉OpenGL我们新的顶点格式:
1
2
3
4
//告诉OpenGL如何解析纹理数据,就和之前我们告知如何解析VBO的数据一样!
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
//启用顶点属性
glEnableVertexAttribArray(2);

绑定纹理,它会自动把纹理赋值给片段着色器的采样器:

1
2
3
4
5
6
7
//每次使用不同的模型的时候会使用不同的贴图,因此在使用贴图之前你必须要先
//绑定一张贴图
glBindTexture(GL_TEXTURE_2D, texture);
//绑定VAO
glBindVertexArray(VAO);
//draw
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

GLSL中使用纹理数据

1
2
3
4
5
6
7
8
9
10
11
12
#version 330 core
out vec4 FragColor;

in vec3 ourColor;
in vec2 TexCoord;

uniform sampler2D ourTexture;

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

GLSL有一个供纹理对象使用的内建数据类型,叫做采样器(Sampler),它以纹理类型作为后缀,比如sampler1D、sampler3D,或在我们的例子中的sampler2D。

我们使用GLSL内建的texture函数来采样纹理的颜色,它第一个参数是纹理采样器,第二个参数是对应的纹理坐标。texture函数会使用之前设置的纹理参数对相应的颜色值进行采样。这个片段着色器的输出就是纹理的(插值)纹理坐标上的(过滤后的)颜色。

为什么上面的着色器代码中sampler2D变量是个uniform,我们却不用glUniform给它赋值?
一个纹理的位置值通常称为一个纹理单元(Texture Unit)。一个纹理的默认纹理单元是0,它是默认的激活纹理单元,所以如果只打算用这一张贴图我们不需要手动uniform。如果你想使用多张贴图,使用glUniform1i,我们可以给纹理采样器分配一个位置值,这样的话我们能够在一个片段着色器中设置多个纹理。

通过把纹理单元赋值给采样器,我们可以一次绑定多个纹理,只要我们首先激活对应的纹理单元。就像glBindTexture一样,我们可以使用glActiveTexture激活纹理单元,传入我们需要使用的纹理单元:

1
2
glActiveTexture(GL_TEXTURE0); // 在绑定纹理之前先激活纹理单元
glBindTexture(GL_TEXTURE_2D, texture);

OpenGL至少保证有16个纹理单元供你使用,也就是说你可以激活从GL_TEXTURE0到GL_TEXTRUE15。它们都是按顺序定义的,所以我们也可以通过GL_TEXTURE0 + 8的方式获得GL_TEXTURE8,这在当我们需要循环一些纹理单元的时候会很有用。

使用多个纹理单元流程

使用多个纹理单元(这里以两个单元为例)的写法,我们必须改变一点渲染流程,先绑定两个纹理到对应的纹理单元,然后定义哪个uniform采样器对应哪个纹理单元:

1)shader代码中增加一个sampler变量

fragment shader
1
2
3
4
5
6
7
8
9
10
11
12
13
#version 330 core
...

uniform sampler2D texture1;
uniform sampler2D texture2;

void main()
{
//mix函数需要接受两个值作为参数,并对它们根据第三个参数进行线性插值。
//如果第三个值是0.0,它会返回第一个输入;
//如果是1.0,会返回第二个输入值。0.2会返回80%的第一个输入颜色和20%的第二个输入颜色,即返回两个纹理的混合色。
FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);
}

2)生成纹理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*生成第一张纹理*/
unsigned int texture;
glGenTextures(1, &texture);
//由于这里要绑定多张图,所以在此之前要先激活告知OpenGL要把纹理存储到哪一个纹理单元中
glActiveTexture(GL_TEXTURE0);
//bind到上面激活的纹理单元中
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);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, 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)
{
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);

/*生成第二张纹理。。方法和上面类似,此时需要注意要激活另一个槽位的贴图,比如glActiveTexture(GL_TEXTURE1);*/
//...

3)通过使用glUniform1i设置每个采样器的方式告诉OpenGL每个采样器属于哪个纹理单元。我们只需要设置一次即可,所以这个会放在渲染循环的前面

1
2
3
4
5
6
7
8
9
10
myShader->use(); // 别忘记在激活着色器前先设置uniform!
//glGetUniformLocation(你要传递数据的shader的ID,sampler在shader中的名字)
//你要把贴图放在哪个sampler的槽位中
glUniform1i(glGetUniformLocation(myShader->ID, "texture1"), 0);
glUniform1i(glGetUniformLocation(myShader->ID, "texture2"), 1);

while(...)
{
[...]
}

4)应用纹理,绘制

游戏循环
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
while(...)
{

...

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);

 //激活shader
 myshader->use();
//在激活shader之后可以给shader里面传入数据,只不过这里没有这样做
//glUniform1i(glGetUniformLocation(myShader->ID, "texture1"), 0);
//glUniform1i(glGetUniformLocation(myShader->ID, "texture2"), 1);

glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

...
}

纹理总结

OpenGL状态机
对于传入GPU的纹理也和之前的VAO EBO一样有个传入的接口,并且当你要使用或者给纹理传数据的时候也需要有创建绑定等操作。

到这里我不妨大胆假设,由于OpenGL这种状态机的性质,在往后的一些内容中,凡是涉及到bind的,在这个状态机上应该都有相应的接口,因此可以继续想象在这张图上添加新的内容。


参考资料
LearnOpenGL CN

评论