OpenGL ES Particle System Tutorial: Part 2/3

In this second part of our OpenGL ES particle system tutorial series, learn how to implement a generic particle system that deals with some “explosive” concepts! By Ricardo Rendon Cepeda.

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

Loading Your Shader Program and Particle System

Open up EmitterObject.m and add the following line just below the existing #import statement:

#import "EmitterShader.h"

Still working in EmitterObject.m, add the following properties just above the @implementation statement:

@interface EmitterObject ()

@property (assign) Emitter emitter;
@property (strong) EmitterShader* shader;

@end

Stay with EmitterObject.m and add the following method that loads your shader, just above the @end statement:

- (void)loadShader
{
    self.shader = [[EmitterShader alloc] init];
    [self.shader loadShader];
    glUseProgram(self.shader.program);
}

At this point, you're ready to load up your particle system. Add the following methods to EmitterObject.m, after loadShader:

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

- (void)loadParticleSystem
{
    // 2
    Emitter newEmitter = {0.0f};
    
    // 3
    // Offset bounds
    float oRadius = 0.10f;      // 0.0 = circle; 1.0 = ring
    float oVelocity = 0.50f;    // Speed
    float oDecay = 0.25f;       // Time
    float oSize = 8.00f;        // Pixels
    float oColor = 0.25f;       // 0.5 = 50% shade offset
    
    // 4
    // 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
        newEmitter.eParticles[i].pRadiusOffset = [self randomFloatBetween:oRadius and:1.00f];
        newEmitter.eParticles[i].pVelocityOffset = [self randomFloatBetween:-oVelocity and:oVelocity];
        newEmitter.eParticles[i].pDecayOffset = [self randomFloatBetween:-oDecay and:oDecay];
        newEmitter.eParticles[i].pSizeOffset = [self randomFloatBetween:-oSize and:oSize];
        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);
    }
    
    // 5
    // Load Properties
    newEmitter.eRadius = 0.75f;                                     // Blast radius
    newEmitter.eVelocity = 3.00f;                                   // Explosion velocity
    newEmitter.eDecay = 2.00f;                                      // Explosion decay
    newEmitter.eSize = 32.00f;                                      // Fragment size
    newEmitter.eColor = GLKVector3Make(1.00f, 0.50f, 0.00f);        // Fragment color
    
    // 6
    // Set global factors
    float growth = newEmitter.eRadius / newEmitter.eVelocity;       // Growth time
    _life = growth + newEmitter.eDecay + oDecay;                    // Simulation lifetime
    
    float drag = 10.00f;                                            // Drag (air resistance)
    _gravity = GLKVector2Make(0.00f, -9.81f*(1.0f/drag));           // World gravity
    
    // 7
    // Set Emitter & VBO
    self.emitter = newEmitter;
    GLuint particleBuffer = 0;
    glGenBuffers(1, &particleBuffer);
    glBindBuffer(GL_ARRAY_BUFFER, particleBuffer);
    glBufferData(GL_ARRAY_BUFFER, sizeof(self.emitter.eParticles), self.emitter.eParticles, GL_STATIC_DRAW);
}

Okay — that's a ton of code. Take a minute to walk through the code, comment by comment:

  1. This function is taken straight from Part 1 and is used to produce a random float value between two bounds.
  2. The member variables of your Emitter structure can't be assigned values through your emitter property. Therefore, you must create a new emitter variable for this initialization stage, which is then assigned in full to your emitter property.
  3. Here, you define the bounds used to calculate each particle’s offset according to the particle system structures.
  4. All particles are initialized with a unique ID and random offsets.
  5. Here, you define your emitter properties, creating a custom explosion based on a generic template.
  6. Next, you set the global factors that affect the simulation of your scene.
  7. Your new emitter is assigned to your emitter property and a Vertex Buffer Object (VBO) is created for storing its particles.

To finish off your emitter, scroll all the way back up EmitterObject.m and add the following code to initEmitterObject inside the if statement immediately after your variable initialization statements:

// Load Shader
[self loadShader];
        
// Load Particle System
[self loadParticleSystem];

These two lines of code initialize your shader and your particle system.

Rendering Your Explosion

All of the emitter and particle frameworks are in place. All that's left to do is add the code that will take care of rendering your system on-screen.

Open EmitterObject.m and add the following code to renderWithProjection::

// Uniforms
glUniformMatrix4fv(self.shader.u_ProjectionMatrix, 1, 0, projectionMatrix.m);
glUniform2f(self.shader.u_Gravity, _gravity.x, _gravity.y);
glUniform1f(self.shader.u_Time, _time);
glUniform1f(self.shader.u_eRadius, self.emitter.eRadius);
glUniform1f(self.shader.u_eVelocity, self.emitter.eVelocity);
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_pID);
glEnableVertexAttribArray(self.shader.a_pRadiusOffset);
glEnableVertexAttribArray(self.shader.a_pVelocityOffset);
glEnableVertexAttribArray(self.shader.a_pDecayOffset);
glEnableVertexAttribArray(self.shader.a_pSizeOffset);
glEnableVertexAttribArray(self.shader.a_pColorOffset);
    
