I’m at a point where I’m doing a lot of work, but the progress seems minimal. This happens often and is not at all unusual, especially projects like this that develop somewhat organically.
You’ll often find yourself working on a component only to rework it when you look at an adjacent component or even the same component with fresh eyes.
While this isn’t unusual, it does make blogging work in progress difficult. I’ll either have non-functional code or some simple test scene which isn’t interesting or difficult to explain out of context.
To make this easier, I’m hoping to move onto scene management soon. To be able to switch between scenes without having to copy, paste, comment and uncomment large blocks of code would be nice. However, this isn’t a trivial task.
Shadows: Redux
I don’t know if this was obvious, but in my first post about shadows I was pretty bummed out. I spent a lot of time staring at, moving around and redoing what looked to be working code only to discover most of my problems were either simple range/clipping issues, invalid shadow biases and of course… ignorance.
After spending some time away from shadows (and time away from my computer over the Christmas break), I am back fixing problems I didn’t have the heart–or more accurately, patience–to address at the time.
Shadow Maps
In my first attempt, my shadow pass and its resources allowed for a max of eight 2k (2048x2048) textures and my demo scene was using all of them. I wasn’t really thinking too much about it at the time, I was more focussed on getting it to work. This is a common development approach, first you make it work then you make it work well.
The second time around I have introduced the concept of Quality which will be used for many things.
For my shadow maps, it’s currently implemented like this:
const QUALITY_CONFIG: Record<Quality, Map<Quality, ShadowMapConfig>> = {
[Quality.AUTO]: new Map([
[Quality.HIGH, { resolution: -1, maxMaps: 0 }], // Unused
[Quality.MEDIUM, { resolution: -1, maxMaps: 0 }], // Unused
[Quality.LOW, { resolution: -1, maxMaps: 0 }], // Unused
]),
[Quality.LOW]: new Map([
[Quality.HIGH, { resolution: 1024, maxMaps: 2 }],
[Quality.MEDIUM, { resolution: 512, maxMaps: 6 }],
[Quality.LOW, { resolution: 256, maxMaps: 8 }],
]),
[Quality.MEDIUM]: new Map([
[Quality.HIGH, { resolution: 2048, maxMaps: 4 }],
[Quality.MEDIUM, { resolution: 1024, maxMaps: 4 }],
[Quality.LOW, { resolution: 512, maxMaps: 8 }],
]),
[Quality.HIGH]: new Map([
[Quality.HIGH, { resolution: 4096, maxMaps: 2 }],
[Quality.MEDIUM, { resolution: 2048, maxMaps: 4 }],
[Quality.LOW, { resolution: 1024, maxMaps: 8 }],
]),
};
This tiered quality configuration allows for a global setting of Low, Medium or High (and Auto, more on that in a minute) and the available slots for shadow maps at Low, Medium and High quality.
For small screens or mobile devices the global shadow quality could be set Low, so two High quality (1024x1024) maps, six Medium quality (512x512) maps and eight Low quality (256x256) maps for a total of 16 shadow casting lights.
These numbers are kind of arbitrary at the moment, I can tune them later if/when I get a range of hardware to profile/test with.
Softening Jaggies
A shadow map is a texture and there is only so much we can render to it. Like a regular texture, if you get too close or the resolution is too low you can see the individual texels; the wider the coverage, the worse this is. Unlike a regular texture however, you cannot prefilter a shadow map to remove the aliasing1.
A simple option to help combat this is “Percentage Closer Filtering”. Rather than a single sample that tells us whether a pixel is in shadow, we do multiple samples and average them out. This takes us from the binary 0 (shadow) or 1 (light) to some value between them–the more samples that are in shadow, the closer to 0 it will be, vice versa for light. So the term “percentage closer” refers to the percentage of samples that pass the depth comparison (i.e., are “closer” to the light than the shadow caster). This averaging provides a softer gradient at the shadow edges.
Instead of just sampling n-texels in a grid around the current one, I’ve implemented Poisson disk sampling2, which provides a more natural, randomized sample distribution that helps reduce obvious noise/patterns.
Quality Control
Shadow quality is set globally based on device hardware (Auto) and can be exposed to the user via video quality settings in the future. Each shadow casting light can then set its quality. This can be an artistic decision (set to either Low, Medium or High manually) or automatically assigned based on whatever condition (Auto).
Lastly, the shadow map filter is currently tied to the spot lights penumbra. I’m not sure if this is a good approach yet but it makes sense to me. The softer the light, the softer the shadows.
In this example, there is a single shadow casting spot in the scene. You can play around with the spot light’s angle, penumbra and the shadow quality to see how they affect each other. In the (collapsed by default) Render Options folder, you can set the global shadow quality.
A few things of note:
- If you set the spot lights shadow quality to
Autothe shadow map pops at one point. This is when the map resolution changes and it’s not exactly subtle. I don’t think I’d be doing this at runtime, instead just doing the quality assignment once and then leaving it at that. - You’ll notice that when the resoluton is low, the penumbra has a much larger impact on scattering of the shadow (this is the reason the shift between maps is so obvious). This is something I can try account for in the shader to make it less obvious. Playing around with it interactively, it looks like I can use some kind of divison of the penumbra used for the shadows based on the quality, try it for yourself.
- Combinations of the spot angle and penumbra can create strange clipping within the shadow map, I’m not entirely sure why and I can’t really be bothered looking into it right now. If it becomes a problem, I’ll look at it then.