Chapters

Hide chapters

Metal by Tutorials

Second Edition · iOS 13 · Swift 5.1 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section I: The Player

Section 1: 8 chapters
Show chapters Hide chapters

Section III: The Effects

Section 3: 10 chapters
Show chapters Hide chapters

19. Advanced Shadows
Written by Marius Horga

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

Shadows and lighting are important topics in Computer Graphics. In Chapter 14, “Multipass & Deferred Rendering,” you learned how to render basic shadows in two passes: one to render from the light source location to get a shadow map of the scene; and one to render from the camera location to incorporate the shadow map into the rendered scene.

By the end of this chapter, you’ll be able to create various shadow types.

Along the way, you’ll work through:

  • Hard shadows.
  • Soft shadows.
  • Ambient Occlusion.
  • Percentage Closer Filtering.

Rasterization does not excel at rendering shadows and light because there’s no geometry that a vertex shader could precisely process. So now you’ll learn how to do it differently.

Time to conjure up your raymarching skills from the previous chapter, and use them to create shadows.

Hard shadows

In this section, you’ll create shadows using raymarching instead of using a shadow map like you did in Chapter 14, “Multipass & Deferred Rendering”. A shadow map is a non real-time tool that requires you to bake the shadows in a previous pass.

With raymarching, you’re making use of signed distance fields (SDF). An SDF is a real-time tool that provides you with the precise distance to a boundary.

This makes calculating shadows easy as they come for “free”, meaning that all of the information you need to compute shadows already exists and is available because of the SDF.

The principle is common to both rendering methods: If there’s an occluder between the light source and the object, the object is in the shadow; otherwise, it’s lit.

Great! Time to put that wisdom down in code.

Open the starter playground, and select the Hard Shadows playground page. In the Resources folder, open Shaders.metal.

Add a new struct, so you can create rectangle objects:

struct Rectangle {
  float2 center;
  float2 size;
};

Next, add a function that gets the distance from any point on the screen to a given rectangle boundary. If its return value is positive, a given point is outside the rectangle; all other values are inside the rectangle.

float distanceToRectangle(float2 point, Rectangle rectangle) {
  // 1
  float2 distances = 
      abs(point - rectangle.center) - rectangle.size / 2;
  return
    // 2
    all(sign(distances) > 0)
    ? length(distances)
    // 3
    : max(distances.x, distances.y);
}

Going through the code:

  1. Offset the current point coordinates by the given rectangle center. Then, get the symmetrical coordinates of the given point by using the abs() function, and calculate the signed distance to each of the two edges.
  2. If those two distances are positive, then you’ll need to calculate the distance to the corner.
  3. Otherwise, return the distance to the closer edge.

Note: In this case, rectangle.size / 2 is the distance from the rectangle center to an edge, similar to what a radius is for a circle.

Next is a handy function that lets you subtract one shape from another. Think about Set Theory from back in your school days.

Note: You can find out more about Set Theory here: https://en.wikipedia.org/wiki/Complement_(set_theory)#Relative_complement

Add this function to Shaders.metal:

float differenceOperator(float d0, float d1) {
  return max(d0, -d1);
}

This yields a value that can be used to calculate the difference result from the previous image, where the second shape is subtracted from the first. The result of this function is a signed distance to a compound shape boundary. It’ll only be negative when inside the first shape, but outside the second.

Next, design a basic scene:

float distanceToScene(float2 point) {
  // 1
  Rectangle r1 = Rectangle{float2(0.0), float2(0.3)};
  float d2r1 = distanceToRectangle(point, r1);
  // 2
  Rectangle r2 = Rectangle{float2(0.05), float2(0.04)};
  float2 mod = point - 0.1 * floor(point / 0.1);
  float d2r2 = distanceToRectangle(mod, r2);
  // 3
  float diff = differenceOperator(d2r1, d2r2);
  return diff;
}

Going through the code:

  1. Create a rectangle, and get the distance to it.
  2. Create a second, smaller rectangle, and get the distance to it. The difference here is that the area is repeated every 0.1 points — which is a 10th of the size of the scene — using a modulo operation. See the note below.
  3. Subtract the second repeated rectangle from the first rectangle, and return the resulting distance.

Note: The fmod() function in MSL uses trunc() instead of floor(), so you create a custom mod operator because you also want to use the negative values. You use the GLSL specification for mod() which is x - y * floor(x/y). You need the modulus operator to draw many small rectangles mirrored with a distance of 0.1 from each other.

Finally, use these functions to generate a shape that looks a bit like a fence or a trellis.

At the end of the kernel, replace the color assignment with:

