Getting Started with OpenGL ES in Android

AmirHossein Aghajari
9 min readDec 17, 2023

--

Hello 👋 , If you’ve recently explored “Shading the Canvas: A Beginner’s Guide to Vertex and Fragment Shaders,” you’ve gained valuable insights into the fundamental concepts of shaders that form the backbone of graphics programming. Now, it’s time to bring those theoretical foundations to life through practical implementation.

Understanding GLSurfaceView:

When it comes to integrating OpenGL ES into Android applications, GLSurfaceView takes center stage. This specialized view provides a dedicated surface for rendering OpenGL graphics, offering seamless integration between the Android UI framework and the OpenGL rendering pipeline.

GLSurfaceView is a key component that facilitates the integration of OpenGL ES rendering into the Android application framework. It acts as a bridge between the OpenGL rendering thread and the main UI thread, ensuring smooth and efficient graphics processing.

Let’s Start Implementing!

  • Step 1: Create a new project.
  • Step 2: Add GLSurfaceView to your activity.
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<android.opengl.GLSurfaceView
android:id="@+id/gl"
android:layout_width="match_parent"
android:layout_height="match_parent" />

</FrameLayout>
  • Step 3: Set version of GLES to 3
val glSurfaceView = findViewById<GLSurfaceView>(R.id.gl)
glSurfaceView.setEGLContextClientVersion(3)
  • Step 4: Create a Custom Renderer
class MyRenderer : GLSurfaceView.Renderer {

override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
}

override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
}

override fun onDrawFrame(gl: GL10?) {
}
}
  • Step 5: Set the custom renderer
glSurfaceView.setRenderer(MyRenderer())

Understanding GLSurfaceView.Renderer:

The GLSurfaceView.Renderer interface is a core element in OpenGL ES development for Android. It defines methods that are called by the GLSurfaceView at different stages of the rendering process. By implementing this interface, you gain control over how your OpenGL graphics are initialized, updated, and presented on the screen.

  • onSurfaceCreated : This method is called when the surface is created, typically at the start of the application or after the device is unpaused. It’s the ideal place to perform one-time initialization, such as setting up shaders and loading textures.
  • onSurfaceChanged : This method is called when the surface changes, such as when the device is rotated. It provides information about the new size of the surface, allowing you to adjust your rendering setup accordingly.
  • onDrawFrame : The onDrawFrame method is invoked for each frame to handle rendering operations. Here, you define the logic for drawing your OpenGL content.

Compile Shader Codes:

In OpenGL ES, creating and compiling shaders from the GLSL code is a breeze. With just a few lines of Kotlin, developers can seamlessly integrate custom shaders into their graphics pipeline:

val shader = GLES31.glCreateShader(<SHADER TYPE HERE>)
GLES31.glShaderSource(shader, "<SHADER CODE HERE>")
GLES31.glCompileShader(shader)

Replace <SHADER TYPE HERE> with GLES31.GL_VERTEX_SHADER for vertex shaders or GLES31.GL_FRAGMENT_SHADER for fragment shaders.

To ensure a successful shader compilation, add the following code to check the compilation status:

val compileStatus = IntArray(1)
GLES31.glGetShaderiv(shader, GLES31.GL_COMPILE_STATUS, compileStatus, 0)
if (compileStatus[0] == 0) {
GLES31.glDeleteShader(shader)
throw RuntimeException("Error compiling shader: " + GLES31.glGetShaderInfoLog(shader))
}

Quick Note: I’ve crafted some handy helper functions that simplify the process of reading GLSL code from a file in the assets directory and compiling shaders. With just one line of code, such as compileShader(context, GLES31.GL_VERTEX_SHADER, "vertex_shader.glsl")

Create an OpenGL ES Program and Link Shaders:

In OpenGL ES, a program is a container for shaders. By creating a program, you establish a unified entity that encapsulates both the vertex and fragment shaders required for rendering.

private var program = 0
private var vertexShader = 0
private var fragmentShader = 0

override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
vertexShader = compileShader(context, GLES31.GL_VERTEX_SHADER, "vertex_shader.glsl")
fragmentShader = compileShader(context, GLES31.GL_FRAGMENT_SHADER, "fragment_shader.glsl")

