Android OpenGL天空盒

在我们的项目学习过程中,我们从一片漆黑的虚空开始构建。为了给这个世界增添一些色彩,我们加入了三个粒子喷泉,但即便如此,我们的世界依然大部分被黑暗和虚无所笼罩。这些喷泉仿佛悬浮在无尽的黑暗之中,没有边界,粒子也似乎会永远地坠落。

接下来的任务是为我们的场景添加一个背景,这将大大提升视觉效果。许多游戏和动态壁纸都采用了二维艺术与三维技术的结合来创造背景。在本教程中,我们将学习如何使用天空盒技术将喷泉置于一个天空的背景下。天空盒是一种能够提供360度全景的技术,它最早在20世纪90年代末的游戏中出现,并且至今仍被广泛使用。不仅仅是在游戏中,比如全景地图,也利用了这项技术来展示全方位的景象。

本篇的学习计划如下:

  • 我们将学习立方体贴图技术,这是一种将六个不同的纹理贴到一个立方体上,以此来定义天空盒的方法。
  • 我们还将首次接触索引数组的概念,它是一种优化技术,通过存储唯一的顶点并使用索引来引用这些顶点,从而减少顶点数据的冗余。这种方法不仅可以减少内存的使用,而且在有很多顶点可以重用的情况下,还能提高性能。

让我们将会在上一篇项目的基础上继续。

创建天空盒

天空盒是一种用于创建三维全景效果的方法,它能够让你无论头转向那边,这个全景在任何方向上都能被看见。实现这种效果的传统方法是通过一个围绕观察者的立方体,在其每个面上都贴上细致的纹理,这种做法也被称作立方体贴图。在绘制天空盒时,要确保它始终位于场景中所有其他物体的后面。

当我们使用天空盒的时候,用一个立方体表示天空,因此,我们通常会期望天空就像一个巨大的立方体,它的每个面之间都有非常明显的连接处。然而,我们有可以使立方体的边缘消失的技巧:立方体的每个面都是用90度视野和平面投影拍摄的一幅画,我们期望这个投影与OpenGL本身用的是同一类型。我们把观察者置于立方体的中间,并使用一个常规视野的透视投影,此时,一切都排列得恰到好处,立方体的边缘消失了,我们得到了一个无缝的全景图。

尽管我们用立方体来模拟天空,但通常我们并不希望看到立方体各个面之间的明显接缝。为了解决这个问题,我们采用了一种技巧:每个面的纹理图像都是在90度视野角和平面投影下拍摄的。这样,当我们将观察者置于立方体中心,并使用常规的透视投影时,立方体的边缘就会消失,从而呈现出一个无缝的全景图。

尽管这些图像本身是用90度视野创建的,但OpenGL场景自身可以使用的角度范围很广,比如45度或者60度。因为OpenGL透视投影的直线性质会引起边缘周围的扭曲程度增加,只要不使用过大的角度,这个技巧就能工作得很好。

在下面的图中,我们可以看到一个例子,一个立方体的六个面的贴图彼此相邻排列,它显示了每个面如何融入其相邻的面。

天空和技术的有点和缺点

尽管立方体贴图十分适合于实现一个简单的天空盒,但它也有简单所带来的一些缺点。如果天空盒是从立方体的中心被渲染的,这个效果才会很好,因此,对于天空盒中的任何部分,不管它们离得多远,从观察者的角度来说,它们总是处于同样的距离。因为天空盒是由预先渲染的纹理组成的,这个场景也必然是静态的,其中的云并不移动。根据观察者的视野的不同,要想让这些立方体贴图的纹理在屏幕上看起来不错,它们也许需要非常高的分辨率,这会消耗很多纹理内存和带宽。

许多现代的游戏和应用程序通常会变通地解决这些限制,它们使用自己的动态元素和云的独立的三维场景实现传统的天空盒技术。这个独立的三维场景还是在所有其他物体后面被渲染出来,但是相机可以围绕这个独立的场景移动,而且场景内部的元素可以有动画效果,给观察者处于一个巨大的三维世界中的幻象。

要在场景中创建天空盒,我们要把立方体的每个面都存储在一个单独的纹理中,然后,告诉OpenGL把这些纹理拼接成立方体贴图,并把它应用在一个立方体上。当OpenGL使用线性插值渲染这个立方体时,许多GPU实际上会把相邻面之间的纹理单元混合起来,以使立方体的边缘看不出缝隙。事实上,OpenGLES3.0版本保证支持无缝立方体贴图。

