Bootstrap

Android OpenGL高度图

在这一篇节中,我们将探索如何利用高度图来为场景添加地形。通过这一过程,我们会接触到OpenGL的新概念,并学习如何利用深度缓冲区来避免不必要的绘制,以及如何通过在GPU上直接存储顶点和索引数组来提升性能。

高度图是一种方便的工具,可以通过常见的绘图软件轻松生成或编辑,用于在场景中创建地形。深度缓冲区是OpenGL中的关键部分,它简化了复杂场景的渲染过程,无需过多关注场景的具体拼接方式。

本章的计划包括以下几个步骤:

  • 首先,我们将学习如何创建高度图,并使用顶点缓冲区对象和索引缓冲区对象将其加载到我们的应用程序中。
  • 接下来,我们会探讨剔除技术和深度缓冲区的使用,这两种技术有助于隐藏那些被其他物体遮挡的部分。

为了继续进行,我们将从上一篇的项目开始。

创建高度图

高度图是一种二维地图,它通过不同的颜色或灰度值来表示地形的高度。它类似于地图集中的地形图,其中亮色区域代表高地,而暗色区域则代表低地。创建高度图的一个简单方法是使用灰度图,其中较亮的区域代表较高的地形,较暗的区域代表较低的地形。由于高度图本质上是一张图片,你可以使用任何常见的绘图软件来绘制自己的高度图,或者从网上下载真实的地形高度图。

对于这个项目,我们将使用一张特定的图作为高度图,如下图,将这张图放置在项目目录下的“/res/drawable-nodpi/”文件夹中。接下来,我们将学习如何将这张高度图的数据加载到我们的项目中。

创建定点和索引缓冲区对象

为了将高度图数据加载到我们的应用程序中,我们需要使用OpenGL中的两个新对象:顶点缓冲区对象(VBO)和索引缓冲区对象(EBO)。这些对象的功能类似于我们在之前章节中使用的顶点数组和索引数组,但它们有一个关键的区别:图形驱动程序可以选择将这些对象直接存储在GPU的内存中。对于像高度图这样一旦创建就很少变化的数据,这种方式可以提供更好的性能。

然而,需要注意的是,缓冲区对象并不总是比传统的数组更快。因此,在决定使用哪种方法时,比较这两种选项的性能差异是值得的。这样可以确保你选择最适合你项目需求的方法。

1.创建一个顶点缓冲区对象

为了将缓冲区对象加载到我们的应用程序中,我们需要编写一些额外的代码。具体来说,我们需要创建一个新的类,命名为VertexBuffer。在这个类中,我们将定义必要的成员变量以及一个构造函数,以便初始化和管理这些缓冲区对象。这样的设计将帮助我们更有效地处理和渲染高度图数据。

class VertexBuffer(vertexData:FloatArray) {
    var bufferId:Int = 0

    init {
        val buffers = IntArray(1)
        GLES20.glGenBuffers(buffers.size,buffers,0)
        if(buffers[0] == 0){
            throw RuntimeException("Could not create a new vertex buffer object.")
        }

        bufferId = buffers[0]

        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER,bufferId)

        val vertexArray = ByteBuffer.allocateDirect(vertexData.size*BYTES_PER_FLOAT)
            .order(ByteOrder.nativeOrder())
            .asFloatBuffer()
            .put(vertexData)

        vertexArray.position(0)

        GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER,vertexArray.capacity()* BYTES_PER_FLOAT,vertexArray,GLES20.GL_STATIC_DRAW)

        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER,0)
    }

    fun setVertexAttribPointer(dataOffset:Int,attributeLocation:Int,componentCount:Int,stride:Int){
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER,bufferId)
        GLES20.glVertexAttribPointer(attributeLocation,componentCount,GLES20.GL_FLOAT,false,stride,dataOffset)
        GLES20.glEnableVertexAttribArray(attributeLocation)
        GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER,0)
    }

}

要将顶点数据发送到顶点缓冲区对象,我们首先需要创建一个新的缓冲区对象。这可以通过调用glGenBuffers()方法来实现,该方法需要一个数组作为参数。因此,我们创建了一个只有一个元素的新数组,用来存储新缓冲区对象的ID。接下来,我们使用glBindBuffer()方法将这个缓冲区绑定到当前的上下文中,并指定GL_ARRAY_BUFFER作为参数,告诉OpenGL这是一个用于存储顶点数据的缓冲区。

与之前使用顶点数组时的操作类似,我们需要先将数据复制到本地内存中,然后再将其发送到缓冲区对象。这可以通过glBufferData()方法来完成。这个方法有几个参数,它们的作用如下(具体细节可以参考下表):

