Particle Emitter with C++, SDL2, and OpenGL

This is a naive approach to creating a particle emitter with C++, SDL2, and OpenGL. To create an efficient particle emitter all the vertices would be held in a single buffer and you would only call glDrawArrays() once every frame. In order to elaborate on drawing separate objects, I’ve opted to draw each particle separately. So let us begin.

Getting Started

There are a few prerequisites to get this started. First, you will need to have installed * OpenGL

I include a Bazel BUILD file with the project but you can easily create a MAKEFILE to build the project.

This project builds off of two other projects I’ve previously created. GameLoop and CaL-GLSL which are both found on Github. All the required files can be downloaded HERE. You can also check out the repository at GitHub

Generic Object

First we will create a generic object that will hold boilerplate position, velocity, size, and methods for animating in OpenGL. We will derive our Particle class from this. Here are the declarations:

// "h/Obj.h"

class Obj{
    public:
        using MapUint   = std::map< std::string, GLuint >;
        using MapInt    = std::map< std::string, GLint >;
        using MapVec3   = std::map< std::string, glm::vec3 >;
        using MapMat4   = std::map< std::string, glm::mat4 >;
        using MapProg   = std::map< std::string, GLProgram >;

        // The Vertex Array Objects to hold our OpenGL state
        MapUint vao = {
                { "main", GLuint{} }
            };

        // The Buffers to hold our data
        MapUint buffer = {
                { "position", GLuint{} },
                { "color", GLuint{} }
            };

        // The position of the object for animating
        MapVec3 position = {
                { "now", glm::vec3{0.0f, 0.0f, 0.0f} },
                { "prev", glm::vec3{0.0f, 0.0f, 0.0f} }
            };

        // The current velocity of the object and the speed to add to it
        MapVec3 movement = {
            {"vel", glm::vec3{0.0f, 0.0f, 0.0f} },
            {"speed", glm::vec3{0.0f, 0.0f, 0.0f} }
        };

        // Default radius of our object
        GLfloat radius = 5.0f;

        // The Uniform names in our shaders
        MapInt uniform = {
                { "model", GLint{} },
                { "view", GLint{} },
                { "proj", GLint{} }
            };

        // The attribute names in our shaders
        MapInt attr = {
                { "position", GLint{} },
                { "color", GLint{} }
            };

        // The model, view, and projection to send to the shader program
        MapMat4 MVP = {
                { "model",  glm::translate(
                                glm::mat4(1.0f),
                                this->position["now"]
                            )},
                { "view",   glm::lookAt(
                                glm::vec3(0.0f, 0.0f, 0.0f),
                                glm::vec3(0.0f, 0.0f, 0.0f),
                                glm::vec3(0.0f, 1.0f, 0.0f)
                            )},
                { "proj",   glm::perspective(
                                glm::radians(45.0f), 800.0f / 600.0f, 0.1f, 100.0f
                            )},
                { "ortho",  glm::ortho(0.0f, 800.0f,  0.0f, 600.0f, 0.1f, 100.0f)}
            };

        // The OpenGL program
        MapProg program = {
                { "simple", GLProgram{} }
            };

        // The number of vertexes in our position buffer
        GLint numVertices = 0;

        Obj(){};
        virtual ~Obj(){};

        /**********************************************
        *
        *               OpenGL
        *
        ***********************************************/
        virtual Obj& getGLLocations() {
            return *this;
        };
        virtual Obj& fillBuffers() {
            return *this;
        };
        virtual Obj& setVAOState() {
            return *this;
        };
        virtual Obj& setMVP() {
            return *this;
        };
        virtual Obj& updateGL() {
            return *this;
        };

        virtual Obj& init() {
            return *this;
        };

        /**********************************************
        *
        *               Logic
        *
        ***********************************************/
        virtual Obj& input(const SDL_Event& ev) {
            return *this;
        };
        virtual Obj& handleEdge() {
            return *this;
        };
        virtual Obj& handleMovement() {
            return *this;
        };
        virtual Obj& updatePosition(const float& dt=1) {
            return *this;
        };
        virtual Obj& collisions() {
            return *this;
        };
        virtual Obj& interpolate(const float& ip=1) {
            return *this;
        };

        /**********************************************
        *
        *               Draw
        *
        ***********************************************/
        virtual Obj& draw() {
            return *this;
        };
};

As you can see, it’s just a header file with some default values for our object.

Particle Class

Here we will derive our values from the Object class and do everything that needs to be done to get a circle on the screen in OpenGL in this Particle Class. Here are the declarations:

// "h/Particle.h"

// Inherit the public and protected member of Obj
class Particle : public Obj {
public:
    virtual ~Particle();

    virtual Particle& init();
    /**********************************************
    *
    *               OpenGL
    *
    ***********************************************/
    virtual Particle& getGLLocations();
    virtual Particle& compileShaders();
    virtual Particle& fillBuffers();
    virtual Particle& setVAOState();
    virtual Particle& setMVP();
    virtual Particle& updateGL();
    virtual Particle& deleteBuffers();
    virtual Particle& deleteVertexArrays();
    /**********************************************
    *
    *               Logic
    *
    ***********************************************/
    virtual Particle& input(const SDL_Event& ev);
    virtual Particle& handleEdge();
    virtual Particle& handleMovement();
    virtual Particle& updatePosition(const float& dt=1);
    virtual Particle& collisions();
    virtual Particle& interpolate(const float& ip=1);

