公众号: C++学习与探索 | 个人主页: rainInSunny | 个人专栏: Learn OpenGL In Qt
文章目录
- 纹理
- 纹理坐标
- 纹理环绕方式
- 纹理采样
- 多级渐远纹理
- 纹理加载和创建
- 加载纹理
- 创建纹理
- 应用纹理
纹理
纹理坐标
在前面的教程中,我们给三角形的每个顶点传递一个颜色值,画出了一个类似调色盘的三角形。如果我们想要画出更加逼真的图像,就需要传递更多的顶点和更多的颜色值。如果绘制一个特定的三角形都需要成千上万个点,那实在是太麻烦了。在OpenGL中通常通过纹理来实现这样的绘制效果。
纹理通常是一张 2D 图片(也可能是 1D 或者 3D),它可以用来添加物体的细节;你可以想象纹理是一张绘有砖块的纸,无缝折叠贴合到你的 3D 的房子上,这样你的房子看起来就像有砖墙外表了。因为我们可以在一张图片上插入非常多的细节,这样就可以让物体非常精细而不用指定额外的顶点。
为了能够把纹理映射(Map)到三角形上,我们需要指定三角形的每个顶点各自对应纹理的哪个部分。这样每个顶点就会关联着一个纹理坐标(Texture Coordinate),用来标明该从纹理图像的哪个部分采样。之后在图形的其它片段上进行片段插值(Fragment Interpolation)。纹理坐标在 x 和 y 轴上,范围为 0 到 1 之间(注意我们使用的是 2D 纹理图像)。使用纹理坐标获取纹理颜色叫做采样(Sampling)。纹理坐标起始于(0, 0),也就是纹理图片的左下角,终止于(1, 1),即纹理图片的右上角。下面的图片展示了我们是如何把纹理坐标映射到三角形上的。
三角形有三个顶点坐标,我们只需要依次指定三个纹理坐标像下面这样:
float texCoords[] = {0.0f, 0.0f, // 左下角1.0f, 0.0f, // 右下角0.5f, 1.0f // 上中
};
纹理环绕方式
纹理坐标通常范围是(0, 1),如果设置数值超出了这个范围,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 是等价的):
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),这里环绕方式为
GL_MIRRORED_REPEAT
。 - 如果选择
GL_CLAMP_TO_BORDER
选项,我们还需要指定一个边缘的颜色。这需要使用glTexParameter
函数的fv
后缀形式,用GL_TEXTURE_BORDER_COLOR
作为它的选项,并且传递一个float
数组作为边缘的颜色:
float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
纹理采样
纹理坐标不依赖分辨率,如果实际绘制到屏幕上的区域很大,但是提供的纹理图片分辨率不够高,就需要通过插值的方式来填充缺失的像素值。OpenGL 提供了很多中采样的方式来进行插值,这里只讨论 GL_NEAREST
和 GL_LINEAR
。
GL_NEAREST
是 OpenGL 默认的纹理插值方式。当设置为 GL_NEAREST 的时候,OpenGL 会选择中心点最接近纹理坐标的那个像素。下图中你可以看到四个像素,加号代表纹理坐标。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色:
GL_LINEAR
它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中你可以看到返回的颜色是邻近像素的混合色:
两种方式的效果如下:
当进行放大(Magnify)和缩小(Minify)操作的时候可以设置纹理插值的选项,比如你可以在纹理被缩小的时候使用邻近插值,被放大时使用线性插值。我们需要使用 glTexParameter*
函数为放大和缩小指定插值方式。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
多级渐远纹理
当实际屏幕绘制的分辨率太小,和纹理分辨率相差太多的时候,无论设置那种插值方式可能都无法得到较好的效果。OpenGL 使用一种叫做多级渐远纹理(Mipmap)的概念来解决这个问题,它简单来说就是一系列的纹理图像,后一个纹理图像是前一个的二分之一。多级渐远纹理背后的理念很简单:距观察者的距离超过一定的阈值,OpenGL 会使用不同的多级渐远纹理,即最适合物体的距离的那个。由于距离远,解析度不高也不会被用户注意到。同时,多级渐远纹理另一加分之处是它的性能非常好。让我们看一下多级渐远纹理是什么样子的:
OpenGL 提供 glGenerateMipmap 帮我们创建多集渐远纹理。在渲染中切换多级渐远纹理级别(Level)时,OpenGL 在两个不同级别的多级渐远纹理层之间会产生不真实的生硬边界。就像普通的纹理过滤一样,切换多级渐远纹理级别时你也可以在两个不同多级渐远纹理级别之间使用 NEAREST 和 LINEAR 插值。为了指定不同多级渐远纹理级别之间的插值方式,你可以使用下面四个选项中的一个代替原有的方式:
插值方式 | 描述 |
---|---|
GL_NEAREST_MIPMAP_NEAREST | 使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近采样进行纹理插值 |
GL_LINEAR_MIPMAP_NEAREST | 使用最邻近的多级渐远纹理级别,并使用线性采样进行插值 |
GL_NEAREST_MIPMAP_LINEAR | 在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近采样进行插值 |
GL_LINEAR_MIPMAP_LINEAR | 在两个邻近的多级渐远纹理之间使用线性插值,并使用线性采样进行插值 |
同样使用 glTexParameteri
设置这些采样方式:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
一个常见的错误是,将放大采样的选项设置为多级渐远纹理采样选项之一。这样没有任何效果,因为多级渐远纹理主要是使用在纹理被缩小的情况下的:纹理放大不会使用多级渐远纹理,为放大过滤设置多级渐远纹理的选项会产生一个 GL_INVALID_ENUM
错误代码。
纹理加载和创建
加载纹理
首先我们需要将纹理加载进来,由于是基于 Qt 编写代码,加载图片直接使用 QImage 就可以。
// Load image
QImage image;
image.load(fileName);
image = QGLWidget::convertToGLFormat(image);
[static] QImage QGLWidget::convertToGLFormat(const QImage &img)
Converts the image img into the unnamed format expected by OpenGL functions such as glTexImage2D(). The returned image is not usable as a QImage, but QImage::width(), QImage::height() and QImage::bits() may be used with OpenGL. The GL format used is GL_RGBA.
需要注意的是 QGLWidget::convertToGLFormat
转换出来的图片数据都是 GL_RGBA
格式。
创建纹理
和之前的 vAO、VBO一样,OpenGL 通过 ID 来标识一个纹理对象。
unsigned int texture;
glGenTextures(1, &texture);
glGenTextures
函数首先需要输入生成纹理的数量,然后把它们储存在第二个参数的 unsigned int 数组中(我们的例子中只是单独的一个 unsigned int),就像其他对象一样,我们需要绑定它,让之后任何的纹理指令都可以配置当前绑定的纹理:
glBindTexture(GL_TEXTURE_2D, texture);
现在纹理已经绑定了,我们可以使用前面载入的图片数据生成一个纹理了。纹理可以通过 glTexImage2D
来生成:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
函数参数解释如下:
- 第一个参数指定了纹理目标(Target)。设置为
GL_TEXTURE_2D
意味着会生成与当前绑定的纹理对象在同一个目标上的纹理(任何绑定到GL_TEXTURE_1D
和GL_TEXTURE_3D
的纹理不会受到影响)。 - 第二个参数为纹理指定多级渐远纹理的级别,如果你希望单独手动设置每个多级渐远纹理的级别的话。这里我们填 0,也就是基本级别。
- 第三个参数告诉 OpenGL 我们希望把纹理储存为何种格式。上面提到 Qt 转换出来的格式都是
GL_RGBA
。 - 第四个和第五个参数设置最终的纹理的宽度和高度。我们之前加载图像的时候储存了它们,所以我们使用对应的变量。
- 下个参数应该总是被设为 0(历史遗留的问题)。
- 第七第八个参数定义了源图的格式和数据类型。我们使用 RGBA 值加载这个图像,并把它们储存为 char(byte)数组,我们将会传入对应值。
- 最后一个参数是真正的图像数据。
直接在生成纹理之后调用 glGenerateMipmap
。这会为当前绑定的纹理自动生成所有需要的多级渐远纹理。综上,生成一个纹理的过程大概是这样:
unsigned int loadTexture(const QString& fileName)
{unsigned int texture = 0;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);// 加载并生成纹理QImage image;image.load(fileName);image = QGLWidget::convertToGLFormat(image);unsigned char *data = image.bits();if (data){glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image.width(), image.height(), 0, GL_RGBA, GL_UNSIGNED_BYTE, data);glGenerateMipmap(GL_TEXTURE_2D);}else{std::cout << "Failed to load texture" << std::endl;}return texture;
}
当然我们也可以用 Qt 提供的 QOpenGLTexture
来实现纹理的加载:
unsigned int loadTexture(const QString& fileName)
{QImage img(fileName);if (img.isNull()){qWarning() << "Could not find the image";Q_ASSERT(false);}QOpenGLTexture *texture = new QOpenGLTexture(img);texture->setMinificationFilter(QOpenGLTexture::Linear);texture->setMagnificationFilter(QOpenGLTexture::Linear);texture->setAutoMipMapGenerationEnabled(true);return m_texture->textureId();
}
应用纹理
我们会使用 glDrawElements
绘制「你好,三角形」教程最后一部分的矩形。我们需要告知 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我们新的顶点格式:
我们同样需要调整前面两个顶点属性的步长参数为 8 * sizeof(float)
。
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);
接着我们需要调整顶点着色器使其能够接受顶点坐标为一个顶点属性,并把坐标传给片段着色器:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aTexCoord;out vec3 ourColor;
out vec2 TexCoord;void main()
{gl_Position = vec4(aPos, 1.0);ourColor = aColor;TexCoord = aTexCoord;
}
片段着色器应该接下来会把输出变量 TexCoord 作为输入变量。片段着色器也应该能访问纹理对象,但是我们怎样能把纹理对象传给片段着色器呢?GLSL 有一个供纹理对象使用的内建数据类型,叫做采样器(Sampler),它以纹理类型作为后缀,比如 sampler1D、sampler3D,或在我们的例子中的 sampler2D。我们可以简单声明一个 uniform sampler2D 把一个纹理添加到片段着色器中,稍后我们会把纹理赋值给这个 uniform。
#version 330 core
out vec4 FragColor;in vec3 ourColor;
in vec2 TexCoord;uniform sampler2D ourTexture;void main()
{FragColor = texture(ourTexture, TexCoord);
}
我们使用 GLSL 内建的 texture 函数来采样纹理的颜色,它第一个参数是纹理采样器,第二个参数是对应的纹理坐标。texture 函数会使用之前设置的纹理参数对相应的颜色值进行采样。这个片段着色器的输出就是纹理的(插值)纹理坐标上的(过滤后的)颜色。接着使用 glUniform1i,我们可以给纹理采样器分配一个位置值,这样的话我们能够在一个片段着色器中设置多个纹理。一个纹理的位置值通常称为一个纹理单元(Texture Unit)。一个纹理的默认纹理单元是 0,它是默认的激活纹理单元。
glUniform1i(glGetUniformLocation(ourShader.ID, "ourTexture"), 0);
glActiveTexture(GL_TEXTURE0); // 在绑定纹理之前先激活纹理单元
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
OpenGL 至少保证有 16 个纹理单元供你使用,也就是说你可以激活从
GL_TEXTURE0
到GL_TEXTRUE15
。它们都是按顺序定义的,所以我们也可以通过GL_TEXTURE0 + 8
的方式获得GL_TEXTURE8
,这在当我们需要循环一些纹理单元的时候会很有用。
如果没有激活纹理单元,那么默认纹理单元 0 将被默认激活。另外片段着色器中的 uniform sampler2D ourTexture 是设置 Uniform 传递的纹理单元,然后我们在程序中将传递的纹理单元绑定上我们需要的纹理,片段着色器就能从这个纹理中采样了
下面通过一个例子来看看纹理的应用。首先实现着色器:
#version 330 core......uniform sampler2D texture0;
uniform sampler2D texture1;void main()
{// 按比例混合 texture0 和 texture1FragColor = mix(texture(texture0, TexCoord), texture(texture1, TexCoord), 0.2);
}
然后加载并创建两个纹理:
unsigned int texture0 = loadTexture("container.jpg");
unsigned int texture1 = loadTexture("awesomeface.png");
其次定义哪个 uniform 采样器对应哪个纹理单元:
......ShaderProgram *pShaderProgram = new ShaderProgram(pContext);
pShaderProgram->compileSourceCode(vertexShaderSource, fragmentShaderSource);
pShaderProgram->use(); // 不要忘记在设置uniform变量之前激活着色器程序!
pShaderProgram->setInteger("texture0", 0);
pShaderProgram->setInteger("texture1", 1);......
再绑定两个纹理到对应的纹理单元后绘制:
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture0);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture1);glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
如果一切都顺利,会得到下面的效果:
发现笑脸是倒过来的,这是因为 OpenGL 要求 y 轴 0.0 坐标是在图片的底部的,但是图片的 y 轴 0.0 坐标通常在顶部。我们直接在加载图片的时候将图片上下翻转即可,image = image.mirrored(false, true);
。最后得到下面的结果:
这样我们就利用 OpenGL 绘制出了想要的纹理。
关注公众号:C++学习与探索,有惊喜哦~