【C++ OpenGL入门-6】纹理,让物体更漂亮

148 阅读8分钟

最新大型开源项目-云游戏,云桌面系统,欢迎关注

GammaRay源码地址

本项目代码仓库

点击这里

1.为什么使用纹理

在之前的章节中,我们绘制了一个矩形,给了它特定的颜色,也通过矩阵操作了它平移旋转。但无论是炫酷的3A大作,还是简单的益智游戏,都是很漂亮,很有设计感的。单纯通过给顶点颜色,几乎很难做到,那么我们可以通过给这个矩形贴上一张图片,让他变得华丽起来。比如我的一个场景(可看这里的视频):
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gfyjvkX3-1623148877864)(oscimg.oschina.net/oscnet/up-f…)]
上图中,背景的图片,圆形的图片,小雨滴,甚至81192这几个字也是图片,也就是纹理。它几乎无处不在,极大的丰富我们的画面。

2.纹理采样与纹理环绕

纹理坐标在几何上是连续的,而片段或者像素是离散的,当我们要将坐标映射到纹理上,从纹理上去除颜色值给OpenGL,就是采样的过程。

  • 3.1 采样/纹理过滤 OpenGL中的过滤方式由多种,常用的两种:GL_NEAREST和GL_LINEAR。
    GL_NEAREST(最近邻):

    从图上可以看出,要采样这个坐标的颜色,在GL_NEAREST模式下,会找一个离他最近的一个中心点的颜色,来作为最后的结果。
    GL_LINEAR(线性插值):

    线性插值的情况下,会找周围相邻的几个点的颜色,做一个平均。当然也不是绝对的平均,会根据距离做加权平均。也不一定是选取周围4个,这些值不固定,与算法实现有关系,但道理是相通的。
    两种过滤方式,会产生明显的差别,可以参看如下图示:

    在OpenGL中可以很简单的设置:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
  • 3.2 纹理环绕
    OpenGL规定纹理坐标的范围是[0,1],如果超过了怎么办呢?
    实际中我们见过很多这种情况,铺地板。假如每块地板都是一个纹理,不停的铺同一种地板就是一种纹理环绕方式:重复。参看如下图示:

    OpenGL提供多种环绕方式:
    GL_REPEAT 重复纹理图像。 这是OpenGL的默认选项。
    GL_MIRRORED_REPEAT 也是重复,但是图片被镜像了。
    GL_CLAMP_TO_EDGE 超出的部分全都用边缘的颜色代替。
    GL_CLAMP_TO_BORDER 超出的部分用户自己指定颜色。
    看下图:
    在OpenGL中我们可以这样设置:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);

如果自己设置颜色,则可以通过这样设置:

float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
  • 3.3 mipmap贴图
    思考一个问题,如果一个物体离我们很远,即便它很大,我们也只是看它是一点点的。就像拍照一样,雷峰塔再高,我们与它合影,拍在画面里也仅仅是一些屏幕的像素。那么问题来了,当一个说很高清的物体离我们很远的时候,通常只需要很少的像素就能表示它,但是我们却因为它的分辨率很大,很难产生理想的片段输出。因此发明了一个叫多级渐远纹理(mipmap)的技术。注意:既然叫多级渐远,那么通常就用来表示远处的物体,也就是缩小时用的。放大的情况并不适合它。

mipmap本质上说就是一堆图片,以原始分辨率的图开始,以后每一张是上一张尺寸的一半,看下面的图:

我们按照距离来划分,在不同的距离范围内,从不同的尺寸上采样,这样就不会出现离得非常远,还要从原始分辨率上采样了,直接在一个小尺寸的图像上采样即可。
需要声明的一个问题是,这些图片也不是连续变化的,是以除以2的尺寸递减,那么必然会出现断档的情况,比如要用0.3倍的图像时,怎么办呢?
答案是让不同尺寸下的图片也进行所谓的纹理过滤,也就是OpenGL通过前后两张图帮我们生成一个中间的过渡图,然后再采样。
生成mipmap非常简单,OpenGL已经提供了一个方法,只需要调用一下即可:

glGenerateMipmap(GL_TEXTURE_2D);

可以用如下代码设置缩小时的纹理过滤:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
3.如何使用纹理
  • 2.1 现实中,如何给一个矩形贴一张画呢?
    如果我们的画跟矩形一样大,那么只要画的四个顶点跟矩形的四个顶点都对齐就能保证,画严丝合缝的被贴上。
  • 2.2 OpenGL中如何做呢?
    跟现实中类似,只要我的纹理四个点的坐标跟矩形的四个顶点对应上,就能保证被贴上了。更方便的是,纹理是可以被拉伸缩放的,我们总能把纹理贴在任意大小的矩形上。
    现在看一下纹理的坐标,区间是[0,1]

