GPU Particle Burst

../../_images/explosions1.gif

In this example, we show how to create explosions using particles. The particles are tracked by the GPU, significantly improving the performance.

Step 1: Open a Blank Window

First, let’s start with a blank window.

gpu_particle_burst_01.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
"""
Example showing how to create particle explosions via the GPU.
"""
import arcade

SCREEN_WIDTH = 1024
SCREEN_HEIGHT = 768
SCREEN_TITLE = "GPU Particle Explosion"

class MyWindow(arcade.Window):
    """ Main window"""
    def __init__(self):
        super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE)

    def on_draw(self):
        """ Draw everything """
        self.clear()

    def on_update(self, dt):
        """ Update everything """
        pass

    def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
        """ User clicks mouse """
        pass


if __name__ == "__main__":
    window = MyWindow()
    window.center_window()
    arcade.run()

Step 2: Create One Particle For Each Click

../../_images/gpu_particle_burst_02.png

For this next section, we are going to draw a dot each time the user clicks her mouse on the screen.

For each click, we are going to create an instance of a “burst” that will eventually be turned into a full explosion. Each burst instance will be added to a list.

Imports

First, we’ll import some more items for our program:

from array import array
from dataclasses import dataclass
import arcade
import arcade.gl

Burst Dataclass

Next, we’ll create a dataclass to track our data for each burst. For each burst we need to track a Vertex Array Object (VAO) which stores information about our burst. Inside of that, we’ll have a Vertex Buffer Object (VBO) which will be a high-speed memory buffer where we’ll store locations, colors, velocity, etc.

@dataclass
class Burst:
    """ Track for each burst. """
    buffer: arcade.gl.Buffer
    vao: arcade.gl.Geometry

Init method

Next, we’ll create an empty list attribute called burst_list. We’ll also create our OpenGL shader program. The program will be a collection of two shader programs. These will be stored in separate files, saved in the same directory.

Note

In addition to loading the program via the load_program() method of ArcadeContext shown, it is also possible to keep the GLSL programs in triple- quoted string by using program() of Context.

MyWindow.__init__
    def __init__(self):
        super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE)
        self.burst_list = []

        # Program to visualize the points
        self.program = self.ctx.load_program(
            vertex_shader="vertex_shader_v1.glsl",
            fragment_shader="fragment_shader.glsl",
        )

        self.ctx.enable_only()

OpenGL Shaders

The OpenGL Shading Language (GLSL) is C-style language that runs on your graphics card (GPU) rather than your CPU. Unfortunately a full explanation of the language is beyond the scope of this tutorial. I hope, however, the tutorial can get you started understanding how it works.

We’ll have two shaders. A vertex shader, and a fragment shader. A vertex shader runs for each vertex point of the geometry we are rendering, and a fragment shader runs for each pixel. For example, vertex shader might run four times for each point on a rectangle, and the fragment shader would run for each pixel on the screen.

The vertex shader takes in the position of our vertex. We’ll set in_pos in our Python program, and pass that data to this shader.

The vertex shader outputs the color of our vertex. Colors are in Red-Green-Blue-Alpha (RGBA) format, with floating-point numbers ranging from 0 to 1. In our program below case, we set the color to (1, 1, 1) which is white, and the fourth 1 for completely opaque.

vertex_shader_v1.glsl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#version 330

// (x, y) position passed in
in vec2 in_pos;

// Output the color to the fragment shader
out vec4 color;

void main() {

    // Set the RGBA color
    color = vec4(1, 1, 1, 1);

    // Set the position. (x, y, z, w)
    gl_Position = vec4(in_pos, 0.0, 1);
}

There’s not much to the fragment shader, it just takes in color from the vertex shader and passes it back out as the pixel color. We’ll use the same fragment shader for every version in this tutorial.

fragment_shader.glsl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#version 330

// Color passed in from the vertex shader
in vec4 color;

// The pixel we are writing to in the framebuffer
out vec4 fragColor;

void main() {

    // Fill the point
    fragColor = vec4(color);
}

Mouse Pressed

Each time we press the mouse button, we are going to create a burst at that location.

The data for that burst will be stored in an instance of the Burst class.

The Burst class needs our data buffer. The data buffer contains information about each particle. In this case, we just have one particle and only need to store the x, y of that particle in the buffer. However, eventually we’ll have hundreds of particles, each with a position, velocity, color, and fade rate. To accommodate creating that data, we have made a generator function _gen_initial_data. It is totally overkill at this point, but we’ll add on to it in this tutorial.

