joi, 25 noiembrie 2010

Shaders Part II: Let there be light!

Lighting is a very important part of graphics. Actually, one of the main purposes for shaders was allowing the implementation of arbitrary lighting models. We'll talk more about lighting models in a future post, but for now let's start with something really simple. Fire up Render Monkey and create a default DirectX effect. You should have a red sphere.
In the model we're going to use, we differentiate 3 components to the light contribution. First there's ambient light, which approximates light that bounces many times off other objects before reaching our pixel.Consider a simple experiment: put your hand in front of a light source, in a dark room. The shadow your hand casts on the wall isn't perfectly black; that is what we're approximating with ambient light. Then there's diffuse light, which enters our object, bounces several times inside the object, and is then re-emitted. This component is strongly influenced by the object's color. Finally there's specular light, which bounces directly off the object's surface. This component is usually only influenced by the light source's color(metals are an exception to this).
To begin with, we're going to do our lighting computations per-vertex(this means we evaluate the lighting equation for each vertex and interpolate the results). Oddly enough, we'll start with the pixel shader, which needs to take a color from the vertex shader and output it to the screen:

float4 ps_main(float4 color:COLOR0) : COLOR0
{  
   return( color);
}

This should be fairly easy to understand, so we'll move along. We should now add an ambient component, so let's create a variable for it's color. Right click the root node->Add Variable->Color. Rename it to Ambient, or whatever makes sense to you. Now for the vertex shader:

float4x4 matViewProjection;
float4 Ambient;
struct VS_INPUT
{
   float4 Position : POSITION0;
  
};

struct VS_OUTPUT
{
   float4 Position : POSITION;
   float4 Color    : COLOR;
  
};

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;
  
   Output.Position = mul( Input.Position, matViewProjection );
   Output.Color = Ambient;
   return( Output );
 }

All we did was to make the object the same color as our ambient light. Now here's where things get interesting: As I previously said, diffuse lighting models the contribution of light that enters the object surface and scatteres, re-exiting nearby(in this context, nearby means the same pixel). This is also called local sub-surface scattering(as opposed to global subsurface scattering, which may be used to model materials such as milk, or marble, or, more importantly, skin). We need to find the amount of light that gets to the viewer from our pixel, as a function of the ammount of light that gets to the pixel(this is also called a BRDF-bidirectional reflectance distribution function; we'll cover them in depth in a future post).

Consider a light source that emmits a known ammount of energy per surface unit(let's call it Li). Take a look at the image bellow(sorry for the poor art).

If our surface is perpendicular on the incoming light, the amount of energy that reaches each surface unit is Li. If the surface is at an arbitrary angle a with the incoming light, the surface that recieves the same ammount of light is 1/cos(a) larger, so each surface unit receives cos(a) more light. Our surface re-emits Kdiff of incoming light(Kdiff depends on wavelength, so it will be a RGB vector). The outgoing energy will be Li *Kdiff * cos(a). Cos(a) can be expressed as a dot product between the normalized light vector(pixel position - light position) and our normal.  This is usually called a Lambertian term. Now for some code; first add normals to the stream map. Next add a new float3 variable for the light's position(diffuse lighting depends on the light's and our point's position, but not on the viewer's). Now import both the normals and the light's position in the vertex shader.

float4 lightPos;
struct VS_INPUT
{
   float4 Position : POSITION0;
   float3 Normal   : NORMAL;  
};



Now modify the color output line to:

Output.Color = Ambient + dot(normalize(lightPos-Input.Position.xyz),Input.Normal);


