OpenGL ES Particle System Tutorial: Part 3/3

In this third part of our OpenGL ES particle system tutorial series, learn how to add your particle system into a simple 2D game! By Ricardo Rendon Cepeda.

Leave a rating/review
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Sending Shader Data to the GPU

With your emitters initialized, shaders ready, and GPU-CPU bridge all set, it’s time to actually send some data to OpenGL ES 2.0.

Inside SGGEmitter.m, add the following method just above the @end statement at the bottom of the file:

- (void)renderWithModelViewMatrix:(GLKMatrix4)modelViewMatrix
{
    [super renderWithModelViewMatrix:modelViewMatrix];
    
    // Uniforms
    glUniform1i(self.shader.u_Texture, 0);
    glUniformMatrix4fv(self.shader.u_ProjectionMatrix, 1, 0, _projectionMatrix.m);
    glUniformMatrix4fv(self.shader.u_ModelViewMatrix, 1, 0, modelViewMatrix.m);
    glUniform2f(self.shader.u_ePosition, self.emitter.ePosition.x, self.emitter.ePosition.y);
    
    // Attributes
    glEnableVertexAttribArray(self.shader.a_pID);
    glVertexAttribPointer(self.shader.a_pID, 1, GL_FLOAT, GL_FALSE, sizeof(Particle), (void*)(offsetof(Particle, pID)));
    
    // Draw particles
    glDrawArrays(GL_POINTS, 0, NUM_PARTICLES);
    glDisableVertexAttribArray(self.shader.a_pID);
}

This simply sends your particle data to the GPU. It sounds like you're ready to render your new particle effect...or are you?

While creating all these new particle system files, keep in mind that your modifications need to co-exist with the base code which already includes OpenGL ES 2.0 elements.

Although the SimpleGLKitGame graphics use GLKBaseEffect, texture binding, shader programs, and other OpenGL elements are hiding under the hood. Therefore, you must set and reset particular OpenGL ES 2.0 elements in your rendering cycle to avoid ugly clashes with the existing code.

Open up SGGEmitter.m and locate renderWithModelViewMatrix:. Add the following lines to the top of renderWithModelViewMatrix:, immediately following the call to super:

// "Set"
glUseProgram(self.shader.program);
glBindTexture(GL_TEXTURE_2D, _texture);
glBindBuffer(GL_ARRAY_BUFFER, _particleBuffer);

Now add the following lines to the very bottom of renderWithModelViewMatrix::

// "Reset"
glUseProgram(0);
glBindTexture(GL_TEXTURE_2D, 0);
glBindBuffer(GL_ARRAY_BUFFER, 0);

Programs, textures, and VBOs are processing-heavy; you can’t assume that your graphics pipeline will “work itself out” when rendering multiple objects in different rendering cycles. The code above helps OpenGL ES 2.0 know what instructions to use and when to use them.

Now you need to complete a similar process with the loading methods in SGGEmitter.m. Add the following line to the end of loadShader:

glUseProgram(0);

Next, add the following code to the end of loadEmitter in SGGEmitter.m:

glBindBuffer(GL_ARRAY_BUFFER, 0);

Finally, add the following line to the end of loadTexture: in SGGEmitter.m:

glBindTexture(GL_TEXTURE_2D, 0);

Now your particle effect is ready to be rendered — without any nasty clashes.

Initializing Your Emitter

Time to move back to the SGG hierarchy and properly initialize your emitter object.

Open up SGGActionScene.m and replace addEmitter with the following:

- (void)addEmitter:(GLKVector2)position
{
    SGGEmitter* emitter = [[SGGEmitter alloc] initWithFile:@"particle_32.png" projectionMatrix:self.effect.transform.projectionMatrix position:position];
    [self.children addObject:emitter];
    [self.emitters addObject:emitter];
}

This new declaration creates your emitter objects with the game’s projection matrix at the given position. It passes particle_32.png to load as a texture; this is an image file that was included in the starter project.

Still working in SGGActionScene.m, locate the for loop that deals with targetsToDelete in update:. Replace the following line:

[self addEmitter];

with:

[self addEmitter:target.position];

This loop removes targets, or enemies, from play if they are hit by a ninja star. Each target is a subclass of SGGNode, which conveniently stores a position for each of its children. This position is updated as the targets move about, so you can use the position of the target’s collision as the point to initialize your emitter object.

You've been a very patient code ninja to get through all of this without a break, but here's where you can take out your coding frustrations on some monsters! :]

