Shadertoy Tutorial Part 13 - Shadows

Published: Monday, April 26, 2021
Updated: Wednesday, November 9, 2022

Greetings, friends! Welcome to Part 13 of my Shadertoy tutorial series. In this tutorial, we'll learn how to add shadows to our 3D scene.

Initial Setup

Our starting code for this tutorial is going to be a bit different this time. We're going to go back to rendering scenes with just one color and we'll go back to using a basic camera with no lookat point. I've also made the rayMarch function a bit simpler. It accepts two parameters instead of four. We weren't really using the last two parameters anyways.

glsl
Copied! ⭐️
const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;
const float EPSILON = 0.0005;

float sdSphere(vec3 p, float r, vec3 offset)
{
  return length(p - offset) - r;
}

float sdFloor(vec3 p) {
  return p.y + 1.;
}

float scene(vec3 p) {
  float co = min(sdSphere(p, 1., vec3(0, 0, -2)), sdFloor(p));
  return co;
}

float rayMarch(vec3 ro, vec3 rd) {
  float depth = MIN_DIST;
  float d; // distance ray has travelled

  for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
    vec3 p = ro + depth * rd;
    d = scene(p);
    depth += d;
    if (d < PRECISION || depth > MAX_DIST) break;
  }

  d = depth;

  return d;
}

vec3 calcNormal(in vec3 p) {
    vec2 e = vec2(1, -1) * EPSILON;
    return normalize(
      e.xyy * scene(p + e.xyy) +
      e.yyx * scene(p + e.yyx) +
      e.yxy * scene(p + e.yxy) +
      e.xxx * scene(p + e.xxx));
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
  vec3 backgroundColor = vec3(0);

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
  vec3 rd = normalize(vec3(uv, -1)); // ray direction

  float sd = rayMarch(ro, rd); // signed distance value to closest object

  if (sd > MAX_DIST) {
    col = backgroundColor; // ray didn't hit anything
  } else {
    vec3 p = ro + rd * sd; // point discovered from ray marching
    vec3 normal = calcNormal(p); // surface normal

    vec3 lightPosition = vec3(cos(iTime), 2, sin(iTime));
    vec3 lightDirection = normalize(lightPosition - p);

    float dif = clamp(dot(normal, lightDirection), 0., 1.); // diffuse reflection clamped between zero and one

    col = vec3(dif);
  }

  fragColor = vec4(col, 1.0);
}

After running the code, we should see a very basic 3D scene with a sphere, a floor, and diffuse reflection. The color from the diffuse reflection will be shades of gray between black and white.

Canvas with a black background and white sphere in the center. The sphere is illuminated from the top-right, causing the sphere to appear dark on the bottom-left. The sphere sits on top of a white floor.

Basic Shadows

Let's start with learning how to add very simple shadows. Before we start coding, let's look at the image below to visualize how the algorithm will work.

Ray tracing diagram. A camera shoots out rays through a virtual canvas called the image. These rays then bounce along the floor or hit a sphere. Some rays bounce back toward a light source and others are blocked by the sphere. The rays that bounce off the floor and hit the sphere and don't make it to the light source are known as shadow rays.
Ray tracing diagram by Wikipedia

Our rayMarch function implements the ray marching algorithm. We currently use it for discovering a point in the scene that hits the nearest object or surface. However, we can use it a second time to generate a new ray and point this ray toward our light source in the scene. In the image above, there are "shadow rays" that are casted toward the light source from the floor.

In our code, we will perform ray marching a second time, where the new ray origin is equal to p, the point on the sphere or floor we discovered from the first ray marching step. The new ray direction will be equal to lightDirection. In our code, it's as simple as adding three lines underneath the diffuse reflection calculation.

glsl
Copied! ⭐️
float dif = clamp(dot(normal, lightDirection), 0., 1.); // diffuse reflection clamped between zero and one

vec3 newRayOrigin = p;
float shadowRayLength = rayMarch(newRayOrigin, lightDirection); // cast shadow ray to the light source
if (shadowRayLength < length(lightPosition - newRayOrigin)) dif *= 0.; // if the shadow ray hits the sphere, set the diffuse reflection to zero, simulating a shadow

