In part 2 we created a working lighting model for our ray tracer and got a step closer to creating a realistic-looking sphere. Soon we will increase the realism further by applying reflections and creating shapes other than spheres. However, this article is going to be about multiplicity. We are going to extend our program to handle multiple spheres and multiple light sources. This will help teach you about working with important concepts in functional programming as well as learn about some of the F# collections libraries.
But before we get started I just want to tidy up the code a little, because it is just a bit badly formed.
At the moment a sphere has a diffuse colour and we are assuming that specular highlights are always white. While this generally gives good results, it’s not perfect. Because we will want to do more with a shape’s material than just lighting at some point (textures, translucency, etc) let’s break out material properties into another type. Also, the intersection type doesn’t really need a reference to the shape it intersects – it only needs to know the material (as well as the other information it has). So let’s change that too. After you make these simple changes to your types, go through the code and make the appropriate changes, including fixing the code that breaks and removing magic constants from the colorAt function.
type Material = { diffuseColor: Color; specularColor: Color; shininess:float } type Sphere = { center:Point3D; radius:float; material:Material } type Intersection = { normal:Vector3D; point:Point3D; ray:Ray; material:Material; t:float } //.... and constructing the sphere will look like let material = { diffuseColor=Color(0.8, 0.1, 0.1); specularColor=Color(0.7,0.7,0.7); shininess=40.0 } let sphere = { center=Point3D(1.0,1.0,1.0); radius=0.4; material=material }
For completeness, here is the colorAt function.
let colorAt intersections scene = let closest = List.minBy(fun i -> i.t) intersections let kd = closest.material.diffuseColor let ks = closest.material.specularColor let Ia = scene.ambientLight let L = norm (scene.light.position - closest.point) let Id = Math.Max(0.0, Vector3D.DotProduct(L, closest.normal)) let V = scene.camera.position - closest.point let H = norm (L + V) let Is = Math.Pow(Math.Max(0.0, Vector3D.DotProduct(H,closest.normal)), closest.material.shininess) let ambient = kd * Ia let diffuse = kd * Id let specular = ks * Is ambient + diffuse + specular
I’m still not quite happy with the layout of this code. It’s weird that colorAt does the calculation to find the closest intersection point to the camera (or ray origin, more precisely). I’d prefer to move that into the calling function, so in the main nested loop
match intersects with | [] -> bmp.SetPixel(x,y,Color.Gray) | _ -> let color = colorAt intersects scene bmp.SetPixel(x,y, Color.FromArgb(255, (int)(color.r*255.0), (int)(color.g*255.0), (int)(color.b*255.0)))
becomes
match intersects with | [] -> bmp.SetPixel(x,y,Color.Gray) | _ -> let color = colorAt (List.minBy(fun i -> i.t) intersects) scene bmp.SetPixel(x,y, Color.FromArgb(255, (int)(color.r*255.0), (int)(color.g*255.0), (int)(color.b*255.0)))
And making the corresponding change in colorAt is simple:
let colorAt intersection scene = let kd = intersection.material.diffuseColor let ks = intersection.material.specularColor // etc...
Now we want to allow for multiple spheres. Change the definition for Scene so it looks like
type Scene = { camera:Camera; spheres:Sphere list; ambientLight:Color; light:Light }
Note the word ‘list’. This means that the ‘spheres’ field will carry a typed list of Sphere values.
This of course breaks our castRay function, which depends heavily on the Scene having just a single sphere. So currently the castRay function performs the task of calculating intersection points. Really what castRay should be doing is delegating this task to another function and simply collecting and returning the results. So let’s make some changes to castRay and also define a separate intersect function to handle the sphere intersect code.
let intersect ray sphere = let s = ray.origin - sphere.center let rayDir = norm ray.direction let sv = Vector3D.DotProduct(s,rayDir) let ss = Vector3D.DotProduct(s,s) let discr = sv*sv - ss + sphere.radius*sphere.radius if discr < 0.0 then [] else let normalAtTime t = norm (pointAtTime ray t - sphere.center) let (t1,t2) = (-sv + sqrt(discr), -sv - sqrt(discr)) [ { normal = normalAtTime t1; point = pointAtTime ray t1; ray = ray; material=sphere.material; t=t1 }; { normal = normalAtTime t2; point = pointAtTime ray t2; ray = ray; material=sphere.material; t=t2 } ] let castRay ray (scene:Scene) = scene.spheres |> List.collect (fun x -> intersect ray x)
The intersect function merely returns a list of intersections with a sphere. We will be making this more generic to handle other shapes later, but for now this works fine. Incidentally, this is the reason we’re using a list instead of a tuple or something where handling only two results would have been more appropriate.
Notice the |> operator in castRay. This is called the pipeline operator and Dom Syme, the man behind the F# language, it is “perhaps the most important operator in F# programming.” (This quote is from Expert F# 2.0). Basically it allows us to “pipe” the value of some expression into another expression, which results in some pretty cool looking code. We’ll be using the pipelining feature more later on.
So this is pretty much all we need to render multiple shapes. Let’s add another shape to the scene and take a look.
let material1 = { diffuseColor=Color(0.8, 0.1, 0.1); specularColor=Color(0.7,0.7,0.7); shininess=40.0 } let sphere1 = { center=Point3D(-0.1, -0.1, 1.8); radius=0.39; material=material1 } let material2 = { diffuseColor=Color(0.1, 0.1, 0.8); specularColor=Color(0.7,0.7,0.7); shininess=20.0 } let sphere2 = { center=Point3D(0.4, 0.3, 2.0); radius=0.3; material=material2 } let scene = { camera=camera; spheres=[sphere1;sphere2]; ambientLight=Color(0.2,0.2,0.2); light=light }
Of course you are free to use as many spheres as you like – experiment with procedurally creating arrays of spheres, it can be pretty fun.
Image may be NSFW.
Clik here to view.
We’re going to take a similar approach for using multiple light sources. Here we’re going to use nested inner functions (closures) to break our code into functions which we can hide from the calling code. colorAt now looks like
let colorAt intersection scene = // nested function for ambient color let ambientColorAt intersection scene = scene.ambientLight * intersection.material.diffuseColor // nested function for specular color let specularColorAt intersection scene = let ks = intersection.material.specularColor let V = scene.camera.position - intersection.point let specularAtLight light = let L = norm (light.position - intersection.point) let H = norm (L + V) let Is = light.color * Math.Pow(Math.Max(0.0, Vector3D.DotProduct(H,intersection.normal)), intersection.material.shininess) ks * Is List.sumBy(fun x -> specularAtLight x) scene.lights //nested function for diffuse color let diffuseColorAt intersection scene = let kd = intersection.material.diffuseColor let diffuseAtLight light = let L = norm (light.position - intersection.point) let Id = light.color * Math.Max(0.0, Vector3D.DotProduct(L, intersection.normal)) kd * Id List.sumBy(fun x -> diffuseAtLight x) scene.lights let ambient = ambientColorAt intersection scene let specular = specularColorAt intersection scene let diffuse = diffuseColorAt intersection scene ambient + diffuse + specular
On a side note, I just noticed that throughout this series I have made an embarrassing mistake – I forgot to multiply the light intensity values with the surface reflectance, resulting in all lights behaving as though they were coloured 1.0,1.0,1.0. This is fixed in the preceding code.
You can see from this snippet that we are summing the values of all colours collected from all light to create specular and diffuse values. Setting up a second light source allows us to view the results.
let light = { position=Point3D(1.0,4.0,-3.0); color=Color(0.5,0.5,0.5) } let light2 = { position=Point3D(-3.0,-2.0,-1.0); color=Color(0.8,0.8,0.8) } let scene = { camera=camera; spheres=[sphere1;sphere2]; ambientLight=Color(0.2,0.2,0.2); lights=[light;light2] }
Image may be NSFW.
Clik here to view.
Now there’s one more thing I want to do before I close off this entry. Right now other objects are ignored when we are summing up the lights. If we just check for intersections between each intersection point and the light source we are inspecting we can see if that intersection point should be in shadow. Seems simple enough, so let’s implement some simple shadowing.
So all we need to do is check whether a ray between the intersection point and the light source intersects ANY other shapes. If not, we know there’s a clear path to the light source. If so, that light source doesn’t contribute to the lighting at that intersection point. We can ignore ambient lighting, because it applies whether we are in shadow or not. Make the following modifications to the colorAt function.
let colorAt intersection scene = // check if we're in shadow let inShadow point light = let ray = { origin = intersection.point; direction = light.position - intersection.point} let intersections = castRay ray scene if intersections.Length = 0 then false else true // ...then where diffuse light is calculated... let diffuseColorAt intersection scene = let kd = intersection.material.diffuseColor let diffuseAtLight light = let L = norm (light.position - intersection.point) let Id = light.color * Math.Max(0.0, Vector3D.DotProduct(L, intersection.normal)) if inShadow intersection.point light then Color(0.0,0.0,0.0) else kd * Id List.sumBy(fun x -> diffuseAtLight x) scene.lights
Although I’m sure technically we should do the same for specular light, in my experience it looks like ass with these sharp shadows, so I keep specular light around. I’ll let you judge for yourself which is better. At some stage we’ll implement more advanced lighting and shadowing and we won’t need to decide. So here’s the scene rendered earlier with our shadow model. On the left if with specular lighting shadowed, the right not shadowed. I prefer the right.
Image may be NSFW.
Clik here to view.
The banding effect on the shadows occurs because I included a third light to demonstrate that shadows can have multiple levels of darkness, depending on how many lights are blocked.
In the next article we will finally start actually ray tracing, which means reflections! We’ll get to play with recursion and we’ll also start using shapes other than spheres. The source code so far is available here.