    /**********************************************
    *
    *               Draw
    *
    ***********************************************/
    virtual Particle& draw();
};

This one is a bit smaller but the meat is in the CPP file. Let’s take a look at the steps it takes to create vertices for our circle and upload those to the OpenGL server in Particle::init().

// "cpp/Particle.cpp"

Particle& Particle::init() {
 this->compileShaders()
 .fillBuffers()
 .getGLLocations()
 .setVAOState()
 .setMVP();

 return *this;
}

So, we compile the shaders into a program, fill the buffers with our data, get the locations of variables in our shader program, set the OpenGL server state for our vertex array object, then set our model-view-projection matrix. You can take a look at the source files to see what is going on in each method. For now we will look at the Particle::updateGL() and Particle::draw() methods to give you a higher level understanding of how things get positioned and rendered on the screen. First, our draw method:

// "cpp/Particle.cpp"

Particle& Particle::draw() {

 // Start using our program
 this->program["simple"].programStart();

 // Set the OpenGL server state
 glBindVertexArray(this->vao["main"]);

 // Send our MVP to the OpenGL server
 this->updateGL();

 // Draw our triangles in a fan to create our circle
 glDrawArrays(GL_TRIANGLE_FAN, 0, this->numVertices * 3);

 // Unbind the VAO and stop using the program so other objects
 // can use the server
 glBindVertexArray(0);
 this->program["simple"].programStop();

 return *this;
}

So, we tell the OpenGL server to use our shader program, set the OpenGL state by binding our vertex array object, update our model and send the model-view-projection matrix to the OpenGL server, tell OpenGL to draw what’s in the buffers using a GL_TRIANGLE_FAN, then finally, unbind our vertex array object and stop using our shader program so another object can use it’s own shader program and vertex array object to draw with the server. Now look at how we update the model and send the model-view-projection matrix to the OpenGL server:

// "cpp/Particle.cpp"&#xA0;

Particle& Particle::updateGL() {
    // Send the Model, View, and Projection to the OpenGL server
    // We do this every time we start using the program which is on every draw
    this->MVP["model"] = glm::translate(
        glm::mat4(1.0f),
        this->position["now"]
    );

    glUniformMatrix4fv(this->uniform["model"], 1, GL_FALSE, glm::value_ptr(this->MVP["model"]));
    glUniformMatrix4fv(this->uniform["view"], 1, GL_FALSE, glm::value_ptr(this->MVP["view"]));
    // Note the orthographic projection
    glUniformMatrix4fv(this->uniform["proj"], 1, GL_FALSE, glm::value_ptr(this->MVP["ortho"]));

    return *this;
}

We create a new glm::mat4 matrix using glm::translate(). We send it the position our Particle is currently at in this frame and send off our model, view, and projection to be multiplied in our shader program. You could multiply the model, view, and projection here then send it the shader program as well. This way gives you a better understanding of how the model-view-projection matrix is applied to our vertices by looking at the file “glsl/vertex.glsl”.

Particle Emitter

Now, the easy part. We will create a vector of Particle objects, give them a random speed, random radius, then update their positions while applying gravity. Here are the declarations for our Particles class:

// "h/Particles.h"

class Particles{
public:
    // A vector and holds all of our particles
    std::vector< Particle > particles;

    // The position of our Emitter
    glm::vec3 pos = {400.0f, 50.0f, 0.0f};

    // The number of particles to emit
    GLint numParticles = 100;

    // The maximum radius of each particle
    // This number is divided by 100.0f to get more variety in size
    GLint maxRadius = 1000;
    GLint minRadius = 250;

    // The rate we will subtract from our particles vertical velocity each frame
    GLfloat gravity = 750.0f;

    // The maximum speed our particles move horizontally
    // This value will be cut in half to make our particles move left and right of the origin
    GLint maxSpeedX = 800;

    // The minimum and maximum initial vertical speed
    GLint minSpeedY = 300;
    GLint maxSpeedY = 450;

    Particles& init();
    Particles& resetParticle(Particle& p);

    /**********************************************
    *
    *               Logic
    *
    ***********************************************/
    Particles& input(const SDL_Event& ev);
    Particles& handleEdge();
    Particles& handleMovement(const float& dt = 1);
    Particles& addGravity(const float& dt = 1);
    Particles& updatePosition(const float& dt = 1);
    Particles& collisions();
    Particles& interpolate(const float& dt = 1, const float& ip = 1);

    /**********************************************
    *
    *               Draw
    *
    ***********************************************/
    Particles& draw();
};

That’s not so bad. We basically just hold all of our defaults in this class. Now for the fun part. Let’s take a look at how we create our vector of Particles:

// "cpp/Particles.cpp"

