Rabid Lion Games

 

Greetings! We’ve come a long way so far, and we have a little way to go yet, but you’ll be pleased to know that this is the last part of the series where we’re actually working on our lighting system. By the end of this post you’ll be able to build your lighting engine (and work through all the inevitable typos that come from us not having built it up until now… oops!).

 

In this part we’ll be using the light map generated from the occlusion map that we wrote the code for in the last part of the series. In the last part we briefly talked about the idea of a spot light having an inner cone and an outer cone, or in our case, an inner and outer triangle. Pixels within the inner triangle are treated exactly as they would be if they were lit by a point light. The pixels in the outer triangle are treated differently. The power of the light in these pixels drops off linearly between the edge of the inner triangle and the edge of the outer triangle.

In other words, a pixel just on the edge of the inner triangle will be lit the same as if it were lit by a point light, a pixel on the edge of the outer triangle will have the value minLight, and a pixel halfway between the two (measured by angle) will have a value exactly half way between the two.

The steps for creating our light map are broadly similar to those that we used for creating our pointLight light map, with a few additions towards the end (note however that even where we have the same step the implementation may be different):

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) Determining if the pixel is visible

6) Calculating what the value of the light map for that pixel would be assuming that it is not obscured by a shadow caster AND that it is within the inner triangle

7) Calculating how far, on a scale on 0 – 1, the ray that the pixel lies on is between the edge of the inner triangle and the edge of the outer triangle, with everything inside the inner triangle up to it’s edge having the value 1 and everything outside the outer triangle up to it’s edge having the value 0.

8 ) Using the results from step 5, 6, and 7 to determine the final lighting value for this pixel and returning it

 

Once again, we’ll explain the steps that aren’t self-explanatory (and weren’t covered in part 5) as go along. For now though, lets get started on our implementation!

 

The SpotLight shader

As you’re probably tired of doing now, fire up our Visual Studio solution from the end of the last part and create a new Effect file, this time called SpotLight.fx. As normal, delete the contents, and add the normal stubb:

 

float4 PixelShaderFunction(float2 TexCoord : TEXCOORD0) : COLOR0
{

}

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

 

First up let’s implement the first step:¬†Calculating the vector from the light to the pixel. You’ll be pleased to know this identical to the code from this step in the PointLight shader, so either copy paste from there or add the following code to the top of the function:

 

float2 screenCoords = float2(ScreenDimensions.x * TexCoord.x, ScreenDimensions.y * TexCoord.y);
float2 lightToPix = screenCoords - LightPos;
float pixDist = length(lightToPix);

Once again we drop the line in here to calculate the distance from the pixel to the light as it’s convenient.

The next step is more involved, and is more complicated than it was for our point lights. Due to the fact that we may or may not need to add a bias to some of our angles for them to make sense (see the discussion at the end of the last part), we need to do some calculations to get to the correct angle for our ray. Add the following code to your shader and then we’ll discuss it:

 

float rayAngle = acos(dot(float2(0, 1), normalize(lightToPix)));
float leftOrRight = sign(-lightToPix);

rayAngle *= leftOrRight;
rayAngle += (1 - ((leftOrRight + 1) / 2.0f)) * AngleBias;

rayAngle = clamp(rayAngle, OuterMinAngle, OuterMaxAngle);

float occlusionU = (rayAngle - OuterMinAngle) / (OuterMaxAngle - OuterMinAngle);

 

Before we explain this, let’s quickly add the parameters for both this, and the last section of code to the top of the file:

 

float2 ScreenDimensions;

float2 LightPos;

float AngleBias;

float OuterMinAngle;
float OuterMaxAngle;

 

Right, so what were we doing in that snippet? First of all we were using the inverse cos function to find the angle of the ray in the same way we did for our point light shader. However, we can’t just use this value as-is. acos() returns an angle between -PI and PI. Before we can use this value we need to calculate whether we need to add our angle bias to it.

The first step in determining if we need to add our angle bias to the angle is to see whether this ray is to the left or the right of the light. As we discussed in the last part, those rays to the left of the light are treated in the same way as they were with our point light. Those to the right may need a bias adding to them depending on which direction the light is pointing. However, we already wrote the code to calculate the angle bias in the last part, so we don’t need to worry about that. All we need to know is whether our pixel potentially needs a bias adding to it (i.e. whether it is the right of the light) and if so add the bias to it. Rather than using an if statement to accomplish this, I have again used some mathematical trickery to avoid this expense.

This code effectively adds the AngleBias (which may be zero remember) to our pixel if and only if it lies on the right hand side of the light. There is one edge case we need to mention: what if the pixel is directly above or below the light (i.e. lightToPix.x == 0)?

