Shadows
In this section we will implement basic shadow mapping which allows us to check if a pixel has a shadow cast on it.
The shadow
Pass
The first thing we need to do is create two new files: shadow.vsh
and shadow.fsh
. They should be in the shaders
folder, just like your other shader programs.
These compose the ‘shadow pass’, which runs before the gbuffers passes. It renders the scene from the perspective of the sun (or moon), storing depth and color information which we can use for shadow mapping.
The shadow programs can actually be pretty much copies of your gbuffers_terrain
programs. They will function the same, however less info needs to be passed through, so if you want, you can delete the extra in
and out
declarations we aren’t using. The only ones we need are texcoord
and glcolor
.
This is actually all we need to get Iris rendering a shadow map. Let’s now go back to our composite
pass.
Reading the Shadow Map
The shadow map is accessable as shadowtex0
, so let’s add this as a sampler2D
.
First, to make sure things are working properly, let’s render the shadow map to the screen.
You should see something like this:
This may not make much sense to look at, but this shows how far away the nearest thing the sun can see is.
Getting the Position in Shadow Space
To check if a pixel is shadowed, we need to know where in the shadow map it is. We can do this by transforming the position of the pixel into shadow space. We will need the following transformation matrices:
To do this, we first need to determine the position of the pixel at all. We can do this using the screen texture coordinate, as well as the depth. For more information on these space conversions, see Coordinate Spaces.
To do these space conversions, we will make use of a function, projectAndDivide
. This function applies a projection matrix and then divides by the w
component, skipping clip space. Place this function definition outside and before main
but after the declaration of texcoord
.
Then, for our space conversion:
We can then sample the shadow map at shadowScreenPos
.
Sampling the Shadow Map
So, the shadow map contains the depth of the nearest thing to the light source in that direction. Therefore, if something is further away from the sun, it must be in shadow. What we do is compare the depth in the shadow map at our shadow position’s xy component to the z component of our shadow position. If the z component is not greater than the depth, it must be in sunlight. We can do this comparison with the step
function. Let’s say we have step(a, b)
. It returns 1.0 if b
is more than a
. With that in mind, we can declare:
We can then replace our multiplication by lightmap.g
with one by shadow
.
You should now see something like this:
Now, while you can tell that things are casting shadows, a lot of surfaces seem to be in shadow when they shouldn’t be, with weird patterns. This is due to something called ‘shadow acne’, and it occurs when something ends up casting a shadow on itself due to the lack of precision in the shadow map. We can fix this by adding something known as ‘shadow bias’ where we offset surfaces slightly towards the sun to prevent them casting shadows on themselves:
Your shadows should now look a lot more reasonable.
Making Shadows Sharper
At the moment, our shadows are extremely blocky. This is due to the limited resolution of the shadow map. We can improve things somewhat by increasing this, using the shadowMapResolution
const. This constant can be defined anywhere, but just because it’s a nice place to put it, we’ll go back to shadow.fsh
. Let’s add the following. It should be outside the main
function - I put mine just before my layout
qualifier declaration.
This makes things a little bit sharper, but they still don’t look great.
The easy solution here would be to just increase the shadow map resolution to some very big number like 8192 (it is convention to use a power of two for your shadow map size), but this will start taking up an awful lot of video memory, causing a performance impact. Instead, we can make use of something known as ‘shadow distortion’. The idea is that since stuff that is closer to us is what we can see most clearly, we want to dedicate more of the shadow map resolution to this stuff, and less of it to things that are further away.
Shadow Distortion
For our shadow distortion, we want to scale things to make stuff closer to the player occupy more space in the shadow map, squashing the stuff that’s further away towards the edges. We are going to do this operation in shadow clip space. Let’s make a new function which takes in a position in shadow screen space, distorts it, and returns it. We want to access this function within both shadow.vsh
and composite.fsh
, so we are going to make use of a GLSL feature called #include
. This lets us include code from one file inside multiple other files.
Let’s make a new file called distort.glsl
. We’ll put it in a folder called lib
for the sake of organisation.
We can then add
to our shadow.vsh
. This should be included before the main
function, as you cannot define functions within a function. I put mine just before my out
declarations.
We should also move our declaration of shadowMapResolution
to distort.glsl
. This way, we can access this variable anywhere we are sampling the shadow map.
Within distort.glsl
, let’s write a function distortShadowClipPos
. This function is the one we described earlier.
So, how do we actually distort the position? Well, in shadow clip space, all positions are between -1.0
and 1.0
. Now, as the distance from the position to the origin (where the player is) increases, we want to make that distance even greater by pushing it closer to the edge. What we can do is divide the position by the distance to the origin.
We now need to apply that distortion. Back in shadow.vsh
, gl_Position
is in clip space, so we can simply apply it to the xyz
component.
At this point, let’s write the shadow map to the screen again to check what it looks like now. We did this earlier on, so I’ll not give you the code again.
As expected, stuff in the middle of the shadow map has been expanded to take up more space!
To apply this distortion when sampling the shadow map, we will #include /lib/distort.glsl
in composite.fsh
, and then apply the distortion to our shadow clip position after the bias.
Our shadows now look nice and sharp near the player!
Transparent Shadows
If you look at something like stained glass, you’ll see that it casts a solid shadow. This doesn’t really make sense, since (it being partially translucent) it should be letting some light through. Handily, there are a couple of extra shadowmaps we can access to help us with this.
shadowtex0
contains everything that casts a shadowshadowtex1
contains only things which are fully opaque and cast a shadowshadowcolor0
contains the color (including how transparent it is) of things which cast a shadow.
We will of course need to add these other two shadow maps as uniform declarations alongside shadowtex0
.
With these three shadow maps, we can work out how much shadow is being cast by something, and even what color that shadow should be!
Let’s go back to composite.fsh
and make a new function called getShadow
. It should take in a shadowScreenPos
which is of course a vec3
. As The logic we will use is as follows:
We can then replace our original
with
and we now get nice colored shadows!
Softer Shadows
The final piece of the puzzle is those nasty jagged edges on our shadows. This is called aliasing, and is another artifact of the limited resolution on our shadow map. If you look at real shadows, you’ll also notice that they don’t tend to have clean, sharp edges. Instead, they seem to have a slightly softer edge. This area at the edge of the shadow is known as the penumbra.
What this means is that we can blur our shadows slightly, and not only will it remove the aliasing, it will also make them more physically plausible.
The first thing we are going to do is add the following to distort.glsl
, just after we set the resolution.
This will remove the curved edges on the shadows, making them even more jagged, but it’s necessary for us to be able to correctly blur them, and also resolves some artifacts on transparent shadows you may have noticed.
To blur our shadows, we are going to be using something known as percentage closer filtering. To do this, we take multiple shadow samples within an area around the position and average them. To do this, we will be using a loop. Within this loop, we will generate an offset from the shadow position, apply it in clip space, and then convert to screen space before our shadow samples.
First, we should define how many samples we are going to take, and how far we should offset our samples. We can do this with a preprocessor directive which we can later make configurable in the settings menu.
Let’s define another function getSoftShadow
in composite.fsh
. This function will call getShadow
so it must be placed after it.
We can then remove some of our conversion code and replace our call to getShadow
with a call to softShadow
.
Our shadows now have soft edges, and the aliasing artifacts are no longer visible. However, they still look a little bit pixelated. This is for two reasons. The first one is that we are using a box kernel, which means that we are sampling evenly within a square box. Ideally, a circular kernel would be used, however implementing this is left as an exercise to the reader. The second, more important reason is that we are sampling the exact same points for every pixel on screen. We can resolve this issue by randomly rotating the area we sample within for each frame. We can get a random rotation using another texture, noisetex
. Let’s write noisetex
to the screen. I’m once again leaving this up to you to do, because we’ve written a fair few textures to the screen at this point. Like every other texture, noisetex
is declared with a uniform sampler2D noisetex;
.
You’ll see the pixels in noisetex
are pretty chunky. This is because it has a resolution of 64x64, and is being stretched to fit the screen. We could just increase the resolution, but to ensure we’re getting a unique value for every pixel, we’ll instead use a function called texelFetch
. texelFetch
takes in an exact pixel coordinate in a texture and returns the value there without doing any filtering. We will need to convert our texcoord
to a pixel coordinate within the range (0-64). We can do this using another couple of uniforms, viewWidth
and viewHeight
(both float
s). We will also make use of the type ivec2
which is like a vec2
except it can store only integer values. Let’s make another new function called getNoise
. It will take in a vec2 texcoord and return a vec4, the lookup value from noisetex
. We’re going to access it in getSoftShadow
so it should be placed before this function.
While the image clearly repeats, we now have unique values per pixel for the noise. We can then modify our softShadow
function to take this into account.
Our shadows no longer have any visible pixels!