Tuesday, July 23, 2013 Eric Richards

Lighting, Take 1

Last time, we finished up our exploration of the examples from Chapter 6 of Frank Luna’s Introduction to 3D Game Programming with Direct3D 11.0 .  This time, and for the next week or so, we’re going to be adding lighting to our repertoire.  There are only two demo applications on tap in this chapter, but they are comparatively meatier, so I may take a couple posts to describe each one.

First off, we are going to modify our previous Waves Demo to render using per-pixel lighting, as opposed to the flat color we used previously.  Even without any more advanced techniques, this gives us a much prettier visual. lighting waves To get there, we are going to have to do a lot of legwork to implement this more realistic lighting model.  We will need to implement three different varieties of lights, a material class, and a much more advanced shader effect.  Along the way, we will run into a couple interesting and slightly aggravating differences between the way one would do things in C++ and native DirectX and how we need to implement the same ideas in C# with SlimDX.  I would suggest that you follow along with my code from https://github.com/ericrrichards/dx11.git.  You will want to examine the Examples/LightingDemo and Core projects from the solution.

Local Illumination

You may want to brush up on your 3D math and computer graphics theory.  I am not going to go into the details of the illumination model, so if this becomes Greek to you, take a minute and study up a bit.  Luna’s explanation is pretty solid, but if you want a fuller treatment of lighting theory, I would suggest Fundamentals of Computer Graphics by Peter Shirley.  I’ve also seen a lot of good review for Real-Time Rendering, but I have nor had the pleasure of reading through it yet myself.

We are going to be using what is often called the Phong Shading Model.  Instead of specifying the color explicitly for each vertex, we will instead be specifying a set of material properties for each object, and then using a lighting equation to determine the color of each pixel based upon the pixel’s interpolated normal vector.  A normal vector, if you are not familiar, is the unit vector perpendicular to a point on the object’s surface.  Specifying normals for each pixel on an object’s surface would be massively memory-intensive, but fortunately, the graphics hardware will interpolate normals for each pixel, so long as we provide a normal for each vertex of each triangle (I may be dating myself here, but when I was first playing video games, per-pixel lighting was a big deal.  Graphics chips were either non-existent or quite wimpy, so many games used a simpler shading model, called Gouraud Shading or per-vertex shading, which was less computationally expensive, but less attractive.  Thankfully, even the cheapest of integrated cards can handle per-pixel lighting these days…)  In our current example, since we are using mathematical surfaces, we can compute these vertex normals directly using calculus, but typically, one computes the normal at each vertex by averaging the face normals for each triangle that uses the vertex.

So, first up, we will need to create a new vertex structure, which will contain a position and a normal.  We are going to imaginatively call this new structure VertexPN.  I have renamed the previous vertex structure VertexPC, for what I hope are obvious reasons.

[StructLayout(LayoutKind.Sequential)]
public struct VertexPN {
    public Vector3 Position;
    public Vector3 Normal;

    public VertexPN(Vector3 position, Vector3 normal) {
        Position = position;
        Normal = normal;
    }

    public const int Stride = 24;
}

Note that our vertex stride has changed.  The two vectors only occupy 24 bytes of space (4 bytes for each float component), whereas before, we had a vector and a four-float color component.  You can run into some very strange bugs and crashes if you continue using the previous 28 byte stride. We will also need to change our vertex layout when we use this new vertex format.  Thus, in BuildVertexLayout(), we will now be using the following InputElement array to create our InputLayout:

var vertexDesc = new[] {
    new InputElement("POSITION", 0, Format.R32G32B32_Float, 
        0, 0, InputClassification.PerVertexData, 0),
    new InputElement("NORMAL", 0, Format.R32G32B32_Float, 
        12, 0, InputClassification.PerVertexData, 0)
};

Additionally, this change carries over into our shader file in the definition of our VertexIn structure.  (See FX/Lighting.fx)

struct VertexIn
{
    float3 PosL    : POSITION;
    float3 NormalL : NORMAL;
};

Next, we’ll build up the structures necessary for our lighting equation.

Material

The Phong reflection model is an empirical model of for the light absorption and reflection properties of objects.  There are three main components of this model; Ambient color, Diffuse color, and Specular color. 

  • Diffuse color is the most straightforward of the three; this is the color of an object if it were to be hit square-on by a white light.  The diffuse color of an object varies depending on the surface normal at the point being lit; thus, surfaces angled towards the light are more brightly lit than surfaces facing away, and faces that are back-to to the light are not lit at all.  This relationship is known as Lambert’s Cosine Law.
  • Shiny objects have highlights or “glare” that can be seen if you are in a cone opposite from the direction of the light.  Specular color describes the color of this glare.  Additionally, shiny objects typically have narrower, brighter reflections than matte objects, which have wider, dimmer reflections.  We can control this shininess using what is commonly called the specular power and using it as an exponent in a modified version of Lambert’s law (more on this later).
  • In real scenes, not all the light that reaches an object comes directly from lights; some of it is bounced off multiple other objects first, which is why it is not pitch-black in an unlit room, for instance.  Modeling these light bounces in a so-called global illumination model is massively expensive computationally; off-line rendering applications, such as movie CGI can perform this, because they can employ server farms of tens or hundreds of processors chugging along rendering a scene every few seconds or minutes(and even then, they typically only model two or three bounces), but in a real-time application we need to render a frame 30-60 times per second.  Ambient color is a hack to achieve realistic-enough looking images without all this extra computation.  It is typically a darker shade of the diffuse color, which is applied to approximate the ambient light that lights objects even when they are not fully lit by a direct light.

