Sparrow WebGL Devlog 9: Shadows
One of the most important features to create depth and realism in a 3D scene is shadows. They provide visual cues about the shape and position of objects. Shadows also convey a lot of mood and atmosphere.
The main technique used to render shadows in real-time 3D applications is shadow mapping. Shadow volumes were an alternative in the past, but have fallen out of favor in recent years compared to the flexibility and ease of use of shadow maps.
The idea behind shadow mapping is quite genius. The scene is rendered from the viewpoint of the light to a framebuffer with an attached depth texture. The depth texture contains the closest distances from the light, which is how shadows work in real life too. The leaves of the tree above you are closer to the sun than you are, which means the leaves are in the sun, while you are shaded. When using a directional light like the sun, this is done with orthographic projection while point lights use perspective projection.
Here is an example of how the scene and the depth texture look from the light's point of view:
The next step is to render the scene to the screen with a few extra steps in the shaders. The vertex shader needs the model view projection matrix of the shadow map, which is used to calculate the shadow coordinates of every vertex:
#version 300 es
layout( location = 0 ) in vec3 vertexPosition;
uniform mat4 M_mvp;
uniform mat4 M_shadowMap_mvp;
out vec4 shadowCoord;
gl_Position = M_mvp * vec4( vertexPosition , 1.0 );
shadowCoord = M_shadowMap_mvp * vec4( vertexPosition , 1.0 );
In the fragment shader, the shadow coordinates can be used to look up the value of the depth texture for every pixel. That value can then be compared to the distance of the fragment to the light and if the depth value from the shadow map is bigger, the fragment is lit.
#version 300 es
precision mediump float;
in vec4 shadowCoord;
uniform sampler2D shadowMap;
out vec4 fragColor;
float visibility = 0.0;
float depthValue = texture( shadowMap , shadowCoord.xy ).r;
if ( depthValue > shadowCoord.z ) visibility = 1.0;
fragColor = vec4( visibility , visibility , visibility , 1.0 );
This simple fragment shader code assumes a directional light, i.e. orthographic projection. When using point lights with perspective projections you need to do the perspective division in the shader too (divide the xyz values of the shadow coords by their w value). I'm mostly using directional lights because there is generally only one directional light (the sun) and I don't have to worry about multiple light setups, which can be a lot more annoying to deal with.
Another important step is that the light's model view projection matrix has to be multiplied with another special bias matrix before it is sent to the vertex shaders. It's required to convert from normalized device coordinates which go from -1 to 1 to the 0 to 1 range that is used for uv texture lookups. Alternatively, this can be done directly in the shader, but for lights that don't change their position often, it's more efficient to multiply the matrix once and have fewer shader instructions.
var M = new mat4();
M.set( [ 0.5 , 0.0 , 0.0 , 0.0 , 0.0 , 0.5 , 0.0 , 0.0 , 0.0 , 0.0 , 0.5 , 0.0 , 0.5 , 0.5 , 0.5 , 1.0 ] );
this.M_bias_mvp = mat4.multiply( M , this.M_vp );
I have implemented shadow mapping in OpenGL before, so I could use it as a starting point for the WebGL version. However, it turns out that, unlike OpenGL, WebGL does not support linear filtering for depth textures, so I wasted a lot of time trying to figure out why my shadows were not working.
Depending on the scene and especially when using low shadow map resolutions, the shadows can look very blocky and bad:
The first and easiest solution is to increase the resolution of the shadow map. The next option could be Percentage Closer Filtering (PCF). The basic idea of PCF is to sample the shadow map at multiple positions and average the result. This leads to smoother shadows at the cost of some performance when using too many samples.
Another technique that's often used is Cascaded Shadow Maps (CSM). They create higher-resolution shadows near the camera and lower-resolution shadows farther away, which helps to reduce aliasing and improve the overall quality of the shadows.
The basic idea of CSMs is to divide the view frustum into a few cascades, each with a different depth range. A shadow map is then rendered for each cascade, with the resolution of the shadow map decreasing as the distance increases.
CSMs are a very effective way to improve the quality of shadows in shadow mapping. They are relatively easy to implement and can be used with any type of shadow mapping. However, they can also be computationally expensive, especially if a large number of cascades are used.
I haven't added CSMs to my WebGL engine yet because I probably won't need them for the next project, but I have implemented them in my C++/OpenGL engine before.
Shadows are very important for 3D scenes. Simple shadow mapping isn't too difficult to implement once you know the basic principles. Rendering smooth shadows with a big view distance is more tricky to get right. My implementation works well enough for now, but there are also a lot of improvements I have to make if I want to use it for larger environments.
by Christian - 19.07.2023 - 12:09