参数说明
int target顶点缓冲区对象应设为GL_ARRAY_BUFFER,索引缓冲区对象应设为GL_ELEMENT_ARRAY_BUFFER
int size数据的大小(以字节为单位)
Buffer dataallocateDirect()创建的一个缓冲区Buffer对象 )
int usage 告诉OpenGL对这个缓冲区对象所期望的使用模式。可用选项包括:
GL_STREAM_DRAW:这个对象只会被修改一次,并且不会被经常使用。
GL_STATIC_DRAW:这个对象将被修改一次,但是会经常使用。
GL_DYNAMIC_DRAW:这个对象将被修改和使用很多次。
这些只是提示,而不是限制,所以OpenGL可以根据需要做任何优化。
大多数情况下,我们都使用GL_STATIC_DRAW

在将数据加载到缓冲区对象后,我们需要确保与该缓冲区解除绑定。这可以通过调用glBindBuffer()方法并传递0作为缓冲区ID来实现。这一步是必要的,否则后续的函数调用可能会受到影响,例如glVertexAttribPointer()可能无法正常工作。

此外,我们封装了glVertexAttribPointer()方法(setVertexAttribPointer()中),类似于之前在构建VertexArray类时所做的,以简化顶点属性指针的设置过程。这样做可以提高代码的可读性和可维护性。在调用glVertexAttribPointer()之前,需要绑定到缓冲区,而且使用了一个稍有不同的glVertexAttribPointer(),它的最后一个参数为int类型,而不是Buffer对象。这个整型参数告诉OpenGL当前属性对应的以字节为单位的偏移值;对于第一个属性,它可能是0,对于其后续的属性,它就是一个指定的字节偏移值。

2.创建一个索引缓存区对象

为了封装索引缓冲区,我们需要再创建一个新的类,命名为IndexBuffer。在这个新类中,我们将复制VertexBuffer的成员变量和构造函数,完整代码如下:

class IndexBuffer(indexData:ShortArray) {
    var bufferId:Int = 0

    init {
        val buffers = IntArray(1)
        GLES20.glGenBuffers(buffers.size,buffers,0)
        if(buffers[0] == 0){
            throw RuntimeException("Could not create a new vertex buffer object.")
        }

        bufferId = buffers[0]

        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER,bufferId)

        val vertexArray = ByteBuffer.allocateDirect(indexData.size* BYTES_PER_SHORT)
            .order(ByteOrder.nativeOrder())
            .asShortBuffer()
            .put(indexData)

        vertexArray.position(0)

        GLES20.glBufferData(GLES20.GL_ELEMENT_ARRAY_BUFFER,vertexArray.capacity()* BYTES_PER_SHORT,vertexArray,GLES20.GL_STATIC_DRAW)

        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER,0)
    }

    fun setVertexAttribPointer(dataOffset:Int,attributeLocation:Int,componentCount:Int,stride:Int){
        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER,bufferId)
        GLES20.glVertexAttribPointer(attributeLocation,componentCount,GLES20.GL_SHORT,false,stride,dataOffset)
        GLES20.glEnableVertexAttribArray(attributeLocation)
        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER,0)
    }

}
  • 使用short[]和ShortBuffer作为数据类型。
  • 使用GL_ELEMENT_ARRAY_BUFFER作为目标,而不是GL_ARRAY_BUFFER。
  • 在Constants类中添加一个值为2的常量BYTES_PER_SHORT,并在调用glBufferData()时使用这个常量来获取数据的字节大小,而不是使用BYTES_PER_FLOAT。

既然配套代码已经到位了,让我们把高度图加载进来。

加载高度图

为了在OpenGL中使用高度图,我们首先需要将图像数据加载进来,并将其转换成一系列的顶点。在这个过程中,每个顶点都会对应图像中的一个像素点。这些顶点的位置将基于它们在图像中的位置,而它们的高度则根据像素的亮度来确定。一旦所有的顶点都被成功加载,我们就可以使用索引缓冲区将这些顶点连接起来,形成OpenGL能够渲染的三角形。这个过程涉及到将二维图像数据转换成三维空间中的几何形状,为后续的渲染做好准备。

1.生成定点数据和索引数据

让我们继续创建一个新类,命名为“Heightmap”,并在类中加入如下代码:

class Heightmap(bitmap: Bitmap) {
    private val POSITION_COMPONENT_COUNT = 3

    var width = 0
    var height = 0
    var numElements = 0
    lateinit var vertexBuffer:VertexBuffer
    lateinit var indexBuffer:IndexBuffer

    init {
        width = bitmap.width
        height = bitmap.height

        if(width*height > 65536){
            throw RuntimeException("Heightmap is too large for the index buffer")
        }

        numElements = calculateNumElements()
        vertexBuffer = VertexBuffer(loadBitmapData(bitmap))
        indexBuffer = IndexBuffer(createIndexData())
    }