The buffer_description says that each vertex has two floating data points (2f) and those data points will come into the shader with the reference name in_pos which we defined above in our OpenGL Shaders

MyWindow.on_mouse_press
    def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
        """ User clicks mouse """

        def _gen_initial_data(initial_x, initial_y):
            """ Generate data for each particle """
            yield initial_x
            yield initial_y

        # Recalculate the coordinates from pixels to the OpenGL system with
        # 0, 0 at the center.
        x2 = x / self.width * 2. - 1.
        y2 = y / self.height * 2. - 1.

        # Get initial particle data
        initial_data = _gen_initial_data(x2, y2)

        # Create a buffer with that data
        buffer = self.ctx.buffer(data=array('f', initial_data))

        # Create a buffer description that says how the buffer data is formatted.
        buffer_description = arcade.gl.BufferDescription(buffer,
                                                         '2f',
                                                         ['in_pos'])
        # Create our Vertex Attribute Object
        vao = self.ctx.geometry([buffer_description])

        # Create the Burst object and add it to the list of bursts
        burst = Burst(buffer=buffer, vao=vao)
        self.burst_list.append(burst)

Drawing

Finally, draw it.

MyWindow.on_draw
    def on_draw(self):
        """ Draw everything """
        self.clear()

        # Set the particle size
        self.ctx.point_size = 2 * self.get_pixel_ratio()

        # Loop through each burst
        for burst in self.burst_list:

            # Render the burst
            burst.vao.render(self.program, mode=self.ctx.POINTS)

Program Listings

Step 3: Multiple Moving Particles

../../_images/gpu_particle_burst_03.png

Next step is to have more than one particle, and to have the particles move. We’ll do this by creating the particles, and calculating where they should be based on the time since creation. This is a bit different than the way we move sprites, as they are manually repositioned bit-by-bit during each update call.

Imports

First, we’ll import both the random and time libraries:

import random
import time

Constants

Then we need to create a constant that contains the number of particles to create:

PARTICLE_COUNT = 300

Burst Dataclass

We’ll need to add a time to our burst data. This will be a floating point number that represents the start-time of when the burst was created.

@dataclass
class Burst:
    """ Track for each burst. """
    buffer: arcade.gl.Buffer
    vao: arcade.gl.Geometry
    start_time: float

Update Burst Creation

Now when we create a burst, we need multiple particles, and each particle also needs a velocity. In _gen_initial_data we add a loop for each particle, and also output a delta x and y.

Note: Because of how we set delta x and delta y, the particles will expand into a rectangle rather than a circle. We’ll fix that on a later step.

Because we added a velocity, our buffer now needs two pairs of floats 2f 2f named in_pos and in_vel. We’ll update our shader in a bit to work with the new values.

Finally, our burst object needs to track the time we created the burst.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
    def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
        """ User clicks mouse """

        def _gen_initial_data(initial_x, initial_y):
            """ Generate data for each particle """
            for i in range(PARTICLE_COUNT):
                dx = random.uniform(-.2, .2)
                dy = random.uniform(-.2, .2)
                yield initial_x
                yield initial_y
                yield dx
                yield dy

        # Recalculate the coordinates from pixels to the OpenGL system with
        # 0, 0 at the center.
        x2 = x / self.width * 2. - 1.
        y2 = y / self.height * 2. - 1.

        # Get initial particle data
        initial_data = _gen_initial_data(x2, y2)

        # Create a buffer with that data
        buffer = self.ctx.buffer(data=array('f', initial_data))

        # Create a buffer description that says how the buffer data is formatted.
        buffer_description = arcade.gl.BufferDescription(buffer,
                                                         '2f 2f',
                                                         ['in_pos', 'in_vel'])
        # Create our Vertex Attribute Object
        vao = self.ctx.geometry([buffer_description])

        # Create the Burst object and add it to the list of bursts
        burst = Burst(buffer=buffer, vao=vao, start_time=time.time())
        self.burst_list.append(burst)

Set Time in on_draw

When we draw, we need to set “uniform data” (data that is the same for all points) that says how many seconds it has been since the burst started. The shader will use this to calculate particle position.

    def on_draw(self):
        """ Draw everything """
        self.clear()

        # Set the particle size
        self.ctx.point_size = 2 * self.get_pixel_ratio()

        # Loop through each burst
        for burst in self.burst_list:

            # Set the uniform data
            self.program['time'] = time.time() - burst.start_time

            # Render the burst
            burst.vao.render(self.program, mode=self.ctx.POINTS)