program = GLES31.glCreateProgram()
GLES31.glAttachShader(program, vertexShader)
GLES31.glAttachShader(program, fragmentShader)
GLES31.glLinkProgram(program)
GLES31.glUseProgram(program)
}
  • glCreateProgram creates a new program object and returns its unique identifier.
  • glAttachShader is used to attach a shader to a program.
  • glLinkProgram takes a program object as an argument and links the shaders attached to it. This includes both the vertex and fragment shaders, which are essential components of the graphics pipeline.
  • glUseProgram takes a program object as an argument and makes it the currently active program in the OpenGL pipeline. This means that subsequent rendering calls will use the shaders and configurations associated with this program.

Ensure optimal resource management in your OpenGL applications by promptly deleting shaders and programs that are no longer in use.

fun destroy() {
GLES31.glDeleteProgram(program)
GLES31.glDeleteShader(fragmentShader)
GLES31.glDeleteShader(vertexShader)
}

Set OpenGL Viewport

In OpenGL, the glViewport function is used to set the dimensions of the viewport, which defines the mapping of normalized device coordinates to window coordinates. The viewport is essentially the area of the window where OpenGL will render.

override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
GLES31.glViewport(0, 0, width, height)
}

Quick Note: When setting the OpenGL viewport, consider creating a projection matrix if you aim to map the OpenGL right-handed coordinate system (RHS) to your preferred coordinate system.

Time to Draw!

After laying the foundation with OpenGL initialization, the next exciting step is to bring our scenes to life by drawing frames. To begin, let’s focus on a simple yet impactful task: drawing a point at the center of the screen.

Consider the following vertex and fragment shaders, which have been elaborated upon in the previous article.

#version 300 es

layout(location = 0) in vec2 position;

out vec4 vertexColor;

void main() {
if (position.x < -0.5) {
vertexColor = vec4(1.0, 0.0, 0.0, 1.0); // Red
} else if (position.x >= -0.5 && position.x <= 0.5) {
vertexColor = vec4(0.0, 1.0, 0.0, 1.0); // Green
} else {
vertexColor = vec4(0.0, 0.0, 1.0, 1.0); // Blue
}

gl_Position = vec4(position, 0.0, 1.0);
gl_PointSize = 100.0;
}
#version 300 es

precision mediump float;

in vec4 vertexColor;

out vec4 FragColor;

void main() {
FragColor = vertexColor;
}

Quick Note: While gl_PointSize is a handy attribute in OpenGL used to set the size of rendered points, it's essential to be mindful of its inherent constraints. This attribute operates within a specified minimum and maximum range, ensuring that points neither become too minuscule nor excessively large.

private val position = floatArrayOf(0.0f, 0.0f)

override fun onDrawFrame(gl: GL10?) {
GLES31.glClear(GLES31.GL_COLOR_BUFFER_BIT)

GLES31.glVertexAttrib2fv(0, position, 0)
GLES31.glDrawArrays(GLES31.GL_POINTS, 0, 1)
}
  • GLES31.glClear(GLES31.GL_COLOR_BUFFER_BIT) : This operation effectively wipes out the contents of the framebuffer, preparing it for rendering new content.
  • GLES31.glVertexAttrib2fv(0, position, 0) : This function is instrumental in defining the input position within the vertex shader. The first parameter signifies the location of the position attribute, while the second parameter is an array of floats representing the point’s coordinates (typically x and y in 2D space). The last parameter denotes the offset within the position array, providing flexibility in handling vertex attribute data.
  • GLES31.glDrawArrays(GLES31.GL_POINTS, 0, 1) : This marks the final step in the rendering process, signaling the GPU to draw each vertex as an individual point on the screen. The third parameter, set to 1, specifies that only one vertex (point) is intended to be rendered.

Creating Circular Points in OpenGL: Unleashing the Power of Geometry for Stunning Visuals

We understand that the fragment shader is executed at least once for each pixel intended for drawing. So, to achieve a circular shape, we simply discard the corner edges of the rectangular point in the fragment shader.

There is a built-in system input called gl_PointCoord which provides information about the coordinate within the point being drawn. This invaluable input allows us to precisely determine the specific pixel within the point that is intended for drawing:

The location within a point primitive that defines the position of the fragment relative to the side of the point. Points are effectively rasterized as window-space squares of a certain pixel size. Since points are defined by a single vertex, the only way to tell where in that square a particular fragment is is with gl_PointCoord.
The values of gl_PointCoord’s coordinates range from [0, 1].

Consider a circle centered within a rectangle with a radius of 1. In this setup, the leftmost pixel has an x-coordinate of -1, while the rightmost pixel corresponds to an x-coordinate of 1. So, as our initial step, we embark on mapping the range of gl_PointCoord from [0, 1] to the circle's coordinates of [-1, 1].

vec2 circleCoord = 2.0 * gl_PointCoord - 1.0;

Now, recalling the equation of a circle with a radius of 1: x²+y²=1,
we recognize that x²+y²<1 signifies points inside the circle, while x²+y²>1 indicates points outside the circle. Our approach is straightforward: we selectively discard the fragments residing outside the circle, ensuring that only those within the circular boundary contribute to the final rendering.
To calculate x²+y², we leverage the dot product of the coordinates within the circle.

vec2 circleCoord = 2.0 * gl_PointCoord - 1.0;
if (dot(circleCoord, circleCoord) > 1.0) {
discard;
}

That’s it! Here’s the polished and final version of our fragment shader:

#version 300 es

precision mediump float;

in vec4 vertexColor;

out vec4 FragColor;

void main() {
vec2 circleCoord = 2.0 * gl_PointCoord - 1.0;
if (dot(circleCoord, circleCoord) > 1.0) {
discard;
}

FragColor = vertexColor;
}

And consider the following shader, where the fragments inside the circle are selectively discarded:

vec2 circCoord = 2.0 * gl_PointCoord - 1.0;
if (dot(circCoord, circCoord) < 1.0) {
discard;
}

And that wraps up our point-drawing adventure! Feel free to access the complete source code for this project from the following link:

Mapping OpenGL Right-Handed Coordinate System to Android View Coordinate System:

Gain a solid theoretical understanding by exploring the ‘Finding Your Way in OpenGL: A Beginner’s Guide to Coordinates’ article first. It will pave the way for a smoother exploration into the practical aspects of our current endeavor.

To kick things off, let’s improve our vertex shader by adding a vital uniform variable for projection:

#version 300 es

layout(location = 0) in vec2 position;
uniform mat4 projection; // HERE
out vec4 vertexColor;

void main() {
vec4 rhsPosition = projection * vec4(position, 0.0, 1.0);

if (rhsPosition.x < -0.5) {
vertexColor = vec4(1.0, 0.0, 0.0, 1.0); // Red
} else if (rhsPosition.x >= -0.5 && rhsPosition.x <= 0.5) {
vertexColor = vec4(0.0, 1.0, 0.0, 1.0); // Green
} else {
vertexColor = vec4(0.0, 0.0, 1.0, 1.0); // Blue
}

gl_Position = rhsPosition;
gl_PointSize = 100.0;
}

Next, we move on to computing the Orthographic projection matrix and updating the corresponding uniform variable. This pivotal step takes place within the onSurfaceChanged.

override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
GLES31.glViewport(0, 0, width, height)

val projection = FloatArray(16)
Matrix.orthoM(projection, 0, 0f, width.toFloat(), height.toFloat(), 0f, -1f, 1f)

val projectionLocation = GLES31.glGetUniformLocation(program, "projection")
GLES31.glUniformMatrix4fv(projectionLocation, 1, false, projection, 0)
}

That’s it! We’ve successfully aligned the Android View Coordinate System with OpenGL’s Right-Handed System. Now, the position array conveniently holds the left and top offsets from the circle’s center.

Feel free to access the complete source code for this project from the following link:

Alright, today we’ve delved into the essentials of setting up and implementing basic OpenGL ES functionality in Android. Stay tuned for future articles where we’ll explore more advanced concepts, including drawing, animating with OpenGL, and uncovering further details.
The journey continues!

--

--

AmirHossein Aghajari

Hi! I'm AmirHossein Aghajari, your friendly neighborhood Android developer with over 7 years of experience! Currently on an exciting journey at Cafe Bazaar.