float d2scene = distanceToScene(uv);
bool inside = d2scene < 0.0;
float4 color = inside ? float4(0.8,0.5,0.5,1.0) :
  float4(0.9,0.9,0.8,1.0);

Run the playground, and you’ll see something like this:

For shadows to work, you need to:

  1. Get the distance to the light.
  2. Know the light direction.
  3. Step in that direction until you either reach the light or hit an object.

Above the last line in the kernel, add this:

float2 lightPos = 2.8 * float2(sin(time), cos(time));
float dist2light = length(lightPos - uv);
color *= max(0.3, 2.0 - dist2light);

First, you create a light at position lightPos, which you’ll animate just for fun using the timer uniform that you passed from the host (API) code.

Then, you get the distance from any given point to lightPos, and you color the pixel based on the distance from the light — but only if it’s not inside an object. You make the color lighter when closer to the light, and darker when further away with the max() function to avoid negative values for the brightness of the light.

Run the playground, and you’ll see a similar image. Notice the moving light.

You just took care of the first two steps: light position and direction. Now it’s time to handle the third one: the shadow function.

Add this code above the kernel function:

float getShadow(float2 point, float2 lightPos) {
  // 1
  float2 lightDir = lightPos - point;
  // 2
  for (float lerp = 0; lerp < 1; lerp += 1 / 300.0) {
    // 3
    float2 currentPoint = point + lightDir * lerp;
    // 4
    float d2scene = distanceToScene(currentPoint);
    if (d2scene <= 0.0) { return 0.0; }
  }
  return 1.0;
}

Going through the code:

  1. Get a vector from the point to the light.
  2. Use a loop to divide the vector into many smaller steps. If you don’t use enough steps, you might jump past the object, leaving holes in the shadow.
  3. Calculate how far along the ray you are currently, and move along the ray by this lerp distance to find the point in space you are sampling.
  4. See how far you are from the surface at that point, and then test if you’re inside an object. If yes, return 0, because you’re in the shadow; otherwise, return 1, because the ray didn’t hit an object.

It’s finally time to see some shadows.

Above the last line in the kernel, add this:

float shadow = getShadow(uv, lightPos);
color *= 2;
color *= shadow * .5 + .5;

A value of 2 is used here to enhance the light brightness and the effect of the shadow. Feel free to play with various values and notice how changes affect it.

Run the playground, and you’ll see something like this:

The shadow loop goes in 1-pixel steps, which is not good performance-wise. You can improve that a little by moving along in big steps, provided you don’t step past the object. You can safely step in any direction by the distance to the scene instead of a fixed step size, and this way you skip over empty areas fast.

When finding the distance to the nearest surface, you don’t know what direction the surface is in, but you have the radius of a circle that intersects with the nearest part of the scene. You can trace along the ray, always stepping to the edge of the circle until the circle radius becomes 0, which means it intersected a surface.

Replace the contents of the getShadow() function with this:

float2 lightDir = normalize(lightPos - point);
float shadowDistance = 0.75;
float distAlongRay = 0.0;
for (float i = 0; i < 80; i++) {
  float2 currentPoint = point + lightDir * distAlongRay;
  float d2scene = distanceToScene(currentPoint);
  if (d2scene <= 0.001) { return 0.0; }
  distAlongRay += d2scene;
  if (distAlongRay > shadowDistance) { break; }
}
return 1.0;

Run the playground again, and the shadow is now faster and looks more accurate.

In raymarching, the size of the step depends on the distance from the surface. In empty areas, it jumps big distances, and it can travel a long way. However, if it’s parallel to the object and close to it, the distance is always small, so the jump size is also small. That means the ray travels very slowly. With a fixed number of steps, it doesn’t travel far. With 80 or more steps you should be safe from getting holes in the shadow.

Congratulations, you made your first hard shadow. Next, you’ll be looking into soft shadows. Soft shadows tend to be more realistic and thus, better looking.

Soft shadows

Shadows are not only black or white, and objects aren’t just in shadow or not. Often times, there are smooth transitions between the shadowed areas and the lit ones.

struct Ray {
  float3 origin;
  float3 direction;
};

struct Sphere {
  float3 center;
  float radius;
};

struct Plane {
  float yCoord;
};

struct Light {
  float3 position;
};
float distToSphere(Ray ray, Sphere s) {
  return length(ray.origin - s.center) - s.radius;
}

float distToPlane(Ray ray, Plane plane) {
  return ray.origin.y - plane.yCoord;
}

float differenceOp(float d0, float d1) {
  return max(d0, -d1);
}

float unionOp(float d0, float d1) {
  return min(d0, d1);
}

