九、使用 OpenGL ES 2 以 60 FPS 的速度拍摄小行星

欢迎来到最终项目。在接下来的三章中,我们将使用 OpenGL ES 2 图形应用编程接口构建一个类似小行星的游戏。如果你想知道 OpenGL ES 2 到底是什么,那么我们将在本章后面讨论细节。

我们将构建一个非常简单但有趣且具有挑战性的游戏,在这个游戏中,我们可以一次绘制数百个对象并制作动画,即使是在相当旧的安卓设备上。

有了 OpenGL,我们将把我们的绘图效率提高到一个更高的水平,通过一些不太复杂的数学运算,我们的运动和碰撞检测将比我们以前的项目大大增强。

到本章结束时,我们将有一个基本工作的 OpenGL ES 2 引擎将我们简单但暂时静止的飞船绘制到屏幕上;60 FPS 或更高。

类型

如果你没看过或者玩过 80 年代街机热播(1979 年 11 月上映)小行星,为什么现在不去看看它的克隆版或者视频呢?

http://www.freeasteroids.org/免费网络游戏。

在 https://www.youtube.com/watch?v=WYSupJ5r2zo 的 YouTube 上。

让我们确切地讨论一下我们打算建造什么。

小行星模拟器

我们的游戏将设定在一个四向滚动的世界,玩家可以在搜寻小行星时穿越这个世界。世界将被封闭在一个矩形的边界中,以防止小行星偏离太远,边界也将成为玩家要避免的另一个危险。

游戏控制

我们将通过一些简单的修改重用我们的类,甚至可以保持相同的按钮布局。然而,正如我们将看到的,我们将以与我们的复古平台非常不同的方式在屏幕上绘制按钮。此外,玩家将左右旋转飞船 360 度,而不是左右行走。跳转按钮将成为一个推力切换开关,打开和关闭前进运动,拍摄按钮将保持不变。我们还会在同一个地方设置暂停按钮。

游戏规则

当小行星撞击边界时,会弹回游戏世界。如果玩家击中边界,将会失去一条生命,飞船将在屏幕中央重生。如果小行星撞上飞船,这也是致命的。

玩家将从三条生命开始,必须清除小行星模拟器中的所有小行星。平视显示器将显示剩余小行星和生命的总数。如果玩家清除了所有的小行星,那么下一波将会比上一波开始。它们也会移动得快一点。每清除一波将获得额外生命奖励。

随着项目的进行,我们将执行这些规则。

介绍 OpenGL ES 2

OpenGL ES 2 是用于嵌入式系统的开放图形库 ( OpenGL )的第二个主要版本。它是 OpenGL 在桌面系统上的移动化身。

为什么要用,怎么用?

OpenGL 作为本机进程运行,而不是像我们 Java 的其他部分一样在 Dalvik 虚拟机上运行。这也是超快的原因之一。OpenGL ES API 消除了与本机代码交互的所有复杂性,OpenGL 本身也在其本机代码库中提供了非常高效和快速的算法。

OpenGL 的第一个版本于 1992 年完成。关键是,即使在那个时候,OpenGL 也使用了可以说是最有效的代码和算法来绘制图形。现在,20 多年过去了,它一直在不断完善和改进,并适应了最新的图形硬件,包括移动和桌面。所有的移动图形处理器制造商都专门设计他们的硬件来兼容最新版本的 OpenGL ES。

因此,试图改进 OpenGL ES 可能是徒劳的。

类型

当专门为称为 DirectX 的 Windows 设备开发时,还有另一个可行的图形应用编程接口选项。

版本 2 的整洁是什么?

OpenGL ES 的第一个版本在当时肯定印象深刻。我记得第一次在手机上玩 3D 射手的时候差点从椅子上摔下来!这当然很平常。然而,与桌面版的 OpenGL 相比,OpenGL ES 1 有一个主要缺点。

OpenGL ES 1 有一个固定的功能管道。要绘制的几何图形进入图形处理器并被绘制出来,但是在 OpenGL ES 接管游戏框架的绘制之前,需要对单个像素进行任何进一步的操作。

现在,有了 OpenGL ES 2,我们可以访问所谓的可编程流水线。也就是说,我们可以将我们的图形发送出去进行绘制,但我们也可以编写在能够独立操作每个像素的图形处理器上运行的代码。这是一个非常强大的特性,尽管我们不会深入探讨它。

这个在图形处理器上运行的额外代码叫做 着色器程序。我们可以编写代码来操纵图形在所谓的 顶点着色器中的几何(位置)。我们还可以编写代码,单独操纵每个像素的外观,称为 片段着色器

实际上,我们甚至可以做得比像素操作更好。片段不一定是像素。这取决于硬件和正在处理的图形的具体性质。它可以是一个以上的像素或一个子像素:屏幕硬件中组成一个像素的几个光中的一个。

OpenGL ES 2 对于像这样的简单游戏的缺点是,你必须提供至少一个顶点和一个片段着色器,即使你不会用它们做很多事情。然而,正如我们将看到的,这并不十分困难。虽然我们不会深入探索着色器,但我们将使用 GL 着色器语言(【GLSL】)编写一些着色器代码,并一窥它们提供的可能性。

类型

如果可编程图形管道和着色器的力量太令人兴奋了,不能再等一天,那么我可以强烈推荐雅各布·罗德里格斯的 GLSL 精粹

https://www . packtpub . com/硬件与创意/glsl-essentials

这本书探索了桌面上的 OpenGL 着色器,任何有基本编程知识并愿意学习另一种语言(GLSL)的读者都可以很容易地访问它,尽管这种语言与 Java 有一些语法相似之处。

我们将如何使用 OpenGL ES 2?

我们将如何使用 OpenGL ES 2?

在 OpenGL 中,一切都是点、线或三角形。此外,我们可以将颜色和纹理附加到这个基本的几何图形上,还可以将这些元素组合起来,制作出我们在当今现代手机游戏中看到的复杂图形。

我们将使用每种类型的元素(点、线和三角形),它们统称为图元。

我们不会在这个项目上使用纹理。幸运的是,无纹理图元的外观适合构建我们的类似小行星的游戏。

