Introduction
The source code of this tutorial you can find here: https://github.com/pogi-b/OpenGLShadowDemo
The application itself (apk) you can download from here: https://github.com/pogi-b/OpenGLShadowDemo/releases
Shadow mapping is a solution for dynamic shadows. Many times they are computationally too expensive, especially for mobile phones, so it can be useful to see how they perform in simple cases.
In this tutorial I show basic shadow mapping and PCF (Percentage Close Filtering) with adjustable shadow map size and bias type so you can see how they perform on Android. The simple algorithm is much faster but it has two outputs for each pixel (shadow / no shadow), so the edges are usually aliased. PCF results in a smooth shadow as it computes the shadow value as average of the pixels arounds, but it turns out many times to be too slow to produce real time shadows.
Simple shadow mapping
|
PCF shadow mapping
|
There are much more possible variations of shadow mapping algorithms so feel free to play around and mix them as you like. Good tutorials can be found that I also used as source in this demo application:
Credit also goes to Shayan Javed - Getting started with OpenGL ES 2.0 shaders on Android.
Background
In this tutorial I don't cover the basics of OpenGL, OpenGL ES 2.0 and Android development. But all this background you can find at Learn OpenGL ES tutorials and this OpenGL tutorial about shadow mapping.
Rendering the shadow map
The basic of shadow mapping is that we render the scene first as the light source would be the camera. In order to do that we create two View matrices and two Projection matrices, one for the light source and one for the camera. In the first step we pass the light source MVP matrix to the shaders.
From this step we only need the distance of objects from the light source which is called the shadow map. To use it later we store this depth values in a texture. On some android devices it's not possible to render depth values directly to a texture (GPUs without OES_depth_texture OpenGL extension) so we have to pack the depth values into RGBA components and later unpack them. To decide which method to use:
String extensions = GLES20.glGetString(GLES20.GL_EXTENSIONS);
if (extensions.contains("OES_depth_texture"))
mHasDepthTextureExtension = true;
With or without OES_depth_texture the vertex and fragment shaders are also different. One group is with "depth_tex_" prefix and the other group is without. To make it easy to switch between shader programs I used a separate class (RenderProgram.java) to compile, link and store OpenGL program handles (based on this solution).
The shaders used for rendering shadow map:
- (depth_tex_)v_depth_map.glsl
- (depth_tex_)f_shadow_map.glsl
To make it clear: if your device has the extension, only the simpler shaders will run without packing and unpacking, so you can start with checking that shaders, as they are easier to understand.
The only shader here which is not straightforward is the fragment shader when packing to RGBA is necessary.
f_shadow_map.glsl
precision highp float;
varying vec4 vPosition;
vec4 pack (float depth)
{
const vec4 bitSh = vec4(256.0 * 256.0 * 256.0,
256.0 * 256.0,
256.0,
1.0);
const vec4 bitMsk = vec4(0,
1.0 / 256.0,
1.0 / 256.0,
1.0 / 256.0);
vec4 comp = fract(depth * bitSh);
comp -= comp.xxyz * bitMsk;
return comp;
}
void main() {
float normalizedDistance = vPosition.z / vPosition.w;
normalizedDistance = (normalizedDistance + 1.0) / 2.0;
gl_FragColor = pack(normalizedDistance);
}
What happens here is that we encode the depth value (coordinate Z) to 4 components. You can find the explanation of math at the source if you want: https://github.com/fabiensanglard/dEngine/blob/master/data/shaders/f_shadowMapGenerator.glsl
Rendering the scene
After we have the depth map we can use that information to decide if a pixel is in shadow or not. To calculate that we count for each fragment:
- What it's coordinate from light point of view (we need lightMVP for that passed to the shader as uniform)
vShadowCoord = uShadowProjMatrix * aPosition;
- What is the depth value on depth map which belongs to this point
vec4 shadowMapPosition = vShadowCoord / vShadowCoord.w;
float distanceFromLight = texture2D(uShadowTexture, shadowMapPosition.st).z;
- Is the fragment farther from light than the depth value? If yes the fragment is in shadow.
return float(distanceFromLight > shadowMapPosition.z);
Shadows with different setup
In the demo application you can change shadow type and bias type of the shadow algorithm in the options menu. I could have put all algorithms in one shader, pass in uniforms and decide with if conditions which algorithm to use. The problem with this approach is that because of parallel computation of GPUs, both cases of conditions will be evaluated leading to poor performance that makes comparison of speed impossible. Another solution would be to use #ifdef and compile shader with different #define statements.
Constant / Dynamic bias
Common solution to remove shadow acne is to add a small error margin to depth value before comparing it to fragment distance from light source.
float bias = 0.005;
return float(distanceFromLight + bias > shadowMapPosition.z);
After adding constant bias it's visible that shadow acne disappears, but another problem shows up which is called Peter Panning, as objects on the ground look like flying.
You can notice that shadow acne appers more likely on surfaces which are visible in smaller angle from the light source. This leads to another solution when bias is adjusted according to the normal vector of the surface.
float calcBias()
{
float bias;
vec3 n = normalize( vNormal );
vec3 l = normalize( uLightPos );
float cosTheta = clamp( dot( n,l ), 0.0, 1.0 );
bias = 0.0001*tan(acos(cosTheta));
bias = clamp(bias, 0.0, 0.01);
return bias;
}
No Bias / Constant Bias (0.005) / Dynamic Bias
Shadow map sizes
You can change shadow map size in the menu:
- 0.5 displayWidth x 0.5 displayHeight
- 1.0 displayWidth x 1.0 displayHeight
- 1.5 displayWidth x 1.5 displayHeight
- 2.0 displayWidth x 2.0 displayHeight
Bigger shadow map texture results to better shadow edges, but after some point it doesn't lead too significantly better result, so it doesn't worth to make much bigger than the resolution of the screen (especially that it makes the algorithm slower).
Simple shadow mapping / PCF shadow mapping
PCF algorithm is based on sampling depth map more times around the position of the current fragment. This means that if we use a window with size 4x4 the value of shadow can have 16 different values. This results a soft shadow and less aliased edges. The problem with this approach is that we will have 16 times more lookup at the depth map and 16 times more comparison which you can also recognize at the drop of FPS results.
Cover shadow acne with diffuse lighting
Many articles write about how to solve shadow acne with adding bias. I was using a solution with the diffuse lighting component: if the vertex is not facing the light (gl_BackFace from light source's point of view), I just skip calculation of shadow in the fragment shader:
float shadow = 1.0;
if (diffuseComponent < 0.01)
{
shadow = 1.0;
}
else
{
if (vShadowCoord.w > 0.0) {
shadow = shadowSimple();
}
}
gl_FragColor = (vColor * (diffuseComponent + ambientComponent * shadow));
You can see the result here with using no bias:
Thank you for reading!
Please send your feedback or comments.