Lab 5 Introduction to Particle Systems

Aims

The aim of this lab is to develop a simple particle system and write data to various file formats. We will then visualize the data using 3rd party tools

  1. Introduction to Particle Systems
  2. using lists and numpy
  3. Writing data to disk
  4. Visualizing data.

Introduction to Particle Systems

Particle systems were introduced in the 1983 paper Particle Systems—A Technique for Modeling a Class of Fuzzy Objects and can be seen in action in the “Genesis Effect” sequence from Star Trek II : The Wrath of Khan. It is interesting to note that this movie also has fractal terrain, alpha compositing and other core CGI elements that were basically invented for this sequence.

A particle system is usually generated as a collection of point like elements. Usually they are animated using a physics based system but using a simplified model. In most cases particles are assumed to not collide with themselves (to reduce complexity) and if rendering we make a number of assumptions to ensure rendering is quicker, for example lack of shadows etc.

Particle structure

Particle systems have changed very little from the days of the Original Reeves paper, where he proposed a Particle has the following basic attributes.

  1. initial position
  2. initial velocity (both speed and direction)
  3. initial size
  4. initial color
  5. initial transparency
  6. shape
  7. lifetime

Whilst not explicitly mentioned in the Reeves paper a particle system usually has a controlling class called the Emitter which acts as the source of the particle’s initial position. It will usually have methods to render and update the system as well as to add and remove particles.

classDiagram
class Vec3{
  x : float
  y : float
  z : float
}
class Particle{
  + pos : Vec3
  + dir : Vec3
  + colour : Vec3
  + life : int
  + size : float
}

class Emitter{
- m_particles : std::vector<Particle>
- m_position : Vec3
- m_spread : float
- m_emitDir : Vec3
+ Emitter(_pos : Vec3, _numParticles : size_t)
+ ~Emitter()
+ update()
+ saveFrame(_fname : const std::string &)
+ draw()
}

Emitter --> Vec3
Particle --> Vec3
Emitter "1" -->"1..*" Particle : Creates

System Outline

graph LR
A[Init<br>Emitter]-->B{Finished}
B -->|No|C[Update All<br>Particles]
C-->D[WriteFrame<BR>To File]
D-->B
B -->|YES| E[EXIT]

The overall simulation is quite simple, we will loop for each particle and update using a simple motion equation, we will then write the particle position and size to disk and update the simulation for the next frame. To start with we will do some simple updates to ensure the basic system works then we will add more elements to get a better system.

Getting Started.

We are going to use a combination of TDD an YAGNI techniques in developing this system. We are going to start by creating our empty project and tests as usual.

First we will create an empty project folder and add files as follows.

For ease I have created some starter boiler plate code for this. Download the file Particle.tgz and copy it to your labs folder.

To extract do the following.

tar vfxz Particle.tgz
Particle
├── Emitter.py
├── main.py
├── Particle.py
├── pyproject.toml
├── Random.py
├── README.md
├── tests
│   ├── test_Emitter.py
│   ├── test_particle.py
│   └── test_Vec3.py
├── uv.lock
└── Vec3.py

2 directories, 11 files

TDD Steps

We will now start to generate our particle system bit by bit only developing the elements we need at the time. The rough order will be as follows

  1. Construct Particle with No attributes.
  2. Generate Default Vec3 class with x,y,z so we can add attributes to Particle
  3. Generate Particle struct with all attributes needed.
  4. Generate a Simple Emitter class with default Particles.
  5. Generate better initial particles (will require a new Random Class)
  6. Update the particles using algorithm outlined below.
  7. Write particles per frame to file.
  8. Play!

Particle Birth

To create the new particle the emitter will set the default values for a particle whilst adding some random variation using the following pseudo code.

p.pos = emitter position
p.dir = m_emitDir * randomPositiveFloat() + randomVectorOnSphere() * m_spread;
p.dir.m_y = std::abs(p.dir.m_y);
p.colour = randomPositiveVec3();
p.maxLife = randomPositiveFloat(5000)+500;
p.life = 0;
p.scale= 0.01f;