除了图元之外,开放总帐还使用矩阵。矩阵 是一种进行算术运算的方法和结构。这种算法的范围可以从极其简单的高中水平计算到移动(翻译)坐标,或者执行更高级的数学运算将我们的游戏世界坐标转换成 GPU 可以使用的 OpenGL 屏幕坐标可能相当复杂。

关键是矩阵和使用它们的方法都完全由 OpenGL API 提供。这意味着我们只需要学习哪些方法进行哪些图形操作,而不必关心幕后(在图形处理器上)潜在的复杂数学。

在 OpenGL 中学习着色器、图元和矩阵的最好方法是开始使用它们。

准备 OpenGL ES 2

首先我们从我们的Activity类开始,和以前一样,这是我们游戏的切入点。创建一个新项目,并在应用程序名称字段中输入C9 Asteroids。选择手机和平板,出现提示后选择空白活动。在活动名称字段中键入AsteroidsActivity

类型

显然,您不必遵循我的确切命名选择,只需记住在代码中做一些小的修改,以反映您自己的命名选择。

您可以从layout文件夹中删除activity_asteroids.xml。您也可以删除AsteroidsActivity.java文件中的所有代码。留下包裹申报单就行了。

将布局锁定为横向

就像我们对前两个项目所做的一样,我们将确保游戏仅在风景模式下运行。我们将制作我们的AndroidManifest.xml文件,强制我们的AsteroidsActivity类全屏运行,并将其锁定为横向。让我们进行这些更改:

  1. 现在打开manifests文件夹,双击AndroidManifest.xml文件,在代码编辑器中打开。
  2. AndroidManifest.xml文件中,找到下面一行代码:

    java android:name=".AsteroidsActivity"

  3. 立即键入或复制粘贴这两行,使PlatformActivity全屏运行,并锁定在横向方向:

    java android:theme="@android:style/Theme.NoTitleBar.Fullscreen" android:screenOrientation="landscape"

现在我们可以继续用 OpenGL 实现我们的小行星模拟器游戏了。

活动

首先,我们有我们熟悉的课。这里唯一新的是我们视图类的类型。我们声明一个名为GLSurfaceView的成员。这是一个可以让我们轻松访问 OpenGL 的类。我们很快就会知道了。请注意,我们所做的只是通过传递Activity上下文和我们以通常方式获得的屏幕分辨率来初始化GLSurfaceView。执行AsteroidsActivity类,如图所示:

package com.gamecodeschool.c9asteroids;

import android.app.Activity;
import android.graphics.Point;
import android.opengl.GLSurfaceView;
import android.os.Bundle;
import android.view.Display;

public class AsteroidsActivity extends Activity {

    private GLSurfaceView asteroidsView;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Get a Display object to access screen details
        Display display = getWindowManager().getDefaultDisplay();

        // Load the resolution into a Point object
        Point resolution = new Point();
        display.getSize(resolution);

        asteroidsView = new AsteroidsView 
          (this, resolution.x, resolution.y); 

        setContentView(asteroidsView);
    }

    @Override
    protected void onPause() {
        super.onPause();

        asteroidsView.onPause();

    }

    @Override
    protected void onResume() {
        super.onResume();

        asteroidsView.onResume();

    }
}

接下来,我们将看到一些 OpenGL 代码。

视图

在这里,我们将实现GLSurfaceView类。实际上,这不是真正的动作发生的地方,但是它允许我们附加一个 OpenGL 渲染器。这是一个实现Renderer接口的类。除了在这个关键的Renderer中,GLSurfaceView类使我们能够覆盖onTouchListener方法,该方法将允许我们以与前面项目中SurfaceView相同的方式检测玩家输入。

Android Studio 不自动导入,甚至不建议所有需要的 OpenGL 导入。因此,我在代码清单中包含了一些类的所有导入。此外,您会注意到,有时我们使用静态导入。这也将使代码更易读。

在后面的代码中,我们声明并初始化了一个即将实现的GameManager类型的新对象。我们通过调用setEGLContextClientVersion(2)将 OpenGL 版本设置为 2,通过调用setRenderer()并传入我们的GameManager对象来设置我们的重要渲染器对象。创建一个名为AsteroidsView的新类,并按如下方式实现:

import android.content.Context;
import android.opengl.GLSurfaceView;

public class AsteroidsView extends GLSurfaceView{

    GameManager gm;

    public AsteroidsView(Context context, int screenX, int screenY) {
        super(context);

        gm = new GameManager(screenX, screenY);

        // Which version of OpenGl we are using
        setEGLContextClientVersion(2);

        // Attach our renderer to the GLSurfaceView
        setRenderer(new AsteroidsRenderer(gm));

    }

}

现在,我们可以看看我们的GameManager课涉及到了什么。

一个班级来管理我们的游戏

这个类将控制玩家的等级,生命数量,以及游戏世界的整体大小。随着项目的进展,它会有所发展,但与上一个项目中的 LevelManager 和 PlayerState 类的组合深度相比,它仍然非常简单,尽管它实际上取代了两者。

在接下来的代码中,我们声明int成员持有游戏世界的宽度和高度;我们可以把它做得更大或更小。我们用布尔playing记录游戏状态。

GameManager类还需要知道屏幕的高度和宽度(以像素为单位),当对象在AsteroidsView类中初始化回来时,这个信息被传递给构造器。

还要注意metresToShowXmetresToShowY成员变量。从我们上一个项目的Viewport课上,这些听起来可能很熟悉。这些变量将用于完全相同的事情:定义游戏世界的当前可视区域。但是,这一次,OpenGL 将在绘制之前(使用矩阵)处理要裁剪的对象。我们将很快看到这种情况的发生。

请注意,虽然 OpenGL 负责裁剪和缩放我们想要显示的游戏世界区域,但它对每帧更新哪些对象没有任何影响。然而,正如我们将看到的,这正是我们想要的游戏,因为我们希望我们所有的对象每帧都更新自己,即使它们在屏幕外。所以这个游戏不需要Viewport类。

最后,我们想要一个方便的暂停和取消暂停游戏的方法,我们通过switchPlayingStatus方法提供了这个功能。创建一个名为GameManager的新类并实现,如图所示:

public class GameManager {

