一.前言

  我们的空气曲棍球游戏已经取得了很大的进展,桌子已经放到了一个很好的角度,并且由于使用了纹理,更加好看了。然而,我们现在是用的点去代替木槌,它们实际看起来还不像木槌,许多应用都是通过合并简单的物体去构建更复杂的物体,我们在这篇文章中将学会如何绘制木槌以及桌子中间的冰球。

  我们还缺少一个方法在场景中平移,旋转和来回移动,许多三维应用都是通过一个视图矩阵来完成的,对矩阵所做的改动将会影响整个场景,我们会学习如何创建这个视图矩阵。

二.合并三角形带和三角形扇

  对于要构建一个木槌和冰球,我们可以先在较高的层次去想象一下它们的形状。一个冰球可以用一个扁平的圆柱体表示,如下图所示:

   而木槌可以用两个圆柱体表示,一个大的圆柱体在下面,然后一个小的圆柱体在上面充当手柄,如下图所示:

   为了弄清楚如何在OpenGL中绘制这些物体,让我们想象一下如何用纸张去叠一个冰球和木槌。对于冰球,我们可以先在纸上面剪出一个圆,然后再把一张白纸弯曲成一个圆管,将圆形的纸放在圆管上就可以组成一个圆柱体了,这个圆柱体就可以充当冰球,而两个这样的圆柱体就可以组成一个木槌了。

  结果证明,这在OpenGL中是相当容易实现的。要构建圆,我们可以使用一个三角形扇,我们之前在画空气曲棍球桌子的时候,已经用到了它。我们先用前三个点构建第一个三角形,后面每加入一个点,就会新增一个三角形,当三角形足够多的时候,就会形成一个圆,就像下图所示的那样,当三角形的数量有足够多的时候,就可以铺成一个圆。

    

   而要构建圆柱体的侧面,我们要用到另一个概念,三角形带。和三角形扇一样,三角形带可以让我们定义多个三角形而不用一遍又一遍重复那些三角形中共有的点,但它不是绕圆扇形展开,他是呈一个带状展开,那些三角形彼此相邻放置,如下图所示的那样:

    

   和三角形扇类似,三角形带也是由前三个点构建第一个三角形,然后每增加一个点,就会增加一个三角形。最后,我们只需将这个带状物体弯成一个圆管即可,要做到这一点,我们只要让前两个起点和后两个终点重合即可。

三.添加表示几何图形的类

 我们将定义一个Geometry类,并在这个类的内部定义点,圆和圆柱体类,代码如下:

class Geometry {
    class Point(val x:Float,val y:Float,val z:Float){
        fun translateY(distance:Float):Point{//沿y轴平移
            return Point(x,y+distance,z)
        }
    }
    class Circle(val center:Point,val radius:Float){
        fun scale(scale:Float):Circle{//缩放圆的半径
            return Circle(center,radius*scale)
        }
    }
    class Cylinder(val center:Point,val radius:Float,val height:Float){}
}

四.添加物体构建器

  我们将添加一个物体构建器类ObjectBuilder,这个类中有两个方法createPuck()和createMallet(),我们将分别用这两个方法创建冰球和木槌,这两个方法会返回创建物体所需要的顶点数据以及物体的绘制步骤,代码如下:

class ObjectBuilder(sizeInVertexs:Int) {