However, when you run this code, the screen will appear almost completely black. What's going on? During the first ray march loop, we fire off rays from the camera. If our ray hits a point, p, that is closer to the floor than the sphere, then the signed distance value will equal to the length from the camera to the floor.

When we use this same p value in the second ray march loop, we already know it's closer to the floor than the surface of the sphere. Therefore almost everything will seem like it's in the shadow, causing the screen to go black. We need to choose a value very close to p during the second ray march step, so we don't have this issue occurring.

A common approach is to add the surface normal, multiplied by a tiny value, to the value of p, so we get a neighboring point. We will use the PRECISION variable as the tiny value that will slightly nudge p to a neighboring point.

glsl
Copied! ⭐️
vec3 newRayOrigin = p + normal * PRECISION;

When you run the code, you should now see a shadow appear below the sphere. However, there's a strange artifact near the center of the sphere.

Canvas with a black background and white sphere in the center. The sphere is illuminated from the top-right, causing the sphere to appear dark on the bottom-left. The sphere sits on top of a white floor. The light is casting a shadow behind the sphere. A strange artifact is in the middle of the sphere.

We can multiply the precision value by two to make it go away.

glsl
Copied! ⭐️
vec3 newRayOrigin = p + normal * PRECISION * 2.;

Canvas with a black background and white sphere in the center. The sphere is illuminated from the top-right, causing the sphere to appear dark on the bottom-left. The sphere sits on top of a white floor. The light is casting a shadow behind the sphere. The strange artifact is now gone.

When adding shadows to your scene, you may need to keep adjusting newRayOrigin by multiplying by different factors to see what works. Making realistic shadows is not an easy task, and you may find yourself playing around with values until it looks good.

You finished code should look like the following:

glsl
Copied! ⭐️
const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;
const float EPSILON = 0.0005;

float sdSphere(vec3 p, float r, vec3 offset)
{
  return length(p - offset) - r;
}

float sdFloor(vec3 p) {
  return p.y + 1.;
}

float scene(vec3 p) {
  float co = min(sdSphere(p, 1., vec3(0, 0, -2)), sdFloor(p));
  return co;
}

float rayMarch(vec3 ro, vec3 rd) {
  float depth = MIN_DIST;
  float d; // distance ray has travelled

  for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
    vec3 p = ro + depth * rd;
    d = scene(p);
    depth += d;
    if (d < PRECISION || depth > MAX_DIST) break;
  }

  d = depth;

  return d;
}

vec3 calcNormal(in vec3 p) {
    vec2 e = vec2(1, -1) * EPSILON;
    return normalize(
      e.xyy * scene(p + e.xyy) +
      e.yyx * scene(p + e.yyx) +
      e.yxy * scene(p + e.yxy) +
      e.xxx * scene(p + e.xxx));
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
  vec3 backgroundColor = vec3(0);

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
  vec3 rd = normalize(vec3(uv, -1)); // ray direction

  float sd = rayMarch(ro, rd); // signed distance value to closest object

  if (sd > MAX_DIST) {
    col = backgroundColor; // ray didn't hit anything
  } else {
    vec3 p = ro + rd * sd; // point discovered from ray marching
    vec3 normal = calcNormal(p); // surface normal

    vec3 lightPosition = vec3(cos(iTime), 2, sin(iTime));
    vec3 lightDirection = normalize(lightPosition - p);

    float dif = clamp(dot(normal, lightDirection), 0., 1.); // diffuse reflection clamped between zero and one

    vec3 newRayOrigin = p + normal * PRECISION * 2.;
    float shadowRayLength = rayMarch(newRayOrigin, lightDirection);
    if (shadowRayLength < length(lightPosition - newRayOrigin)) dif *= 0.;

    col = vec3(dif);
  }

  fragColor = vec4(col, 1.0);
}

Adding Shadows to Colored Scenes

Using the same technique, we can apply shadows to the colored scenes we've been working with in the past few tutorials.

glsl
Copied! ⭐️
const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;
const float EPSILON = 0.0005;

struct Surface {
    float sd; // signed distance value
    vec3 col; // color
};

Surface sdFloor(vec3 p, vec3 col) {
  float d = p.y + 1.;
  return Surface(d, col);
}