    int mapWidth = 600;
    int mapHeight = 600;
    private boolean playing = false;

    // Our first game object
    SpaceShip ship;

    int screenWidth;
    int screenHeight;

    // How many metres of our virtual world
    // we will show on screen at any time.
    int metresToShowX = 390;
    int metresToShowY = 220;

    public GameManager(int x, int y){

        screenWidth = x;
        screenHeight = y;

    }

    public void switchPlayingStatus() {
        playing = !playing;

    }

    public boolean isPlaying(){
        return playing;
    }
}

我们现在可以先看看这些强大的着色器,以及我们将如何管理它们。

管理简单着色器

一个应用程序可以有许多着色器。然后,我们可以将不同的着色器附加到不同的游戏对象上,以创建所需的效果。

在这个游戏中,我们将只有一个顶点和一个片段着色器。但是,当您看到如何将着色器附加到基本体时,很明显拥有更多着色器很简单。

  1. 首先,我们需要将在 GPU 中执行的着色器的代码。
  2. 然后我们需要编译代码。
  3. 最后,我们需要将两个编译的着色器链接到一个总帐程序中。

当我们实现下一个简单的类时,我们将看到如何将这个功能捆绑到一个方法调用中,这个方法调用可以由我们游戏中的一个对象进行,并将准备运行的 GL 程序返回给游戏对象。当我们在本章后面构建我们的GameObject类时,我们将看到如何使用这个 GL 程序。

让我们继续,在新的课堂上实施必要的三个步骤。创建一个新类,并将其称为GLManager。如下所示添加静态导入:

import static android.opengl.GLES20.GL_FRAGMENT_SHADER;
import static android.opengl.GLES20.GL_VERTEX_SHADER;
import static android.opengl.GLES20.glAttachShader;
import static android.opengl.GLES20.glCompileShader;
import static android.opengl.GLES20.glCreateProgram;
import static android.opengl.GLES20.glCreateShader;
import static android.opengl.GLES20.glLinkProgram;
import static android.opengl.GLES20.glShaderSource;

接下来,我们将添加一些公共静态最终成员变量,这些变量可以在本章后面的GameObject类中使用。虽然当我们开始使用它们的时候,我们会看到它们是如何工作的,但这里有一个快速的初步解释。

COPONENTS_PER_VERTEX是将用于表示构成我们游戏对象的图元中的单个顶点(点)的值的数量。如您所见,我们将其初始化为三个坐标: xyz

我们还有有FLOAT_SIZE,初始化为4。这是 Java 浮点中的字节数。正如我们将很快看到的,OpenGL 喜欢以ByteBuffer的形式传递给它的所有图元。我们需要确保我们准确知道每条信息在ByteBuffer中的位置。

接下来,我们声明STRIDE并将其初始化为COMPONENTS_PER_VERTEX * FLOAT_SIZE。由于 OpenGL 使用浮点类型来保存几乎所有它使用的数据,STRIDE现在等于代表一个对象的单个顶点的数据的字节大小。继续将这些成员添加到类的顶部:

public class GLManager {

     // Some constants to help count the number of bytes between
     // elements of our vertex data arrays
     public static final int COMPONENTS_PER_VERTEX = 3;
     public  static final int FLOAT_SIZE = 4;
     public static final int STRIDE =
       (COMPONENTS_PER_VERTEX)
        * FLOAT_SIZE;

     public static final int ELEMENTS_PER_VERTEX = 3;// x,y,z

GLSL 本身就是一种语言,它也有自己的类型,可以利用这些类型的变量。在这里,我们声明并初始化一些字符串,我们可以使用这些字符串在代码中更清晰地引用这些变量。

对这些类型的讨论超出了本书的范围,但简单解释了它们将代表一个矩阵(u_matrix)、一个位置(a_position)和一种颜色(u_Color)。我们将很快在我们的着色器代码中看到这些变量的实际 GLSL 类型的示例。

在字符串之后,我们声明三个int类型。这三个公共静态(但不是最终)成员将用于存储着色器中同名类型的位置。这允许我们在给 OpenGL 绘制图元的最终指令之前,操纵着色器程序中的值。

// Some constants to represent GLSL types in our shaders
public static final String U_MATRIX = "u_Matrix";
public static final String A_POSITION = "a_Position";
public static final String U_COLOR = "u_Color";

// Each of the above constants also has a matching int
// which will represent its location in the open GL glProgram
public static int uMatrixLocation;
public static int aPositionLocation;
public static int uColorLocation;

最后,我们来到我们的 GLSL 代码,它是一个打包在字符串中的顶点着色器。请注意,我们声明了一个名为u_Matrix的类型统一的变量mat4和类型属性vec4的变量a_Position。稍后我们将在我们的GameObject类中看到如何获取这些变量的位置,以使我们能够从我们的 Java 代码中为它们传递值。

代码中以void main()开头的那行是实际执行着色器代码的地方。注意gl_position被赋予了我们刚才声明的两个变量乘积的值。同样gl_PointSize被赋予3.0的值。这将是我们绘制所有点图元的大小。在前一个代码块之后输入顶点着色器的代码:

// A very simple vertexShader glProgram
// that we can define with a String

private static String vertexShader =
     "uniform mat4 u_Matrix;" +
     "attribute vec4 a_Position;" +

     "void main()" +
     "{" +
       "gl_Position = u_Matrix * a_Position;" +
       "gl_PointSize = 3.0;"+
  "}";

接下来,我们将实现片段着色器。这里发生了一些事情。首先,线精度mediump浮动告诉 OpenGL 以中等精度绘制,因此速度也是中等的。然后我们可以看到我们的变量u_Color被声明为类型一致vec4。我们将很快在GameObject类中看到如何将color值传递给这个变量。

void main()开始执行时,我们只需将u_Color分配给gl_FragColor。所以,无论给u_Colour分配什么颜色,所有的碎片都会是那个颜色。就在片段着色器之后,我们声明了一个名为programint,它将作为我们的 GL 程序的句柄。

在前一个代码块之后输入片段着色器的代码:

// A very simple vertexShader glProgram
// that we can define with a String

private static String vertexShader =
    "uniform mat4 u_Matrix;" +
    "attribute vec4 a_Position;" +

