Android OpenGL ES:纹理

522 阅读15分钟

Android OpenGL ES系列目录

本系列文章主要是基于LearnOpenGL和对应的中文教程。与原教程主要的差异是,该系列讲解的是基于Android设备环境的OpenGL ES,并提供对应的Java示例。

原文:Textures

中文原文:纹理

从前面的教程,我们已经了解到,我们可以为每个顶点添加颜色来增加图形的细节,从而创建出有趣的图像。但是,按这种方式,如果想让图形看起来更真实,我们就必须有足够多的顶点,从而指定足够多的颜色。这将会产生很多额外开销,因为每个图形都会需求更多的顶点,每个顶点又需求一个颜色属性。

这时候,我们需要纹理(Texture)

很容易会将纹理理解成一张图片,实际上在计算机图形学中,我们也确实经常会通过一张张格式诸如png、jpg之类的图片来获得纹理数据。

但在图形学中,纹理更多地是被认作一块数据。它也不再局限在2D空间,也会有一维纹理、三维纹理、立方体映射纹理、数组纹理等。

纹理是由纹素(texel)构成的,每个纹素记录着相应的颜色信息,它类似于屏幕的像素(pixel),但又不保证与像素一一对应。

纹理映射(texture mapping)实际上也就是一块纹理数据与2D、3D模型建立关联的过程。比如,将一张图片(离线渲染生成)“粘”在物体的表面上,就像贴墙纸一样。

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

下面你会看到之前教程的那个三角形贴上了一张砖墙图片。

为了把纹理映射(Map)到三角形上,我们需要指定三角形的每个顶点对应纹理的哪个部分。即每个顶点都会关联一个纹理坐标(Texture Coordinate),以指定从纹理图像的哪个部分采样。然后,在图形的其它片段上进行片段插值(Fragment Interpolation)

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

我们为三角形指定了3个纹理坐标点。如上图所示,我们希望三角形的左下角对应纹理的左下角,因此我们把三角形左下角顶点的纹理坐标设置为(0, 0);三角形的上顶点对应于图片的上中位置,所以我们把它的纹理坐标设置为(0.5, 1.0);同理右下方的顶点设置为(1, 0)。我们只需要给顶点着色器传递这三个纹理坐标,然后传递至片段着色器中。片段着色器会为所有片段完成纹理坐标的插值。

纹理坐标看起来就像这样:

float[] texCoords = {
    0.0f, 0.0f, // 左下角
    1.0f, 0.0f, // 右下角
    0.5f, 1.0f // 上中
};

纹理采样有几种不同的插值方式。所以我们需要告诉OpenGL该怎样进行纹理采样

纹理环绕方式

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

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

当纹理坐标超出默认范围时,每个选项都有不同的视觉效果输出。我们来看看这些纹理图像的例子:

前面提到的每个选项都可以使用glTexParameter*函数对单独的一个坐标轴设置(st,如果是使用3D纹理那么还有一个r。它们和xyz是等价的):

GLES20.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
GLES20.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
  • void glTexParameteri(int target, int pname, int param)

    glTexParameter*系列函数,主要用于设置纹理参数

    • target:指定纹理目标。可选目标有:
      • GL_TEXTURE_2D
      • GL_TEXTURE_3D
      • GL_TEXTURE_2D_ARRAY
      • GL_TEXTURE_CUBE_MAP
    • pname:指定设置的纹理参数。可选参数较多,见官方文档。这里配置的是S和T轴的纹理环绕方式,所以分别传了GL_TEXTURE_WRAP_S、GL_TEXTURE_WRAP_T。
    • param:指定pname的值。比如,pname是纹理环绕,所以param是传递具体的环绕方式,如GL_MIRRORED_REPEAT。

如果选择GL_CLAMP_TO_BORDER,我们还需要指定一个边缘的颜色。这需要使用glTexParameter函数的fv后缀形式,用GL_TEXTURE_BORDER_COLOR作为它的选项,并且传递一个float数组作为边缘的颜色值:

float[] borderColor = { 1.0f, 1.0f, 0.0f, 1.0f };
GLES20.glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

纹理过滤

什么是纹理过滤?

