Building A Basic Particle System

Years ago someone I know made an Android app that displays Touhou-like bullet hell from math equations. Back then I don't know a thing about computer graphics and thought there's some extremely complicated stuff behind that. Turns out it wasn't as complicated as I thought, at least in the simpler side of things.

Particle systems are essentially a system of particles, used to model a lot of stuff that the conventional way of using polygons cannot do very well:

Modeling phenomena such as clouds, smoke, water, and fire has proved difficult with the existing techniques of computer image synthesis. These "fuzzy" objects do not have smooth, well-defined, and shiny surfaces; instead their surfaces are irregular, complex, and ill defined.

-- Reeves, William T. "Particle Systems - A Technique for Modeling a Class of Fuzzy Objects" (1982)

It involves three different stages:

Just like anything in general, complicated-level stuff are very complicated, and easy-level stuff are very easy; the original paper involves 3D maths, but to give a basic understanding 2D is enough.

The code

I originally used turtle which is definitely not fast enough, so I switched to tkinter instead.

import math
import tkinter
from tkinter import ttk
from dataclasses import dataclass
from random import random
from typing import Union
import time

# setting up the main window...
WINDOW_WIDTH = 300
WINDOW_HEIGHT = 300

root = tkinter.Tk()
root.title('Simple particle system')
canvas_main = tkinter.Canvas(root, bg="black", height=WINDOW_HEIGHT, width=WINDOW_WIDTH)

The "particles" of things that're modelled - the droplets of water splash, the thin layers of smoke, etc - their properties are of course decided by the laws of physics, but those are way too difficult to pinpoint & calculate efficiently, so instead they're modelled as a combination of a "general" value a random variance. The most basic properties of a particle are:

You can add other properties like size & color. I added color in this code.

@dataclass
class Vec2:
    x: Union[int, float]
    y: Union[int, float]
    def __add__(self, other):
        return Vec2(x=self.x+other.x, y=self.y+other.y)
    def length(self) -> float:
        return math.sqrt(self.x**2+self.y**2)

@dataclass
class NumVariant:
    base: Union[int, float]
    variant: Union[int, float]

    def value(self):
        return (float if type(self.base) is float else int)(self.base + (self.variant * random()))

@dataclass
class Vec2Variant:
    v: Vec2
    angle_variant: float  # in radiant; abs val.
    speed_variant: float
    def value(self):
        z = random()*(self.angle_variant*2)-self.angle_variant
        va = math.atan2(self.v.y, self.v.x)
        na = z + va
        s = self.v.length()
        sv = random()*(self.speed_variant*2)-self.speed_variant
        ns = s + sv
        return Vec2(x=math.cos(na)*ns, y=math.sin(na)*ns)

PARTICLE_SIZE: int = 4

@dataclass
class Color:
    r: int
    g: int
    b: int
    a: int = 0xff  # 0xff-solid 0x00-transparent

    def get_rgb_string(self) -> str:
        return f'#{self.r:02x}{self.g:02x}{self.b:02x}'

    def get_rgba_string(self) -> str:
        return f'#{self.r:02x}{self.g:02x}{self.b:02x}{self.a:02x}'

    def scale(self, light: float):
        return Color(
            r=math.floor(self.r * light),
            g=math.floor(self.g * light),
            b=math.floor(self.b * light),
            a=self.a,
        )

DEFAULT_PARTICLE_COLOR = Color(0,255,255)

@dataclass
class Particle:
    pos: Vec2
    v: Vec2
    # the life/lifespan here is to calculate the light level for scaling the color.
    life: int
    lifespan: int
    color: Color = DEFAULT_PARTICLE_COLOR

    def __post_init__(self):
        # ...

    def draw(self):
        # ...

The way Tk canvas worked is quite different than other canvases I've met: normally you'll just have a thing you draw stuff onto, and whatever you've drawn you cannot remove or change easily, just like a real canvas in real life; but Tk canvas maintains a list of objects that represents drawing elements on the canvas, almost like the list of layers in some advanced painting softwares (Photoshop; GIMP; Clip Studio Paint). The code will have a very different general idea if you decide to use other ways to implement the GUI (e.g. SDL2):

# inside class Particle
    def __post_init__(self):
        color_str = self.color.get_rgb_string()
        self._elem = canvas_main.create_oval(
            self.pos.x-PARTICLE_SIZE/2, self.pos.y-PARTICLE_SIZE/2,
            self.pos.x+PARTICLE_SIZE/2, self.pos.y+PARTICLE_SIZE/2,
            fill=color_str,
            outline=color_str,
        )

    def draw(self):
        canvas_main.moveto(self._elem,
            self.pos.x-PARTICLE_SIZE/2, self.pos.y-PARTICLE_SIZE/2,
        )
        color_str = self.color.scale(self.life/self.lifespan).get_rgb_string()
        canvas_main.itemconfigure(self._elem,
            fill=color_str,
            outline=color_str,
        )