    "void main()" +
    "{" +
        "gl_Position = u_Matrix * a_Position;" +
        "gl_PointSize = 3.0;"+
    "}";

这是一个 getter 方法,它返回 GL 程序的句柄:

public static int getGLProgram(){
  return program;
}

下一个方法看起来可能很复杂,但它所做的只是向调用者返回一个编译和链接的程序。它通过以compileVertexShader()compileFragmentShader()为参数调用 OpenGL 的linkProgram方法来实现。接下来,我们看到这两个新方法,它们需要做的就是调用我们的方法compileShader(),OpenGL 常量表示着色器的类型,适当的字符串保存匹配的着色器 GLSL 代码。

将我们刚才讨论的三种方法输入GLManager类:

public static int buildProgram(){
    // Compile and link our shaders into a GL glProgram object
    return linkProgram(compileVertexShader(),compileFragmentShader());

}

private static int compileVertexShader() {
    return compileShader(GL_VERTEX_SHADER, vertexShader);
}

private static int compileFragmentShader() {
    return compileShader(GL_FRAGMENT_SHADER, fragmentShader);
}

现在我们看到当我们的方法被称为compileShader()时会发生什么。首先,我们基于type参数创建一个着色器的句柄。然后,我们将手柄和代码传递给glShaderSource()。最后,我们用glCompileShader()编译着色器,并返回调用方法的句柄:

private static int compileShader(int type, String shaderCode) {

    // Create a shader object and store its ID
    final int shader = glCreateShader(type);

    // Pass in the code then compile the shader
    glShaderSource(shader, shaderCode);
    glCompileShader(shader);

    return shader;
}

现在我们可以看到流程的最后一步。我们用glCreateProgram()创建一个空程序。然后我们用glAttachShader()依次附加每个编译好的着色器,最后将它们链接到一个我们可以用glLinkProgram()实际使用的程序中:

private static int linkProgram(int vertexShader, int fragmentShader) {

  // A handle to the GL glProgram -
  // the compiled and linked shaders
     program = glCreateProgram();

     // Attach the vertex shader to the glProgram.
     glAttachShader(program, vertexShader);

     // Attach the fragment shader to the glProgram.
     glAttachShader(program, fragmentShader);

     // Link the two shaders together into a glProgram.
     glLinkProgram(program);

     return program;
}
}// End GLManager

注意,我们创建了一个程序,我们可以通过它的句柄和getProgram方法访问它。我们还可以访问我们创建的所有公共静态成员,因此我们可以从 Java 代码中修改着色器程序中的变量。

游戏的主循环——渲染器

现在我们将看到我们代码中真正的“T2”将走向何方。创建一个新类,并将其称为AsteroidsRenderer。这是我们作为渲染器附加到GLSurfaceView的类。添加如下导入语句,注意其中一些是静态的:

import android.graphics.PointF;
import android.opengl.GLSurfaceView.Renderer;
import android.util.Log;
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;
import static android.opengl.GLES20.GL_COLOR_BUFFER_BIT;
import static android.opengl.GLES20.glClear;
import static android.opengl.GLES20.glClearColor;
import static android.opengl.GLES20.glViewport;
import static android.opengl.Matrix.orthoM;

现在我们将构建类。首先要注意的是我们之前提到的类实现了Renderer,所以我们需要重写三个方法。分别是onSurfaceCreated()onSurfaceChanged()onDrawFrame()。此外,在这个类中,我们将首先添加一个构造函数来设置一切,一个createObjects方法,我们将最终初始化我们所有的游戏对象,一个update方法,我们将在每一帧更新我们所有的对象,一个draw方法,我们将在每一帧绘制我们所有的对象。

我们将在实现每种方法时对其进行探索和解释,我们还将看到我们的方法如何适应 OpenGL 渲染器系统,该系统规定了这个类的流程。

首先,我们有一些值得仔细研究的成员变量。

布尔调试将用于打开和关闭控制台的输出。frameCounteraverageFPSfps变量不仅用于检查我们达到的帧速率,还用于传递给我们的游戏对象,这些游戏对象将根据每帧经过的时间进行自我更新。

我们第一个真正有趣的变量是浮点数组viewportMatrix。顾名思义,它将包含一个矩阵,OpenGL 可以使用它来计算进入我们游戏世界的视口。

我们有一个GameManager来保存对GameManager对象的引用,AsteroidsView将其传递给这个类的构造函数。最后,我们有两个PointF对象。

我们将在构造函数中初始化PointF对象,并将它们用于一些不同的事情,以避免在主游戏循环中取消对任何对象的引用。当垃圾收集器开始清理丢弃的对象时,即使是 OpenGL 也会变慢。避免召唤垃圾收集器将是整个游戏的目标。

AsteroidsRenderer类顶部输入成员变量:

public class AsteroidsRenderer implements Renderer {

// Are we debugging at the moment

boolean debugging = true;

// For monitoring and controlling the frames per second

long frameCounter = 0;
long averageFPS = 0;
private long fps;

// For converting each game world coordinate
// into a GL space coordinate (-1,-1 to 1,1)
// for drawing on the screen

private final float[] viewportMatrix = new float[16];

// A class to help manage our game objects
// current state.

private GameManager gm;

// For capturing various PointF details without
// creating new objects in the speed critical areas

PointF handyPointF;
PointF handyPointF2;

这是我们的构造函数,从参数中初始化我们的GameManager引用,并创建两个方便的PointF对象以备使用:

public AsteroidsRenderer(GameManager gameManager) {

     gm = gameManager;

     handyPointF = new PointF();
     handyPointF2 = new PointF();

}

这是第一个被重写的方法。每次创建带有附加渲染器的GLSurfaceView类时都会调用它。我们调用glClearColor()来设置每次清理屏幕时 OpenGL 将使用哪种颜色。然后,我们使用我们的GLManager.buildProgram()方法构建着色器程序,并调用我们即将编码的createObjects方法。

@Override
public void onSurfaceCreated(GL10 glUnused, EGLConfig config) {

   // The color that will be used to clear the
   // screen each frame in onDrawFrame()
   glClearColor(0.0f, 0.0f, 0.0f, 0.0f);

   // Get GLManager to compile and link the shaders into an object
   GLManager.buildProgram();

   createObjects();

}

这个下一个被覆盖的方法在onSurfaceCreated()之后和屏幕方向改变的任何时候被调用一次。在这里,我们调用glViewport()方法告诉 OpenGL 将 OpenGL 坐标系映射到的像素坐标。

OpenGL 坐标系与我们在前面两个项目中用来处理的像素坐标有很大的不同。屏幕中心为 0,0,左右为-1,上下为 1。

The game's main loop – the renderer

由于大多数屏幕不是方形的,所以前面的情况更加复杂,但是-1 到 1 的范围必须同时代表 xy 轴。幸运的是,我们的glViewport()已经为我们处理好了。

我们在这个方法中看到的最后一件事是调用orthoM方法,我们的viewportMatrix作为第一个参数。OpenGL 现在将准备viewportMatrix在 OpenGL 内部使用。方法orthoM()创建一个矩阵,将坐标转换为正投影视图。如果我们的坐标是三维的,它会有让所有的物体看起来距离相同的效果。由于我们正在制作一个二维游戏,这也适合我们。

输入onSurfaceChanged方法的代码:

@Override
    public void onSurfaceChanged(GL10 glUnused, int width, int height) {

        // Make full screen
        glViewport(0, 0, width, height);

        /*
            Initialize our viewport matrix by passing in the starting
            range of the game world that will be mapped, by OpenGL to
            the screen. We will dynamically amend this as the player
            moves around.

            The arguments to setup the viewport matrix:
            our array,
            starting index in array,
            min x, max x,
            min y, max y,
            min z, max z)
        */

            orthoM(viewportMatrix, 0, 0, 
        gm.metresToShowX, 0, 
        gm.metresToShowY, 0f, 1f);
}

这是我们的createObjects方法,正如你所看到的,我们创建一个类型为SpaceShip的对象,并将地图的高度和宽度传递给构造函数。我们将在本章后面构建SpaceShip类及其父类GameObject。进入createObjects方法:

    private void createObjects() {
        // Create our game objects

        // First the ship in the center of the map
        gm.ship = new SpaceShip(gm.mapWidth / 2, gm.mapHeight / 2);
    }

这是被覆盖的onDrawFrame方法。它被系统连续调用。当我们将AsteroidsRenderer附加到视图上时,我们可以通过设置渲染模式来控制何时调用这个函数,但是默认的 OpenGL 控制的连续调用正是我们所需要的。

我们将startFrameTime设置为当前系统时间。然后,如果isPlaying()返回true,我们调用我们即将实现的update方法。然后,我们称之为draw(),它会告诉我们所有的物体画出它们自己。

然后我们更新timeThisFramefps,如果我们正在调试,可以选择每秒输出平均帧,每 100 帧。

现在我们知道 OpenGL 每秒会调用onDrawFrame()多达数百次。我们每次都有条件地调用我们的update方法,也调用我们的draw方法。除了实际的draw和更新方法本身,我们已经有效地实现了我们的游戏循环。

onDrawFrame方法添加到类中:

@Override
public void onDrawFrame(GL10 glUnused) {

        long startFrameTime = System.currentTimeMillis();

        if (gm.isPlaying()) {
            update(fps);
        }

        draw();

        // Calculate the fps this frame
        // We can then use the result to
        // time animations and more.
        long timeThisFrame = System.currentTimeMillis() - startFrameTime;
        if (timeThisFrame >= 1) {
            fps = 1000 / timeThisFrame;
        }

        // Output the average frames per second to the console
        if (debugging) {
            frameCounter++ ;
            averageFPS = averageFPS + fps;
            if (frameCounter > 100) {
                averageFPS = averageFPS / frameCounter;
                frameCounter = 0;
                Log.e("averageFPS:", "" + averageFPS);
            }
        }
    }

下面是我们的update方法,暂时留下一个空身体:

    private void update(long fps) {

    }

现在,我们来到我们的draw方法,从onDrawFrame方法开始每帧调用一次。在这里,我们将船只的当前位置加载到一个方便的PointF对象中。显然,由于我们还没有实现我们的SpaceShip类,这个方法调用将产生一个错误。

我们在draw()中做的下一件事相当有趣。我们根据游戏世界中的当前位置以及分配给metresToShowXmetresToShowY的值来修改我们的viewportMatrix。简单地说,我们以船所在的地方为中心,向四个方向延伸一半的距离。请记住,这发生在每一帧,所以我们的视口将不断跟随玩家船。

接下来,我们调用glClear()用我们在onSurfaceCreated()中设置的颜色清除屏幕。我们在draw()做的最后一件事是在我们的SpaceShip对象上调用draw方法。这意味着与我们之前的两款游戏相比,设计上有了很大的改变。

我们已经提到了这一点,但在这里我们可以看到它在起作用:每个对象都会画出自己。另外,请注意,我们传入了新配置的viewportMatrix

输入draw方法的代码:

private void draw() {

    // Where is the ship?
    handyPointF = gm.ship.getWorldLocation();

    // Modify the viewport matrix orthographic projection
    // based on the ship location
    orthoM(viewportMatrix, 0,
        handyPointF.x - gm.metresToShowX / 2,
        handyPointF.x + gm.metresToShowX / 2,
        handyPointF.y - gm.metresToShowY / 2,
        handyPointF.y + gm.metresToShowY / 2,
        0f, 1f);

    // Clear the screen
    glClear(GL_COLOR_BUFFER_BIT);

    // Start drawing!

    // Draw the ship
    gm.ship.draw(viewportMatrix);
}
}

现在,我们可以建立我们的GameObject超级班级,紧随其后的是它的第一个孩子,SpaceShip。我们将看到这些对象将如何设法使用 OpenGL 来绘制它们自己。

构建一个 OpenGL 友好的游戏对象超级类

让我们直接进入代码。正如我们将会看到的,这个GameObject将会和上一个项目的GameObject类有很多共同之处。最显著的区别是,这个最新的GameObject当然会使用 GL 程序的句柄、子类的图元(顶点)数据和包含在viewportMatrix中的视口矩阵来绘制自己。

