GPU Particle Burst
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.
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 | """
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
For this next section, we are going to draw a dot each time the user clicks their mouse on the screen.
For each click, we are going to create an instance of a Burst
class 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.
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.
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.
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
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.
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
fragment_shader.glsl Full Listing ← Where we are right now
vertex_shader_v1.glsl Full Listing ← Where we are right now
gpu_particle_burst_02.py Full Listing ← Where we are right now
gpu_particle_burst_02.py Diff ← What we changed to get here
Step 3: Multiple Moving Particles
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
.
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
vertex_shader_v2.glsl Full Listing ← Where we are right now
vertex_shader_v2.glsl Diff ← What we changed to get here
gpu_particle_burst_03.py Full Listing ← Where we are right now
gpu_particle_burst_03.py Diff ← What we changed to get here
Step 4: Random Angle and Speed
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
gpu_particle_burst_04.py Full Listing ← Where we are right now
gpu_particle_burst_04.py Diff ← What we changed to get here
Step 5: Gaussian Distribution
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:
for i in range(PARTICLE_COUNT):
Program Listings
gpu_particle_burst_05.py Full Listing ← Where we are right now
gpu_particle_burst_05.py Diff ← What we changed to get here
Step 6: Add Color
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:
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
vertex_shader_v3.glsl Full Listing ← Where we are right now
vertex_shader_v3.glsl Diff ← What we changed to get here
gpu_particle_burst_06.py Full Listing ← Where we are right now
gpu_particle_burst_06.py Diff ← What we changed to get here
Step 7: Fade Out
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.
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
vertex_shader_v4.glsl Full Listing ← Where we are right now
vertex_shader_v4.glsl Diff ← What we changed to get here
gpu_particle_burst_07.py Full Listing ← Where we are right now
gpu_particle_burst_07.py Diff ← What we changed to get here
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
vertex_shader_v5.glsl Full Listing ← Where we are right now
vertex_shader_v5.glsl Diff ← What we changed to get here