|
#macro Trace(P, D, recLev)
If the ray-sphere intersection macro was the core of the raytracer, then the Trace-macro is practically everything else, the "body" of the raytracer.
The Trace-macro is a macro which takes the starting point of a ray, the direction of the ray and a recursion count (which should always be 1 when calling the macro from outside; 1 could be its default value if POV-Ray supported default values for macro parameters). It calculates and returns a color for that ray.
This is the macro we call for each pixel we want to calculate. That is, the starting point of the ray is our camera location and the direction is the direction of the ray starting from there and going through the "pixel" we are calculating. The macro returns the color of that pixel.
What the macro does is to see which sphere (if any) does the ray hit and then calculates the lighting for that intersection point (which includes calculating reflection), and returns the color.
The Trace-macro is recursive, meaning that it calls itself. More
specifically, it calls itself when it wants to calculate the ray reflected
from the surface of a sphere. The recLev
value is used to stop
this recursion when the maximum recursion level is reached (ie. it calculates
the reflection only if recLev < MaxRecLev
).
Let's examine this relatively long macro part by part:
#local minT = MaxDist; #local closest = ObjAmnt; // Find closest intersection: #local Ind = 0; #while(Ind < ObjAmnt) #local T = calcRaySphereIntersection(P, D, Ind); #if(T>0 & T<minT) #local minT = T; #local closest = Ind; #end #local Ind = Ind+1; #end
A ray can hit several spheres and we need the closest intersection point (and to know which sphere does it belong to). One could think that calculating the closest intersection is rather complicated, needing things like sorting all the intersection points and such. However, it's quite simple, as seen in the code above.
If we remember from the previous part, the ray-sphere intersection macro returns a factor value which tells us how much do we have to multiply the direction vector in order to get the intersection point. What we do is just to call the ray-sphere intersection macro for each sphere and take the smallest returned value (which is greater than zero).
First we initialize the minT
identifier, which will hold
this smallest value to something big (this is where we need the
MaxDist
value, although modifying this code to work around this
limitation is trivial and left to the user). Then we go through all the
spheres and call the ray-sphere intersection macro for each one. Then we
look if the returned value was greater than 0 and smaller than
minT
, and if so, we assign the value to minT
. When
the loop ends, we have the smallest intersection point in it.
Note: we also assign the index to the sphere which the closest
intersection belongs to in the closest
identifier.
Here we use a small trick, and it's related to its initial value:
ObjAmnt
. Why did we initialize it to that? The purpose of it
was to initialize it to some value which isn't a legal index to a sphere
(ObjAmnt
is not a legal index as the indices go from 0 to
ObjAmnt-1
); a negative value would have worked as well, it
really doesn't matter. If the ray doesn't hit any sphere, then this identifier
is not changed and so we can see it afterwards.
// If not found, return background color: #if(closest = ObjAmnt) #local Pixel = BGColor;
If the ray didn't hit any sphere, what we do is just to return the
bacground color (defined by the BGColor
identifier).
Now comes one of the most interesting parts of the raytracing process: How do we calculate the color of the intersection point?
First we have to pre-calculate a couple of things:
#else // Else calculate the color of the intersection point: #local IP = P+minT*D; #local R = Coord[closest][1].x; #local Normal = (IP-Coord[closest][0])/R; #local V = P-IP; #local Refl = 2*Normal*(vdot(Normal, V)) - V;
Naturally we need the intersection point itself (needed to calculate the
normal vector and as the starting point of the reflected ray). This is
calculated into the IP
identifier with the formula which I
have been repeating a few times during this tutorial.
Then we need the normal vector of the surface at the intersection point. A normal vector is a vector perpendicular (ie. at 90 degrees) to the surface. For a sphere this is very easy to calculate: It's just the vector from the center of the sphere to the intersection point.
Note: we normalize it (ie. convert it into a unit vector, ie. a vector of length 1) by dividing it by the radius of the sphere. The normal vector needs to be normalized for lighting calculation.
Now a tricky one: We need the direction of the reflected ray. This vector is of course needed to calculate the reflected ray, but it's also needed for specular lighting.
This is calculated into the Refl
identifier in the code
above. What we do is to take the vector from the intersection point to
the starting point (P-IP
) and "mirror" it with respect to
the normal vector. The formula for "mirroring" a vector V
with
respect to a unit vector (let's call it Axis
) is:
MirroredV = 2*Axis*(Axis·V) - V
(We could look at the theory behind this formula in more detail, but let's not go too deep into math in this tutorial, shall we?)
// Lighting: #local Pixel = AmbientLight; #local Ind = 0; #while(Ind < LightAmnt) #local L = LVect[Ind][0];
Now we can calculate the lighting of the intersection point. For this we need to go through all the light sources.
Note: L
contains the direction vector which
points towards the light source, not its location.
We also initialize the color to be returned (Pixel
) with
the ambient light value (given in the global settings part). The goal is to
add colors to this (the colors come from diffuse and specular lighting, and
reflection).
The very first thing to do for calculating the lighting for a light source is to see if the light source is illuminating the intersection point in the first place (this is one of the nicest features of raytracing: shadow calculations are laughably easy to do):
// Shadowtest: #local Shadowed = false; #local Ind2 = 0; #while(Ind2 < ObjAmnt) #if(Ind2!=closest & calcRaySphereIntersection(IP,L,nd2)>0) #local Shadowed = true; #local Ind2 = ObjAmnt; #end #local Ind2 = Ind2+1; #end
What we do is to go through all the spheres (we skip the current sphere although it's not necessary, but a little optimization is still a little optimization), take the intersection point as starting point and the light direction as the direction vector and see if the ray-sphere intersection test returns a positive value for any of them (and quit the loop immediately when one is found, as we don't need to check the rest anymore).
The result of the shadow test is put into the Shadowed
identifier as a boolean value (true
if the point is shadowed).
The diffuse component of lighting is generated when a light ray hits a surface and it's reflected equally to all directions. The brightest part of the surface is where the normal vector points directly in the direction of the light. The lighting diminishes in relation to the cosine of the angle between the normal vector and the light vector.
#if(!Shadowed) // Diffuse: #local Factor = vdot(Normal, L); #if(Factor > 0) #local Pixel = Pixel + LVect[Ind][1]*Coord[closest][2]*Factor; #end
The code for diffuse lighting is suprisingly short.
There's an extremely nice trick in mathematics to get the cosine of the angle between two unit vectors: It's their dot-product.
What we do is to calculate the dot-product of the normal vector and the light vector (both have been normalized previously). If the dot-product is negative it means that the normal vector points in the opposite direction than the light vector. Thus we are only interested in positive values.
Thus, we add to the pixel color the color of the light source multiplied by the color of the surface of the sphere multiplied by the dot-product. This gives us the diffuse component of the lighting.
The specular component of lighting comes from the fact that most surfaces do not reflect light equally to all directions, but they reflect more light to the "reflected ray" direction, that is, the surface has some mirror properties. The brightest part of the surface is where the reflected ray points in the direction of the light.
Photorealistic lighting is a very complicated issue and there are lots of different lighting models out there, which try to simulate real-world lighting more or less accurately. For our simple raytracer we just use a simple Phong lighting model, which suffices more than enough.
// Specular: #local Factor = vdot(vnormalize(Refl), L); #if(Factor > 0) #local Pixel = Pixel + LVect[Ind][1]* pow(Factor, Coord[closest][3].x)* Coord[closest][3].y; #end
The calculation is similar to the diffuse lighting with the following differences:
Thus, the color we add to the pixel color is the color of the light source multiplied by the dot-product (which is raised to the given power) and by the given brightness amount.
Then we close the code blocks:
#end // if(!Shadowed) #local Ind = Ind+1; #end // while(Ind < LightAmnt)
// Reflection: #if(recLev < MaxRecLev & Coord[closest][1].y > 0) #local Pixel = Pixel + Trace(IP, Refl, recLev+1)*Coord[closest][1].y; #end
Another nice aspect of raytracing is that reflection is very easy to calculate.
Here we check that the recursion level has not reached the limit and that the sphere has a reflection component defined. If both are so, we add the reflected component (the color of the reflected ray multiplied by the reflection factor) to the pixel color.
This is where the recursive call happens (the macro calls itself). The recursion level (recLev) is increased by one for the next call so that somewhere down the line, the series of Trace() calls will know to stop (preventing a ray from bouncing back and forth forever between two mirrors). This is basically how the max_trace_level global setting works in POV-Ray.
Finally, we close the code blocks and return the pixel color from the macro:
#end // else Pixel #end
|