For ease we will use a simple random number generator class provided in the Random.py file. For standard random numbers we will use random.randint for example.

import math
import random

from Vec3 import Vec3


class Random:
    """
    Utility class for generating random floats and vectors.
    """

    @staticmethod
    def random_float(mult: float = 1.0) -> float:
        """
        Returns a random float in the range [-1, 1], scaled by `mult`.

        Args:
            mult (float): Multiplier for the random value.

        Returns:
            float: Random float in [-mult, mult].
        """
        return random.uniform(-1.0, 1.0) * mult

    @staticmethod
    def random_positive_float(mult: float = 1.0) -> float:
        """
        Returns a random float in the range [0, 1], scaled by `mult`.

        Args:
            mult (float): Multiplier for the random value.

        Returns:
            float: Random float in [0, mult].
        """
        return random.uniform(0.0, 1.0) * mult

    @staticmethod
    def random_vec3(mult: float = 1.0) -> Vec3:
        """
        Returns a Vec3 with each component in the range [-1, 1], scaled by `mult`.

        Args:
            mult (float): Multiplier for each component.

        Returns:
            Vec3: Random vector with components in [-mult, mult].
        """
        return Vec3(Random.random_float(mult), Random.random_float(mult), Random.random_float(mult))

    @staticmethod
    def random_positive_vec3(mult: float = 1.0) -> Vec3:
        """
        Returns a Vec3 with each component in the range [0, 1], scaled by `mult`.

        Args:
            mult (float): Multiplier for each component.

        Returns:
            Vec3: Random vector with components in [0, mult].
        """
        return Vec3(
            Random.random_positive_float(mult), Random.random_positive_float(mult), Random.random_positive_float(mult)
        )

    @staticmethod
    def random_vector_on_sphere(radius: float = 1.0) -> Vec3:
        """
        Returns a random Vec3 located on the surface of a sphere with the given radius.

        Args:
            radius (float): Radius of the sphere.

        Returns:
            Vec3: Random vector on the sphere's surface.
        """
        phi = Random.random_positive_float(math.pi * 2.0)
        costheta = Random.random_float()
        u = Random.random_positive_float()
        theta = math.acos(costheta)
        r = radius * (u ** (1.0 / 3.0))
        return Vec3(r * math.sin(theta) * math.cos(phi), r * math.sin(theta) * math.sin(phi), r * math.cos(theta))

Finally don’t forget to add the source files to the CMakeLists for both targets.

When testing random number generators the best we can do is to test to see if multiple runs of the generator results in values within the range expected. For example random_positive_vec3 should only give positive values over a number of runs.

def test_random_pos_vec3():
    for _ in range(1000):
        v = Random.random_positive_vec3()
        assert v.x >= 0 and v.x <= 1
        assert v.y >= 0 and v.y <= 1
        assert v.z >= 0 and v.z <= 1

Particle update algorithm

Each frame we will update the particle by using the following basic algorithm

dt = time step
gravity=Vec3(0.0f, -9.81f, 0.0f)
for each particle :
  dir += gravity * _dt * 0.5f
  pos += p.dir * _dt
  scale += randomPositiveFloat(0.05)
  life +=1
  if life >= maxLife or hit ground plane :
    resetParticle

This means that we now need to write operator overloads for the Vec3 class to allow us to add Vec3 elements.

Houdini geo format

No we have a basic working system we can start to write out the data into files each frame and load to Houdini to test.

The ASCII .geo and binary .bgeo file formats are the standard formats for storing Houdini geometry. The .geo format stores all the information contained in the Houdini geometry detail. To write out our particle system we only need a small section of the format to be filled in and it is quite simple.

Header Section

Magic Number: PGEOMETRY
Point/Prim Counts: NPoints # NPrims #
Group Counts: NPointGroups # NPrimGroups #
Attribute Counts: NPointAttrib # NVertexAttrib # NPrimAttrib # NAttrib #

In each of these cases, the # represents the number of the element described. Groups are named and may be defined to contain either points or primitives. Each point or primitive can be a member of any number of groups, thus membership is not exclusive to one group. In our case we will have _numParticles set for the value of NPoints and we are going to save two attributes of our particle system. The colour which Houdini will allocate if we set the Cd attribute and the scale which we will set to the houdini pscale attribute.