    private fun loadBitmapData(bitmap:Bitmap):FloatArray{
        val pixels = IntArray(width*height)
        bitmap.getPixels(pixels,0,width,0,0,width,height)
        bitmap.recycle()

        val heightmapVertices = FloatArray(width*height*POSITION_COMPONENT_COUNT)
        var offset = 0

        for(row in 0 until height){
            for(col in 0 until  width){
                var xPosition = col.toFloat()/(width-1).toFloat() - 0.5f
                var yPosition = Color.red(pixels[row*height+col]).toFloat()/255f
                var zPosition = row.toFloat()/(height-1).toFloat() -0.5f

                Log.d("Heightmap","loadBitmapData yPosition = $yPosition")

                heightmapVertices[offset++] = xPosition
                heightmapVertices[offset++] = yPosition
                heightmapVertices[offset++] = zPosition
            }
        }

        return heightmapVertices
    }

    private fun calculateNumElements():Int{
        return (width-1)*(height-1)*2*3
    }

    private fun createIndexData():ShortArray{
        var indexData = ShortArray(numElements)
        var offset = 0

        for(row in 0 until (height-1)){
            for(col in 0 until (width-1)){
                var topLeftIndexNum = (row*width+col).toShort()
                var topRightIndexNum = (row*width+col+1).toShort()
                var bottomLeftIndexNum = ((row+1)*width+col).toShort()
                var bottomRightIndexNum = ((row+1)*width+col+1).toShort()

                indexData[offset++] = topLeftIndexNum
                indexData[offset++] = bottomLeftIndexNum
                indexData[offset++] = topRightIndexNum

                indexData[offset++] = topRightIndexNum
                indexData[offset++] = bottomLeftIndexNum
                indexData[offset++] = bottomRightIndexNum
            }
        }
        return indexData
    }
}

我们把一个Android的Bitmap对象传递进去,把数据加载进一个顶点缓冲区,并为那些顶点创建了一个索引缓冲区。

loadBitmapData()中,我们首先用Bitmap的getPixels()调用提取所有的像素,然后,回收bitmap,因为我们不需要保存它了。既然每个像素都对应一个顶点,就用那个位图的宽和高为那些顶点创建一个新的数组heightmapVertices。

要生成高度图的每一个顶点,我们首先要计算顶点的位置;高度图在每个方向上都是1个单位宽,且其以x-z平面上的位置(0,0)为中心,因此,通过这些循环,位图的左上角将被映射到(-0.5,-0.5),右下角会被映射到(0.5,0.5)。

我们假定这个图像是灰度图,因此,读入其对应像素的红色分量,并把它除以255,得到高度。一个像素值0对应高度0,而一个像素值255对应高度1。一旦我们计算完了位置和高度,就可以把这个新的顶点写入数组了。

仔细地看一下这个循环。为什么一行一行地读取这个位图,并从左向右扫描每一列呢?我们为什么不一列一列地读取这个位图呢?一行一行地读取位图的原因在于其在内存中的布局方式就是这样的,当CPU按顺序缓存和移动数据时,它们更有效率。

记住存取像素的方式也是很重要的。当我们用getPixels()提取像素时,Android给我们返回一个一维数组。那么我们怎么知道去哪里读入像素呢?可以用下面的公式计算其正确的位置:

像素偏移值=当前行*高度+当前列

通过这个公式,我们可以用两个循环读入一个一维数组,就好像它是一个二维位图一样。

我们通过调用**calculateNumElements()**得出需要多少个索引元素,并把其结果存入numElements。其工作原理是,针对高度图中每4个顶点构成的组,会生成2个三角形,且每个三角形有3个索引,总共需要6个索引。通过用(width-1)乘以(height-1),计算出我们需要多少组,然后,我们只需要用组数乘以每组2个三角形和每个三角形的3个元素就得出所有的元素个数。比如,一个3x3的高度图有(3-1)x(3-1)=2x2=4个组,以及每个组需要2个三角形和每个三角形需要3个元素,总共得到24个元素。

之后就是通过createIndexData()函数生成索引数据。这个方法根据需要的大小创建了一个short类型的数组,然后,通过行和列的循环为每4个顶点构成的组创建三角形。我们甚至不需要实际的像素数据就能做到这一点;我们所需要的只是宽度和高度。如果你尝试存储一个大于32767的索引值,一些有趣的事情就会发生:short类型的转换会使这个数值回绕成一个负数。然而,因为补码的原因,当OpenGL把它们作为无符号值读入的时候,这些负数也会被当作正确的值)。只要索引数组的元素数不超过65535,就不会有问题。

