## Ray Tracing in Lua

This is a work-in-progress. I'll keep posting as I work through the book!!

Last update: <2020-03-01 Sun>

I've never done much graphics programming, but I've always wanted to explore it. I think the most common approach is to go straight to OpenGL, but I tend to approach a domain from first principles, especially when I'm coding for fun, so I turned to Amazon and found Ray Tracing in One Weekend for \$3. It tackles everything in C++, and I thought it would be a fun exercise to follow the algorithms laid out in the book, and I could focus on learning how ray tracing works while porting all the code to Lua.

Lua might seem like exactly the wrong choice for ray tracing, which requires high performance and lends itself to a language that can parallelize the compuation, but I enjoy working in small languages that use few data structures to accomplish many things, so I'm curious to see how far I can get in Lua. I'm going to stick with luajit, however, as it is quite a bit faster than lua5.3. As such, the code will be lua5.1 compatible, since that's the version of the language luajit targets.

### Chapter 1 - Graphical Hello World

The first step is to produce a PPM file that demonstrates we can create images at all. All it does is alter the red and green channel during iteration over the pixels to create a color gradient. Our image size is defined at the top. In the C++ code, it is locked to a 200px x 100px image, but I found that that scaling it by 3x to 600x300 is more visually engaging, so I switched in the Lua code to reflect that. PPM files are easy to create, but very inefficient. In the spirit of "make it work, make it right, make it fast", I'll worry about optimizing the file format later, and probably only if needed.

#! /usr/bin/env luajit

nx = 600
ny = 300
print(string.format("P3\n%s %s\n255\n", nx, ny))
for j = ny-1, 0, -1 do
for i = 0, nx-1 do
r = i/nx
g = j/ny
b = 0.2
ir = math.floor(256*r)
ig = math.floor(256*g)
ib = math.floor(256*b)
print(string.format("%s %s %s", ir, ig, ib))
end
end


I keep a running Emacs shell buffer alongside my code, and I can run this command to display the image after I change the code:

./ray.lua | convert - chapter1.png && emacsclient -n chapter1.png


The result looks like this: ### Chapter 2 - The Vector3 Class

This chapter defines the Vector3 class, which I understand will be used for all sorts of vectors in later chapters. I thought a bit about whether to change to a more functional approach since I'm in Lua, and decided that I'd learn a bit more and have a bit of fun by using Lua's metatables to retain the object-oriented approach in the original C++. So I did a bit of research about how objects are approached in Lua put together Vector3, a Lua interpretation of the C++ version's vec3.

Vectors are simply Lua tables, and since they are generic, they have accessors supporting both cartesian and RGB interpretations. This is unconventional, but follows the pattern in the book.

Vector3 = {}

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

-- Accessors for cartesian coords
function Vector3:x() return self end
function Vector3:y() return self end
function Vector3:z() return self end

-- Accessors for RGB values
function Vector3:r() return self end
function Vector3:g() return self end
function Vector3:b() return self end


Since I'm trying out all my code using the Lua REPL, I also want a nice string representation for vectors. It's easy to attach the tostring method to the metamethod __tostring, which is used by the REPL when printing out results.

-- REPL usability
function Vector3:tostring()
return string.format("Vector3{%s, %s, %s}", self, self, self)
end
Vector3.__tostring = Vector3.tostring


Next, I implement a straight port of the C++ mathematical functions for vectors, attaching them to Lua's metamethods where available.

-- Mathematical operations
return Vector3:new{self + vec, self + vec, self + vec}
end

function Vector3:sub(vec)
return Vector3:new{self - vec, self - vec, self - vec}
end
Vector3.__sub = Vector3.sub

function Vector3:mul(val)
local t = type(val)
if t == "number" then return self:nmul(val)
elseif t == "table" then return self:vmul(val)
end
end
Vector3.__mul = Vector3.mul

function Vector3:div(val)
local t = type(val)
if t == "number" then return self:ndiv(val)
elseif t == "table" then return self:vdiv(val)
end
end
Vector3.__div = Vector3.div

function Vector3:negate()
return Vector3:new{-self, -self, -self}
end
Vector3.__unm = Vector3.negate

function Vector3:length()
return math.sqrt(self:squared_length())
end

function Vector3:squared_length()
return self*self +
self*self +
self*self
end

function Vector3:dot(vec)
return self * vec + self * vec + self * vec
end

function Vector3:cross(vec)
return Vector3:new{
self * vec - self * vec,
self * vec - self * vec,
self * vec - self * vec
}
end

function Vector3:unit_vector()
local l = self:length()
return Vector3:new{self/l, self/l, self/l}
end

-- Destructive functions

function Vector3:make_unit_vector()
k = 1 / self:length()
self = self*k
self = self*k
self = self*k
end