Build and run your app — each you hit an enemy target, the enemy will disappear and its last position will be marked with a single star, as shown in the screenshot below:

Run3

Creating Your Particle Effect

You know, you could simply replace the stars with a tombstone or a crater and call it a day. However, you're now a particle system aficionado, and you know you can do better than that! In this section, you’re going to take things to the next level and render a cool particle effect every time you hit a monster with a ninja star.

Your particle effect won’t be as complex as the explosion from Part 2, but it will definitely be a little more sophisticated than the polar rose from Part 1. You need something flashy, yet something that maintains the game’s simple 2D charm. How about an animated ring of colorful stars that slowly fade away?

Open up SGGEmitter.m and add the following field to your Particle structure:

GLKVector3  pColorOffset;

Now, add the following fields to your Emitter structure in SGGEmitter.m:

float       eRadius;
float       eGrowth;
float       eDecay;
float       eSize;
GLKVector3  eColor;

Add the following time variable to your list of instance variables in SGGEmitter.m, immediately below the line that reads GLKVector2 _position;:

float       _time;

Finally, add the following initialization statement to initWithFile:projectionMatrix:position: in SGGEmitter.m, immediately below the line that initializes the _position variable:

_time = 0.0f;

Okay, that completes the setup of the new emitter variables for your particle system. As a challenge to yourself, see if you can port these new variables to your GLSL code all on your own — with all the correct types and qualifiers. As a hint, you'll need a single varying variable.

If you get stuck, check out the solution below:

[spoiler title="Adding GLSL Variables"]
Add the following vertex shader variables to Emitter.vsh, before main:

// Attributes
attribute vec3      a_pColorOffset;

// Uniforms
uniform float       u_Time;
uniform float       u_eRadius;
uniform float       u_eGrowth;
uniform float       u_eDecay;
uniform float       u_eSize;

// Varying
varying vec3        v_pColorOffset;

Add the following fragment shader variables to Emitter.fsh, also before main:

// Varying
varying highp vec3      v_pColorOffset;
 
// Uniforms
uniform highp float     u_Time;
uniform highp float     u_eGrowth;
uniform highp float     u_eDecay;
uniform highp vec3      u_eColor;

[/spoiler]

How did you do?

It's time to put these new variables to use. Open up Emitter.vsh and replace main with the following:

void main(void)
{        
    // 1
    // Convert polar angle to cartesian coordinates and calculate radius
    float x = cos(a_pID);
    float y = sin(a_pID);
    float r = u_eRadius;
    
    // Size
    float s = u_eSize;

    // 2
    // If blast is growing
    if(u_Time < u_eGrowth)
    {
        float t = u_Time / u_eGrowth;
        x = x * r * t;
        y = y * r * t;
    }
    
    // 3
    // Else if blast is decaying
    else
    {
        float t = (u_Time - u_eGrowth) / u_eDecay;
        x = x * r;
        y = y * r;
        s = (1.0 - t) * u_eSize;
    }
    
    // 4
    // Calculate position with respect to emitter source
    vec2 position = vec2(x,y) + u_ePosition;
    
    // Required OpenGL ES 2.0 outputs
    gl_Position = u_ProjectionMatrix * u_ModelViewMatrix * vec4(position, 0.0, 1.0);
    gl_PointSize = s;
    
    // Fragment Shader outputs
    v_pColorOffset = a_pColorOffset;
}

Similarly, open up Emitter.fsh and replace main with:

void main(void)
{
    highp vec4 texture = texture2D(u_Texture, gl_PointCoord);
    highp vec4 color = vec4(1.0);
    
    // 5
    // Calculate color with offset
    color.rgb = u_eColor + v_pColorOffset;
    color.rgb = clamp(color.rgb, vec3(0.0), vec3(1.0));
    
    // 6
    // If blast is growing
    if(u_Time < u_eGrowth)
    {
        color.a = 1.0;
    }
    
    // 7
    // Else if blast is decaying
    else
    {
        highp float t = (u_Time - u_eGrowth) / u_eDecay;
        color.a = 1.0 - t;
    }
    
    // Required OpenGL ES 2.0 outputs
    gl_FragColor = texture * color;
}

