Rabid Lion Games

 

In the last part of the series we saw how to ‘unwrap’ the rays of a point light onto a texture. In Part 3 we saw how to use blend states to collapse these unwrapped rays into an occlusion map, a 1D texture holding the distance of the closest shadow casting pixel to the light along each ray. In this part of the series we will see how to use that occlusion map to create a light map that can be used with the shader we created in Part 2 to create the final lit scene.

 

Creating the light map can be broadly split into the following steps:

1) Calculating the vector from the light to the pixel

2) Determining which ray the pixel lies on

3) Sampling the value stored in the cell of the occlusion map that corresponds to that ray

4) Using the sampled value to calculate the distance along that ray to the closest shadow casting pixel

5) Calculating what the value of the light map for that pixel would be assuming that it is not obscured by a shadow caster

6) Testing whether the pixel is closer to the light than the closest shadow caster on that ray (taking into account a small bias to avoid jagged edges being visible): if it is then store the value calculated in step 5. If not, store (0, 0, 0, 1).

 

Most of these steps are self explanatory, so we simply need to implement them in our shader, but one or two will need further explanation as and when we come to write the code for them.

 

The PointLight shader

First things first. Fire up your solution from last time and create a new .fx file in the Content project called ‘PointLight.fx’. As usual, delete the contents and add the usual stubb:

 

float4 PixelShaderFunction(float2 Texcoord : TEXCOORD0) : COLOR0
{

}

technique Technique1
{
   pass Pass1
   {
       PixelShader = compile ps_2_0 PixelShaderFunction();
   }
}

 

The first thing we need to do is calculate the screen coordinates (rather than texture coordinates) of the current pixel. To do that all we need to do is multiply the screen dimensions by the texture coordinates as we’ve done in previous parts of the series. Add the following to the top of the shader function:

 

float2 screenCoords = float2(ScreenDimensions.x
    * Texcoord.x, ScreenDimensions.y * Texcoord.y);

 

Obviously for this to work we’ll need to add a ScreenDimensions parameter to the top of the shader file:

 

float2 ScreenDimensions;

 

Now to get the vector from the light to the current pixel we simply subtract the light’s screen coordinates from the current pixel’s screen coordinates:

 

float2 lightToPix = screenCoords - LightPos;

 

And so we’ll need a parameter at the top of our file again for our Light position:

 

float2 LightPos;

 

And that’s step 1) done. On to step 2!

 

Before we move on, we will quickly calculate the distance of the pixel to the light. This is of course just the length of the vector we just calculated:

 

float pixDist = length(lightToPix);

 

Now we look at calculating which ray the pixel lies on. To do that we need some trigonometry:

 

float occlusionU = acos(dot(float2(0, 1), normalize(lightToPix)));

 

Lets pause and break this down. Inside the brackets we have a function dot() which takes the dot product of two vectors. The dot product is actually given by the following formula:

 

dot(a, b) = cos(theta) * length(a) * length(b)

 

Where theta is the angle between the two vectors. In our case both of the vectors we’re feeding it have length 1 (since we normalize lightToPix), so this formula becomes:

 

dot(a, b) = cos(theta)

 

Then it should be clear that taking acos() of this will give us the original theta. Note that the other vector we are using in the dot product is our zero angle vector that we decided on in the last part.

There is one more decision from the last part that we should remark on at this point. Recall that we decided to make the angle positive in both directions from the zero line since we were storing the rays on each half of the texture separately. This now comes in handy, since we don’t have to do any work to figure out whether the result from acos() should be positive or negative, we can just use the positive value that it gives us.

So this then gives us the angle of the ray that the pixel lies on, which completes step 2 of our procedure above.

 

Step 3 involves sampling the occlusion map to get the value stored that corresponds to our ray. In order to do this we need to convert our angle that we calculated in step 2 into a texture coordinate. Since the occlusion map is a 1D row of pixels we know that the y coordinate will be 0.5 (the centre of the row vertically). To calculate the x coordinate we need to transform the angle from its current range of 0 – PI to the texture coordinate range of 0 – 1. We accomplish this simply by dividing the angle by PI:

 

occlusionU /= PI;

 

