Dynamic Lighting Shader with Cocos2d v2.0
Creating a performant normal mapped lighting shader for Cocos2d v2.0 and CCSpriteBatchNode
For v1.0.3 of Trisector, I wanted to add dynamic lighting to the CCSpriteBatchNode layers to enhance the depth of field and to make the scene a bit more visually interesting. This article will discuss a dynamic lighting shader and a dynamic lighting and blur shader used in Trisector.
As discussed in the previous Blur Shader with Cocos2d v2.0 article, I knew the first generation retina iPad was going to cause some trouble so I started small and pushed the lighting shader as far as I could for that device. The end result keeps Trisector’s performance at 60fps and looks good in use, which I consider to be a win. I had a normal mapped lighting shader running fine on an iPhone 5 (iPhone5,1) and an iPad Mini (iPad2,5), but it completely choked on the first generation retina iPad (iPad3,1+). Thus, a simpler lighting shader was used for all devices, which will be described below.
Here’s a picture illustrating the lighting and blur shader discussed below:
To add the Lighting Shader to a CCSpriteBatchNode in Cocos2d v2.0, the shader first needs to be defined and loaded to the shader cache, and then the shader needs to be applied to the layer. Note, the shader functions below reside in a C style function library.
// define/load the Lighting Shader
addLightingShader();
// apply Lighting Shader to the layer
layer.shaderProgram = [[CCShaderCache sharedShaderCache] programForKey:@"LightingShader"];
Here is how the Lighting Shader program is setup and defined:
void addLightingShader()
{
// define the default shader program
CCGLProgram *shaderProgram = [[CCGLProgram alloc] initWithVertexShaderByteArray:Shaders_lighting_vert
fragmentShaderByteArray:Shaders_lighting_frag];
if (GLOBAL_SHADER_DEBUG) CHECK_GL_ERROR_DEBUG();
// add the standard attribute values
[shaderProgram addAttribute:kCCAttributeNamePosition index:kCCVertexAttrib_Position];
[shaderProgram addAttribute:kCCAttributeNameTexCoord index:kCCVertexAttrib_TexCoords];
[shaderProgram addAttribute:kCCAttributeNameColor index:kCCVertexAttrib_Color];
if (GLOBAL_SHADER_DEBUG) CHECK_GL_ERROR_DEBUG();
[shaderProgram link];
if (GLOBAL_SHADER_DEBUG) CHECK_GL_ERROR_DEBUG();
[shaderProgram updateUniforms];
if (GLOBAL_SHADER_DEBUG) CHECK_GL_ERROR_DEBUG();
// this is the name of the shader referenced on load
[[CCShaderCache sharedShaderCache] addProgram:shaderProgram forKey:@"LightingShader"];
if (GLOBAL_SHADER_DEBUG) CHECK_GL_ERROR_DEBUG();
// the lighting uses two variables that are updated each frame: falloff and intensity
// get position for the falloff variable and set default value
GLint lightFalloffUniformLocation = glGetUniformLocation(shaderProgram->program_, "u_lightFalloff");
glUniform1f(lightFalloffUniformLocation, LIGHTING_COLLISION_FALLOFF);
// get position for the intensity variable and set the default value
GLint lightIntensityUniformLocation = glGetUniformLocation(shaderProgram->program_, "u_lightIntensity");
glUniform1f(lightIntensityUniformLocation, LIGHTING_COLLISION_INTENSITY);
[shaderProgram release];
shaderProgram = nil;
}
In this lighting shader, the vertex shader has a lighter workload than the fragment shader, so as many calculations as possible are performed in the vertex shader. If the calculation relies upon a value from the texture, it has to be performed in the fragment shader.
const GLchar *Shaders_lighting_vert =
"attribute vec4 a_position; \n\
attribute vec2 a_texCoord; \n\
attribute vec4 a_color; \n\
\n\
uniform mat4 u_MVPMatrix; \n\
uniform vec4 u_lightPosition; \n\
uniform float u_lightFalloff; \n\
uniform float u_lightIntensity; \n\
\n\
varying lowp vec3 v_fragmentColor; \n\
varying lowp vec2 v_texCoord; \n\
\n\
void main() \n\
{ \n\
// position: transform to eye space using MVPMatrix \n\
gl_Position = u_MVPMatrix * a_position; \n\
\n\
// calc light dist in node space \n\
mediump float lightDist = distance(a_position, u_lightPosition); \n\
\n\
// falloff: 0.00005 = dark, 0.000005 = light \n\
lowp float lightValue = (1.0 / (1.0 + (u_lightFalloff * lightDist * lightDist))); \n\
\n\
// pre-mul the color and light values to save muls in frag shader \n\
v_fragmentColor = a_color.rgb * (lightValue * u_lightIntensity); \n\
v_texCoord = a_texCoord; \n\
}";
In the above vertex shader, the distance from the light source to the vertex is determined, and that distance is used to calculate how bright the light value is at that vertex. Then, the intermediate fragmentColor variable is set to the tintColor (a_color) multiplied by the lightValue and the lightIntensity value.
The values used in Trisector for the background layer for the light falloff and light intensity are:
Intensity: 1.5 iPad, 0.75 iPhone Falloff: 0.000025 iPad, 0.00005 iPhone
The fragment shader is pretty simple since most of the work was performed in the vertex shader. First, the fragment shader samples the texture value (fragColor) and multiplies that by the fragmentColor determined in the vertex shader. Since these tilesheets contain transparency, the shader uses the alpha value from the texture for the final fragmentColor.
const GLchar *Shaders_lighting_frag =
"varying lowp vec3 v_fragmentColor; \n\
varying lowp vec2 v_texCoord; \n\
\n\
uniform sampler2D u_texture; \n\
\n\
void main() \n\
{ \n\
// sample texture \n\
lowp vec4 fragColor = texture2D(u_texture, v_texCoord); \n\
\n\
// set the color to fragment tint color * native fragment color \n\
gl_FragColor = vec4(v_fragmentColor * fragColor.rgb, fragColor.a); \n\
}";
In order to set the u_lightPosition uniform value, the shader needs to be set to active, the position of the variable needs to be determined, and then the value that corresponds to the light position needs to be set at the variable’s position. Note, the light’s position value is in node space.
// set the shader program
[layer.shaderProgram use];
// get the position for the variable and set the light position in node space
GLint lightUniformPosition = glGetUniformLocation(layer.shaderProgram->program_, "u_lightPosition");
glUniform4f(lightUniformPosition, lightPosition.x, lightPosition.y, 1.0f, 1.0f);
if (GLOBAL_SHADER_DEBUG) CHECK_GL_ERROR_DEBUG();
The u_lightIntensity and u_lightFalloff uniforms may be updated in the same manner as u_lightPosition above. Trisector dynamically changes the lighting by varying u_lightIntensity and u_lightFalloff based on the number of lasers, bullets, explosions, etc on screen. Trisector sets the u_lightPosition, u_lightIntensity and u_lightFalloff uniform values each frame after the layers, player, lasers, bullets, explosions, etc. and are in their final positions.
IMPORTANT NOTE: Shader uniform values are stored PER PROGRAM. That means that if you have the same shader program applied to two different layers, the uniform values will be the same for both layers. If you need to have different uniform values for the different layers, make a separate shader program for each layer.
In Trisector, the background layer uses a shader program that has lighting and blur, and the foreground layer uses a different shader program that only has lighting, so the uniform values per program are not an issue. This caveat caused me a little trouble during development when I was using the same shader for each layer, so please keep it in mind when updating your shader program uniforms.
In interest of completeness, here’s the background layer shader that uses lighting and blur (as described in the previous Blur Shader with Cocos2d v2.0 article). The lighting and blur shader is very similar to the lighting only shader, except it has additional varying values for the coordinates to the left and right of the texture coordinate.
For the lighting and blur shader, the u_lightIntensity and u_lightFalloff values used are different from the lighting only shader since the lighting and blur shader for the background layer has a more intense spotlight and is darker overall.
Also, for the blur shader variant, keep in mind that the intermediate v_fragmentColor from the vertex shader should be pre-multiplied by 0.5 since the fragment shader (shown below) is performing an add operation for the two texture values to achieve the blur. In Trisector, I pre-multiplied the intensity value by 0.5 to account for this add operation. Thus, since the intensity value is calculated outside the shader once per frame, a multiply operation per fragment or vertex is saved inside the shader.
Intensity: 2.25 iPad, 1.125 iPhone Falloff: 0.0000025 iPad, 0.000004 iPhone
The lighting and blur shader program definition looks the same as the one above, except it uses the lightingBlur vertex and fragment shaders:
// define the default shader program
CCGLProgram *shaderProgram = [[CCGLProgram alloc]
initWithVertexShaderByteArray:Shaders_lightingBlur_vert
fragmentShaderByteArray:Shaders_lightingBlur_frag];
Here is the lighting and blur vertex shader:
const GLchar *Shaders_lightingBlur_vert =
"attribute vec4 a_position; \n\
attribute vec2 a_texCoord; \n\
attribute vec4 a_color; \n\
\n\
uniform mat4 u_MVPMatrix; \n\
uniform vec4 u_lightPosition; \n\
uniform float u_lightFalloff; \n\
uniform float u_lightIntensity; \n\
\n\
varying lowp vec2 v_texCoord; \n\
varying lowp vec2 v_textCoordL; \n\
varying lowp vec2 v_textCoordR; \n\
varying lowp vec3 v_fragmentColor; \n\
\n\
void main() \n\
{ \n\
// position: transform to eye space using MVPMatrix \n\
gl_Position = u_MVPMatrix * a_position; \n\
\n\
// calc light dist \n\
mediump float lightDist = distance(a_position, u_lightPosition); \n\
\n\
// falloff: 0.00005 = dark, 0.000005 = light \n\
lowp float lightValue = (1.0 / (1.0 + (u_lightFalloff * lightDist * lightDist))); \n\
\n\
// note, the light intensity is different for the blur shader \n\
v_fragmentColor = a_color.rgb * (lightValue * u_lightIntensity); \n\
\n\
// coordinates: pixel, left pixel, right pixel \n\
v_texCoord = a_texCoord; \n\
v_textCoordL = vec2(a_texCoord.x-0.00390625, a_texCoord.y); \n\
v_textCoordR = vec2(a_texCoord.x+0.00390625, a_texCoord.y); \n\
}";
And here is the lighting and blur fragment shader:
const GLchar *Shaders_lightingBlur_frag =
"varying lowp vec3 v_fragmentColor; \n\
varying lowp vec2 v_texCoord; \n\
varying lowp vec2 v_textCoordL; \n\
varying lowp vec2 v_textCoordR; \n\
\n\
uniform sampler2D u_texture; \n\
\n\
void main() \n\
{ \n\
// sample texture \n\
lowp vec4 fragColor = texture2D(u_texture, v_texCoord); \n\
lowp vec4 fragBlur = max(texture2D(u_texture, v_textCoordL), texture2D(u_texture, v_textCoordR)); \n\
\n\
// set the color to fragment tint color * native fragment color (note add operation for blur) \n\
gl_FragColor = vec4(fragColor.a * v_fragmentColor * (fragColor.rgb + fragBlur.rgb), fragColor.a); \n\
}";
Note, for Trisector’s tilesheets with transparency, the leading fragColor.a is needed since there are visual artifacts without it (even though fragColor.a sets alpha to zero). The fragment shader would save a multiply operation if your texture did not need strict transparency values for each pixel.
Again, due to the CPU/GPU capacity of the retina iPad3,1, this isn’t exactly the lighting shader that I set out to write, but the end result produces a pleasing lighting effect on the layers and doesn’t drop the frame rate below 60fps. I’ll take that as another win over shader code for now.
The following two performance measurements are from a particular heavy section of the game on an iPad 3,1.
This first performance measurement is a heavily optimized normal mapped lighting shader that ran fine on the iPhone5 and iPad mini, but completely choked on the iPad3,1.
This second performance measurement is the final lighting shader that uses the shaders discussed above (lighting and blur on background layer, lighting only on foreground layer).
And for comparison, here is the shader performance of the blur shader on only the background layer running on an iPad3,1:
In practice, the lighting and lighting+blur shaders use a little more CPU time than the blur only shader, and the GPU time is about the same (even though the simple lighting performance sample above shows 8ms, it’s actually closer to 11/12ms on average).
For the normal mapped lighting, I was lucky that Filter Forge also creates normal maps for the textures, which saved me a TON of time in trying to create normal maps by hand. The normal mapped lighting looked better, but was definitely heavier on the CPU/GPU. In order to keep my code base simple and not have multiple shaders for different devices, I standardized on the simple lighting and lighting+blur shaders discussed above.
If you want to create a normal mapped lighting shader, the above information and the information on this link should get you going: ShaderLesson6 : mattdesl/lwjgl-basics. Just be sure to push as many calculations as possible into the vertex shader since the fragment shader is going to be the limiting factor with the extra texture read, normalization, etc. If a calculation doesn’t rely on a value from the texture or the normal map, then push it into the vertex shader.
Here are a few tips to get you started on adding the normal map texture. In the fragment shader, need to add a uniform for the normal map texture:
uniform sampler2D u_normalMap;
...
lowp vec3 normalMapValue = texture2D(u_normalMap, v_texCoord).rgb;
...
And in order to bind the normal map texture to the u_normalMap uniform, the normal map’s CCTexture2D needs to be passed to the shader program:
void addNormalMapLightingShader(CCTexture2D *normalMapTexture)
{
...
// set the normal map texture location
GLint normalMapUniformLocation = glGetUniformLocation(shaderProgram->program_, "u_normalMap");
glUniform1i(normalMapUniformLocation, 1);
if (GLOBAL_SHADER_DEBUG) CHECK_GL_ERROR_DEBUG();
// bind the normal map to texture 1
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, [normalMapTexture name]);
if (GLOBAL_SHADER_DEBUG) CHECK_GL_ERROR_DEBUG();
// set back to texture 0 which is the normal spritesheet texture
glActiveTexture(GL_TEXTURE0);
if (GLOBAL_SHADER_DEBUG) CHECK_GL_ERROR_DEBUG();
...
}
Here’s an example Lighting and Blur Shader project for cocos2d v2.x: Example Lighting and Blur Shader project for cocos2d v2.x
And an example Lighting and Blur Shader project for cocos2d v3.1+: Example Lighting and Blur Shader project for cocos2d v3.1+
Hope you found this article on Dynamic Lighting Shaders interesting,
Jesse from Smash/Riot