Particles& Particles::init() {

    // Seed our random number generator
    // We aren't dealing with secure communications
    // So this will do fine
    std::srand(std::time(0));

    // Fill our vector with particles
    for (auto i = 0; i < this->numParticles; i++)
    {
        Particle particle;
        this->particles.push_back(particle);
    }

    // For each particle
    for (auto &p : this->particles)
    {
        // Set the radius of the particle randomly
        p.radius = (std::rand() % this->maxRadius + this->minRadius) / 100.0f;

        // Initialize the particle
        // See the Particle::init()
        p.init();

        // Set the initial position, and vertical and horizontal speed
        this->resetParticle(p);
    }

    return *this;
}

Here, we’re just adding our Particle to our container, initializing the Particle which includes all of the OpenGL setup steps, then settings the position and speed randomly. Here you can see how we’re setting the speed and radius:

// "cpp/Particles.cpp"

Particles& Particles::resetParticle(Particle& p)
{
    // Set both the previous position and now position to our emitter point
    p.position["now"].x = this->pos.x;
    p.position["prev"].x = this->pos.x;
    p.position["now"].y = this->pos.y;
    p.position["prev"].y = this->pos.y;

    // Set our velocity to zero so it has to start over
    p.movement["vel"].x = 0;
    p.movement["vel"].y = 0;

    // Set our vertical and horizontal speeds randomly
    p.movement["speed"].x = (std::rand() % this->maxSpeedX - (this->maxSpeedX / 2)) / 10.0f;
    p.movement["speed"].y = (std::rand() % this->maxSpeedY + this->minSpeedY);

    return *this;
}

Now the meat of the Particles class, the Particles::updatePosition() method which handles the animating of the particles.

// "cpp/Particles.cpp"

Particles& Particles::updatePosition(const float& dt) {

    this->handleMovement(dt)
        .addGravity(dt);

    // For each particle
    for (auto &p : this->particles)
    {
        // Set the current position based on the previous position and add the velocity
        // Multiplying the velocity by deltaTime allows us to set velocity in pixels per second
        p.position["now"].x = p.position["prev"].x + p.movement["vel"].x * dt;
        p.position["now"].y = p.position["prev"].y + p.movement["vel"].y * dt;
    }

    this->handleEdge();

    // For each particle
    for (auto &p : this->particles)
        // Set the previous position to the current position to test against on the next frame
        p.position["prev"] = p.position["now"];

    return *this;
}

In our Particles::handleMovement() method, we are adding the speed to the velocity. Remember, this happens each frame. We multiply the speed by deltaTime, or the time between seconds our frame lands, so we can give our speeds in pixels per second. Next we add gravity by simply subtracting our gravity from the speed while also multiplying it by deltaTime so we can give its unit in pixels per second. Next, we position each particle by using the previous position and it’s current velocity. After that, we make sure to reposition our particle if it hits an edge by either letting it bounce or resetting it to the emitter origin and giving it a new speed. The last thing to do is set our previous position to our current position so we can test against it on the next frame. The Particles::interpolate() method simply puts our Particle into the correct position while we are currently between frames. The position doesn’t actually change, the Particle is simply shown in its correct location. You can see that method here:

// "cpp/Particles.cpp"

Particles& Particles::interpolate(const float& dt, const float& ip) {
    // For each particle
    for (auto &p : this->particles)
    {
        // Do the same as in updatePosition() but utilize interpolation
        // This is merely showing our particles in the correct place in time
        p.position["now"].x = p.position["prev"].x + (p.movement["vel"].x * dt) * ip;
        p.position["now"].y = p.position["prev"].y + (p.movement["vel"].y * dt) * ip;
    }
    return *this;
}

Now, the Particles.draw() method calls the Particle::draw() method for every Particle:

// "cpp/Particles.cpp"

Particles& Particles::draw() {
    // For each particle, draw
    for (auto &p : this->particles)
        p.draw();

    return *this;
}

Simple enough. Let’s hook our Particles class into our GameLoop.

// "cpp/main.cpp"

class myGameLoop: public GameLoop{
private:
    Particles particles;

    GLboolean isStarted = false;
public:
    using GameLoop::GameLoop;
    virtual myGameLoop& init(){
        particles.init();
        return *this;
    }
    virtual myGameLoop& consoleOutput(){
        GameLoop::consoleOutput();

        return *this;
    }
    virtual myGameLoop& inputs(){
        switch (this->e.type) {
            case SDL_KEYDOWN: {

                // On key press, start the animation
                this->isStarted = true;

                break;
            }
        }
        return *this;
    }
    virtual myGameLoop& updatePositions(){

        if (this->isStarted)
            particles.updatePosition(this->deltaTime);

        return *this;
    }
    virtual myGameLoop& interpolate(){

        if (this->isStarted)
            particles.interpolate(this->deltaTime, this->interpolation);

        return *this;
    }
    virtual myGameLoop& draw(){
        glClearColor(0.05f, 0.05f, 0.05f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

        particles.draw();

        SDL_GL_SwapWindow(this->win);
        return *this;
    }
};

We create an instance of Particles, initialize it, update the positions of our Particle, interpolate, then finally draw! We set it up so it only updates and interpolates the positions after we have pressed a key on the keyboard. Phew! That was a long one.