float distToScene(Ray r) {
  // 1
  Plane p = Plane{0.0};
  float d2p = distToPlane(r, p);
  // 2
  Sphere s1 = Sphere{float3(2.0), 2.0};
  Sphere s2 = Sphere{float3(0.0, 4.0, 0.0), 4.0};
  Sphere s3 = Sphere{float3(0.0, 4.0, 0.0), 3.9};
  // 3
  Ray repeatRay = r;
  repeatRay.origin = fract(r.origin / 4.0) * 4.0;
  // 4
  float d2s1 = distToSphere(repeatRay, s1);
  float d2s2 = distToSphere(r, s2);
  float d2s3 = distToSphere(r, s3);
  // 5
  float dist = differenceOp(d2s2, d2s3);
  dist = differenceOp(dist, d2s1);
  dist = unionOp(d2p, dist);
  return dist;
}
float3 getNormal(Ray ray) {
  float2 eps = float2(0.001, 0.0);
  float3 n = float3(
    distToScene(Ray{ray.origin + eps.xyy, ray.direction}) -
    distToScene(Ray{ray.origin - eps.xyy, ray.direction}),
    distToScene(Ray{ray.origin + eps.yxy, ray.direction}) -
    distToScene(Ray{ray.origin - eps.yxy, ray.direction}),
    distToScene(Ray{ray.origin + eps.yyx, ray.direction}) -
    distToScene(Ray{ray.origin - eps.yyx, ray.direction}));
  return normalize(n);
}
// 1
Ray ray = Ray{float3(0., 4., -12), normalize(float3(uv, 1.))};
// 2
for (int i = 0; i < 100; i++) {
  // 3
  float dist = distToScene(ray);
  // 4
  if (dist < 0.001) {
    col = float3(1.0);
    break;
  }
  // 5
  ray.origin += ray.direction * dist;
}
// 6
float3 n = getNormal(ray);
output.write(float4(col * n, 1.0), gid);

float lighting(Ray ray, float3 normal, Light light) {
  // 1
  float3 lightRay = normalize(light.position - ray.origin);
  // 2
  float diffuse = max(0.0, dot(normal, lightRay));
  // 3
  float3 reflectedRay = reflect(ray.direction, normal);
  float specular = max(0.0, dot(reflectedRay, lightRay));
  // 4
  specular = pow(specular, 200.0);
  return diffuse + specular;
}
Light light = Light{float3(sin(time) * 10.0, 5.0, 
                           cos(time) * 10.0)};
float l = lighting(ray, n, light);
output.write(float4(col * l, 1.0), gid);

float shadow(Ray ray, Light light) {
  float3 lightDir = light.position - ray.origin;
  float lightDist = length(lightDir);
  lightDir = normalize(lightDir);
  float distAlongRay = 0.01;
  for (int i = 0; i < 100; i++) {
    Ray lightRay = Ray{ray.origin + lightDir * distAlongRay, 
                       lightDir};
    float dist = distToScene(lightRay);
    if (dist < 0.001) { return 0.0; }
    distAlongRay += dist;
    if (distAlongRay > lightDist) { break; }
  }
  return 1.0;
}
float s = shadow(ray, light);
output.write(float4(col * l * s, 1.0), gid);

// 1
float shadow(Ray ray, float k, Light l) {
  float3 lightDir = l.position - ray.origin;
  float lightDist = length(lightDir);
  lightDir = normalize(lightDir);
  // 2
  float light = 1.0;
  float eps = 0.1;
  // 3
  float distAlongRay = eps * 2.0;
  for (int i=0; i<100; i++) {
    Ray lightRay = Ray{ray.origin + lightDir * distAlongRay, 
                       lightDir};
    float dist = distToScene(lightRay);
    // 4
    light = min(light, 1.0 - (eps - dist) / eps);
    // 5
    distAlongRay += dist * 0.5;
    eps += dist * k;
    // 6
    if (distAlongRay > lightDist) { break; }
  }
  return max(light, 0.0);
}
// 1
bool hit = false;
for (int i = 0; i < 200; i++) {
  float dist = distToScene(ray);
  if (dist < 0.001) {
    hit = true;
    break;
  }
  ray.origin += ray.direction * dist;
}
// 2
col = float3(1.0);
// 3
if (!hit) {
  col = float3(0.8, 0.5, 0.5);
} else {
  float3 n = getNormal(ray);
  Light light = Light{float3(sin(time) * 10.0, 5.0, 
                             cos(time) * 10.0)};
  float l = lighting(ray, n, light);
  float s = shadow(ray, 0.3, light);
  col = col * l * s;
}
// 4
Light light2 = Light{float3(0.0, 5.0, -15.0)};
float3 lightRay = normalize(light2.position - ray.origin);
float fl = max(0.0, dot(getNormal(ray), lightRay) / 2.0);
col = col + fl;
output.write(float4(col, 1.0), gid);