立方体贴图加载到OpenGL

本篇我们将使用以下资源贴图资源:

贴图对应立方体顺序为:左、右、上、下、前、后。

让我们给TextureHelper类加入一个新方法,把这些纹理加载到一个OpenGL的立方体贴图上。这个方法被称为loadCubeMap(),完整代码如下:

    fun loadCubeMap(context: Context,cubeResources:IntArray):Int{
        //向OpenGL申请一个纹理对象
        val textureObjectIds = IntArray(1)
        GLES20.glGenTextures(1,textureObjectIds,0)
        if(textureObjectIds[0] == 0){
            if(LoggerConfig.ON){
                Log.w(TAG,"Could not generate a new OpenGL texture object.")
            }
            return 0
        }
        //依次加载6个图像到内存中
        val options = BitmapFactory.Options()
        options.inScaled = false

        val cubeBitmaps = arrayOfNulls<Bitmap>(6)
        for(i in 0 until 6){
            cubeBitmaps[i] = BitmapFactory.decodeResource(context.resources,cubeResources[i],options)
            if(cubeBitmaps[i] == null){
                if(LoggerConfig.ON){
                    Log.w(TAG,"Resource ID ${cubeResources[i]} could not be decoded.")
                }
                GLES20.glDeleteTextures(1,textureObjectIds,0)
                return 0
            }
        }

        //通过GL_TEXTURE_CUBE_MAP绑定纹理对象
        GLES20.glBindTexture(GLES20.GL_TEXTURE_CUBE_MAP,textureObjectIds[0])

        //设置纹理双线性过滤
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_CUBE_MAP,GLES20.GL_TEXTURE_MIN_FILTER,GLES20.GL_LINEAR)
        GLES20.glTexParameteri(GLES20.GL_TEXTURE_CUBE_MAP,GLES20.GL_TEXTURE_MAG_FILTER,GLES20.GL_LINEAR)
        //按照预定顺序对立方体绑定对应的贴图
        GLUtils.texImage2D(GLES20.GL_TEXTURE_CUBE_MAP_NEGATIVE_X,0,cubeBitmaps[0],0)
        GLUtils.texImage2D(GLES20.GL_TEXTURE_CUBE_MAP_POSITIVE_X,0,cubeBitmaps[1],0)

        GLUtils.texImage2D(GLES20.GL_TEXTURE_CUBE_MAP_NEGATIVE_Y,0,cubeBitmaps[2],0)
        GLUtils.texImage2D(GLES20.GL_TEXTURE_CUBE_MAP_POSITIVE_Y,0,cubeBitmaps[3],0)

        GLUtils.texImage2D(GLES20.GL_TEXTURE_CUBE_MAP_NEGATIVE_Z,0,cubeBitmaps[4],0)
        GLUtils.texImage2D(GLES20.GL_TEXTURE_CUBE_MAP_POSITIVE_Z,0,cubeBitmaps[5],0)
        //一切处理完毕解绑纹理对象
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,0)
        //释放Bitmap
        cubeBitmaps.forEach { it?.recycle() }

        return textureObjectIds[0]
    }

当调用这个方法时,我们传递进去6个图像资源,其中每一个都对应立方体的一个面。这个顺序有很大影响,因此我们将以一个标准顺序使用这些图像,它的顺序可以在这个方法的注释中记录下来。开始,就像加载正常的纹理一样,我们创建了一个OpenGL纹理对象。当我们从资源中加载图像并把它们传输给OpenGL时,我们用了6个位图对象临时在内存中保存这些图像资源数据。

之后我们把所有6个图像都解码到内存中,加载过程中要确保每次加载都是成功的。

因为每个立方体贴图的纹理都总是从同一个视点被观察,没必要使用前面篇节中所描述的MIP贴图技术,所以我们只使用常规的双线性过滤,也能节省纹理内存。如果立方体贴图的分辨率比设备分辨率高很多,在把纹理加载到OpenGL之前,我们也可以把它们都缩小。

再然后把每张图像与其对应的立方体贴图的面关联起来。当我们调用这个方法时,我们要以左、右、下、上、前和后的顺序传递立方体的面。只要这个方法的调用者也按同样的顺序传递那些面,这里就没有问题。