    interface DrawCommand{
        fun draw()
    }
    data class GeneratedData(val vertexData:FloatArray,val drawList:List<DrawCommand>)//一个holder,用于保存顶点数据和绘制命令
    private var vertexData:FloatArray//保存顶点数据
    private val drawList= arrayListOf<DrawCommand>()//存储绘制命令
    private var offset=0//记录下一个顶点的位置
    companion object{
        private val FloatsPerVertex=3//记录每个顶点需要三个浮点数表示
        fun sizeOfCircleInVertexs(numPoints:Int):Int{//计算OpenGL画一个圆需要的顶点数量
            return 1+(numPoints+1)//需要一个圆心,并且终点需要和起点重合
        }
        fun sizeOfOpenCylinderInVertexs(numPoints:Int):Int{//计算一个圆筒需要的顶点数量
            return (numPoints+1)*2//三角形带两个起点和终点需要重合
        }
        fun createPuck(puck: Geometry.Cylinder,numPoints:Int):GeneratedData{//用圆柱体创建冰球
            val size= sizeOfCircleInVertexs(numPoints)+ sizeOfOpenCylinderInVertexs(numPoints)//冰球需要的总顶点数
            val builder=ObjectBuilder(size)
            val puckTop=Geometry.Circle(puck.center.translateY(puck.height/2f),puck.radius)
            builder.appendCircle(puckTop,numPoints)//添加顶部圆
            builder.appendOpenCylinder(puck,numPoints)//添加圆筒
            return builder.build()
        }
        fun createMallet(center: Geometry.Point,radius:Float,height:Float,numPoints:Int):GeneratedData{//用两个圆柱体创建木槌
            val size= sizeOfCircleInVertexs(numPoints)*2+ sizeOfOpenCylinderInVertexs(numPoints)*2//木槌需要的总顶点数
            val builder=ObjectBuilder(size)
            val baseHeight=height*0.25f//木槌的底部的高度是手柄高度的1/3,手柄半径是底部圆半径的1/3
            val baseCircle=Geometry.Circle(center.translateY(baseHeight),radius)
            val baseCylinder=Geometry.Cylinder(baseCircle.center.translateY(-baseHeight/2f),radius,baseHeight)
            builder.appendCircle(baseCircle,numPoints)
            builder.appendOpenCylinder(baseCylinder,numPoints)
            val handleHeight=0.75f*height
            val handleRadius=radius/3f
            val handleCircle=Geometry.Circle(center.translateY(height),handleRadius)
            val handleCylinder=Geometry.Cylinder(handleCircle.center.translateY(-handleHeight/2f),handleRadius,handleHeight)
            builder.appendCircle(handleCircle,numPoints)
            builder.appendOpenCylinder(handleCylinder,numPoints)
            return builder.build()
        }
    }
    init{
        vertexData=FloatArray(sizeInVertexs* FloatsPerVertex)
    }
    fun appendCircle(circle: Geometry.Circle,numPoints: Int){//用三角形扇构建圆
        val startVertex=offset/ FloatsPerVertex//起始顶点
        val numVertexs= sizeOfCircleInVertexs(numPoints)//构建圆需要的总顶点数
        //存储圆心
        vertexData[offset++]=circle.center.x
        vertexData[offset++]=circle.center.y
        vertexData[offset++]=circle.center.z

        for(i in 0..numPoints){//将点绕成一个圆
            val angleInRadians=i.toFloat()/numPoints.toFloat()*Math.PI.toFloat()*2f
            vertexData[offset++]=circle.center.x+circle.radius*cos(angleInRadians)
            vertexData[offset++]=circle.center.y
            vertexData[offset++]=circle.center.z+circle.radius*sin(angleInRadians)
        }
        drawList.add(object:DrawCommand{//添加绘制命令
            override fun draw() {
                glDrawArrays(GL_TRIANGLE_FAN,startVertex,numVertexs)//绘制圆
            }
        })
    }
    fun appendOpenCylinder(cylinder: Geometry.Cylinder,numPoints: Int){//用三角形带构建圆筒
        val startVertex=offset/ FloatsPerVertex//起始顶点
        val numVertexs= sizeOfOpenCylinderInVertexs(numPoints)//构建圆筒需要的总顶点数
        val yStart=cylinder.center.y-cylinder.height/2f
        val yEnd=cylinder.center.y+cylinder.height/2f

        for(i in 0..numPoints){
            val angleInRadians=i.toFloat()/numPoints.toFloat()*Math.PI.toFloat()*2f
            val xPosition=cylinder.center.x+cylinder.radius*cos(angleInRadians)
            val zPosition=cylinder.center.z+cylinder.radius*sin(angleInRadians)
            vertexData[offset++]=xPosition
            vertexData[offset++]=yStart
            vertexData[offset++]=zPosition

            vertexData[offset++]=xPosition
            vertexData[offset++]=yEnd
            vertexData[offset++]=zPosition
        }
        drawList.add(object:DrawCommand{
            override fun draw() {//绘制圆筒
                glDrawArrays(GL_TRIANGLE_STRIP,startVertex,numVertexs)
            }
        })
    }
    fun build():GeneratedData{
        return GeneratedData(vertexData,drawList)
    }
}