Actually, our maths still holds. lightToPix.x == 0 if and only if the ray is pointing vertically up or down, i.e. if the angle is either zero or PI. Since sign(0) = 0, leftOrRight will be zero. If we substitute zero for leftOrRight we get the following:

 

rayAngle *= 0 // which equals zero

rayAngle += (1 – ((0 + 1) / 2.0f)) * AngleBias; // which cancels down to 0.5f * AngleBias

 

So our final value for rayAngle (before we clamp it in the next stage) is 1/2 of AngleBias. Now recall that if the light’s arc includes the ray that points straight down (i.e. the correct value for rayAngle = 0) then we want our AngleBias to be zero (since we need the angles be continuous over 0). In that instance our formula gives us the correct answer (0.5f * 0 == 0).

On the other hand if our light’s arc includes the ray that points straight up (i.e. the correct value for rayAngle = PI) then we want our AngleBias to be 2 * PI in order for the angles to be continuous either side of the angle PI. Again, our code gives us the correct answer, as 0.5f * (2 * PI) == PI. Hence our code holds for our edge case and we don’t need to handle it as a special case.

The next step in our code is to clamp the angle to within the arc of the outer triangle of our light (i.e. between OuterMinAngle and OuterMaxAngle). This is because any pixel on rays outside of these bounds gets treated in exactly the same way to one on the edge of them, they received no light from the spot light. In this way we will get the correct value for the rest of our scene whilst not wasting any calculations testing each ray to see if it lies within the arc of the light.

Finally we convert this angle to the range 0 – 1 (with zero representing OuterMinAngle and 1 representing OuterMaxAngle) by linearly interpolating between them as normal. We could have used the built in lerp() function here, but it makes little difference.

The next step is identical to the equivalent step in the pointLight shader, we simply sample our occlusion map at the x coordinate calculated in the previous step:

 

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

 

As usual we need to add the occlusionSampler’s declaration to the top of the file:

 

sampler occlusionSampler : register(s0);

 

 

Next up is step 4, using this value to calculate the distance to the nearest shadow casting pixel. This is very similar to the code we used in the point light shader, with one minor difference:

 

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

float dist = occlusionDist.x;

 

And we add the DiagonalLength parameter to the top of the file:

 

float DiagonalLength;

 

 

The only real difference here is that we are only interested in the x channel. As mentioned before, we could in theory pack multiple spotLight occlusion maps into a single texture if we wanted, in which case we’d need to look at the other channels as well.

From here we deviate from the steps used in the point light shader. For starters, the next step is to determine the visibility of the current pixel. The difference here is that in the point light shader we used this as the conditional in an if statement to determine what color to return. Here we simply cache the value as a 1 (for visible) or a 0 (for not visible) to be used later:

 

float visible = (pixDist - Bias < min(Radius, dist)) ? 1 : 0;

 

And we’ll need to add Bias and Radius as parameters:

 

float Bias;

float Radius;

 

This is exactly the same test as in the point light shader, just returning a 1 or a 0 instead of being used inside an if statement.

Next up is step 6: Calculating what the light value would be if it were in inside the light’s inner triangle. This is again identical to the point light shader:

 

float MaxLight = (1 - (pixDist / Radius)) * LightPow;

 

And again we’ll need to add a parameter at the top of the file, this time LightPow:

 

float LightPow;

 

On to step7, and the last of our non-trivial steps. We effectively need to linearly interpolate our angle between the min/max angle of the inner triangle and the min/max angle of the outer triangle, where we use min if the angle is anticlockwise from the centre ray of the light, and max otherwise. Any ray within the inner triangle can be clamped to one or other of these intervals, since pixels at the edge of the inner triangle are treated the same as those that lie fully within it.

In fact, it’s much simpler if can somehow use the fact that the light is symmetrical around the centre ray of the light. We do this by subtracting this centerAngle from our rayAngle, and then throwing away the sign of the answer we get (i.e. taking the absolute value). We can then clamp this value between the distances from the center ray to the edge of the inner and outer triangles respectively, before mapping this to the range 0 – 1, with 1 being the distance from the center ray to the edge of the inner triangle, and 0 being the distance from the center ray to the edge of the outer triangle. Or, in code:

 

float lerpVal = (clamp(abs(rayAngle - CenterAngle), HalfInnerArc, HalfOuterArc) - HalfInnerArc) / (HalfOuterArc - HalfInnerArc);

lerpVal = 1 - lerpVal;

 

And we have to add a whole bunch of parameters for that code to work:

 

float CenterAngle;

float HalfInnerArc;

float HalfOuterArc;

 

Finally we move on to our last step, using everything we’ve calculated so far (the Maximum lighting value, the lerp value, and the visibility) to get our final lighting value for our pixel and return it:

 

float3 lighting = (visible * lerpVal * MaxLight);

return float4(lighting, 1);

 

