Moving Dots: A Beginner’s Guide to Animated Points from Stillness to Motion

AmirHossein Aghajari
11 min readDec 27, 2023

Hello everyone! Remember the single point we drew in the previous article? Well, today is an exciting day because we’re going to bring that point to life by making it move across the screen! After our initial implementation, we’ll delve into optimization and fine-tuning timing for smooth animation, considering the device’s screen refresh rate. Following that, we’ll explore the concept of buffers in OpenGL, discussing how to generate them and draw multiple points with customization.
Get ready for a beginner-friendly journey into animating points in OpenGL ES for Android.

First things first: if you haven’t read the previous article, make sure to check it out here.

Now, let’s kick off our animation journey by incorporating a uniform variable into our vertex shader to represent time, expressed as the animation fraction within the range [0, 1].

uniform float animFraction;

As we’re familiar, OpenGL positioning operates within the range [-1, 1], with (x=0, y=0) representing the center. Now, picture this: our goal is to animate that single point, moving it smoothly from the left side to the right side of the screen.
To achieve our animation goal, we need to convert the animation fraction from the range [0, 1] to [-1, 1], effectively describing the x-coordinate of our point’s movement.

float x = 2.0 * animFraction - 1.0;
vec4 position = vec4(x, 0.0, 0.0, 1.0);

While we won’t be altering the fragment shader code, let’s take a look at the complete code for the vertex shader:

#version 300 es

uniform float animFraction;
out vec4 vertexColor;

void main() {
float x = 1.8 * animFraction - 0.9;
vec4 position = vec4(x, 0.0, 0.0, 1.0);

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 = position;
gl_PointSize = 100.0;
}

Alright! Now, let’s dive into modifying our Android code to dynamically set the animation fraction in each frame.

We’ll begin by declaring two new variables in our Android code. The first one is to obtain the location of the vertex shader’s uniform variable ‘animFraction,’ and the second variable will track the starting time of our animation.

private var animFraction = 0
private var startTime = 0L

Next, let’s initialize these variables in the onSurfaceCreated method:

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

animFraction = GLES31.glGetUniformLocation(program, "animFraction")
startTime = System.currentTimeMillis() + 1000 // 1s delay
}

At the final step, we’ll calculate the animation fraction and update the uniform variable just before drawing. This ensures that our point smoothly moves from left to right as we progress through each frame.

val duration = 3000L
val now = System.currentTimeMillis()
var fraction = (now - startTime).toFloat() / duration
fraction = max(0f, fraction)
fraction = min(1f, fraction)

GLES31.glUniform1f(animFraction, fraction)

The code snippet sets the duration of the animation to 3 seconds. The expression (now - startTime) / duration calculates a linear transformation, determining the progression of the animation over time. The max(0f, fraction) ensures that any starting delay won't affect the fraction, keeping the point at the left before the animation begins. On the other hand, min(1f, fraction) ensures that the point stays at the right side of the screen after the animation is completed.

Here is the final code for the onDrawFrame method:

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

val duration = 3000L
val now = System.currentTimeMillis()
var fraction = (now - startTime).toFloat() / duration
fraction = max(0f, fraction)
fraction = min(1f, fraction)

GLES31.glUniform1f(animFraction, fraction)
GLES31.glDrawArrays(GLES31.GL_POINTS, 0, 1)
}

And here’s the expected output: the point smoothly transitioning from the left side to the right side of the screen over the specified duration.

Output

Let’s enhance our animation by making the point move in a loop, alternating between left to right and right to left.

To achieve a looping animation, let’s refine our fraction logic. When the fraction reaches 1, we’ll change the direction, allowing the point to smoothly transition from left to right and then seamlessly reverse its course from right to left.

Here’s the additional logic we need to incorporate:

if (isRightToLeft) {
fraction = 1f - fraction
}
if (startTime + duration <= now) {
isRightToLeft = !isRightToLeft
startTime = now
}

When moving the point from right to left, the fraction = 1f - fraction ensures a smooth animation back from 1 to 0. The condition startTime + duration <= now is true when the animation is complete, signaling that it's time to reverse our direction and initiate a new animation timeline.

Here is the final code for the onDrawFrame method:

private var isRightToLeft = false

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

val duration = 3000L
val now = System.currentTimeMillis()
var fraction = (now - startTime).toFloat() / duration
fraction = max(0f, fraction)
fraction = min(1f, fraction)

if (isRightToLeft) {
fraction = 1f - fraction
}
if (startTime + duration <= now) {
isRightToLeft = !isRightToLeft
startTime = now
}

GLES31.glUniform1f(animFraction, fraction)
GLES31.glDrawArrays(GLES31.GL_POINTS, 0, 1)
}

With this updated logic, our point gracefully moves in a loop, transitioning from left to right and then seamlessly reversing its path from right to left.

Output

Explore the full source code:

Fine-Tuning Timing for Smooth Animation