Surface sdSphere(vec3 p, float r, vec3 offset, vec3 col) {
  p = (p - offset);
  float d = length(p) - r;
  return Surface(d, col);
}

Surface opUnion(Surface obj1, Surface obj2) {
  if (obj2.sd < obj1.sd) return obj2;
  return obj1;
}

Surface scene(vec3 p) {
  vec3 floorColor = vec3(0.1 + 0.7 * mod(floor(p.x) + floor(p.z), 2.0));
  Surface co = sdFloor(p, floorColor);
  co = opUnion(co, sdSphere(p, 1., vec3(0, 0, -2), vec3(1, 0, 0)));
  return co;
}

Surface rayMarch(vec3 ro, vec3 rd) {
  float depth = MIN_DIST;
  Surface co; // closest object

  for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
    vec3 p = ro + depth * rd;
    co = scene(p);
    depth += co.sd;
    if (co.sd < PRECISION || depth > MAX_DIST) break;
  }

  co.sd = depth;

  return co;
}

vec3 calcNormal(in vec3 p) {
    vec2 e = vec2(1, -1) * EPSILON;
    return normalize(
      e.xyy * scene(p + e.xyy).sd +
      e.yyx * scene(p + e.yyx).sd +
      e.yxy * scene(p + e.yxy).sd +
      e.xxx * scene(p + e.xxx).sd);
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
  vec3 backgroundColor = vec3(0.835, 1, 1);

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
  vec3 rd = normalize(vec3(uv, -1)); // ray direction

  Surface co = rayMarch(ro, rd); // closest object

  if (co.sd > MAX_DIST) {
    col = backgroundColor; // ray didn't hit anything
  } else {
    vec3 p = ro + rd * co.sd; // point discovered from ray marching
    vec3 normal = calcNormal(p);

    vec3 lightPosition = vec3(cos(iTime), 2, sin(iTime));
    vec3 lightDirection = normalize(lightPosition - p);

    float dif = clamp(dot(normal, lightDirection), 0., 1.); // diffuse reflection

    vec3 newRayOrigin = p + normal * PRECISION * 2.;
    float shadowRayLength = rayMarch(newRayOrigin, lightDirection).sd; // cast shadow ray to the light source
    if (shadowRayLength < length(lightPosition - newRayOrigin)) dif *= 0.0; // shadow

    col = dif * co.col;

  }

  fragColor = vec4(col, 1.0); // Output to screen
}

If you run this code, you should see a red sphere with a moving light source (and therefore "moving" shadow), but the entire scene appears a bit too dark.

Shadertoy canvas with light blue background color and a red sphere in the center. A light is casting a shadow behind the sphere. A tiled checkered floor is behind the sphere and goes from the middle of the canvas to the bottom. The tiles alternates between black and white.

Gamma Correction

We can apply a bit of gamma correction to make the darker colors brighter. We'll add this line right before we output the final color to the screen.

glsl
Copied! ⭐️
col = pow(col, vec3(1.0/2.2)); // Gamma correction

Your mainImage function should now look like the following:

glsl
Copied! ⭐️
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
  vec3 backgroundColor = vec3(0.835, 1, 1);

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
  vec3 rd = normalize(vec3(uv, -1)); // ray direction

  Surface co = rayMarch(ro, rd); // closest object

  if (co.sd > MAX_DIST) {
    col = backgroundColor; // ray didn't hit anything
  } else {
    vec3 p = ro + rd * co.sd; // point discovered from ray marching
    vec3 normal = calcNormal(p);

    vec3 lightPosition = vec3(cos(iTime), 2, sin(iTime));
    vec3 lightDirection = normalize(lightPosition - p);

    float dif = clamp(dot(normal, lightDirection), 0., 1.); // diffuse reflection

    vec3 newRayOrigin = p + normal * PRECISION * 2.;
    float shadowRayLength = rayMarch(newRayOrigin, lightDirection).sd; // cast shadow ray to the light source
    if (shadowRayLength < length(lightPosition - newRayOrigin)) dif *= 0.; // shadow

    col = dif * co.col;

  }

  col = pow(col, vec3(1.0/2.2)); // Gamma correction
  fragColor = vec4(col, 1.0); // Output to screen
}

When you run the code, you should see the entire scene appear brighter.