创建一个新的类,称之为GameObject,并输入这些导入语句,再次注意其中一些是静态的:

import android.graphics.PointF;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import static android.opengl.GLES20.GL_FLOAT;
import static android.opengl.GLES20.GL_LINES;
import static android.opengl.GLES20.GL_POINTS;
import static android.opengl.GLES20.GL_TRIANGLES;
import static android.opengl.GLES20.glDrawArrays;
import static android.opengl.GLES20.glEnableVertexAttribArray;
import static android.opengl.GLES20.glGetAttribLocation;
import static android.opengl.GLES20.glGetUniformLocation;
import static android.opengl.GLES20.glUniform4f;
import static android.opengl.GLES20.glUniformMatrix4fv;
import static android.opengl.GLES20.glUseProgram;
import static android.opengl.Matrix.multiplyMM;
import static android.opengl.Matrix.setIdentityM;
import static android.opengl.Matrix.setRotateM;
import static android.opengl.Matrix.translateM;
import static android.opengl.GLES20.glVertexAttribPointer;
import static com.gamecodeschool.c9asteroids.GLManager.*;

有许多成员变量,许多是不言自明的,评论只是为了刷新我们的记忆,但也有一些全新的。

例如,我们有一个和一个enum来表示我们将要创建的每种类型的GameObject。这样做的原因是我们会把一些物体画成点,一些画成线,还有一个画成三角形。我们使用 OpenGL 的方式在不同类型的图元之间是一致的;因此,这就是为什么我们将代码捆绑到这个父类中。然而,绘制基元的最终调用因基元的类型而异。我们可以在switch语句中使用type变量来执行正确类型的draw方法。

我们还有一个int numElementsnumVertices保存组成任何给定GameObject的点数。我们很快就会看到,这些将从子类中设置。

我们有另一个称为modelVertices的浮动数组,它将保存组成模型的所有顶点。

GameObject类中输入第一批成员变量,看一下评论,刷新一下记忆或者搞清楚各个成员最终会用于什么:

public class GameObject {

    boolean isActive;

    public enum Type {SHIP, ASTEROID, BORDER, BULLET, STAR}

    private Type type;

    private static int glProgram =-1;

    // How many vertices does it take to make
    // this particular game object?
    private int numElements;
    private int numVertices;

    // To hold the coordinates of the vertices that
    // define our GameObject model
    private float[] modelVertices;

    // Which way is the object moving and how fast?
    private float xVelocity = 0f;
    private float yVelocity = 0f;
    private float speed = 0;
    private float maxSpeed = 200;

    // Where is the object centre in the game world?
    private PointF worldLocation = new PointF();

接下来,我们将添加另一批成员变量。首先,也是最值得注意的,我们有一个FloatBuffer叫做vertices。正如我们所知,OpenGL 以本机代码执行,FloatBuffers是它喜欢如何消费它的数据。我们将看到如何将所有顶点打包到这个FloatBuffer中。

我们还将使用我们GLManager类的所有公共静态成员来帮助我们做好这件事。

就 OpenGL 而言,第二个最有趣的新成员可能是我们还有另外三个浮点数组,分别叫做modelMatrixviewportModelMatrixrotateViewportModelMatrix。这些将有助于 OpenGL 完全按照要求绘制GameObject类。当我们到达这个类的draw方法时,我们将检查它们是如何被初始化和使用的。

我们也有一堆成员持有不同的角度和旋转率。我们如何使用和更新这些来通知 OpenGL 我们的对象的方向,我们将很快看到:

    // This will hold our vertex data that is
    // passed into the openGL glProgram
    // OPenGL likes FloatBuffer
    private FloatBuffer vertices;

    // For translating each point from the model (ship, asteroid etc)
    // to its game world coordinates
    private final float[] modelMatrix = new float[16];

    // Some more matrices for Open GL transformations
    float[] viewportModelMatrix = new float[16];
    float[] rotateViewportModelMatrix = new float[16];

    // Where is the GameObject facing?
    private float facingAngle = 90f;

    // How fast is it rotating?
    private float rotationRate = 0f;

    // Which direction is it heading?
    private float travellingAngle = 0f;

