OpenGL ES 2.0 for Android教程(八):构建简单物体

OpenGL ES 2 第八章:构建简单物体

文章传送门

OpenGL ES 2.0 for Android教程(一)

OpenGL ES 2.0 for Android教程(二)

OpenGL ES 2.0 for Android教程(三)

OpenGL ES 2.0 for Android教程(四)

OpenGL ES 2.0 for Android教程(五)

OpenGL ES 2.0 for Android教程(六)

OpenGL ES 2.0 for Android教程(七)

OpenGL ES 2.0 for Android教程(九)

我们的空中曲棍球项目已经取得了长足的进步:现在我们的桌子放在一个很棒的角度,而且现在我们使用了纹理,看起来更好看了。然而,我们的木槌实际上看起来并不像一个真正的木槌,因为我们现在把每个木槌都画成一个点。你能想象用一个小点作为木槌打空气曲棍球吗?许多应用程序都使用简单图形的组合来创建更复杂的物体,我们将在这里学习如何创建更好的木槌。

我们还缺少在场景中平移、旋转和移动的简单方法。许多3D应用程序通过使用视图矩阵来实现这一点;对该矩阵所做的更改会影响整个场景,就好像我们在从移动的摄影机中观察事物一样。我们将添加一个视图矩阵,使其更易于旋转和移动。

让我们浏览一下本章的计划:

  • 首先,我们将学习如何将三角形组织成为三角形带(Triangle Strips)和三角形扇形,然后将它们合并在一起形成单个对象。
  • 我们还将学习如何定义视图矩阵,并将其集成到矩阵层次结构中。

一旦我们完成了这些任务,我们就可以只用一行代码来移动场景了,并且木槌足以用来敲击冰球。说到冰球,场景中还没有,所以我们也要加上它。

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

为了制作一个木槌,或者一个冰球,让我们首先试着想象它的立体形状。冰球可以表示为一个扁平的圆柱体,如下所示:

在这里插入图片描述

木槌稍微复杂一点,可以表示为两个圆柱体:
OpenGL ES 2.0 for Android教程(八):构建简单物体_第1张图片
我知道你在想什么:一个真正的木槌不仅仅是两个圆柱体粘在一起。不过,现在让我们把事情变得更简单一些,一旦我们学会了基本知识,你就有足够的自信来建立一个更细致的木槌。

为了弄清楚如何在OpenGL中构建我们想要的形状,让我们想象一下如何用纸张构建这些物体。首先,我们剪出一个圆形当作圆柱体顶部。然后我们拿另一张纸剪成合适的尺寸,再把它卷成圆筒,我们需要一个用于冰球的圆筒和两个用于木槌的圆筒。

事实证明,这很容易用OpenGL实现。首先,我们可以用三角形扇形(Triangle Fans)来构成圆,只需要使用更多的三角形,将外部顶点排列成圆形。

为了构建圆柱体的侧面,我们可以使用一个相关的概念,称为三角形带(Triangle Strips)。就像三角形扇形一样,三角形带可以让我们定义许多三角形,而无需反复复制共享点;但它并不是围绕一个圆心扇形展开,而是像桥梁一样排列出一条三角形带,三角形彼此相邻:
OpenGL ES 2.0 for Android教程(八):构建简单物体_第2张图片

与三角形扇形一样,三角形条带的前三个顶点定义了第一个三角形。之后的每个附加顶点定义一个附加三角形。要使用三角形条构建圆柱体的侧面,我们只需要把三角形带卷成圆管,并确保最后两个顶点与前两个顶点对齐。

三角形扇形与三角形带体现出了两种不同的顶点共享策略。

三角形扇形约定第一个顶点作为扇形的圆心,所有三角形一定会包含这个点,接下来共享的另一个点是上次绘制的三角形的最后一个点。

三角形带使用上次绘制的三角形的最后两个点作为共享点。

添加几何体的类

我们现在对制作冰球和木槌需要什么已经有了成熟的想法:对于冰球,我们需要一个三角形扇形作为顶部,一个三角形带作为侧面;对于木槌,我们需要两个三角形扇形两个三角形带。为了更容易地构建这些对象,我们将定义一个几何体的类来保存一些基本形状定义,并定义一个ObjectBuilder来进行实际构建。

