Lua Ray Tracer

Chapter 5
Login

Chapter 5 - Surface Normals and Multiple Objects

Surface normals are vectors that point directly away from the surface of an object. It's really just the the point where the ray hits the object minus the center of the object. In more geometric terms, it's an arrow pointing from the center of the sphere outward towards where the ray hit the sphere. That's it.

Visualizing Normals

To visualize that we're computing this correctly, we can, as an intermittent step, simply visualize the normals by converting them to colors. To do this, we need to first modify hit_sphere to calculate the normal, and instead of returning a boolean to indicate whether the sphere was hit, we instead return the point on the sphere where the ray hit it. If the ray does not intersect the sphere, we use a sentinel value of -1 to indicate that.

-- Parameters:
--   center: Vector3
--   radius: number
-- Returns: [Vector3] the point on the sphere where the ray hit it
--
-- Returns true iff this Ray intersects the sphere specified
-- by the provided center and radius, otherwise false.
function Ray:hit_sphere(center, radius)
   oc = self:origin() - center
   a = self:direction():dot(self:direction())
   b = oc:dot(self:direction()) * 2.0
   c = oc:dot(oc) - radius * radius
   discriminant = b * b - a * c * 4
   if discriminant < 0 then
      return -1
   else
      return (-b - math.sqrt(discriminant)) / 2 * a
   end
end

Previously, however, the color method relied on hit_sphere returning a boolean value, and it also assumed the color of the sphere was always red. Let's update color to make use of the point at which the ray hits the sphere and create a color based on it's normal.

function Ray:color()
   t = self:hit_sphere(Vector3:new{0, 0, -1}, 0.5)
   if t > 0 then
      normal = (self:point_at_parameter(t) - Vector3:new{0, 0, -1}):unit_vector()
      return Vector3:new{normal:x()+1, normal:y()+1, normal:z()+1} * 0.5
   end

   unit_direction = self:direction():unit_vector()
   t = 0.5 * (unit_direction:y() + 1)
   return Vector3:new{1, 1, 1} * (1 - t) + Vector3:new{0.5, 0.7, 1} * t
end

This yields sort of rainbow effect since it visualizes the vector space of all the normals as colors:

Creating Objects

The current code bakes the sphere we've been rendering into the render path itself, but we'll want to have the rendering code independent of what objects it is rendering, so we need an abstraction. One useful way to approach this is based on the insight that we're dealing with objects that a Ray could hit. Naming objects in object-oriented languages is challenging, so Ray Tracing in One Weekend opted to called them hitable. Since we're in a dynamic language, I can elide the contract boilerplate in the original C++, but I need to ensure each object we want to render has a hit method attached. The first step is to pull out the logic for our sphere into a dedicated class, which we can then attach a hit method to.

Sphere = {}

-- Parameters:
--   origin: Vector3
--   radius: number
-- Example:
--   Sphere:new{Vector3:new{0, 0, 0}, 0.5}
function Sphere:new(o)
   o = o or {}
   self.__index = self
   setmetatable(o, self)
   return o
end

function Sphere:center()
   return self[1]
end

function Sphere:radius()
   return self[2]
end

As we did with Ray, it's nice to have a good string representation available on the REPL.

function Sphere:tostring()
   return string.format("Sphere{center: %s, radius: %s}", self[1], self[2])
end
Sphere.__tostring = Sphere.tostring

Making Objects Hitable

The hit method itself has an interesting contract that depends upon a hit_record. The Lua version of hit_record looks something like this:

HitRecord = {}

-- Parameters
--   t: [number] time at which impact occurred
--   p  [Vector3] point of impact
--   normal [Vector3] normal from point of impact
function HitRecord:new(o)
   o = o or {}
   self.__index = self
   setmetatable(o, self)
   return o
end

function HitRecord:t() return self[1] end
function HitRecord:p() return self[2] end
function HitRecord:normal() return self[3] end

This implementation covers the three values included in the C++ version, but that version returns a boolean value indicating whether the object was hit or not as well. If a hit does occur, the C++ code destructively modifies the hit_record parameter passed in. This is typical in C and C++, but not idiomatic in Lua. For the Lua version, we can provide a field in hit_record to record whether a hit occurred, and then simply return the hit_record regardless of whether a hit occurred:

function HitRecord:hit() return self[1] end
function HitRecord:t() return self[2] end
function HitRecord:p() return self[3] end
function HitRecord:normal() return self[4] end

This alters the contract to make it less surprising in Lua, but doesn't yet make a Sphere hitable. If we follow the original C++, this requires implementing a hit method for Sphere. This is a moment to pause and consider our architecture, since I'd previously decided to attach the hit method to Ray class. Ultimately, we'll have a table that contains a whole bunch of objects (we'll assume that's called the world) that support the hit method, and as we render the scene, we'll create rays one-by-one and ask them what color they are. Should every Ray contain a reference to the world? Will we want to ask a Ray for its color in the context of more than one world? It doesn't seem likely, since we often think of a ray-traced image as a visual representation of a particular world. The overhead of passing the world pointer to each ray as it is constructed is likely negligible, so we'll assume for now that the hit method will move from the Ray class to the object classes (making them hitable!) and that later on, we'll pass the whole scene/world to each Ray when we construct it. Let's do it!

