openGL ES教程(四):纹理

1. 前言

理解纹理之前,需要理解两个概念:

  1. Fragment;
  2. Fragment 的片段插值;

官方描述如下:

Fragment
片段插值

总结:

  1. 一个 Fragment 对应一个像素,只不过 Fragment 是一个数据模型,其中的数据提供给 Fragment Shader 最终渲染出这个 Pixel 的 RGBA;
  2. 片段插值对每个属性都其作用,不仅仅是颜色。这个功能可以帮助开发者节省大量的工作,比如减少输入的顶点数量、减少输入的颜色等;
  3. 纹理坐标的个数最开始只和顶点数量一致,但是片段插值之后,每个 Fragment 都对应着有一个纹理坐标,从而可以在纹理中取出对应的像素色值(后文会讲);

2. 纹理的概念

我们已经了解到,我们可以为每个顶点添加颜色来增加图形的细节,从而创建出有趣的图像。但是,如果想让图形看起来更真实,我们就必须有足够多的顶点,从而指定足够多的颜色。虽然片段着色器会根据顶点和顶点输入的属性进行片段插值的操作,但是仍然满足不了很多场景。

三个顶点进行片段插值之后的效果

此时,纹理的概念就出现了。

纹理是一个2D图片(甚至也有1D和3D的纹理),它可以用来添加物体的细节,纹理类似于一张贴纸一样,可以直接贴在图元上。因为我们可以在一张图片上插入非常多的细节,这样就可以让物体非常精细而不用指定额外的顶点。

纹理贴图

除了图像以外,纹理也可以被用来储存大量的数据,这些数据可以发送到着色器上,但是这不是我们现在的主题。

3. 纹理坐标

纹理坐标是针对纹理而言的,对于 2D 纹理而言,纹理坐标在 x 和 y 轴上,范围为 0 到 1 之间,纹理坐标起始于(0, 0),也就是纹理图片的左下角,终于(1, 1),即纹理图片的右上角。其坐标系如下:

纹理坐标

为了能够把纹理映射(Map)到三角形上(顶点/图元),我们需要指定三角形的每个顶点各自对应纹理的哪个部分。这样每个顶点就会关联着一个纹理坐标(Texture Coordinate),用来标明该从纹理图像的哪个部分采集片段颜色(采样,后文会将)。

三个顶点对应的纹理坐标确定之后,因为经过光栅化阶段之后会产生很多片段(Fragment)供片段着色器来渲染像素点最终的颜色。此时,片段着色器会根据三个顶点关联的纹理坐标进行采样。同时,还会根据三个顶点关联的纹理坐标来对每个 Fragment 的纹理坐标属性进行片段插值(Fragment Interpolation)。也就是为每个 Fragment 都计算并关联一个纹理坐标,再根据这个纹理坐标去纹理中根据一定的规则获取颜色(采样),最终计算出这个 Fragment 对应的 pixel 的 RGBA;

这个步骤的理解很重要,关乎到能否理解后面的线性采样和邻近采样为什么会产生不同的效果。

4. 环绕方式

环绕方式和我们平常接触到的平铺方式关联性很大。

纹理坐标的范围通常是从(0, 0)到(1, 1),那如果我们把纹理坐标设置在范围之外会发生什么?OpenGL 默认的行为是重复这个纹理图像(,但OpenGL提供了更多的选择:

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

不同行为的效果如下:

环绕方式

这是方式如下:

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
  • 第二个参数:纹理轴
  • 第三个参数:该轴上的环绕方式

如果我们选择 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);

5. 纹理过滤

纹理坐标相当于台球桌上框球的三脚架。当纹理坐标确定之后,这张图片上哪些像素点需要被映射到 OpenGL(或者说Buffer)上就确定了;

此时考虑,从选取像素数量的多少大概可以分为三种情况:

  1. 顶点坐标选取了很多像素点,也就是图片缩放比例很小:
  1. 顶点坐标选取了较多像素点,图片被稍微放大,展现部分更偏向细节:
  1. 顶点坐标选取了很少的像素点,图片被放的很大,展现部分更加粒子化:

针对第三种情况,如果直接将范围内的像素点进行映射,那么效果就和实际效果一样,粒子化(锯齿化)很严重。

openGL 提供了两种纹理过滤方案:

  1. GL_NEAREST:邻近过滤

邻近过滤是 openGL 默认的过滤方式,也就是会展示实际效果,其原理如下:

邻近过滤
  1. GL_LINEAR 线性过滤

它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。

线性过滤

在分辨率较低时,两种过滤方式的差别如下:

