OpenGL ES 2 第七章:使用纹理添加细节
我们已经设法用简单的形状和颜色完成了很多工作。不过,我们缺少了一些东西:如果我们希望在图形上绘画,并添加精致的细节呢?像艺术家一样,我们可以从基本图形和颜色开始,通过使用纹理在表面上添加额外的细节。简单来说,纹理就是可以加载到OpenGL上的图像或图片。
我们可以通过纹理(Texture)添加大量细节。想想你最近可能玩过的一款精美的3D游戏,这些游戏的本质也只是使用点、线和三角形,就像其他3D程序一样。然而,通过纹理添加的细节和艺术家的加工,这些三角形可以用纹理构建美丽的3D场景。
一旦我们开始使用纹理,就要开始使用多个着色器程序了。为了便于管理,我们将学习如何调整代码,以便使用多个着色器程序和顶点数据源,并在它们之间切换。
以下是本章的计划:
- 我们将从介绍纹理开始,然后编写代码将纹理加载到OpenGL中。
- 我们将学习如何显示纹理,调整代码以支持多个着色器程序。
- 我们还将介绍不同的纹理过滤模式及其作用。
什么是“纹理”
OpenGL中的纹理可以用来表示图像、图片,甚至是由数学算法生成的分形数据。每个二维纹理都由许多小的纹理元素(texel)组成,这些小块数据类似于我们之前讨论过的片元和像素。使用纹理最常用的方法是直接从图像文件加载数据。
我们将使用下面这张图片作为新的空气曲棍球桌表面,并将其作为纹理加载。可以右键保存这张图片,然后将纹理存储在项目的/res/drawable-nodpi/文件夹中。
每个二维纹理都有自己的坐标空间,其范围是从一个角的(0,0)到另一个角的(1,1)。按照惯例,一个维度称为S,另一个维度称为T。当我们想要将纹理应用于一个三角形或一组三角形时,我们将为每个顶点指定一组ST纹理坐标,以便OpenGL知道它需要在每个三角形上绘制纹理的哪些部分。这些纹理坐标有时也称为UV纹理坐标。
OpenGL纹理没有内在的方向性,我们可以使用不同的坐标系以任何我们喜欢的方式来确定它的方向。然而,大多数计算机图像文件都有一个默认方向:它们的y轴通常向下(如下图所示),y值随着我们向图像底部移动而增加。如果我们想以正确的方向看到图像,那么我们的纹理坐标需要考虑到这一点。
在OpenGL ES 2.0标准中,纹理不必是正方形,但每个维度都应该是二的幂(POT)。这意味着每个维度应该是128、256、512等数字。这是因为非POT纹理的使用范围非常有限,而POT纹理适用于所有用途。纹理还有最大尺寸的限制,根据实现的不同而不同,但通常是较大的,比如2048 x 2048。
将纹理加载到OpenGL中
我们的第一个任务是将图像文件中的数据加载到OpenGL纹理中。我们首先在util包下的Utils.kt内添加以下代码:
fun Context.loadTexture(@DrawableRes resourceId: Int): Int {
val textureObjectIds = IntArray(1)
GLES20.glGenTextures(1, textureObjectIds, 0)
if (textureObjectIds[0] == 0) {
LogU.w(message = "Could not generate a new OpenGL texture object.")
return 0
}
// TODO
}
我们通过调用glGenTextures(int n, int[] textures, int offset)
生成一个纹理对象。OpenGL将生成的ID存储在textureObjectIds
中。我们还检查对glGenTextures()
的调用是否成功,仅当它不等于零时才继续;否则,我们将打印日志并返回0。
加载Bitmap数据并绑定到纹理
下一步是使用Android的API从图像文件中读取数据。OpenGL无法直接从PNG或JPEG文件中读取数据,因为这些文件被编码为特定的压缩格式。OpenGL需要未压缩的原始数据,因此我们需要使用Android内置的BitmapFactory创建位图,将图像文件解压缩为OpenGL能够理解的格式。
让我们继续往loadTexture()
内添加代码,并将图像解压缩为Android的Bitmap:
fun Context.loadTexture(@DrawableRes resourceId: Int): Int {
val textureObjectIds = IntArray(1)
GLES20.glGenTextures(1, textureObjectIds, 0)
if (textureObjectIds[0] == 0) {
LogU.w(message = "Could not generate a new OpenGL texture object.")
return 0
}
val option = BitmapFactory.Options().apply {
inScaled = false
}
val bitmap: Bitmap? = BitmapFactory.decodeResource(resources, resourceId, option)
if (bitmap == null) {
LogU.w(message = "Resource ID $resourceId could not be decoded.")
GLES20.glDeleteTextures(1, textureObjectIds, 0)
return 0
}
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureObjectIds[0])
// TODO
}
我们将创建Bitmap的inScaled选项设置为false。这告诉Android提供原始图像数据,而不是数据的缩放版本。
然后我们根据资源id执行实际解码,如果Bitmap为空,我们将检查该故障并删除OpenGL纹理对象。如果解码成功,我们将继续处理纹理。
在我们可以对新生成的纹理对象做任何其他事情之前,我们需要告诉OpenGL,后面的纹理调用应该应用于这个纹理对象。我们通过调用glBindTexture()
来实现这一点。第一个参数GL_TEXTURE_2D
告诉OpenGL应该将其视为二维纹理,第二个参数告诉OpenGL要绑定到哪个纹理对象ID。
了解纹理过滤
我们还需要使用纹理过滤来指定当纹理被放大或缩小时应该发生什么。当我们在渲染表面上绘制纹理时,纹理元素可能不会精确映射到OpenGL生成的片元上。有两种情况:缩小和放大。缩小是指我们试图将几个元素填充到同一个片元上,放大是指我们将一个元素分散到多个片元上。针对每一种情况,我们可以配置OpenGL对其使用纹理过滤器。
首先,我们将介绍两种基本的过滤模式:最邻近过滤(nearest-neighbor)和双线性插值(bilinear interpolation.)。随后会详细介绍其他过滤模式。我们将使用下图来说明每种过滤模式:
最邻近过滤
这将为每个片元选择最近的纹理元素。当我们放大纹理时,能看到边缘出现明显的锯齿,如下所示:
每个纹理都清楚地显示为一个小方块。
当我们缩小纹理时,许多细节将丢失,因为我们没有足够的片元来容纳所有纹理:
双线性过滤
双线性过滤使用双线性插值来平滑像素之间的过渡。OpenGL将为每个片元使用四个相邻的纹理元素进行计算,而不是简单选用最近的纹理元素。进行计算所用的基本原理就是我们之前讨论过的线性插值(第四章),我们之所以称之为双线性,是因为它是沿着两个维度进行的。以下是使用双线性插值放大的与之前相同的纹理:
纹理现在看起来比以前平滑多了。但仍然会出现一些锯齿,因为我们纹理放大倍数太大了,但锯齿不像使用最邻近过滤那样明显。
MIP映射
虽然双线性过滤很适合处理放大,但缩小超过一定尺寸后的效果并不好。一个纹理元素在渲染表面上所占的大小越是减小,就会有越多的纹理元素拥挤在单个片元上。由于双线性过滤只给每个片元提供四个元素,我们仍然会丢失很多细节。若每帧需要选择不同的纹理元素,这可能会导致移动中对象的闪烁。
为了克服这些缺点,我们可以使用被称之为MIP映射或MIP贴图(Mipmapping)的技术,它可以生成优化过的不同大小的纹理集。在生成纹理集时,OpenGL可以使用所有的纹理元素来生成每个级别的纹理,在渲染时,OpenGL将根据每个片元的纹理数为每个片元选择最合适的级别。这种行为有点像我们准备各种不同dpi的drawable,然后交给系统来决定使用哪一张。
下图展示了一组MIP映射纹理,为了对比更清晰,因此把它们组合在一张图上:
使用MIP映射的一个小缺点是会占用更多内存,毕竟我们为一份纹理准备了多个不同的文件;优点则是渲染速度会更快,因为更小级别的纹理在GPU的纹理缓存中占用的空间更少(因此绘制更快)。
为了更好地理解MIP映射是如何提高缩小的质量的,让我们比较一下不使用MIP映射与使用MIP映射缩小的效果。下图使用双线性过滤来将纹理缩小到原始纹理大小的12.5%。
嗯,缩小质量甚至比最邻近过滤还糟糕。让我们看看当我们添加MIP映射时的效果如何。
启用MIP映射后,OpenGL将选择最适当的纹理级别,然后使用该优化纹理进行双线性过滤。每一级别的纹理都是用所有纹理的信息构建的,因此生成的图像看起来更好些,保留了更多细节。
三线性过滤
当我们将MIP映射与双线性过滤结合使用时,我们有时会在渲染场景中看到清晰和模糊明显的分界,这是在不同级别的纹理进行切换时留下的。我们可以转而使用三线性过滤(Trilinear Filtering),让OpenGL在两个临近的级别分界之间也要进行线性插值,每个片元总共使用八个元素来进行插值。这有助于消除每个MIP映射级别之间的过渡,并产生更平滑的图像。
设置默认纹理过滤参数
现在我们了解了纹理过滤,让我们继续在loadTexture()
内添加以下代码:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
我们通过调用glTexParameteri()
来设置每个过滤器:GL_TEXTURE_MIN_FILTER
表示缩小的情况,而GL_TEXTURE_MAG_FILTER
表示放大的情况。对于缩小,我们选择GL_LINEAR_MIPMAP_LINEAR
,它告诉OpenGL使用三线性过滤。我们将放大过滤器设置为GL_LINEAR
,这告诉OpenGL使用双线性过滤。
下面两个表展示了OpenGL纹理过滤模式以及每种情况下支持的纹理过滤模式。
OpenGL纹理过滤模式 | 解释 |
---|---|
GL_NEAREST | 最邻近过滤 |
GL_NEAREST_MIPMAP_NEAREST | 使用MIP映射的最邻近过滤 |
GL_NEAREST_MIPMAP_LINEAR | 仅在MIP映射级别分界处使用线性插值的最邻近过滤 |
GL_LINEAR | 双线性过滤 |
GL_LINEAR_MIPMAP_NEAREST | 使用MIP映射的双线性过滤 |
GL_LINEAR_MIPMAP_LINEAR | 三线性过滤 |
情况 | 支持的过滤模式 |
---|---|
缩小 | GL_NEAREST GL_NEAREST_MIPMAP_NEAREST GL_NEAREST_MIPMAP_LINEAR GL_LINEAR GL_LINEAR_MIPMAP_NEAREST GL_LINEAR_MIPMAP_LINEAR |
放大 | GL_NEAREST GL_LINEAR |
将纹理加载到OpenGL并返回ID
我们添加以下代码:
fun Context.loadTexture(@DrawableRes resourceId: Int): Int {
...
// 将位图数据加载到OpenGL
GLUtils.texImage2D(GL_TEXTURE_2D, 0, bitmap, 0)
// 回收位图
bitmap.recycle()
// 生成MIP映射需要的纹理
glGenerateMipmap(GL_TEXTURE_2D)
// 解除纹理绑定(传入的纹理id为0)
glBindTexture(GL_TEXTURE_2D, 0)
return textureObjectIds[0]
}
GLUtils.texImage2D()
这个调用告诉OpenGL读入位图数据,并将其复制到当前绑定的纹理对象中。然后我们手动回收掉Bitmap。然后我们调用glGenerateMipmap()
,我们可以告诉OpenGL生成所有必要的MIP映射级别。到此,我们已经加载完纹理,一个好的习惯就是解除对纹理对象的绑定绑定,这样我们就不会意外地通过其他纹理调用对该纹理进行进一步更改,我们再次调用glBindTexture()
,但是这次我们传入的id为0,这样绑定就解除了。
最后,我们将返回一个纹理ID,可以用作此纹理的引用,如果加载失败,则返回0。
创建一组新的着色器
在将纹理绘制到屏幕之前,我们必须创建一组新的着色器,以接受纹理并将其应用于正在绘制的片元。这些新的着色器将与我们迄今为止一直使用的着色器类似,只需进行几处细微的更改,以添加对纹理的支持。
创建新的顶点着色器
在/res/raw文件夹下创建新的文件texture_vertex_shader.glsl,然后添加以下内容:
uniform mat4 u_Matrix;
attribute vec4 a_Position;
attribute vec2 a_TextureCoordinates;
varying vec2 v_TextureCoordinates;
void main() {
v_TextureCoordinates = a_TextureCoordinates;
gl_Position = u_Matrix * a_Position;
}
大多数着色器代码看起来应该很熟悉:我们已经为矩阵定义了一个uniform
变量,并且为位置定义了一个属性。我们使用这些设置最终的gl_Position
。现在来看新内容:我们还为纹理坐标添加了一个新属性,称为a_TextureCoordinates
,它被定义为vec2
,因为有两个分量:S坐标和T坐标。我们将这些坐标赋值给varying变量v_TextureCoordinates
,发送到片元着色器。
创建新的片元着色器
在同一个目录下新建texture_fragment_shader.glsl文件并添加以下内容:
precision mediump float;
uniform sampler2D u_TextureUnit;
varying vec2 v_TextureCoordinates;
void main() {
gl_FragColor = texture2D(u_TextureUnit, v_TextureCoordinates);
}
要在对象上绘制纹理,OpenGL将为每个片元调用片元着色器,每次调用都将接收v_TextureCoordinates
中的纹理坐标。片元着色器还将通过uniform
变量u_TextureUnit
(类型为sampler2D
)接收实际纹理数据。此变量类型指的是二维纹理数据。
被插值的纹理坐标和纹理数据被传递到着色器函数texture2D()
,该函数将读取该特定坐标处纹理的颜色值。然后,我们将结果赋值给gl_FragColor
,将片元设置为该颜色。
接下来的两个部分将稍微复杂一些:我们将创建一组新的类,并将桌子的数据和着色器程序的现有代码放入这些类中。然后,我们将在运行时在它们之间切换。
为我们的着色器程序创建类
在本节中,我们将为纹理着色器程序创建一个类,为颜色着色器程序创建另一个类;我们将使用纹理着色器程序绘制桌子,并使用颜色着色器程序绘制木槌。我们还将为通用功能创建一个基类。
让我们首先向ShaderHelper
添加一个辅助方法。打开该类,并在末尾添加以下方法:
fun buildProgram(vertexShaderSource: String, fragmentShaderSource: String): Int {
val vertexShaderId = compileVertexShader(vertexShaderSource)
val fragmentShaderId = compileFragmentShader(fragmentShaderSource)
val program = linkProgram(vertexShaderId, fragmentShaderId)
validateProgram(program)
return program
}
此辅助方法将编译vertexShaderSource和fragmentShaderSource定义的着色器,并将它们链接到一个程序中,它还将验证程序。我们将使用这个辅助方法来构建我们的基类。
创建一个新的包programs,并在该包中创建一个名为ShaderProgram
的抽象类。在类中添加以下代码:
abstract class ShaderProgram(
context: Context,
vertexShaderResId: Int,
fragmentShaderResId: Int,
) {
protected val programId: Int
init {
with(context) {
programId = ShaderHelper.buildProgram(
readStringFromRaw(vertexShaderResId),
readStringFromRaw(fragmentShaderResId)
)
}
}
fun getAttribLocation(name: String): Int = glGetAttribLocation(programId, name)
fun getUniformLocation(name: String): Int = glGetUniformLocation(programId, name)
fun useProgram() {
glUseProgram(programId)
}
}
我们调用刚刚定义的辅助方法,并使用它指定的着色器构建OpenGL着色器程序。
添加TextureShaderProgram类
现在我们将定义一个具体的类来表示我们的纹理着色器程序,包含texture_fragment_shader和texture_vertex_shader,创建一个名为TextureShaderProgram
的新类,继承ShaderProgram
,并在该类中添加以下代码:
class TextureShaderProgram(
context: Context
): ShaderProgram(context, R.raw.texture_vertex_shader, R.raw.texture_fragment_shader) {
private val uMatrixLocation: Int = getUniformLocation(U_MATRIX)
private val uTextureUnitLocation: Int = getUniformLocation(U_TEXTURE_UNIT)
val aPositionLocation: Int = getAttribLocation(A_POSITION)
val aTextureCoordinatesLocation: Int = getAttribLocation(A_TEXTURE_COORDINATES)
companion object {
private const val U_MATRIX = "u_Matrix"
private const val U_TEXTURE_UNIT = "u_TextureUnit"
private const val A_POSITION = "a_Position"
private const val A_TEXTURE_COORDINATES = "a_TextureCoordinates"
}
}
设置Uniform变量
接下来我们添加一个方法来设置Uniform变量的值:
fun setUniforms(matrix: FloatArray, textureId: Int) {
// 传递矩阵的值
glUniformMatrix4fv(uMatrixLocation, 1, false, matrix, 0)
// 将活动纹理单位设置为纹理单位0
glActiveTexture(GL_TEXTURE0)
// 将纹理绑定到该单元上
glBindTexture(GL_TEXTURE_2D, textureId)
// 指定sampler对应的纹理单元
glUniform1i(uTextureUnitLocation, 0)
}
第一步是将矩阵传递到uniform变量,这非常简单,其他的代码则需要更多解释。在OpenGL中使用纹理进行绘制时,我们不需要将纹理直接传递到着色器。相反,我们使用一个纹理单元(texture unit)来保存那个纹理(注意,是texture,不是texture texel)。这样做是因为一个GPU只能同时绘制数量有限的纹理。它使用这些纹理单元来表示当前正在被绘制的活动纹理。
如果需要切换纹理,我们可以在纹理单元中来回切换纹理,但如果切换太频繁,可能会降低渲染速度。我们可以使用多个纹理单元同时绘制多个纹理。
我们首先通过调用glActiveTexture()
将活动纹理单元设置为纹理单元0,然后通过调用glBindTexture()
将纹理绑定到此单元。接着,通过调用glUniform1i(uTextureUnitLocation,0)
,将选定的纹理单元传递给片元着色器中的u_TextureUnit
。
添加ColorShaderProgram类
在同一个包中创建另一个类,并将其称为ColorShaderProgram
。这个类还应该继承ShaderProgram
,在类中添加以下代码:
class ColorShaderProgram(
context: Context
): ShaderProgram(context, R.raw.simple_vertex_shader, R.raw.simple_fragment_shader) {
private val uMatrixLocation: Int = getUniformLocation(U_MATRIX)
val aPositionLocation: Int = getAttribLocation(A_POSITION)
val aColorLocation: Int = getAttribLocation(A_COLOR)
fun setUniforms(matrix: FloatArray) {
glUniformMatrix4fv(uMatrixLocation, 1, false, matrix, 0)
}
companion object {
private const val U_MATRIX = "u_Matrix"
private const val A_POSITION = "a_Position"
private const val A_COLOR = "a_Color"
}
}
我们将使用这个程序来绘制木槌。通过将着色器程序与使用这些程序绘制的数据分离,我们可以更容易地重用代码,我们可以使用颜色着色器程序绘制任何具有颜色属性的对象。
为顶点数据创建新的类
我们将顶点数据分离到不同的类,一个类代表一种类型的物理对象。我们将为桌子和木槌分别创建一个类。我们不需要给那条线创建类,因为我们的纹理上已经有一条线了。
我们还将创建一个独立的类来封装实际的顶点数组,并减少代码重复。我们的类结构结构如下:
我们将创建Mallet
类来管理木槌数据,并创建Table
类来管理桌子数据;每个类都有一个VertexArray
实例,它将封装存储顶点数组的FloatBuffer
。
我们从VertexArray
开始。在项目中创建一个新的包data,并在该包中创建一个名为VertexArray
的新类。在类中添加以下代码:
class VertexArray(
vertexData: FloatArray
) {
private val floatBuffer: FloatBuffer = ByteBuffer
.allocateDirect(vertexData.size * Constants.BYTES_PER_FLOAT)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(vertexData)
/**
* 将[floatBuffer]内的数据作为属性数据传输到OpenGL中
* @param dataOffset 第一个该属性数据的位置
* @param attributeLocation 属性的位置id
* @param componentCount 属性包含多少分量
* @param stride 读取属性时需要的步长
*/
fun setVertexAttribPointer(dataOffset: Int, attributeLocation: Int,
componentCount: Int, stride: Int) {
floatBuffer.position(dataOffset)
glVertexAttribPointer(attributeLocation, componentCount, GL_FLOAT,
false, stride, floatBuffer)
glEnableVertexAttribArray(attributeLocation)
// 将偏移量复原
floatBuffer.position(0)
}
companion object {
private const val BYTES_PER_FLOAT = 4
}
}
然后我们创建一个常量类Constants
存放BYTES_PER_FLOAT
的定义:
object Constants {
const val BYTES_PER_FLOAT = 4
}
我们初始化floatBuffer
的代码与之前在第二章的代码几乎一致。我们还添加了一个传输数据的通用代码。
添加桌子数据
现在我们将定义一个类来存储桌子的位置数据,我们还将添加纹理坐标以将纹理应用于桌子。
添加类常量
创建一个新的包objects
,然后在这个包下创建一个新的类Table
,并添加以下代码:
class Table {
companion object {
private const val POSITION_COMPONENT_COUNT = 2
private const val TEXTURE_COORDINATES_COMPONENT_COUNT = 2
private const val STRIDE: Int = (POSITION_COMPONENT_COUNT
+ TEXTURE_COORDINATES_COMPONENT_COUNT) * Constants.BYTES_PER_FLOAT
}
}
我们定义了位置组件计数、纹理组件计数和步幅。
添加顶点数据
下一步是添加顶点数据:
class Table {
companion object {
...
private val vertexData: FloatArray = floatArrayOf(
// 数据顺序: X, Y, S, T
// 三角形扇形
0f, 0f, 0.5f, 0.5f,
-0.5f, -0.8f, 0f, 0.9f,
0.5f, -0.8f, 1f, 0.9f,
0.5f, 0.8f, 1f, 0.1f,
-0.5f, 0.8f, 0f, 0.1f,
-0.5f, -0.8f, 0f, 0.9f
)
}
}
这个数组包含我们的曲棍球桌的顶点数据。我们已经定义了x和y位置以及S和T纹理坐标。您可能会注意到T分量与Y分量的的位置是相反的。比如第二个顶点,按照位置坐标来说它应该位于左下角,然而我们给出了一个左上角的纹理坐标,这在之前的”什么是纹理“一节有解释,因为图像文件的Y坐标很可能是相反的。虽然我们使用对称纹理时,是否反转T分量实际上并不重要,但在其他情况下它会很重要,所以保持对坐标的反转是一个很好的习惯。
纹理剪裁
我们还使用了0.1f和0.9f作为T坐标。为什么?我们的桌子宽1个单位,高1.6个单位。我们的纹理图像是512 x 1024像素,所以如果宽度对应于1个单位,纹理实际上是2个单位高。为了避免挤压纹理,我们使用范围0.1到0.9来剪裁边缘而不是使用0.0到1.0来接纳全高度的纹理,这样只绘制中心部分,边缘的纹理实际上被“剪去”了。
下图说明了这一概念:
我们也可以不进行纹理剪裁,而是在高度上对纹理进行预拉伸,这样的话就可以使用0.0到1.0之间的纹理坐标来代替剪裁行为,在挤压到空气曲棍球台上后,看起来就是正常的。这样,我们就不会把内存空间浪费在纹理中未显示的部分。下图展示了这一种行为:
初始化并绘制数据
接下来我们在Table
添加一个类变量、将顶点数组绑定到着色器程序的方法、以及一个绘制方法:
class Table {
private val vertexArray: VertexArray = VertexArray(vertexData)
// ...
fun bindData(textureShaderProgram: TextureShaderProgram) {
// 获取程序对象的变量的位置,并绑定vertexArray包含的数据
vertexArray.setVertexAttribPointer(
dataOffset = 0,
attributeLocation = textureShaderProgram.aPositionLocation,
componentCount = POSITION_COMPONENT_COUNT,
stride = STRIDE
)
vertexArray.setVertexAttribPointer(
dataOffset = POSITION_COMPONENT_COUNT,
attributeLocation = textureShaderProgram.aTextureCoordinatesLocation,
componentCount = TEXTURE_COORDINATES_COMPONENT_COUNT,
stride = STRIDE
)
}
fun draw() {
glDrawArrays(GL_TRIANGLE_FAN, 0, 6)
}
}
添加Mallet类
新建Mallet
类并添加以下代码:
class Mallet {
private val vertexArray: VertexArray = VertexArray(vertexData)
fun bindData(colorShaderProgram: ColorShaderProgram) {
vertexArray.setVertexAttribPointer(
dataOffset = 0,
attributeLocation = colorShaderProgram.aPositionLocation,
componentCount = POSITION_COMPONENT_COUNT,
stride = STRIDE
)
vertexArray.setVertexAttribPointer(
dataOffset = POSITION_COMPONENT_COUNT,
attributeLocation = colorShaderProgram.aColorLocation,
componentCount = COLOR_COMPONENT_COUNT,
stride = STRIDE
)
}
fun draw() {
glDrawArrays(GL_POINTS, 0, 2)
}
companion object {
private const val POSITION_COMPONENT_COUNT = 2
private const val COLOR_COMPONENT_COUNT = 3
private const val STRIDE: Int = (POSITION_COMPONENT_COUNT
+ COLOR_COMPONENT_COUNT) * Constants.BYTES_PER_FLOAT
private val vertexData: FloatArray = floatArrayOf(
// 数据顺序: X, Y, R, G, B
// 点
0f, -0.4f, 0f, 0f, 1f,
0f, 0.4f, 1f, 0f, 0f
)
}
}
Mallet
类的逻辑与Table
类大同小异,如果愿意,你还可以继续进行进一步的类层级结构抽象。
绘制纹理
现在我们已经将顶点数据和着色器程序划分为不同的类,让我们更新AirHockeyRenderer
以使用纹理绘制。打开AirHockeyRenderer.kt
,删除除onSurfaceChanged()
之外的所有内容,这是我们唯一不会更改的方法。添加以下成员:
class AirHockeyRenderer5(private val context: Context): GLSurfaceView.Renderer {
/**
* 透视投影矩阵
*/
private val projectionMatrix: FloatArray = FloatArray(16)
/**
* 模型矩阵
*/
private val modelMatrix = FloatArray(16)
private lateinit var table: Table
private lateinit var mallet: Mallet
private lateinit var textureShaderProgram: TextureShaderProgram
private lateinit var colorShaderProgram: ColorShaderProgram
private var texture: Int = 0
...
}
我们保留了两个矩阵变量,还为绘制对象、着色器程序和纹理添加了变量。
初始化变量
让我们在onSurfaceCreated()
添加以下代码来初始化新变量:
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
// 设置Clear颜色
glClearColor(0F, 0F, 0F, 0F)
table = Table()
mallet = Mallet()
textureShaderProgram = TextureShaderProgram(context)
colorShaderProgram = ColorShaderProgram(context)
texture = context.loadTexture(R.drawable.air_hockey_surface)
}
使用纹理来进行绘制
让我们在onDrawFrame()
添加以下代码来绘制桌子和木槌:
override fun onDrawFrame(gl: GL10?) {
// 清除之前绘制的内容
glClear(GL_COLOR_BUFFER_BIT)
// 使用该程序
textureShaderProgram.useProgram()
// 赋值Uniform
textureShaderProgram.setUniforms(matrix = projectionMatrix, textureId = texture)
// 赋值Attribute
table.bindData(textureShaderProgram)
// 绘制
table.draw()
colorShaderProgram.useProgram()
colorShaderProgram.setUniforms(matrix = projectionMatrix)
mallet.bindData(colorShaderProgram)
mallet.draw()
}
运行效果
现在桌子看起来就像下面这样:
您可能会在logcat调试日志中注意到一个错误,如"E/IMGSRV(20095): :0: HardwareMipGen: Failed to generate texture mipmap levels (error=3)"。这可能意味着你的设备调用glGenerateMipMap()
时不支持非正方形纹理。
解决这个问题的一个简单方法是压缩纹理,使其变成正方形。因为纹理被应用到一个矩形表面上,它会被拉伸,所以看起来仍然和以前一样。
本章小结
我们现在知道如何加载纹理并将其显示在桌子上。我们还学习了如何重新组织程序,以便轻松地在多个着色器程序和顶点数组之间进行切换。我们可以通过调整纹理坐标或预先拉伸、压缩纹理图像来对纹理进行调整,以适应它们所绘制的形状。
纹理不会被直接绘制,首先它们要被绑定到纹理单元,然后我们将这些纹理单元传递给着色器。我们还可以在同一个纹理单元内切换不同的纹理,这样就能够在一个场景中绘制不同的纹理,但频繁切换可能会降低绘制性能。我们也可以使用多个纹理单元来同时绘制多个纹理。
练习
尝试加载其他图像,并使用其他纹理单元将此图像与当前图像混合。在片元着色器中将这些值赋值给gl_FragColor
时,可以尝试将它们相加或相乘。
完成这些练习后,我们将在下一章学习如何改进木槌的外观。