让我们在util包下创建新的Geometry.kt文件,然后添加下列代码:

class Point(
    val x: Float,
    val y: Float,
    val z: Float,
) {
    fun translateY(distance: Float): Point = Point(x, y + distance, z)
}

我们添加了一个类来表示3D空间中的一个点,以及一个辅助方法来沿y轴平移该点。我们还需要一个圆的定义;在后面添加以下内容:

class Circle(
    val center: Point,
    val radius: Float
) {
    fun scale(scale: Float): Circle = Circle(center, radius * scale)
}

我们添加了一个辅助方法来缩放圆的半径。最后是圆柱体的定义:

class Cylinder(
    val center: Point,
    val radius: Float,
    val height: Float
)

圆柱体看起来就是有”厚度“的圆,所以我们定义了一个中心,一个半径和一个高度。

你可能已经注意到,我们已经将几何类定义为不可变的;每当我们进行更改时,我们都会返回一个新对象。这有助于使代码更易于使用和理解,但当您需要更高的性能时,您可以使用简单的浮点数组代替对象建模,使用静态函数对其进行直接的修改。

添加ObjectBuilder

在objects包中创建类ObjectBuilder,添加以下代码:

class ObjectBuilder {

    private val vertexDataList: MutableList<Float> = mutableListOf()
    
    private val commands = mutableListOf<DrawCommand>()

    fun appendCircle(circle: Circle, numPoints: Int): ObjectBuilder {
        // TODO
        return this
    }

    fun appendOpenCylinder(cylinder: Cylinder, numPoints: Int): ObjectBuilder {
        // TODO
        return this
    }

    fun build(): GeneratedData {
        // TODO
    }

    companion object {
        private const val FLOATS_PER_VERTEX = 3
    }
}

fun interface DrawCommand {
    fun draw()
}

class GeneratedData(
    val vertexData: FloatArray,
    val drawCommands: List<DrawCommand>
)

现在让我们来解释一下上述代码:

首先,我们使用List来缓存顶点数据,然后我们还给ObjectBuilder添加了一些尚未实现的方法,这些方法展示了ObjectBuilder的任务:接收抽象的图像类型,并构建具体化的OpenGL顶点位置与绘制指令。例如,我们通过appendCircle()要求绘制一个圆,ObjectBuilder将自动生成绘制这个圆需要的具体的顶点位置和绘制指令。我们需要给出圆的圆心位置和半径,还用numPoints要求ObjectBuilder用多少个点来模拟圆周——别忘记OpenGL只能用三角形来模拟圆,因此,我们需要决定模拟圆周用的顶点的个数。显然,用的点越多,就越像圆。

在最后的build()阶段,我们将ObjectBuilder缓存的信息填充到数据类GeneratedData中。

接口DrawCommand是一个描述绘制物体所需要的具体指令的一个函数式接口(SAM),我们在interface前添加的fun关键字就是让它成为函数式接口的意思,这样,接受该接口作为参数的方法,也接受与之等价的lambda表达式。

常数FLOATS_PER_VERTEX的意思是ObjectBuilder认为顶点数据由三个分量x、y、z组成,因为ObjectBuilder这个类就是拿来构建三维物体的。另外,这个类还有一个关于坐标系的隐式约定(之后的具体实现会基于这个约定)。我们约定,使用ObjectBuilder时,物体以调用方指定的位置为中心,并且是平放在xoz平面上的,换句话说,我们使用的坐标系Y轴朝上,这也是OpenGL坐标系的通用做法,因此在设计物体坐标时要考虑到这一点。

现在让我们再往ObjectBuilder添加两个辅助方法:

/**
 * 计算绘制一个圆实际需要的顶点数,等于圆周顶点数+2
 */
private fun sizeOfCircleInVertices(numPoints: Int): Int = numPoints + 2

/**
 * 计算绘制一个圆筒实际需要的顶点数,等于(圆周顶点数+1)*2
 */
private fun sizeOfOpenCylinderInVertices(numPoints: Int): Int = (numPoints + 1) * 2