两种过滤方式对比

上图可以看出,线性过滤的锯齿化很轻,邻近过滤的像素画很严重。

一开始会无法理解为什么线性过滤时,内部像素点的锯齿化也很低。按理来讲,只有 4 个顶点,那么最多也就是影响 4 个顶点的锯齿化程度。这也是为什么文章一开始着重理解片段插值的原因。

虽然一开始只传入三个或者四个顶点,也就只关联了和顶点数量一致的纹理坐标。但是光栅化之后的片段着色器阶段,会为每个 Fragment 的纹理坐标进行片段插值,此时:

Fragment.count == Pixel.count == TextureCoordinate.counts

所以,内部的像素点也会有一个纹理坐标作用在纹理过滤这个过程中,最终导致所有像素点都会被处理。

6. 多级渐远纹理

多级渐远纹理用于处理深度不一时的纹理贴图的性能问题。

想象一下,假设我们有一个包含着上千物体的大房间,每个物体上都有纹理。有些物体会很远,由于远处的物体可能只产生很少的片段,OpenGL 从高分辨率纹理中为这些片段获取正确的颜色值就很困难,因为它需要对一个跨过纹理很大部分的片段只拾取一个纹理颜色。

来看一个例子:

上图中,假设两个矩形的纹理坐标都是 (0,1)、(1,1)、(1,0)、(0,0),也就是全量取纹理中的像素。

因为两个矩形深度不一样,那么光栅化之后产生的 Fragment 数量也不一样。假设 Fragment 的数量一个是 100,一个是 10 ,纹理的像素点是 1000 个。较小的矩形如果也是全量取纹理中的数据,根据线性过滤的计算方式, 1 个点就需要取周围大概 100 个像素点来计算该点的色值进行。而较大的矩形是 100 个,大概只需要取周围 10 个像素点来计算最终的色值。

上述的计算过程很不严谨,单纯的只是为了理解多级渐远纹理的概念而 YY 出来的;

虽然总计算量可能是一样的,但是因为远处的物体所占像素点很少,即使图像质量很细腻,人眼也无法区分。而近处的物体“很大”,人眼能够区分,所以这个计算的消耗是值得的。

因此,推出了多级渐远纹理的概念,它简单来说就是一系列的纹理图像,后一个纹理图像是前一个的二分之一。

多级渐远纹理背后的理念很简单:距观察者的距离超过一定的阈值,OpenGL会使用不同的多级渐远纹理,即最适合物体的距离的那个。

大概原理如下:

多级渐远纹理

多级渐远纹理的类型有:

  1. GL_NEAREST_MIPMAP_NEAREST:使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近插值进行纹理采样
  2. GL_LINEAR_MIPMAP_NEAREST:使用最邻近的多级渐远纹理级别,并使用线性插值进行采样
  3. GL_NEAREST_MIPMAP_LINEAR:在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近插值进行采样
    GL_LINEAR_MIPMAP_LINEAR:在两个邻近的多级渐远纹理之间使用线性插值,并使用线性插值进行采样

其计算过程和距离选择不需要开发者操太多心,只需要在创建完一个纹理后调用 glGenerateMipmaps,OpenGL 就会承担接下来的所有工作了。后面的教程中你会看到该如何使用它。

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

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

另外,多级渐远纹理的枚举其实和上文提到的两种过滤方式是同一个类型的枚举:

枚举

7. 纹理的创建和配置

上面讲述了纹理相关的基本概念,现在需要使用纹理来绘图了,需要做这几件事:

  1. 纹理的创建

这一步和之前的 VBO、VAO 等创建过程类似,很简单:

// 创建纹理
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
  1. 初始化纹理配置

纹理创建完成之后就需要对上面讲述的概念进行配置:

/** 环绕方式
 * 第一个参数:纹理目标。我们使用2D纹理,所以是GL_TEXTURE_2D
 * 第二个参数:纹理轴
 * 第三个参数:该轴上的环绕方式
 */
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
    
// 纹理过滤
// 多级渐远纹理
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

  1. 纹理的映射

见后文

  1. 绘图

见后文

8. glTexImage2D 方法解析

纹理的映射就是需要把纹理的来源数据(如图片)化到纹理对象上,一般而言是把一张图片映射到纹理上。

映射纹理之前需要调用 glTexImage2D 来设置纹理的处理方式、数据源等信息。其入参如下:

  • 第一个参数:指定了纹理目标(Target)。