我们首先把左面和右面的纹理贴到x轴的负面和正面,把下面和上面的纹理贴到y轴的负面和正面,再把前面的纹理贴到z轴的负面,后面的纹理贴到z轴的正面,我们似乎是在使用左手坐标系统。立方体贴图的惯例是:在立方体内部使用左手坐标系统,而在立方体外部使用右手坐标系统。当我们渲染天空盒时,这很重要,如果我们搞混了这两个惯例,天空盒看起来就像翻转了z轴。

创建立方体

下一个任务是为天空盒创建一个新的立方体对象,这需要创建我们的第一个索引数组。因为一个立方体只有8个相互独立的顶点,它很适合使用索引技术。每个顶点有3个位置分量,要存储这些顶点,我们需要24个浮点数。

假设我们决定每个面用2个三角形来绘制这个立方体,我们就总共需要12个三角形。每个三角形有3个顶点,如果我们只用一个顶点数组绘制这个立方体,我们将需要36个顶点或者108个浮点数,其中很多数据是重复的。通过使用一个索引数组,就不用重复所有的顶点数据了。相反,只需要重复那些索引值。这允许我们减少数据的整体大小。

让我们创建一个名为"Skybox"的新类,完整代码如下:

class Skybox{

    private val POSITION_COMPONENT_COUNT = 3
    private val vertexArray:VertexArray
    private val indexArray:ByteBuffer

    init {
        vertexArray = VertexArray(
            floatArrayOf(
                -1f,1f,1f,     
                 1f,1f,1f,     
                -1f,-1f,1f,    
                1f,-1f,1f,     
                -1f,1f,-1f,    
                1f,1f,-1f,     
                -1f,-1f,-1f,   
                1f,-1f,-1f     
            )
        )

        indexArray = ByteBuffer.allocateDirect(6*6).put(
            byteArrayOf(
                //front
                 1,3,0,
                0,3,2,
                
                //back
                4,6,5,
                5,6,7,
                
                //left
                0,2,4,
                4,2,6,
                
                //right
                5,7,1,
                1,7,3,
                
                //top
                5,1,4,
                4,1,0,
                
                //bottom
                6,2,7,
                7,2,3
            )
        )

        indexArray.position(0)
    }

    fun bindData(skyboxProgram: SkyboxShaderProgram){
        vertexArray.setVertexAttribPointer(0,skyboxProgram.aPositionLocation,POSITION_COMPONENT_COUNT,0)
    }

    fun draw(){
        GLES20.glDrawElements(GLES20.GL_TRIANGLES,36,GLES20.GL_UNSIGNED_BYTE,indexArray)
    }
}

这里的vertexArray保存我们的顶点数组,而indexArray使用ByteBuffer保存索引数组。

这个索引数组使用索引偏移值指向每个顶点。比如,0指向顶点数组中的第一个顶点,且1指向第二个顶点。通过这个索引数组,我们把所有顶点分别绑定成三角形组,每个组有立方体上每个面的2个三角形。

通过一个索引数组,我们可以用位置指向每个顶点,而不用一遍又一遍地重复同一个顶点数据。

bindData()方法是标准的.

要绘制立方体,我们需要调用glDrawElements(GL_TRIANGLES,36,GL_UNSIGNED_BYTE,indices),它告诉OpenGL绘制我们在bindData()中绑定的顶点,它使用了indices所定义的索引数组,并把这个数组解释为无符号字节数(unsignedbyte)。在OpenGLES2中,indices需要是无符号字节数(范围为0-255的8位整型数)或者无符号短整型数(范围为0~65535的16位整型数)。

之前已经把索引数组定义为ByteBuffer了,因此我们要告诉OpenGL把这个数据理解为无符号字节数据流。Java的字节类型(byte)实际上是有符号的(signed),意味着它的范围是从-128到127,但是只要我们一直使用这个范围的正值部分,我们就不会碰到问题。

有符号和无符号的转换

有时候,使用有符号数据类型的取值范围是不够用的。当我们使用无符号数时,我们可以存储从0到255的数值。然而,Java通常只让我们用-128到127范围内的数值,255绝对在这个范围之外了。尽管我们不能写这样的代码"byteb=255;",但还是有方法欺骗Java,让它把这个数值解释为一个有符号数。

