Mobile Development and 3D Graphics - Part 10

OpenGL part 4 - Drawing more complex shapes with vertex indices

Today we will look at drawing more complex shapes made up of individual triangles by looking at index buffers.

Drawing more complex shapes: using vertex indices

So far we have just been drawing single triangles. However in most cases we're interested in drawing more complex 3D polygons. The key thing is that more complex shapes are made up of triangles. So we can draw a shape by drawing the individual triangles making up the shape.

One way of doing this would be to put all vertices of all triangles making up the shape in a buffer. For example, if we have two triangles making up one face of a cube, with vertices at (0,0,0) (1,0,0) (1,1,0) and (0,1,0) we can divide it into two triangles, with vertices at (0,0,0) (1,0,0) (1,1,0) for the first triangle and (0,0,0) (0,1,0) (1,1,0) for the second triangle. So we could create a buffer with 6 vertices (2 triangles).

However this is inefficient in terms of memory usage. Our square face contains only four vertices, while we are using six to draw it (two triangles of three vertices each).

To deal with this we can use vertex indices. We store each vertex only once in a buffer and then create another buffer of indices to define how the shapes are drawn. So for example, to draw our square face we create one buffer with our vertices:

(0,0,0) (1,0,0) (1,1,0) (0,1,0)
and then an index buffer containing these values:
0,1,2, 2,3,0
The index buffer contains the indices of each vertex that will be drawn, rather than the vertex itself. So with the above index buffer, we can see that the first triangle will be drawn with vertices 0,1 and 2 (ie. (0,0,0) (1,0,0) (1,1,0)) and the second triangle will be drawn with vertices 2, 3 and 0 (ie. (1,1,0) (0,1,0) (0,0,0)).

This is shown below:
Index buffers to draw a square

Hopefully it should be obvious that repeating indices is a good deal less wasteful of memory than repeating the vertices themselves! The indices are typically stored as short ints (data type short, occupying 2 bytes) whereas a vertex stores three floats, i.e. it occupies 12 bytes in total.

One thing to bear in mind is that you should define your triangles so that the vertices are specified in anticlockwise order. In more complex shapes, e.g. cubes, OpenGL uses this to determine which is the front face and which is the back face - the front face will be the one with the coordinates specified in anticlockwise order. You can choose not to show the back face with back face culling. This concept is known as winding order.

Setting up an index buffer

Setting up an index buffer is very similar to setting up a vertex buffer, you create an array (of shorts this time, rather than floats) and create the buffer from the array:

val indices = shortArrayOf( 0,1,2, 2,3,0 )
val indexBuffer = OpenGLUtils.makeShortBuffer(indices)
Then, when you draw the shape, you use GPUInterface.drawIndexedBufferedData() rather than GPUInterface.drawBufferedTriangles():
gpu.drawIndexedBufferedData(vertexBuffer, indexBuffer, stride, ref_aVertex)
where vertexBuffer is your vertex buffer, indexBuffer your index buffer, stride the stride (see week 7 notes and week 8 lab video), and ref_aVertex is your reference to the vertex attribute shader variable.

Note that we do not need to use GPUInterface.specifyBufferedDataFormat() with GPUInterface.drawIndexedBufferedData() as it's called for us.

Mixing indexed and non-indexed shapes

Through multiple calls to GPUInterface.drawIndexedBufferData() and GPUInterface.drawBufferedTriangles() you can mix indexed and non-indexed shapes in the same scene, e.g.:

gpu.setUniform4FloatArray(ref_uColour, colour1)
gpu.drawIndexedBufferedData(indexedVertexBuffer, indexBuffer, stride, ref_aVertex)

gpu.specifyBufferedDataFormat(ref_aVertex, nonIndexedVertexBuffer, stride)
gpu.setUniform4FloatArray(ref_uColour, colour2)
gpu.drawBufferedTriangles(0, 3)

Note - dealing with multiple nullable objects

You will now have several nullable objects including multiple vertex and index buffers. How can you do this without it being cumbersome? The best way is probably to do one if statement which null-checks all the buffers. However, if you try to use the buffers without a null-safety operator inside this if statement, you will perhaps surprisingly still get a compiler error. The reason is that multiple threads might be operating on these variables and in theory, another thread might change them back to null again. In your case, this is not happening, so you can explicitly tell the Kotlin compiler that you know a variable is non-null with the non-null assertion operator !!, e.g.:

fbuf!!

Exercise 1 - creating a square

You are now going to use indices to create a class to represent a 3D cube (cube). First, try setting up a vertex buffer with these coordinates:

(0,0,-2), (1,0,-2), (1,1,-2), (0,1,-2).
This is a square made up of two triangles, but four vertices in total. Try drawing the square using vertex indices. Colour it in a different colour to the two triangles (e.g. blue)