2.需要注意的点

在使用缓冲区对象时,有几个重要的注意事项。虽然Android从2.2版本(Froyo)开始支持OpenGL ES 2.0,但该版本的Java与本地接口(JNI)绑定存在问题,导致无法在Java层面直接使用顶点和索引缓冲区,除非编写自定义的JNI绑定。

好的一点是,这些绑定问题在Android Gingerbread版本中已经得到修复。截至目前应该很少有这类系统了,因此这个问题的影响应该已经不大,没有过去那么严重。

与Java的ByteBuffers类似,如果不正确使用OpenGL缓冲区对象,可能会导致本地应用程序崩溃,而且这种问题很难调试。如果你的应用程序突然消失,并且在Android的日志中看到“Segmentation fault”等错误信息,建议仔细检查所有涉及缓冲区的调用,尤其是glVertexAttribPointer()调用。

绘制高度图

既然已经把高度图加载进来了,让我们把它绘制到屏幕上。

在项目的“/res/raw”文件夹中创建一个新的文件,命名为“heightmap_vertex_shader.glsl”,并加入如下代码:

uniform mat4 u_Matrix;
attribute vec3 a_Position;
varying vec3 v_Color;

void main(){
    v_Color = mix(
      vec3(0.180,0.467,0.153),
      vec3(0.660,0.670,0.680),
      a_Position.y);
    gl_Position = u_Matrix * vec4(a_Position,1.0);
}

这个顶点着色器使用了一个新的着色器函数“mix()”用来在两个不同的颜色间做平滑插值。我们配置了高度图,使其高度处于0和1之间,并使用这个高度作为两个颜色之间的比例。高度图在接近底部的地方呈现绿色,在接近顶部的地方显示灰色。

继续加入一个名为“heightmap_fragment_shader.glsl”的片段着色器文件:

precision mediump float;

varying vec3 v_Color;

void main(){
    gl_FragColor = vec4(v_Color,1.0);
}

要在Java中封装这个着色器,创建一个名为“HeightmapShaderProgram”的新类,它使用与之前其他着色器类同样的模式。
完整代码如下:

class HeightmapShaderProgram:ShaderProgram {
    private var uMatrixLocation = 0

    var aPositionLocation = 0

    constructor(context: Context):super(context, R.raw.heightmap_vertex_shader, R.raw.heightmap_fragment_shader){
        uMatrixLocation = findUniformLocationByName(U_MATRIX)

        aPositionLocation = findAttribLocationByName(A_POSITION)
    }

    fun setUniforms(matrix:FloatArray){
        GLES20.glUniformMatrix4fv(uMatrixLocation,1,false,matrix,0)
    }
}

让我们继续回到Heightmap并添加如下代码:

    fun bindData(heightmapProgram:HeightmapShaderProgram){
        vertexBuffer.setVertexAttribPointer(0,heightmapProgram.aPositionLocation,POSITION_COMPONENT_COUNT,0)
    }

    fun draw(){
        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER,indexBuffer.bufferId)
        GLES20.glDrawElements(GLES20.GL_TRIANGLES,numElements,GLES20.GL_UNSIGNED_SHORT,0)
        GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER,0)
    }

我们将使用bindData()告诉OpenGL当调用draw()时去哪里获取数据。在draw()中,我们告诉OpenGL使用索引缓冲区绘制数据。这个调用使用的glDrawElements()与前面使用的那个有点不同:就像用glVertexAttribPointer()一样,最后一个参数使用了一个int类型的偏移,而不是Buffer对象的引用,这用来告诉OpenGL从哪个索引开始读取数据。

与以前一样,我们也会在使用之前绑定缓冲区,并确保在使用之后解除绑定。

1.更新Renderer类

因为所有的组件都准备好了,让我们前往Renderer把它们整合在一起。首先需要在类的顶部加入两个新的矩阵,把矩阵列表更新为如下代码:

    var modelMatrix = FloatArray(16)
    var viewMatrix = FloatArray(16)
    var viewMatrixForSkybox = FloatArray(16)
    var projectionMatrix = FloatArray(16)

    var tempMatrix = FloatArray(16)
    var modelViewProjectionMatrix = FloatArray(16)

还需要为高度图和高度图着色器程序加入两个新的成员变量:

    lateinit var heightmapProgram:HeightmapShaderProgram
    lateinit var heightmap: Heightmap

在 onSurfaceCreated()中初始化它们:

        heightmapProgram = HeightmapShaderProgram(context)
        heightmap = Heightmap(context.resources.getDrawable(R.drawable.heightmap).toBitmap())