让我们看一下255的二进制值:在二进制中,我们会把这个数值表示为"11111111"。我们只需要找到一种方法,告诉Java把这些位放到其字节中。事实证明,我们可以做到这一点,我们可以使用Java的位掩码技术屏蔽掉数值255的最后8位,看看下面的代码:

byte b=(byte)(255& 0xff);

这个方法有效,因为Java会把数字255解释为一个32位的整型值,这个整型值足以容纳数值255。然而,一旦我们把255赋值给字节变量,Java实际上会把这个字节变量的值当作-1,而不是255,因为对Java来说,字节是有符号的,它会把这个值用补码表示。然而, OpenGL或任何其他使用无符号数值的地方都会把这个字节变量的值看作255。

要把这个值在Java中读回来,我们不能直接就去读它,因为Java把它当作-1,我们需要较大的数据类型来容纳这个结果。举个例子,我们可以用如下的代码做这个转换:

short s=(short)(b& Oxff);

我们不能只使用"short s=b;",因为Java会做符号扩展(sign extension),s的值还是-1。通过告诉Java屏蔽最后8位,我们就隐式地把它b转换为一个整型值,然后,Java会正确地把这个整型值的最后8位解释为255。跟我们期望的一样,s也被设为255了。

增加天空盒着色器

让我们继续为天空盒创建一个顶点着色器。在"/raw"资源文件夹中创建一个新的文件,命名为"skybox_vertex_shader.glsl",并加入如下内容:

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

void main(){
    v_Position = a_Position;
    v_Position.z = -v_Position.z;

    gl_Position = u_Matrix * vec4(a_Position,1.0);
    gl_Position = gl_Position.xyww;
}

如main()内第一行代码所示,我们首先把顶点的位置传递给片段着色器,接着在下一行反转其z分量;这个传递给片段着色器的位置就是立方体上每个面之间将被插值的位置,这使我们以后可以使用这个位置查看天空盒的纹理上正确的部分。其z分量被反转了,使得我们可以把世界的右手坐标空间转换为天空盒所期望的左手坐标空间。如果我们跳过这一步,天空盒仍然能正常工作,但是它的纹理看上去会是反的。

通过用a_Position乘以矩阵把那个位置投影到剪裁空间坐标之后,要用下面的代码把其z分量设置为与其w分量相等的值:

gl_Position= gl_Position.xyww;

这是一种技巧,它确保天空盒的每一部分都将位于归一化设备坐标的远平面上以及场景中的其他一切后面。这个技巧能够奏效,是因为透视除法把一切都除以w,并且w除以它自己,结果等于1。透视除法之后,z最终就在值为1的远平面上了。

这个技巧目前可能看起来没有必要,因为,如果我们想要天空盒出现在其他一切物体的后面,可以先把它画出来,然后再在它上面绘制其他的物体。但这个技巧的背后还有性能的考虑,我们将会在之后讲述更多的细节。

继续编写片段着色器。加入一个名为"skybox_fragment_shader.glsl"的新文件,并加入如下代码:

precision mediump float;

uniform samplerCube u_TextureUnit;
varying vec3 v_Position;

void main(){
    gl_FragColor = textureCube(u_TextureUnit,v_Position);
}

为了使用立方体纹理绘制这个天空盒,我们调用textureCube()时,把那个被插值的立方体面的位置作为那个片段的纹理坐标。

让我们加入相匹配的Java类来封装这个着色器程序,加入一个名为"SkyboxShaderProgram"的新类,这个类继承自ShaderProgram,完整代码如下:

class SkyboxShaderProgram :ShaderProgram{

     var uMatrixLocation = 0
     var uTextureUnitLocation = 0
     var aPositionLocation = 0

    constructor(context:Context):super(context, R.raw.skybox_vertex_shader,R.raw.skybox_fragment_shader){
        uMatrixLocation = findUniformLocationByName(U_MATRIX)
        uTextureUnitLocation = findUniformLocationByName(U_TEXTURE_UNIT)
        aPositionLocation = findAttribLocationByName(A_POSITION)
    }

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

        GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
        GLES20.glBindTexture(GLES20.GL_TEXTURE_CUBE_MAP,textureId)
        GLES20.glUniform1i(uTextureUnitLocation,0)
    }
}

到目前为止,我们已经很熟悉这个着色器程序封装的类了,它也非常简单。因为我们使用了一个立方体贴图纹理,所以用GL_TEXTURE_CUBE_MAP绑定这个纹理。

在场景中加入天空盒