设置为 GL_TEXTURE_2D 意味着会生成与当前绑定的纹理对象在同一个目标上的纹理(任何绑定到 GL_TEXTURE_1D和GL_TEXTURE_3D 的纹理不会受到影响)。

  • 第二个参数:为纹理指定多级渐远纹理的级别

如果你希望单独手动设置每个多级渐远纹理的级别的话。这里我们填0,也就是基本级别。

  • 第三个参数:告诉OpenGL我们希望把纹理储存为何种格式

我们的图像只有RGB值,因此我们也把纹理储存为RGB值。

  • 第四个、五个参数:设置最终的纹理的宽度和高度

我们之前加载图像的时候储存了它们,所以我们使用对应的变量。
下个参数应该总是被设为0(历史遗留的问题)。

  • 第七、八个参数:定义了源图的格式和数据类型。

我们使用RGB值加载这个图像,并把它们储存为char(byte)数组,我们将会传入对应值。

  • 第九个参数:真正的图像数据

这里需要一个指针,指向图片被解压到内存之后的存储位置;

调用示例:

// 设置纹理数据
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, cwidth, cheight, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
// 映射纹理
glGenerateMipmap(GL_TEXTURE_2D);

9. 使用 stb_image 库加载图片

映射纹理,首先需要创建并解码图片,官网教程中使用的是 stb_image 这个三方库,其使用方法也比较简单,下载源码之后做如下配置即可:

#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

源码算上注释有将近 8000 行,代码就不赘述了:

stb_image

而上面的配置就是根据当前的平台省略一些代码,编译时就不会有那么多源码了。使用 stb_image 映射图元的代码如下:

NSString *path = [[NSBundle mainBundle] pathForResource:@"container" ofType:@"jpeg"];
char* cPath = (char*) [path cStringUsingEncoding:NSUTF8StringEncoding];
int cwidth, cheight, cnrChannels;
unsigned char *cdata = stbi_load(cPath, &cwidth, &cheight, &cnrChannels, 0);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, cwidth, cheight, 0, GL_RGB, GL_UNSIGNED_BYTE, cdata);
glGenerateMipmap(GL_TEXTURE_2D);

stb_image 中的 stbi_load 本质上是从这个 path 上来解析图片。这属于图形学中图片格式、图片解压缩等相关领域的知识。

大体过一下相关概念:

  1. 物理世界中的人眼所见视图可以无限精细,但是电脑世界图片由一定的像素点组成。像素的多少和人眼能识别的粒度有关,这也是不同分辨率看上去细腻程度不同的本质原因;
  2. 每个像素点由色值来表示,深度为 8 的 RGBA_8888 的图片大小中,一个像素点所占字节为 8 bit * 4 = 32 bit = 4 byte。因此分辨率为 1024 * 1024 时,图片你的大小为 1024 * 1024 * 8byte = 4M,如果不压缩,图片在磁盘上占用的内存就很大;
  3. 图片压缩格式分为有损压缩和无损压缩。有损压缩的本质是删除附近的像素点,加载到内存后也无法恢复,代表格式 JPG。无损压缩的本质是相同色值只存储一次,加载到内存时再恢复,代表格式 JPEG。WBP 是集大成者,支持有损和无损,还支持 gif,但是本质是通过解压缩时间来换取功能或图片质量,解压缩时间相对前两者较长;
  4. 图片加载到内存之后,其所占内存和图片在磁盘上的大小无关,而是和分辨率、图片大小、颜色通道大小有关;
  5. 图片的加载过程就是解析磁盘上的源文件,然后通过解压源文件中的被压缩过的 bitmap 信息得出实际的 bitmap,比如无损压缩需要根据信息进行复原;

详情见:iOS图形学(二):bitmap位图详解

总之:stbi_load 方法的本质就是获取图片信息、同时将图片从磁盘加载到内存;

10. 使用 iOS 中的方法加载图片

glTexImage2D 所需要的纹理数据本质上就是一个指针,指向解压完成后的 bitmap;

弄清楚了本质之后,其实使用 iOS 中的相关方法同样可以完成图片的加载操作:

// 获取磁盘中的图片数据
NSString *path = [[NSBundle mainBundle] pathForResource:@"pika" ofType:@"jpg"];
NSData *imageData = [NSData dataWithContentsOfFile:path];
UIImage *image = [UIImage imageWithData:imageData];

// 解压图片
CGDataProviderRef provider = CGImageGetDataProvider(image.CGImage);
CFDataRef cfdata = CGDataProviderCopyData(provider);
const UInt8 *p8 = CFDataGetBytePtr(cfdata);

// 映射
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image.size.width, image.size.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, p8);
glGenerateMipmap(GL_TEXTURE_2D);