Now that we’ve animated our point, let’s focus on optimizing the timing to ensure a smooth and visually appealing experience. In graphics programming, it’s crucial to synchronize animations with the screen refresh rate to avoid unnecessary redraws and deliver a seamless user experience. OpenGL ES doesn’t automatically synchronize your rendering loop with the screen’s refresh rate. Without synchronization, your application might redraw frames at a rate higher than the screen refresh rate, leading to wasted resources and potential visual artifacts.

Let’s start by finding out how fast our device’s screen can refresh!
Here’s a simple function to get the screen refresh rate in Android:

fun Context.getScreenRefreshRate(): Float {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
display?.refreshRate
} else {
val manager = getSystemService(Context.WINDOW_SERVICE) as? WindowManager
manager?.defaultDisplay?.refreshRate
} ?: 60f
}

Now, let’s optimize our animations by setting the minimum and maximum delta time:

// Set min and max delta time based on screen refresh rate
private val minDeltaTime = 1000f / context.getScreenRefreshRate()
private val maxDeltaTime = 2f * minDeltaTime

minDeltaTime represents the minimum time between animation frames, calculated based on the screen refresh rate. The maxDeltaTime is set at twice the minimum, providing an upper threshold for animation timing. This dynamic approach ensures that our animations adapt to the device's capabilities, resulting in a smoother and visually appealing user experience.

And then we should declare two new variables for time tracking:

private var lastTime = 0L
private var time = 0f

lastTime keeps a record of the nanosecond timestamp of the last rendered frame, while time ticks away, measuring the duration since its initiation.

And at the end, let’s refine our onDrawFrame method to encapsulate our meticulous timing and frame rendering:

override fun onDrawFrame(gl: GL10?) {
val now = System.nanoTime()
val deltaT = ((now - lastTime) / 1000000f)
if (deltaT < minDeltaTime) {
return
}
time += min(maxDeltaTime, deltaT)
lastTime = now

GLES31.glClear(GLES31.GL_COLOR_BUFFER_BIT)
...
}

In this refined onDrawFrame method, we’re making sure our animations run smoothly by controlling the timing. The deltaT variable measures how much time has passed since the last frame, and we use it to update our time counter. This ensures our animations progress consistently. The screen is then cleared for the next frame.

With our improved timing method, forget about startTime – it's always 0. Instead, rely on our trusty companion, time, which keeps track of how long it's been since the beginning. Now, calculating the animation fraction is a breeze with time / duration. To check if the animation is complete, a quick comparison with duration <= time does the trick.

Here is the finalized code for the onDrawFrame method:

override fun onDrawFrame(gl: GL10?) {
val now = System.nanoTime()
val deltaT = ((now - lastTime) / 1000000f)
if (deltaT < minDeltaTime) {
return
}
time += min(maxDeltaTime, deltaT)
lastTime = now

GLES31.glClear(GLES31.GL_COLOR_BUFFER_BIT)

val duration = 3000f
var fraction = time / duration
fraction = max(0f, fraction)
fraction = min(1f, fraction)

if (isRightToLeft) {
fraction = 1f - fraction
}
if (duration <= time) {
isRightToLeft = !isRightToLeft
time = 0f
}

GLES31.glUniform1f(animFraction, fraction)
GLES31.glDrawArrays(GLES31.GL_POINTS, 0, 1)
}

Here is the source code:

Diving into Multiplicity: Drawing Multiple Points!

Our goal is to animate two points simultaneously.
The initial step involves generating a new buffer.

A buffer object is a container for storing data, such as vertex coordinates or feedback values. we can create a new buffer by using glGenBuffers. the function takes a number and a reference to an array, and fills the array with unique identifiers ready for use with subsequent OpenGL functions to allocate and manipulate buffer data.

private val buffer = IntArray(1)

override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
...
GLES31.glGenBuffers(buffer.size, buffer, 0)
}

Let’s give each point its own special top position! To do this, we’ll add a special input to the vertex shader that represents the vertical position (y-coordinate) of each point.

#version 300 es

layout(location = 0) in float inY; // <- HERE

uniform float animFraction;
out vec4 vertexColor;

void main() {
float x = 2.0 * animFraction - 1.0;
vec4 position = vec4(x, inY, 0.0, 1.0);

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 = position;
gl_PointSize = 100.0;
}

We’ve introduced a single input variable with the declaration layout(location = 0) in float inY;. The type of this variable is float, indicating that each point requires 4 bytes of storage.

const val NUMBER_OF_POINTS = 2
const val POINT_BYTES = 4
const val TOTAL_BYTES = NUMBER_OF_POINTS * POINT_BYTES

The next step in the process is to bind our buffer and populate it with the corresponding data.

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

GLES31.glGenBuffers(buffer.size, buffer, 0)

val data = ByteBuffer.allocateDirect(TOTAL_BYTES)
.order(ByteOrder.nativeOrder())
.asFloatBuffer().apply {
put(-0.1f) // y position of the first point
put(0.1f) // y position of the second point
position(0) // look at the first byte of data
}

GLES31.glBindBuffer(GLES31.GL_ARRAY_BUFFER, buffer[0])
GLES31.glBufferData(GLES31.GL_ARRAY_BUFFER, TOTAL_BYTES, data, GLES31.GL_STATIC_DRAW)
}