以吃鸡游戏为例。假设游戏中,卡车的游戏设计纹理贴图是400 * 400纹素。

  • 当你(摄像机)跟卡车的距离为0时,卡车在你屏幕上显示的像素就是400 * 400,跟贴图的纹素大小相同,此时不需要做特殊处理。

  • 当你离卡车10米远时,屏幕上显示的像素为200 * 200。当200 * 200的像素要显示400 * 400纹素的物体时,一颗屏幕像素需要映射一块2 * 2的纹理像素。这个时候,这颗像素要显示什么颜色?

纹理过滤(Texture Filtering)就是用来处理这个问题里的。纹理过滤有很多种选项,这里只讨论最重要的两种:GL_NEARESTGL_LINEAR

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

GL_LINEAR(也叫线性过滤,(Bi)linear Filtering)它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中你可以看到返回的颜色是邻近像素的混合色:

那么这两种纹理过滤方式有怎样的视觉效果呢?让我们看看一张低分辨率的纹理被映射到一个很大的物体上时会发生什么吧(纹理被放大了,每个纹素都能看到):

GL_NEAREST产生了颗粒状的图案,我们能够清晰看到组成纹理的纹素,而GL_LINEAR能够产生更平滑的图案,很难看出单个的纹素。GL_LINEAR可以产生更真实的输出,但相对的,也更耗时耗资源。

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

GLES20.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
GLES20.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

多级渐远纹理

想象一下,假设我们有一个包含着上千物体的大房间,每个物体上都有纹理。有些物体会很远,但其纹理会拥有与近处物体同样高的分辨率。对这些小物体使用高分辨率纹理存在浪费内存的问题。而且由于远处的物体可能只产生很少的片段,OpenGL从高分辨率纹理中为这些片段获取正确的颜色值就很困难,因为它需要对一个跨过纹理很大部分的片段只拾取一个纹素(可能一百个纹素,只选择了一个纹素,其余没有起到参考作用)。在小物体上这会产生不真实的感觉,容易产生锯齿或者摩尔纹:

多级渐远纹理(Mipmap)就是用来解决这个问题的。多级渐远纹理背后的理念很简单:距观察者的距离超过一定的阈值,OpenGL会使用对应的多级渐远纹理,即最适合物体的距离的那个。由于距离远,解析度不高也不会被用户注意到。让我们看一下多级渐远纹理是什么样子的:

Mipmap就是一系列的纹理图像,是预先创建的,需要原始纹理的三分之一内存。Mipmap中每张图都是原始纹理的一个特定比例的缩小细节的复制品。某些必要的视角,原始纹理仍然会被使用,来渲染完整的细节。

如果原图是256x256的话,它的Mipmap就会有8个层级。每个层级是上一层级的四分之一的大小,依次大小是:128x128、64x64、32x32、16x16、8x8、4x4、2x2、1x1。

因为Mipmap纹理需要被读取的像素远少于原始纹理,所以渲染的速度得到了提升。使用Mipmap纹理,也能有效解决锯齿、摩尔纹。

手工为每个纹理图像创建一系列多级渐远纹理很麻烦,幸好OpenGL有一个glGenerateMipmaps函数,在创建完一个纹理后调用它,OpenGL就会承担接下来的所有工作了。后面的教程中你会看到该如何使用它。

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

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

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

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

一个常见的错误是,将放大过滤的选项设置为多级渐远纹理过滤选项之一。这样没有任何效果,因为多级渐远纹理主要是使用在纹理被缩小的情况下的:纹理放大不会使用多级渐远纹理,为放大过滤设置多级渐远纹理的选项会产生一个GL_INVALID_ENUM错误代码。

生成纹理

生成、绑定纹理,和前面创建VAO、VBO类似一样:

int[] texture = new int[1];
GLES20.glGenTextures(1, texture, 0);
GLES20.glBindTexture(GL_TEXTURE_2D, texture[0]);
  • void glGenTextures(int n, int[] textures, int offset)

    向OpenGL ES申请生成n个纹理,并通过textures数组获取纹理的handle,handle的类型为整型。

    • n:申请的纹理个数
    • textures:用于存储纹理handle的数组
    • offset:textures数组的偏移量,即从buffers的第offset个位置开始存储handle
    • 需要满足 n + offset <= textures.length

现在纹理已经绑定了,我们可以使用载入的图像数据生成一个纹理了。加载图像数据在Android上有两种方法:

  • GLES20.glTexImage2D
  • GLUtils.texImage2D

GLUtils.texImage2D本质是Android对glTexImage2D的封装,传参与glTexImage2D类似,只是可以直接传递Bitmap作为图像数据。所以推荐使用texImage2D