glVertexAttribPointer(self.shader.a_pID, 1, GL_FLOAT, GL_FALSE, sizeof(Particle), (void*)(offsetof(Particle, pID)));
glVertexAttribPointer(self.shader.a_pRadiusOffset, 1, GL_FLOAT, GL_FALSE, sizeof(Particle), (void*)(offsetof(Particle, pRadiusOffset)));
glVertexAttribPointer(self.shader.a_pVelocityOffset, 1, GL_FLOAT, GL_FALSE, sizeof(Particle), (void*)(offsetof(Particle, pVelocityOffset)));
glVertexAttribPointer(self.shader.a_pDecayOffset, 1, GL_FLOAT, GL_FALSE, sizeof(Particle), (void*)(offsetof(Particle, pDecayOffset)));
glVertexAttribPointer(self.shader.a_pSizeOffset, 1, GL_FLOAT, GL_FALSE, sizeof(Particle), (void*)(offsetof(Particle, pSizeOffset)));
glVertexAttribPointer(self.shader.a_pColorOffset, 3, GL_FLOAT, GL_FALSE, sizeof(Particle), (void*)(offsetof(Particle, pColorOffset)));
    
// Draw particles
glDrawArrays(GL_POINTS, 0, NUM_PARTICLES);
glDisableVertexAttribArray(self.shader.a_pID);
glDisableVertexAttribArray(self.shader.a_pRadiusOffset);
glDisableVertexAttribArray(self.shader.a_pVelocityOffset);
glDisableVertexAttribArray(self.shader.a_pDecayOffset);
glDisableVertexAttribArray(self.shader.a_pSizeOffset);
glDisableVertexAttribArray(self.shader.a_pColorOffset);

In the code above, you are simply sending all your uniform and attribute data to your shader program and drawing all your particles.

Still working with EmitterObject.m, add the following code to updateLifeCycle:

_time += timeElapsed;
    
if(_time > _life)
    _time = 0.0f;

This small bit of code will continuously repeat your explosion from birth to death.

Build and run your app — your explosion should look just as if you blew up Stone Man’s stage in Mega Man 5, as shown in the image below:

Run2

There's something incredibly satisfying to blowing things up! :] Note how each particle is completely unique, differing in position, speed, size, and color.

Enhancing Your Particle System

So far you've built a pretty good foundation for a generic particle system. However, as the particles decay, nothing really exciting happens — they simply fall to the ground under the force of gravity. A real explosion changes over time, such as fire turning into smoke, or embers shrinking away.

You're going to implement a similar effect in your particle system.

Open up EmitterObject.m and modify the Emitter structure by replacing the following variables:

float       eSize;
GLKVector3  eColor;

with:

float       eSizeStart;
float       eSizeEnd;
GLKVector3  eColorStart;
GLKVector3  eColorEnd;

Instead of having a fixed size and color, you now have a start and end target color and size.

Whoops — Xcode is noting some compile errors. To get rid of these compile errors, remove the following lines from renderWithProjection: in EmitterObject.m:

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);

Still working in EmitterObject.m, replace the following lines in loadParticleSystem:

newEmitter.eSize = 32.00f;                                      // Fragment size
newEmitter.eColor = GLKVector3Make(1.00f, 0.50f, 0.00f);        // Fragment color

with the following:

newEmitter.eSizeStart = 32.00f;                                 // Fragment start size
newEmitter.eSizeEnd = 8.00f;                                    // Fragment end size
newEmitter.eColorStart = GLKVector3Make(1.00f, 0.50f, 0.00f);   // Fragment start color
newEmitter.eColorEnd = GLKVector3Make(0.25f, 0.00f, 0.00f);     // Fragment end color

This will make your explosion will fade from bright orange to dark red as your particles decrease in size from 32 pixels to 8. Now you just need to communicate this to your shaders.

Open up Emitter.vsh and replace the following uniform:

uniform float       u_eSize;

with:

uniform float       u_eSizeStart;
uniform float       u_eSizeEnd;

Next, add the following varying variables to Emitter.vsh just below v_pColorOffset:

varying float       v_Growth;
varying float       v_Decay;

These varying variables will be used in the fragment shader.

There's a few changes to make to main in Emitter.vsh, but rather than try to explain where to make your changes, just replace the entire main method with the following:

void main(void)
{
    // Convert polar angle to cartesian coordinates and calculate radius
    float x = cos(a_pID);
    float y = sin(a_pID);
    float r = u_eRadius * a_pRadiusOffset;
    
    // Lifetime
    float growth = r / (u_eVelocity + a_pVelocityOffset);
    float decay = u_eDecay + a_pDecayOffset;
    
    // Size
    float s = 1.0;
    
    // If blast is growing
    if(u_Time < growth)
    {
        float time = u_Time / growth;
        x = x * r * time;
        y = y * r * time;
        
        // 1
        s = u_eSizeStart;
    }
    
    // Else if blast is decaying
    else
    {
        float time = (u_Time - growth) / decay;
        x = (x * r) + (u_Gravity.x * time);
        y = (y * r) + (u_Gravity.y * time);
        
        // 2
        s = mix(u_eSizeStart, u_eSizeEnd, time);
    }
    
    // Required OpenGL ES 2.0 outputs
    gl_Position = u_ProjectionMatrix * vec4(x, y, 0.0, 1.0);
    
    // 3
    gl_PointSize = max(0.0, (s + a_pSizeOffset));
    
    // Fragment Shader outputs
    v_pColorOffset = a_pColorOffset;
    v_Growth = growth;
    v_Decay = decay;
}

The changes are subtle, but they're explained below:

  1. If the blast is growing, maintain the particle starting size.
  2. If the blast is decaying, gradually decrease the size of the particle from the starting size to the ending size according to the relative decay time. You use the mix function to interpolate between the two.
  3. Finally, output the resulting size after adding or subtracting the offset.

The fragment shader follows a very similar implementation.

Open Emitter.fsh and add the following lines just below v_pColorOffset:

varying highp float     v_Growth;
varying highp float     v_Decay;

Then, replace the following uniform:

uniform highp vec3      u_eColor;

with:

uniform highp vec3      u_eColorStart;
uniform highp vec3      u_eColorEnd;

Finally, replace all of main with:

void main(void)
{
    // Color
    highp vec4 color = vec4(1.0);
    
    // If blast is growing
    if(u_Time < v_Growth)
    {
        // 1
        color.rgb = u_eColorStart;
    }
    
    // Else if blast is decaying
    else
    {
        highp float time = (u_Time - v_Growth) / v_Decay;
        
        // 2
        color.rgb = mix(u_eColorStart, u_eColorEnd, time);
    }
    
    // 3
    color.rgb += v_pColorOffset;
    color.rgb = clamp(color.rgb, vec3(0.0), vec3(1.0));
    
    // Required OpenGL ES 2.0 outputs
    gl_FragColor = color;
}

The color implementation is almost identical to the size implementation:

  1. If the blast is growing, maintain the particle starting color.
  2. If the blast is decaying, gradually change the color of the particle from the starting color to the ending color, according to the relative decay time. Again, you're using the mix function to interpolate between the two.
  3. Finally, output the resulting color after adding or subtracting the offset, using the clamp function to stay within the bounds of 0.0 (black) and 1.0 (white).

With your shaders in place it’s time to complete the obligatory EmitterShader bridge. See if you can complete this step all on your own! If you’re stuck, you can check out the solution below:

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

@property (readwrite) GLint     u_eSize;
@property (readwrite) GLint     u_eColor;

with:

@property (readwrite) GLint     u_eSizeStart;
@property (readwrite) GLint     u_eSizeEnd;
@property (readwrite) GLint     u_eColorStart;
@property (readwrite) GLint     u_eColorEnd;

Then, open EmitterShader.m and replace the following lines:

self.u_eSize = glGetUniformLocation(self.program, "u_eSize");
self.u_eColor = glGetUniformLocation(self.program, "u_eColor");

with:

self.u_eSizeStart = glGetUniformLocation(self.program, "u_eSizeStart");
self.u_eSizeEnd = glGetUniformLocation(self.program, "u_eSizeEnd");
self.u_eColorStart = glGetUniformLocation(self.program, "u_eColorStart");
self.u_eColorEnd = glGetUniformLocation(self.program, "u_eColorEnd");

[/spoiler]

If you think you know what the next step is, and are still in the mood for a challenge, then go ahead and try it yourself! Otherwise, you can find the solution in the spoiler section below:

[spoiler title="Sending Data to OpenGL ES 2.0"]

Open up EmitterObject.m and add the following lines to renderWithProjection: next to the other glUniform... calls:

glUniform1f(self.shader.u_eSizeStart, self.emitter.eSizeStart);
glUniform1f(self.shader.u_eSizeEnd, self.emitter.eSizeEnd);
glUniform3f(self.shader.u_eColorStart, self.emitter.eColorStart.r, self.emitter.eColorStart.g, self.emitter.eColorStart.b);
glUniform3f(self.shader.u_eColorEnd, self.emitter.eColorEnd.r, self.emitter.eColorEnd.g, self.emitter.eColorEnd.b);

[/spoiler]

Build and run your app — your particles should now decay in color and size, as shown below:

Run3

It's a really nice visual effect, especially considering how little code you wrote to achieve this result.

Ricardo Rendon Cepeda

Contributors

Ricardo Rendon Cepeda

Author

Over 300 content creators. Join our team.