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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
OpenGL ES Particle System Tutorial: Part 3/3
40 mins
- Particle System and 2D Game Series Review
- Getting Started
- Deciphering the Game Hierarchy
- Creating the Emitter
- Rendering Your Emitter Object
- Creating the GPU-CPU Bridge
- Generating Data for Your Particle System
- Sending Shader Data to the GPU
- Initializing Your Emitter
- Creating Your Particle Effect
- Deleting Your Emitter Objects
- Gratuitous Sound Effects
- Where To Go From Here?
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:
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:
- Each particle’s unique ID is used to calculate the particle's position on the ring’s circumference.
- If the blast is growing, the particles travel from the source center towards the final ring position, relative to the growth time.
- 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.
- The final position is calculated relative to the emitter source.
- 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 of0.0
(black) and1.0
(white). - If the blast is growing, the particles remain fully visible.
- 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:
That adds a great little 2D special effect to the game, doesn't it?