这两个方法用来计算绘制圆、圆筒真正需要的顶点数。绘制圆时,如果圆周用10个点来模拟,那么需要一个额外的顶点作为圆心来绘制三角形扇形;另外,圆周的第一个顶点要重复两次,这样我们才可以闭合圆周。因此总共需要12个顶点。绘制圆筒时,如果圆周用10个点来模拟,同样,我们需要重复两次第一个顶点来闭合圆周;另外,绘制三角形带时,上圆周和下圆周都需要一个顶点,因此需要乘以2,最终我们需要使用22个顶点。

使用三角形扇形构建圆

下一步是实现方法appendCircle()。我们添加以下代码:

fun appendCircle(circle: Circle, numPoints: Int): ObjectBuilder {
    val numVertices = sizeOfCircleInVertices(numPoints)
    // 添加顶点之前先计算起始顶点的index
    val startVertex = vertexDataList.size / FLOATS_PER_VERTEX
    // 计算2PI
    val pi2: Double = Math.PI * 2
    // 缓存每个点的角度
    var angleInRadians: Double
    vertexDataList.apply {
        // 添加圆心
        add(circle.center.x)
        add(circle.center.y)
        add(circle.center.z)
        // 因为我们想重复加入第一个点,因此允许i赋值为numPoints
        for (i in 0..numPoints) {
            // 计算该点的角度
            angleInRadians = (i.toDouble()) / (numPoints.toDouble()) * pi2
            add(circle.center.x + circle.radius * cos(angleInRadians).toFloat())
            add(circle.center.y)
            add(circle.center.z + circle.radius * sin(angleInRadians).toFloat())
        }
    }
    commands.add {
        glDrawArrays(GL_TRIANGLE_FAN, offset, numVertices)
    }
    return this
}

为了构建三角形扇形,我们首先添加圆心顶点,然后我们围绕中心点生成扇形,注意,要重复第一个点两次。我们使用三角函数和单位圆的概念生成点。

OpenGL ES 2.0 for Android教程(八):构建简单物体_第3张图片

我们的圆现在平行于xoz平面,因此我们圆的y分量和圆心保持一致,而x分量、z分量随着圆周而变化。我们根据比例计算点的角度,点的角度=比例*2 π \pi π。新的x、z的值等于在原来x、z的基础加上根据半径进行缩小放大的相应的cos、sin值。

使用三角形带来构建圆筒

我们现在来实现appendOpenCylinder方法:

fun appendOpenCylinder(cylinder: Cylinder, numPoints: Int): ObjectBuilder {
    val numVertices = sizeOfCircleInVertices(numPoints)
    // 添加顶点之前先计算起始顶点的index
    val startVertex = vertexDataList.size / FLOATS_PER_VERTEX
    // 计算2PI
    val pi2: Double = Math.PI * 2
    // 下圆周的y分量
    val yStart: Float = cylinder.center.y - (cylinder.height / 2F)
    // 上圆周的y分量
    val yEnd: Float = cylinder.center.y + (cylinder.height / 2F)
    // TODO
    return this
}

就像之前一样,我们计算出起始顶点和顶点数,以便在绘制命令中使用它们。我们还计算出上圆周和下圆周的y分量的值。

OpenGL ES 2.0 for Android教程(八):构建简单物体_第4张图片

我们继续添加代码:

fun appendOpenCylinder(cylinder: Cylinder, numPoints: Int): ObjectBuilder {
    // ...
    // 缓存每个点的角度
    var angleInRadians: Double
    // 缓存点的x、z分量
    var xPosition: Float
    var zPosition: Float
    vertexDataList.apply {
        // 因为我们想重复加入第一个点,因此允许i赋值为numPoints
        for (i in 0..numPoints) {
            // 计算该点的角度
            angleInRadians = (i.toDouble()) / (numPoints.toDouble()) * pi2

            xPosition = cylinder.center.x + cylinder.radius * cos(angleInRadians).toFloat()
            zPosition = cylinder.center.z + cylinder.radius * sin(angleInRadians).toFloat()

            add(xPosition)
            add(yStart)
            add(zPosition)

            add(xPosition)
            add(yEnd)
            add(zPosition)
        }
    }
    commands.add {
        glDrawArrays(GL_TRIANGLE_STRIP, startVertex, numVertices)
    }
    return this
}