方法介绍:

  • void glTexImage2D(int target, int level, int internalformat, int width, int height, int border, int format, int type, java.nio.Buffer pixels)

    • target:指定纹理目标。比如:GL_TEXTURE_2D、 GL_TEXTURE_CUBE_MAP_POSITIVE_X等。
    • level:指定多级渐远纹理的级别,如果你希望单独手动设置每个多级渐远纹理的级别的话。这里我们填0,也就是基本级别。
    • internalformat:指定纹理数据存储格式。比如:GL_RGB、GL_RGBA等。
    • width:指定纹理宽度。
    • height:指定纹理高度。
    • border:必须为0。历史遗留问题。
    • format:指定原图的像素格式。比如:GL_RGB、GL_RGBA等。
    • type:指定原图的像素数据格式。比如:GL_UNSIGNED_BYTE, GL_BYTE等。
    • pixels:图像数据。
  • void texImage2D(int target, int level, Bitmap bitmap, int border)

    • target:指定纹理目标。比如:GL_TEXTURE_2D、 GL_TEXTURE_CUBE_MAP_POSITIVE_X等。
    • level:指定多级渐远纹理的级别,如果你希望单独手动设置每个多级渐远纹理的级别的话。这里我们填0,也就是基本级别。
    • bitmap:图像数据

加载一张木箱图进入纹理:

Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.wooden_container);
GLUtils.texImage2D(GL_TEXTURE_2D,0,bitmap,0);

应用纹理

后面的这部分我们会使用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我们新增的顶点属性:

GLES20.glVertexAttribPointer(2, 2, GL_FLOAT, false, 8 * BYTES_PER_FLOAT, 6 * BYTES_PER_FLOAT);
GLES20.glEnableVertexAttribArray(2);

注意,我们同样需要调整前面两个顶点属性的步长参数为8 * BYTES_PER_FLOAT

接着我们需要调整顶点着色器,使其能够接受纹理坐标为一个顶点属性,并把坐标传给片段着色器:

#version 300 es
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),它以纹理类型作为后缀,比如sampler1Dsampler3D。现在我们声明一个uniform sampler2D变量,用来把一个纹理添加到片段着色器中。稍后我们会把纹理赋值给这个uniform。

#version 300 es
precision mediump float;
out vec4 FragColor;
in vec3 ourColor;
in vec2 TexCoord;

uniform sampler2D texture1;

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

我们使用GLSL的texture函数来采样纹理的颜色,它第一个参数是纹理采样器,第二个参数是对应的纹理坐标。texture函数会依据设置的参数,对相应的颜色值进行采样。最后片段着色器输出的FragColor,就是各个纹理坐标(包括插值的)上的颜色(经过纹理过滤处理)。

最后调用glDrawElements进行绘制即可:

shader.use();
GLES30.glBindVertexArray(vao[0]);
GLES20.glDrawElements(GLES20.GL_TRIANGLES, 6, GLES20.GL_UNSIGNED_INT, 0);

运行结果:

渲染器代码:WoodenBoxRender

顶点着色器代码:vertex_wooden_box

片段着色器代码:frag_wooden_box

如果你的矩形是全黑或全白的,你可能在哪儿做错了什么。建议检查你的着色器日志。

如果你的纹理代码依然不能正常工作或者显示是全黑,请继续阅读,并一直跟进我们的代码到最后的例子,它是应该能够工作的。在一些驱动中,必须要对每个采样器uniform都附加上纹理单元才可以,这个会在下面介绍。

我们还可以把得到的纹理颜色与顶点颜色混合,来获得更有趣的效果。我们只需把纹理颜色与顶点颜色在片段着色器中相乘来混合二者的颜色:

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

最终的效果应该是顶点颜色和纹理颜色的混合色:

我猜你会说我们的箱子喜欢跳70年代的迪斯科。

渲染器代码:ColouredWoodenBoxRender

顶点着色器代码:vertex_wooden_box (与原来的木箱一致)

片段着色器代码:frag_coloured_wooden_box

纹理单元

使用glUniform1i,我们可以给纹理采样器分配一个位置值,这样的话我们能够在一个片段着色器中设置多个纹理。一个纹理的位置值通常称为一个纹理单元(Texture Unit)。纹理单元的主要目的是让我们在着色器中可以使用多于一个的纹理。通过把纹理单元赋值给采样器,我们可以一次绑定多个纹理,只要我们首先激活对应的纹理单元。我们可以使用glActiveTexture激活纹理单元:

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