And that’s it, our final line of shader code written! The full code for the shader file is set out below:

 

sampler occlusionSampler : register(s0);

float2 ScreenDimensions;
float DiagonalLength;
float2 LightPos;
float LightPow;
float Radius;
float AngleBias;
float Bias;
float OuterMinAngle;
float OuterMaxAngle;
float CenterAngle;
float HalfInnerArc;
float HalfOuterArc;

float4 PixelShaderFunction(float2 TexCoord : TEXCOORD0) : COLOR0
{
	float2 screenCoords = float2(ScreenDimensions.x * TexCoord.x, ScreenDimensions.y * TexCoord.y);
	float2 lightToPix = screenCoords - LightPos;
	float pixDist = length(lightToPix);
	float rayAngle = acos(dot(float2(0, 1), normalize(lightToPix)));
	float leftOrRight = sign(-lightToPix.x);
	rayAngle *= leftOrRight;
	rayAngle += (1 - ((leftOrRight + 1) / 2.0f)) * AngleBias;

	rayAngle = clamp(rayAngle, OuterMinAngle, OuterMaxAngle);

	float occlusionU = (rayAngle - OuterMinAngle) / (OuterMaxAngle - OuterMinAngle);
	float4 occlusionDist = tex2D(occlusionSampler, float2(occlusionU, 0.5));
	occlusionDist.xyz = mul(occlusionDist.xyz, DiagonalLength);
	float dist = occlusionDist.x;
	float visible = (pixDist - Bias < min(Radius, dist)) ? 1 : 0;

	float MaxLight = (1 - (pixDist / Radius)) * LightPow;
	float lerpVal = (clamp(abs(rayAngle - CenterAngle), HalfInnerArc, HalfOuterArc) - HalfInnerArc)/ (HalfOuterArc - HalfInnerArc);
	lerpVal = 1 - lerpVal;
	float3 lighting = (visible * lerpVal * MaxLight);
	return float4(lighting, 1);
}

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

 

Now on to our LightRenderer code. Open up the LightRenderer class as normal. Jump to the PrepareResources() method and add the following code:

 

spotLight.Parameters["ScreenDimensions"].SetValue(screenDims);
spotLight.Parameters["DiagonalLength"].SetValue(screenDims.Length());
spotLight.Parameters["Bias"].SetValue(lightBias);

 

Then go to the CreateLightMap(SpotLight sLight) method. We left a comment here that there would be more parameters. Add these now by changing the name of the method to the following:

 

private void CreateLightMap(SpotLight sLight, float lightDirAngle, float angleBias)

 

And add the following lines of code after the call to graphics.GraphicsDevice.Clear():

 

spotLight.Parameters["LightPos"].SetValue(sLight.Position);
spotLight.Parameters["LightPow"].SetValue(sLight.Power);
spotLight.Parameters["Radius"].SetValue(sLight.radius);
spotLight.Parameters["OuterMinAngle"].SetValue(lightDirAngle - (sLight.outerAngle / 2f));
spotLight.Parameters["OuterMaxAngle"].SetValue(lightDirAngle + (sLight.outerAngle / 2f));
spotLight.Parameters["CenterAngle"].SetValue(lightDirAngle);
spotLight.Parameters["HalfInnerArc"].SetValue(sLight.innerAngle / 2f);
spotLight.Parameters["HalfOuterArc"].SetValue(sLight.outerAngle / 2f);
spotLight.Parameters["AngleBias"].SetValue(angleBias);

 

Phew, that’s a ¬†lot of parameters! Finally, lets head to the DrawLitScene() method and, with trembling fingers, write the last bits of code for our lighting system. Find the call to CreateLightMap(spotLights[i]) inside our spotLight loop and change it to the following:

 

CreateLightMap(spotLights[i], lightDirAngle, angleBias);

 

And we’re done! Now the moment of truth… Go to Build-> Build Solution… and look at the nice long stream of errors! Yep, I made several typos in the course of the series. We haven’t missed out any code, but where I’ve swapped names about to try and make the code make more sense I’ve added tiny errors which we’re only finding now. Oops! Fortunately I’ve added a clean copy of the code below if you don’t fancy wading through and fixing the typos. I’ve also gone back and corrected the earlier parts of the series, so if you started the series after I’ve published this part, you may be wondering what on earth I’m going on about! It also means that if you’ve got big differences between your code and the code I’ve linked to below you can go back to the appropriate part and figure out where you/ I went wrong.

 

Part 8 Solution

 

Next time we’ll be writing some code in our Game class to actually use our Lighting system to create the demo that you saw all those weeks ago. I’ll also discuss some of the enhancements that you could make if you want to take your engine further and create some more ambitious effects with it.

We’re nearly there, see you back here soon for the final installment!

Leave a Reply


+ 1 = three

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