为了减少矩阵代码复制和粘贴的使用量,需要做更多的改动。为此,我们使用一个矩阵代表相机,它应用于所有的物体,再为天空盒定义一个矩阵,它只表示旋转。

在类中加入一个名为“updateViewMatrices()”的新方法,如下代码所示:

    private fun updateViewMatrix(){
        Matrix.setIdentityM(viewMatrix,0)
        Matrix.rotateM(viewMatrix,0,-yRotation,1f,0f,0f)
        Matrix.rotateM(viewMatrix,0,-xRotation,0f,1f,0f)
        System.arraycopy(viewMatrix,0,viewMatrixForSkybox,0,viewMatrix.size)

        Matrix.translateM(viewMatrix,0,0f,-1.5f,-7f)
    }

通过这段代码,我们就可以使用viewMatrix一起旋转和平移高度图和粒子了,我们也可以使用viewMatrixForSkybox旋转天空盒。

我们曾在前面篇节介绍过矩阵的层次结构,这里也遵循同样的概念。我们要使用这个新的方法,在handleTouchDrag()的结尾处加入updateViewMatrices()调用。在onSurfaceChanged()中,我们也需要用另外一个updateViewMatrices()调用替换perspectiveM()调用之后的代码,如下代码所示:

    override fun onSurfaceChanged(p0: GL10?, width: Int, height: Int) {
        GLES20.glViewport(0,0,width,height)
        Matrix.perspectiveM(projectionMatrix,0,45f,width.toFloat()/height.toFloat(),1f,20f)
        updateViewMatrix()
    }

我们还需要两个新的辅助方法把这些矩阵相乘形成一个最终合并在一起的模型-视图投影矩阵,这两个方法分别用于绘制天空盒和其他物体。在类中加入如下两个方法:

    private fun updateMvpMatrix(){
        Matrix.multiplyMM(tempMatrix,0,viewMatrix,0,modelMatrix,0)
        Matrix.multiplyMM(modelViewProjectionMatrix,0,projectionMatrix,0,tempMatrix,0)
    }

    private fun updateMvpMatrixForSkybox(){
        Matrix.multiplyMM(tempMatrix,0,viewMatrixForSkybox,0,modelMatrix,0)
        Matrix.multiplyMM(modelViewProjectionMatrix,0,projectionMatrix,0,tempMatrix,0)
    }

我们需要使用一个临时矩阵存储其中间结果,因为如果使用同一个矩阵既作为最终的结果又作为一个操作数会弄乱这个矩阵。现在可以用下面的代码替换掉drawSkybox()中现有的与矩阵相关的代码了:

        Matrix.setIdentityM(modelMatrix,0)
        updateMvpMatrixForSkybox()

对于drawParticles(),也用下面的代码替换掉其现有的与矩阵相关的代码:

        Matrix.setIdentityM(modelMatrix,0)
        updateMvpMatrix()

一旦完成了这些,就可以更新天空盒和粒子的setUniforms()调用了,用modelView-ProjectionMatrix替换掉那些遗漏的viewProjectionMatrix引用。现在要处理相机的旋转,并用视图矩阵把物体平移放进场景中,因此,我们不再需要为每个物体复制和粘贴与矩阵设置相关的代码了。通过调用setIdentityM(modelMatrix,0),我们要把模型矩阵重置为标准矩阵,实际上这个标准矩阵什么都没做,因此,当我们在updateMvpMatrix()中把所有矩阵乘在一起时,只有视图矩阵和投影矩阵起作用。

2.绘制高度图

搞清楚了这些矩阵,让我们继续绘制高度图!

在onDrawFrame()中,就在调用glClear()之后加入调用drawHeightmap()。加入的这个方法的内容如下:

    private fun drawHeightmap(){
        Matrix.setIdentityM(modelMatrix,0)
        Matrix.scaleM(modelMatrix,0,20f,2f,20f)
        updateMvpMatrix()
        heightmapProgram.useProgram()
        heightmapProgram.setUniforms(modelViewProjectionMatrix)
        heightmap.bindData(heightmapProgram)
        heightmap.draw()
    }
    

因为我们不想让这个山太突兀,我们用模型矩阵使高度图在x和z方向上变宽20倍,而在y方向上只变高2倍。我们知道着色器中的颜色插值依赖于顶点所在位置的y值,这不会扰乱它吗?不会的,因为着色器读入顶点位置的时间是在我们把它与矩阵相乘之前。

我们还需要更新投影矩阵给出足够的空间,因此,在onSurfaceChanged()中,把perspectiveM()的最后一个参数改为20f;这会设置投影,以使我们可以在被远平面剪裁之前的最大100个单位空间上绘制东西。

遮罩隐藏的物体

