Ray Marching Soft Shadows in 2D – Ryan Kaplan

Disclaimer: The demos on this page use WebGL features that are not available on some mobile devices.

A few weeks ago I tweeted a video of a toy graphics project (below). It didn’t happen, but a lot of people liked it which was surprising and fun! Some people asked how it works, so that’s what this post is about.

Under the hood it uses something called distance zone. The distance field is an image like the one below that tells you how far each pixel is from your shape. Light gray pixels are closer to the shape and dark gray pixels are farther away from it.

When the demo starts, it draws some text on a 2D canvas and creates a distance area of ​​it. It uses a library I wrote that generates distance fields really fast. If you want to know how the library works, I wrote about it here.

Our lighting scheme works like this: When processing a particular pixel we consider the beam of light traveling from it, like…

If the ray intersects a glyph, the pixel we are shading must be in shadow because there is something between it and the light.

The easiest way to check this would be to move along the ray in 1px increments, starting at the pixel we are shading and ending at the light, repeatedly asking the distance field if we are at distance 0 from a shape. This will work, but it will be really slow.

We could choose some specific length like 30px and move in increments of that size, but then we risk jumping to glyphs smaller than 30px. We may think we are not in the shadows when we should be.

The main idea of ​​ray marching is this: the distance field tells you how far you are from the nearest glyph. You can safely proceed that distance with your ray without missing any glyphs.

Let’s go through an example. We start as in the picture above and ask the distance field how far we are from a glyph. In this case it turns out that the answer is 95px (picture on the left). This means we can rotate 95px along our ray without missing anything!

Now we are a little closer to the light. We repeat the process until we reach the ascender of B! If the B glyph wasn’t there, we would have kept going until we reached the light.

Below is a demo that shows the ray marching steps for a given pixel. The red box is the pixel we’re shading, and each circle along the ray represents the marching phase of the ray and the distance from the scene at that phase.

Try dragging the light and pixels around to create an intuition for it.

Below is GLSL to implement this technique. This assumes that you have defined a function getDistance It samples the distance field.

vec2 rayOrigin = ...;
vec2 rayDirection = ...;

float rayProgress = 0;
while (true) {
  if (rayProgress > distance(rayOrigin, lightPosition)) {
    // We hit the light! This pixel is not in shadow.
    return 1.;
  }

  float sceneDist = getDistance(
    rayOrigin + rayProgress * rayDirection);
  if (sceneDist <= 0.) {
    // We hit a shape! This pixel is in shadow.
    return 0.;
  }

  rayProgress += sceneDist;
}

It turns out that processing some pixels is really expensive. So in practice we use a for-loop instead of a while loop – that way we are saved if we have taken too many steps. A common “slow case” in ray marching occurs when a ray is parallel to the edge of a figure in the scene…

The approach I’ve described so far will give you a view that looks like the one below.

It’s nice, but the shadows are sharp which doesn’t look very good. This is what the shadows look like in the demo…

One big disclaimer is that they are not physically realistic! Actual shadows look like hard shadows where the edges have been blurred. This approach does something different: all the pixels that were previously in shadow are still completely in shadow. We’ve just added a shadow of partially shaded pixels around them.

The good thing is that they are beautiful and fast to compute, and that’s all I care about! His calculations involve three “rules”.

rule 1: The closer the ray gets to intersecting a shape, the more its pixels should be shaded. In the image below there are two identical rays (their distance from the figure depicted in yellow and green). We want the one that gets closest to touching the corner to be more shaded.

It is cheaper to calculate because of the variables sceneDist Tells us how far we are from the nearest shape at each ray marching step. smallest value of so sceneDist The yellow and green lines in the image above are a good estimate of all the steps.

Rule 2: If the pixel we’re shading is far away from the point where it almost intersects a shape, we’ll want the shadow to be more spread out.

Consider two pixels along the above ray. One is closer to the near-intersection and is lighter (its distance is the green line). The other is more distant and darker (its distance is the yellow line). In general: the further away a pixel is from its approximate intersection, the more “in shadow” we should make it.

It is cheaper to calculate because of the variables rayProgress The green and yellow lines in the above image have lengths.

So: we returned first 1.0 For pixels that were not in shadow. To apply rules 1 and 2, we calculate sceneDist / rayProgress At each ray marching step, keep track of its minimum value, and return it instead.