我们使用GL_TRIANGLE_STRIP告诉OpenGL绘制三角形带。

最后我们简单实现一下build方法:

fun build(): GeneratedData = GeneratedData(
    vertexData = vertexDataList.toFloatArray(),
    drawCommands = commands
)

更新物体

用圆柱体创建冰球

我们现在可以添加一个Puck类来表示冰球,在objects包下创建该类:

class Puck(
    val radius: Float,
    val height: Float,
    numPointsAroundPuck: Int
) {
    private val vertexArray: VertexArray
    private val drawList: List<DrawCommand>

    init {
        
    }

    private fun createPuck(point: Point, radius: Float, height: Float, numPoints: Int): GeneratedData {
        // TODO
    }
    
    companion object {
        private const val POSITION_COMPONENT_COUNT = 3
    }
}

我们可以依赖ObjectBuilder来创建我们的冰球,继续添加createPuckinit代码块的实现:

init {
    val data = createPuck(
        puck = Cylinder(
            center = Point(0F, 0F, 0F),
            radius = radius,
            height = height
        ),
        numPoints = numPointsAroundPuck
    )
    vertexArray = VertexArray(data.vertexData)
    drawList = data.drawCommands
}

private fun createPuck(puck: Cylinder, numPoints: Int): GeneratedData {
    // 添加顶部的圆,再添加配套的圆筒
    return ObjectBuilder()
        .appendCircle(
            circle = Circle(puck.center.translateY(puck.height / 2F), puck.radius), 
            numPoints = numPoints
        ).appendOpenCylinder(puck, numPoints)
        .build()
}

冰球的中心也是OpenGL坐标系的中心。另外,我们根据圆柱的中心坐标变换出了顶部圆心的坐标,你可以看到我们之前的translateY是有作用的。
OpenGL ES 2.0 for Android教程(八):构建简单物体_第5张图片

最后,让我们添加bindData()draw()

fun bindData(colorShaderProgram: ColorShaderProgram) {
    vertexArray.setVertexAttribPointer(
        dataOffset = 0,
        attributeLocation = colorShaderProgram.aPositionLocation,
        componentCount = POSITION_COMPONENT_COUNT,
        stride = 0
    )
}
fun draw() {
    drawList.forEach {
        it.draw()
    }
}

重新构建木槌

我们使用ObjectBuilder来重新构建Mallet类。木槌可以由两个圆柱体制成,我们将以下图的形式来定义木槌:
OpenGL ES 2.0 for Android教程(八):构建简单物体_第6张图片

手柄高度约为总高度的75%,底座高度约为总高度的25%。而手柄的宽度大约是底座宽度的三分之一。有了这些定义,我们就能计算出组成木槌的两个圆柱体的放置位置。为了制作木槌,我们需要计算出每个圆柱体顶部的y分量以及每个圆柱体的中心位置。

让我们在Mallet类添加createMallet()方法:

/**
 * 构建木槌
 * @param center 木槌整体的中心
 * @param radius 底座的半径
 * @param height 木槌整体高度
 * @param numPoints 底座与手柄的圆周需要以几个顶点模拟
 */
fun createMallet(center: Point, radius: Float, height: Float, numPoints: Int): GeneratedData {
    // TODO
}

首先我们构建底座:

fun createMallet(center: Point, radius: Float, height: Float, numPoints: Int): GeneratedData {
    val builder = ObjectBuilder()
    // 计算底座的高度
    val baseHeight = height * 0.25F

    // 底座顶部的圆
    val baseCircle = Circle(
        // 下降0.25个高度就是底座顶部圆心
        center = center.translateY(-baseHeight),
        radius = radius
    )
    val baseCylinder = Cylinder(
        // 顶部再下降自身一半的高度
        center = baseCircle.center.translateY(-baseHeight / 2F),
        radius = radius,
        height = baseHeight
    )
    builder.appendCircle(baseCircle, numPoints)
            .appendOpenCylinder(baseCylinder, numPoints)

    // TODO
}

然后我们继续构建手柄,并返回GeneratedData