与颜色一样,纹理坐标也是顶点的一个属性,所以我们把纹理坐标追加到颜色属性的后面,代码如下:

    float vertices[] = {
            0.5f, 0.5f, 0.0f,       1.0, 0.0, 0.0,    1.0f, 1.0f,
            0.5f, -0.5f, 0.0f,      0.0, 1.0, 0.0,    1.0f, 0.0f,
            -0.5f, -0.5f, 0.0f,     0.0, 0.0, 1.0,    0.0f, 0.0f,
            -0.5f, 0.5f, 0.0f,      0.9, 0.6, 0.8,     0.0f, 1.0f
    };

最后的两列就是我们的纹理坐标。内存分布如下图所示:

以一个点为例:(0.5, 0.5) 是顶点的位置坐标,代表矩形右上角的位置,0.5是它在屏幕的坐标系下的位置。对应到纹理上就是 (1,1),因为纹理的右上角要贴在这个矩形的右上角,而不关心矩形到底有多大。参考下图:

  • 2.3 修改我们的顶点着色器:
#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aTex;

uniform mat4 model;

out vec3 outColor;
out vec2 outTex;

void main()
{
    gl_Position = model * vec4(aPos, 1.0);
    outColor = aColor;
    outTex = aTex;
}

在我们的顶点着色器中,添加了一个新的属性纹理坐标 layout (location = 2) in vec2 aTex;, 并用相同的类型将这个属性的值传递给片段着色器 outTex = aTex;
此时还要在C++代码中,告诉OpenGL如何用刚才添加的数据,这与之前颜色的做法相同
注意此时stride要修改为8。

    glEnableVertexAttribArray(2);
    glVertexAttribPointer(2, 2, GL_FLOAT, false, stride, (void*)(6*sizeof(float)));

紧接着在片段着色器中

#version 330 core

in vec3 outColor;
in vec2 outTex;

uniform sampler2D image;

void main()
{
    vec4 color = texture(image, outTex);
    gl_FragColor = color;
}

我们接收到了顶点坐标,并声明了一个uniform的2D的纹理对象。因为纹理对于所有的片段都是一致的,所以是uniform变量。然后用texture函数从纹理上在outTex的位置进行采样,得到最终的颜色值并输出给下一个环节,最终通过各种测试后,显示到屏幕上。

4.加载纹理并使用

万事俱备,只差把图片数据传给OpenGL使用了。

  • 4.1 首先,我们需要将图片加载到内存中来
    因为图片大都是压缩格式保存到硬盘的,jpg,png等格式都是压缩的,需要把他们解压缩,变成RGB或者RGBA或者BGR等排列方式的原始数据,我们这里使用一个库(stb image loader)来做,它已经做了大量格式的兼容,并且使用简单:
    int width, height, channels;
    unsigned char* data = stbi_load("../resources/images/person.jpg", &width, &height, &channels, 0 );
  • 4.2 像OpenGL申请一块内存,并把数据传过去,完整使用代码如下:
// Gen Texture
    GLuint texture;
    glGenTextures(1, &texture);
    glBindTexture(GL_TEXTURE_2D, texture);
    glTextureParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTextureParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    glTextureParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTextureParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

    // Load Image
    int width, height, channels;
    unsigned char* data = stbi_load("../resources/images/person.jpg", &width, &height, &channels, 0 );
    if (data) {
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
        glGenerateMipmap(GL_TEXTURE_2D);

    }
    stbi_image_free(data);
    glBindTexture(GL_TEXTURE_2D, 0);

最后记得要释放内存,因为已经传递给OpenGL了,显存上已经存在,那么主内存的数据就不需要了。

  • 4.3 直接运行绘制

    从运行效果看,我们的确将图片显示了出来,但却是上下翻转的。这是因为OpenGL的Y轴 0 从底部开始,而图像的 Y轴 0 基本从 上面开始。这也很常见,比如Android的屏幕坐标就是左上角开始的,向下Y轴增加。
    直接使用stb_image.h这个头文件里的一个方法翻转图片就可以了:
stbi_set_flip_vertically_on_load(true);

以上是使用单张图片的全部内容,如何使用多张图片,我们下一章再看。