vec2 rayOrigin = ...;
vec2 rayDirection = ...;
float rayProgress = 0.;
float stopAt = distance(samplePt, lightPosition);
float lightContribution = 1.;
for (int i = 0; i < 64; i++) {
  if (rayProgress > stopAt) {
    return lightContribution;
  }

  // `getDistance` samples our distance field texture.
  float sceneDist = getDistance(
    rayOrigin + rayProgress * rayDirection);
  if (sceneDist <= 0.) {
    // We hit a shape! This pixel is in shadow.
    return 0.;
  }

  lightContribution = min(
    lightContribution,
    sceneDist / rayProgress
  );

  rayProgress += sceneDist;
}

// Ray-marching took more than 64 steps!
return 0.;

This ratio seems magical to me because it does not correspond to any physical value. So let’s develop some intuition for it by wondering why it might take on particular values…

  • If sceneDist / rayProgress >= 1then either sceneDist is bigger or rayProgress is smaller (relative to each other). In the first case we are too far from any shape and we should not be in shadow, hence its lighter value. 1 Makes sense. In the latter case, the pixel we are shading is actually close to the object casting the shadow and the shadow is not yet obscured, hence its lighter value. 1 Makes sense.
  • the ratio is 0 only when sceneDist Is 0It corresponds to rays that intersect an object and whose pixels are in shadow,

And here’s a demo of what we have so far…

Rule #3 The simplest thing is this: the light gets weaker the further you move away from it.

Instead of returning the minimum value of sceneDist / rayProgress Literally, we multiply it by a distanceFactor which is 1 right next to the light, 0 It is very far away from it, and it gets quadratically smaller as you move away from it.

Overall, the code for the approach so far looks like this…

vec2 rayOrigin = ...;
vec2 rayDirection = ...;
float rayProgress = 0.;
float stopAt = distance(samplePt, lightPosition);
float lightContribution = 1.;
for (int i = 0; i < 64; i++) {
  if (rayProgress > stopAt) {
    // We hit the light!
    float LIGHT_RADIUS_PX = 800.;

    // fadeRatio is 1.0 next to the light and 0. at
    // LIGHT_RADIUS_PX away.
    float fadeRatio =
      1.0 - clamp(stopAt / LIGHT_RADIUS_PX, 0., 1.);

    // We'd like the light to fade off quadratically instead of
    // linearly.
    float distanceFactor = pow(fadeRatio, 2.);
    return lightContribution * distanceFactor;
  }

  // `getDistance` samples our distance field texture.
  float sceneDist = getDistance(rayOrigin + rayProgress * rayDirection);
  if (sceneDist <= 0.) {
    // We hit a shape! This pixel is in shadow.
    return 0.;
  }

  lightContribution = min(
    lightContribution,
    sceneDist / rayProgress
  );

  rayProgress += sceneDist;
}

// Ray-marching took more than 64 steps!
return 0.;

I’ve forgotten where I found this soft-shadow technique, but I certainly didn’t invent it. Inigo Quilez has a great post on this where he talks about using it in 3D.

Inigo’s post also talks about a gotcha with this approach that you may have noticed in the demo above: it causes banding artifacts. This is because Rule 1 assumes that the smallest value sceneDist At all stages there is a good estimate of the distance from the ray to the scene. This is not always true as we sometimes take very few ray marching steps.

So in my demo I use a better approximation that Inigo wrote about in his post. I also use another tactic that is more effective but less performant: moving instead of sceneDist On each ray marching step, I move something like this sceneDist * randomJitter Where? randomJitter is between 0 And 1,

This improves the approximation as we are adding more steps to our ray march. But we can go ahead and do this sceneDist * .3Random jitter ensures that pixels next to each other do not end up in the same band, This makes the result a bit grainy which is not good, But I think the banding looks better,,, This is one aspect of the demo I’m still not satisfied with, so if you have any ideas on how to improve it please let me know!

Overall there are some additional changes to my demo that I may write about in the future but that’s the core of it. Thanks for reading! If you have any questions or comments let me know on Twitter,

Thanks to Jessica Liu, Susan Wang, Matt Nichols, and Kenrick Riley for feedback on early drafts of this post! Also, if you enjoyed this post you might enjoy working with me in Figma!





<a href

Leave a Comment