既然我们已经有了立方体模型,也写好了着色器代码,让我们给这个场景加入天空盒。打开Renderer,代码更新如下:

class ParticlesRenderer:Renderer {

    var context:Context

    var projectionMatrix = FloatArray(16)
    var viewMatrix = FloatArray(16)
    var viewProjectionMatrix = 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

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

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

        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
        ))
    }

    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,10f)
    }

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

    private fun drawSkybox(){
        Matrix.setIdentityM(viewMatrix,0)
        Matrix.rotateM(viewMatrix,0,-yRotation,1f,0f,0f)
        Matrix.rotateM(viewMatrix,0,-xRotation,0f,1f,0f)
        Matrix.multiplyMM(viewProjectionMatrix,0,projectionMatrix,0,viewMatrix,0)
        skyboxProgram.useProgram()
        skyboxProgram.setUniforms(viewProjectionMatrix,skyboxTexture)
        skybox.bindData(skyboxProgram)
        skybox.draw()
    }

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

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

        Matrix.setIdentityM(viewMatrix,0)
        Matrix.rotateM(viewMatrix,0,-yRotation,1f,0f,0f)
        Matrix.rotateM(viewMatrix,0,-xRotation,0f,1f,0f)
        Matrix.translateM(viewMatrix,0,0f,-1.5f,-7f)
        Matrix.multiplyMM(viewProjectionMatrix,0,projectionMatrix,0,viewMatrix,0)


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

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

        GLES20.glDisable(GLES20.GL_BLEND)
    }

    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
            }
        }
    }
}

这块首先添加了天空盒相关的变量定义:

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

同时,把已经存在的那个成员变量"texture"重命名为"particleTexture"。加入任何遗漏的导人,然后,在 onSurfaceCreated()中,用如下代码初始化这些新的变量:

        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
        ))

因为使用了天空盒,我们不想把平移矩阵应用到这个场景上,也不想把它应用到天空盒上。出于这个原因,我们需要为天空盒和粒子使用一个不同的矩阵,因此,去掉onSurfaceChanged()中的perspectiveM()调用之后的所有代码行;相反,我们会在onDrawFrame()中设置这些矩阵。因此onDrawFrame()更新为如下代码:

        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
        drawSkybox()
        drawParticles()

首先画出天空盒,然后在天空盒上绘制粒子:

    private fun drawSkybox(){
        Matrix.setIdentityM(viewMatrix,0)
        Matrix.rotateM(viewMatrix,0,-yRotation,1f,0f,0f)
        Matrix.rotateM(viewMatrix,0,-xRotation,0f,1f,0f)
        Matrix.multiplyMM(viewProjectionMatrix,0,projectionMatrix,0,viewMatrix,0)
        skyboxProgram.useProgram()
        skyboxProgram.setUniforms(viewProjectionMatrix,skyboxTexture)
        skybox.bindData(skyboxProgram)
        skybox.draw()
    }

以(0,0,0)为中心绘制天空盒,以使我们站在天空盒中间观察,从视觉上看,一切都是正确的。

之后是绘制粒子的代码:

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

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

        Matrix.setIdentityM(viewMatrix,0)
        Matrix.rotateM(viewMatrix,0,-yRotation,1f,0f,0f)
        Matrix.rotateM(viewMatrix,0,-xRotation,0f,1f,0f)
        Matrix.translateM(viewMatrix,0,0f,-1.5f,-7f)
        Matrix.multiplyMM(viewProjectionMatrix,0,projectionMatrix,0,viewMatrix,0)


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

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

        GLES20.glDisable(GLES20.GL_BLEND)
    }

这与上一章是相似的,粒子喷泉被移到可视的距离内,只是现在是在drawParticles内部更新矩阵,并在这个方法内打开和关闭混合功能。之所以这样做,是因为我们不想在绘制天空盒的时候打开混合功能。

因为只有绘制粒子时才启用和停用混合功能,所以把 onSurfaceCreated()中的 glEnable(GL_BLEND)和 glBlendFunc()调用去掉了。

让我们测试一下这个新的天空盒,看看怎么样。如果一切顺利,它看起来应该下图所示。

我们现在有了一个暴风雨天空的背景衬托这个场景了!看到那些粒子喷泉飘浮在空中,还是有点奇怪,我们将在下篇中通过添加一些地形来解决这一问题。

围绕场景移动相机

