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:
- Emission stage: in this stage we generate new particles.
- Simulation stage: in this stage we simulate the movement of particles one by one.
- Render stage: in this stage we render the thing.
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:
- its position;
- its velocity (to where and how fast is the particle going);
- and its life span (how long will the particle last).
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:
- its position;
- its life span, i.e. how long will it last;
- and its initial velocity.
In the following code I've made these choices:
- the emission is managed by an emitter object;
- each emitter has its own position & general velocity; new particles will get its lifespan, initial position & initial velocity from such emitter object.
@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:
- can emerge from the enemy or just simply appear any where;
- can follow a fixed pattern (e.g. circles, spirals & lines) or follow the player;
- can have different stages, e.g. when fighting Izayoi Sakuya the bullets will freeze in the air for some time, signifying her ability (stated in the lore) to stop time.
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.