Update Vertex Shader

Our vertex shader needs to be updated. We now take in a uniform float called time. Uniform data is set once, and each vertex in the program can use it. In our case, we don’t need a separate copy of the burst’s start time for each particle in the burst, therefore it is uniform data.

We also need to add another vector of two floats that will take in our velocity. We set in_vel in Update Burst Creation.

Then finally we calculate a new position based on the time and our particle’s velocity. We use that new position when setting gl_Position.

vertex_shader_v2.glsl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#version 330

// Time since burst start
uniform float time;

// (x, y) position passed in
in vec2 in_pos;

// Velocity of particle
in vec2 in_vel;

// Output the color to the fragment shader
out vec4 color;

void main() {

    // Set the RGBA color
    color = vec4(1, 1, 1, 1);

    // Calculate a new position
    vec2 new_pos = in_pos + (time * in_vel);

    // Set the position. (x, y, z, w)
    gl_Position = vec4(new_pos, 0.0, 1);
}

Program Listings

Step 4: Random Angle and Speed

../../_images/gpu_particle_burst_04.png

Step 3 didn’t do a good job of picking a velocity, as our particles expanded into a rectangle rather than a circle. Rather than just pick a random delta x and y, we need to pick a random direction and speed. Then calculate delta x and y from that.

Update Imports

Import the math library so we can do some trig:

import math

Update Burst Creation

Now, pick a random direction from zero to 2 pi radians. Also, pick a random speed. Then use sine and cosine to calculate the delta x and y.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
        """ User clicks mouse """

        def _gen_initial_data(initial_x, initial_y):
            """ Generate data for each particle """
            for i in range(PARTICLE_COUNT):
                angle = random.uniform(0, 2 * math.pi)
                speed = random.uniform(0.0, 0.3)
                dx = math.sin(angle) * speed
                dy = math.cos(angle) * speed
                yield initial_x
                yield initial_y
                yield dx
                yield dy

Program Listings

Step 5: Gaussian Distribution

../../_images/gpu_particle_burst_05.png

Setting speed to a random amount makes for an expanding circle. Another option is to use a gaussian function to produce more of a ‘splat’ look:

                speed = abs(random.gauss(0, 1)) * .5

Program Listings

Step 6: Add Color

../../_images/gpu_particle_burst_06.png

So far our particles have all been white. How do we add in color? We’ll need to generate it for each particle. Shaders take colors in the form of RGB floats, so we’ll generate a random number for red, and add in some green to get our yellows. Don’t add more green than red, or else you get a green tint.

Finally, pass in the three floats as in_color to the shader buffer (VBO).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
    def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
        """ User clicks mouse """

        def _gen_initial_data(initial_x, initial_y):
            """ Generate data for each particle """
            for i in range(PARTICLE_COUNT):
                angle = random.uniform(0, 2 * math.pi)
                speed = abs(random.gauss(0, 1)) * .5
                dx = math.sin(angle) * speed
                dy = math.cos(angle) * speed
                red = random.uniform(0.5, 1.0)
                green = random.uniform(0, red)
                blue = 0
                yield initial_x
                yield initial_y
                yield dx
                yield dy
                yield red
                yield green
                yield blue

        # Recalculate the coordinates from pixels to the OpenGL system with
        # 0, 0 at the center.
        x2 = x / self.width * 2. - 1.
        y2 = y / self.height * 2. - 1.

        # Get initial particle data
        initial_data = _gen_initial_data(x2, y2)

        # Create a buffer with that data
        buffer = self.ctx.buffer(data=array('f', initial_data))

        # Create a buffer description that says how the buffer data is formatted.
        buffer_description = arcade.gl.BufferDescription(buffer,
                                                         '2f 2f 3f',
                                                         ['in_pos', 'in_vel', 'in_color'])
        # Create our Vertex Attribute Object
        vao = self.ctx.geometry([buffer_description])

        # Create the Burst object and add it to the list of bursts
        burst = Burst(buffer=buffer, vao=vao, start_time=time.time())
        self.burst_list.append(burst)

Then, update the shader to use the color instead of always using white:

vertex_shader_v3.glsl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#version 330

// Time since burst start
uniform float time;

// (x, y) position passed in
in vec2 in_pos;

// Velocity of particle
in vec2 in_vel;