fun createMallet(center: Point, radius: Float, height: Float, numPoints: Int): GeneratedData {
    val builder = ObjectBuilder()
    // 计算底座的高度
    val baseHeight = height * 0.25F

    // 底座顶部的圆
    val baseCircle = Circle(
        // 下降0.25个高度就是底座顶部圆心
        center = center.translateY(-baseHeight),
        radius = radius
    )
    val baseCylinder = Cylinder(
        // 顶部再下降自身一半的高度
        center = baseCircle.center.translateY(-baseHeight / 2F),
        radius = radius,
        height = height
    )
    builder.appendCircle(baseCircle, numPoints)
            .appendOpenCylinder(baseCylinder, numPoints)
    
    // 计算手柄的高度
    val handleHeight = height - baseHeight
    // 计算手柄半径
    val handleRadius = radius / 3F
    val handleCircle = Circle(
        center = center.translateY(height / 2F),
        radius = handleRadius
    )
    val handleCylinder = Cylinder(
        center = handleCircle.center.translateY(-handleHeight / 2F),
        radius = handleRadius,
        height = handleHeight
    )
    return builder.appendCircle(handleCircle, numPoints)
        .appendOpenCylinder(handleCylinder, numPoints)
        .build()
    }
}

现在,让我们使用上面的方法来重新构建木槌,整体上类Mallet与类Puck的代码类似:

class Mallet(
    val baseRadius: Float,
    val height: Float,
    numPointsAroundMallet: Int
) {
    private val vertexArray: VertexArray
    private val drawList: List<DrawCommand>

    init {
        val data = createMallet(
            center = Point(0F, 0F, 0F),
            radius = baseRadius,
            height = height,
            numPoints = numPointsAroundMallet
        )
        vertexArray = VertexArray(data.vertexData)
        drawList = data.drawCommands
    }

    fun bindData(colorShaderProgram: ColorShaderProgram) {
        vertexArray.setVertexAttribPointer(
            dataOffset = 0,
            attributeLocation = colorShaderProgram.aPositionLocation,
            componentCount = POSITION_COMPONENT_COUNT,
            stride = 0
        )
    }

    fun draw() {
        drawList.forEach { 
            it.draw()
        }
    }

    /**
     * 构建木槌
     * @param center 木槌整体的中心
     * @param radius 底座的半径
     * @param height 木槌整体高度
     * @param numPoints 底座与手柄的圆周需要以几个顶点模拟
     */
    fun createMallet(center: Point, radius: Float, height: Float, numPoints: Int): GeneratedData {...}

    companion object {
        private const val POSITION_COMPONENT_COUNT = 3
    }
}

更新着色器

我们的顶点数组已经不包含颜色属性了,事实上也不需要为每个顶点指定颜色,我们可以使用uniform变量来进行替代。首先我们删除所有与a_Color相关的变量、常量,包括aPositionLocation等,然后再添加一个常量U_COLOR,我们更新之后的ColorShaderProgram如下:

import androidx.compose.ui.graphics.Color

class ColorShaderProgram(
    context: Context
): ShaderProgram(context, R.raw.simple_vertex_shader, R.raw.simple_fragment_shader) {

    private val uMatrixLocation: Int = getUniformLocation(U_MATRIX)
    private val uColorLocation: Int = getUniformLocation(U_COLOR)

    val aPositionLocation: Int = getAttribLocation(A_POSITION)

    fun setUniforms(matrix: FloatArray, color: Color) {
        glUniformMatrix4fv(uMatrixLocation, 1, false, matrix, 0)
        glUniform4f(uColorLocation, color.red, color.green, color.blue, color.alpha)
    }

    companion object {
        private const val U_MATRIX = "u_Matrix"
        private const val U_COLOR = "u_Color"

        private const val A_POSITION = "a_Position"
    }
}

注意,我们使用的是androidx.compose.ui.graphics包下的Color类,这个Color类随着Compose的引入而被引入,使用这个Color类我们可以轻松封装ARGB颜色而不必在我们的setUniforms()方法中添加四个Float类型的参数。

然后我们更新glsl代码,simple_fragment_shader.glsl如下:

precision mediump float;
uniform vec4 u_Color;

void main() {
    gl_FragColor = u_Color;
}

simple_vertex_shader.glsl如下:

uniform mat4 u_Matrix;
attribute vec4 a_Position;

void main() {
    gl_Position = u_Matrix * a_Position;
    gl_PointSize = 10.0;
}

整合我们的变化

本章最难的部分已经完成。我们学习了如何用简单的几何形状制作冰球和木槌,我们还更新了着色器以反映变化。剩下的就是将这些变化整合到AirHockeyRenderer中;同时,我们还将学习如何加入视图矩阵来添加相机的概念。

那么我们为什么要添加另一个矩阵呢?当我们第一次开始我们的空中曲棍球项目时,我们最初根本没有使用任何矩阵。我们首先添加一个正交矩阵来调整宽高比,然后切换到透视投影矩阵来获得3D投影。后来,我们又添加了一个模型矩阵,开始移动物体。视图矩阵实际上只是模型矩阵的扩展,它的使用出于同样的目的,只不过它平等地应用于场景中的每一个物体。

一个简单的矩阵层次结构

让我们花点时间回顾一下三种主要类型的矩阵,我们将使用它们将物体显示在屏幕上:

  • 模型矩阵(Model matrix)

    模型矩阵用于将物体放置到世界空间(world-space)坐标中。例如,我们的冰球模型和木槌模型最初可能以(0,0,0)为中心。如果没有模型矩阵,我们的三维模型将被困在那里,如果我们想移动它们,就不得不自己更新每个顶点。不想这么做的话就可以使用模型矩阵,通过将顶点与矩阵相乘来移动顶点,这样我们就不用为顶点未处于指定位置而烦恼。

  • 视图矩阵(View matrix)

    使用视图矩阵的原因与使用模型矩阵的原因相同,但它对场景中的每个对象都有相同的影响。因为它影响所有物体,所以它在功能上相当于一个相机:相机移动时,你会从不同的角度看到那些东西。

    使用单独的矩阵的优点是,它允许我们预先将一系列变换处理成单个矩阵。例如,假设我们想要来回旋转场景并将其移动一定距离,我们可以简单地对每个对象进行同样的旋转和平移调用,但将这些变换保存到单独的矩阵后,再将其应用于每个对象会更容易。

  • 投影矩阵(Projection matrix)

    最后,谈谈投影矩阵。这个矩阵有助于创造3D效果,通常只有在屏幕改变方向时才需要改变这个矩阵。

我们下面再回顾一下顶点如何从其原始位置变换到屏幕:

vertex m o d e l \textup{vertex}_{model} vertexmodel

这是模型坐标中的一个顶点。例如,被包含在桌子顶点内的位置。

vertex w o r l d \textup{vertex}_{world} vertexworld

这是世界空间中使用模型矩阵定位过(has been positioned)的顶点。

vertex e y e \textup{vertex}_{eye} vertexeye

这是相对于我们的眼睛或相机的一个顶点。我们相对于当前的观察位置使用视图矩阵来移动世界空间上的所有顶点。

vertex c l i p \textup{vertex}_{clip} vertexclip

这是一个用投影矩阵处理过的顶点。下一步就是进行透视除法了。

vertex n d c \textup{vertex}_{ndc} vertexndc

这是标准化设备坐标中的顶点。一旦顶点位于这些坐标中,OpenGL就会将其映射到viewport,您就可以在屏幕上看到它了。

这个链条看起来就像下面这样:
vertex c l i p = ProjectionMatrix ∗ vertex e y e vertex c l i p = ProjectionMatrix ∗ ViewMatrix ∗ vertex w o r l d vertex c l i p = ProjectionMatrix ∗ ViewMatrix ∗ ModelMatrix ∗ vertex m o d e l \textup{vertex}_{clip}=\textup{ProjectionMatrix} * \textup{vertex}_{eye}\\ \textup{vertex}_{clip}=\textup{ProjectionMatrix} * \textup{ViewMatrix}*\textup{vertex}_{world} \\ \textup{vertex}_{clip}=\textup{ProjectionMatrix} * \textup{ViewMatrix}* \textup{ModelMatrix} *\textup{vertex}_{model} \\ vertexclip=ProjectionMatrixvertexeyevertexclip=ProjectionMatrixViewMatrixvertexworldvertexclip=ProjectionMatrixViewMatrixModelMatrixvertexmodel