Many of the functions above are defined in the C++ code as inline and const. I'm not well-versed in C++, but after reading up on these keywords, I think they are both compiler hints that improve performance: inline inlines the function so we avoid dispatch overhead, and const allows the compiler to assume the value won't change, allowing further optimizations. Neither is present in Lua, so I've ignored them above. The C++ implementation also leverages static dispatch for performance, but Lua isn't statically typed, so my implementation will be dynamically dispatched. In the case of mul and div, which can accept either a number (scalar context) or a table (vector context), I use reflection via the type method to determine context and then dispatch to the appropriate implementation. The actual implementations are below.

-- Internal implementations

function Vector3:nmul(num)
return Vector3:new{self*num, self*num, self*num}
end

function Vector3:vmul(vec)
return Vector3:new{self*vec, self*vec, self*vec}
end

function Vector3:ndiv(num)
return Vector3:new{self/num, self/num, self/num}
end

function Vector3:vdiv(vec)
return Vector3:new{self/vec, self/vec, self/vec}
end


This completes our Lua implementation of Vector3. A notable omission from the C++ implementation are the +=, -=, *=, and /= operators, which have no associated metamethods in Lua due to its single-pass parse model. As a result, I'm leaving them out for now, but I might add them later if the ray tracing code becomes to unweildy without them.

The code at the end of the chapter uses the new vec3 abstraction. We can use our Vector3 class instead.

nx = 600
ny = 300
print(string.format("P3\n%s %s\n255\n", nx, ny))
for j = ny-1, 0, -1 do
for i = 0, nx-1 do
v = Vector3:new{i/nx, j/ny, 0.2}
ir = math.floor(256*v)
ig = math.floor(256*v)
ib = math.floor(256*v)
print(string.format("%s %s %s", ir, ig, ib))-
end
end


With this new libary to handle vectors, we can take a look at modeling how light moves!

### 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
end

function Ray:direction()
return self
end

function Ray:point_at_parameter(t)
return self + self * 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, self)
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)
ig = math.floor(256 * col)
ib = math.floor(256 * col)
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. ### Chapter 4 - Adding a Sphere

I'd previously made the decision to move the color function into the Ray class simply because a ray's color can be expected to be constant for a given scene, and I envision all this code running in the context of a constant scene. Chapter 4 modifies Ray's color function to call an additional check called hit_sphere, so I've baked that function into the Ray class as well. This clearly isn't sustainable, since binding scene logic to the Ray class is madness. But for now, it's great, and I'll change it when need be.

The modification to color is straightforward: if the ray would hit a sphere centered at $$\{0, 0, -1\}$$ with a radius of 0.5, return red. Otherwise, compute the value for the blue/white gradient we defined in Chapter 3.

function Ray:color()
if (self:hit_sphere(Vector3:new{0, 0, -1}, 0.5)) then
return Vector3:new{1, 0, 0}
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


Next, we need to define hit_sphere, which is responsible for whether or not the Ray intersects the sphere.

The original text has a great explanation on how to approach this for implementing a numerical method in a ray tracer. If you consider a sphere centered at $$(cx, cy, cz)$$ with a radius $$R$$, the text leverages the insight that points on that sphere must satisfy $$cx^2 + cy^2 + cz^2 = R^2$$. This is a scalar equation, however, and we want to leverage our vector-based abstractions. Following this line of reasoning, the text observes that, letting C represent the center of the sphere, point $$p$$ is on the sphere if $$(p-c) \cdot (p-c) = R^2$$. This is a useful insight, but we need to somehow map its notion of $$p$$ onto our notion of a Ray, which takes a parameter, $$t$$. If we simply substitute $$p(t)$$ in for $$p$$, and then utilize $$p(t) = A + tB$$, we obtain $$(A + tB - C) \cdot (A + tB - C)) = R^2$$.

-- Parameters:
--   center: Vector3
-- Returns: boolean
--
-- Returns true iff this Ray intersects the sphere specified
-- by the provided center and radius, otherwise false.
offset = self:origin() - center
a = self:direction():dot(self:direction())
b = offset:dot(self:direction()) * 2.0
discriminant = b * b - a * c * 4
return discriminant > 0
end


Running this, we obtain the expected result: a red ball in the middle of the screen! ### 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
-- 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.
oc = self:origin() - center
a = self:direction():dot(self:direction())
b = oc:dot(self:direction()) * 2.0
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
-- 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
end

return self
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, self)
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 end
function HitRecord:p() return self end
function HitRecord:normal() return self 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 end
function HitRecord:t() return self end
function HitRecord:p() return self end
function HitRecord:normal() return self 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())
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,
}
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
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)
ig = math.floor(256 * col)
ib = math.floor(256 * col)
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: ### 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
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)
ig = math.floor(256 * col)
ib = math.floor(256 * col)
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: 