In terms of complexity, the shader code is a nice balance of the simplicity of Part 1 and the showiness of Part 2. Take a moment and walk through the code of the two files, comment by comment:

  1. Each particle’s unique ID is used to calculate the particle's position on the ring’s circumference.
  2. If the blast is growing, the particles travel from the source center towards the final ring position, relative to the growth time.
  3. If the blast is decaying, the particles hold their position on the ring. However, the size of the particles gradually decreases relative to the decay size, down to 1 pixel.
  4. The final position is calculated relative to the emitter source.
  5. Each particle’s RGB color is calculated by adding/subtracting its offset to the overall emitter color, using the clamp function to stay within the bounds of 0.0 (black) and 1.0 (white).
  6. If the blast is growing, the particles remain fully visible.
  7. If the blast is decaying, the particles’ opacity gradually decays to full transparency, relative to the decay time.

With your shaders all ready to rock and roll, it’s time to complete the obligatory EmitterShader bridge. You've done this several times before — see if you can complete this step all on your own! If you need a little help, check out the solution below:

[spoiler title="CPU-GPU Bridge"]
Open up EmitterShader.h and add the following properties:

// Attribute Handles
@property (readwrite) GLint     a_pColorOffset;

// Uniform Handles
@property (readwrite) GLint     u_Time;
@property (readwrite) GLint     u_eRadius;
@property (readwrite) GLint     u_eGrowth;
@property (readwrite) GLint     u_eDecay;
@property (readwrite) GLint     u_eSize;
@property (readwrite) GLint     u_eColor;

Then, open EmitterShader.m and complete their implementation within loadShader, by adding the following lines along with the other attribute and uniform initializations:

// Attributes
self.a_pColorOffset = glGetAttribLocation(self.program, "a_pColorOffset");
    
// Uniforms
self.u_Time = glGetUniformLocation(self.program, "u_Time");
self.u_eRadius = glGetUniformLocation(self.program, "u_eRadius");
self.u_eGrowth = glGetUniformLocation(self.program, "u_eGrowth");
self.u_eDecay = glGetUniformLocation(self.program, "u_eDecay");
self.u_eSize = glGetUniformLocation(self.program, "u_eSize");
self.u_eColor = glGetUniformLocation(self.program, "u_eColor");

[/spoiler]

If you feel like a challenge and you think you know what the next step will be, then give it a try on your own! Otherwise, take a look at the solution below:

[spoiler title="Sending Data to OpenGL ES 2.0"]
Open up SGGEmitter.m and add the following lines to renderWithModelViewMatrix:, respecting the OpenGL ES 2.0 command order for attributes:

// Uniforms
glUniform1f(self.shader.u_Time, _time);
glUniform1f(self.shader.u_eRadius, self.emitter.eRadius);
glUniform1f(self.shader.u_eGrowth, self.emitter.eGrowth);
glUniform1f(self.shader.u_eDecay, self.emitter.eDecay);
glUniform1f(self.shader.u_eSize, self.emitter.eSize);
glUniform3f(self.shader.u_eColor, self.emitter.eColor.r, self.emitter.eColor.g, self.emitter.eColor.b);
    
// Attributes
glEnableVertexAttribArray(self.shader.a_pColorOffset);

glVertexAttribPointer(self.shader.a_pColorOffset, 3, GL_FLOAT, GL_FALSE, sizeof(Particle), (void*)(offsetof(Particle, pColorOffset)));

glDisableVertexAttribArray(self.shader.a_pColorOffset);

When you're done, renderWithModelViewMatrix: should look like this:

- (void)renderWithModelViewMatrix:(GLKMatrix4)modelViewMatrix
{    
    [super renderWithModelViewMatrix:modelViewMatrix];
    
    // "Set"
    glUseProgram(self.shader.program);
    glBindTexture(GL_TEXTURE_2D, _texture);
    glBindBuffer(GL_ARRAY_BUFFER, _particleBuffer);
    
    // Uniforms
    glUniform1i(self.shader.u_Texture, 0);
    glUniformMatrix4fv(self.shader.u_ProjectionMatrix, 1, 0, _projectionMatrix.m);
    glUniformMatrix4fv(self.shader.u_ModelViewMatrix, 1, 0, modelViewMatrix.m);
    glUniform1f(self.shader.u_Time, _time); // NEW
    glUniform2f(self.shader.u_ePosition, self.emitter.ePosition.x, self.emitter.ePosition.y);
    glUniform1f(self.shader.u_eRadius, self.emitter.eRadius); // NEW
    glUniform1f(self.shader.u_eGrowth, self.emitter.eGrowth); // NEW
    glUniform1f(self.shader.u_eDecay, self.emitter.eDecay); // NEW
    glUniform1f(self.shader.u_eSize, self.emitter.eSize); // NEW
    glUniform3f(self.shader.u_eColor, self.emitter.eColor.r, self.emitter.eColor.g, self.emitter.eColor.b); // NEW
    
    // Attributes
    glEnableVertexAttribArray(self.shader.a_pID);
    glEnableVertexAttribArray(self.shader.a_pColorOffset); // NEW
    glVertexAttribPointer(self.shader.a_pID, 1, GL_FLOAT, GL_FALSE, sizeof(Particle), (void*)(offsetof(Particle, pID)));
    glVertexAttribPointer(self.shader.a_pColorOffset, 3, GL_FLOAT, GL_FALSE, sizeof(Particle), (void*)(offsetof(Particle, pColorOffset))); // NEW
    
    // Draw particles
    glDrawArrays(GL_POINTS, 0, NUM_PARTICLES);
    glDisableVertexAttribArray(self.shader.a_pID);
    glDisableVertexAttribArray(self.shader.a_pColorOffset); // NEW
    
    // "Reset"
    glUseProgram(0);
    glBindTexture(GL_TEXTURE_2D, 0);
    glBindBuffer(GL_ARRAY_BUFFER, 0);
}

[/spoiler]

Now you need to populate all these new variables. Add the following method to SGGEmitter.m, just above the @end statement at the bottom of the file:

- (float)randomFloatBetween:(float)min and:(float)max
{
    float range = max - min;
    return (((float) (arc4random() % ((unsigned)RAND_MAX + 1)) / RAND_MAX) * range) + min;
}

Then, replace the current loadEmitter method with the following code:

- (void)loadEmitter
{
    Emitter newEmitter = {0.0f};
    
    // Offset bounds
    float oColor = 0.25f;   // 0.5 = 50% shade offset
    
    // Load Particles
    for(int i=0; i<NUM_PARTICLES; i++)
    {
        // Assign a unique ID to each particle, between 0 and 360 (in radians)
        newEmitter.eParticles[i].pID = GLKMathDegreesToRadians(((float)i/(float)NUM_PARTICLES)*360.0f);
        
        // Assign random offsets within bounds
        float r = [self randomFloatBetween:-oColor and:oColor];
        float g = [self randomFloatBetween:-oColor and:oColor];
        float b = [self randomFloatBetween:-oColor and:oColor];
        newEmitter.eParticles[i].pColorOffset = GLKVector3Make(r, g, b);
    }
    
    // Load Properties
    newEmitter.ePosition = _position;                       // Source position
    newEmitter.eRadius = 50.0f;                             // Blast radius
    newEmitter.eGrowth = 0.25f;                             // Growth time
    newEmitter.eDecay = 0.75f;                              // Decay time
    newEmitter.eSize = 32.00f;                              // Fragment size
    newEmitter.eColor = GLKVector3Make(0.5f, 0.0f, 0.0f);   // Fragment color
    
    // Set Emitter & VBO
    self.emitter = newEmitter;
    glGenBuffers(1, &_particleBuffer);
    glBindBuffer(GL_ARRAY_BUFFER, _particleBuffer);
    glBufferData(GL_ARRAY_BUFFER, sizeof(self.emitter.eParticles), self.emitter.eParticles, GL_STATIC_DRAW);
    glBindBuffer(GL_ARRAY_BUFFER, 0);
}

This new version of loadEmitter is quite similar to the old version. It sets the particle's pID within the for loop along with a random color offset. It then sets the various new emitter properties, such as eRadius and eGrowth, among others.

To animate your blast, you need to implement update: that was defined in the SGGNode interface. Add the following method to SGGEmitter.m, just above the @end statement at the bottom of the file:

- (void)update:(float)dt
{
  const float life = self.emitter.eGrowth + self.emitter.eDecay;

  if(_time < life)
      _time += dt;
}

This method simply keeps track of the total time that this emitter has been alive. Once the time exceeds the emitter's allowed life span, it stops.

Of course, what would a particle system be without more than one particle flying around? At the top of SGGEmitter.m, change the line:

#define NUM_PARTICLES 1

To:

#define NUM_PARTICLES 18

Build and run — your targets will now be blown up into rings of stars when hit, as in the screenshot below:

Run4

That adds a great little 2D special effect to the game, doesn't it?

Ricardo Rendon Cepeda

Contributors

Ricardo Rendon Cepeda

Author

Over 300 content creators. Join our team.