The OpenGL Tutorial — Part V

Refactoring — Using GameActivity and OpenGL ES 3 on Android

Roger Boesch
5 min readSep 26, 2024

Introduction

Six years ago, I wrote part IV of this tutorial series and promised to show how to write 3D games. Now it’s time for this, but I also decided to refactor the code first to prepare for part VI.

Refactoring

GameActivity

In the previous chapter, I explained how to do everything manually, creating a JNI wrapper and gluing it to C++. Understanding the basics still makes sense. In the meantime, there is also a class called GameActivity that is part of Google’s game developer kit, and I will use this for all future chapters.

Source: https://developer.android.com/games/agdk/game-activity

You can even use Android Studio to create a basic project that uses the GameActivity template. Besides the activity written in Java/Kotlin, a C file (main.cpp) has also been created. Here, we can start to include our game code:

void android_main(struct android_app *pApp) { ... }
void handle_cmd(android_app *pApp, int32_t cmd) { ... }

Both functions get called from the activity class, while android_main is the main entry point in C++, where we get a reference to the app and implement our game loop. The first we have to do is to register handle_cmd() to receive all applications on the C++ side:

 pApp->onAppCmd = handle_cmd;

After this, we start our game loop:

do {
// Handle events
if (ALooper_pollAll(0, nullptr, &events, (void **) &pSource) >= 0) {
if (pSource) {
pSource->process(pApp, pSource);
}
}

// Call renderer
if (pApp->userData) {
auto *pRenderer = reinterpret_cast<RBRenderer *>(pApp->userData);

pRenderer->HandleInput(); // Handle user input
pRenderer->RenderFrame(); // Render a frame
}
} while (!pApp->destroyRequested); // Quit if requested

Like in the last chapter, we handle the user input and rendering later in Renderer.cpp.

The second thing we must do is to create and destroy the render class. This happens inside of handle_cmd():

void handle_cmd(android_app *pApp, int32_t cmd) {
switch (cmd) {
case APP_CMD_INIT_WINDOW:
pApp->userData = new RBRenderer(pApp); // Create renderer
break;
case APP_CMD_TERM_WINDOW:
if (pApp->userData) {
//
auto *pRenderer = reinterpret_cast<RBRenderer *>(pApp->userData);
pApp->userData = nullptr;
delete pRenderer; // Destroy renderer
}
break;
default:
break;
}
}

That’s all in comparison to the last chapter. Much more cleaner code, right?

Input

The second thing that must be changed is how user input is handled. In the last chapter, I created a hidden view on top that catches touch events and passed that to the render class. This is a bit different, but it also allows much more control. All the needed code is in HandleInput():

// Input handling
void RBRenderer::HandleInput() {
...
bool leftSide = x < gGame->GetGamesSize().width/2 ? true: false;

// determine the kind of event it is
switch (actionMasked) {
case AMOTION_EVENT_ACTION_DOWN:
case AMOTION_EVENT_ACTION_POINTER_DOWN:
RBLOG_2D("Pointer down", x, y);
UserInput(leftSide, true, x, y);
break;

case AMOTION_EVENT_ACTION_UP:
case AMOTION_EVENT_ACTION_POINTER_UP:
RBLOG_2D("Pointer up", x, y);
UserInput(leftSide, false, x, y);
break;

default:
RBLOG_2D("Pointer move", x, y);
break;
}
...
}

This is quite similar to how we have handled user input before. We check if touches are either on the left or right side and pass the values to UserInput(), which generates keystrokes that get sent to the game.

That was the last step of the refactoring related to Android and GameActivity. All the other refactorings are needed to be more flexible when creating 3D games.

Shader 2D/3D

Previously, I had all the shader code in the renderer class, also for simplicity. Now, it makes sense to create a separate class for this. Therefore, all shader code is now in RBShader.cpp.

The vertex shader is the one with the “biggest” change:

// Vertex shader
attribute vec4 vertexPosition;
uniform mat4 projectionMatrix;
uniform vec4 vertexColor;
varying vec4 v_color;

void main() {
v_color = vertexColor;
gl_Position = vertexPosition * projectionMatrix;
}