And again we’ll need to add our definition of PI to the top of the shader file:

 

#define PI 3.14159265

 

Now we just need to sample the occlusion map in the normal way:

 

float4 occlusionDist = tex2D(occlusionSampler, float2(occlusionU, 0.5));

 

And clearly we’ll need to create a sampler for our occlusion map at the top of the file:

 

sampler occlusionSampler : register(s0);

 

And that’s it for step 3, as we now have the value stored in the occlusion map for our ray.

For step 4, calculating the actual distance along the ray to the first shadow casting pixel, we have two stages to go through. The first is to convert the value from the occlusion map to pixel/ screen coordinates. Recall from the last part that we made this conversion by dividing the distance of the pixel from the light by the diagonal length of the screen in order to get the values in the 0 – 1 range. So to get the distance from the value we need to multiply the sampled value by the diagonal distance of the screen like so:

 

occlusionDist.xyz = mul(occlusionDist.xyz, DiagonalLength);

 

Note that we use the .xyz suffix as we don’t want to multiply the alpha component by the DiagonalLength, since this wouldn’t make a lot of sense!

Once again we’ll need a parameter for DiagonalLength for this to work:

 

float DiagonalLength;

 

 

We haven’t quite completed step 4 yet, as if you recall, there are actually 2 distance encoded in each cell of our occlusion map, one in the r channel for rays to the left of the light and one in the g channel for rays to the right of the light. Clearly in our case we are only interested in one of these. So how do we determine which? Simple, we need to determine whether our pixel that we’re lighting is to the left or the right of the light. Then we can use the outcome to select the right channel of our occlusionDist vector:

 

float dist = (screenCoords.x < LightPos.x) ? occlusionDist.r : occlusionDist.g;

 

And we’re done with step 4!

 

Step 5 is going to need unpacking a bit more before we can determine what shader code we need to add. Assuming that there are no shadow casters in the scene, how do we go about deciding what light value to give our pixel? There are several factors.

Each light has a radius determined by the developer. Clearly any pixel outside of that radius won’t receive any light at all. Inside the radius there are several ways of deciding how the light should fall-off as it gets further away from the light source. The simplest way would be to simply linearly decrease the light from maximum power at the light source itself, down to 0 at the edge of the radius. For a moment let’s assume that this is the method we’ve chosen. How would we do this?

For our current pixel we already know how far it is from the light source. Assuming that it is within the radius then this distance lies on a range between 0 – r, where r is the radius of the light in pixels. However, we want to transform this to the range 1 – 0, crucially where 1 corresponds to 0 pixels from the light and 0 corresponds to r pixels from the light. We also want the value to decrease linearly.

The first step to solve this puzzle is to transform the distance from the light to the range 0 – 1 (rather than 1 – 0). Once we have that we can simply take our answer away from 1 to transform this new value to the range 1 – 0. This is a much simpler problem, one that we have solved several times before. We simply divide the distance to the light by the radius, which transforms it to the range 0 – 1, with 0 being the light source and 1 being the edge of the radius. Or, in the form of a formula:

lighting = 1 – (pixDist / Radius)

However, if we limit the developer to only having lights that decrease linearly we won’t be able to represent lights that are more or less powerful. What if the developer wants a powerful spot light? Or a dimmer light that gets lighter and darker? To handle these situations, we allow the developer to specify the power of the light. Given that we are also allowing them to decide on the light radius this means that they can decide how much of the area covered by the light will receive maximum power, and how quickly it will fall-off to nothing. If the developer specifies a high enough power there would be no fall-off at all and they would have a very stark, bright light. Alternatively if they choose a power value less than 1 then can ‘dim’ the light so that it is darker at the light source and gradually fades to nothing.

There are many other, more realistic ways that we could have used to decide how fast the light falls off. However, by allowing the developer to change the radius, light power, and light color (see later in this post) the developer can simulate most kinds of light that they would want to represent, and we still have a relatively simple shader. If you’re implementing your own system and want to go for more realism then feel free to experiment with other methods.

So, what does this look like in code?:

 

float3 lighting = (1 - (pixDist / Radius)) * LightPow;

 