这里有两个地方需要说明。

  1. NSData 的 bytes 方法

这个方法官方文档描述是指向 NSData 的数据区域。但是在使用时,如果直接把这个数据传递给 glTexImage2D,结果是怎么也加载不出图片

其实官方文档解释也没有错,这个方法确实是指向 NSData 在内存中的数据区。但是,这个数据是没有经过解压的图片数据。可以查看对应的内存:

未解压之前的NSData

从上图可以看出,这个数据还有 Photoshop 和图片的时间,很明显,如果是 bitmap,里面全都是像素点的色值,怎么可能有时间信息?

所以,这里需要有一个基本认知:

  • 磁盘中的图片文件包含图片的基本信息 + 压缩后的像素信息

因此,图片上才可以看到很多信息:

图片信息
  1. UIImage 的意义

UIImage 只是一个数据模型,映射的是磁盘上的图片文件对应的信息,所有才会有 width、height 等信息。

图片的渲染是由 UIImageView 来完成,图片的解压是由 Provider 来完成,所以上面使用 CGDataProviderCopyData() 来获取真实的 bitmap,然后再通过 CFDataGetBytePtr() 获取到内存中 bitmap 的指针,最终传递 glTexImage2D

其实可以看到,UIImage 的大小也是图片在磁盘上的大小,而并不是图片被夹在到内存之后的大小:

磁盘大小和内存大小
  1. 一个注意点

使用第三方库 stb_image 解析时,像素通道用的是 RGB,但是使用 UIImage时,需要改成 RGBA,否则加载出来的图片和正常图片对比如下:

image.png

猜测 iOS 中的 ImageProvider 默认使用 RGBA 来解压并生成 bitmap;

11. 其他代码

纹理映射完成了,还需要一个矩形图片来进行贴图,顶点设置、属性设置、VAO、EBO 等相关代码如下:

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开始!
    0, 1, 3, // 第一个三角形
    1, 2, 3  // 第二个三角形
};

GLuint VAO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);

GLuint VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

GLuint EBO;
glGenBuffers(1, &EBO);
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);

[self configTexture];
[self useProgram];

顶点着色器代码如下:

const char *vertexShaderString = "#version 300 es\n"
"layout (location = 0) in vec3 aPos;\n"
"layout (location = 1) in vec3 aColor;\n"
"layout (location = 2) in vec2 aTexCoord;\n"
"out vec3 ourColor;\n"
"out vec2 TexCoord;\n"
"void main(){\n"
    "gl_Position = vec4(aPos, 1.0);\n"
    "ourColor = aColor;\n"
    "TexCoord = aTexCoord;\n"
"}\0";

GLint vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderString,NULL);
glCompileShader(vertexShader);

片段着色器代码如下:

const char *fragmentShaderString = "#version 300 es\n"
"out lowp vec4 FragColor;\n"
"in lowp vec3 ourColor;\n"
"in lowp vec2 TexCoord;\n"
"uniform sampler2D ourTexture;\n"
"void main(){\n"
    "FragColor = texture(ourTexture, TexCoord);\n"
"}\0";

GLint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderString, NULL);
glCompileShader(fragmentShader);

绘制代码:

- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect {
    glClearColor(0.3f, 0.6f, 1.0f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
}

12. 皮一下

原本的效果:

原效果

混个颜色:

FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);

结果:

老年disco

13. EBO 和 uniform

这里在说几个点:

  1. EBO

本质上是通过 EBO 来告诉着色器应该如何连接顶点,从而节省输入的顶点,进而提高效率。

比如画一个矩形需要两个三角形,正常逻辑是使用 6 个顶点:

float vertices[] = {
    // 第一个三角形
    0.5f, 0.5f, 0.0f,   // 右上角
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, 0.5f, 0.0f,  // 左上角
    // 第二个三角形
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, -0.5f, 0.0f, // 左下角
    -0.5f, 0.5f, 0.0f   // 左上角
};

此时有 6 个顶点,进行了 6 次连线才组成了一个矩形。但是有两个顶点是重合的,如果是上千个三角形,则浪费了 50% 的效率。另外,图元装配阶段多了一次连线。

所以,使用索引缓冲对象(EBO)来告诉着色器程序应该如何组织图形:

float vertices[] = {
    0.5f, 0.5f, 0.0f,   // 右上角
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, -0.5f, 0.0f, // 左下角
    -0.5f, 0.5f, 0.0f   // 左上角
};

