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