There are a couple of new parameters here to specify, so we’ll need to add the following to our parameters at the top of the file:

 

float Radius;

float LightPow;

 

Now I imagine there’s a few people wondering how exactly this handles pixels outside the light’s radius, and you’re right, it doesn’t. We actually deal with those pixels as part of Step 6, which we will now move on to, since we’re done with Step 5.

 

The final step for our shader to accomplish is actually pretty straight forward. We simply test whether the pixel we’re drawing to is closer or further away from the light than the value we calculated in step 4. If it’s closer, return the value we calculated in step 5, otherwise return (0, 0, 0, 1). Ok, so I lied, it’s not quite that¬†simple.

We also need to determine whether the pixel is inside the light radius. However, it turns out this is rather easy to combine with testing against the value in step 5. If the pixel is closer than the value from step 4, but further than the radius, we want to return (0, 0, 0, 1). If it’s closer than the radius, but further than the step 4 value, then we also want to return (0, 0, 0, 1). In other words, we only return the lighting value from step 5 if the distance from the pixel to the light is less than the minimum¬†of the light radius and the step 4 value. Or, in a forumula, we return lighting only if the following is true:

pixDist < min(Radius, dist))

Where dist is the value we calculated in step 4. There’s one more element we need to add before we write the code though. If we leave this as it is then tiny bits of shadow may be visible along the edges of the shadow casting objects nearest to the light. This is because we deem any partially transparent pixels to cast a shadow, but of course the shadow that is ‘underneath’ these pixels will be partially visible. To counteract this, we have a bias value (which can be tinkered with by the developer), which causes the shadow to start slightly further away from the light. This ensures that the start of the shadow is hidden underneath the object that is casting it. So our final comparison becomes:

pixDist – Bias < min(Radius, dist))

Or in code:

 

if (pixDist - Bias < min(Radius, dist))
    return float4(lighting, 1);
return float4(0, 0, 0, 1);

 

Clearly we need to add Bias to our parameters:

 

float Bias;

 

And we’re done with our shader! Now on to the C# code (and don’t worry, I haven’t forgotten about the light color I mentioned earlier).

Open up the LightRenderer class and add the following field at the top of the class:

 

public float lightBias = -1f;

 

We want our developer to set the lightBias when they initialize the system, so by setting the value to -1 it should create some horrible visual effects with shadows poking out from under sprites to remind them! (I know, I know, terrible API design, but it served as a reminder for me, feel free to change it).

Next in PrepareResources() add the following:

 

pointLight.Parameters["ScreenDimensions"].SetValue(screenDims);

pointLight.Parameters["DiagonalLength"].SetValue(screenDims.Length());

pointLight.Parameters["Bias"].SetValue(lightBias);

 

And then add the following to CreateLightMap(PointLight pLight) after graphics.GraphicsDevice.Clear(Color.Black):

 

pointLight.Parameters["LightPos"].SetValue(pLight.Position);

pointLight.Parameters["LightPow"].SetValue(pLight.Power);

pointLight.Parameters["Radius"].SetValue(pLight.radius);

 

And we’re done! There’s one more point I promised to address earlier in the post. I mentioned that we also allow the developer to tune the light’s color. That actually happens in the AccumulateLightMaps() method. This is called after we generate each lightMap to add the results to our final light map texture. When we do this we pass in light.color as the Color parameter to spriteBatch.Draw. This has the effect of tinting our light map, so that instead of going from Pure white at maximum and black at minimum, instead the maximum value for the light map is light.color. In this way, if the developer wants to dim a light without altering the fall-off, they can simply reduce light.color’s magnitude by multiplying it by a value between 0 and 1.

 

As usual I’ve uploaded the solution so far on the codeplex site for you to check your code. I’ve also corrected the version I uploaded at the end of the last part, as I managed to miss out some of the code in the original version!

 

Part 5 solution

 

That’s it for now. Next time we’ll be looking at how we process the lightMap we get as a result of our pointLight shader to give us some pleasantly soft shadows.

 

Until then!

Leave a Reply


seven + 2 =

Proudly powered by WordPress. Theme developed with WordPress Theme Generator.
Copyright © Rabid Lion Games. All rights reserved.