Author Topic: SotK's Oneshots One: Particles!  (Read 1380 times)

Son of the King

  • Technikos
    Technical Officer
  • Posts: 3234
  • Karma: 29
  • Awards Awarded for outstanding services to Exilian!
    • View Profile
    • SotK
    • Awards
SotK's Oneshots One: Particles!
« on: January 26, 2015, 09:47:38 PM »
SotK's Oneshots One: Particles!

Welcome to SotK's Oneshots, a series of Python programming tutorials which will be spread across this area and the general Software Tutorials section depending on their content. They will all be "oneshots", that is each will be unrelated and a standalone guide. They will probably vary in length, and I intend for them to pass on some knowledge of programming techniques beyond their specific focus.

This first installment is called Particles!, and is unsurprisingly about particles. Specifically, particle systems in games. Particle systems have a number of uses, from making weather, through nice looking smoke to realistic hair. At the end of this tutorial, you should have a program that is a basic 2D simulation of snowfall. I'm posting this before the content is actually finished, since I like to live on the edge, and also so you can follow along with me, or take your own direction and see how our results differ.

You Will Need
  • Python (version shouldn't matter, I don't think I'm doing anything specific to 2.7 or 3.*. I am using python-2.7 if you want to be safe.)
  • Pygame - version should be the latest for your installation of Python

Instructions on how to get pygame are here. For Python, look here.

Step I - Create a window

The first thing we need is a window to display things in! This is covered in detail in my 6th Python tutorial, so here I'll just give you the code that results from that. If you want explanation of it, look in the aforementioned thread.

Code: (Step I) [Select]
import pygame

def run():
    # initialise pygame
    pygame.init()
    screen = pygame.display.set_mode((600, 600))
    pygame.display.set_caption('Hello, world!')
   
    # game loop
    running = True
    while running:
        # event loop
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # Terminate on window closure
                running = False
            elif event.type == pygame.KEYUP and event.key == pygame.K_ESCAPE:
                # Terminate on Esc keypress
                running = False
   
        # drawing code
        screen.fill((0, 0, 0))
   
        pygame.display.flip()

if __name__ == '__main__':
    run()

Step II - Generate some particles

The next step is to display something on that screen! My 7th Python tutorial talks about displaying a box, but we don't really want to use shapes for this. The window (represented by the screen variable) is what pygame calls a "Surface". A surface is something which can be drawn or drawn on, and the screen surface is the window's main surface, anything drawn in it is displayed in the window (assuming its drawn within the window).

We want to draw particles on this Surface as small as we can. Luckily, pygame Surfaces have a method (more on the meaning of this later) called set_at, which allows us to set the colour of the pixel at a given coordinate.

Code: [Select]
screen.set_at(coordinate, colour)
In this call, colour is of the same form as in screen.fill. We want white pixels, since we're making "snow", so colour should be (255, 255, 255). The coordinate is the position of our particle. This is a pair of integers (can't have fractional numbers of pixels), which we can either decide on or randomly choose. Randomly choosing seems fun, and snow doesn't all start in one place!

Python has a module called random for just this purpose. random has a function called randint which generates a psuedo-random integer between two integers. Our screen has coordinates from 0 to 600 on both axes, and we don't want to spawn them right on the edge, so lets pick random numbers between 1 and 599. We'll also want to store all our particles so they don't disappear after one frame. We'll store them in a set. This is basically a representation of a mathematical set. It can only contain one instance of an item (i.e. if we try to put two of the same coordinates in, only one will end up stored). This is a minor optimisation at this point, to stop us drawing the same point multiple times per frame.

Code: [Select]
particles = set()
Code: [Select]
next_p = (random.randint(1, 599), random.randint(1, 599))
particles.add(next_p)

We also need to actually draw our particles. We do this by looping through the elements of the set and setting the colour at the correct pixel for each one.

Code: [Select]
for particle in particles:
    screen.set_at(particle, (255, 255, 255))

Lets put this together. We'll initialise the set before the game loop, add one particle each time, and draw all of them each frame.

Code: (Step II) [Select]
import random
import pygame

def run():
    # initialise pygame
    pygame.init()
    screen = pygame.display.set_mode((600, 600))
    pygame.display.set_caption('Hello, world!')
   
    # game loop
    particles = set()
    running = True
    while running:
        # event loop
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # Terminate on window closure
                running = False
            elif event.type == pygame.KEYUP and event.key == pygame.K_ESCAPE:
                # Terminate on Esc keypress
                running = False

        next_p = (random.randint(1, 599), random.randint(1, 599))
        particles.add(next_p)
        # drawing code
        screen.fill((0, 0, 0))
        for particle in particles:
            screen.set_at(particle, (255, 255, 255))
   
        pygame.display.flip()