With that background in material theory, we can build our Material class.  We will assign each object in our scene (or sometime subportions of an object) a Material that will describe its surface properties.

[StructLayout(LayoutKind.Sequential)]
public struct Material {
    public Color4 Ambient;
    public Color4 Diffuse;
    public Color4 Specular;
    public Color4 Reflect;
}

Ignore the Reflect property for now.  Also, note that we specify the Specular power as the Alpha component of the specular color, so that we can fit this structure nicely into the 128-bit registers common to most graphics cards; it doesn’t make a great deal of sense to have an alpha component for a specular highlight, in any case.  In our shader code, this Material structure is represented as:

struct Material
{
    float4 Ambient;
    float4 Diffuse;
    float4 Specular; // .w = SpecPower
    float4 Reflect;
};

Lights

Similar to our Materials, our lights will have Ambient, Diffuse and Specular components, describing the shades of light that they output.  This enables us to have colored lights; for example, red emergency lights, or yellowish older-style lightbulbs in comparison to white halogens.  Thus, we can have the same material appear differently depending on the light color – under a red light, a green object will appear gray, rather than green, as it would under a white light.

We will implement three different varieties of light:

  • Directional Lights – These are lights that are so distant that we can consider the power and direction of the lights to be constant.  The canonical example of a directional light would be the Sun, as seen from a point on earth.  In addition to the light’s color properties, we will add a vector representing the incoming light angle.

  • Point Lights – These are lights that are set a position in the scene, and radiate light equally in all directions.  In real-life, light intensity declines according to an inverse-square law, like so: CodeCogsEqn Typically, a different formula is used for more visually-pleasing results in games: att2 Further, although the attenuation equation will ensure that our light’s intensity will decline to nothing at a certain distance, if we specify an explicit range for the light, we can perform some optimizations by discarding lights that are too far away from our objects to be considered.

  • Spot Lights – Last, we have lights which are located at a point, directed in a given angle, and only project light within a cone.  A typical flashlight would be an example of a spotlight.  A spotlight is very similar to a point light, with respect to attenuation, except that we add an additional power factor to scale the size of the spot light cone.

From this description, we can create our C# structures for our lights.

[StructLayout(LayoutKind.Sequential)]
public struct DirectionalLight {
    public Color4 Ambient;
    public Color4 Diffuse;
    public Color4 Specular;
    public Vector3 Direction;
    public float Pad;
}
[StructLayout(LayoutKind.Sequential)]
public struct PointLight {
    public Color4 Ambient;
    public Color4 Diffuse;
    public Color4 Specular;
    public Vector3 Position;
    public float Range;
    public Vector3 Attenuation;
    public float Pad;
}
[StructLayout(LayoutKind.Sequential)]
public struct SpotLight {
    public Color4 Ambient;
    public Color4 Diffuse;
    public Color4 Specular;
    public Vector3 Position;
    public float Range;
    public Vector3 Direction;
    public float Spot;
    public Vector3 Attenuation;
    public float Pad;
}

Note the float Pad variables, and the way in which we have interleaved Vector3 and float variables.  This is necessary because HLSL structures members need to be aligned on 16-byte boundaries, and cannot be split across a boundary, due to the way the GPUs registers are optimized for vector and matrix operations.  You can run into some very tricky errors if you mess this up, particularly if you are uploading an array of C# structures to your shader code.  Here are our resulting shader structures:

struct DirectionalLight
{
    float4 Ambient;
    float4 Diffuse;
    float4 Specular;
    float3 Direction;
    float pad;
};
struct PointLight
{ 
    float4 Ambient;
    float4 Diffuse;
    float4 Specular;

    float3 Position;
    float Range;

    float3 Att;
    float pad;
};
struct SpotLight
{
    float4 Ambient;
    float4 Diffuse;
    float4 Specular;

    float3 Position;
    float Range;

    float3 Direction;
    float Spot;

    float3 Att;
    float pad;
};

We’ll wrap up by discussing the shader functions that compute the per-pixel light for the different light types.  This code can be found in FX/LightHelper.fx, which is split out from our main shader file for this example, so that we can resuse it going forward more easily by just #include’ing it.  These shaders are relatively straight-forward implementations of the lighting equations discussed in the book.  As a bonus, they are much more simple to understand than the corresponding Wikipedia entries…