    // How long and wide is the GameObject?
    private float length;
    private float width;

我们现在实现构造函数。首先,我们检查之前是否编译过着色器,因为我们只需要编译一次。如果我们没有,这就是if(glProgarm == -1)区块内部发生的情况。

我们称setGLProgram()之后是glUseProgram(),以glProgram为自变量。这就是我们要做的,剩下的GLManager做,我们的 OpenGL 程序已经准备好了。

然而,在继续之前,我们通过调用各自的方法(glGetUniformLocation()glGetAttrtibuteLocation)来保存关键着色器变量的位置,以获取它们在我们的 GL 程序中的位置。我们将在这个类的draw方法中看到如何使用这些位置来操纵着色器中的值。

最后,我们将isActive设置为true。将此方法输入GameObject类:

public GameObject(){
    // Only compile shaders once
    if (glProgram == -1){
        setGLProgram();

        // tell OpenGl to use the glProgram
        glUseProgram(glProgram);

        // Now we have a glProgram we need the locations
        // of our three GLSL variables.
        // We will use these when we call draw on the object.
        uMatrixLocation = glGetUniformLocation(glProgram, U_MATRIX);
        aPositionLocation = glGetAttribLocation(glProgram, A_POSITION);
        uColorLocation = glGetUniformLocation(glProgram, U_COLOR);
    }

    // Set the object as active
    isActive = true;

}

现在我们有一些获取器和设置器,包括getWorldLocation(),这是我们从AsteroidsRenderersetGLProgram()中的draw方法中调用的。这使用了GLManager类的静态方法getGLProgram()来获得我们的 GL 程序的句柄。

将所有这些方法输入GameObject类:

public boolean isActive() {
  return isActive;
}

public void setActive(boolean isActive) {
  this.isActive = isActive;
}

public void setGLProgram(){
  glProgram = GLManager.getGLProgram();
}

public Type getType() {
  return type;
}

public void setType(Type t) {
  this.type = t;
}

public void setSize(float w, float l){
  width = w;
  length = l;

}

public PointF getWorldLocation() {
  return worldLocation;
}

public void setWorldLocation(float x, float y) {
  this.worldLocation.x = x;
  this.worldLocation.y = y;
}

下一个方法setVertices()是准备用 OpenGL 绘制对象的重要步骤。在我们的每个子类中,我们将构建一个浮点类型数组来表示构成游戏对象形状的顶点。每个游戏对象在外形上会明显不同,但setVertices法不需要欣赏差异,只需要数据。

正如我们在下一个代码块中看到的,该方法接收一个浮点数组作为参数。然后,它将等于数组长度的元素数量存储在numElements中。请注意,元素的数量不同于元素所代表的顶点的数量。制作一个顶点需要三个元素( xyz )。因此,我们可以通过将numElements除以ELEMENTS_PER_VERTEX将正确的值存储到numVertices中。

现在我们实际上可以通过调用allocateDirect()来初始化我们的ByteBuffer,并将我们新初始化的变量和FLOAT_SIZE一起传入。ByteOrder.nativeOrder方法只是检测特定系统的字符顺序,asFloatBuffer()告诉ByteBuffer将要存储的数据类型。我们现在可以通过调用vertices.put(modelVertices)将顶点数组存储到顶点ByteBuffer中。这个数据现在可以传递给 OpenGL 了。

类型

如果你想了解更多关于字符顺序的知识,请看维基百科的这篇文章:

http://en . Wikipedia . org/wiki/endiance

setVertices方法输入GameObject类:

public void setVertices(float[] objectVertices){

    modelVertices = new float[objectVertices.length];
    modelVertices = objectVertices;

    // Store how many vertices and elements there is for future use
    numElements = modelVertices.length;

    numVertices = numElements/ELEMENTS_PER_VERTEX;

    // Initialize the vertices ByteBuffer object based on the
    // number of vertices in the ship design and the number of
    // bytes there are in the float type
    vertices = ByteBuffer.allocateDirect(
            numElements
            * FLOAT_SIZE)
            .order(ByteOrder.nativeOrder()).asFloatBuffer();

    // Add the ship into the ByteBuffer object
    vertices.put(modelVertices);

}

现在我们来看看我们实际上是如何绘制我们的ByteBuffer的内容的。乍一看,下面的代码可能看起来很复杂,但是当我们在我们的ByteBuffer中讨论数据的性质以及 OpenGL 绘制这些数据所经历的步骤时,我们会看到它实际上相当简单。

由于我们还没有为我们的第一个GameObject子类编写代码,有一件关键的事情需要指出。代表游戏对象形状的顶点基于其自身的中心为零。

OpenGL 坐标系以 0,0 为中心,但是,说清楚一点,这是没有关系的。这叫做模型空间。下一张图片是我们的宇宙飞船在模型空间中的图像,我们将很快创建它:

Building an OpenGL-friendly, GameObject super class

正是这些数据包含在我们的ByteBuffer中。这个数据没有考虑方位(是船还是小行星旋转了),也没有考虑它在游戏世界中的位置,提醒一下,和 OpenGL 坐标系完全无关。

因此,在绘制我们的ByteBuffer之前,我们需要转换这个数据,或者更准确地说,我们需要准备一个合适的矩阵,我们将与数据一起传递到 OpenGL 中,以便 OpenGL 知道如何使用或转换数据。

我已经把draw方法分成了六大块来讨论我们如何做到这一点。请注意,我们的viewPort矩阵是在我们的AsteroidsRenderer类的draw方法中准备的,该方法以船只的位置为中心,基于我们想要显示的游戏世界的比例,并作为参数传递。

首先,我们调用glUseProgram()并将句柄传递给我们的程序。然后我们将ByteBuffer的内部指针设置为vertices.position(0)开始。

glVertexAttributePointer方法使用我们的aPositionLocation变量以及我们的GLManager静态常数,当然还有vertices ByteBuffer来将我们的顶点与顶点着色器中的aPosition变量相关联。最后,对于这段代码,我们告诉 OpenGL 启用属性数组:

    public void draw(float[] viewportMatrix){

        // tell OpenGl to use the glProgram
        glUseProgram(glProgram);

        // Set vertices to the first byte
        vertices.position(0);

        glVertexAttribPointer(
              aPositionLocation,
              COMPONENTS_PER_VERTEX,
              GL_FLOAT,
              false,
              STRIDE,
              vertices);

        glEnableVertexAttribArray(aPositionLocation);

现在,我们将矩阵投入使用。我们通过调用setIndentityM()modelMatrix数组中创建一个身份矩阵。

正如我们将看到的,我们将使用和组合大量矩阵。一个身份矩阵作为一个起点或容器,我们可以在其上构建一个矩阵,它组合了我们需要发生的所有转换。关于恒等式矩阵的一种非常简单但不完全准确的思考方式是,它就像数字 1。当你乘以一个恒等式矩阵时,它不会对和的另一部分造成任何改变。然而,对于进入等式的下一部分,答案是正确的。如果这让你很烦,你想知道更多,看看这些关于矩阵和矩阵的身份的快速教程。

矩阵:

https://www . khanacademy . org/math/precalculus/precalc-matrix/Basic _ matrix _ operations/v/matrix 简介

身份矩阵:

https://www . khanacademy . org/math/precalculus/precalc-matrix/zero-identity-matrix-tutorial/v/identity-matrix

然后我们将我们的新modelMatrix传入translateM方法。翻译是数学说话的移动。仔细看看传入translateM()的论点。我们正在通过物体的 x 任何 y 世界位置。这就是 OpenGL 知道对象在哪里的方式:

    // Translate model coordinates into world coordinates
    // Make an identity matrix to base our future calculations on
    // Or we will get very strange results
    setIdentityM(modelMatrix, 0);
    // Make a translation matrix

    /*
        Parameters:
        m   matrix
        mOffset index into m where the matrix starts
        x   translation factor x
        y   translation factor y
        z   translation factor z
    */
    translateM(modelMatrix, 0, worldLocation.x, worldLocation.y, 0);

我们知道 OpenGL 有一个矩阵来将我们的对象转换成它的世界位置。它也有一个带有模型空间坐标的ByteBuffer类,但是它是如何将翻译后的模型空间坐标转换成我们使用 OpenGL 坐标系绘制的视口的呢?

它使用视口矩阵,该矩阵由每个帧修改并传递到此方法中。我们需要做的就是用multiplyMM()viewportMatrix和最近翻译的modelMatrix相乘。该方法创建组合或相乘的矩阵,并将结果存储在viewportModelMatrix中:

   // Combine the model with the viewport
   // into a new matrix
   multiplyMM(viewportModelMatrix, 0, 
      viewportMatrix, 0, modelMatrix, 0);

我们几乎完成了矩阵的创建。OpenGL 需要对ByteBuffer中的顶点进行的唯一其他可能的变形是将它们旋转到facingAngle参数。

接下来,我们创建一个适合当前对象的朝向角度的旋转矩阵,并将结果存储回modelMatrix

然后,我们将新旋转的modelMatrix与我们的viewportModelMatrix组合或相乘,并将结果存储在rotateViewportModelMatrix中。这是我们将传递到 OpenGL 系统中的最终矩阵:

   /*
        Now rotate the model - just the ship model

        Parameters
        rm  returns the result
        rmOffset    index into rm where the result matrix starts
        a   angle to rotate in degrees
        x   X axis component
        y   Y axis component
        z   Z axis component
    */
    setRotateM(modelMatrix, 0, facingAngle, 0, 0, 1.0f);

    // And multiply the rotation matrix into the model-viewport 
    // matrix
    multiplyMM(rotateViewportModelMatrix, 0, 
      viewportModelMatrix, 0, modelMatrix, 0);

现在,我们使用glUniformMatrix4fv()方法传入矩阵,并使用uMatrixLocation变量(这是顶点着色器中矩阵相关变量的位置)和参数中的最终矩阵。

我们还通过调用带有uColorLocation和 RGBT(红、绿、蓝、透明度)值的glUniform4f()来选择颜色。所有值都设置为 1.0,因此片段着色器将绘制白色。

   // Give the matrix to OpenGL

    glUniformMatrix4fv(uMatrixLocation, 1, false,                                        
    rotateViewportModelMatrix, 0);

    // Assign a color to the fragment shader
    glUniform4f(uColorLocation, 1.0f, 1.0f, 1.0f, 1.0f);

最后,我们根据对象类型进行切换,并绘制点、线或三角形图元:

   // Draw the point, lines or triangle
    switch (type){
        case SHIP:
        glDrawArrays(GL_TRIANGLES, 0, numVertices);
        break;

        case ASTEROID:
        glDrawArrays(GL_LINES, 0, numVertices);
        break;

        case BORDER:
        glDrawArrays(GL_LINES, 0, numVertices);
        break;

       case STAR:
        glDrawArrays(GL_POINTS, 0, numVertices);
        break;

        case BULLET:
        glDrawArrays(GL_POINTS, 0, numVertices);
        break;
    }

} // End draw()

}// End class

现在我们已经有了我们的GameObject类的基础,我们可以制作一个类来表示我们的飞船,并把它画到屏幕上。

飞船

这个类是好看又简单,虽然会随着项目的发展而演变。构造函数接收游戏世界中的起始位置。我们使用GameObject类中的方法设置船只的类型和世界位置,并设置宽度和高度。

我们声明并初始化一些变量,以简化模型空间坐标的初始化,然后我们继续初始化一个浮点数组,该数组有三个顶点,代表我们船上的三角形。请注意,这些值是以 x = 0y = 0 为中心的。

接下来我们要做的就是,调用setVertices(),而GameObject将为 OpenGL 准备好ByteBuffer:

public class SpaceShip extends GameObject{

  public SpaceShip(float worldLocationX, float worldLocationY){
       super();

        // Make sure we know this object is a ship
        // So the draw() method knows what type
        // of primitive to construct from the vertices

        setType(Type.SHIP);

        setWorldLocation(worldLocationX,worldLocationY);

        float width = 15;
        float length = 20;

        setSize(width, length);

        // It will be useful to have a copy of the
        // length and width/2 so we don't have to keep dividing by 2
        float halfW = width / 2;
        float halfL = length / 2;

        // Define the space ship shape
        // as a triangle from point to point
        // in anti clockwise order
        float [] shipVertices = new float[]{

               - halfW, - halfL, 0,
               halfW, - halfL, 0,
               0, 0 + halfL, 0

      };

       setVertices(shipVertices);

     }

}

终于,我们可以看到自己的劳动成果了。

以 60 + FPS 绘制

通过三个简单的步骤,我们将能够瞥见我们的宇宙飞船:

  • GameManager成员变量添加一个SpaceShip对象:

    ```java private boolean playing = false;

    // Our first game object SpaceShip ship;

     int screenWidth;
    

    ```

  • 将新的SpaceShip()呼叫添加到createObjects方法:

    ```java private void createObjects() {

    // Create our game objects // First the ship in the center of the map gm.ship = new SpaceShip(gm.mapWidth / 2, gm.mapHeight / 2); } ```

  • AsteroidsRenderer

    ```java // Start drawing! // Draw the ship gm.ship.draw(viewportMatrix);

    ```

    draw方法中加入在每一帧画飞船的调用

运行游戏并查看输出:

Drawing at 60 + FPS

不太令人印象深刻的视觉效果,但它在调试模式下每秒运行 67 到 212 帧,同时在老化的三星 Galaxy S2 手机上输出到控制台。

Drawing at 60 + FPS

在整个项目中,我们的目标是添加数百个对象,并保持每秒帧数超过 60 帧。

类型

该书的一位审稿人报告称,Nexus 5 的帧率超过了每秒 1000 帧!因此,如果您计划向谷歌 Play 商店发布最大帧率锁定策略,以节省电池寿命,这将是值得考虑的。

总结

建立一个绘图系统有点冗长。然而,现在它已经完成,我们可以更容易地生产新的对象。我们所要做的就是定义类型和顶点,然后我们可以轻松地绘制它们。

正是因为这项基础工作,下一章在视觉上会更有收获。接下来,我们将创建闪烁的星星、游戏世界边界、旋转和移动的小行星、呼啸的子弹和平视显示器,并为飞船添加完整的控制和运动。