if __name__ == '__main__':
    run()

Step III - Monte Carlo

That creates us a nice screen full of particles. However they are a square shape. This might not be bad, but circles are nicer. How do you get your random numbers to produce particles in a circle?

We will use a technique called Monte Carlo randomisation to generate our coordinates inside a circle. This is based on the fact that the equation of a circle is:

(x - a)2 + (y - b)2 = r2

For the "unit circle" - that is, a circle at the origin with radius 1 - this simplifies to:

x2 + y2 = 1

Therefore, if we want a point inside this circle, we just need to pick x and y such that:

x2 + y2 <= 1

That sounds like a while loop to me!

Code: [Select]
def monte_carlo():
    randx = 2
    randy = 2
    while (randx * randx) + (randy * randy) > 1:
        random.seed()
        randx = random.uniform(1, -1)
        randy = random.uniform(1, -1)
    return [randx, randy]

random.uniform(a, b) creates a random number between a and b. random.seed() resets the random seed used to generate the numbers, calling it each time marginally improves the randomness. This is a brute force way to get random numbers for the x and y values within the unit circle, but its not slow enough to be a problem this point.

Now lets modify our particle creation code to use this function.

Code: [Select]
point = monte_carlo()
next_p = (int((200*point[0]) + 300), int((200*point[1]) + 300))
particles.add(next_p)

The second line in that snippet looks ugly, so lets break it down. It takes the point in the unit circle returned by monte_carlo(), then scales it up by a factor of 200 (this gives values between -200 and 200) and translates it by (300, 300) (this moves them such that the values are relative to the centre of the window). Finally, the x and y values are being cast to integers. They are floats (decimals) when returned by monte_carlo(), and we need them to be integers to use them with set_at.

Lets see the finished code at this point:

Code: (Step III) [Select]
import math
import random
import pygame

def monte_carlo():
    randx = 2
    randy = 2
    while (randx * randx) + (randy * randy) > 1:
        random.seed()
        randx = random.uniform(1, -1)
        randy = random.uniform(1, -1)
    return [randx, randy]

def run():
    # initialise pygame
    pygame.init()
    screen = pygame.display.set_mode((600, 600))
    pygame.display.set_caption('Hello, world!')
   
    # game loop
    particles = set()
    running = True
    while running:
        # event loop
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # Terminate on window closure
                running = False
            elif event.type == pygame.KEYUP and event.key == pygame.K_ESCAPE:
                # Terminate on Esc keypress
                running = False

        point = monte_carlo()
        next_p = (int((200*point[0]) + 300), int((200*point[1]) + 300))
        particles.add(next_p)
        # drawing code
        screen.fill((0, 0, 0))
        for particle in particles:
            screen.set_at(particle, (255, 255, 255))
   
        pygame.display.flip()

if __name__ == '__main__':
    run()

Step IV - Tidying up

That horrible line did three different things and was hard to understand, so lets split it up. When programming, its a good idea to keep things as simple as possible so that someone with no knowledge of your code can understand it easily. This is achieved in part by naming your variables and functions sensibly, partly by useful comments (the code in this tutorial does not contain useful comments at this point), and partly by keeping your functions small and atomic (one function serves one purpose). Lets write three functions to do the different parts of that line:

Code: [Select]
def scale(point, sf):
    return (point[0] * sf, point[1] * sf)

def translate(point, dx, dy):
    return (point[0] + dx, point[1] + dy)

def make_int(point):
    return (int(point[0]), int(point[1]))

These are pretty self explanatory, what they do is obvious from looking, and was explained above :) . Now lets use them to replace the ugly line:

Code: [Select]
next_p = make_int(translate(scale(point, 50), 300, 300))
We could simplify this further by replacing point with monte_carlo(), but we'll be moving this code soon so this will do for now. The code at this stage should look something like this:

Code: (Step IV) [Select]
import math
import random
import pygame

def monte_carlo():
    randx = 2
    randy = 2
    while (randx * randx) + (randy * randy) > 1:
        random.seed()
        randx = random.uniform(1, -1)
        randy = random.uniform(1, -1)
    return [randx, randy]

def scale(point, sf):
    return (point[0] * sf, point[1] * sf)

def translate(point, dx, dy):
    return (point[0] + dx, point[1] + dy)

def make_int(point):
    return (int(point[0]), int(point[1]))

