Part I - Lighting through Shaders

Workshop 1 - Ambient Lighting

In the real world all the light has a source and direction. In computer graphics, however, we cannot calculate the path of every light beam because that would be way too computationally intensive for current hardware to keep an interactive frame rate.

Most of the things we see around us are not lit by a light source directly, instead, the light is scattered and reflected several times before entering our eye. We can use this to our advantage by discarding the source and direction information and giving all the objects in a scene a base luminance based solely on an intensity and color value. In computer graphics, we call this kind of lighting ambient.

The ambient lighting component is described by the following formula:

Ambient Light = Ambient Intensity * Ambient Color

Where Ambient Light is a vector containing the amount of red, green and blue ambient light on the pixel, Ambient Color is a vector containing the color of the ambient light and Ambient Intensity is a value for the intensity of the ambient light.

Implementation


This is how a real shader (ambient.fx) looks like:
// Tweakables: 
static const float AmbientIntensity = 1.0f;  // The intensity of the ambient light. 
    
// Application fed data: 
const float4x4 matWorldViewProj;   // World*view*projection matrix. 
const float4 vecAmbient; // Ambient color, passed by the engine. 
    
// Vertex Shader: 
float4 AmbientVS(in float4 InPos: POSITION): POSITION 
{ 
// Transform the vertex from object space to clip space: 
   return mul(InPos, matWorldViewProj); 
} 
    
// Pixel Shader: 
float4 AmbientPS(): COLOR 
{ 
   return AmbientIntensity * vecAmbient; 
} 
    
// Technique: 
technique AmbientTechnique 
{ 
   pass P0 
   { 
      VertexShader = compile vs_1_1 AmbientVS(); 
      PixelShader  = compile ps_1_1 AmbientPS(); 
   } 
} 
That may look scary but don't run away just yet. We will discuss every line in detail. Let's first take a look at the variable definitions at the beginning of the code.
// Tweakables: 
static const float AmbientIntensity = 1.0f;  // The intensity of the ambient light. 
    
// Application fed data: 
const float4x4 matWorldViewProj;   // World*view*projection matrix. 
const float4 vecAmbient; // Ambient color, passed by the engine. 