With this code, we initialize a new data buffer to accommodate the y-positions of our two points. The glBufferData function is then employed to set the data in the buffer associated with its pointer. This step prepares the necessary information for rendering our points in subsequent steps.

Now, we need to instruct the vertex shader on how to interpret the data in the buffer for its input values. This involves specifying the layout and format of the data so that the shader can correctly access and utilize it.

override fun onDrawFrame(gl: GL10?) {
...

GLES31.glBindBuffer(GLES31.GL_ARRAY_BUFFER, buffer[0])
GLES31.glVertexAttribPointer(0, 1, GLES31.GL_FLOAT, false, POINT_BYTES, 0)
GLES31.glEnableVertexAttribArray(0)

GLES31.glDrawArrays(GLES31.GL_POINTS, 0, NUMBER_OF_POINTS)
}
  1. Bind the Buffer:
    Indicates that subsequent operations will be performed on the buffer that has the identifier buffer[0] . This is the buffer containing the y-positions of our points.
  2. Specify Vertex Attribute Pointer:
    The first parameter corresponds to the location of the first input value in the vertex shader, specified by layout(location = 0).
    Second parameter specifies the number of components per generic vertex attribute. (for example: float = 1, vec2 = 2, vec4 = 4 and mat4 = 4)
    GLES31.GL_FLOAT: Indicates the type of data in the buffer.
    false: Specifies whether the data should be normalized.
    POINT_BYTES: This is the byte offset between consecutive generic vertex attributes. It represents the total size of one set of vertex attributes.
    The last parameter is the byte offset of the first component in the data buffer. It starts at the beginning of the buffer.
  3. Enable Vertex Attribute Array:
    This line enables the generic vertex attribute array specified by the location 0. It allows the vertex shader to access the data stored in the buffer for the specified attribute.
  4. Draw Points
Output

Let’s spice things up by giving each point its own color!

Let’s start with introducing a new input variable to represent the color of each point.

A color is commonly depicted as a vec4, signifying its red (r), green (g), blue (b), and alpha (a) components, with each value constrained within the range of [0, 1].

#version 300 es

layout(location = 0) in float inY;
layout(location = 1) in vec4 inColor; // <- HERE

uniform float animFraction;
out vec4 vertexColor;

void main() {
float x = 1.8 * animFraction - 0.9;
vec4 position = vec4(x, inY, 0.0, 1.0);

vertexColor = inColor;
gl_Position = position;
gl_PointSize = 100.0;
}

We’ve incorporated a color (vec4) into the attributes of each point. Consequently, we need to adjust POINT_BYTES to include sizeof inY and sizeof color, resulting in 4 + 4 * 4 = 20. (y, r, g, b, a)

const val NUMBER_OF_POINTS = 2
const val POINT_BYTES = 5 * 4 // <- UPDATED
const val TOTAL_BYTES = NUMBER_OF_POINTS * POINT_BYTES

Now, it’s time to assign colors to each point in our data buffer. Let’s kick things off by crafting a helpful function that converts Android ARGB color format into OpenGL RGBA color format.

fun FloatBuffer.putColor(color: Int) {
put(Color.red(color) / 255f)
put(Color.green(color) / 255f)
put(Color.blue(color) / 255f)
put(Color.alpha(color) / 255f)
}

With this helper function, We can effortlessly include the color in the data buffer by using putColor(Color.RED), for instance.

...
val data = ByteBuffer.allocateDirect(TOTAL_BYTES)
.order(ByteOrder.nativeOrder())
.asFloatBuffer().apply {
put(-0.1f) // y position of the first point
putColor(Color.CYAN) // color of the first point
put(0.1f) // y position of the second point
putColor(Color.YELLOW) // color of the second point
position(0) // look at the first byte of data
}
...

Finally, for the last step, we must specify and enable the vertex attribute pointer for inColor.

GLES31.glBindBuffer(GLES31.GL_ARRAY_BUFFER, buffer[0])

// inY (float)
GLES31.glVertexAttribPointer(0, 1, GLES31.GL_FLOAT, false, POINT_BYTES, 0)
GLES31.glEnableVertexAttribArray(0)

// inColor (vec4)
GLES31.glVertexAttribPointer(1, 4, GLES31.GL_FLOAT, false, POINT_BYTES, 4)
GLES31.glEnableVertexAttribArray(1)

GLES31.glDrawArrays(GLES31.GL_POINTS, 0, NUMBER_OF_POINTS)

Note: In the glVertexAttribPointer function for inColor, the last parameter indicates the starting offset of the first byte pointing to the color. It's set to 4 because there's only one float (inY) before the color, taking up 4 bytes of space.

Output

Here is the full source code:

As we conclude this article, we’ve embarked on a captivating journey of animating points in OpenGL ES for Android, from basic movement to fine-tuning timing and drawing multiple points with customization. But hold on, our exploration doesn’t end here! In the next installment, we’ll delve into feedback values in OpenGL, taking our animation logic a step further by moving it into the vertex shader itself, reducing the burden on our Android code. Stay tuned for an even more immersive dive into the world of OpenGL ES!

--

--

AmirHossein Aghajari

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