Directional Light Shader

void ComputeDirectionalLight(Material mat, DirectionalLight L, 
                             float3 normal, float3 toEye,
                             out float4 ambient,
                             out float4 diffuse,
                             out float4 spec)
{
    // Initialize outputs.
    ambient = float4(0.0f, 0.0f, 0.0f, 0.0f);
    diffuse = float4(0.0f, 0.0f, 0.0f, 0.0f);
    spec    = float4(0.0f, 0.0f, 0.0f, 0.0f);

    // The light vector aims opposite the direction the light rays travel.
    float3 lightVec = -L.Direction;

    // Add ambient term.
    ambient = mat.Ambient * L.Ambient;    

    // Add diffuse and specular term, provided the surface is in 
    // the line of site of the light.
    
    float diffuseFactor = dot(lightVec, normal);

    // Flatten to avoid dynamic branching.
    [flatten]
    if( diffuseFactor > 0.0f )
    {
        float3 v         = reflect(-lightVec, normal);
        float specFactor = pow(max(dot(v, toEye), 0.0f), mat.Specular.w);
                    
        diffuse = diffuseFactor * mat.Diffuse * L.Diffuse;
        spec    = specFactor * mat.Specular * L.Specular;
    }
}

Point Light Shader

void ComputePointLight(Material mat, PointLight L, float3 pos, float3 normal, float3 toEye,
                   out float4 ambient, out float4 diffuse, out float4 spec)
{
    // Initialize outputs.
    ambient = float4(0.0f, 0.0f, 0.0f, 0.0f);
    diffuse = float4(0.0f, 0.0f, 0.0f, 0.0f);
    spec    = float4(0.0f, 0.0f, 0.0f, 0.0f);

    // The vector from the surface to the light.
    float3 lightVec = L.Position - pos;
        
    // The distance from surface to light.
    float d = length(lightVec);
    
    // Range test.
    if( d > L.Range )
        return;
        
    // Normalize the light vector.
    lightVec /= d; 
    
    // Ambient term.
    ambient = mat.Ambient * L.Ambient;    

    // Add diffuse and specular term, provided the surface is in 
    // the line of site of the light.

    float diffuseFactor = dot(lightVec, normal);

    // Flatten to avoid dynamic branching.
    [flatten]
    if( diffuseFactor > 0.0f )
    {
        float3 v         = reflect(-lightVec, normal);
        float specFactor = pow(max(dot(v, toEye), 0.0f), mat.Specular.w);
                    
        diffuse = diffuseFactor * mat.Diffuse * L.Diffuse;
        spec    = specFactor * mat.Specular * L.Specular;
    }

    // Attenuate
    float att = 1.0f / dot(L.Att, float3(1.0f, d, d*d));

    diffuse *= att;
    spec    *= att;
}

Spot Light Shader

void ComputeSpotLight(Material mat, SpotLight L, float3 pos, float3 normal, float3 toEye,
                  out float4 ambient, out float4 diffuse, out float4 spec)
{
    // Initialize outputs.
    ambient = float4(0.0f, 0.0f, 0.0f, 0.0f);
    diffuse = float4(0.0f, 0.0f, 0.0f, 0.0f);
    spec    = float4(0.0f, 0.0f, 0.0f, 0.0f);

    // The vector from the surface to the light.
    float3 lightVec = L.Position - pos;
        
    // The distance from surface to light.
    float d = length(lightVec);
    
    // Range test.
    if( d > L.Range )
        return;
        
    // Normalize the light vector.
    lightVec /= d; 
    
    // Ambient term.
    ambient = mat.Ambient * L.Ambient;    

    // Add diffuse and specular term, provided the surface is in 
    // the line of site of the light.

    float diffuseFactor = dot(lightVec, normal);

    // Flatten to avoid dynamic branching.
    [flatten]
    if( diffuseFactor > 0.0f )
    {
        float3 v         = reflect(-lightVec, normal);
        float specFactor = pow(max(dot(v, toEye), 0.0f), mat.Specular.w);
                    
        diffuse = diffuseFactor * mat.Diffuse * L.Diffuse;
        spec    = specFactor * mat.Specular * L.Specular;
    }
    
    // Scale by spotlight factor and attenuate.
    float spot = pow(max(dot(-lightVec, L.Direction), 0.0f), L.Spot);

    // Scale by spotlight factor and attenuate.
    float att = spot / dot(L.Att, float3(1.0f, d, d*d));

    ambient *= spot;
    diffuse *= att;
    spec    *= att;
}

Next Time…

Whew…  That was a lot of math and theory.  Still, I’ve only done the briefest overview here, so I would encourage you to bone up and actually read the chapter if any of that went over your head.  Next time around, we’ll get to actually implementing the demo and getting our beautifully lit pixels up on the screen.