Chapter 7 - Diffuse Materials
So far, the code has either been basing the color of our objects on a constant (red), or converting the normal vector to a color and using that. The text now turns its attention to materials, which are important in determining what "real" colors for the objects should be. The first material type it examines is diffuse, meaning that when a ray hits it, it may be absorbed, or it may bounce off in a random direction. The chance of the ray being absorbed is a function of the object's color, with darker colors absorbing more rays than lighter colors.
To model this, I followed the text, starting with p, which is the point where they ray hits the object. The general strategy is to then construct a unit sphere that is tangent (sitting on) the point of impact, and then choose a random point inside that sphere to define the direction in which the ray bounces¹. Conceptually, this is straightforward, but now we need to implement it numerically.
The text adopts a rejection-based method: generate a random point within the unit cube², calculate the distance from the the center and if it greater than 1 (the size of the unit sphere, by definition), it is rejected and we try again. Since the point is generated within the volume of the unit cube, we can calculate the expected number of generations necessary before generating a valid point. The volume of our unit cube is 2³ = 8, while the volume of the unit sphere is 4/3πr³ = 4/3π (since r = 1) ≅ 4.188. This means that the unit sphere occupies 4.188/8 ≅ 52% of the volume the unit cube does, so we'll typically see 1 or 2 passes through this algorithm to generate the bounce direction. To start, let's see what this approach looks like by creating a function, random_in_unit_sphere, to implement it.
function random_in_unit_sphere()
vec = Vector3:new{1, 1, 1}
while vec:length() > 1 do
vec = Vector3:new{
(2 * math.random()) - 1,
(2 * math.random()) - 1,
(2 * math.random()) - 1
}
end
return vec
end
This code leverages the existing Vector methods to compute length. To generate a new candidate point, the code starts by generating a random value between 0 and 1, scales it to the range 0 to 2, and then subtracts 1 to shift the final range to be between -1 and 1. It does this for each dimension and tests the distance for the result until we find one within the unit sphere, at which point we return it. Since we're changing how color is computed, we'll now need to modify the color method on the Ray class to implement this new logic.
function Ray:color()
hit_record = self:world():hit(self, 0.0, math.huge)
if hit_record:hit() then
target = hit_record:p() + hit_record:normal() + random_in_unit_sphere()
return Ray:new{hit_record:p(), target - hit_record:p(), world}:color() * 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
At the bottom of the method, we see the old logic that provides the blue gradient background. But the case at the top that returns a color on hit now uses the random_in_unit_sphere function to generate some point in the unit sphere centered on the origin. That point is in the wrong place, though. It needs to be based on a sphere tangent to the hit point. So we add the hit point p to that point, and then add the unit normal of p as well to obtain the center of that sphere. This displaces our random point to the correct region, so the code can then create a new Ray with an origin of p and a direction that is obtained by subtracting the hit point from the target point³.
Notably, the code at this point is recursive: it calls the color method on the resulting ray. This means the ray will continue bouncing until it no longer hits any objects. Interestingly, at each point in the recursion, we multiply by a 'magic' value of 0.5. This is simply hardcoding a 50% reflectivity rate. Considering a ray as a stream of photons, it simply means that on each bounce, only half the photons will be absorbed, with the other half bouncing. The result is that with each bounce, the color becomes darker. Intuitively, this means that if a ray bouncing into an area surrounding by objects, the resulting color will be darker. This creates a shadow beneath objects:
Handling Gamma
The text notes that this image is quite dark, and mentions that this is because image software expects images to be stored with a gamma setting greater than 1. It implements a gamma of 2 by taking the square root of the color returned by the ray deep inside the nested for loop to render the image:
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)
col = Vector3:new{math.sqrt(col[1]), math.sqrt(col[2]), math.sqrt(col[3])}
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
This approach puts a lot of the logic around colors into main, which is not particularly a responsibility of main I wouldn't think. The result is that main is now implementing both antialiasing logic, as well as gamma logic. I'm hoping as I continue with the text this logic gets moved.
The result is a much lighter image than we originally created.
Fixing 'Shadow Acne'
The final bit of chapter 7 mentions that there's a bit of graininess to the image because of the way we calculate sphere hits. Essentially, floating point representation results in cases where values that are very near zero are returned in cases where the code would expect exactly zero. As with many floating point codebases, this is solved by adding an epsilon of 0.001 to the hit detection logic that discards all values that are very close to zero. The only change is to alter the 0.0 in the code above to be 0.001:
function Ray:color()
hit_record = self:world():hit(self, 0.001, math.huge)
if hit_record:hit() then
target = hit_record:p() + hit_record:normal() + random_in_unit_sphere()
return Ray:new{hit_record:p(), target - hit_record:p(), world}:color() * 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 does result in a smoother image:
Notes
¹ Distribution of Angles: The original text doesn't say much about this approach, but I found it interesting. Consider the distribution of reflection angles produced by this technique: the likelihood of reflecting at a particular angle is a function of the diameter of the unit sphere at that angle. So there's only one point on the sphere that will produce a perfect normal (90°), and there are many points around the circumference that will result in a reflection of 45°. Correspondingly, there is a diminishing chance of a angle of reflection that goes towards 0°. This matches the intuition that a diffuse surface reflects in a random direction, but tends to produce reflections of around 45°.
² Unit cube vs. Unit sphere: The original text plays a bit fast-and-loose with terminology here. A unit sphere is a sphere defined by the set of points within a distance of 1 from the center of the sphere, giving the sphere a diameter of 2. But the unit cube is defined to be a cube with a side length of 1, which would easily fit inside a unit sphere. For purposes of this ray tracing algorithm, we're therefore treating the unit cube as a 2×2×2 cube to make its definition symmetric with that of the unit sphere.
² Performance: When I was writing this up, it bothered me that one one line we add p to target, and in the next line (the only use of target!) we immediately subtract p. I ran a couple of tests with and without this duplication and no wall-clock difference was apparent: both versions took about 51 seconds on my laptop running luajit. I'd like to focus on performance at some point for fun, but at this point I'm still focusing entirely an replicating the text in Lua, only focusing on improving speed when the code gets to slow to work with.