// Color of particle
in vec3 in_color;

// Output the color to the fragment shader
out vec4 color;

void main() {

    // Set the RGBA color
    color = vec4(in_color[0], in_color[1], in_color[2], 1);

    // Calculate a new position
    vec2 new_pos = in_pos + (time * in_vel);

    // Set the position. (x, y, z, w)
    gl_Position = vec4(new_pos, 0.0, 1);
}

Program Listings

Step 7: Fade Out

../../_images/gpu_particle_burst_07.png

Right now the explosion particles last forever. Let’s get them to fade out. Once a burst has faded out, let’s remove it from burst_list.

Constants

First, let’s add a couple constants to control the minimum and maximum tile to fade a particle:

MIN_FADE_TIME = 0.25
MAX_FADE_TIME = 1.5

Update Init

Next, we need to update our OpenGL context to support alpha blending. Go back to the __init__ method and update the enable_only call to:

self.ctx.enable_only(self.ctx.BLEND)

Add Fade Rate to Buffer

Next, add the fade rate to the VBO:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
    def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
        """ User clicks mouse """

        def _gen_initial_data(initial_x, initial_y):
            """ Generate data for each particle """
            for i in range(PARTICLE_COUNT):
                angle = random.uniform(0, 2 * math.pi)
                speed = abs(random.gauss(0, 1)) * .5
                dx = math.sin(angle) * speed
                dy = math.cos(angle) * speed
                red = random.uniform(0.5, 1.0)
                green = random.uniform(0, red)
                blue = 0
                fade_rate = random.uniform(1 / MAX_FADE_TIME, 1 / MIN_FADE_TIME)

                yield initial_x
                yield initial_y
                yield dx
                yield dy
                yield red
                yield green
                yield blue
                yield fade_rate

        # Recalculate the coordinates from pixels to the OpenGL system with
        # 0, 0 at the center.
        x2 = x / self.width * 2. - 1.
        y2 = y / self.height * 2. - 1.

        # Get initial particle data
        initial_data = _gen_initial_data(x2, y2)

        # Create a buffer with that data
        buffer = self.ctx.buffer(data=array('f', initial_data))

        # Create a buffer description that says how the buffer data is formatted.
        buffer_description = arcade.gl.BufferDescription(buffer,
                                                         '2f 2f 3f f',
                                                         ['in_pos',
                                                          'in_vel',
                                                          'in_color',
                                                          'in_fade_rate'])
        # Create our Vertex Attribute Object
        vao = self.ctx.geometry([buffer_description])

        # Create the Burst object and add it to the list of bursts
        burst = Burst(buffer=buffer, vao=vao, start_time=time.time())
        self.burst_list.append(burst)

Update Shader

Update the shader. Calculate the alpha. If it is less that 0, just use 0.

vertex_shader_v4.glsl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#version 330

// Time since burst start
uniform float time;

// (x, y) position passed in
in vec2 in_pos;

// Velocity of particle
in vec2 in_vel;

// Color of particle
in vec3 in_color;

// Fade rate
in float in_fade_rate;

// Output the color to the fragment shader
out vec4 color;

void main() {

    // Calculate alpha based on time and fade rate
    float alpha = 1.0 - (in_fade_rate * time);
    if(alpha < 0.0) alpha = 0;

    // Set the RGBA color
    color = vec4(in_color[0], in_color[1], in_color[2], alpha);

    // Calculate a new position
    vec2 new_pos = in_pos + (time * in_vel);

    // Set the position. (x, y, z, w)
    gl_Position = vec4(new_pos, 0.0, 1);
}

Remove Faded Bursts

Once our burst has completely faded, no need to keep it around. So in our on_update remove the burst from the burst_list after it has been faded.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    def on_update(self, dt):
        """ Update game """

        # Create a copy of our list, as we can't modify a list while iterating
        # it. Then see if any of the items have completely faded out and need
        # to be removed.
        temp_list = self.burst_list.copy()
        for burst in temp_list:
            if time.time() - burst.start_time > MAX_FADE_TIME:
               self.burst_list.remove(burst)

Program Listings

Step 8: Add Gravity

You could also add come gravity to the particles by adjusting the velocity based on a gravity constant. (In this case, 1.1.)

// Adjust velocity based on gravity
vec2 new_vel = in_vel;
new_vel[1] -= time * 1.1;

// Calculate a new position
vec2 new_pos = in_pos + (time * new_vel);

Program Listings