We first define a variable called AmbientIntensity and assign it a value of 1.0. This variable is a floating point (float) variable, meaning it can contain decimals like 3.1415. For assigning a number to a floating point variable, an `f' must be added at the end of it: "1.0f". The "static" keyword tells the compiler that the variable may not be changed by the application (the 3D engine). The "const" keyword tells the compiler that the variable cannot be changed from within the shader. A "static const" variable can never change (except by the programmer before runtime).

Next is the world-view-projection matrix which is used to transform the vertex to clip space (see "Transformation & Lighting" in section I of this tutorial and "Coordinate Spaces" in Appendix B). Because it is a 4x4 matrix, we use the float4x4 data type. We use const to tell the compiler that the variable may not be changed from within the shader. We don't use static, nor do we assign any initial values because the matrix is set once per frame by the engine for every model that uses this shader.

The matrix MUST be named "matWorldViewProj" because otherwise the engine doesn't know it has to be set. The same goes for the vecAmbient. These variables are called effect variables in the Gamestudio manual. They are assigned a value by the engine before the shader is executed. You can find a list of all such effect variables in the Gamestudio manual under Materials and Shaders / Effect Variables.

Lastly, we define the color vector vecAmbient. We use a "float4" data type so we can store the 4 values that make up the color (r, g, b, a, meaning red, green, blue, and alpha). If you know lite-C then you probably know the concept of a VECTOR with its x, y, z components. A float4 is similar to a VECTOR but with 4 components. Generally, shaders often use 4-dimensional vectors even for 3D positions or directions, and store some extra information in the 4th component.

Like the world-view-projection matrix, vecAmbient can only be changed by the engine, not in the shader itself. More precisely, the engine will pass through this vector the ambient color defined in the material plus the environment lighting by static lights placed in the level. You can find details about ambient light in the Lighting chapter of the Gamestudio manual.

On to the vertex shader:

float4 AmbientVS(in float4 InPos: POSITION) : POSITION 
{ 
// Transform the vertex from object space to clip space: 
   return mul(InPos, matWorldViewProj);  
}

The vertex shader is just a function. The function is called AmbientVS and has one input (in) variable called InPos. It returns a float4 vector. Both the InPos and the returned function value are marked with the POSITION semantic. A semantic is a keyword that tells the compiler what the data is meant for or what kind of data is stored in this variable. This is important, because the subsequent graphics pipeline stages must know what to do with the data. For C/C++ programmers: you can also use a struct to define the input and returned data.

The content of the function is only one command which transforms the vertex position to clip space. This is done by multiplying the input position vector by the world-view-projection matrix. Multiplying a vector with a matrix transforms the vector to a different coordinate system - look up "matrices" in the appendix A for details. For multiplying matrices and vectors you must use the mul intrinsic function.

Next is the pixel shader:

float4 AmbientPS(): COLOR 
{ 
   return AmbientIntensity * vecAmbient; 
} 

Once again, we define a function. We call it AmbientPS. This function returns a float4 which is marked by a COLOR semantic. No input variables are needed for this function.

We return a color vector which is the product of the ambient intensity and the ambient color vector. It is important to note that we are multiplying a vector with a single value. This is possible because HLSL natively supports vectors. We can perform operations on vectors in a single instruction that would in lite-C or normal C/C++ require multiple instructions:

// HLSL: 
VectorB = VectorA * 3; 
VectorC = VectorD * VectorE; 
  
// C/C++: 
VectorB.x = VectorA.x * 3; 
VectorB.y = VectorA.y * 3; 
VectorB.z = VectorA.z * 3; 
  
VectorC.x = VectorD.x * VectorE.x; 
VectorC.y = VectorD.y * VectorE.y; 
VectorC.z = VectorD.z * VectorE.z;  

So, since we are writing HLSL, each component (R,G,B and A) gets multiplied with the value of AmbientIntensity (see "Scaling a Vector" in Appendix B) before it is returned. This tutorial does not provide an overview of the whole HLSL syntax, but you can find a HLSL reference on Microsoft Developer Network (MSDN, see the "Further Reading" section of this tutorial), or in the DirectX SDK documentation. I suggest to

 !!  An important thing to keep in mind when writing shaders is that the pixel shader function is executed for EVERY pixel drawn to the screen, while the vertex shader function is only executed for every vertex of the mesh. Thus, pixel shaders are a lot more expensive than vertex shaders! Always keep the pixel shader as short as possible, and transfer as much calculations as possible to the vertex shader. You can see this in the shaders of Gamestudio's mtlFX.c shader library that are highly optimized. But here I have kept things as simple as possible to make it easier to understand. The purpose of the shaders in this workshop is educational; commercial shaders will look a little different.

Back to our ambient shader. The last step is wrapping everything up in a "technique":

technique AmbientTechnique 
{ 
   pass P0 
   { 
      VertexShader = compile vs_1_1 AmbientVS(); 
      PixelShader  = compile ps_1_1 AmbientPS(); 
   } 
} 

The technique is the "heart" of the shader, here we tie everything together.

A technique consists of one or more passes. In a pass, the model gets rendered once with a certain vertex and pixel shader. You may also set any parameters of the FFP but we don't need that here. Sometimes, you will need multiple passes, for example when rendering the outline and insides of a mesh for a toon shader seperately. The second pass may render the model in a different way and the results of both passes can be mixed. However, that is beyond the scope of this tutorial.

We define the technique and call it AmbientTechnique. In the pass, which we call "P0", we specify a vertex and pixel shader function which are both compiled to the 1.1 shader model. Choosing the shader model 1.1 allows this shader to run even on old, first-generation shader hardware, but it also limits the possibilities of the programmer. Because this is a very simple shader we don't need any special instructions and VS/PS 1.1 will do.

Our first shader

Now enough of theory - let's finally run our shader! Start SED and open ambientdemo.c in the workshops folder (unzip the folder into an arbitrary directory on your hard disk if you haven't done so already):

//////////////////////////////////////////////////////
#include <acknex.h>
#include <default.c>

MATERIAL* mtlAmbient =
{
   ambient_red = 64; // The ambient color - a dark grey.
   ambient_green = 64;
   ambient_blue = 64;
   effect = "ambient.fx"; // The effect file containing shader and technique.
   flags = AUTORELOAD; // allows to edit the shader at runtime
}

function main()
{
// Load an empty level, create a model, and assign the material
   level_load("");
   ENTITY* ent = ent_create("blob.mdl",vector(100,0,0),NULL);
   ent.material = mtlAmbient;
}

This is a not-so-complicated lite-C script for displaying a shader. Before we can assign the shader to an entity in our scene, we must first define a material in the script. This material contains a pointer to the effect file which in turn contains the shader code. A material definition may also contain other material properties like ambient color, specular power and more. Note that if you use a shader, the other material properties will not influence the look of the model unless you have programmed your shader to use those variables.

We're defining a MATERIAL with an ambient color and a .fx effect file with the shader content that we've described above. The AUTORELOAD flag allows us to edit the .fx file at runtime in SED or any other editor, and observe the result (or the error message when we made a mistake) immediately in the engine window. This flag involves checking the file date any couple of frames, so don't forget to remove the flag before you publish a shader application! The main() function just opens an empty level, places a test model at position (100,0,0) and assigns the material to the model. This is all you need to do for displaying your shader.

Note that this script requires the A7 engine version 7.07 or above. Older engines need a wait(2) after level loading, and don't support the AUTORELOAD flag. Now click the [Run] button (the black triangle):



As you can see, the ambient lighting algorithm paints the whole object with exactly the same color. Because of this, no contour information is visible except for the outline of the object. As this does not look very impressive, let's proceed with more advanced shaders in our next workshop.

Next: Diffuse Lighting