Shadertoy canvas with light blue background color and a red sphere in the center. A light is casting a shadow behind the sphere. A tiled checkered floor is behind the sphere and goes from the middle of the canvas to the bottom. With gamma correction applied, the scene appears brighter, and the tiles alternates between dark gray and light gray.

The shadow seems a bit too dark still. We can lighten it by adjusting how much we should scale the diffuse reflection by. Currently, we're setting the diffuse reflection color of the floor and sphere to zero when we calculate which points lie in the shadow.

We can change the "scaling factor" to 0.2 instead:

glsl
Copied! ⭐️
if (shadowRayLength < length(lightPosition - newRayOrigin)) dif *= 0.2; // shadow

Now the shadow looks a bit better, and you can see the diffuse color of the floor through the shadow.

Shadertoy canvas with light blue background color and a red sphere in the center. The shadow behind the sphere is brighter and appears translucent. A tiled checkered floor is behind the sphere and goes from the middle of the canvas to the bottom. With gamma correction applied, the scene appears brighter, and the tiles alternates between dark gray and light gray.

Soft Shadows

In real life, shadows tend to have multiple parts, including an umbra, penumbra, and antumbra. We can add a "soft shadow" that tries to copy shadows in real life by using algorithms found on Inigo Quilez's website.

Below is an implementation of the "soft shadow" function found in the popular Shadertoy shader, Raymarching Primitives Commented. I have made adjustments to make it compatible with our code.

glsl
Copied! ⭐️
float softShadow(vec3 ro, vec3 rd, float mint, float tmax) {
  float res = 1.0;
  float t = mint;

  for(int i = 0; i < 16; i++) {
    float h = scene(ro + rd * t).sd;
      res = min(res, 8.0*h/t);
      t += clamp(h, 0.02, 0.10);
      if(h < 0.001 || t > tmax) break;
  }

  return clamp( res, 0.0, 1.0 );
}

In our mainImage function, we can remove the "hard shadow" code and replace it with the "soft shadow" implementation.

glsl
Copied! ⭐️
float softShadow = clamp(softShadow(p, lightDirection, 0.02, 2.5), 0.1, 1.0);
col = dif * co.col * softShadow;

We can clamp the shadow between 0.1 and 1.0 to lighten the shadow a bit, so it's not too dark.

Shadertoy canvas with light blue background color and a red sphere in the center. There is a soft shadow behind the sphere that appears translucent and has soft edges. A tiled checkered floor is behind the sphere and goes from the middle of the canvas to the bottom. With gamma correction applied, the scene appears brighter, and the tiles alternates between dark gray and light gray.

Notice the edges of the soft shadow. It's a smoother transition between the shadow and normal floor color.

Applying Fog

You may have noticed that the color of the sphere not facing the light appears too dark still. We can attempt to lighten it by adding 0.5 to the diffuse reflection, dif.

glsl
Copied! ⭐️
float dif = clamp(dot(normal, lightDirection), 0., 1.) + 0.5; // diffuse reflection

When you run the code, you'll see that the sphere appears a bit brighter, but the back of the floor in the distance looks kinda weird.

Shadertoy canvas with light blue background color and a red sphere in the center. There is a soft shadow behind the sphere that appears translucent and has soft edges. A tiled checkered floor is behind the sphere and goes from the middle of the canvas to the bottom. With gamma correction applied, the scene appears brighter, and the tiles alternates between dark gray and light gray. The entire scene appears brighter with 0.5 added to the diffuse reflection value.

You may commonly see people hide any irregularities of the background by applying fog. Let's apply fog right before the gamma correction.

glsl
Copied! ⭐️
col = mix(col, backgroundColor, 1.0 - exp(-0.0002 * co.sd * co.sd * co.sd)); // fog

Now, the scene looks a bit more realistic!

Shadertoy canvas with light blue background color and a red sphere in the center. There is a soft shadow behind the sphere that appears translucent and has soft edges. A tiled checkered floor is behind the sphere and goes from the middle of the canvas to the bottom. With gamma correction applied, the scene appears brighter, and the tiles alternates between dark gray and light gray. It looks like there is a fog applied to the back of the floor. The color of the fog matches the light blue background color.