当前,我们只能在屏幕上看到很小一部分的天空盒。如果我们能来回移动并能看到天空盒的其余部分,是不是很好呢?

通过监听触控事件,并用那些触控事件一起旋转天空盒和场景,我们可以很容易地实现。

眼睛锐利的同学可能已经发现了上面我们Render中的onTouch函数了:

    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
            }
        }
    }

这段代码它会测量你的手指在每个连续的 onTouch()调用之间滑动了多远。当你第一次触摸屏幕时,当前触摸的位置就被记录在previousX和previousY中。

当你在屏幕上滑动手指时,你会得到一串拖动事件,并且每次拖动都会这样,首先会计算新位置与旧位置的差,并把它存储到deltaX和deltaY中,然后你要更新previousX和previousY。

之后采用每个方向上拖动的距离,并把它加到xRotation和yRotation上,这两个变量表示以度为单位的旋转。我们不想让触控过于灵敏,所以用32缩减了拖动的效果,我们也不想上下旋转的角度过大,因此把y旋转限制在+90度和-90度之间。

为了使旋转的效果应用到天空盒和场景上。我们也更新drawSkybox()中的代码,矩阵相关的代码:

 Matrix.setIdentityM(viewMatrix,0)
        Matrix.rotateM(viewMatrix,0,-yRotation,1f,0f,0f)
        Matrix.rotateM(viewMatrix,0,-xRotation,0f,1f,0f)
        Matrix.multiplyMM(viewProjectionMatrix,0,projectionMatrix,0,viewMatrix,0)

之后为粒子更新了如下代码:

        Matrix.setIdentityM(viewMatrix,0)
        Matrix.rotateM(viewMatrix,0,-yRotation,1f,0f,0f)
        Matrix.rotateM(viewMatrix,0,-xRotation,0f,1f,0f)
        Matrix.translateM(viewMatrix,0,0f,-1.5f,-7f)
        Matrix.multiplyMM(viewProjectionMatrix,0,projectionMatrix,0,viewMatrix,0)

这个旋转首先应用y轴的旋转矩阵,然后应用x轴的旋转矩阵,这叫"FPS样式"旋转(FPS代表FirstPersonShooter第一人称射击游戏),向上或向下旋转总是让你向头上看或者向脚下看,向左或向右旋转总是让你围着以你的脚为中心的圆来回旋转。

现在,如果你再次运行这个应用程序,并在屏幕上滑动你的手指,你就可以来回移动相机,并看到天空盒的其他部分了,如下图所示:

小结

在本章的学习中,我们掌握了如何为场景增加一个天空盒的效果。这一技术主要依赖于立方体贴图,它通过将六个不同的纹理映射到立方体的每个面上来实现。此外,我们还探讨了索引数组的使用,这对于减少具有许多共享顶点的物体的内存占用非常有帮助。

我们了解到,尽管本章主要探讨了如何将立方体贴图应用于天空盒,但这种技术同样可以用于其他方面,比如作为物体的环境贴图,以增强其视觉效果。在处理更复杂的3D模型和场景时,我们可以通过比较不同方法的结果来选择最佳的性能解决方案。

相关推荐
1024小神8 分钟前
tauri2.0版本开发苹果ios和安卓android应用,环境搭建和最后编译为apk
android·ios·tauri
兰琛16 分钟前
20241121 android中树结构列表(使用recyclerView实现)
android·gitee
Y多了个想法1 小时前
RK3568 android11 适配敦泰触摸屏 FocalTech-ft5526
android·rk3568·触摸屏·tp·敦泰·focaltech·ft5526
NotesChapter2 小时前
Android吸顶效果,并有着ViewPager左右切换
android
_祝你今天愉快3 小时前
分析android :The binary version of its metadata is 1.8.0, expected version is 1.5.
android
暮志未晚Webgl3 小时前
109. UE5 GAS RPG 实现检查点的存档功能
android·java·ue5
麦田里的守望者江4 小时前
KMP 中的 expect 和 actual 声明
android·ios·kotlin
Dnelic-4 小时前
解决 Android 单元测试 No tests found for given includes:
android·junit·单元测试·问题记录·自学笔记
佛系小嘟嘟5 小时前
Android Studio不显示需要的tag日志解决办法《All logs entries are hidden by the filter》
android·ide·android studio
mariokkm5 小时前
Django一分钟:django中收集关联对象关联数据的方法
android·django·sqlite