def run():
    # initialise pygame
    pygame.init()
    screen = pygame.display.set_mode((600, 600))
    pygame.display.set_caption('Hello, world!')
   
    # game loop
    particles = set()
    running = True
    while running:
        # event loop
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # Terminate on window closure
                running = False
            elif event.type == pygame.KEYUP and event.key == pygame.K_ESCAPE:
                # Terminate on Esc keypress
                running = False

        point = monte_carlo()
        next_p = make_int(translate(scale(point, 50), 300, 300))
        particles.add(next_p)
        # drawing code
        screen.fill((0, 0, 0))
        for particle in particles:
            screen.set_at(particle, (255, 255, 255))
   
        pygame.display.flip()

if __name__ == '__main__':
    run()

Step V - Getting older

OK, we're generating particles forever, and none of them are disappearing. This is Not GoodTM. We will eventually run out of memory and crash (actually the spawning area would end up entirely white and we'd stop adding particles since we use a set not a list, but if your spawning area is big enough you will cause a crash). Lets make our particles age. You will want aging particles so you can keep a lid on them nicely. If you randomly generate a lifespan for them, some disappearing and others appearing should seem nice and constant.

We're going to need to be able to store more than one piece of information about each particle now (the age and the position). Lets store them as dictionaries rather than just a coordinate. We could go all object oriented on this, but we'll save that for later :) .

Code: [Select]
def add_particle(particles):
    particle = {
        'name': 'snow',
        'age': 0,
        'position': make_int(translate(scale(monte_carlo(), 50), 300, 300)),
        'colour': (255, 255, 255)
    }
    particles.append(particle)

Here we add a function which creates a particle. Note we are using particles.append now, rather than particles.add. This is because we need to change particles from a set to a list. This is a downside of using a dictionary to represent a particle - dictionaries are not "hashable types" in Python, and items in a set must be "hashable" (hashable means that the object can be converted to a unique string which can then be converted back into the object).

The add_particle function is again pretty self-explanatory. We set the variable particle to be a dictionary containing 4 keys. These keys are 'name', 'age', 'position', and 'colour'. Each of these keys has a "value" - in the case of 'name', the value is 'snow'. Notice the value of 'position' - that is the ugly next_p line which we neatened. I told you we'd move that code soon!

Now we should utilise this code in our main loop. First, our particle adding code is replaced by a single function call:

Code: [Select]
add_particle(particles)
Next, lets increment the age each frame and remove any particles that get too old from the list:

Code: [Select]
particle['age'] += 1
if particle['age'] > 1000:
    particles.remove(particle)

We'll put that in the for loop we use for set_at. particle['age'] gets the value stored in the dictionary stored in particle with the key 'age'.

I said that you should make the lifespan random, but in this case it looks fine with a fixed lifespan. As each particle is created one frame after the last, there is a steady flow of particles disappearing and appearing. The code now looks like this:

Code: (Step V) [Select]
import math
import random
import pygame

def monte_carlo():
    randx = 2
    randy = 2
    while (randx * randx) + (randy * randy) > 1:
        random.seed()
        randx = random.uniform(1, -1)
        randy = random.uniform(1, -1)
    return [randx, randy]

def scale(point, sf):
    return (point[0] * sf, point[1] * sf)

def translate(point, dx, dy):
    return (point[0] + dx, point[1] + dy)

def make_int(point):
    return (int(point[0]), int(point[1]))

def add_particle(particles):
    particle = {
        'name': 'snow',
        'age': 0,
        'position': make_int(translate(scale(monte_carlo(), 50), 300, 300)),
        'colour': (255, 255, 255)
    }
    particles.append(particle)

def run():
    # initialise pygame
    pygame.init()
    screen = pygame.display.set_mode((600, 600))
    pygame.display.set_caption('Hello, world!')
   
    # game loop
    particles = []
    running = True
    while running:
        # event loop
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # Terminate on window closure
                running = False
            elif event.type == pygame.KEYUP and event.key == pygame.K_ESCAPE:
                # Terminate on Esc keypress
                running = False

        add_particle(particles)

        # drawing code
        screen.fill((0, 0, 0))
        for particle in particles:
            particle['age'] += 1
            if particle['age'] > 1000:
                particles.remove(particle)
            screen.set_at(particle['position'], particle['colour'])
   
        pygame.display.flip()

if __name__ == '__main__':
    run()

Step VI - Getting moving

We now have an easy way to add information to our particles, we can simply add to the dictionary created when we generate them! Lets get our particles moving around the screen a bit, they're boring stood still.

