Chapter 3 - Rays, Camera, Background
We start by defining a Ray class:
-- Ray Class
Ray = {}
-- Parameters:
--   origin: Vector3
--   direction: Vector3
-- Example:
--   Ray:new{Vector3:new{0, 0, 0}, Vector3:new{0, 0, -1}}
function Ray:new(o)
   o = o or {}
   self.__index = self
   setmetatable(o, self)
   return o
end
function Ray:origin()
   return self[1]
end
function Ray:direction()
   return self[2]
end
function Ray:point_at_parameter(t)
   return self[1] + self[2] * t
end
While we're here, we can add our usual handy tostring method and bind
it.
function Ray:tostring()
   return string.format("Ray{origin: %s, direction: %s}", self[1], self[2])
end
Ray.__tostring = Ray.tostring
The Color Method
Now we can add the color method:
function Ray:color()
   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
The color method is worth an explanation. It first generates the unit
vector for the direction, clamping the length to 1. It then computes
t, which takes the unit vector's vertical component (which can vary
from -1 to 1, since that's the y-value of the bottom and top of our
virtual screen, as you'll see below), adds one to get a value between 0
and 2, and then compresses the range to 0 to 1 by multiplying by 0.5.
This is mathematically attractive because t can now be used as a
scaling factor for our color vectors. The final part of color is to
calculate the color of the pixel.
The general strategy is to blend pure white (an RGB vector of 1, 1, 1)
and light blue (an RGB vector of 0.5, 0.7, 1) proportionally to the
value of t, which again is really an expression of the vertical offset
of the pixel. This means the pixels at the top of the image will be pure
blue and the pixels at the bottom will be pure white, with pixels in
between blending the two.
You'll notice that it's not a pure vertical gradient, though. That's because the vertical offset when clamping the ray's direction to a unit vector is diminished for pixels with a high horizontal offset, so pixels near the corners avoid extreme values of pure white and pure blue, instead remaining a blend. This produces a very natural effect that looks like the sky.
Adding Color to Each Pixel
I then put together the main function, and simply inlined it at the end of the file.
nx = 200
ny = 100
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}
for j = ny-1, 0, -1 do
   for i = 0, nx-1 do
      u = i / nx
      v = j / ny
      r = Ray:new{origin, lower_left_corner + (horizontal * u) + (vertical * v)}
      col = r: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
This new approach introduces the notion of an origin, which is where
the camera is placed. It also introduces the notion of a screen, and
places it one unit away, with a Z value of -1. The screen is defined
in terms of its lower_left_corner and its horizontal and vertical
dimensions. The screen is twice as wide as it is high, with a width of
4 units and a height of 2 units.
In every graphics application, there needs to be some way to map the
scene-space the world is calculated in to the pixels in the image being
generated. Above, this is handled at the moment we shoot a ray out from
the camera. Each Ray requires two vectors: an origin and a
direction. The origin of that ray is the camera, but the direction
requires some computation.
We're trying to determine the color of a pixel, so our computation in
based on the coordinates of that pixel in image-space, but the final
value needs to be in scene-space to match the origin vector. We
compute the direction by starting from lower left corner of our image
and calculating the offset of the current pixel in the x and y
direction. Since we're iterating over each pixel in our image (defined
by nx and ny), this offset is calulated in terms of a the ratio of
we are along the x and y axis to the total image size in that dimension.
We then convert to "scene-space" by multiplying by the coodinate
dimensions of the image ((horizontal and vertical) to get the final
direction vector. Our ray is now fully defined, and we can ask it what
its color is.
In the original C++, most vector multiplication by a scalar, like in the
color function for a Ray, are performed by multiplying the number by
the vector, rather than the vector by the number. Mathematically it
doesn't make a difference, but the order matters during dispatch, and
numbers don't have a __mul operator that works with Vector3. To
remedy this, I changed the order of the parameters in the Lua
implementation. After converting to png, the output of the program
matches the C++ version.
Next up: Chapter 4 - Adding a Sphere