function Sphere:hit(ray, t_min, t_max)
   oc = ray:origin() - self:center()
   a = ray:direction():dot(ray:direction())
   b = oc:dot(ray:direction())
   c = oc:dot(oc) - self:radius() * self:radius()
   discriminant = b * b - a * c
   if discriminant > 0 then
      time = (-b - math.sqrt(discriminant)) / a
      if time < t_max and time > t_min then
         return self:hit_record(ray, time)
      end
      time = (-b + math.sqrt(discriminant)) / a
      if time < t_max and time > t_min then
         return self:hit_record(ray, time)
      end
   end
   return HitRecord:new{false}
end

You'll notice that this method delegates to a method I created for this implementation called hit_record. I did this to minimize the duplication of code within Sphere. Here it is:

function Sphere:hit_record(ray, time)
   point_of_impact = ray:point_at_parameter(time)
   return HitRecord:new{
      true,
      time,
      point_of_impact,
      (point_of_impact - self:center()) / self:radius()
   }
end

Creating the World

Now that we have the notion of a hitable object, we can make it easy to test whether a Ray hits any of them by creating a World object. In the original code, this is called hitable_list, and it has a hit method just as hitable objects do. We'll apply the same parameter transformation that we did for Sphere and elide the hit record from the parameter list.

World = {}

function World:new(o)
   o = o or {}
   self.__index = self
   setmetatable(o, self)
   return o
end

function World:hit(ray, t_min, t_max)
   closest_hit_record = HitRecord:new{false, t_max}
   for idx, obj in pairs(self) do
      hit_record = obj:hit(ray, t_min, closest_hit_record:t())
      if hit_record:hit() then
         closest_hit_record = hit_record
      end
   end
   return closest_hit_record
end

A couple of notes here. First, we have to iterate through every object no matter what, because we need to return the point that the ray hits that is closest to the camera. We can't be sure we got it right unless we check every object. This also means that the order of iteration through the objects in the world doesn't matter, so I use pairs instead of ipairs. I'm not sure if it is actually slower to use ipairs in luajit, but given that it adds an additional constraint to the iteration, I suspect it may be. Secondly, the use of HitRecord as the sole return value from the this method significantly cleans up the code when compared to the corresponding C++ code with uses destructive modification. Since it's allocating a new table on each call, however, I suspect there's a significant performance penalty in terms of at least GC, if not allocation. We'll revisit this decision if it becomes a problem, but cleaner code takes precedence, all other things being equal.

We now need to make sure that Ray gets a reference to world when it is created. Since the way we're crafting constructors is very liberal, only the documentation needs to be updated. But we still need to define an accessor.

function Ray:world()
   return self[3]
end

But we still haven't update Ray:color to remove the hardcoded reference to a sphere. Let's update that.

function Ray:color()
   hit_record = self:world():hit(self, 0, math.huge)
   if hit_record:hit() then
      normal = hit_record:normal()
      return Vector3:new{normal:x()+1, normal:y()+1, normal:z()+1} * 0.5
   end
   unit_direction = self:direction():unit_vector()
   t = 0.5 * (unit_direction:y() + 1)
   return Vector3:new{1, 1, 1} * (1 - t) + Vector3:new{0.5, 0.7, 1} * t
end

This method is largely symmetric with the version we used in Chapter 4, returning the normal-vector-as-a-color if a hit is detected, and otherwise returning the blue background gradient from Chapter 3.

Finally, we can update our entry point to make use of all our new machinery!

nx = 600
ny = 300
print(string.format("P3\n%s %s\n255\n", nx, ny))
lower_left_corner = Vector3:new{-2, -1, -1}
horizontal = Vector3:new{4, 0, 0}
vertical = Vector3:new{0, 2, 0}
origin = Vector3:new{0, 0, 0}
small_sphere = Sphere:new{Vector3:new{0, 0, -1}, 0.5}
big_sphere = Sphere:new{Vector3:new{0, -100.5, -1}, 100}
world = World:new{small_sphere, big_sphere}
for j = ny-1, 0, -1 do
   for i = 0, nx-1 do
      u = i / nx
      v = j / ny
      ray = Ray:new{origin, lower_left_corner + (horizontal * u) + (vertical * v), world}
      col = ray:color()
      ir = math.floor(256 * col[1])
      ig = math.floor(256 * col[2])
      ib = math.floor(256 * col[3])
      print(string.format("%s %s %s", ir, ig, ib))
   end
end

Aside from the "world creation" code just before the iteration, there's remarkably little different from the earlier iterations. Here's the result:

Next up: Chapter 6 - Antialiasing