GPU Particle Burst

../../_images/explosions.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"""
 2Example showing how to create particle explosions via the GPU.
 3"""
 4import arcade
 5
 6SCREEN_WIDTH = 1024
 7SCREEN_HEIGHT = 768
 8SCREEN_TITLE = "GPU Particle Explosion"
 9
10class MyWindow(arcade.Window):
11    """ Main window"""
12    def __init__(self):
13        super().__init__(SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE)
14
15    def on_draw(self):
16        """ Draw everything """
17        self.clear()
18
19    def on_update(self, dt):
20        """ Update everything """
21        pass
22
23    def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
24        """ User clicks mouse """
25        pass
26
27
28if __name__ == "__main__":
29    window = MyWindow()
30    window.center_window()
31    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#version 330
 2
 3// (x, y) position passed in
 4in vec2 in_pos;
 5
 6// Output the color to the fragment shader
 7out vec4 color;
 8
 9void main() {
10
11    // Set the RGBA color
12    color = vec4(1, 1, 1, 1);
13
14    // Set the position. (x, y, z, w)
15    gl_Position = vec4(in_pos, 0.0, 1);
16}

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#version 330
 2
 3// Color passed in from the vertex shader
 4in vec4 color;
 5
 6// The pixel we are writing to in the framebuffer
 7out vec4 fragColor;
 8
 9void main() {
10
11    // Fill the point
12    fragColor = vec4(color);
13}

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    def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
 2        """ User clicks mouse """
 3
 4        def _gen_initial_data(initial_x, initial_y):
 5            """ Generate data for each particle """
 6            for i in range(PARTICLE_COUNT):
 7                dx = random.uniform(-.2, .2)
 8                dy = random.uniform(-.2, .2)
 9                yield initial_x
10                yield initial_y
11                yield dx
12                yield dy
13
14        # Recalculate the coordinates from pixels to the OpenGL system with
15        # 0, 0 at the center.
16        x2 = x / self.width * 2. - 1.
17        y2 = y / self.height * 2. - 1.
18
19        # Get initial particle data
20        initial_data = _gen_initial_data(x2, y2)
21
22        # Create a buffer with that data
23        buffer = self.ctx.buffer(data=array('f', initial_data))
24
25        # Create a buffer description that says how the buffer data is formatted.
26        buffer_description = arcade.gl.BufferDescription(buffer,
27                                                         '2f 2f',
28                                                         ['in_pos', 'in_vel'])
29        # Create our Vertex Attribute Object
30        vao = self.ctx.geometry([buffer_description])
31
32        # Create the Burst object and add it to the list of bursts
33        burst = Burst(buffer=buffer, vao=vao, start_time=time.time())
34        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#version 330
 2
 3// Time since burst start
 4uniform float time;
 5
 6// (x, y) position passed in
 7in vec2 in_pos;
 8
 9// Velocity of particle
10in vec2 in_vel;
11
12// Output the color to the fragment shader
13out vec4 color;
14
15void main() {
16
17    // Set the RGBA color
18    color = vec4(1, 1, 1, 1);
19
20    // Calculate a new position
21    vec2 new_pos = in_pos + (time * in_vel);
22
23    // Set the position. (x, y, z, w)
24    gl_Position = vec4(new_pos, 0.0, 1);
25}

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    def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
 2        """ User clicks mouse """
 3
 4        def _gen_initial_data(initial_x, initial_y):
 5            """ Generate data for each particle """
 6            for i in range(PARTICLE_COUNT):
 7                angle = random.uniform(0, 2 * math.pi)
 8                speed = random.uniform(0.0, 0.3)
 9                dx = math.sin(angle) * speed
10                dy = math.cos(angle) * speed
11                yield initial_x
12                yield initial_y
13                yield dx
14                yield dy
15

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    def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
 2        """ User clicks mouse """
 3
 4        def _gen_initial_data(initial_x, initial_y):
 5            """ Generate data for each particle """
 6            for i in range(PARTICLE_COUNT):
 7                angle = random.uniform(0, 2 * math.pi)
 8                speed = abs(random.gauss(0, 1)) * .5
 9                dx = math.sin(angle) * speed