The dot color  between two normalized vectors is equal to the value of the cosine between the two vectors. The normal is already normalized, but we need the normalized light vector. This is computed as the difference between the light's position and our point's position(in this case we work in world space, that's why we use the input position; we can work in any convenient space, as long as all vectors are in that space). 
We still have a slight problem, the color on the dark side of our sphere should be the ambient light's color, but it's currently perfectly black. The reason for this is that our dot product can actually become negative(and it does, when the normal and the light vector at an angle higher than Pi/2), which actually darkens our color. We need to clamp it to 0, so the line becomes:
Output.Color = Ambient + max(0,dot(normalize(lightPos-Input.Position.xyz),Input.Normal));

We still haven't taken the object's or the light's color into account. Add a new color variable for the light's color, and one for the ball's color. The material's color affects both the ambient term and the diffuse term, but the light's color only affects the diffuse term(remember, the ambient term is an approximation of all lights in the scene). Our output line should look something like this:

Output.Color = matColor * (Ambient + lightColor * max(0,dot(normalize(lightPos-Input.Position.xyz),Input.Normal)));


Now to add some specular light. A specular term models the light that bounces off the surface of the object and makes it's way to the eye. On a perfectly flat surface, a point light would cast a single ray of light to the viewer(that ray makes the same angle with the normal as the viewer does).
However, no real surface is perfectly flat. Each surface presents some irregularities at a sub-pixel level(this is called microgeometry). So, in effect, more than one light ray reaches the viewer:
The Phong lighting model considers that the ammount of light that reaches the viewer by direct reflection is proportional to the (clamped)cosine of the angle between the light vector and the reflected(around the normal) view vector, raised to a positive power. The angle in that equation is actually a measure of how far we are from an ideal reflection angle. The power to which we raise that cosine is a measure of how rough the surface is(for a roughness of infinity the surface behaves like it is perfectly flat). First we'll need to know the viewer's position. The view matrix contains a translation equal to minus the camera's position, so all we need is the inverse view matrix. Luckily for us, Render Monkey can do that. Right click the root node->Add variable->matriz->Predefined -> matViewInverse. We should also add a variable for the specular exponent(I called it roughness). Now go ahead and import it into the vertex shader. Our output line, based on the Phong lighting model becomes:

float4 specular = lightColor * pow(max(0,dot(normalize(Input.Position.xyz -lightPos),
                        reflect(normalize(Input.Position - matViewInverse[3]).xyz, Input.Normal))),roughness);
   Output.Color = matColor * (Ambient + lightColor *max(0,dot(normalize(lightPos-Input.Position.xyz),Input.Normal))) + specular;

I've separated the specular component, so that the equation is easier to read. There is a slight catch: this equation doesn't model metals very well. With most materials, the specular component is independent of material color. Good conductors behave differently. First, no diffuse lighting is generally present, because light doesn't enter a metallic surface. Secondly, the specular component's color depends on the material's color. 
All in all, this is the image you should be seeing at this stage:
This is actually what the fixed function pipeline does, and you can already see it's problems. You can actually distinguish the borders of the sphere's underlying triangles. That's because we calculate the color per vertex and linearly interpolate to get the value at each pixel. So, if the center of the highlight happens to be at a vertex's position, we'll have a strong highlight, but if it's right in the middle of a triangle, we'll have a weak one(or even not at all if the triangle is large enough). The solution is computing our lighting equation for each pixel.First of all, we need to change the output from our vertex shader. Our pixel shader needs the light and view vectors, plus the normals, so that`ll be our output. In this case, we'll be working in world space(and in a real application, you'd need to multiply both position and normals with the model matrix). We'll be outputting the three vectors to TEXCOORD0 to 2 (although you'd think these have to do with texture coordinates, they can actually be used for anything). This is the whole vertex shader code

float4x4 matViewProjection;
float4x4 matViewInverse;
float3 lightPos;
float4 vViewPosition;
struct VS_INPUT
{
   float4 Position : POSITION0;
   float3 Normal   : NORMAL;
   
};

struct VS_OUTPUT
{
  
float4 Position : POSITION0;
  
float3 Light    : TEXCOORD0;
   float3 Normal   : TEXCOORD1;
   float3 View     : TEXCOORD2;
   
};

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.Position = mul( Input.Position, matViewProjection );
   Output.Light = Input.Position.xyz - lightPos;
   Output.View  = Input.Position.xyz - matViewInverse[3].xyz;
   Output.Normal = Input.Normal;
   
   return( Output );
   
}


In this case the pixel shader does all the work. The computations are the same as earlier, but we use the interpolated view vector, light vector and normal. 


float4 Ambient;
float4 matColor;
float4 lightColor;

float roughness;

struct PS_INPUT{
   float3 Light : TEXCOORD0;
   float3 Normal   : TEXCOORD1;
   float3 View     : TEXCOORD2;
};
float4 ps_main(PS_INPUT Input) : COLOR0
{  
  
   float4 specular = lightColor * pow(max(0,dot(normalize(Input.Light),
                        reflect(normalize(Input.View), normalize(Input.Normal)))),roughness);
   return( matColor * (Ambient + lightColor * max(0,dot(normalize(Input.Light),normalize(Input.Normal)))) +specular);  
}



I've also attached the final image, and I'll try adding the render monkey project, when I can find a place to host it.









duminică, 21 noiembrie 2010

Shaders Part I: Hello World!

This post is the first in a series involving shader programming. Shaders are programs executed by the GPU. Vertex Shaders are executed for each vertex that goes trough the graphics pipeline, Geometry Shaders are executed for each primitive(triangle, line, etc), and pixel shaders(OpenGL calls them fragment shaders) are executed for each fragment. This will become clearer once you see some code. We'll be using AMD's Render Monkey for writing shaders, since it allows us to focus on the shaders themselves, and not on the application code(I'll try to cover that in later posts). Go ahead and download Render Monkey from here. After you download and install it, fire it up. You should see something like this:

Right click on Effect Workspace -> Add Effect Group -> Effect Group W/ DirectX Effect
You should now see something like this:

 Now you have a red ball..isn't that exciting? Well, not really, but it is a start. Let's have a look at what's involved in rendering this red ball. First off, look at the tree panel on the left, where you can see 3 variables and a rendering pass. The first variable is the ViewProjection Matrix, and it's supplied by the application code(in this case by Render Monkey); variables that are set by the application, are called uniform(in this case it's actually declared as a global variable). The next variable is named stream mapping and it's used to correlate model data to vertex shader inputs. The third variable is your model, which should be pretty self explanatory(you can right click it to change models or coordinate system orientation).
Next we have a rendering pass with a vertex and pixel shader. Shader syntax is very similar to C, so it should look pretty familiar. Keep in mind that conditionals(and loops) are only available in shader model 3 or higher, and even there, they are very slow. The rendering pass also has a reference to our model and to our Stream Mapping(you can recognize that it's a reference by the small arrow icon). You need such references because models and Stream Maps are implicit(you don't use your model's name anywhere in your shader code), so Render Monkey needs to know what to use for that pass.
The vertex shader simply takes every vertex and transforms it by the ModelViewProjection matrix(in this case just the ViewProjection matrix, since in RM object space is actually world space). Let's look at it line by line.

float4x4 matViewProjection;


This line declares the view projection matrix as a 4x4 matrix of floats(most variables in shaders tend to be floating point, so either half(16 bits), float(32 bits), or double(64 bits); you should keep in mind that using halfs is significantly faster on most GPUs) .


struct VS_INPUT
{
   float4 Position : POSITION0;
  
};


This defines our input structure, in this case a 4 float vector. POSITION0 is something called a semantic; it tells the shader compiler to bind an incoming vertex's position to this variable. 

struct VS_OUTPUT
{
   float4 Position : POSITION0;
  
};



Our output looks similar to our input, in that we only write the position. A vertex shader must always write POSITION0, since vertices go trough other stages in the pipeline after the vertex shader. 

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.Position = mul( Input.Position, matViewProjection );
  
   return( Output );
  
}


Our entry point is called vs_main(this is the main function of the vertex shader, we can have other functions as well), and it takes a VS_INPUT parameter and returns a VS_OUTPUT.  Using structures isn't mandatory, we could also have something like(On a side note, POSITION0 and POSITION are the same thing):


float4 vs_main( float4 Position : POSITION ) : POSITION

The only actual computation in the vertex shader is multiplying the input position by the ViewProjection matrix. The mul function is very flexible, you can multiply two matrices or a matrix and a vector, as long as they are properly sized. 

On to the pixel shader:

float4 ps_main() : COLOR0
{  
   return( float4( 1.0f, 0.0f, 0.0f, 1.0f ) );
  
}
In this case, the pixel shader takes no parameters(the ball is uniformly red), and returns a color(the pixel shader has to write COLOR0). 

Let's try changing our model, and adding a texture.  First, right click the model node(not the reference to it), go to Change Model, and choose Cracked Quad.3ds. Now we need some texture coordinates, and to do that, you need to double click the stream mapping and add TEXCOORD to the stream list, like so:
Now we need an actual texture: right click the top node->Add Texture->Add 2D Texture and choose Fieldstone.tga.

We also have to add a texture sampler to our render pass. Right click Pass0->Add Texture Object and select our texture. This links a sampling unit to your texture image. The sampling unit is in charge of, well...sampling the texture(this involves filtering), and you have to know that they are a limited resource(D3D9 GPUs have 16 samplers or more, for example).
Now, let's modify the pixel shader code(btw, I renamed my sampler to Diffuse, Texture0 is pretty non-descriptive for a name).First, we need to add our sampler, as a global variable:
sampler2D Diffuse;
Now we need to add the texture coordinates as an input, so our definition of PS_MAIN becomes:
float4 ps_main(float2 texcoords:TEXCOORD0) : COLOR0
Let's also sample the texture:
float4 diffuse = tex2D(Diffuse, texcoords); 
return( diffuse );
The first argument of the tex2D instruction is the sampler, and the second one is a set of coordinates(for a 2d texture we only need 2 coordinates, but we can also sample 1D textures, 3D textures or cube maps). 
If all went well, you should now see a beige colored model. Now, that doesn't look like our image, so what went wrong? There are two things that could go wrong here: the sampler might not be set up correctly, or the texture coordinates aren't the right ones. The latter is easier to test: just output them to the screen. Change the return line to the following:
return( float4(texcoords,0.0,1.0) ); 
You should now see a black screen; this means our texture coordinates are always (0.0,0.0). That can't be right... The problem is that except for uniform variables, anything that gets to the pixel shader needs to pass trough the vertex shader as well, which doesn't happen with our tex coords. Modify the vertex shader to this:



float4x4 matViewProjection;

struct VS_INPUT
{
   float4 Position : POSITION0;
   float2 texcoord : TEXCOORD;
  
};

struct VS_OUTPUT
{
   float4 Position : POSITION0;
   float2 texcoord : TEXCOORD;
  
};

VS_OUTPUT vs_main( VS_INPUT Input )
{
   VS_OUTPUT Output;

   Output.Position = mul( Input.Position, matViewProjection );
   Output.texcoord = Input.texcoord;
   return( Output );
  
}
 


This simply forwards the tex coordinates to the pixel shader. You should now see something like this:




Now simply change the return statement in the pixel shader back, and you should have a textured cracked quad.



 

First Things First

Hi, my name is Radu Andrei, and I'm a Computer Science student. I decided to start this blog because I find it useful to document what I do, and if someone finds my posts useful, all the better. If you happen to find any mistakes in what I post, or you don't understand something, feel free to post a comment, or e-mail me at andrei [d.o.t] radu [shift+2] cti[d.o.t]pub [d.o.t] ro (apparently spam bots have gotten smarter).