Chapter 6 - Antialiasing
If you look at the images above, particularly if you zoom in, you'll notice "jaggies": the image is jagged at the edges of the spheres. That's because in our current implementation, each pixel either hits the object (and takes on that object's color at that point), or it simply takes on the color of the background at that location: there is no in-between. Antialiasing is a technique that randomly samples multiple points within a pixel, and then blends the colors at each point within that pixel to compute the final color the pixel should be. Antialiasing is measured in terms of the number of samples taken per-pixel, all the way from 2x antialiasing to 16x antialiasing. I gather that there's actually a lot more to antialiasing than this, but this is my understanding so far from reading this book and my previous knowledge from tweaking video game settings to trade off between performance and quality.
Introducing the Camera
If we're going to shoot multiple rays per pixel, where should the logic
live? The Ray class seems inappropriate, as does World and
Vector3. In the real world, analog cameras (using film!) do this
naturally. Perhaps a solution is to introduce a class that represents
the camera. Rather than the main method, the camera can encapsulate the
screen dimensions and ray generation instead.
Camera = {}
function Camera:new(o)
   base = {
      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}
   }
   o = o or {}
   for k,v in pairs(base) do
      o[k] = v
   end
   self.__index = self
   setmetatable(o, self)
   return o
end
-- Accessors to maintain symmetric access via function calls
function Camera:lower_left_corner() return self.lower_left_corner end
function Camera:horizontal() return self.horizontal end
function Camera:vertical() return self.vertical end
function Camera:origin() return self.origin end
In addition to capturing the basic screen dimensions we're working
with, we also want the camera to generate rays for us. To do this,
though, it needs a reference to the world. So, we define an accessor,
and then proceed with get_ray:
function Camera:world()
   return self[1]
end
function Camera:get_ray(u, v)
   return Ray:new{
      self.origin,
      self.lower_left_corner + (self.horizontal * u) + (self.vertical * v) - self.origin,
      self:world()
   }
end
This is essentially the exact same logic that was previously in the main
method, but now encapsulated. But that means we need to update main to
request that the camera generate multiple rays per pixel.
function main()
   nx = 600
   ny = 300
   ns = 100
  print(string.format("P3\n%s %s\n255\n", nx, ny))
   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}
   camera = Camera:new{world}
   for j = ny-1, 0, -1 do
      for i = 0, nx-1 do
         col = Vector3:new{0, 0, 0}
         for s = 0, ns do
            u = (i + math.random()) / nx
            v = (j + math.random()) / ny
            ray = camera:get_ray(u, v)
            col = col + ray:color()
         end
         col = col / (ns + 1)
         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
end
The notable addition here is that we now sample each pixel 100 times and average the color that is returned. Running this takes about 24 seconds on my laptop, and produces this result:
Next up: Chapter 7 - Diffuse Materials