1、概述
前面几篇文章OpenGL ES 3.0(一)综述 、OpenGL ES 3.0(二)GLSL与着色器 讨论到可以为每个顶点添加颜色来增加图形的细节,从而创建出有趣的图像。但是,如果想让图形看起来更真实,就必须有足够多的顶点,从而指定足够多的颜色。这将会产生很多额外开销,因为每个模型都会需求更多的顶点,每个顶点又需求一个颜色属性。所以一般来说更多的会使用纹理(Texture)。纹理是一个2D图片(甚至也有1D和3D的纹理),它可以用来添加物体的细节。可以想象纹理是一张绘有砖块的纸,无缝折叠贴合到3D的房子上,这样的房子看起来就像有砖墙外表了。因为可以在一张图片上插入非常多的细节,这样就可以让物体非常精细而不用指定额外的顶点。除了图像以外,纹理也可以被用来储存大量的数据,这些数据可以发送到着色器上,但这里对这不做深入讨论。
如果将一张图片贴到一个三角形上面,如下所示:
为了能够把纹理映射到三角形上,需要指定三角形的每个顶点各自对应纹理的哪个部分。这样每个顶点就会关联着一个纹理坐标,用来标明该从纹理图像的哪个部分采样即采集片段颜色。之后在图形的其它片段上进行片段插值。
纹理坐标在x和y轴上,范围为0到1之间,这边指2D纹理图像。使用纹理坐标获取纹理颜色叫做采样。有效纹理坐标起始于(0, 0),也就是纹理图片的左下角,终始于(1, 1),即纹理图片的右上角。下面的图片展示了我们是如何把纹理坐标映射到三角形上的。但是这边要说明的是,纹理坐标的范围并不只此,当你采样的坐标大于1或者小于0时,会根据纹理缠绕方式进行扩展,具体下面会进行讨论。
为三角形指定了3个纹理坐标点。如上图所示,希望三角形的左下角对应纹理的左下角,因此把三角形左下角顶点的纹理坐标设置为(0, 0)。三角形的上顶点对应于图片的上中位置所以把它的纹理坐标设置为(0.5, 1.0);同理右下方的顶点设置为(1, 0)。只要给顶点着色器传递这三个纹理坐标就行了,接下来它们会被传片段着色器中,它会为每个片段进行纹理坐标的插值。纹理坐标如下:
var vertices = floatArrayOf(
0.0f, 0.0f, // 左下角
1.0f, 0.0f, // 右下角
0.5f, 1.0f // 上中
)
对纹理采样的解释非常宽松,它可以采用几种不同的插值方式。所以需要告诉OpenGL该怎样对纹理采样。
2、纹理环绕方式
纹理坐标的范围通常是从(0, 0)到(1, 1),如果把纹理坐标设置在范围之外 OpenGL ES默认的行为是重复这个纹理图像,但OpenGL ES提供了更多的选择。
环绕方式 | 描述 |
---|---|
GL_REPEAT | 对纹理的默认行为。重复纹理图像。 |
GL_MIRRORED_REPEAT | 和GL_REPEAT一样,但每次重复图片是镜像放置的。 |
GL_CLAMP_TO_EDGE | 纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。 |
GL_CLAMP_TO_BORDER | 超出的坐标为用户指定的边缘颜色。 |
前面提到的每个选项都可以使用glTexParameter()对单独的一个坐标轴设置(s轴、t轴、r轴)。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
第一个参数指定了纹理目标,对于2d图片使用的是2D纹理,因此纹理目标是GL_TEXTURE_2D。第二个参数需要指定设置的选项与应用的纹理轴。配置的是WRAP选项,并且指定S和T轴。最后一个参数需要传递一个环绕方式,在这个例子中OpenGL会给当前激活的纹理设定纹理环绕方式为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);
3、纹理过滤
纹理坐标不依赖于分辨率,它可以是任意浮点值,所以OpenGL ES需要知道怎样将纹理像素映射到纹理坐标。当有一个很大的物体但是纹理的分辨率很低的时候这就变得很重要了。OpenGL ES 也有对于纹理过滤的选项。纹理过滤有很多个选项,这边讨论最重要的两种:GL_NEAREST和GL_LINEAR。
GL_NEAREST(也叫邻近过滤,Nearest Neighbor Filtering)是OpenGL ES默认的纹理过滤方式。当设置为GL_NEAREST的时候,OpenGL ES会选择中心点最接近纹理坐标的那个像素。下图中可以看到四个像素,加号代表纹理坐标。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色:
GL_LINEAR(也叫线性过滤,(Bi)linear Filtering)它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中可以看到返回的颜色是邻近像素的混合色:
两种过滤方式各有利弊,首先从效率来讲肯定是邻近过滤更加高效,但从过滤效果来讲可能是线性过滤更加平滑。当然也有可能需要一种类似像素风的形式,这样的话可能临近过滤会适合些。
从上图可以看出,GL_NEAREST产生了颗粒状的图案,能够清晰看到组成纹理的像素,而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);
4、多级渐远纹理
假设有一个包含着上千物体的大房间,每个物体上都有纹理。有些物体会很远,但其纹理会拥有与近处物体同样高的分辨率。由于远处的物体可能只产生很少的片段,OpenGL ES 从高分辨率纹理中为这些片段获取正确的颜色值就很困难,因为它需要对一个跨过纹理很大部分的片段只拾取一个纹理颜色。在小物体上这会产生不真实的感觉,更不用说对它们使用高分辨率纹理浪费内存的问题了。OpenGL ES 使用一种叫做多级渐远纹理(Mipmap)的概念来解决这个问题,它简单来说就是一系列的纹理图像,后一个纹理图像是前一个的二分之一。多级渐远纹理背后的理念很简单:距观察者的距离超过一定的阈值,OpenGL ES会使用不同的多级渐远纹理,即最适合物体的距离的那个。由于距离远,解析度不高也不会被用户注意到。同时,多级渐远纹理另一加分之处是它的性能非常好。
手工为每个纹理图像创建一系列多级渐远纹理很麻烦,OpenGL ES有一个glGenerateMipmaps(),在创建完一个纹理后调用它OpenGL ES就会承担接下来的所有工作。
在渲染中切换多级渐远纹理级别时,OpenGL ES在两个不同级别的多级渐远纹理层之间会产生不真实的生硬边界。就像普通的纹理过滤一样,切换多级渐远纹理级别时也可以在两个不同多级渐远纹理级别之间使用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错误代码。
5、使用纹理
5.1 加载纹理
使用纹理之前要做的第一件事是把它们加载到应用中。纹理图像可能被储存为各种各样的格式,每种都有自己的数据结构和排列。比较幸运的是Android 已经提供了许多加载图片的方式,比如说通过将图片加载成Bitmap,具体可以看我的这篇文章——Android Bitmap及相关概念简述。这边做如下操作:
init {
...
val options: BitmapFactory.Options = BitmapFactory.Options()
options.inPreferredConfig = Bitmap.Config.RGB_565
// 加载时缩小图片,防止图片过大造成内存溢出
options.inSampleSize = 16
val bitmap: Bitmap = BitmapFactory.decodeResource(mContext.resources, R.drawable.wall ,options)
val buf = ByteBuffer.allocate(bitmap.byteCount)
bitmap.copyPixelsToBuffer(buf)
// 将ByteBuffer的写模式转成读模式
buf.flip()
...
}
如上所示,需要指定一下图片加载的rgb格式这边采用RGB_565,这个格式需要与后面讲到的格式相对应。这边特别需要提醒一下ARGB_4444这个格式已经废弃,Bitmap图片加载时会使用自动转成ARGB_8888。同时需要注意的是将Bitmap像素值写入到ByteBuffer后需要将其从写模式转成读模式。
5.2 生成纹理
和之前生成的OpenGL ES对象一样,纹理也是使用ID引用。
private val textureIds: IntBuffer
init {
...
textureIds = IntBuffer.allocate(2);
GLES30.glGenBuffers(2, textureIds)
...
}
glGenTextures()首先需要输入生成纹理的数量,然后把它们储存在第二个参数的IntBuffer数组中,由于之后打算生成两个纹理,所以这边申请了两个纹理id。就像其他对象一样,需要绑定它,让之后任何的纹理指令都可以配置当前绑定的纹理:
init {
...
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D,textureIds.get(0))
...
}
纹理绑定之后,可以使用前面载入的图片数据生成一个纹理。纹理可以通过glTexImage2D()来生成:
init {
...
GLES30.glTexImage2D(GLES30.GL_TEXTURE_2D, 0, GLES30.GL_RGB, bitmap.width, bitmap.height, 0, GLES30.GL_RGB, GLES30.GL_UNSIGNED_SHORT_5_6_5, buf)
GLES30.glGenerateMipmap(GLES30.GL_TEXTURE_2D)
...
}
glTexImage2D()函数的参数比较多这边一一进行讲解:
① target:指定了纹理目标。设置为GL_TEXTURE_2D意味着会生成与当前绑定的纹理对象在同一个目标上的纹理(任何绑定到GL_TEXTURE_1D和GL_TEXTURE_3D的纹理不会受到影响)。
② level:为纹理指定多级渐远纹理的级别。0是最基本的图像级别,n表示第N级贴图细化级别。
③ internalformat: 告诉OpenGL ES希望把纹理储存为何种格式。可选的值有GL_ALPHA,GL_RGB,GL_RGBA,GL_LUMINANCE, GL_LUMINANCE_ALPHA 等几种。
④ 、⑤ width、height: 参数设置最终的纹理的宽度和高度。纹理图片至少要支持64个材质像素的宽度。
⑥ border: 应该总是被设为0(历史遗留的问题)。
⑦ format:定义了源图的的颜色格式, 不需要和internalformatt取值必须相同。可选的值参考internalformat。
⑧ type: 定义了源图的数据类型。代表原始数据中的像素数据以什么样的形式进行理解和读取。可以使用的值有GL_UNSIGNED_BYTE,GL_UNSIGNED_SHORT_5_6_5,GL_UNSIGNED_SHORT_4_4_4_4,GL_UNSIGNED_SHORT_5_5_5_1。
⑨ pixels:是真正的图像像素数据。
当调用glTexImage2D()时,当前绑定的纹理对象就会被附加上纹理图像。然而,目前只有基本级别(Base-level)的纹理图像被加载了,如果要使用多级渐远纹理,必须手动设置所有不同的图像(不断递增第二个参数)。或者,直接在生成纹理之后调用glGenerateMipmap()。这会为当前绑定的纹理自动生成所有需要的多级渐远纹理。
生成一个纹理的过程总的来说应该这样:
private val textureIds: IntBuffer
init{
...
textureIds = IntBuffer.allocate(2);
GLES30.glGenBuffers(2, textureIds)
...
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textureIds.get(0))
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_WRAP_S, GLES30.GL_REPEAT)
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_WRAP_T, GLES30.GL_MIRRORED_REPEAT)
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MIN_FILTER, GLES30.GL_LINEAR)
GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MAG_FILTER, GLES30.GL_LINEAR)
val options: BitmapFactory.Options = BitmapFactory.Options()
options.inPreferredConfig = Bitmap.Config.RGB_565
options.inSampleSize = 16
val bitmap: Bitmap = BitmapFactory.decodeResource(mContext.resources, R.drawable.wall ,options)
val buf = ByteBuffer.allocate(bitmap.byteCount)
bitmap.copyPixelsToBuffer(buf)
buf.flip()
GLES30.glTexImage2D(GLES30.GL_TEXTURE_2D, 0, GLES30.GL_RGB, bitmap.width, bitmap.height, 0, GLES30.GL_RGB, GLES30.GL_UNSIGNED_SHORT_5_6_5, buf)
GLES30.glGenerateMipmap(GLES30.GL_TEXTURE_2D)
...
}
5.3 应用纹理
这部分会使用glDrawElements绘制前面文章提到的绘制矩形来进行讨论。需要告知OpenGL ES如何采样纹理,所以必须使用纹理坐标更新顶点数据:
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 // 左上
};
这时候其实不用再使用颜色坐标了,但为了考虑到文章的前后连贯性,这里不去掉颜色坐标,而是额外再加上一个顶点属性即纹理坐标,此时缓存中的形式应该如下图。
init {
...
GLES30.glVertexAttribPointer(2, 2, GLES30.GL_FLOAT, false, vertexStride, 6 * 4)
...
}
fun draw() {
...
GLES30.glEnableVertexAttribArray(2)
...
GLES30.glDisableVertexAttribArray(2)
...
}
companion object {
internal val COORDS_PER_VERTEX =8
...
}
接着需要调整顶点着色器使其能够接受顶点坐标为一个顶点属性,并把坐标传给片段着色器。
private val vertexShaderCode =
"#version 300 es \n" +
" 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。
private val fragmentShaderCode = (
"#version 300 es \n " +
"#ifdef GL_ES\n" +
"precision highp float;\n" +
"#endif\n" +
"out vec4 FragColor; " +
"in vec3 ourColor; " +
"in vec2 TexCoord; " +
"uniform sampler2D ourTexture;" +
"void main() {" +
" FragColor =texture(ourTexture, TexCoord) ;" +
"}")
使用GLSL内建的texture()来采样纹理的颜色,它第一个参数是纹理采样器,第二个参数是对应的纹理坐标。texture()会使用之前设置的纹理参数对相应的颜色值进行采样。这个片段着色器的输出就是纹理的(插值)纹理坐标上的(过滤后的)颜色。之后就是在调用glDrawElements()之前要先绑定纹理,它会自动把纹理赋值给片段着色器的采样器:
fun draw() {
...
GLES30.glUseProgram(mProgram)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textureIds.get(0))
GLES30.glBindVertexArray(VAOids.get(0))
GLES30.glDrawElements(GLES30.GL_TRIANGLES, 6, GLES30.GL_UNSIGNED_INT, 0);
...
}
还可以把得到的纹理颜色与顶点颜色混合,来获得更有趣的效果。只需把纹理颜色与顶点颜色在片段着色器中相乘来混合二者的颜色:
FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);
这样最终会是顶点颜色和纹理颜色的混合。
5.4 纹理单元
可以给纹理采样器分配一个位置值,这样的话能够在一个片段着色器中设置多个纹理。一个纹理的位置值通常称为一个纹理单元(Texture Unit)。一个纹理的默认纹理单元是0,它是默认的激活纹理单元,所以前面部分没有分配一个位置值。
纹理单元的主要目的是让开发者在着色器中可以使用多于一个的纹理。通过把纹理单元赋值给采样器,可以一次绑定多个纹理,只要首先激活对应的纹理单元。
fun draw() {
...
GLES30.glActiveTexture(GLES30.GL_TEXTURE0)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textureIds.get(0))
GLES30.glUniform1i(GLES30.glGetUniformLocation(mProgram, "texture1"), 0)
GLES30.glActiveTexture(GLES30.GL_TEXTURE1)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textureIds.get(1))
GLES30.glUniform1i(GLES30.glGetUniformLocation(mProgram, "texture2"), 1)
...
}
通过上面的操作将纹理单元,纹理缓存空间和纹理属性相关联起来。先用glActiveTexture()激活纹理单元。再通过glBindTexture()将此时激活的纹理单元与之前申请的纹理缓存空间相关联。再通过glUniform1i()将纹理单元,即该函数的第二个参数,与GLSL中的属性相关联。这样这三者就互相联系上了。而之前没有使用glActiveTexture()对GL_TEXTURE0进行激活是因为纹理单元GL_TEXTURE0默认总是被激活。OpenGL ES至少保证有16个纹理单元供使用,也就是说可以激活从GL_TEXTURE0到GL_TEXTRUE15。它们都是按顺序定义的,所以也可以通过GL_TEXTURE0 + 15的方式获得GL_TEXTURE15。
此时需要重新编辑片段着色器来接收另一个采样器。
private val fragmentShaderCode = (
"#version 300 es \n " +
"#ifdef GL_ES\n" +
"precision highp float;\n" +
"#endif\n" +
"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);\n" +
"}")
最终输出颜色现在是两个纹理的结合。GLSL内建的mix函数需要接受两个值作为参数,并对它们根据第三个参数进行线性插值。如果第三个值是0.0,它会返回第一个输入;如果是1.0,会返回第二个输入值。0.2会返回80%的第一个输入颜色和20%的第二个输入颜色,即返回两个纹理的混合色。