Let’s continue working on our ray tracer
and pick up where we left off last week. If we want to render spheres of different materials, Peter Shirley
recommends creating an abstract material class that encapsulates behavior. As a computer scientist myself, I couldn’t agree more!
The material
class will let us produce a scattered ray and calculate how much it was absorbed or attenuated by its reflectance. Let’s create a new file named material.swift or any other name of your choice. Inside, let’s create a protocol
which is well suited for an abstract class in Swift
:
protocol material {
func scatter(ray_in: ray, _ rec: hit_record, inout _ attenuation: float3, inout _ scattered: ray) -> Bool
}
Now that we have the material
blueprint, we can render our diffuse (Lambertian
) spheres by using a new class that conforms to the material
protocol. We give it an attenuation factor, an initializer and implement the scatter
function from the protocol, of course:
class lambertian: material {
var albedo: float3
init(a: float3) {
albedo = a
}
func scatter(ray_in: ray, _ rec: hit_record, inout _ attenuation: float3, inout _ scattered: ray) -> Bool {
let target = rec.p + rec.normal + random_in_unit_sphere()
scattered = ray(origin: rec.p, direction: target - rec.p)
attenuation = albedo
return true
}
}
For metallic
materials, the ray is not scattered randomly as for Lambertian
materials but the ray is rather reflected with the same angle from the normal, except in the other direction. Again, our class has an attenuation factor, an initializer, the scatter
function and also a fuzz
factor which we need so our materials can range from a highly reflective surface to an almost not reflective one:
class metal: material {
var albedo: float3
var fuzz: Float
init(a: float3, f: Float) {
albedo = a
if f < 1 {
fuzz = f
} else {
fuzz = 1
}
}
func scatter(ray_in: ray, _ rec: hit_record, inout _ attenuation: float3, inout _ scattered: ray) -> Bool {
let reflected = reflect(normalize(ray_in.direction), n: rec.normal)
scattered = ray(origin: rec.p, direction: reflected + fuzz * random_in_unit_sphere())
attenuation = albedo
return dot(scattered.direction, rec.normal) > 0
}
}
We need to also have a pointer to the material
color inside the hit_record struct, in the objects.swift
file. We can update the pointer when we compute the color later:
var mat_ptr: material
Next, we need to adapt our color() function inside the ray.swift
file, to take the material
pointer into consideration. Notice that we also added a depth
factor so we can more accurately compute the color by calling the function recursively when the ray hits an object:
func color(r: ray, _ world: hitable, _ depth: Int) -> float3 {
var rec = hit_record()
if world.hit(r, 0.001, Float.infinity, &rec) {
var scattered = r
var attenuantion = float3()
if depth < 50 && rec.mat_ptr.scatter(r, rec, &attenuantion, &scattered) {
return attenuantion * color(scattered, world, depth + 1)
} else {
return float3(x: 0, y: 0, z: 0)
}
} else {
let unit_direction = normalize(r.direction)
let t = 0.5 * (unit_direction.y + 1)
return (1.0 - t) * float3(x: 1, y: 1, z: 1) + t * float3(x: 0.5, y: 0.7, z: 1.0)
}
}
Finally, in the pixel.swift
file we can now create the objects using our new material
classes:
var object = sphere(c: float3(x: 0, y: -100.5, z: -1), r: 100, m: lambertian(a: float3(x: 0, y: 0.7, z: 0.3)))
world.add(object)
object = sphere(c: float3(x: 1, y: 0, z: -1.1), r: 0.5, m: metal(a: float3(x: 0.8, y: 0.6, z: 0.2), f: 0.7))
world.add(object)
object = sphere(c: float3(x: -1, y: 0, z: -1.1), r: 0.5, m: metal(a: float3(x: 0.8, y: 0.8, z: 0.8), f: 0.1))
world.add(object)
object = sphere(c: float3(x: 0, y: 0, z: -1), r: 0.5, m: lambertian(a: float3(x: 0.3, y: 0, z: 0)))
world.add(object)
In the main playground page, see the generated new image:

Stay tuned for the next part of this series, where we will look at different type of materials and tweak the camera for a better viewing angle, so the two side spheres don’t look distorted anymore. The source code is posted on Github as usual.
Until next time!