The definition for particles is ready; we need something that models the emission of particles. To generate a particle, we need to at least decide three things:

In the following code I've made these choices:

@dataclass
class Emitter:
    name: str
    pos: Vec2
    init_v: Vec2Variant
    particle: list[Particle]
    max_particle: int = 100
    current_particle: int = 0
    life: NumVariant = NumVariant(base=100, variant=2)
    force: Vec2 = Vec2(0, 0.05)  # explained later

    def add_particle(self):
        if self.current_particle < self.max_particle:
            life = self.life.value()
            new_particle = Particle(
                pos=self.pos,
                v=self.init_v.value(),
                life=life,
                lifespan=life,
            )
            self.particle.append(new_particle)
    
    def update_particle(self):
        # ...

    def draw_particle(self):
        for p in self.particle: p.draw()

em = Emitter(
    name='em1',
    pos=Vec2(WINDOW_WIDTH/2, WINDOW_HEIGHT/2),
    init_v=Vec2Variant(v=Vec2(0, -1), angle_variant=math.radians(30), speed_variant=0.5),
    particle=[]
)

In some scenarioes where there's a strict restriction on what you can use, you'll have to pay more effort on creating & managing objects, e.g. production usage which requires things to work in real-time; most (if not all) of the real-time scenarioes you can't use malloc because the latency is not predictable.

Now the simulation stage. According to physics, if not affected by any forces, a particle should just stay still (if no velocity) or move towards a fixed direction at a constant speed (i.e. a stable velocity), and if affected by forces, the velocity changes and the change (i.e. acceleration) is related to the force and the particle's mass. In this code there's only acceleration (incorrectly defined, in a technical sense, as force above) which is enough for this demonstration; in a more serious scenario other parameters should be considered as well.

# in class Emitter
    def update_particle(self):
        for i, p in enumerate(self.particle):
            p.life -= 1
            # we won't bother updating if the particle is already dead
            if p.life <= 0: continue
            p.v = p.v + self.force
            p.pos = p.pos + p.v
        for p in self.particle:
            if (p.life <= 0):
                canvas_main.delete(p._elem)
        self.particle = [p for p in self.particle if p.life > 0]

I decided to have something like a control panel that can control the variances; this will be helpful for exploring/demonstrating how one can use such a system to render stuff.

control_panel = tkinter.Toplevel()
control_panel.title('Control Panel')

lbl_particle_emit_width = ttk.Label(control_panel, text='Particle emit angle')
lbl_particle_emit_width.pack()
def update_emit_width(val):
    lbl_particle_emit_width['text'] = f'Particle emit angle: {val}'
    em.init_v.angle_variant = math.radians(float(val))
sc_particle_emit_width = ttk.Scale(control_panel, orient=tkinter.HORIZONTAL, length=400, from_=0, to=90, command=update_emit_width)
sc_particle_emit_width.pack()
sc_particle_emit_width.set(30)

lbl_particle_emit_speed_variant = ttk.Label(control_panel, text='Particle emit speed variant')
lbl_particle_emit_speed_variant.pack()
def update_emit_speed_variant(val):
    lbl_particle_emit_speed_variant['text'] = f'Particle emit speed variant: {val}'
    em.init_v.speed_variant = float(val)
sc_particle_emit_speed_variant = ttk.Scale(control_panel, orient=tkinter.HORIZONTAL, length=400, from_=-5, to=5, command=update_emit_speed_variant)
sc_particle_emit_speed_variant.pack()
sc_particle_emit_speed_variant.set(0.5)

lbl_particle_emit_v = ttk.Label(control_panel, text='Particle emit velocity (on y axis)')
lbl_particle_emit_v.pack()
def update_emit_v(val):
    lbl_particle_emit_v['text'] = f'Particle emit velocity (on y axis): {val}'
    em.init_v.v.y = float(val)
sc_particle_emit_v = ttk.Scale(control_panel, orient=tkinter.HORIZONTAL, length=400, from_=-5, to=5, command=update_emit_v)
sc_particle_emit_v.pack()
sc_particle_emit_v.set(-1)

lbl_particle_emit_g = ttk.Label(control_panel, text='Gravity (on y axis)')
lbl_particle_emit_g.pack()
def update_emit_g(val):
    lbl_particle_emit_g['text'] = f'Gravity (on y axis): {val}'
    em.force.y = float(val)
sc_particle_emit_g = ttk.Scale(control_panel, orient=tkinter.HORIZONTAL, length=400, from_=-1, to=1, command=update_emit_g)
sc_particle_emit_g.pack()
sc_particle_emit_g.set(0.05)

At the end we need code that kickstarts the simulation:

while True:
    em.add_particle()
    em.update_particle()
    em.draw_particle()
    root.update()
    time.sleep(1/60)

root.mainloop()

Modeling water & fire

