Lua Ray Tracer

Chapter 6
Login

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