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.
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 2/3
45 mins
- Getting Started
- Designing Your Particle System
- Abstracting Your Rendering Cycle
- Adding Vertex and Fragment Shaders
- Building an Objective-C Bridge
- Loading Your Shader Program and Particle System
- Rendering Your Explosion
- Enhancing Your Particle System
- Adding Textures
- Adding Multiple Emitters
- Correcting the Rendering Cycle
- Where To Go From Here?
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:
- This function is taken straight from Part 1 and is used to produce a random float value between two bounds.
- 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. - Here, you define the bounds used to calculate each particle’s offset according to the particle system structures.
- All particles are initialized with a unique ID and random offsets.
- Here, you define your emitter properties, creating a custom explosion based on a generic template.
- Next, you set the global factors that affect the simulation of your scene.
- 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:
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:
- If the blast is growing, maintain the particle starting size.
- 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. - 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:
- If the blast is growing, maintain the particle starting color.
- 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. - Finally, output the resulting color after adding or subtracting the offset, using the
clamp
function to stay within the bounds of0.0
(black) and1.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:
It's a really nice visual effect, especially considering how little code you wrote to achieve this result.