unsigned int indices[] = { // 注意索引从0开始! 
    0, 1, 3, // 第一个三角形
    1, 2, 3  // 第二个三角形
};

EBO 的创建和使用和 VAO 大体相似,只不过需要注意的是,此时如果使用 glDrawCall() 进行绘制,因为内部没有 EBO 的逻辑,加上少了两个顶点,所以会导致绘制错误。

使用 EBO 之后需要调用 glDrawElements() 来进行绘制,该方法内部实现了 EBO 指向 VAO/VBO 的索引逻辑,大概逻辑如下:

EBO
  1. uniform

作用:在着色器内部声明一个变量,CPU 可以向这个变量传递数据,以此将数据传递到 GPU 供着色器使用;

使用方法,首先在着色器内部声明一个变量:

#version 330 core
out vec4 FragColor;

uniform vec4 ourColor; // 在OpenGL程序代码中设定这个变量

void main()
{
    FragColor = ourColor;
}

向 uniform 变量传递数据:

float timeValue = glfwGetTime();
float greenValue = (sin(timeValue) / 2.0f) + 0.5f;
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

上述例子的大概效果就是三角形的颜色会随着时间推移而变化,Demo 就不演示了,具体可以去官网看:https://learnopengl-cn.github.io/01%20Getting%20started/05%20Shaders/#uniform

  1. uniform sampler2D ourTexture;

sampler2D 是默认纹理处理器;

14. 两个纹理的混合

混个皮神吧~~

首先在片段着色器中声明两个采样器:

#version 300 es
uniform sampler2D texture1;
uniform sampler2D texture2;

void main()
{
    FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.5);
}

sampler2D 是纹理采样器。如果采样器不指定纹理单元的位置,默认对第一个纹理单元进行采样;

什么是纹理单元?说白了就是个坑位,让开发者有位置可以放置处理好的纹理,一般 OpenGL 会至少提供 16 个纹理单元,iOS 中提供了 32 个:

纹理单元

上述片段着色器代码表示:

  1. 使用 texture1 和 texture2 两个采样器进行采样;
  2. 两个采样器会根据自己内部的纹理单元位置对指定纹理进行采样,默认纹理位置是 0;
  3. 按照 0.5 进行混合;

接下来就需要设置纹理采样器中的纹理单元位置了:

- (void)configTexture {
    NSString *path = [[NSBundle mainBundle] pathForResource:@"container" ofType:@"jpeg"];
    // 1.创建
    GLuint texture;
    glGenTextures(1, &texture);
    // 2.激活第一个纹理单元
    glActiveTexture(GL_TEXTURE0);
    // 3.绑定
    glBindTexture(GL_TEXTURE_2D, texture);
    // 4.映射
    [self genTexture:texture path:path];
    
    NSString *path2 = [[NSBundle mainBundle] pathForResource:@"pika" ofType:@"jpg"];
    GLuint texture2;
    glGenTextures(1, &texture2);
    glActiveTexture(GL_TEXTURE1);
    glBindTexture(GL_TEXTURE_2D, texture);
    [self genTexture:texture2 path:path2];
}

上述代码中,首先创建了 2 个纹理。

然后调用 glActiveTexture 激活了纹理单元,默认第一个纹理单元是激活的。激活的意思是,接下来我要往第一个纹理单元这个坑位里面放数据了。

紧接着绑定了第一个纹理。绑定的意思就是接下来的纹理设置、映射等函数都是针对这个纹理的;

最后是调用纹理映射函数进行数据映射,上面已经写很多了,就不展示代码了;

至此,纹理单元 1 和 纹理单元 2 中各有了两个纹理数据了,接下来向着色器程序中传递 uniform 数据并绘制了:

// 传递数据之前先要激活着色器程序
glUseProgram(shaderProgram);

glUniform1i(glGetUniformLocation(shaderProgram, "texture1"), 0); 
glUniform1i(glGetUniformLocation(shaderProgram, "texture2"), 1); 

// drawRect中调用
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

向着色器程序中传递数据之前,需要先激活这个着色器程序;

另外还需要注意的是,glUniform1i 中的第二个参数是 Value,这里是直接设置采样器属于哪个纹理单元,用 0 、1 、2 这样的值,不能传 GL_TEXTURE0GL_TEXTURE1 这种枚举,因为枚举的值是这样的:

GL_TEXTURE0

这个值具体怎么用的不是很清楚,但是感觉是个指针一样的东西,而 glUniform1i 第二个参数是 int 类型,所以这里不能传递指针;

皮神现世

最后,图片翻转的原理和操作就不赘述了~~~

推荐阅读更多精彩内容