现在,一切都准备就绪了,让我们运行这个应用程序,看看会发生什么。你可能会惊讶地发现地形图竟然没有显示出来!检查一下onDrawFrame(),看看哪里出错了:

    override fun onDrawFrame(p0: GL10?) {
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
        drawHeightmap()
        drawSkybox()
        drawParticles()
    }

我们首先绘制了高度图,然后就在其后面绘制了天空盒,这个天空盒擦除了所有之前绘制的东西。切换这两个绘图调用函数,使天空盒先画出来,然后再让高度图绘制出来,看看会发生什么?

地形图现在应该显示出来了,但是,你可能看见一些奇怪的效果(图略)。之所以会这样,是因为地形图本身也有重复绘制的问题:没有考虑地形的哪些部分与观察者更近,其不同的部分彼此重叠绘制了。你可能也注意到了,粒子仍然掉进了“地里”,这也不太合理了。

1.用深度缓冲区消除隐藏面

我们能否把所有的三角形排序呢?以便可以按照从后向前的顺序绘制东西,使它们看起来与期望的一样。这是一个可能的解决方案,但是会碰到两大问题:首先,绘制的顺序依赖于当前的观察点,使它的计算很复杂;其次,这个方案也浪费,因为将花费大量的时间绘制那些从来不会被看见的东西,它们会被近处的东西覆盖。

OpenGL用深度缓冲区技术为我们提供了一个更好的解决方案,它是一个特殊的缓冲区,用于记录屏幕上每个片段的深度。当这个缓冲区打开时,OpenGL会为每个片段执行深度测试算法:如果片段比已经存在的片段更近,就绘制它;否则,就丢掉它。

让我们反转onDrawFrame()的绘制顺序,以使高度图首先被绘制,然后,在onSurfaceCreated()内,让我们在调用glClearColor()后面添加调用glEnable(GL_DEPTH_TEST),打开深度缓冲区功能。我们也需要在onDrawFrame()中把glClear()调用更新为glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)。这告诉OpenGL在每个新帧上也要清空深度缓冲区。

让我们再次运行这个应用,看看能得到什么。地形现在没问题了,粒子也不再落入地里了,但是,天空盒现在相当混乱(图略)。

深度测试

在前面的篇节中,我们设置天空盒着色器程序,使其在远平面上进行绘制。现在,由于开启了深度测试功能,OpenGL默认只渲染那些比其他片段更近或者接近远平面的片段。理论上我们不应该看到天空盒的任何部分,但由于浮点数精度问题,天空盒的部分内容仍然被显示出来。

为了解决这个问题,我们有两个选择:一是修改天空盒着色器,使其稍微靠近一些进行绘制;二是调整深度测试算法,让那些片段能够通过测试。在drawSkybox()函数中,我们可以按照以下方式修改深度测试算法:

 GLES20.glDepthFunc(GLES20.GL_LEQUAL)
 //《draw() 》
 GLES20.glDepthFunc(GLES20.GL_LESS)

通常情况下,深度测试使用的是比较运算符GL_LESS,这表示只有当新的片段比场景中已存在的片段更接近观察者,或者比远平面更近时,才会绘制这个新片段。为了解决天空盒显示的问题,我们可以将深度测试的比较运算符从GL_LESS更改为GL_LEQUAL。这样,如果新片段与场景中已存在的片段距离相同,或者更近,它也会被绘制。

在绘制天空盒之后,我们需要将深度测试的比较运算符重置回GL_LESS,以确保场景中的其他对象按照预期的方式进行绘制。

深度缓冲区和半透明物体

如果我们重新运行应用程序,天空盒的显示效果应该会有所改善。然而,如在下图中所示,粒子系统还存在一些异常。粒子现在被地面裁剪掉了,而且它们之间似乎相互遮挡,这并不符合我们期望的效果,因为我们希望粒子是半透明的,并且能够相互混合。

为了解决这个问题,我们需要找到一种方法,使得粒子在接触地面时不会被相互遮挡,同时还要确保它们能够被正确裁剪。这意味着我们需要调整粒子的渲染方式,以确保它们的半透明效果得以保留,同时在与地面接触的地方能够被适当地裁剪。

在OpenGL中,我们可以通过保持深度测试功能开启的同时禁用深度写入操作来实现粒子系统的期望效果。这样做意味着粒子将进行深度测试以确定是否应该被绘制,但它们的深度信息不会被写入到深度缓冲区中,从而避免了相互之间的遮挡。

为了实现这一点,我们需要在绘制粒子的函数drawParticles()中进行调整,关闭深度写入操作。这样,粒子可以正确地进行深度测试,但它们的深度信息不会影响后续的绘制操作,因为我们是在场景的最后阶段绘制粒子,所以这种方法是可行的。