五.更新物体

  我们既然有了一个物体构建器,就不用将木槌画成点了,我们需要更新一下Mallet类,并且我们还需要添加一个Puck冰球类,我们先从冰球类开始,在类中添加如下代码:

class Puck(val radius:Float,val height:Float,numPointsAroundPuck:Int) {

    private var vertexArray:VertexArray
    private var drawList:List<ObjectBuilder.DrawCommand>
    companion object{
        val POSITION_COMPONENT_COUNT=3//记录顶点的位置需要三个分量表示
    }
    init{
        val generatedData=ObjectBuilder.createPuck(Geometry.Cylinder(Geometry.Point(0f,height/2f,0f),radius,height),numPointsAroundPuck)
        vertexArray= VertexArray(generatedData.vertexData)
        drawList=generatedData.drawList
    }
    fun bindData(){
        vertexArray.setVertexAttribPointer(0,0, POSITION_COMPONENT_COUNT,0)
    }
    fun draw(){
        for(drawCommand in drawList){
            drawCommand.draw()
        }
    }
}

  我们也需要更新Mallet类,用下面的代码替换之前的代码:

class Mallet(val radius:Float,val height:Float,numPointsAroundMallet:Int) {

    private var vertexArray:VertexArray
    private var drawList:List<ObjectBuilder.DrawCommand>
    companion object{
        val POSITION_COMPONENT_COUNT=3
    }
    init{
        val generatedData=ObjectBuilder.createMallet(Geometry.Point(0f,0f,0f),radius,height,numPointsAroundMallet)
        vertexArray= VertexArray(generatedData.vertexData)
        drawList=generatedData.drawList
    }
    fun bindData(colorShaderProgram: ColorShaderProgram){
        vertexArray.setVertexAttribPointer(0,0, POSITION_COMPONENT_COUNT,0)
    }
    fun draw(){
        for(drawCommand in drawList){
            drawCommand.draw()
        }
    }
}

六.更新着色器

  我们还需要更新颜色着色器,之前的createPuck()和createMallet()方法只是生成了位置数据,并没有生成颜色数据,所以我们需要把颜色作为一个uniform传递进去。我们首先需要更新ColorShaderProgram类,修改之后的代码如下:

class ColorShaderProgram(context: Context):ShaderProgram(context,R.raw.simple_vertex_shader,R.raw.simple_fragment_shader) {
    fun setUniforms(matrix:FloatArray,r:Float,g:Float,b:Float){
        glUniformMatrix4fv(0,1,false,matrix,0)
        glUniform4f(1,r,g,b,1.0f)
    }
}

  然后,还需要修改着色器代码,顶点着色器simple_vertex_shader.glsl修改如下:

#version 300 es
layout(location=0) in vec4 a_Position;
layout(location=0) uniform mat4 u_Matrix;
void main() {
    gl_Position=u_Matrix*a_Position;
    gl_PointSize=10.0;
}

  片段着色器simple_fragment_shader.glsl代码修改如下:

#version 300 es
precision mediump float;
layout(location=1) uniform vec4 u_Color;
out vec4 fragColor;
void main() {
    fragColor=u_Color;
}

七.添加视图矩阵并集成所有变化

  视图矩阵出于和模型矩阵一样的目的被使用,但是它平等地影响场景中每一个物体,它的功能等同于一个相机,来回移动相机,你将从不同的角度看见那些东西。我们可以使用Matrix.setLookAtM()函数创建一个视图矩阵,这个函数每个参数的定义如下图所示:

   添加好视图矩阵并且集成了所有变化后,MyRenderer的代码如下:

class MyRenderer(val context: Context):Renderer {
    private val projectionMatrix:FloatArray=FloatArray(16)//存储投影矩阵
    private val modelMatrix:FloatArray=FloatArray(16)//存储模型矩阵
    private val viewMatrix:FloatArray=FloatArray(16)//存储视图矩阵
    //存储矩阵相乘的中间结果
    private val viewProjectionMatrix:FloatArray=FloatArray(16)
    private val modelViewProjectionMatrix:FloatArray=FloatArray(16)
    private var table:Table?=null
    private var mallet:Mallet?=null
    private var puck:Puck?=null
    private var textureShaderProgram:TextureShaderProgram?=null
    private var colorShaderProgram:ColorShaderProgram?=null
    private var texture=0
    override fun onSurfaceCreated(p0: GL10?, p1: EGLConfig?) {
        glClearColor(0.0F,0.0F,0.0F,0.0F)//设置清除所使用的颜色,参数分别代表红绿蓝和透明度
        table= Table()
        //每个物体都是由围绕圆的32个点创建的
        mallet= Mallet(0.08f,0.15f,32)
        puck=Puck(0.06f,0.02f,32)
        textureShaderProgram= TextureShaderProgram(context)
        colorShaderProgram= ColorShaderProgram(context)
        texture=TextureHelper.loadTexture(context,R.drawable.air_hockey_surface)
    }

    override fun onSurfaceChanged(p0: GL10?, width: Int, height: Int) {
        glViewport(0,0,width,height)//是一个用于设置视口的函数,视口定义了在屏幕上渲染图形的区域。这个函数通常用于在渲染过程中指定绘图区域的大小和位置,前两个参数x,y表示视口左下角在屏幕的位置
        Matrix.perspectiveM(projectionMatrix,0,45f,width.toFloat()/height.toFloat(),1f,10f)
        Matrix.setLookAtM(viewMatrix,0,0f,1.2f,2.2f,0f,0f,0f,0f,1f,0f)
    }

    override fun onDrawFrame(p0: GL10?) {
        glClear(GL_COLOR_BUFFER_BIT)//清除帧缓冲区内容,和glClearColor一起使用
        Matrix.multiplyMM(viewProjectionMatrix,0,projectionMatrix,0,viewMatrix,0)
        //绘制桌子
        positionTableInScene()
        textureShaderProgram?.useProgram()
        textureShaderProgram?.setUniforms(modelViewProjectionMatrix,texture)
        table?.bindData()
        table?.draw()
        //绘制第一个木槌
        positionObjectInScene(0f,0f,-0.4f)
        colorShaderProgram?.useProgram()
        colorShaderProgram?.setUniforms(modelViewProjectionMatrix,1f,0f,0f)
        mallet?.bindData()
        mallet?.draw()
        //绘制第二个木槌,用的同一份数据,只不过在最后平移了一下
        positionObjectInScene(0f,0f,0.4f)
        colorShaderProgram?.setUniforms(modelViewProjectionMatrix,0f,0f,1f)
        mallet?.draw()
        //绘制冰球
        positionObjectInScene(0f,0f,0f)
        colorShaderProgram?.setUniforms(modelViewProjectionMatrix,0.8f,0.8f,1f)
        puck?.bindData()
        puck?.draw()
    }
    fun positionTableInScene(){
        Matrix.setIdentityM(modelMatrix,0)
        Matrix.rotateM(modelMatrix,0,-90F,1f,0f,0f)//旋转角度为-90度,可以让桌面和x-z平面重合,这个时候桌面上所有的点的坐标的y分量的值为0
        Matrix.multiplyMM(modelViewProjectionMatrix,0,viewProjectionMatrix,0,modelMatrix,0)
    }
    fun positionObjectInScene(x:Float,y:Float,z:Float){
        Matrix.setIdentityM(modelMatrix,0)
        Matrix.translateM(modelMatrix,0,x,y,z)
        Matrix.multiplyMM(modelViewProjectionMatrix,0,viewProjectionMatrix,0,modelMatrix,0)
    }
}

  接下来,可以运行程序,看看最终的效果。