Attribute Definitions

Internally, there are “dictionaries” to define the attributes associated with each element. These dictionaries define the name of the attribute, the type of the attribute and the size of the attribute. Also, the default value of the attribute is stored in the dictionary.

Name Size Type Default

For our attributes we will write the following

NPointAttrib 2  NVertexAttrib 0 NPrimAttrib 1 NAttrib 0
PointAttrib
Cd 3 float 1 1 1
pscale 1 float 1

Finally the point data is written with the attributes as follows

x y z 1 ( Cd Cd Cd pscale)
^^^^^     ^^^^^^^^ ^^^^^^
position  Colour   scale

Finally we need to write out the index into the primitives list, with a full file of 10 particles at 0,0,0 with random colours shown.

PGEOMETRY V5
NPoints 10 NPrims 1
NPointGroups 0 NPrimGroups 0
NPointAttrib 2  NVertexAttrib 0 NPrimAttrib 1 NAttrib 0
PointAttrib
Cd 3 float 1 1 1
pscale 1 float 1
0 0 0 1 (0.826752 0.937736 0.746026 0.1)
0 0 0 1 (0.093763 0.623236 0.443004 0.1)
0 0 0 1 (0.941186 0.451678 0.684774 0.1)
0 0 0 1 (0.716227 0.405941 0.600561 0.1)
0 0 0 1 (0.918985 0.756861 0.584415 0.1)
0 0 0 1 (0.867986 0.576151 0.698259 0.1)
0 0 0 1 (0.215546 0.0504826 0.486265 0.1)
0 0 0 1 -0.936334 0.59456 0.412092 0.1)
0 0 0 1 (0.646916 0.988137 0.805736 0.1)
PrimitiveAttrib
generator 1 index 1 papi
Part 10 0 1 2 3 4 5 6 7 8 9 10 [0]
beginExtra
endExtra

Loading into Houdini

As we have a size attribute saved as pscale we can use the copy to points node to scale a sphere for our visualization as shown.

A hython save method

The houdini python API has the ability to save geo files in the new json format, we can use the following code to do this

import hou
from Emitter import Emitter
from Vec3 import Vec3


def write_particles(emitter, filename):
    geo = hou.Geometry()
    geo.addAttrib(hou.attribType.Point, "Cd", hou.Vector3(0, 0, 0))
    geo.addAttrib(hou.attribType.Point, "pscale", 1.0)

    point_objects = []
    for particle in emitter.particles:
        p = geo.createPoint()
        pos = particle.position
        p.setPosition(hou.Vector3(pos.x, pos.y, pos.z))
        colour = particle.colour
        p.setAttribValue("Cd", hou.Vector3(colour.x, colour.y, colour.z))
        p.setAttribValue("pscale", particle.scale)
        point_objects.append(p)
    geo.saveToFile(filename)


def main():
    emitter = Emitter(Vec3(0, 0, 0), 500)
    for frame in range(100):
        write_particles(emitter, f"particles_{frame:03}.geo")
        emitter.update(0.1)


if __name__ == "__main__":
    main()

This needs to be run using hython not python.

Lets Play

Now we have a working system we can experiment and change things. One of the best ways of doing this is to create a git branch for each experiment. Assuming that the whole project is now under git version control we cna generate a new branch by doing the following.

git branch Experiment1
git checkout Experiment1

All changes to the project will now only be seen on the Experiment1 branch once commited. We can also push this to a different branch on GitHub by using the following.

git push -u origin Experiment1

And to change back to the main we use.

git checkout main

Things to try.

  1. In the original paper the generation of particles is done not all at once as we do now but using a distribution function where multiple particles are birthed each frame based on a random value. Update the emitter to try this.
  2. At present the particles have no Mass, using the equations of projectile motion update the simulation to add mass to the particles.
  3. At present we write to the Houdini Geo format, however there are other formats we can use. One easy way to do this is via the Disney partio library. Have a look and see how it can be used.

References

Youtube Vintage CG

Previous