GLES20.glDepthMask(false)
//《draw() 》
GLES20.glDepthMask(true)

再次运行,可以看到和我们预期的一致了。

高度缓冲区和透视除法

深度缓冲区保存了经过透视除法处理后的深度值,这导致深度值与距离之间形成了一种非线性的关系。这种关系意味着在靠近近平面的区域,深度的精度较高,但是随着距离的增加,精度逐渐下降,这可能会导致一些渲染问题。因此,在设置透视投影时,近平面和远平面之间的距离比应该保持在场景所需的范围内。例如,如果近平面设为1,远平面设为100,通常不会有问题。但如果近平面设为0.001,而远平面设为100000,则可能会引发问题。

2.剔除

OpenGL提供了一种提高渲染效率的方法,即通过启用剔除技术来消除不可见的表面。默认情况下,OpenGL会渲染所有多边形的两个面。为了演示这一点,我们可以将视点移动到高度图的正下方,这样从内部向外观察地形,会看到一些不寻常的效果。

由于渲染地形的下方通常是没有必要的,我们可以指示OpenGL关闭双面绘制,从而减少渲染的开销。为此,我们可以在onSurfaceCreated()方法中添加glEnable(GL_CULL_FACE)的调用。这样,OpenGL会检查每个三角形的顶点顺序,如果从观察点看是逆时针的,那么这个三角形就会被渲染;如果不是,它就会被剔除。

在定义物体时,我们已经确保了它们的顶点顺序对于观察者来说是正确的,因此一旦启用了剔除功能,它就可以正常工作,无需额外的操作。

Renderer完整源码:

class ParticlesRenderer:Renderer {

    var context:Context

    var modelMatrix = FloatArray(16)
    var viewMatrix = FloatArray(16)
    var viewMatrixForSkybox = FloatArray(16)
    var projectionMatrix = FloatArray(16)

    var tempMatrix = FloatArray(16)
    var modelViewProjectionMatrix = FloatArray(16)

    lateinit var particleProgram:ParticleShaderProgram
    lateinit var particleSystem: ParticleSystem
    lateinit var redParticleShooter: ParticleShooter
    lateinit var greenParticleShooter: ParticleShooter
    lateinit var blueParticleShooter: ParticleShooter
    var globalStartTime:Long = 0L

    var texture:Int = 0

    lateinit var skyboxProgram: SkyboxShaderProgram
    lateinit var skybox: Skybox
    var skyboxTexture:Int = 0

    lateinit var heightmapProgram:HeightmapShaderProgram
    lateinit var heightmap: Heightmap

    constructor(context: Context){
        this.context = context
    }

    override fun onSurfaceCreated(p0: GL10?, p1: EGLConfig?) {
        GLES20.glClearColor(0.0f,0.0f,0.0f,0.0f)
        GLES20.glEnable(GLES20.GL_DEPTH_TEST)
        GLES20.glEnable(GLES20.GL_CULL_FACE )

        val angleVarianceInDegrees = 5f
        val speedVariance = 1f;

        particleProgram = ParticleShaderProgram(context)
        particleSystem = ParticleSystem(10000)
        globalStartTime = System.nanoTime()

        val particleDirection = Vector(0f,0.5f,0f)

        redParticleShooter = ParticleShooter(Point(-1f,0f,0f),particleDirection, Color.rgb(255,50,5),angleVarianceInDegrees,speedVariance)
        greenParticleShooter = ParticleShooter(Point(0f,0f,0f),particleDirection, Color.rgb(25,255,25),angleVarianceInDegrees,speedVariance)
        blueParticleShooter = ParticleShooter(Point(1f,0f,0f),particleDirection, Color.rgb(5,50,255),angleVarianceInDegrees,speedVariance)

        texture = TextureHelper.loadTexture(context,R.drawable.particle_texture)

        skyboxProgram = SkyboxShaderProgram(context)
        skybox = Skybox()
        skyboxTexture = TextureHelper.loadCubeMap(context, intArrayOf(
            R.drawable.left,R.drawable.right,
            R.drawable.bottom,R.drawable.top,
            R.drawable.front,R.drawable.back
        ))

        heightmapProgram = HeightmapShaderProgram(context)
        heightmap = Heightmap(context.resources.getDrawable(R.drawable.heightmap).toBitmap())
    }

    override fun onSurfaceChanged(p0: GL10?, width: Int, height: Int) {
        GLES20.glViewport(0,0,width,height)
        Matrix.perspectiveM(projectionMatrix,0,45f,width.toFloat()/height.toFloat(),1f,20f)
        updateViewMatrix()
    }

    override fun onDrawFrame(p0: GL10?) {
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
        drawSkybox()
        drawHeightmap()
        drawParticles()
    }