你可能会奇怪为什么前面的sampler2D类型的texture1变量明明是个uniform,我们却不用glUniform给它赋值。因为一个纹理的默认纹理单元是0,即GL_TEXTURE0,它是默认激活的纹理单元,所以教程前面我们没有分配一个位置值,也无需激活任何纹理单元。

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

我们编辑片段着色器来接收另一个采样器:

#version 300 es
precision mediump float;
out vec4 FragColor;
in vec3 ourColor;
in vec2 TexCoord;

uniform sampler2D texture1;
uniform sampler2D texture2;

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

最终输出颜色是两个纹理的结合。GLSL内建的mix函数需要接受两个值作为参数,并根据第三个参数,对它们进行线性插值。如果第三个值是0.0,它会返回第一个输入;如果是1.0,会返回第二个输入值。0.2会返回80%的第一个输入颜色和20%的第二个输入颜色,即返回两个纹理的混合色(测试发现,在OpenGL ES里,不能设置0.01.0,会导致崩溃)。

我们现在需要载入并创建另一个纹理。对于第二个纹理,我们使用一张你学习OpenGL时的面部表情图片。

同时启用两个纹理,初始化代码:

 //创建、绑定、填充第一个纹理
int[] texture1 = new int[1];
GLES20.glGenTextures(1, texture1, 0);
GLES20.glBindTexture(GL_TEXTURE_2D, texture1[0]);
GLES20.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
GLES20.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.wooden_container);
GLUtils.texImage2D(GL_TEXTURE_2D, 0, bitmap, 0);
GLES20.glGenerateMipmap(GL_TEXTURE_2D);

//创建、绑定、填充第二个纹理
int[] texture2 = new int[1];
GLES20.glGenTextures(1, texture2, 0);
GLES20.glBindTexture(GL_TEXTURE_2D, texture2[0]);
GLES20.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
GLES20.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
Bitmap bitmap2 = BitmapFactory.decodeResource(context.getResources(), R.drawable.awesomeface);
GLUtils.texImage2D(GL_TEXTURE_2D, 0, bitmap2, 0);
GLES20.glGenerateMipmap(GL_TEXTURE_2D);

然后还得用glUniform1i告诉OpenGL每个着色器采样器属于哪个纹理单元。在调用glUniform1i之前,记得先激活着色器程序:

shader.use();
shader.setInt("texture1",0);
shader.setInt("texture2", 1);

最后在渲染的时候,要绑定两个纹理到对应的纹理单元,需要先激活对应的纹理,然后定义哪个uniform采样器对应哪个纹理单元:

GLES20.glActiveTexture(GL_TEXTURE0);
GLES20.glBindTexture(GL_TEXTURE_2D, texture1[0]);
GLES20.glActiveTexture(GL_TEXTURE1);
GLES20.glBindTexture(GL_TEXTURE_2D, texture2[0]);

shader.use();
GLES30.glBindVertexArray(vao[0]);
GLES20.glDrawElements(GLES20.GL_TRIANGLES, 6, GLES20.GL_UNSIGNED_INT, 0);

最终渲染结果:

纹理上下颠倒了!这是因为OpenGL要求y轴0.0坐标是在图片的底部的,但是图片的y轴0.0坐标通常在顶部。

对于这种情况,有三种方法处理:

  • 颠倒Bitmap:不过需要先用原图生成一张Bitmap,再在这个Bitmap的基础上生成新的颠倒后的Bitmap。不推荐。
  • 颠倒位置属性的Y坐标
  • 颠倒纹理坐标的Y坐标

这里提供后面两种方法:

原来的顶点数据:

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    // 左上
};

颠倒位置属性的Y坐标:

颠倒位置属性Y坐标.png

颠倒纹理坐标的Y坐标:

颠倒纹理坐标Y坐标.png

如果你看到了一个开心的箱子,你就做对了。

渲染器代码:LaughingWoodenBoxRender

顶点着色器代码:vertex_wooden_box (与原来的木箱一致)

片段着色器代码:frag_laughing_wooden_box

参考资料

纹理那些事儿

Mipmap详解(有无Mipmap区别、原理、优点、缺点、优化)

Mipmap