10                dy = math.cos(angle) * speed
11                red = random.uniform(0.5, 1.0)
12                green = random.uniform(0, red)
13                blue = 0
14                yield initial_x
15                yield initial_y
16                yield dx
17                yield dy
18                yield red
19                yield green
20                yield blue
21
22        # Recalculate the coordinates from pixels to the OpenGL system with
23        # 0, 0 at the center.
24        x2 = x / self.width * 2. - 1.
25        y2 = y / self.height * 2. - 1.
26
27        # Get initial particle data
28        initial_data = _gen_initial_data(x2, y2)
29
30        # Create a buffer with that data
31        buffer = self.ctx.buffer(data=array('f', initial_data))
32
33        # Create a buffer description that says how the buffer data is formatted.
34        buffer_description = arcade.gl.BufferDescription(buffer,
35                                                         '2f 2f 3f',
36                                                         ['in_pos', 'in_vel', 'in_color'])
37        # Create our Vertex Attribute Object
38        vao = self.ctx.geometry([buffer_description])
39
40        # Create the Burst object and add it to the list of bursts
41        burst = Burst(buffer=buffer, vao=vao, start_time=time.time())
42        self.burst_list.append(burst)

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

vertex_shader_v3.glsl
 1#version 330
 2
 3// Time since burst start
 4uniform float time;
 5
 6// (x, y) position passed in
 7in vec2 in_pos;
 8
 9// Velocity of particle
10in vec2 in_vel;
11
12// Color of particle
13in vec3 in_color;
14
15// Output the color to the fragment shader
16out vec4 color;
17
18void main() {
19
20    // Set the RGBA color
21    color = vec4(in_color[0], in_color[1], in_color[2], 1);
22
23    // Calculate a new position
24    vec2 new_pos = in_pos + (time * in_vel);
25
26    // Set the position. (x, y, z, w)
27    gl_Position = vec4(new_pos, 0.0, 1);
28}

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    def on_mouse_press(self, x: float, y: float, button: int, modifiers: int):
 2        """ User clicks mouse """
 3
 4        def _gen_initial_data(initial_x, initial_y):
 5            """ Generate data for each particle """
 6            for i in range(PARTICLE_COUNT):
 7                angle = random.uniform(0, 2 * math.pi)
 8                speed = abs(random.gauss(0, 1)) * .5
 9                dx = math.sin(angle) * speed
10                dy = math.cos(angle) * speed
11                red = random.uniform(0.5, 1.0)
12                green = random.uniform(0, red)
13                blue = 0
14                fade_rate = random.uniform(1 / MAX_FADE_TIME, 1 / MIN_FADE_TIME)
15
16                yield initial_x
17                yield initial_y
18                yield dx
19                yield dy
20                yield red
21                yield green
22                yield blue
23                yield fade_rate
24
25        # Recalculate the coordinates from pixels to the OpenGL system with
26        # 0, 0 at the center.
27        x2 = x / self.width * 2. - 1.
28        y2 = y / self.height * 2. - 1.
29
30        # Get initial particle data
31        initial_data = _gen_initial_data(x2, y2)
32
33        # Create a buffer with that data
34        buffer = self.ctx.buffer(data=array('f', initial_data))
35
36        # Create a buffer description that says how the buffer data is formatted.
37        buffer_description = arcade.gl.BufferDescription(buffer,
38                                                         '2f 2f 3f f',
39                                                         ['in_pos',
40                                                          'in_vel',
41                                                          'in_color',
42                                                          'in_fade_rate'])
43        # Create our Vertex Attribute Object
44        vao = self.ctx.geometry([buffer_description])
45
46        # Create the Burst object and add it to the list of bursts
47        burst = Burst(buffer=buffer, vao=vao, start_time=time.time())
48        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#version 330
 2
 3// Time since burst start
 4uniform float time;
 5
 6// (x, y) position passed in
 7in vec2 in_pos;
 8
 9// Velocity of particle
10in vec2 in_vel;
11
12// Color of particle
13in vec3 in_color;
14
15// Fade rate
16in float in_fade_rate;
17
18// Output the color to the fragment shader
19out vec4 color;
20
21void main() {
22
23    // Calculate alpha based on time and fade rate
24    float alpha = 1.0 - (in_fade_rate * time);
25    if(alpha < 0.0) alpha = 0;
26
27    // Set the RGBA color
28    color = vec4(in_color[0], in_color[1], in_color[2], alpha);
29
30    // Calculate a new position
31    vec2 new_pos = in_pos + (time * in_vel);
32
33    // Set the position. (x, y, z, w)
34    gl_Position = vec4(new_pos, 0.0, 1);
35}

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    def on_update(self, dt):
 2        """ Update game """
 3
 4        # Create a copy of our list, as we can't modify a list while iterating
 5        # it. Then see if any of the items have completely faded out and need
 6        # to be removed.
 7        temp_list = self.burst_list.copy()
 8        for burst in temp_list:
 9            if time.time() - burst.start_time > MAX_FADE_TIME:
10               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