    private fun drawSkybox(){
        Matrix.setIdentityM(modelMatrix,0)
        updateMvpMatrixForSkybox()

        GLES20.glDepthFunc(GLES20.GL_LEQUAL)
        skyboxProgram.useProgram()
        skyboxProgram.setUniforms(modelViewProjectionMatrix,skyboxTexture)
        skybox.bindData(skyboxProgram)
        skybox.draw()
        GLES20.glDepthFunc(GLES20.GL_LESS)
    }

    private fun drawParticles(){
        var currentTime = (System.nanoTime() - globalStartTime) / 1000000000f

        redParticleShooter.addParticles(particleSystem,currentTime,2)
        greenParticleShooter.addParticles(particleSystem,currentTime,2)
        blueParticleShooter.addParticles(particleSystem,currentTime,2)

        Matrix.setIdentityM(modelMatrix,0)
        updateMvpMatrix()

        GLES20.glEnable(GLES20.GL_BLEND)
        GLES20.glBlendFunc(GLES20.GL_ONE,GLES20.GL_ONE)
        GLES20.glDepthMask(false)

        particleProgram.useProgram()
        particleProgram.setUniforms(modelViewProjectionMatrix,currentTime,texture)
        particleSystem.bindData(particleProgram)
        particleSystem.draw()

        GLES20.glDepthMask(true)
        GLES20.glDisable(GLES20.GL_BLEND)
    }

    private fun drawHeightmap(){
        Matrix.setIdentityM(modelMatrix,0)
        Matrix.scaleM(modelMatrix,0,20f,2f,20f)
        updateMvpMatrix()
        heightmapProgram.useProgram()
        heightmapProgram.setUniforms(modelViewProjectionMatrix)
        heightmap.bindData(heightmapProgram)
        heightmap.draw()
    }

    private fun updateViewMatrix(){
        Matrix.setIdentityM(viewMatrix,0)
        Matrix.rotateM(viewMatrix,0,-yRotation,1f,0f,0f)
        Matrix.rotateM(viewMatrix,0,-xRotation,0f,1f,0f)
        System.arraycopy(viewMatrix,0,viewMatrixForSkybox,0,viewMatrix.size)

        Matrix.translateM(viewMatrix,0,0f,-1.5f,-7f)
    }

    private fun updateMvpMatrix(){
        Matrix.multiplyMM(tempMatrix,0,viewMatrix,0,modelMatrix,0)
        Matrix.multiplyMM(modelViewProjectionMatrix,0,projectionMatrix,0,tempMatrix,0)
    }

    private fun updateMvpMatrixForSkybox(){
        Matrix.multiplyMM(tempMatrix,0,viewMatrixForSkybox,0,modelMatrix,0)
        Matrix.multiplyMM(modelViewProjectionMatrix,0,projectionMatrix,0,tempMatrix,0)
    }

    var previousX = 0f
    var previousY = 0f
    var xRotation = 0f
    var yRotation = 0f
    fun onTouch(event:MotionEvent){
        if(event.action == MotionEvent.ACTION_DOWN){
            previousX = event.x
            previousY = event.y
        }else if(event.action == MotionEvent.ACTION_MOVE){
            var deltaX = event.x - previousX
            var deltaY = event.y - previousY
            previousX = event.x
            previousY = event.y

            xRotation += deltaX/32f
            yRotation += deltaY/32f

            if(yRotation<-90f){
                yRotation = -90f
            }else if(yRotation>90){
                yRotation = 90f
            }

            updateViewMatrix()
        }
    }
}

小结

在本篇,我们探讨了如何从文件中读取高度图,并将其转换成OpenGL能够渲染的格式,这涉及到了使用顶点缓冲区对象和索引缓冲区对象。高度图是表示地形的一种有效方法,但它们主要基于二维图像,因此难以表现地形中的空洞,如洞穴或拱形结构。此外,高度图的分辨率受到源图像的尺寸和像素精度的限制。

我们同样讨论了深度测试和剔除技术,这些技术有助于我们更准确地渲染物体,同时也提高了渲染性能。在之前的章节中,我们提到了将天空盒放置在远平面上,并确保它在其他物体之后被绘制,这样做可以提升性能。原因之一是,使用深度缓冲区时,GPU会丢弃不可见的片段,从而节省渲染时间。另一个原因是许多移动设备的GPU采用基于图块的渲染方式,这使得它们能够快速丢弃大量隐藏的几何体。

此外,利用硬件剔除技术将天空盒等物体绘制在所有不透明物体之后也是一个好策略,因为这样GPU可以识别出大部分天空盒被地形遮挡,从而跳过这些部分的渲染,节省时间。

;