Code: [Select]
def add_particle(particles):
    particle = {
        'name': 'snow',
        'age': 0,
        'lifespan': random.randint(1, 1000),
        'position': round_to_int(translate(scale(monte_carlo(), 100), 300, 300)),
        'velocity': monte_carlo(),
        'colour': (255, 255, 255)
    }
    particles.append(particle)

We have added a 'velocity' key to the dictionary (also a 'lifespan' one, I decided to make it random anyway). This uses our monte_carlo() function from earlier to get two values inside the unit circle. Note that make_int has changed to round_to_int. I learnt something writing this, casting to int in Python truncates the number rather than rounding (so 3.6 gets turned into 3 not 4). Here is the round_to_int function:

Code: [Select]
def round_to_int(point):
    return [int(round(point[0], 0)), int(round(point[1], 0))]

round(number, decimal_places) is being used here. This does proper rounding, not truncation.

Now we want to make our particles understand the velocity they have.

Code: [Select]
particle['position'][0] += particle['velocity'][0]
particle['position'][1] += particle['velocity'][1]

We'll put this in the place where we deal with removing our particles, in the for loop. However, it won't work yet since the position + the velocity will be a float not an int, and therefore can't be used when we set pixels. We'll need to turn it into a pair of integers. Lucky we have a function for that eh?

Code: [Select]
particle['display_position'] = round_to_int(particle['position'])
screen.set_at(particle['display_position'], particle['colour'])

We're growing a lot of code in this for loop now. Remembering what I said about simplifying code, lets make an update_particle function.

Code: [Select]
def update_particle(particle, particles):
    particle['age'] += 1
    if particle['age'] > particle['lifespan']:
        particles.remove(particle)
    particle['position'][0] += particle['velocity'][0]
    particle['position'][1] += particle['velocity'][1]
    particle['display_position'] = round_to_int(particle['position'])

This is just the code that was in that for loop. We can now replace that with a single function call:

Code: [Select]
update_particle(particle, particles)
Easy! The code should now look like this:

Code: (Step VI) [Select]
import math
import random
import pygame

def monte_carlo():
    randx = 2
    randy = 2
    while (randx * randx) + (randy * randy) > 1:
        random.seed()
        randx = random.uniform(1, -1)
        randy = random.uniform(1, -1)
    return [randx, randy]

def scale(point, sf):
    return (point[0] * sf, point[1] * sf)

def translate(point, dx, dy):
    return (point[0] + dx, point[1] + dy)

def round_to_int(point):
    return [int(round(point[0], 0)), int(round(point[1], 0))]

def add_particle(particles):
    particle = {
        'name': 'snow',
        'age': 0,
        'lifespan': random.randint(1, 1000),
        'position': round_to_int(translate(scale(monte_carlo(), 100), 300, 300)),
        'velocity': monte_carlo(),
        'colour': (255, 255, 255)
    }
    particles.append(particle)

def update_particle(particle, particles):
    particle['age'] += 1
    if particle['age'] > particle['lifespan']:
        particles.remove(particle)
    particle['position'][0] += particle['velocity'][0]
    particle['position'][1] += particle['velocity'][1]
    particle['display_position'] = round_to_int(particle['position'])

def run():
    # initialise pygame
    pygame.init()
    screen = pygame.display.set_mode((600, 600))
    pygame.display.set_caption('Hello, world!')
   
    # game loop
    particles = []
    running = True
    while running:
        # event loop
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                # Terminate on window closure
                running = False
            elif event.type == pygame.KEYUP and event.key == pygame.K_ESCAPE:
                # Terminate on Esc keypress
                running = False

        add_particle(particles)

        # drawing code
        screen.fill((0, 0, 0))
        for particle in particles:
            update_particle(particle, particles)
            screen.set_at(particle['display_position'], particle['colour'])
   
        pygame.display.flip()

if __name__ == '__main__':
    run()

Coming soon
  • Acceleration due to gravity
  • Making things configurable
  • Snow falls from more than one place really
  • The floor is dry, why not stick?

Jubal

  • Megadux
    Executive Officer
  • Posts: 22230
  • Karma: 100
  • Awards Awarded for oustanding services to Exilian!
    • View Profile
    • Awards
Re: SotK's Oneshots One: Particles!
« Reply #1 on: January 26, 2015, 10:16:12 PM »
I need to get into using pygame - probably when I've pushed through and got Adventures of Soros more finished, whatever my next game project is might be in pygame (it should perhaps ideally be in a compiled language, but I have at least 2-3 human languages to learn before I let myself embark on more computing ones...)
The duke, the wanderer, the philosopher, the mariner, the warrior, the strategist, the storyteller, the wizard, the wayfarer...