The settings in the code above should produce something resembles water splash; as long as the color is blue & the force is big enough to pull the particles downward it'll look like water splash. Change the following lines will make it look like a small flame with smoke.

# before class Particle
DEFAULT_PARTICLE_COLOR = Color(255,32,0)

# in class Emitter
force: Vec2 = Vec2(0, -0.005)

# in definition of `em`
em = Emitter(
    name='em1',
    pos=Vec2(WINDOW_WIDTH/2, WINDOW_HEIGHT/2),
    init_v=Vec2Variant(v=Vec2(0, -0.33), angle_variant=math.radians(45), speed_variant=0),
    particle=[]
)

# in the code for the control panel
sc_particle_emit_width.set(45)
sc_particle_emit_speed_variant.set(0)
sc_particle_emit_v.set(-0.33)
sc_particle_emit_g.set(-0.005)

Touhou-like bullet hell

If you like vertically scrolling shooter like Raiden and have never tried any Touhou Project game then please do... it's one hell of a game. Or games. For casual players they're all hellishly hard even on easy mode.

Particles (i.e. the bullets) in Touhou:

For this demonstration I'll only consider the spiral kind.

This time the movement of the particles is not effected by any kind of forces. The direction of the initial velocity changes, and the speed will slow down gradually until hitting the lowest reasonable speed. From these observations we can have the following code:

# inside class Particle
    # ...
    def draw(self):
        canvas_main.moveto(self._elem,
            self.pos.x-PARTICLE_SIZE/2, self.pos.y-PARTICLE_SIZE/2,
        )
        color_str = self.color.get_rgb_string()
        canvas_main.itemconfigure(self._elem,
            fill=color_str,
            outline=color_str,
        )

@dataclass
class Emitter:
    name: str
    pos: Vec2
    init_speed: float
    particle: list[Particle]
    current_phi: float = 0 # in degree
    max_particle: int = 100
    current_particle: int = 0
    life: int = 100
    step: float = 10
    min_speed_factor: float = 0.5

    def add_particle(self):
        if self.current_particle < self.max_particle:
            new_particle = Particle(
                pos=self.pos,
                v=Vec2(
                    x=math.cos(math.radians(self.current_phi)),
                    y=math.sin(math.radians(self.current_phi)),
                ).scale(self.init_speed),
                life=self.life,
                lifespan=self.life,
            )
            self.particle.append(new_particle)
            self.current_phi = (self.current_phi + self.step) % 360
    
    def update_particle(self):
        for i, p in enumerate(self.particle):
            p.life -= 1
            if p.life <= 0: continue
            p.pos = p.pos + p.v.scale(max(p.life/p.lifespan, self.min_speed_factor))
        for p in self.particle:
            if (p.life <= 0):
                canvas_main.delete(p._elem)
        self.particle = [p for p in self.particle if p.life > 0]

    def draw_particle(self):
        for p in self.particle: p.draw()

em = Emitter(
    name='em1',
    pos=Vec2(WINDOW_WIDTH/2, WINDOW_HEIGHT/2),
    init_speed=1,
    particle=[]
)

# a new control panel.

control_panel = tkinter.Toplevel()
control_panel.title('Control Panel')

lbl_particle_emit_angle_step = ttk.Label(control_panel, text='Particle emit angle step (deg)')
lbl_particle_emit_angle_step.pack()
def update_emit_width(val):
    lbl_particle_emit_angle_step['text'] = f'Particle emit angle step (deg): {val}'
    em.step = float(val)
sc_particle_emit_angle_step = ttk.Scale(control_panel, orient=tkinter.HORIZONTAL, length=400, from_=0, to=360, command=update_emit_width)
sc_particle_emit_angle_step.pack()
sc_particle_emit_angle_step.set(116)

lbl_particle_emit_speed = ttk.Label(control_panel, text='Particle emit speed')
lbl_particle_emit_speed.pack()
def update_emit_speed_variant(val):
    lbl_particle_emit_speed['text'] = f'Particle emit speed: {val}'
    em.init_speed = float(val)
sc_particle_emit_speed = ttk.Scale(control_panel, orient=tkinter.HORIZONTAL, length=400, from_=-10, to=10, command=update_emit_speed_variant)
sc_particle_emit_speed.pack()
sc_particle_emit_speed.set(3.5)


lbl_particle_emit_min_speed_factor = ttk.Label(control_panel, text='Particle emit min speed factor')
lbl_particle_emit_min_speed_factor.pack()
def update_emit_g(val):
    lbl_particle_emit_min_speed_factor['text'] = f'Particle emit min speed factor: {val}'
    em.min_speed_factor = float(val)
sc_particle_emit_min_speed_factor = ttk.Scale(control_panel, orient=tkinter.HORIZONTAL, length=400, from_=-1, to=1, command=update_emit_g)
sc_particle_emit_min_speed_factor.pack()
sc_particle_emit_min_speed_factor.set(0.5)

The code to the two demo can be downloaded from here and here.