You can find the finished code below:

glsl
Copied! ⭐️
/* The MIT License
** Copyright © 2022 Nathan Vaughn
** Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
**
** Example on how to create a shadow, apply gamma correction, and apply fog.
** Visit my tutorial to learn more: https://inspirnathan.com/posts/63-shadertoy-tutorial-part-16/
**
** Resources/Credit:
** Primitive SDFs: https://iquilezles.org/articles/distfunctions
** Soft Shadows: https://iquilezles.org/articles/rmshadows/
*/

const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;
const float EPSILON = 0.0005;

struct Surface {
    float sd; // signed distance value
    vec3 col; // color
};

Surface sdFloor(vec3 p, vec3 col) {
  float d = p.y + 1.;
  return Surface(d, col);
}

Surface sdSphere(vec3 p, float r, vec3 offset, vec3 col) {
  p = (p - offset);
  float d = length(p) - r;
  return Surface(d, col);
}

Surface opUnion(Surface obj1, Surface obj2) {
  if (obj2.sd < obj1.sd) return obj2;
  return obj1;
}

Surface scene(vec3 p) {
  vec3 floorColor = vec3(0.1 + 0.7*mod(floor(p.x) + floor(p.z), 2.0));
  Surface co = sdFloor(p, floorColor);
  co = opUnion(co, sdSphere(p, 1., vec3(0, 0, -2), vec3(1, 0, 0)));
  return co;
}

Surface rayMarch(vec3 ro, vec3 rd) {
  float depth = MIN_DIST;
  Surface co; // closest object

  for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
    vec3 p = ro + depth * rd;
    co = scene(p);
    depth += co.sd;
    if (co.sd < PRECISION || depth > MAX_DIST) break;
  }

  co.sd = depth;

  return co;
}

vec3 calcNormal(in vec3 p) {
    vec2 e = vec2(1, -1) * EPSILON;
    return normalize(
      e.xyy * scene(p + e.xyy).sd +
      e.yyx * scene(p + e.yyx).sd +
      e.yxy * scene(p + e.yxy).sd +
      e.xxx * scene(p + e.xxx).sd);
}

float softShadow(vec3 ro, vec3 rd, float mint, float tmax) {
  float res = 1.0;
  float t = mint;

  for(int i = 0; i < 16; i++) {
    float h = scene(ro + rd * t).sd;
      res = min(res, 8.0*h/t);
      t += clamp(h, 0.02, 0.10);
      if(h < 0.001 || t > tmax) break;
  }

  return clamp( res, 0.0, 1.0 );
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
  vec3 backgroundColor = vec3(0.835, 1, 1);

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
  vec3 rd = normalize(vec3(uv, -1)); // ray direction

  Surface co = rayMarch(ro, rd); // closest object

  if (co.sd > MAX_DIST) {
    col = backgroundColor; // ray didn't hit anything
  } else {
    vec3 p = ro + rd * co.sd; // point discovered from ray marching
    vec3 normal = calcNormal(p);

    vec3 lightPosition = vec3(cos(iTime), 2, sin(iTime));
    vec3 lightDirection = normalize(lightPosition - p);

    float dif = clamp(dot(normal, lightDirection), 0., 1.) + 0.5; // diffuse reflection

    float softShadow = clamp(softShadow(p, lightDirection, 0.02, 2.5), 0.1, 1.0);

    col = dif * co.col * softShadow;
  }

  col = mix(col, backgroundColor, 1.0 - exp(-0.0002 * co.sd * co.sd * co.sd)); // fog
  col = pow(col, vec3(1.0/2.2)); // Gamma correction
  fragColor = vec4(col, 1.0); // Output to screen
}

Conclusion

In this tutorial, you learned how to apply "hard shadows," "soft shadows," gamma correction, and fog. As we've seen, adding shadows can be a bit tricky. In this tutorial, I discussed how to add shadows to a scene with only diffuse reflection, but the same principles apply to scenes with other types of reflections as well. You need to make sure you understand how your scene is lit and anticipate how shadows will impact the colors in your scene. What I've mentioned in this article is just one way of adding shadows to your scene. As you dive into the code of various shaders on Shadertoy, you'll find completely different ways lighting is set up in the scene.

Resources