Exercise 2 - creating a cube

Cube

Now you are going to try creating a cube, as shown above, using 8 vertices. The cube has 6 faces, but each face is made up of 2 triangles, so there are 12 triangles in total. So you will need a total of 36 vertex indices in your index buffer, three for each of the 12 triangles.

Colour buffers

So far we have coloured the whole of our shapes in one colour. However, by sending colour buffers (buffers containing colour data) to the shader we can give each vertex a separate colour. The effect of this will be a "gradient" effect where the pixels in between each vertex have a blended colour, interpolated from the vertices and dependent on which vertices are nearest.

In fact, we would often store vertices and colours in the same buffer for efficiency, as discussed in week 7:
Vertices and colours in the same buffer

Shaders using a colour buffer

Here is an example of a shader which will do this. First the vertex shader:

attribute vec4 aVertex, aColour;
varying vec4 vColour;
uniform mat4 uPerspMtx, uMvMtx;
void main(void) {
    gl_Position = uPerspMtx * uMvMtx * aVertex;
    vColour = aColour;
}
Then the fragment shader:
precision mediump float;
varying vec4 vColour;
void main(void) {
    gl_FragColor = vColour;
}
Note how we now have an attribute variable aColour representing the colour of each vertex. This can be linked to a buffer in the same way that aVertex is linked to a buffer of vertices. Note that this is in the vertex shader; this might seem odd as it is the fragment shader which specifies the colours of pixels making up our shape. However, we cannot pass attribute variables directly to the fragment shader, due to the OpenGL rendering pipeline they must be passed to the vertex shader first. Since the vertex shader does not deal with colours, we need to pass it on to the fragment shader. To do this, we store it in a varying variable called vColour (varying variables are used to pass information from the vertex to the fragment shader) and then retrieve and use this variable in the fragment shader.

Note: In recent versions of GLSL, varying variables have been deprecated. However the primary version documented on the Android documentation is still GLSL 1 (used by OpenGL ES 2.0) to maximise device support, as not all devices support newer versions - e.g. see here. Indeed the Android documentation stagtes that OpenGL ES 2.0 (hence GLSL 1) is "the recommended API version to use with current Android devices". So we will continue to use varying variables here as they are part of GLSL 1.

Further note: From Android 13 there is also the new Android Graphics Shading Language. See here. This is based on GLSL 1 to maximise compatibility, though has some important differences.

Passing colour buffers to the shader

Here is an example of some code to send a colour buffer (as well as vertex and index buffers) to a shader. We are going to use a single buffer for both vertices and colours, as introduced in Week 7.

val stride = 24 // because one record contains vertices (12 bytes) and colours (12 bytes)
val attrVarRef= gpu.getAttribLocation("aVertex")
val colourVarRef = gpu.getAttribLocation("aColour")

gpu.specifyBufferedDataFormat(attrVarRef, vertexAndColourBuffer, stride, 0)
gpu.specifyBufferedDataFormat(colourVarRef, vertexAndColourBuffer, stride, 3)
gpu.drawElements(indices)
Note how we have two calls to gpu.specifyBufferedDataFormat(), one for the vertices and one for the colours. The stride is set as 24 in both cases, because the gap in bytes between one record (whether that be vertices or colours) is 24 bytes: 3 floats for the vertices, and 3 floats for the colours (6 floats) multiplied by the size of a float in bytes (4).

To differentiate between the vertices and colours we set the position of the buffer to the appropriate index as a fourth parameter to GPUInterface.specifyBufferedDataFormat(). So when the position is 0, we will be pointing at the first vertex, and when the position is 3, we will be pointing at the first colour.

Also note the different way of actually doing the drawing this time, using GPUInterface.drawElements(). This takes an index buffer as an argument and is intended to be called if GPUInterface.specifyBufferedDataFormat() was used rather than GPUInterface.drawIndexedBufferedData().

Exercise

You'll need to update the OpenGLWrapper library in order to use GPUInterface.drawElements(). Pull the latest version from GitHub, within Android Studio, and build it. Again name the .aar a slightly different name to avoid caching issues.

Modify your cube-drawing application so that the one of the cubes uses different colours for the 8 vertices, e.g: red, yellow, red, yellow for the top 4 vertices and blue, green, blue, green for the bottom 4. You will need to create a new vertex and fragment shader to handle this. Because you are using a different shader program, you will need to create two GPUInterface objects.

Taking it further - models

More complex shapes, representing real-world objects, are typically represented by models. These are typically created by specialist modelling software and saved asexternal files containing the vertices and vertex indices (as well as other properties such as textures - to be covered next time). Common model formats include:

We will not have time to cover loading models (which is more difficult in Android compared to web-based OpenGL applications) but is something you might want to do further research on for the assignment.