将新物体添加到我们的空气曲棍球桌

让我们继续向AirHockeyRender添加新的矩阵定义:

private val viewMatrix = FloatArray(16)
private val viewProjectionMatrix = FloatArray(16)
private val modelViewProjectionMatrix = FloatArray(16)

我们将把视图矩阵存储在viewMatrix中,另外两个矩阵将用于保存矩阵乘法的结果。接下来我们添加冰球的类变量,并修改创建Mallet的方式:

class AirHockeyRenderer6(private val context: Context): GLSurfaceView.Renderer {
    // ...
    private lateinit var table: Table
    private lateinit var mallet: Mallet

    private lateinit var puck: Puck
    // ...
    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        // ...
        table = Table()
        mallet = Mallet(0.08F, 0.15F, 32)
        puck = Puck(0.06F, 0.02F, 32)
        // ...
    }
    // ...
}

冰球和木槌的半径和高度设置为一个适合的大小,以便它们看起来与桌子成比例。每个对象将使用32个点来模拟圆周。

初始化新的矩阵

下一步是更新onSurfaceChanged()并初始化视图矩阵:

 override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
    glViewport(0, 0, width, height)
    MatrixHelper.perspectiveM(projectionMatrix, 55f, width.toFloat() / height.toFloat(), 1f, 10f)

    setLookAtM(viewMatrix, 0, 0f, 1.2f, 2.2f, 0f, 0f, 0f, 0f, 1f, 0f)
}

该方法的开头非常熟悉:我们设置viewport,然后设置投影矩阵。下面则是一个新的API:我们调用setLookAtM()来创建一种特殊类型的视图矩阵:

setLookAtM(float[] rm, int rmOffset, float eyeX, float eyeY, float eyeZ, float centerX, float centerY, float centerZ, float upX, float upY, float upZ)

参数 解释
float[] rm 这是目标矩阵。该数组的长度应至少为16,以便存储视图矩阵。
int rmOffset rm数组开始写入数据的位置偏移量
float eyeX, eyeY, eyeZ 这就是眼睛的位置。场景中的所有内容看起来都像是从这个点观察到的
float centerX, centerY, centerZ 这是眼睛正在看的地方,该位置将出现在场景的中心。
float upX, upY, upZ 刚才讨论了你的眼睛,这个坐标相当于你的头部指向的方向。upY为1意味着你的头部笔直指向上方(在OpenGL坐标系中)。

我们调用setLookAtM()传入的眼睛坐标为 (0, 1.2, 2.2),这意味着你的眼睛在xoz平面上方1.2个单位再向后2.2个单位。换句话说,场景中的所有内容都将显示在你下方1.2个单位和前面2.2个单位的位置。中心设为(0,0,0)意味着你将向下看向你前面的原点,指向坐标up设为(0,1,0),这意味着你的头将笔直地指向上方,场景不会旋转到任何一侧。

更新onDrawFrame

在我们运行程序并看到新的更改之前,还有一些最后的更改。在调用glClear()之后,将以下代码添加到onDrawFrame()

multiplyMM(viewProjectionMatrix, 0, projectionMatrix, 0, viewMatrix, 0)

这会将投影矩阵和视图矩阵相乘的结果缓存到viewProjectionMatrix中。替换onDrawFrame()的其余部分,如下所示:

positionTableInScene()
textureShaderProgram.useProgram()
textureShaderProgram.setUniforms(modelViewProjectionMatrix, texture)
table.bindData(textureShaderProgram)
table.draw()
// 绘制Mallet
positionObjectInScene(0F, mallet.height / 2F, -0.4F)
colorShaderProgram.useProgram()
colorShaderProgram.setUniforms(modelViewProjectionMatrix, Color(1f, 0f, 0f))
mallet.bindData(colorShaderProgram)
mallet.draw()