Most prominently, you see now that the projection matrix is no longer calculated inside the shader code but in C++. This has the advantage that the same shader code can be used for 2D and 3D.

The second change is related to color. I added the functionality to use different colors in this version. Because we will deal with this later, I added the vertexColor color in the vertex shader and passed it to the fragment shader with v_color.

The fragment shader is almost identically to the previous one but uses v_color instead of the hardcoded black color in the last chapter:

// Fragment shader
precision mediump float;
varying vec4 v_color;

void main() {
gl_FragColor = v_color;
}

Mathematics

For the 2D game Pong, we only need a little (linear) mathematics. The only matrix was defined in the shader code, and we also used no transformation, scaling, or rotation. This will change fundamentally in 3D. Many libraries support all these calculations. A good example is glm. I have written my own to show the details behind it and the simplicity of using it on any device and platform. I will go into more detail in the chapters about 3D, but here is already a list of all functions that are defined in RBMath.hpp:

// Vector related functions
RBVec3D RBVec3DMake(int x, int y, int z);
RBVec3D RBVec3DAdd(RBVec3D &v1, RBVec3D &v2);
RBVec3D RBVec3DSub(RBVec3D &v1, RBVec3D &v2);
RBVec3D RBVec3DMul(RBVec3D &v1, float k);
RBVec3D RBVec3DDiv(RBVec3D &v1, float k);
float RBVec3DDotProduct(RBVec3D &v1, RBVec3D &v2);
float RBVec3DLength(RBVec3D &v);
RBVec3D RBVec3DNormalise(RBVec3D &v);
RBVec3D RBVec3DCrossProduct(RBVec3D &v1, RBVec3D &v2);
RBVec3D RBVec3DIntersectPlane(RBVec3D &plane_p, RBVec3D &plane_n, RBVec3D &lineStart, RBVec3D &lineEnd);
float RBVec3DAngle(RBVec3D& vec1, RBVec3D& vec2);

// Matrix related functions
RBVec3D RBMatrixMultiplyVector(RBMat4x4 &m, RBVec3D &i);
RBMat4x4 RBMatrixMakeIdentity();
RBMat4x4 RBMatrixMakeRotationX(float fAngleRad);
RBMat4x4 RBMatrixMakeRotationY(float fAngleRad);
RBMat4x4 RBMatrixMakeRotationZ(float fAngleRad);
RBMat4x4 RBMatrixMakeScale(float sx, float sy, float sz);
RBMat4x4 RBMatrixMakeTranslation(float x, float y, float z);
RBMat4x4 RBMatrixMultiplyMatrix(RBMat4x4 &m1, RBMat4x4 &m2);
RBMat4x4 RBMatrixQuickInverse(RBMat4x4 &m);

// Projection and Camera Matrix related functions
RBMat4x4 RBMatrixPointAt(RBVec3D &pos, RBVec3D &target, RBVec3D &up);
RBMat4x4 RBMatrixMakeProjection(float fFovDegrees, float fAspectRatio, float fNear, float fFar);
RBMat4x4 RBMatrixMakeOrtho(float left, float right, float bottom, float top, float nearZ, float farZ);

With that, we are perfectly prepared for what’s coming. To see how we can use those matrix functions, take a look at the following code:

void RBShader::MapScreenSize(int width, int height) {
RBMat4x4 mat = {2.0f/width, 0.0f, 0.0f, -1.0f,
0.0f, 2.0f/height, 0.0f, -1.0f,
0.0f, 0.0f, -1.0f, 0.0f,
0.0f, 0.0f, 0.0f, 1.0f};
MapProjectionMatrix(mat);
}

Here, we do the same as previously in the shader code. We create a projection matrix that uses screen width and height and pass it to the shader.

And as always, find the code here on GitHub.

Stay tuned for the next chapter when we start writing our first 3D game!

Check out my newsletter, ‘The Spatial Projects’ on Substack, where I write about Spatial Computing on Apple’s Vision Pro.

--

--

Roger Boesch
Roger Boesch

Written by Roger Boesch

Software Engineering Manager worked for Magic Leap, Microsoft and NeXT Computer - 8 years experience on spatial computing

No responses yet