Ambient occlusion

Ambient occlusion (AO) is a global shading technique, unlike the Phong local shading technique you learned about in Chapter 5, “Lighting Fundamentals”. AO is used to calculate how exposed each point in a scene is to ambient lighting which is determined by the neighboring geometry in the scene.

struct Box {
  float3 center;
  float size;
};
float distToBox(Ray r, Box b) {
  float3 d = abs(r.origin - b.center) - float3(b.size);
  return min(max(d.x, max(d.y, d.z)), 0.0) 
              + length(max(d, 0.0));
}
// 1
Sphere s1 = Sphere{float3(0.0, 0.5, 0.0), 8.0};
Sphere s2 = Sphere{float3(0.0, 0.5, 0.0), 6.0};
Sphere s3 = Sphere{float3(10., -5., -10.), 15.0};
float d2s1 = distToSphere(r, s1);
float d2s2 = distToSphere(r, s2);
float d2s3 = distToSphere(r, s3);
// 2
float dist = differenceOp(d2s1, d2s2);
dist = differenceOp(dist, d2s3);
// 3
Box b = Box{float3(1., 1., -4.), 1.};
float dtb = distToBox(r, b);
dist = unionOp(dist, dtb);
dist = unionOp(d2p, dist);
return dist;

float ao(float3 pos, float3 n) {
    return n.y * 0.5 + 0.5;
}
col = col * l * s;
float o = ao(ray.origin, n);
col = col * o;
col = col + fl;

// 1
float eps = 0.01;
// 2
pos += n * eps * 2.0;
// 3
float occlusion = 0.0;
for (float i = 1.0; i < 10.0; i++) {
  // 4
  float d = distToScene(Ray{pos, float3(0)});
  float coneWidth = 2.0 * eps;
  // 5
  float occlusionAmount = max(coneWidth - d, 0.);
  // 6
  float occlusionFactor = occlusionAmount / coneWidth;
  // 7
  occlusionFactor *= 1.0 - (i / 10.0);
  // 8
  occlusion = max(occlusion, occlusionFactor);
  // 9
  eps *= 2.0;
  pos += n * eps;
}
// 10
return max(0.0, 1.0 - occlusion);

struct Camera {
  float3 position;
  Ray ray{float3(0), float3(0)};
  float rayDivergence;
};
Camera setupCam(float3 pos, float3 target, 
                float fov, float2 uv, int x) {
  // 1
  uv *= fov;
  // 2
  float3 cw = normalize(target - pos);
  // 3
  float3 cp = float3(0.0, 1.0, 0.0);
  // 4
  float3 cu = normalize(cross(cw, cp));
  // 5
  float3 cv = normalize(cross(cu, cw));
  // 6
  Ray ray = Ray{pos, 
                normalize(uv.x * cu + uv.y * cv + 0.5 * cw)};
  // 7
  Camera cam = Camera{pos, ray, fov / float(x)};
  return cam;
}
Ray ray = Ray{float3(0., 4., -12), normalize(float3(uv, 1.))};
float3 camPos = float3(sin(time) * 10., 3., cos(time) * 10.);
Camera cam = setupCam(camPos, float3(0), 1.25, uv, width);
Ray ray = cam.ray;

Percentage closer filtering

If you remember from Chapter 14, “Multipass & Deferred Rendering”, you implemented a shadow map.

float shadow_sample = shadowTexture.sample(s, xy);
float current_sample = in.shadowPosition.z / in.shadowPosition.w;
if (current_sample > shadow_sample ) {
  diffuseColor *= 0.5;
}
return float4(diffuseColor, 1);
// 1
const int neighborWidth = 3;
const float neighbors = (neighborWidth * 2.0 + 1.0) * 
                        (neighborWidth * 2.0 + 1.0);
// 2
float mapSize = 4096;
float texelSize = 1.0 / mapSize;
float total = 0.0;
for (int x = -neighborWidth; x <= neighborWidth; x++) {
  for (int y = -neighborWidth; y <= neighborWidth; y++) {
    // 3
    float shadow_sample = shadowTexture.sample(
                           s, xy + float2(x, y) * texelSize);
    float current_sample = 
         in.shadowPosition.z / in.shadowPosition.w;
    if (current_sample > shadow_sample ) {
      total += 1.0;
    }
  }
}
// 4
total /= neighbors;
float lightFactor = 1.0 - (total * in.shadowPosition.w);
return float4(diffuseColor * lightFactor, 1);

Where to go from here?

This chapter took you through a few advanced shadow techniques, specifically:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now