Mobile Development and 3D Graphics - Part 7

Introduction to OpenGL on Android (Practical Part)

Important! In this first week on OpenGL, we will not be dealing with the difference between world and eye coordinates in our code. Instead the camera will be placed at the origin, facing negative z, so world and eye coordinates will be equivalent. Furthermore, we will not yet be applying any sense of perspective, so all shapes we draw will have a z coordinate of 0.

Components of an Android OpenGL Application

An Android OpenGL application includes the following components:

Methods of GLSurfaceView.Renderer

You need to provide implementations of these three methods.

Absolute basic example

Here is an absolute basic example of an Android OpenGL application. It does not draw any graphics; it just shows you how you setup the OpenGL environment, and initialises a black screen ready for rendering 3D content. First we would have a main activity, as always:

package com.example.opengl

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle

class MyActivity: AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle) {
        val glView = OpenGLView(this)
        setContentView(glView)
    }
}
We create an object of class OpenGLView which extends from GLSurfaceView (see below) and make this the main view of our activity. Now we move on to the OpenGLView object:
package com.example.opengl

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

class OpenGLView(ctx: Context)  :GLSurfaceView(ctx), GLSurfaceView.Renderer {
    init {
        setEGLContextClientVersion(2) // specify OpenGL ES 2.0
        setRenderer(this) // set the renderer for this GLSurfaceView
    }

    // We initialise the rendering here
    override fun onSurfaceCreated(unused: GL10, config: EGLConfig) {
        // Set the background colour (red=0, green=0, blue=0, alpha=1) 
        GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f)

        // Enable depth testing - will cause nearer 3D objects to automatically
        // be drawn over further objects
        GLES20.glClearDepthf(1.0f)
        GLES20.glEnable(GLES20.GL_DEPTH_TEST)
    }

    // We draw our shapes here
    override fun onDrawFrame(unused: GL10) {
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
    }

    // Used if the screen is resized
    override fun onSurfaceChanged(unused: GL10, w: Int, h: Int) {
        GLES20.glViewport(0, 0, w, h)
    }
}
Note here:

Using OpenGLWrapper

In order to make using OpenGL easier, I have created an Android library, `OpenGLWrapper`, which makes writing basic OpenGL applications simpler. The raw OpenGL library requires you to make many method calls, some of which require many parameters to be passed which do not change 99% of the time. `OpenGLWrapper` makes the process of basic OpenGL development simpler.

To use:

Loading in shaders

The code above obviously does not do anything. To get it to draw shapes, we first of all need to define our vertex and fragment shaders. Where do we store our shaders? There is no standard way, but a common place to store them is within the assets folder of an Android project. You need to create two files, one for the vertex shader and one for the fragment shader, and save them in assets with extension .glsl.

You have to create the assets folder yourself. Right-click on the "app" folder and then select "New" and "Directory", as shown below:
Create directory in Android Studio

You will then end up with an assets folder. You should then right click on the assets folder and create two new files, vertex.glsl and fragment.glsl for your two shaders:
Android assets folder

First the vertex shader:

attribute vec4 aVertex;
void main(void)
{
    gl_Position = aVertex;
}

Then the fragment shader:

precision mediump float;
uniform vec4 uColour;
void main(void)
{
    gl_FragColor = uColour;
}

You then need to compile the shaders into native GPU machine code. This is relatively easy with OpenGLWrapper. Add the following to the template code above:

Compiling and Linking shader code

What is actually going on inside loadShaders()?

Setting up a vertex buffer

Remember from the discussion above that the vertex data needs to be sent to the GPU in a buffer. Here is how to create a buffer:

Drawing buffered data

Having setup our buffers we need to draw the shape(s) they contain in onDrawFrame(). To do this we need to send the buffered data to the GPU and tell the GPU about the format of our data. How do we do that?

Accessing the shader from Kotlin

The next thing we need to do is to link our buffer data to a shader variable, so that the shader can process each vertex in the buffer in turn. To be able to use a shader variable from Kotlin, we need to get a "handle" on it to allow Kotlin to manipulate it, and then link this "handle" to our vertex data. To obtain the "handle", we can use the method getAttribLocation(), e.g. val handle = gpu.getAttribLocation("aVertex") The name of the shader variable needs to be passed to gpu.getAttribLocation(). Here is a code example, which stores the handle in the variable ref_aVertex (note how we use the shaderProgram variable which we created when we compiled and linked the shader).

// Create a reference to the attribute variable aVertex
val ref_aVertex = gpu.getAttribLocation("aVertex")

We can also get a handle on uniform variables. Remember from the discussion above that a uniform variable is a variable whose values do not vary from vertex to vertex. A good example of a uniform variable is a colour (assuming the shape is of a uniform colour). GLES20.glGetUniformLocation() works in exactly the same way as GLES20.glGetAttribLocation() e.g.

val ref_uColour = gpu.getUniformLocation("uColour")
Having obtained a reference to the uniform variable from outside our shader, we then need to send data to it. The method gpu.setUniform4FloatArray() can be used to send a 4-member array (hence the 4 in the method name) to the shader, containing the drawing colour. The array's 4 members include red, green, blue and alpha - i.e. transparency - components. Here is an example. Note how we pass in the reference to the shader variable and the array we want to send.
val red = floatArrayOf(1.0f, 0.0f, 0.0f, 1.0f)
gpu.setUniform4FloatArray(ref_uColour, red)

Drawing the shapes

Now onto the actual drawing itself. Our example buffer above contains six vertices, making up two triangles - so we are going to draw those triangles. Having placed the vertices in a buffer and obtained a handle on the shader variable which will contain each vertex, we can now actually draw the shape. Drawing a shape is performed by specifying a buffer or buffers to use (we could have one buffer for vertices and another for colours, for example) and then telling Android OpenGL ES 2.0 what format the buffer data is in and what shader variable should receive the buffered data. Here is how to do this.

Extending the example to drawing two triangles

The previous example could be extended very easily to draw two triangles. The only differences are that we would fill the buffer with 6 points rather than 3, and change the gpu.drawBufferedTriangles() call to reflect this:

gpu.drawBufferedTriangles(0, 6)
The method will know to treat each set of three vertices as a separate triangle.

Another example:

gpu.drawBufferedTriangles(3, 3)
If the buffer had at least 6 vertices (i.e. at least 2 triangles), this would draw the second triangle only because the start vertex is 3 (the first vertex of the second triangle), and the number of vertices to draw is 3.

Exercise

Paper exercise

Look at this diagram:
World and eye coordinates exercise
For each of the four diagrams, state the eye coordinates of the red and blue boxes. The world coordinates of the camera and of the red and blue boxes are shown in each case.

Coding exercise

We are going to develop a simple OpenGL application to draw first one, then two red triangles, and finally two triangles in different colours.

  1. Start with the template above, containing an activity, and a GLSurfaceView/Renderer.
  2. Add attributes to your OpenGLView / Renderer:
  3. In onSurfaceCreated, add the methods provided above to load the shaders, and to initialise a vertex buffer using a float array of coordinates. Use the coordinates given above initially.
  4. In onDrawFrame(), add the code (shown above) to obtain references to the shader variables (current vertex and colour), send a colour (red) over to the fragment shader, specify the buffered data format, and actually draw the triangle in the buffer.
  5. Modify your example to draw a second red triangle (as well as the first). The second triangle should have the coordinates
    x=0, y=0, z=0
    x=-1, y=0, z=0
    x=0, y=-1, z=0
  6. Make the first triangle appear in blue (red 0, green 0, blue 1, alpha 1) and the second in yellow (red 1, green 1, blue 0, alpha 1).