positionObjectInScene(0F, mallet.height / 2F, 0.4F)
colorShaderProgram.setUniforms(modelViewProjectionMatrix, Color(0f, 0f, 1f))
// 请注意,我们不必两次定义对象数据,我们只要指定不同的位置,使用不同的颜色。
mallet.draw()
// 绘制Puck
positionObjectInScene(0F, puck.height / 2F, 0F)
colorShaderProgram.setUniforms(modelViewProjectionMatrix, Color(0.8f, 0.8f, 1f))
puck.bindData(colorShaderProgram)
puck.draw()

这段代码与上一个项目中的代码基本相同,但有一些关键的区别。第一个区别是,我们在绘制这些对象之前调用positionTableInScene()positionObjectInScene()。在绘制木槌之前,我们还更新了setUniforms(),并添加了绘制冰球的代码。

你有没有注意到我们正在用相同的木槌数据绘制两个木槌?如果愿意,我们可以使用同一组顶点来绘制数百个对象:我们所要做的只是在绘制每个对象之前更新模型矩阵。

让我们添加positionTableInScene()的定义:

private fun positionTableInScene() {
    setIdentityM(modelMatrix, 0)
    // 这张桌子是用X、Y坐标定义的,所以我们把它旋转90度,使它平放在xoz平面上
    rotateM(modelMatrix, 0, -90f, 1f, 0f, 0f)
    multiplyMM(modelViewProjectionMatrix, 0, viewProjectionMatrix,
                0, modelMatrix, 0)
}

桌子最初是用x和y坐标定义的,所以为了让它平放在地面上,我们将它绕x轴旋转90度。请注意,与前面的课程不同,我们不需要平移桌子,因为我们希望将表格保持在世界坐标系中的(0,0,0),并且视图矩阵已经想办法让桌子对我们可见。

最后一步是将viewProjectionMatrixmodelMatrix相乘,并将结果存储在modelViewProjectionMatrix中,然后将其传递到着色器程序中,从而将所有矩阵组合在一起。

我们还要添加positionObjectInScene()的定义:

private fun positionObjectInScene(x: Float, y: Float, z: Float) {
    setIdentityM(modelMatrix, 0)
    translateM(modelMatrix, 0, x, y, z)
    multiplyMM(
        modelViewProjectionMatrix, 0, viewProjectionMatrix,
        0, modelMatrix, 0
    )
}

木槌和冰球已经被定义为平放在xoz平面上,因此不需要旋转。我们根据传入的参数对它们进行平移,以便将它们放置在桌子上方的适当位置。

继续运行程序。如果一切都按计划进行,那么它应该看起来就像下图。新的木槌和冰球应该出现在桌子上,视图以桌子为中心。木槌可能显得有点太结实,我们将在第13章“照亮世界”学习如何改进这一点。
OpenGL ES 2.0 for Android教程(八):构建简单物体_第7张图片

本章小结

祝贺你完成了又一个紧凑的篇章!我们学习了如何利用三角形带和三角形扇形将它们组合成物体。我们还学习了如何在构建这些物体时封装图形调用,以便我们可以轻松地将它们绑定到单个命令中。

我们还介绍了矩阵层次结构的概念:一个矩阵用于投影,一个用于相机,还有一个用于在世界空间中移动物体。以这种方式可以更容易地操纵场景和移动物体。

练习

作为第一个练习,通过向onDrawFrame()添加一个方法调用,围绕桌子慢慢旋转视点(viewpoint)。

对于更具挑战性的练习,请查看下图:
OpenGL ES 2.0 for Android教程(八):构建简单物体_第8张图片

如何更新木槌生成器,使其更接近这种类型的木槌?你仍然可以用简单的几何形状制作木槌。这里提供一个思路:

  • 两个常规圆柱体侧面:一个用于手柄,一个用于底座外侧
  • 两个圆环:一个用于底座顶部,一个用于底座内部
  • 一个倾斜的圆柱体侧面,用于连接两个圆环
  • 一个半球盖在手柄的上面。

当然,你的想象力是无限的,你可以随心所欲地发挥创造力。当你准备好了,我们将在下一章学习如何用手指移动木槌。

你可能感兴趣的:(OpenGL,ES,2.0教程,kotlin,android,音视频,计算机视觉)