Skip to content

Commit 265bc10

Browse files
committed
shadow visual improvements
1 parent e66e148 commit 265bc10

16 files changed

+437
-6
lines changed
7.61 MB
Loading
14.4 MB
Loading
777 KB
Loading
3.84 MB
Loading
7.92 MB
Loading

articles/tutorials/advanced/2d_shaders/09_shadows_effect/index.md

Lines changed: 229 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,7 @@ The `LightBuffer` is only being cleared at the start of the entire `DrawLights()
548548

549549
[!code-csharp[](./snippets/snippet-9-65.cs)]
550550

551-
And now the lights are working again! The final `DrawLights()` method is written below:
551+
And now the shadows are working again! The final `DrawLights()` method is written below:
552552

553553
[!code-csharp[](./snippets/snippet-9-66.cs)]
554554

@@ -564,12 +564,234 @@ We can remove a lot of unnecessary code.
564564
5. The `PointLight.ShadowBuffer` `RenderTarget` is no longer used. Remove it. Anywhere that referenced the `ShadowBuffer` can also be removed.
565565

566566
## Improving The Look and Feel
567-
TODO
568567

569-
- dither fall off
570-
- box-blur
571-
- stencil mask
572-
- scissor test
568+
The shadow technique we have developed so _cool_, but the visual effect leaves a lot to be desired. The shadows sort of look like dark polygons being drawn on top of the scene, rather than what they actually are, which is the absence of light in certain areas. Part of the problem is that the shadows have hard edges, and in real life, shadows fade smoothly across the boundary between light and darkness. Unfortunately for us, creating physically accurate shadows with soft edges is _hard_. There are lots of techniques you could try, like this technique for [rendering penumbra geometry](https://www.gamedev.net/tutorials/programming/graphics/dynamic-2d-soft-shadows-r3065/), or [use 1d shadow maps](https://github.com/mattdesl/lwjgl-basics/wiki/2D-Pixel-Perfect-Shadows). _(That 1d shadow map article references a classic article in 2d shadow mapping that seems to have fallen off the internet. Luckily the Internet Archive has it available, [here](https://web.archive.org/web/20160226133242/http://www.catalinzima.com/2010/07/my-technique-for-the-shader-based-dynamic-2d-shadows/))_
569+
570+
Soft shadow techniques are out of the scope of this tutorial, so we will need to find other ways to improve the look and feel of our hard-edged shadows. The first thing to do is let go of the need for "physically accurate" shadows. Our 2d _Dungeon Slime_ game is not physically accurate anyway, so the shadows do not need to be either.
571+
572+
### Less Is More
573+
574+
The first thing to do is make _fewer_ lights. This is a personal choice, but I find that the lights we added earlier in the chapter are _cool_, but they are distracting. So many lights cause lots of shadows, and the shadows move around a lot and distract from the main object of the game, _eating bats_.
575+
576+
1. Originally, we added 4 lights at the top of the level because there were already 4 torches in the game world. Remove the two center toches by modifying the `tilemap-definition.xml`:
577+
578+
[!code-xml[](./snippets/snippet-9-67.xml?highlight=6)]
579+
580+
2. Next, remove the center lights from the `InitializeLights()` function.
581+
3. While we are it, replace final 2 moving lights for a single large light that sits at the bottom of the level. When you remove these lights, make sure to remove the `MoveLightsAround()` function as well.
582+
4. We can also get rid of the shadow caster for the walls of the level.
583+
584+
Here is the final `InitializeLights()` function:
585+
586+
[!code-csharp[](./snippets/snippet-9-68.cs)]
587+
588+
Now there is less visual shadow noise going on.
589+
590+
| ![Figure 9-22: Fewer lights mean fewer shadows](./gifs/less-is-more.gif) |
591+
| :----------------------------------------------------------------------: |
592+
| **Figure 9-22: Fewer lights mean fewer shadows** |
593+
594+
### Blur the Shadows
595+
596+
Perhaps the most obvious issue with the shadows are the hard edges. It would be nice if they were _like_ soft shadows, without having to do the hard work of calculating per pixel soft shadows. One easy way to blur the shadows is to blur the `LightBuffer` when we are reading it in the final deferred rendering composite shader.
597+
598+
We will be using a simple blur technique called [box blur](https://en.wikipedia.org/wiki/Box_blur).
599+
600+
1. Add this snippet to your `deferredCompositeEffect.fx`:
601+
602+
[!code-hlsl[](./snippets/snippet-9-69.hlsl)]
603+
604+
2. Then, in the `MainPS` function of the shader, instead of reading the `LightBuffer` directly, get the value from the new `Blur` function.
605+
606+
[!code-hlsl[](./snippets/snippet-9-71.hlsl?highlight=4)]
607+
608+
3. Notice that the box blur needs access to the `ScreenSize`, which we need to set in the `Core`'s `Update()` method:
609+
610+
[!code-csharp[](./snippets/snippet-9-70.cs?highlight=5)]
611+
612+
Now, as we adjust the `BoxBlurStride` size, we can see the shadows blur in and out.
613+
614+
> [!note]
615+
> We could get higher quality blur by increasing the `kernalSize` in the shadow, but that comes at the cost of runtime performance.
616+
617+
| ![Figure 9-23: Bluring the shadows](./gifs/box-blur-extreme.gif) |
618+
| :--------------------------------------------------------------: |
619+
| **Figure 9-23: Bluring the shadows** |
620+
621+
4. It is up to you to find a `BoxBlurStride` value that fits your preference, but I like something around `.18`:
622+
623+
```csharp
624+
DeferredCompositeMaterial.SetParameter("BoxBlurStride", .18f);
625+
```
626+
627+
### Shadow Length
628+
629+
The next visual puzzle is that sometimes the shadow projections look unnatural. The shadows look too _long_. It would be nice to have some artistic control from how long the shadow hulls should be. Ideally, the hulls could be faded out at some distance away from the shadow caster. However, our shadows are using the stencil buffer to literally clip fragments out of the lights, and the stencil buffer cannot be "faded" in the tranditional sense.
630+
631+
There is a technique called [dithering](https://surma.dev/things/ditherpunk/), which fakes a gradient by alternativing pixels on and off. The image below is from [wikipedia](https://en.wikipedia.org/wiki/Dither)'s article on dithering. The image only has two colors, _white_ and _black_. The image _looks_ shaded, but it is just in the art of spacing the black pixels further and further away in the brighter areas.
632+
633+
| ![Figure 9-24: An example of a dithered image](https://upload.wikimedia.org/wikipedia/commons/e/ef/Michelangelo%27s_David_-_Bayer.png) |
634+
| :------------------------------------------------------------------------------------------------------------------------------------: |
635+
| **Figure 9-24: An example of a dithered image** |
636+
637+
We can use the same dithering technique in the `shadowHullEffect.fx` file. If we had a gradient value, we could dither that value to decide if the fragment should be clipped or not.
638+
639+
1. Add the following snippet to the `shadowHullEffect.fx` file,
640+
641+
```hlsl
642+
// Bayer 4x4 values normalized
643+
static const float bayer4x4[16] = {
644+
0.0/16.0, 8.0/16.0, 2.0/16.0, 10.0/16.0,
645+
12.0/16.0, 4.0/16.0, 14.0/16.0, 6.0/16.0,
646+
3.0/16.0, 11.0/16.0, 1.0/16.0, 9.0/16.0,
647+
15.0/16.0, 7.0/16.0, 13.0/16.0, 5.0/16.0
648+
};
649+
650+
float ShadowFadeStartDistance;
651+
float ShadowFadeEndDistance;
652+
```
653+
654+
2. Set the `MainPS` function to the following:
655+
656+
```hlsl
657+
float4 MainPS(VertexShaderOutput input) : COLOR
658+
{
659+
// get an ordered dither value
660+
int2 pixel = int2(input.TextureCoordinates * ScreenSize);
661+
int idx = (pixel.x % 4) + (pixel.y % 4) * 4;
662+
float ditherValue = bayer4x4[idx];
663+
664+
// produce the fade-out gradient
665+
float maxDistance = ScreenSize.x + ScreenSize.y;
666+
float endDistance = ShadowFadeEndDistance;
667+
float startDistance = ShadowFadeStartDistance;
668+
float fade = saturate((input.TextureCoordinates.x - endDistance) / (startDistance - endDistance));
669+
670+
if (ditherValue > fade){
671+
clip(-1);
672+
}
673+
674+
clip(input.Color.a);
675+
return float4(0,0,0,1); // return black
676+
}
677+
```
678+
679+
> [!note]
680+
> Why use `input.TextureCoordinates.x` ?
681+
>
682+
> The shader produces a `fade` value by interpolating the `input.TextureCoordinates.x` between a `startDistance` and `endDistance`. Recall from the [theory section](#rendering-the-shadow-buffer) that the texture coordinates are used to decide vertex is which. The `.x` value of the texture coordinates is `1` when the vertex is the `D` or `F` vertex, and `0` otherwise. The `D` and `F` vertices are the ones that get projected far into the distance. Thus, the `.x` value is a good approximation of the "distance" of any given fragment.
683+
684+
Now when you run the game, you can play around with the shader parameters to create a fall off gradient for the shadow.
685+
686+
| ![Figure 9-25: Controlling shadow length](./gifs/shadow-length.gif) |
687+
| :-----------------------------------------------------------------: |
688+
| **Figure 9-23: Controlling shadow length** |
689+
690+
It is worth calling out that this dithering technique only works well because the box blur is covering the pixellated output. Try disabling the blur entirely, and pay attention to the shadow fall off gradient.
691+
692+
3. You will need to pick values that you like for the shadow fall off. I like `.013` for the start and `.13` for the end.
693+
694+
```csharp
695+
ShadowHullMaterial.SetParameter("ShadowFadeStartDistance", .013f);
696+
ShadowHullMaterial.SetParameter("ShadowFadeEndDistance", .13f);
697+
```
698+
699+
> [!note]
700+
> These gradient numbers are relative to the screen size. If you want to think in terms of pixels, divide the values by the screen size to normalize them:
701+
>
702+
> ```hlsl
703+
> float endDistance = ShadowFadeEndDistance / maxDistance;
704+
> float startDistance = ShadowFadeStartDistance / maxDistance;
705+
> ```
706+
>
707+
> Keep in mind that the debug UI only sets shader parameters from `0` to `1`, so you will need to set these values from code.
708+
709+
### Shadow Intensity
710+
711+
The shadows are mostly solid, except for the blurring effect. However, that can create a very stark atmosphere. It would be nice if we could simply "lighten" all of the shadows. This is a fairly easy extension from the previous [shadow length](#shadow-length) technique. We could set a max value that the shadow is allowed to be before it is forcibly dithered.
712+
713+
1. Modify the `shadowHullEffect.fx` to introduce a new shader parameter, `ShadowIntensity`, and use it to force dithering on top of the existing fade-out.
714+
715+
[!code-hlsl[](./snippets/snippet-9-72.hlsl?highlight=3,17)]
716+
717+
Now you can experiment with different intensity values and fade out the entire shadow.
718+
719+
| ![Figure 9-26: Controlling shadow intensity](./gifs/shadow-intensity.gif) |
720+
| :-----------------------------------------------------------------------: |
721+
| **Figure 9-26: Controlling shadow intensity** |
722+
723+
2. Pick a value that looks good to you, but I like `.85`.
724+
725+
```csharp
726+
ShadowHullMaterial.SetParameter("ShadowIntensity", .85f);
727+
```
728+
729+
### No Self Shadows
730+
731+
The shadows are looking much better! For the final visual adjustment, it will look better if the snake doesn't cast shadows onto _itself_. When the snake is long, and the player curves around, sometimes the shadow from some slime segments will cast onto other slime segments. It produces a lot of visual flickering in the scene that can be distracting. It would be best if the snake didn't receive any shadows what-so-ever. To do that, we will need to extend the stencil buffer logic.
732+
733+
Recall from the [stencil](#the-stencil-buffer) section that the stencil buffer clears every pixel to `0` for reach light, and then shadow caster's shadow hull geometry increases the value. Later, when the lights are drawn, pixels only pass the stencil function when the pixel value is `0`. Importantly, the shadow hulls _always_ increased the stencil buffer value per pixel.
734+
735+
In this section, we are going to write the snake segments to the stencil buffer, and then change the shadow hull pass to only draw shadow hulls when the stencil buffer is _not_ a snake pixel.
736+
737+
In this new edition, the values of the stencil buffer are outlined below,
738+
739+
| Stencil Value | Description |
740+
| :------------ | :-------------------------------- |
741+
| `0` | The snake is occupying this pixel |
742+
| `1` | An empty pixel |
743+
| `2+` | A pixel "in shadow" |
744+
745+
Follow the steps to modify the code so that the snake appears stenciled out of the shadows.
746+
747+
1. First, change the stencil buffer `.Clear()` call to clear the stencil buffer to `1` instead of `0`.
748+
749+
[!code-csharp[](./snippets/snippet-9-73.cs?highlight=14)]
750+
751+
2. Then, add a new `DepthStencilState` field to the `DeferredRenderer` class:
752+
753+
```csharp
754+
/// <summary>
755+
/// The state that will be ignored from shadows
756+
/// </summary>
757+
private DepthStencilState _stencilShadowExclude;
758+
```
759+
760+
3. We need to initialize the `_stencilShadowExclude` state in the constructor:
761+
762+
[!code-csharp[](./snippets/snippet-9-74.cs?highlight=14)]
763+
764+
4. We also need to update the existing states to take the new value into account:
765+
766+
[!code-csharp[](./snippets/snippet-9-75.cs)]
767+
768+
5. The snake actually needs to be drawn at the right location, at the right time. The quickest way to accomplish this is to introduce a callback in the `DrawLights()` method and allow the caller to inject a draw call.
769+
770+
Modify the `DrawLights()` function like so:
771+
772+
[!code-csharp[](./snippets/snippet-9-76.cs?highlight=1,14)]
773+
774+
6. And finally, the `GameScene`'s `Draw()` method should re-draw the snake segments in this callback:
775+
776+
```csharp
777+
// start rendering the lights
778+
_deferredRenderer.DrawLights(_lights, casters, (blend, stencil) =>
779+
{
780+
Core.SpriteBatch.Begin(
781+
effect: _gameMaterial.Effect,
782+
depthStencilState: stencil,
783+
blendState: blend);
784+
_slime.Draw(_ => {});
785+
Core.SpriteBatch.End();
786+
});
787+
```
788+
789+
Now even when the snake character is heading directly into a light, the segments in the back do not receive any shadows.
790+
791+
| ![Figure 9-27: No self shadows](./gifs/shadow-no-self.gif) |
792+
| :--------------------------------------------------------: |
793+
| **Figure 9-26: No self shadows** |
794+
573795

574796
## Conclusion
575797

@@ -579,6 +801,7 @@ And with that, our lighting and shadow system is complete! In this chapter, you
579801
- Wrote a vertex shader to generate a "shadow hull" quad on the fly.
580802
- Implemented a shadow system using a memory-intensive texture-based approach.
581803
- Refactored the system to use the Stencil Buffer for masking.
804+
- Developed several techniques for improving the look and feel of the stencil shadows.
582805

583806
In the final chapter, we will wrap up the series and discuss some other exciting graphics programming topics you could explore from here.
584807

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<Tilemap>
3+
<Tileset region="260 0 80 80" tileWidth="20" tileHeight="20">images/atlas</Tileset>
4+
<Tiles>
5+
00 01 02 01 02 01 02 01 02 01 02 01 02 01 02 03
6+
04 05 05 06 05 05 05 05 05 05 05 05 06 05 05 07
7+
08 09 09 09 09 09 09 09 09 09 09 09 09 09 09 11
8+
04 09 09 09 09 09 09 09 10 09 09 09 09 10 09 07
9+
08 09 10 09 09 09 09 09 09 09 09 09 09 09 09 11
10+
04 09 09 09 09 09 09 09 09 09 09 09 09 09 09 07
11+
08 10 09 09 09 09 09 09 09 09 10 09 09 09 09 11
12+
04 09 09 09 09 09 10 09 09 09 09 09 09 09 09 07
13+
12 13 14 13 14 13 14 13 14 13 14 13 14 13 14 15
14+
</Tiles>
15+
</Tilemap>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
private void InitializeLights()
2+
{
3+
// torch 1
4+
_lights.Add(new PointLight
5+
{
6+
Position = new Vector2(260, 100),
7+
Color = Color.CornflowerBlue,
8+
Radius = 600
9+
});
10+
11+
// torch 2
12+
_lights.Add(new PointLight
13+
{
14+
Position = new Vector2(1000, 100),
15+
Color = Color.CornflowerBlue,
16+
Radius = 600
17+
});
18+
19+
// underlight
20+
_lights.Add(new PointLight
21+
{
22+
Position = new Vector2(600, 660),
23+
Color = Color.MonoGameOrange,
24+
Radius = 1200
25+
});
26+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
float2 ScreenSize;
2+
float BoxBlurStride;
3+
4+
float4 Blur(float2 texCoord)
5+
{
6+
float4 color = float4(0, 0, 0, 0);
7+
8+
float2 texelSize = 1 / ScreenSize;
9+
int kernalSize = 1;
10+
float stride = BoxBlurStride * 30; // allow the stride to range up a size of 30
11+
for (int x = -kernalSize; x <= kernalSize; x++)
12+
{
13+
for (int y = -kernalSize; y <= kernalSize; y++)
14+
{
15+
float2 offset = float2(x, y) * texelSize * stride;
16+
color += tex2D(LightBufferSampler, texCoord + offset);
17+
}
18+
}
19+
20+
int totalSamples = pow(kernalSize*2+1, 2);
21+
color /= totalSamples;
22+
color.a = 1;
23+
return color;
24+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
protected override void Update(GameTime gameTime)
2+
{
3+
// ...
4+
5+
DeferredCompositeMaterial.SetParameter("ScreenSize", new Vector2(GraphicsDevice.Viewport.Width, GraphicsDevice.Viewport.Height));
6+
DeferredCompositeMaterial.Update();
7+
8+
base.Update(gameTime);
9+
}

0 commit comments

Comments
 (0)